From e09e48567ed076deba54da966f1d02036fe7d23b Mon Sep 17 00:00:00 2001 From: catbusconductor Date: Sat, 4 Apr 2026 10:20:27 -0700 Subject: [PATCH 001/213] fix(openviking): correct API endpoint paths and response parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Browse: POST /api/v1/browse → GET /api/v1/fs/{ls,tree,stat} - Read: POST /api/v1/read[/abstract] → GET /api/v1/content/{read,abstract,overview} - System prompt: result.get('children') → len(result) (API returns list) - Content: result.get('content') → result is a plain string - Browse: result['entries'] → result is the list; is_dir → isDir (camelCase) - Browse: add rel_path and abstract fields to entry output Based on PR #4742 by catbusconductor. Auth header changes dropped (already on main via #4825). --- plugins/memory/openviking/__init__.py | 38 ++++++++++++++------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 9f129f907b21..410979a0ec59 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -283,9 +283,9 @@ def system_prompt_block(self) -> str: # Provide brief info about the knowledge base try: # Check what's in the knowledge base via a root listing - resp = self._client.post("/api/v1/browse", {"action": "stat", "path": "viking://"}) - result = resp.get("result", {}) - children = result.get("children", 0) + resp = self._client.get("/api/v1/fs/ls", params={"uri": "viking://"}) + result = resp.get("result", []) + children = len(result) if isinstance(result, list) else 0 if children == 0: return "" return ( @@ -495,16 +495,17 @@ def _tool_read(self, args: dict) -> str: return json.dumps({"error": "uri is required"}) level = args.get("level", "overview") - # Map our level names to OpenViking endpoints + # Map our level names to OpenViking GET endpoints if level == "abstract": - resp = self._client.post("/api/v1/read/abstract", {"uri": uri}) + resp = self._client.get("/api/v1/content/abstract", params={"uri": uri}) elif level == "full": - resp = self._client.post("/api/v1/read", {"uri": uri, "level": "read"}) + resp = self._client.get("/api/v1/content/read", params={"uri": uri}) else: # overview - resp = self._client.post("/api/v1/read", {"uri": uri, "level": "overview"}) + resp = self._client.get("/api/v1/content/overview", params={"uri": uri}) - result = resp.get("result", {}) - content = result.get("content", "") + result = resp.get("result", "") + # result is a plain string from the content endpoints + content = result if isinstance(result, str) else result.get("content", "") # Truncate very long content to avoid flooding the context if len(content) > 8000: @@ -520,20 +521,21 @@ def _tool_browse(self, args: dict) -> str: action = args.get("action", "list") path = args.get("path", "viking://") - resp = self._client.post("/api/v1/browse", { - "action": action, - "path": path, - }) + # Map action to the correct fs endpoint (all GET with uri= param) + endpoint_map = {"tree": "/api/v1/fs/tree", "list": "/api/v1/fs/ls", "stat": "/api/v1/fs/stat"} + endpoint = endpoint_map.get(action, "/api/v1/fs/ls") + resp = self._client.get(endpoint, params={"uri": path}) result = resp.get("result", {}) - # Format for readability - if action == "list" and "entries" in result: + # Format list/tree results for readability + if action in ("list", "tree") and isinstance(result, list): entries = [] - for e in result["entries"][:50]: # cap at 50 entries + for e in result[:50]: # cap at 50 entries entries.append({ - "name": e.get("name", ""), + "name": e.get("rel_path", e.get("name", "")), "uri": e.get("uri", ""), - "type": "dir" if e.get("is_dir") else "file", + "type": "dir" if e.get("isDir") else "file", + "abstract": e.get("abstract", ""), }) return json.dumps({"path": path, "entries": entries}, ensure_ascii=False) From 5d0f55cac400fa6a785b8871a9e60c7ea9f276f9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:43:39 -0700 Subject: [PATCH 002/213] feat(cron): add script field for pre-run data collection (#5082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional 'script' parameter to cron jobs that references a Python script. The script runs before each agent turn, and its stdout is injected into the prompt as context. This enables stateful monitoring — the script handles data collection and change detection, the LLM analyzes and reports. - cron/jobs.py: add script field to create_job(), stored in job dict - cron/scheduler.py: add _run_job_script() executor with timeout handling, inject script output/errors into _build_job_prompt() - tools/cronjob_tools.py: add script to tool schema, create/update handlers, _format_job display - hermes_cli/cron.py: add --script to create/edit, display in list/edit output - hermes_cli/main.py: add --script argparse for cron create/edit subcommands - tests/cron/test_cron_script.py: 20 tests covering job CRUD, script execution, path resolution, error handling, prompt injection, tool API Script paths can be absolute or relative (resolved against ~/.hermes/scripts/). Scripts run with a 120s timeout. Failures are injected as error context so the LLM can report the problem. Empty string clears an attached script. --- cron/jobs.py | 7 + cron/scheduler.py | 79 +++++++++ hermes_cli/cron.py | 10 ++ hermes_cli/main.py | 2 + tests/cron/test_cron_script.py | 300 +++++++++++++++++++++++++++++++++ tools/cronjob_tools.py | 20 ++- 6 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 tests/cron/test_cron_script.py diff --git a/cron/jobs.py b/cron/jobs.py index 22c04d0c63e4..214da521fe5a 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -375,6 +375,7 @@ def create_job( model: Optional[str] = None, provider: Optional[str] = None, base_url: Optional[str] = None, + script: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -391,6 +392,9 @@ def create_job( model: Optional per-job model override provider: Optional per-job provider override base_url: Optional per-job base URL override + script: Optional path to a Python script whose stdout is injected into the + prompt each run. The script runs before the agent turn, and its output + is prepended as context. Useful for data collection / change detection. Returns: The created job dict @@ -419,6 +423,8 @@ def create_job( normalized_model = normalized_model or None normalized_provider = normalized_provider or None normalized_base_url = normalized_base_url or None + normalized_script = str(script).strip() if isinstance(script, str) else None + normalized_script = normalized_script or None label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" job = { @@ -430,6 +436,7 @@ def create_job( "model": normalized_model, "provider": normalized_provider, "base_url": normalized_base_url, + "script": normalized_script, "schedule": parsed_schedule, "schedule_display": parsed_schedule.get("display", schedule), "repeat": { diff --git a/cron/scheduler.py b/cron/scheduler.py index 8a54520a14fb..b014799837e3 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -13,6 +13,7 @@ import json import logging import os +import subprocess import sys import traceback @@ -229,11 +230,89 @@ def _deliver_result(job: dict, content: str) -> None: logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id) +_SCRIPT_TIMEOUT = 120 # seconds + + +def _run_job_script(script_path: str) -> tuple[bool, str]: + """Execute a cron job's data-collection script and capture its output. + + Args: + script_path: Path to a Python script (resolved via HERMES_HOME/scripts/ or absolute). + + Returns: + (success, output) — on failure *output* contains the error message so the + LLM can report the problem to the user. + """ + from hermes_constants import get_hermes_home + + path = Path(script_path).expanduser() + if not path.is_absolute(): + # Resolve relative paths against HERMES_HOME/scripts/ + path = get_hermes_home() / "scripts" / path + + if not path.exists(): + return False, f"Script not found: {path}" + if not path.is_file(): + return False, f"Script path is not a file: {path}" + + try: + result = subprocess.run( + [sys.executable, str(path)], + capture_output=True, + text=True, + timeout=_SCRIPT_TIMEOUT, + cwd=str(path.parent), + ) + stdout = (result.stdout or "").strip() + stderr = (result.stderr or "").strip() + + if result.returncode != 0: + parts = [f"Script exited with code {result.returncode}"] + if stderr: + parts.append(f"stderr:\n{stderr}") + if stdout: + parts.append(f"stdout:\n{stdout}") + return False, "\n".join(parts) + + return True, stdout + + except subprocess.TimeoutExpired: + return False, f"Script timed out after {_SCRIPT_TIMEOUT}s: {path}" + except Exception as exc: + return False, f"Script execution failed: {exc}" + + def _build_job_prompt(job: dict) -> str: """Build the effective prompt for a cron job, optionally loading one or more skills first.""" prompt = job.get("prompt", "") skills = job.get("skills") + # Run data-collection script if configured, inject output as context. + script_path = job.get("script") + if script_path: + success, script_output = _run_job_script(script_path) + if success: + if script_output: + prompt = ( + "## Script Output\n" + "The following data was collected by a pre-run script. " + "Use it as context for your analysis.\n\n" + f"```\n{script_output}\n```\n\n" + f"{prompt}" + ) + else: + prompt = ( + "[Script ran successfully but produced no output.]\n\n" + f"{prompt}" + ) + else: + prompt = ( + "## Script Error\n" + "The data-collection script failed. Report this to the user.\n\n" + f"```\n{script_output}\n```\n\n" + f"{prompt}" + ) + # Always prepend [SILENT] guidance so the cron agent can suppress # delivery when it has nothing new or noteworthy to report. silent_hint = ( diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index f6da8a2d2c9f..d10513a2802f 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -90,6 +90,9 @@ def cron_list(show_all: bool = False): print(f" Deliver: {deliver_str}") if skills: print(f" Skills: {', '.join(skills)}") + script = job.get("script") + if script: + print(f" Script: {script}") print() from hermes_cli.gateway import find_gateway_pids @@ -149,6 +152,7 @@ def cron_create(args): repeat=getattr(args, "repeat", None), skill=getattr(args, "skill", None), skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), + script=getattr(args, "script", None), ) if not result.get("success"): print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -158,6 +162,9 @@ def cron_create(args): print(f" Schedule: {result['schedule']}") if result.get("skills"): print(f" Skills: {', '.join(result['skills'])}") + job_data = result.get("job", {}) + if job_data.get("script"): + print(f" Script: {job_data['script']}") print(f" Next run: {result['next_run_at']}") return 0 @@ -195,6 +202,7 @@ def cron_edit(args): deliver=getattr(args, "deliver", None), repeat=getattr(args, "repeat", None), skills=final_skills, + script=getattr(args, "script", None), ) if not result.get("success"): print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) @@ -208,6 +216,8 @@ def cron_edit(args): print(f" Skills: {', '.join(updated['skills'])}") else: print(" Skills: none") + if updated.get("script"): + print(f" Script: {updated['script']}") return 0 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0f1f4aa513a1..5150bfa1a7e7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4381,6 +4381,7 @@ def main(): cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id") cron_create.add_argument("--repeat", type=int, help="Optional repeat count") cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.") + cron_create.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run") # cron edit cron_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job") @@ -4394,6 +4395,7 @@ def main(): cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.") cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.") cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job") + cron_edit.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.") # lifecycle actions cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py new file mode 100644 index 000000000000..e833963547cc --- /dev/null +++ b/tests/cron/test_cron_script.py @@ -0,0 +1,300 @@ +"""Tests for cron job script injection feature. + +Tests cover: +- Script field in job creation / storage / update +- Script execution and output injection into prompts +- Error handling (missing script, timeout, non-zero exit) +- Path resolution (absolute, relative to HERMES_HOME/scripts/) +""" + +import json +import os +import stat +import sys +import textwrap +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Ensure project root is importable +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + + +@pytest.fixture +def cron_env(tmp_path, monkeypatch): + """Isolated cron environment with temp HERMES_HOME.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "cron").mkdir() + (hermes_home / "cron" / "output").mkdir() + (hermes_home / "scripts").mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Clear cached module-level paths + import cron.jobs as jobs_mod + monkeypatch.setattr(jobs_mod, "HERMES_DIR", hermes_home) + monkeypatch.setattr(jobs_mod, "CRON_DIR", hermes_home / "cron") + monkeypatch.setattr(jobs_mod, "JOBS_FILE", hermes_home / "cron" / "jobs.json") + monkeypatch.setattr(jobs_mod, "OUTPUT_DIR", hermes_home / "cron" / "output") + + return hermes_home + + +class TestJobScriptField: + """Test that the script field is stored and retrieved correctly.""" + + def test_create_job_with_script(self, cron_env): + from cron.jobs import create_job, get_job + + job = create_job( + prompt="Analyze the data", + schedule="every 30m", + script="/path/to/monitor.py", + ) + assert job["script"] == "/path/to/monitor.py" + + loaded = get_job(job["id"]) + assert loaded["script"] == "/path/to/monitor.py" + + def test_create_job_without_script(self, cron_env): + from cron.jobs import create_job + + job = create_job(prompt="Hello", schedule="every 1h") + assert job.get("script") is None + + def test_create_job_empty_script_normalized_to_none(self, cron_env): + from cron.jobs import create_job + + job = create_job(prompt="Hello", schedule="every 1h", script=" ") + assert job.get("script") is None + + def test_update_job_add_script(self, cron_env): + from cron.jobs import create_job, update_job + + job = create_job(prompt="Hello", schedule="every 1h") + assert job.get("script") is None + + updated = update_job(job["id"], {"script": "/new/script.py"}) + assert updated["script"] == "/new/script.py" + + def test_update_job_clear_script(self, cron_env): + from cron.jobs import create_job, update_job + + job = create_job(prompt="Hello", schedule="every 1h", script="/some/script.py") + assert job["script"] == "/some/script.py" + + updated = update_job(job["id"], {"script": None}) + assert updated.get("script") is None + + +class TestRunJobScript: + """Test the _run_job_script() function.""" + + def test_successful_script(self, cron_env): + from cron.scheduler import _run_job_script + + script = cron_env / "scripts" / "test.py" + script.write_text('print("hello from script")\n') + + success, output = _run_job_script(str(script)) + assert success is True + assert output == "hello from script" + + def test_script_relative_path(self, cron_env): + from cron.scheduler import _run_job_script + + script = cron_env / "scripts" / "relative.py" + script.write_text('print("relative works")\n') + + success, output = _run_job_script("relative.py") + assert success is True + assert output == "relative works" + + def test_script_not_found(self, cron_env): + from cron.scheduler import _run_job_script + + success, output = _run_job_script("/nonexistent/script.py") + assert success is False + assert "not found" in output.lower() + + def test_script_nonzero_exit(self, cron_env): + from cron.scheduler import _run_job_script + + script = cron_env / "scripts" / "fail.py" + script.write_text(textwrap.dedent("""\ + import sys + print("partial output") + print("error info", file=sys.stderr) + sys.exit(1) + """)) + + success, output = _run_job_script(str(script)) + assert success is False + assert "exited with code 1" in output + assert "error info" in output + + def test_script_empty_output(self, cron_env): + from cron.scheduler import _run_job_script + + script = cron_env / "scripts" / "empty.py" + script.write_text("# no output\n") + + success, output = _run_job_script(str(script)) + assert success is True + assert output == "" + + def test_script_timeout(self, cron_env, monkeypatch): + from cron import scheduler as sched_mod + from cron.scheduler import _run_job_script + + # Use a very short timeout + monkeypatch.setattr(sched_mod, "_SCRIPT_TIMEOUT", 1) + + script = cron_env / "scripts" / "slow.py" + script.write_text("import time; time.sleep(30)\n") + + success, output = _run_job_script(str(script)) + assert success is False + assert "timed out" in output.lower() + + def test_script_json_output(self, cron_env): + """Scripts can output structured JSON for the LLM to parse.""" + from cron.scheduler import _run_job_script + + script = cron_env / "scripts" / "json_out.py" + script.write_text(textwrap.dedent("""\ + import json + data = {"new_prs": [{"number": 42, "title": "Fix bug"}]} + print(json.dumps(data, indent=2)) + """)) + + success, output = _run_job_script(str(script)) + assert success is True + parsed = json.loads(output) + assert parsed["new_prs"][0]["number"] == 42 + + +class TestBuildJobPromptWithScript: + """Test that script output is injected into the prompt.""" + + def test_script_output_injected(self, cron_env): + from cron.scheduler import _build_job_prompt + + script = cron_env / "scripts" / "data.py" + script.write_text('print("new PR: #123 fix typo")\n') + + job = { + "prompt": "Report any notable changes.", + "script": str(script), + } + prompt = _build_job_prompt(job) + assert "## Script Output" in prompt + assert "new PR: #123 fix typo" in prompt + assert "Report any notable changes." in prompt + + def test_script_error_injected(self, cron_env): + from cron.scheduler import _build_job_prompt + + job = { + "prompt": "Report status.", + "script": "/nonexistent/script.py", + } + prompt = _build_job_prompt(job) + assert "## Script Error" in prompt + assert "not found" in prompt.lower() + assert "Report status." in prompt + + def test_no_script_unchanged(self, cron_env): + from cron.scheduler import _build_job_prompt + + job = {"prompt": "Simple job."} + prompt = _build_job_prompt(job) + assert "## Script Output" not in prompt + assert "Simple job." in prompt + + def test_script_empty_output_noted(self, cron_env): + from cron.scheduler import _build_job_prompt + + script = cron_env / "scripts" / "noop.py" + script.write_text("# nothing\n") + + job = { + "prompt": "Check status.", + "script": str(script), + } + prompt = _build_job_prompt(job) + assert "no output" in prompt.lower() + assert "Check status." in prompt + + +class TestCronjobToolScript: + """Test the cronjob tool's script parameter.""" + + def test_create_with_script(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="/home/user/monitor.py", + )) + assert result["success"] is True + assert result["job"]["script"] == "/home/user/monitor.py" + + def test_update_script(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + create_result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + )) + job_id = create_result["job_id"] + + update_result = json.loads(cronjob( + action="update", + job_id=job_id, + script="/new/script.py", + )) + assert update_result["success"] is True + assert update_result["job"]["script"] == "/new/script.py" + + def test_clear_script(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + create_result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="/some/script.py", + )) + job_id = create_result["job_id"] + + update_result = json.loads(cronjob( + action="update", + job_id=job_id, + script="", + )) + assert update_result["success"] is True + assert "script" not in update_result["job"] + + def test_list_shows_script(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="/path/to/script.py", + ) + + list_result = json.loads(cronjob(action="list")) + assert list_result["success"] is True + assert len(list_result["jobs"]) == 1 + assert list_result["jobs"][0]["script"] == "/path/to/script.py" diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 84054c6e245a..965cfe130376 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -116,7 +116,7 @@ def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: prompt = job.get("prompt", "") skills = _canonical_skills(job.get("skill"), job.get("skills")) - return { + result = { "job_id": job["id"], "name": job["name"], "skill": skills[0] if skills else None, @@ -136,6 +136,9 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: "paused_at": job.get("paused_at"), "paused_reason": job.get("paused_reason"), } + if job.get("script"): + result["script"] = job["script"] + return result def cronjob( @@ -153,6 +156,7 @@ def cronjob( provider: Optional[str] = None, base_url: Optional[str] = None, reason: Optional[str] = None, + script: Optional[str] = None, task_id: str = None, ) -> str: """Unified cron job management tool.""" @@ -183,6 +187,7 @@ def cronjob( model=_normalize_optional_job_value(model), provider=_normalize_optional_job_value(provider), base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True), + script=_normalize_optional_job_value(script), ) return json.dumps( { @@ -265,6 +270,9 @@ def cronjob( updates["provider"] = _normalize_optional_job_value(provider) if base_url is not None: updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True) + if script is not None: + # Pass empty string to clear an existing script + updates["script"] = _normalize_optional_job_value(script) if script else None if repeat is not None: # Normalize: treat 0 or negative as None (infinite) normalized_repeat = None if repeat <= 0 else repeat @@ -338,6 +346,11 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: If skill or skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction. On update, passing skills=[] clears attached skills. +If script is provided on create, the referenced Python script runs before each agent turn. +Its stdout is injected into the prompt as context. Use this for data collection and change +detection — the script handles gathering data, the agent analyzes and reports. +On update, pass script="" to clear an attached script. + NOTE: The agent's final response is auto-delivered to the target. Put the primary user-facing content in the final response. Cron jobs run autonomously with no user present — they cannot ask questions or request clarification. @@ -402,6 +415,10 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: "reason": { "type": "string", "description": "Optional pause reason" + }, + "script": { + "type": "string", + "description": "Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under ~/.hermes/scripts/. On update, pass empty string to clear." } }, "required": ["action"] @@ -451,6 +468,7 @@ def get_cronjob_tool_definitions(): provider=args.get("provider"), base_url=args.get("base_url"), reason=args.get("reason"), + script=args.get("script"), task_id=kw.get("task_id"), ), check_fn=check_cronjob_requirements, From 93aa01c71c696b121001cc2d1812126f488c485c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:07:43 -0700 Subject: [PATCH 003/213] fix: use main provider model for auxiliary tasks on non-aggregator providers (#5091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users on direct API-key providers (Alibaba, DeepSeek, ZAI, etc.) without an OpenRouter or Nous key would get broken auxiliary tasks (compression, vision, etc.) because _resolve_auto() only tried aggregator providers first, then fell back to iterating PROVIDER_REGISTRY with wrong default model names. Now _resolve_auto() checks the user's main provider first. If it's not an aggregator (OpenRouter/Nous), it uses their main model directly for all auxiliary tasks. Aggregator users still get the cheap gemini-flash model as before. Adds _read_main_provider() to read model.provider from config.yaml, mirroring the existing _read_main_model(). Reported by SkyLinx — Alibaba Coding Plan user getting 400 errors from google/gemini-3-flash-preview being sent to DashScope. --- agent/auxiliary_client.py | 46 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index bfbf20b5d6e4..3832ac7369f0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -697,6 +697,25 @@ def _read_main_model() -> str: return "" +def _read_main_provider() -> str: + """Read the user's configured main provider from config.yaml. + + Returns the lowercase provider id (e.g. "alibaba", "openrouter") or "" + if not configured. + """ + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + provider = model_cfg.get("provider", "") + if isinstance(provider, str) and provider.strip(): + return provider.strip().lower() + except Exception: + pass + return "" + + def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]: """Resolve the active custom/main endpoint the same way the main CLI does. @@ -855,10 +874,35 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st } +_AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"}) + + def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: - """Full auto-detection chain: OpenRouter → Nous → custom → Codex → API-key → None.""" + """Full auto-detection chain. + + Priority: + 1. If the user's main provider is NOT an aggregator (OpenRouter / Nous), + use their main provider + main model directly. This ensures users on + Alibaba, DeepSeek, ZAI, etc. get auxiliary tasks handled by the same + provider they already have credentials for — no OpenRouter key needed. + 2. OpenRouter → Nous → custom → Codex → API-key providers (original chain). + """ global auxiliary_is_nous auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins + + # ── Step 1: non-aggregator main provider → use main model directly ── + main_provider = _read_main_provider() + main_model = _read_main_model() + if (main_provider and main_model + and main_provider not in _AGGREGATOR_PROVIDERS + and main_provider not in ("auto", "custom", "")): + client, resolved = resolve_provider_client(main_provider, main_model) + if client is not None: + logger.info("Auxiliary auto-detect: using main provider %s (%s)", + main_provider, resolved or main_model) + return client, resolved or main_model + + # ── Step 2: aggregator / fallback chain ────────────────────────────── tried = [] for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint, _try_codex, _resolve_api_key_provider): From 28e1e210eecc7e68ea92bbb41953234d70e2b2a6 Mon Sep 17 00:00:00 2001 From: Chris Bartholomew Date: Sat, 4 Apr 2026 12:06:08 -0700 Subject: [PATCH 004/213] fix(hindsight): overhaul hindsight memory plugin and memory setup wizard - Dedicated asyncio event loop for Hindsight async calls (fixes aiohttp session leaks) - Client caching (reuse instead of creating per-call) - Local mode daemon management with config change detection and auto-restart - Memory mode support (hybrid/context/tools) and prefetch method (recall/reflect) - Proper shutdown with event loop and client cleanup - Disable HindsightEmbedded.__del__ to avoid GC loop errors - Update API URLs (app -> ui.hindsight.vectorize.io, api_url -> base_url) - Setup wizard: conditional fields (when clause), dynamic defaults (default_from) - Switch dependency install from pip to uv (correct for uv-based venvs) - Add hindsight-all to plugin.yaml and import mapping - 12 new tests for dispatch routing and setup field filtering Original PR #5044 by cdbartholomew. --- hermes_cli/memory_setup.py | 31 ++- plugins/memory/hindsight/README.md | 74 ++++++- plugins/memory/hindsight/__init__.py | 301 ++++++++++++++++++++------- plugins/memory/hindsight/plugin.yaml | 1 + tests/agent/test_memory_provider.py | 250 ++++++++++++++++++++++ 5 files changed, 574 insertions(+), 83 deletions(-) diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 766223fe1f21..786873eb01c8 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -151,6 +151,7 @@ def _install_dependencies(provider_name: str) -> None: "honcho-ai": "honcho", "mem0ai": "mem0", "hindsight-client": "hindsight_client", + "hindsight-all": "hindsight", } # Check which packages are missing @@ -166,9 +167,18 @@ def _install_dependencies(provider_name: str) -> None: return print(f"\n Installing dependencies: {', '.join(missing)}") + + import shutil + uv_path = shutil.which("uv") + if not uv_path: + print(f" ⚠ uv not found — cannot install dependencies") + print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh") + print(f" Then re-run: hermes memory setup") + return + try: subprocess.run( - [sys.executable, "-m", "pip", "install", "--quiet"] + missing, + [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing, check=True, timeout=120, capture_output=True, ) @@ -178,10 +188,10 @@ def _install_dependencies(provider_name: str) -> None: stderr = (e.stderr or b"").decode()[:200] if stderr: print(f" {stderr}") - print(f" Run manually: pip install {' '.join(missing)}") + print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}") except Exception as e: print(f" ⚠ Install failed: {e}") - print(f" Run manually: pip install {' '.join(missing)}") + print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}") # Also show external dependencies (non-pip) if any ext_deps = meta.get("external_dependencies", []) @@ -275,7 +285,6 @@ def cmd_setup(args) -> None: schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] - # Provider config section provider_config = config["memory"].get(name, {}) if not isinstance(provider_config, dict): provider_config = {} @@ -290,11 +299,25 @@ def cmd_setup(args) -> None: key = field["key"] desc = field.get("description", key) default = field.get("default") + # Dynamic default: look up default from another field's value + default_from = field.get("default_from") + if default_from and isinstance(default_from, dict): + ref_field = default_from.get("field", "") + ref_map = default_from.get("map", {}) + ref_value = provider_config.get(ref_field, "") + if ref_value and ref_value in ref_map: + default = ref_map[ref_value] is_secret = field.get("secret", False) choices = field.get("choices") env_var = field.get("env_var") url = field.get("url") + # Skip fields whose "when" condition doesn't match + when = field.get("when") + if when and isinstance(when, dict): + if not all(provider_config.get(k) == v for k, v in when.items()): + continue + if choices and not is_secret: # Use curses picker for choice fields choice_items = [(c, "") for c in choices] diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index de3fc6d258c0..34f5088f3658 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -1,11 +1,11 @@ # Hindsight Memory Provider -Long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval. Supports cloud and local modes. +Long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval. Supports cloud and local (embedded) modes. ## Requirements -- Cloud: `pip install hindsight-client` + API key from [app.hindsight.vectorize.io](https://app.hindsight.vectorize.io) -- Local: `pip install hindsight` + LLM API key for embeddings +- **Cloud:** API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io) +- **Local:** API key for a supported LLM provider (OpenAI, Anthropic, Gemini, Groq, MiniMax, or Ollama). Embeddings and reranking run locally — no additional API keys needed. ## Setup @@ -13,26 +13,86 @@ Long-term memory with knowledge graph, entity resolution, and multi-strategy ret hermes memory setup # select "hindsight" ``` -Or manually: +The setup wizard will install dependencies automatically via `uv` and walk you through configuration. + +Or manually (cloud mode with defaults): ```bash hermes config set memory.provider hindsight echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env ``` +### Cloud Mode + +Connects to the Hindsight Cloud API. Requires an API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io). + +### Local Mode + +Runs an embedded Hindsight server with built-in PostgreSQL. Requires an LLM API key (e.g. Groq, OpenAI, Anthropic) for memory extraction and synthesis. The daemon starts automatically in the background on first use and stops after 5 minutes of inactivity. + +Daemon startup logs: `~/.hermes/logs/hindsight-embed.log` +Daemon runtime logs: `~/.hindsight/profiles/.log` + ## Config -Config file: `$HERMES_HOME/hindsight/config.json` (or `~/.hindsight/config.json` legacy) +Config file: `~/.hermes/hindsight/config.json` + +### Connection | Key | Default | Description | |-----|---------|-------------| | `mode` | `cloud` | `cloud` or `local` | -| `bank_id` | `hermes` | Memory bank identifier | -| `budget` | `mid` | Recall thoroughness: `low`/`mid`/`high` | +| `api_url` | `https://api.hindsight.vectorize.io` | API URL (cloud mode) | +| `api_url` | `http://localhost:8888` | API URL (local mode, unused — daemon manages its own port) | + +### Memory + +| Key | Default | Description | +|-----|---------|-------------| +| `bank_id` | `hermes` | Memory bank name | +| `budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` | + +### Integration + +| Key | Default | Description | +|-----|---------|-------------| +| `memory_mode` | `hybrid` | How memories are integrated into the agent | +| `prefetch_method` | `recall` | Method for automatic context injection | + +**memory_mode:** +- `hybrid` — automatic context injection + tools available to the LLM +- `context` — automatic injection only, no tools exposed +- `tools` — tools only, no automatic injection + +**prefetch_method:** +- `recall` — injects raw memory facts (fast) +- `reflect` — injects LLM-synthesized summary (slower, more coherent) + +### Local Mode LLM + +| Key | Default | Description | +|-----|---------|-------------| +| `llm_provider` | `openai` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama` | +| `llm_model` | per-provider | Model name (e.g. `gpt-4o-mini`, `openai/gpt-oss-120b`) | + +The LLM API key is stored in `~/.hermes/.env` as `HINDSIGHT_LLM_API_KEY`. ## Tools +Available in `hybrid` and `tools` memory modes: + | Tool | Description | |------|-------------| | `hindsight_retain` | Store information with auto entity extraction | | `hindsight_recall` | Multi-strategy search (semantic + entity graph) | | `hindsight_reflect` | Cross-memory synthesis (LLM-powered) | + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `HINDSIGHT_API_KEY` | API key for Hindsight Cloud | +| `HINDSIGHT_LLM_API_KEY` | LLM API key for local mode | +| `HINDSIGHT_API_URL` | Override API endpoint | +| `HINDSIGHT_BANK_ID` | Override bank name | +| `HINDSIGHT_BUDGET` | Override recall budget | +| `HINDSIGHT_MODE` | Override mode (`cloud` / `local`) | diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 2846b9f7b23f..140aa1ea03ab 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -1,7 +1,7 @@ """Hindsight memory plugin — MemoryProvider interface. Long-term memory with knowledge graph, entity resolution, and multi-strategy -retrieval. Supports cloud (API key) and local (embedded PostgreSQL) modes. +retrieval. Supports cloud (API key) and local modes. Original PR #1811 by benfrank241, adapted to MemoryProvider ABC. @@ -18,10 +18,10 @@ from __future__ import annotations +import asyncio import json import logging import os -import queue import threading from typing import Any, Dict, List @@ -30,30 +30,51 @@ logger = logging.getLogger(__name__) _DEFAULT_API_URL = "https://api.hindsight.vectorize.io" +_DEFAULT_LOCAL_URL = "http://localhost:8888" _VALID_BUDGETS = {"low", "mid", "high"} +_PROVIDER_DEFAULT_MODELS = { + "openai": "gpt-4o-mini", + "anthropic": "claude-haiku-4-5", + "gemini": "gemini-2.5-flash", + "groq": "openai/gpt-oss-120b", + "minimax": "MiniMax-M2.7", + "ollama": "gemma3:12b", + "lmstudio": "local-model", +} # --------------------------------------------------------------------------- -# Thread helper (from original PR — avoids aiohttp event loop conflicts) +# Dedicated event loop for Hindsight async calls (one per process, reused). +# Avoids creating ephemeral loops that leak aiohttp sessions. # --------------------------------------------------------------------------- -def _run_in_thread(fn, timeout: float = 30.0): - result_q: queue.Queue = queue.Queue(maxsize=1) +_loop: asyncio.AbstractEventLoop | None = None +_loop_thread: threading.Thread | None = None +_loop_lock = threading.Lock() - def _run(): - import asyncio - asyncio.set_event_loop(None) - try: - result_q.put(("ok", fn())) - except Exception as exc: - result_q.put(("err", exc)) - t = threading.Thread(target=_run, daemon=True, name="hindsight-call") - t.start() - kind, value = result_q.get(timeout=timeout) - if kind == "err": - raise value - return value +def _get_loop() -> asyncio.AbstractEventLoop: + """Return a long-lived event loop running on a background thread.""" + global _loop, _loop_thread + with _loop_lock: + if _loop is not None and _loop.is_running(): + return _loop + _loop = asyncio.new_event_loop() + + def _run(): + asyncio.set_event_loop(_loop) + _loop.run_forever() + + _loop_thread = threading.Thread(target=_run, daemon=True, name="hindsight-loop") + _loop_thread.start() + return _loop + + +def _run_sync(coro, timeout: float = 120.0): + """Schedule *coro* on the shared loop and block until done.""" + loop = _get_loop() + future = asyncio.run_coroutine_threadsafe(coro, loop) + return future.result(timeout=timeout) # --------------------------------------------------------------------------- @@ -161,9 +182,13 @@ class HindsightMemoryProvider(MemoryProvider): def __init__(self): self._config = None self._api_key = None + self._api_url = _DEFAULT_API_URL self._bank_id = "hermes" self._budget = "mid" self._mode = "cloud" + self._memory_mode = "hybrid" # "context", "tools", or "hybrid" + self._prefetch_method = "recall" # "recall" or "reflect" + self._client = None self._prefetch_result = "" self._prefetch_lock = threading.Lock() self._prefetch_thread = None @@ -178,10 +203,10 @@ def is_available(self) -> bool: cfg = _load_config() mode = cfg.get("mode", "cloud") if mode == "local": - embed = cfg.get("embed", {}) - return bool(embed.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY")) - api_key = cfg.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", "") - return bool(api_key) + return True + has_key = bool(cfg.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", "")) + has_url = bool(cfg.get("api_url") or os.environ.get("HINDSIGHT_API_URL", "")) + return has_key or has_url except Exception: return False @@ -204,49 +229,148 @@ def save_config(self, values, hermes_home): def get_config_schema(self): return [ {"key": "mode", "description": "Cloud API or local embedded mode", "default": "cloud", "choices": ["cloud", "local"]}, - {"key": "api_key", "description": "Hindsight Cloud API key", "secret": True, "env_var": "HINDSIGHT_API_KEY", "url": "https://app.hindsight.vectorize.io"}, - {"key": "bank_id", "description": "Memory bank identifier", "default": "hermes"}, + {"key": "api_url", "description": "Hindsight API URL", "default": _DEFAULT_API_URL, "when": {"mode": "cloud"}}, + {"key": "api_key", "description": "Hindsight Cloud API key", "secret": True, "env_var": "HINDSIGHT_API_KEY", "url": "https://ui.hindsight.vectorize.io", "when": {"mode": "cloud"}}, + {"key": "llm_provider", "description": "LLM provider for local mode", "default": "openai", "choices": ["openai", "anthropic", "gemini", "groq", "minimax", "ollama"], "when": {"mode": "local"}}, + {"key": "llm_api_key", "description": "LLM API key for local Hindsight", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY", "when": {"mode": "local"}}, + {"key": "llm_model", "description": "LLM model for local mode", "default": "gpt-4o-mini", "default_from": {"field": "llm_provider", "map": _PROVIDER_DEFAULT_MODELS}, "when": {"mode": "local"}}, + {"key": "bank_id", "description": "Memory bank name", "default": "hermes"}, {"key": "budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, - {"key": "llm_provider", "description": "LLM provider for local mode", "default": "anthropic", "choices": ["anthropic", "openai", "groq", "ollama"]}, - {"key": "llm_api_key", "description": "LLM API key for local mode", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY"}, - {"key": "llm_model", "description": "LLM model for local mode", "default": "claude-haiku-4-5-20251001"}, + {"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]}, + {"key": "prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]}, ] - def _make_client(self): - """Create a fresh Hindsight client (thread-safe).""" - if self._mode == "local": - from hindsight import HindsightEmbedded - embed = self._config.get("embed", {}) - return HindsightEmbedded( - profile=embed.get("profile", "hermes"), - llm_provider=embed.get("llmProvider", ""), - llm_api_key=embed.get("llmApiKey", ""), - llm_model=embed.get("llmModel", ""), - ) - from hindsight_client import Hindsight - return Hindsight(api_key=self._api_key, timeout=30.0) + def _get_client(self): + """Return the cached Hindsight client (created once, reused).""" + if self._client is None: + if self._mode == "local": + from hindsight import HindsightEmbedded + # Disable __del__ on the class to prevent "attached to a + # different loop" errors during GC — we handle cleanup in + # shutdown() instead. + HindsightEmbedded.__del__ = lambda self: None + self._client = HindsightEmbedded( + profile=self._config.get("profile", "hermes"), + llm_provider=self._config.get("llm_provider", ""), + llm_api_key=self._config.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY", ""), + llm_model=self._config.get("llm_model", ""), + ) + else: + from hindsight_client import Hindsight + kwargs = {"base_url": self._api_url, "timeout": 30.0} + if self._api_key: + kwargs["api_key"] = self._api_key + self._client = Hindsight(**kwargs) + return self._client def initialize(self, session_id: str, **kwargs) -> None: self._config = _load_config() self._mode = self._config.get("mode", "cloud") self._api_key = self._config.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", "") + default_url = _DEFAULT_LOCAL_URL if self._mode == "local" else _DEFAULT_API_URL + self._api_url = self._config.get("api_url") or os.environ.get("HINDSIGHT_API_URL", default_url) banks = self._config.get("banks", {}).get("hermes", {}) - self._bank_id = banks.get("bankId", "hermes") - budget = banks.get("budget", "mid") + self._bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes") + budget = self._config.get("budget") or banks.get("budget", "mid") self._budget = budget if budget in _VALID_BUDGETS else "mid" - # Ensure bank exists - try: - client = _run_in_thread(self._make_client) - _run_in_thread(lambda: client.create_bank(bank_id=self._bank_id, name=self._bank_id)) - except Exception: - pass # Already exists + memory_mode = self._config.get("memory_mode", "hybrid") + self._memory_mode = memory_mode if memory_mode in ("context", "tools", "hybrid") else "hybrid" + + prefetch_method = self._config.get("prefetch_method", "recall") + self._prefetch_method = prefetch_method if prefetch_method in ("recall", "reflect") else "recall" + + logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s", + self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method) + + # For local mode, start the embedded daemon in the background so it + # doesn't block the chat. Redirect stdout/stderr to a log file to + # prevent rich startup output from spamming the terminal. + if self._mode == "local": + def _start_daemon(): + import traceback + from pathlib import Path + log_dir = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "hindsight-embed.log" + try: + # Redirect the daemon manager's Rich console to our log file + # instead of stderr. This avoids global fd redirects that + # would capture output from other threads. + import hindsight_embed.daemon_embed_manager as dem + from rich.console import Console + dem.console = Console(file=open(log_path, "a"), force_terminal=False) + + client = self._get_client() + profile = self._config.get("profile", "hermes") + + # Update the profile .env to match our current config so + # the daemon always starts with the right settings. + # If the config changed and the daemon is running, stop it. + from pathlib import Path as _Path + profile_env = _Path.home() / ".hindsight" / "profiles" / f"{profile}.env" + current_key = self._config.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY", "") + current_provider = self._config.get("llm_provider", "") + current_model = self._config.get("llm_model", "") + + # Read saved profile config + saved = {} + if profile_env.exists(): + for line in profile_env.read_text().splitlines(): + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + saved[k.strip()] = v.strip() + + config_changed = ( + saved.get("HINDSIGHT_API_LLM_PROVIDER") != current_provider or + saved.get("HINDSIGHT_API_LLM_MODEL") != current_model or + saved.get("HINDSIGHT_API_LLM_API_KEY") != current_key + ) + + if config_changed: + # Write updated profile .env + profile_env.parent.mkdir(parents=True, exist_ok=True) + profile_env.write_text( + f"HINDSIGHT_API_LLM_PROVIDER={current_provider}\n" + f"HINDSIGHT_API_LLM_API_KEY={current_key}\n" + f"HINDSIGHT_API_LLM_MODEL={current_model}\n" + f"HINDSIGHT_API_LOG_LEVEL=info\n" + ) + if client._manager.is_running(profile): + with open(log_path, "a") as f: + f.write("\n=== Config changed, restarting daemon ===\n") + client._manager.stop(profile) + + client._ensure_started() + with open(log_path, "a") as f: + f.write("\n=== Daemon started successfully ===\n") + except Exception as e: + with open(log_path, "a") as f: + f.write(f"\n=== Daemon startup failed: {e} ===\n") + traceback.print_exc(file=f) + + t = threading.Thread(target=_start_daemon, daemon=True, name="hindsight-daemon-start") + t.start() def system_prompt_block(self) -> str: + if self._memory_mode == "context": + return ( + f"# Hindsight Memory\n" + f"Active (context mode). Bank: {self._bank_id}, budget: {self._budget}.\n" + f"Relevant memories are automatically injected into context." + ) + if self._memory_mode == "tools": + return ( + f"# Hindsight Memory\n" + f"Active (tools mode). Bank: {self._bank_id}, budget: {self._budget}.\n" + f"Use hindsight_recall to search, hindsight_reflect for synthesis, " + f"hindsight_retain to store facts." + ) return ( f"# Hindsight Memory\n" f"Active. Bank: {self._bank_id}, budget: {self._budget}.\n" + f"Relevant memories are automatically injected into context. " f"Use hindsight_recall to search, hindsight_reflect for synthesis, " f"hindsight_retain to store facts." ) @@ -262,12 +386,18 @@ def prefetch(self, query: str, *, session_id: str = "") -> str: return f"## Hindsight Memory\n{result}" def queue_prefetch(self, query: str, *, session_id: str = "") -> None: + if self._memory_mode == "tools": + return def _run(): try: - client = self._make_client() - resp = client.recall(bank_id=self._bank_id, query=query, budget=self._budget) - if resp.results: - text = "\n".join(r.text for r in resp.results if r.text) + client = self._get_client() + if self._prefetch_method == "reflect": + resp = _run_sync(client.areflect(bank_id=self._bank_id, query=query, budget=self._budget)) + text = resp.text or "" + else: + resp = _run_sync(client.arecall(bank_id=self._bank_id, query=query, budget=self._budget)) + text = "\n".join(r.text for r in resp.results if r.text) if resp.results else "" + if text: with self._prefetch_lock: self._prefetch_result = text except Exception as e: @@ -282,11 +412,10 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st def _sync(): try: - _run_in_thread( - lambda: self._make_client().retain( - bank_id=self._bank_id, content=combined, context="conversation" - ) - ) + client = self._get_client() + _run_sync(client.aretain( + bank_id=self._bank_id, content=combined, context="conversation" + )) except Exception as e: logger.warning("Hindsight sync failed: %s", e) @@ -296,22 +425,29 @@ def _sync(): self._sync_thread.start() def get_tool_schemas(self) -> List[Dict[str, Any]]: + if self._memory_mode == "context": + return [] return [RETAIN_SCHEMA, RECALL_SCHEMA, REFLECT_SCHEMA] def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: + try: + client = self._get_client() + except Exception as e: + logger.warning("Hindsight client init failed: %s", e) + return json.dumps({"error": f"Hindsight client unavailable: {e}"}) + if tool_name == "hindsight_retain": content = args.get("content", "") if not content: return json.dumps({"error": "Missing required parameter: content"}) context = args.get("context") try: - _run_in_thread( - lambda: self._make_client().retain( - bank_id=self._bank_id, content=content, context=context - ) - ) + _run_sync(client.aretain( + bank_id=self._bank_id, content=content, context=context + )) return json.dumps({"result": "Memory stored successfully."}) except Exception as e: + logger.warning("hindsight_retain failed: %s", e) return json.dumps({"error": f"Failed to store memory: {e}"}) elif tool_name == "hindsight_recall": @@ -319,16 +455,15 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: if not query: return json.dumps({"error": "Missing required parameter: query"}) try: - resp = _run_in_thread( - lambda: self._make_client().recall( - bank_id=self._bank_id, query=query, budget=self._budget - ) - ) + resp = _run_sync(client.arecall( + bank_id=self._bank_id, query=query, budget=self._budget + )) if not resp.results: return json.dumps({"result": "No relevant memories found."}) lines = [f"{i}. {r.text}" for i, r in enumerate(resp.results, 1)] return json.dumps({"result": "\n".join(lines)}) except Exception as e: + logger.warning("hindsight_recall failed: %s", e) return json.dumps({"error": f"Failed to search memory: {e}"}) elif tool_name == "hindsight_reflect": @@ -336,21 +471,43 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: if not query: return json.dumps({"error": "Missing required parameter: query"}) try: - resp = _run_in_thread( - lambda: self._make_client().reflect( - bank_id=self._bank_id, query=query, budget=self._budget - ) - ) + resp = _run_sync(client.areflect( + bank_id=self._bank_id, query=query, budget=self._budget + )) return json.dumps({"result": resp.text or "No relevant memories found."}) except Exception as e: + logger.warning("hindsight_reflect failed: %s", e) return json.dumps({"error": f"Failed to reflect: {e}"}) return json.dumps({"error": f"Unknown tool: {tool_name}"}) def shutdown(self) -> None: + global _loop, _loop_thread for t in (self._prefetch_thread, self._sync_thread): if t and t.is_alive(): t.join(timeout=5.0) + if self._client is not None: + try: + if self._mode == "local": + # Use the public close() API. The RuntimeError from + # aiohttp's "attached to a different loop" is expected + # and harmless — the daemon keeps running independently. + try: + self._client.close() + except RuntimeError: + pass + else: + _run_sync(self._client.aclose()) + except Exception: + pass + self._client = None + # Stop the background event loop so no tasks are pending at exit + if _loop is not None and _loop.is_running(): + _loop.call_soon_threadsafe(_loop.stop) + if _loop_thread is not None: + _loop_thread.join(timeout=5.0) + _loop = None + _loop_thread = None def register(ctx) -> None: diff --git a/plugins/memory/hindsight/plugin.yaml b/plugins/memory/hindsight/plugin.yaml index 331ef80da7f4..79851899202a 100644 --- a/plugins/memory/hindsight/plugin.yaml +++ b/plugins/memory/hindsight/plugin.yaml @@ -3,6 +3,7 @@ version: 1.0.0 description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval." pip_dependencies: - hindsight-client + - hindsight-all requires_env: - HINDSIGHT_API_KEY hooks: diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index 0d94d59ca78e..f3f737d98f2f 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -547,3 +547,253 @@ def test_load_nonexistent_returns_none(self): """load_memory_provider returns None for unknown names.""" from plugins.memory import load_memory_provider assert load_memory_provider("nonexistent_provider") is None + + +# --------------------------------------------------------------------------- +# Sequential dispatch routing tests +# --------------------------------------------------------------------------- + + +class TestSequentialDispatchRouting: + """Verify that memory provider tools are correctly routed through + memory_manager.has_tool() and handle_tool_call(). + + This is a regression test for a bug where _execute_tool_calls_sequential + in run_agent.py had its own inline dispatch chain that skipped + memory_manager.has_tool(), causing all memory provider tools to fall + through to the registry and return "Unknown tool". The fix added + has_tool() + handle_tool_call() to the sequential path. + + These tests verify the memory_manager contract that both dispatch + paths rely on: has_tool() returns True for registered provider tools, + and handle_tool_call() routes to the correct provider. + """ + + def test_has_tool_returns_true_for_provider_tools(self): + """has_tool returns True for tools registered by memory providers.""" + mgr = MemoryManager() + provider = FakeMemoryProvider("ext", tools=[ + {"name": "ext_recall", "description": "Ext recall", "parameters": {}}, + {"name": "ext_retain", "description": "Ext retain", "parameters": {}}, + ]) + mgr.add_provider(provider) + + assert mgr.has_tool("ext_recall") + assert mgr.has_tool("ext_retain") + + def test_has_tool_returns_false_for_builtin_tools(self): + """has_tool returns False for agent-level tools (terminal, memory, etc.).""" + mgr = MemoryManager() + provider = FakeMemoryProvider("ext", tools=[ + {"name": "ext_recall", "description": "Ext", "parameters": {}}, + ]) + mgr.add_provider(provider) + + assert not mgr.has_tool("terminal") + assert not mgr.has_tool("memory") + assert not mgr.has_tool("todo") + assert not mgr.has_tool("session_search") + assert not mgr.has_tool("nonexistent") + + def test_handle_tool_call_routes_to_provider(self): + """handle_tool_call dispatches to the correct provider's handler.""" + mgr = MemoryManager() + provider = FakeMemoryProvider("hindsight", tools=[ + {"name": "hindsight_recall", "description": "Recall", "parameters": {}}, + {"name": "hindsight_retain", "description": "Retain", "parameters": {}}, + ]) + mgr.add_provider(provider) + + result = json.loads(mgr.handle_tool_call("hindsight_recall", {"query": "alice"})) + assert result["handled"] == "hindsight_recall" + assert result["args"] == {"query": "alice"} + + def test_handle_tool_call_unknown_returns_error(self): + """handle_tool_call returns error for tools not in any provider.""" + mgr = MemoryManager() + provider = FakeMemoryProvider("ext", tools=[ + {"name": "ext_recall", "description": "Ext", "parameters": {}}, + ]) + mgr.add_provider(provider) + + result = json.loads(mgr.handle_tool_call("terminal", {"command": "ls"})) + assert "error" in result + + def test_multiple_providers_route_to_correct_one(self): + """Tools from different providers route to the right handler.""" + mgr = MemoryManager() + builtin = FakeMemoryProvider("builtin", tools=[ + {"name": "builtin_tool", "description": "Builtin", "parameters": {}}, + ]) + external = FakeMemoryProvider("hindsight", tools=[ + {"name": "hindsight_recall", "description": "Recall", "parameters": {}}, + ]) + mgr.add_provider(builtin) + mgr.add_provider(external) + + r1 = json.loads(mgr.handle_tool_call("builtin_tool", {})) + assert r1["handled"] == "builtin_tool" + + r2 = json.loads(mgr.handle_tool_call("hindsight_recall", {"query": "test"})) + assert r2["handled"] == "hindsight_recall" + + def test_tool_names_include_all_providers(self): + """get_all_tool_names returns tools from all registered providers.""" + mgr = MemoryManager() + builtin = FakeMemoryProvider("builtin", tools=[ + {"name": "builtin_tool", "description": "B", "parameters": {}}, + ]) + external = FakeMemoryProvider("ext", tools=[ + {"name": "ext_recall", "description": "E1", "parameters": {}}, + {"name": "ext_retain", "description": "E2", "parameters": {}}, + ]) + mgr.add_provider(builtin) + mgr.add_provider(external) + + names = mgr.get_all_tool_names() + assert names == {"builtin_tool", "ext_recall", "ext_retain"} + + +# --------------------------------------------------------------------------- +# Setup wizard field filtering tests (when clause and default_from) +# --------------------------------------------------------------------------- + + +class TestSetupFieldFiltering: + """Test the 'when' clause and 'default_from' logic used by the + memory setup wizard in hermes_cli/memory_setup.py. + + These features are generic — any memory plugin can use them in + get_config_schema(). Currently used by the hindsight plugin. + """ + + def _filter_fields(self, schema, provider_config): + """Simulate the setup wizard's field filtering logic. + + Returns list of (key, effective_default) for fields that pass + the 'when' filter. + """ + results = [] + for field in schema: + key = field["key"] + default = field.get("default") + + # Dynamic default + default_from = field.get("default_from") + if default_from and isinstance(default_from, dict): + ref_field = default_from.get("field", "") + ref_map = default_from.get("map", {}) + ref_value = provider_config.get(ref_field, "") + if ref_value and ref_value in ref_map: + default = ref_map[ref_value] + + # When clause + when = field.get("when") + if when and isinstance(when, dict): + if not all(provider_config.get(k) == v for k, v in when.items()): + continue + + results.append((key, default)) + return results + + def test_when_clause_filters_fields(self): + """Fields with 'when' are skipped if the condition doesn't match.""" + schema = [ + {"key": "mode", "default": "cloud"}, + {"key": "api_url", "default": "https://api.example.com", "when": {"mode": "cloud"}}, + {"key": "api_key", "default": None, "when": {"mode": "cloud"}}, + {"key": "llm_provider", "default": "openai", "when": {"mode": "local"}}, + {"key": "llm_model", "default": "gpt-4o-mini", "when": {"mode": "local"}}, + {"key": "budget", "default": "mid"}, + ] + + # Cloud mode: should see mode, api_url, api_key, budget + cloud_fields = self._filter_fields(schema, {"mode": "cloud"}) + cloud_keys = [k for k, _ in cloud_fields] + assert cloud_keys == ["mode", "api_url", "api_key", "budget"] + + # Local mode: should see mode, llm_provider, llm_model, budget + local_fields = self._filter_fields(schema, {"mode": "local"}) + local_keys = [k for k, _ in local_fields] + assert local_keys == ["mode", "llm_provider", "llm_model", "budget"] + + def test_when_clause_no_condition_always_shown(self): + """Fields without 'when' are always included.""" + schema = [ + {"key": "bank_id", "default": "hermes"}, + {"key": "budget", "default": "mid"}, + ] + fields = self._filter_fields(schema, {"mode": "cloud"}) + assert [k for k, _ in fields] == ["bank_id", "budget"] + + def test_default_from_resolves_dynamic_default(self): + """default_from looks up the default from another field's value.""" + provider_models = { + "openai": "gpt-4o-mini", + "groq": "openai/gpt-oss-120b", + "anthropic": "claude-haiku-4-5", + } + schema = [ + {"key": "llm_provider", "default": "openai"}, + {"key": "llm_model", "default": "gpt-4o-mini", + "default_from": {"field": "llm_provider", "map": provider_models}}, + ] + + # Groq selected: model should default to groq's default + fields = self._filter_fields(schema, {"llm_provider": "groq"}) + model_default = dict(fields)["llm_model"] + assert model_default == "openai/gpt-oss-120b" + + # Anthropic selected + fields = self._filter_fields(schema, {"llm_provider": "anthropic"}) + model_default = dict(fields)["llm_model"] + assert model_default == "claude-haiku-4-5" + + def test_default_from_falls_back_to_static_default(self): + """default_from falls back to static default if provider not in map.""" + schema = [ + {"key": "llm_model", "default": "gpt-4o-mini", + "default_from": {"field": "llm_provider", "map": {"groq": "openai/gpt-oss-120b"}}}, + ] + + # Unknown provider: should fall back to static default + fields = self._filter_fields(schema, {"llm_provider": "unknown_provider"}) + model_default = dict(fields)["llm_model"] + assert model_default == "gpt-4o-mini" + + def test_default_from_with_no_ref_value(self): + """default_from keeps static default if referenced field is not set.""" + schema = [ + {"key": "llm_model", "default": "gpt-4o-mini", + "default_from": {"field": "llm_provider", "map": {"groq": "openai/gpt-oss-120b"}}}, + ] + + # No provider set at all + fields = self._filter_fields(schema, {}) + model_default = dict(fields)["llm_model"] + assert model_default == "gpt-4o-mini" + + def test_when_and_default_from_combined(self): + """when clause and default_from work together correctly.""" + provider_models = {"groq": "openai/gpt-oss-120b", "openai": "gpt-4o-mini"} + schema = [ + {"key": "mode", "default": "local"}, + {"key": "llm_provider", "default": "openai", "when": {"mode": "local"}}, + {"key": "llm_model", "default": "gpt-4o-mini", + "default_from": {"field": "llm_provider", "map": provider_models}, + "when": {"mode": "local"}}, + {"key": "api_url", "default": "https://api.example.com", "when": {"mode": "cloud"}}, + ] + + # Local + groq: should see llm_model with groq default, no api_url + fields = self._filter_fields(schema, {"mode": "local", "llm_provider": "groq"}) + keys = [k for k, _ in fields] + assert "llm_model" in keys + assert "api_url" not in keys + assert dict(fields)["llm_model"] == "openai/gpt-oss-120b" + + # Cloud: should see api_url, no llm_model + fields = self._filter_fields(schema, {"mode": "cloud"}) + keys = [k for k, _ in fields] + assert "api_url" in keys + assert "llm_model" not in keys From 569e9f96702dfc505501f95d2f0c29a349029267 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:57:49 -0700 Subject: [PATCH 005/213] feat: execute_code runs on remote terminal backends (#5088) * feat: execute_code runs on remote terminal backends (Docker/SSH/Modal/Daytona/Singularity) When TERMINAL_ENV is not 'local', execute_code now ships the script to the remote environment and runs it there via the terminal backend -- the same container/sandbox/SSH session used by terminal() and file tools. Architecture: - Local backend: unchanged (UDS RPC, subprocess.Popen) - Remote backends: file-based RPC via execute_oneshot() polling - Script writes request files, parent polls and dispatches tool calls - Responses written atomically (tmp + rename) via base64/stdin - execute_oneshot() bypasses persistent shell lock for concurrency Changes: - tools/environments/base.py: add execute_oneshot() (delegates to execute()) - tools/environments/persistent_shell.py: override execute_oneshot() to bypass _shell_lock via _execute_oneshot(), enabling concurrent polling - tools/code_execution_tool.py: add file-based transport to generate_hermes_tools_module(), _execute_remote() with full env get-or-create, file shipping, RPC poll loop, output post-processing * fix: use _get_env_config() instead of raw TERMINAL_ENV env var Read terminal backend type through the canonical config resolution path (terminal_tool._get_env_config) instead of os.getenv directly. * fix: use echo piping instead of stdin_data for base64 writes Modal doesn't reliably deliver stdin_data to chained commands (base64 -d > file && mv), producing 0-byte files. Switch to echo 'base64' | base64 -d which works on all backends. Verified E2E on both Docker and Modal. --- tools/code_execution_tool.py | 558 ++++++++++++++++++++++++- tools/environments/base.py | 13 + tools/environments/persistent_shell.py | 13 + 3 files changed, 571 insertions(+), 13 deletions(-) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 2dfdc989a827..ff5c7f7fedd0 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -5,18 +5,30 @@ Lets the LLM write a Python script that calls Hermes tools via RPC, collapsing multi-step tool chains into a single inference turn. -Architecture: - 1. Parent generates a `hermes_tools.py` stub module with RPC functions +Architecture (two transports): + + **Local backend (UDS):** + 1. Parent generates a `hermes_tools.py` stub module with UDS RPC functions 2. Parent opens a Unix domain socket and starts an RPC listener thread 3. Parent spawns a child process that runs the LLM's script - 4. When the script calls a tool function, the call travels over the UDS - back to the parent, which dispatches through handle_function_call - 5. Only the script's stdout is returned to the LLM; intermediate tool - results never enter the context window + 4. Tool calls travel over the UDS back to the parent for dispatch + + **Remote backends (file-based RPC):** + 1. Parent generates `hermes_tools.py` with file-based RPC stubs + 2. Parent ships both files to the remote environment + 3. Script runs inside the terminal backend (Docker/SSH/Modal/Daytona/etc.) + 4. Tool calls are written as request files; a polling thread on the parent + reads them via execute_oneshot(), dispatches, and writes response files + 5. The script polls for response files and continues + +In both cases, only the script's stdout is returned to the LLM; intermediate +tool results never enter the context window. -Platform: Linux / macOS only (Unix domain sockets). Disabled on Windows. +Platform: Linux / macOS only (Unix domain sockets for local). Disabled on Windows. +Remote execution additionally requires Python 3 in the terminal backend. """ +import base64 import json import logging import os @@ -114,11 +126,17 @@ def check_sandbox_requirements() -> bool: } -def generate_hermes_tools_module(enabled_tools: List[str]) -> str: +def generate_hermes_tools_module(enabled_tools: List[str], + transport: str = "uds") -> str: """ Build the source code for the hermes_tools.py stub module. Only tools in both SANDBOX_ALLOWED_TOOLS and enabled_tools get stubs. + + Args: + enabled_tools: Tool names enabled in the current session. + transport: ``"uds"`` for Unix domain socket (local backend) or + ``"file"`` for file-based RPC (remote backends). """ tools_to_generate = sorted(SANDBOX_ALLOWED_TOOLS & set(enabled_tools)) @@ -135,12 +153,17 @@ def generate_hermes_tools_module(enabled_tools: List[str]) -> str: ) export_names.append(func_name) - header = '''\ -"""Auto-generated Hermes tools RPC stubs.""" -import json, os, socket, shlex, time + if transport == "file": + header = _FILE_TRANSPORT_HEADER + else: + header = _UDS_TRANSPORT_HEADER + + return header + "\n".join(stub_functions) -_sock = None +# ---- Shared helpers section (embedded in both transport headers) ---------- + +_COMMON_HELPERS = '''\ # --------------------------------------------------------------------------- # Convenience helpers (avoid common scripting pitfalls) @@ -176,6 +199,17 @@ def retry(fn, max_attempts=3, delay=2): time.sleep(delay * (2 ** attempt)) raise last_err +''' + +# ---- UDS transport (local backend) --------------------------------------- + +_UDS_TRANSPORT_HEADER = '''\ +"""Auto-generated Hermes tools RPC stubs.""" +import json, os, socket, shlex, time + +_sock = None +''' + _COMMON_HELPERS + '''\ + def _connect(): global _sock if _sock is None: @@ -208,7 +242,57 @@ def _call(tool_name, args): ''' - return header + "\n".join(stub_functions) +# ---- File-based transport (remote backends) ------------------------------- + +_FILE_TRANSPORT_HEADER = '''\ +"""Auto-generated Hermes tools RPC stubs (file-based transport).""" +import json, os, shlex, time + +_RPC_DIR = os.environ.get("HERMES_RPC_DIR", "/tmp/hermes_rpc") +_seq = 0 +''' + _COMMON_HELPERS + '''\ + +def _call(tool_name, args): + """Send a tool call request via file-based RPC and wait for response.""" + global _seq + _seq += 1 + seq_str = f"{_seq:06d}" + req_file = os.path.join(_RPC_DIR, f"req_{seq_str}") + res_file = os.path.join(_RPC_DIR, f"res_{seq_str}") + + # Write request atomically (write to .tmp, then rename) + tmp = req_file + ".tmp" + with open(tmp, "w") as f: + json.dump({"tool": tool_name, "args": args, "seq": _seq}, f) + os.rename(tmp, req_file) + + # Wait for response with adaptive polling + deadline = time.monotonic() + 300 # 5-minute timeout per tool call + poll_interval = 0.05 # Start at 50ms + while not os.path.exists(res_file): + if time.monotonic() > deadline: + raise RuntimeError(f"RPC timeout: no response for {tool_name} after 300s") + time.sleep(poll_interval) + poll_interval = min(poll_interval * 1.2, 0.25) # Back off to 250ms + + with open(res_file) as f: + raw = f.read() + + # Clean up response file + try: + os.unlink(res_file) + except OSError: + pass + + result = json.loads(raw) + if isinstance(result, str): + try: + return json.loads(result) + except (json.JSONDecodeError, TypeError): + return result + return result + +''' # --------------------------------------------------------------------------- @@ -339,6 +423,443 @@ def _rpc_server_loop( logger.debug("RPC conn close error: %s", e) +# --------------------------------------------------------------------------- +# Remote execution support (file-based RPC via terminal backend) +# --------------------------------------------------------------------------- + +def _get_or_create_env(task_id: str): + """Get or create the terminal environment for *task_id*. + + Reuses the same environment (container/sandbox/SSH session) that the + terminal and file tools use, creating one if it doesn't exist yet. + Returns ``(env, env_type)`` tuple. + """ + from tools.terminal_tool import ( + _active_environments, _env_lock, _create_environment, + _get_env_config, _last_activity, _start_cleanup_thread, + _creation_locks, _creation_locks_lock, _task_env_overrides, + ) + + effective_task_id = task_id or "default" + + # Fast path: environment already exists + with _env_lock: + if effective_task_id in _active_environments: + _last_activity[effective_task_id] = time.time() + return _active_environments[effective_task_id], _get_env_config()["env_type"] + + # Slow path: create environment (same pattern as file_tools._get_file_ops) + with _creation_locks_lock: + if effective_task_id not in _creation_locks: + _creation_locks[effective_task_id] = threading.Lock() + task_lock = _creation_locks[effective_task_id] + + with task_lock: + with _env_lock: + if effective_task_id in _active_environments: + _last_activity[effective_task_id] = time.time() + return _active_environments[effective_task_id], _get_env_config()["env_type"] + + config = _get_env_config() + env_type = config["env_type"] + overrides = _task_env_overrides.get(effective_task_id, {}) + + if env_type == "docker": + image = overrides.get("docker_image") or config["docker_image"] + elif env_type == "singularity": + image = overrides.get("singularity_image") or config["singularity_image"] + elif env_type == "modal": + image = overrides.get("modal_image") or config["modal_image"] + elif env_type == "daytona": + image = overrides.get("daytona_image") or config["daytona_image"] + else: + image = "" + + cwd = overrides.get("cwd") or config["cwd"] + + container_config = None + if env_type in ("docker", "singularity", "modal", "daytona"): + container_config = { + "container_cpu": config.get("container_cpu", 1), + "container_memory": config.get("container_memory", 5120), + "container_disk": config.get("container_disk", 51200), + "container_persistent": config.get("container_persistent", True), + "docker_volumes": config.get("docker_volumes", []), + } + + ssh_config = None + if env_type == "ssh": + ssh_config = { + "host": config.get("ssh_host", ""), + "user": config.get("ssh_user", ""), + "port": config.get("ssh_port", 22), + "key": config.get("ssh_key", ""), + "persistent": config.get("ssh_persistent", False), + } + + local_config = None + if env_type == "local": + local_config = { + "persistent": config.get("local_persistent", False), + } + + logger.info("Creating new %s environment for execute_code task %s...", + env_type, effective_task_id[:8]) + env = _create_environment( + env_type=env_type, + image=image, + cwd=cwd, + timeout=config["timeout"], + ssh_config=ssh_config, + container_config=container_config, + local_config=local_config, + task_id=effective_task_id, + host_cwd=config.get("host_cwd"), + ) + + with _env_lock: + _active_environments[effective_task_id] = env + _last_activity[effective_task_id] = time.time() + + _start_cleanup_thread() + logger.info("%s environment ready for execute_code task %s", + env_type, effective_task_id[:8]) + return env, env_type + + +def _ship_file_to_remote(env, remote_path: str, content: str) -> None: + """Write *content* to *remote_path* on the remote environment. + + Uses ``echo … | base64 -d`` rather than stdin piping because some + backends (Modal) don't reliably deliver stdin_data to chained + commands. Base64 output is shell-safe ([A-Za-z0-9+/=]) so single + quotes are fine. + """ + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + env.execute_oneshot( + f"echo '{encoded}' | base64 -d > {remote_path}", + cwd="/", + timeout=30, + ) + + +def _rpc_poll_loop( + env, + rpc_dir: str, + task_id: str, + tool_call_log: list, + tool_call_counter: list, + max_tool_calls: int, + allowed_tools: frozenset, + stop_event: threading.Event, +): + """Poll the remote filesystem for tool call requests and dispatch them. + + Runs in a background thread. Uses ``env.execute_oneshot()`` so it can + operate concurrently with the script-execution thread that holds + ``env.execute()`` (important for persistent-shell backends like SSH). + """ + from model_tools import handle_function_call + + poll_interval = 0.1 # 100 ms + + while not stop_event.is_set(): + try: + # List pending request files (skip .tmp partials) + ls_result = env.execute_oneshot( + f"ls -1 {rpc_dir}/req_* 2>/dev/null || true", + cwd="/", + timeout=10, + ) + output = ls_result.get("output", "").strip() + if not output: + stop_event.wait(poll_interval) + continue + + req_files = sorted([ + f.strip() for f in output.split("\n") + if f.strip() + and not f.strip().endswith(".tmp") + and "/req_" in f.strip() + ]) + + for req_file in req_files: + if stop_event.is_set(): + break + + call_start = time.monotonic() + + # Read request + read_result = env.execute_oneshot( + f"cat {req_file}", + cwd="/", + timeout=10, + ) + try: + request = json.loads(read_result.get("output", "")) + except (json.JSONDecodeError, ValueError): + logger.debug("Malformed RPC request in %s", req_file) + # Remove bad request to avoid infinite retry + env.execute_oneshot(f"rm -f {req_file}", cwd="/", timeout=5) + continue + + tool_name = request.get("tool", "") + tool_args = request.get("args", {}) + seq = request.get("seq", 0) + seq_str = f"{seq:06d}" + res_file = f"{rpc_dir}/res_{seq_str}" + + # Enforce allow-list + if tool_name not in allowed_tools: + available = ", ".join(sorted(allowed_tools)) + tool_result = json.dumps({ + "error": ( + f"Tool '{tool_name}' is not available in execute_code. " + f"Available: {available}" + ) + }) + # Enforce tool call limit + elif tool_call_counter[0] >= max_tool_calls: + tool_result = json.dumps({ + "error": ( + f"Tool call limit reached ({max_tool_calls}). " + "No more tool calls allowed in this execution." + ) + }) + else: + # Strip forbidden terminal parameters + if tool_name == "terminal" and isinstance(tool_args, dict): + for param in _TERMINAL_BLOCKED_PARAMS: + tool_args.pop(param, None) + + # Dispatch through the standard tool handler + try: + _real_stdout, _real_stderr = sys.stdout, sys.stderr + devnull = open(os.devnull, "w") + try: + sys.stdout = devnull + sys.stderr = devnull + tool_result = handle_function_call( + tool_name, tool_args, task_id=task_id + ) + finally: + sys.stdout, sys.stderr = _real_stdout, _real_stderr + devnull.close() + except Exception as exc: + logger.error("Tool call failed in remote sandbox: %s", + exc, exc_info=True) + tool_result = json.dumps({"error": str(exc)}) + + tool_call_counter[0] += 1 + call_duration = time.monotonic() - call_start + tool_call_log.append({ + "tool": tool_name, + "args_preview": str(tool_args)[:80], + "duration": round(call_duration, 2), + }) + + # Write response atomically (tmp + rename). + # Use echo piping (not stdin_data) because Modal doesn't + # reliably deliver stdin to chained commands. + encoded_result = base64.b64encode( + tool_result.encode("utf-8") + ).decode("ascii") + env.execute_oneshot( + f"echo '{encoded_result}' | base64 -d > {res_file}.tmp" + f" && mv {res_file}.tmp {res_file}", + cwd="/", + timeout=60, + ) + + # Remove the request file + env.execute_oneshot(f"rm -f {req_file}", cwd="/", timeout=5) + + except Exception as e: + if not stop_event.is_set(): + logger.debug("RPC poll error: %s", e, exc_info=True) + + if not stop_event.is_set(): + stop_event.wait(poll_interval) + + +def _execute_remote( + code: str, + task_id: Optional[str], + enabled_tools: Optional[List[str]], +) -> str: + """Run a script on the remote terminal backend via file-based RPC. + + The script and the generated hermes_tools.py module are shipped to + the remote environment, and tool calls are proxied through a polling + thread that communicates via request/response files. + """ + from tools.terminal_tool import _interrupt_event + + _cfg = _load_config() + timeout = _cfg.get("timeout", DEFAULT_TIMEOUT) + max_tool_calls = _cfg.get("max_tool_calls", DEFAULT_MAX_TOOL_CALLS) + + session_tools = set(enabled_tools) if enabled_tools else set() + sandbox_tools = frozenset(SANDBOX_ALLOWED_TOOLS & session_tools) + if not sandbox_tools: + sandbox_tools = SANDBOX_ALLOWED_TOOLS + + effective_task_id = task_id or "default" + env, env_type = _get_or_create_env(effective_task_id) + + sandbox_id = uuid.uuid4().hex[:12] + sandbox_dir = f"/tmp/hermes_exec_{sandbox_id}" + + tool_call_log: list = [] + tool_call_counter = [0] + exec_start = time.monotonic() + stop_event = threading.Event() + rpc_thread = None + + try: + # Verify Python is available on the remote + py_check = env.execute_oneshot( + "command -v python3 >/dev/null 2>&1 && echo OK", + cwd="/", timeout=15, + ) + if "OK" not in py_check.get("output", ""): + return json.dumps({ + "status": "error", + "error": ( + f"Python 3 is not available in the {env_type} terminal " + "environment. Install Python to use execute_code with " + "remote backends." + ), + "tool_calls_made": 0, + "duration_seconds": 0, + }) + + # Create sandbox directory on remote + env.execute_oneshot( + f"mkdir -p {sandbox_dir}/rpc", cwd="/", timeout=10, + ) + + # Generate and ship files + tools_src = generate_hermes_tools_module( + list(sandbox_tools), transport="file", + ) + _ship_file_to_remote(env, f"{sandbox_dir}/hermes_tools.py", tools_src) + _ship_file_to_remote(env, f"{sandbox_dir}/script.py", code) + + # Start RPC polling thread + rpc_thread = threading.Thread( + target=_rpc_poll_loop, + args=( + env, f"{sandbox_dir}/rpc", effective_task_id, + tool_call_log, tool_call_counter, max_tool_calls, + sandbox_tools, stop_event, + ), + daemon=True, + ) + rpc_thread.start() + + # Build environment variable prefix for the script + env_prefix = ( + f"HERMES_RPC_DIR={sandbox_dir}/rpc " + f"PYTHONDONTWRITEBYTECODE=1" + ) + tz = os.getenv("HERMES_TIMEZONE", "").strip() + if tz: + env_prefix += f" TZ={tz}" + + # Execute the script on the remote backend + logger.info("Executing code on %s backend (task %s)...", + env_type, effective_task_id[:8]) + script_result = env.execute( + f"cd {sandbox_dir} && {env_prefix} python3 script.py", + timeout=timeout, + ) + + stdout_text = script_result.get("output", "") + exit_code = script_result.get("returncode", -1) + status = "success" + + # Check for timeout/interrupt from the backend + if exit_code == 124: + status = "timeout" + elif exit_code == 130: + status = "interrupted" + + except Exception as exc: + duration = round(time.monotonic() - exec_start, 2) + logger.error( + "execute_code remote failed after %ss with %d tool calls: %s: %s", + duration, tool_call_counter[0], type(exc).__name__, exc, + exc_info=True, + ) + return json.dumps({ + "status": "error", + "error": str(exc), + "tool_calls_made": tool_call_counter[0], + "duration_seconds": duration, + }, ensure_ascii=False) + + finally: + # Stop the polling thread + stop_event.set() + if rpc_thread is not None: + rpc_thread.join(timeout=5) + + # Clean up remote sandbox dir + try: + env.execute_oneshot( + f"rm -rf {sandbox_dir}", cwd="/", timeout=15, + ) + except Exception: + logger.debug("Failed to clean up remote sandbox %s", sandbox_dir) + + duration = round(time.monotonic() - exec_start, 2) + + # --- Post-process output (same as local path) --- + + # Truncate stdout to cap + if len(stdout_text) > MAX_STDOUT_BYTES: + head_bytes = int(MAX_STDOUT_BYTES * 0.4) + tail_bytes = MAX_STDOUT_BYTES - head_bytes + head = stdout_text[:head_bytes] + tail = stdout_text[-tail_bytes:] + omitted = len(stdout_text) - len(head) - len(tail) + stdout_text = ( + head + + f"\n\n... [OUTPUT TRUNCATED - {omitted:,} chars omitted " + f"out of {len(stdout_text):,} total] ...\n\n" + + tail + ) + + # Strip ANSI escape sequences + from tools.ansi_strip import strip_ansi + stdout_text = strip_ansi(stdout_text) + + # Redact secrets + from agent.redact import redact_sensitive_text + stdout_text = redact_sensitive_text(stdout_text) + + # Build response + result: Dict[str, Any] = { + "status": status, + "output": stdout_text, + "tool_calls_made": tool_call_counter[0], + "duration_seconds": duration, + } + + if status == "timeout": + result["error"] = f"Script timed out after {timeout}s and was killed." + elif status == "interrupted": + result["output"] = ( + stdout_text + "\n[execution interrupted — user sent a new message]" + ) + elif exit_code != 0: + result["status"] = "error" + result["error"] = f"Script exited with code {exit_code}" + + return json.dumps(result, ensure_ascii=False) + + # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- @@ -352,6 +873,9 @@ def execute_code( Run a Python script in a sandboxed child process with RPC access to a subset of Hermes tools. + Dispatches to the local (UDS) or remote (file-based RPC) path + depending on the configured terminal backend. + Args: code: Python source code to execute. task_id: Session task ID for tool isolation (terminal env, etc.). @@ -369,6 +893,14 @@ def execute_code( if not code or not code.strip(): return json.dumps({"error": "No code provided."}) + # Dispatch: remote backends use file-based RPC, local uses UDS + from tools.terminal_tool import _get_env_config + env_type = _get_env_config()["env_type"] + if env_type != "local": + return _execute_remote(code, task_id, enabled_tools) + + # --- Local execution path (UDS) --- below this line is unchanged --- + # Import interrupt event from terminal_tool (cooperative cancellation) from tools.terminal_tool import _interrupt_event diff --git a/tools/environments/base.py b/tools/environments/base.py index 2b02c3c475a6..21b698ec0c4c 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -91,6 +91,19 @@ def _build_run_kwargs(self, timeout: int | None, kw["stdin"] = subprocess.DEVNULL return kw + def execute_oneshot(self, command: str, cwd: str = "", *, + timeout: int | None = None, + stdin_data: str | None = None) -> dict: + """Execute a command bypassing any persistent shell. + + Safe for concurrent use alongside a long-running execute() call. + Backends that maintain a persistent shell (SSH, Local) override this + to route through their oneshot path, avoiding the shell lock. + Non-persistent backends delegate to execute(). + """ + return self.execute(command, cwd=cwd, timeout=timeout, + stdin_data=stdin_data) + def _timeout_result(self, timeout: int | None) -> dict: """Standard return dict when a command times out.""" return { diff --git a/tools/environments/persistent_shell.py b/tools/environments/persistent_shell.py index b1280bf4e0d4..c4344ff5a12b 100644 --- a/tools/environments/persistent_shell.py +++ b/tools/environments/persistent_shell.py @@ -141,6 +141,19 @@ def execute(self, command: str, cwd: str = "", *, command, cwd, timeout=timeout, stdin_data=stdin_data, ) + def execute_oneshot(self, command: str, cwd: str = "", *, + timeout: int | None = None, + stdin_data: str | None = None) -> dict: + """Always use the oneshot (non-persistent) execution path. + + This bypasses _shell_lock so it can run concurrently with a + long-running command in the persistent shell — used by + execute_code's file-based RPC polling thread. + """ + return self._execute_oneshot( + command, cwd, timeout=timeout, stdin_data=stdin_data, + ) + def cleanup(self): if self.persistent: self._cleanup_persistent_shell() From d86be331615187b5e68963333a0afe10ebc15a8d Mon Sep 17 00:00:00 2001 From: Fran Fitzpatrick Date: Sat, 4 Apr 2026 12:43:20 -0500 Subject: [PATCH 006/213] feat(gateway): add MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD support Bring Matrix feature parity with Discord by adding mention gating and auto-threading. Both default to true, matching Discord behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/config.py | 14 + gateway/platforms/matrix.py | 150 +++++- hermes_cli/config.py | 25 + tests/gateway/test_matrix_mention.py | 458 ++++++++++++++++++ .../docs/reference/environment-variables.md | 3 + website/docs/user-guide/messaging/matrix.md | 29 +- 6 files changed, 670 insertions(+), 9 deletions(-) create mode 100644 tests/gateway/test_matrix_mention.py diff --git a/gateway/config.py b/gateway/config.py index 1896db9ff888..fec050b92d33 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -575,6 +575,20 @@ def load_gateway_config() -> GatewayConfig: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc) + + # Matrix settings → env vars (env vars take precedence) + matrix_cfg = yaml_cfg.get("matrix", {}) + if isinstance(matrix_cfg, dict): + if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): + os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower() + frc = matrix_cfg.get("free_response_rooms") + if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc) + if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): + os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower() + except Exception as e: logger.warning( "Failed to process config.yaml — falling back to .env / gateway.json values. " diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index c9bcd945a0e2..9216d5f2d515 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -5,13 +5,16 @@ when installed with ``pip install "matrix-nio[e2e]"``. Environment variables: - MATRIX_HOMESERVER Homeserver URL (e.g. https://matrix.example.org) - MATRIX_ACCESS_TOKEN Access token (preferred auth method) - MATRIX_USER_ID Full user ID (@bot:server) — required for password login - MATRIX_PASSWORD Password (alternative to access token) - MATRIX_ENCRYPTION Set "true" to enable E2EE - MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) - MATRIX_HOME_ROOM Room ID for cron/notification delivery + MATRIX_HOMESERVER Homeserver URL (e.g. https://matrix.example.org) + MATRIX_ACCESS_TOKEN Access token (preferred auth method) + MATRIX_USER_ID Full user ID (@bot:server) — required for password login + MATRIX_PASSWORD Password (alternative to access token) + MATRIX_ENCRYPTION Set "true" to enable E2EE + MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) + MATRIX_HOME_ROOM Room ID for cron/notification delivery + MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) + MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement + MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) """ from __future__ import annotations @@ -123,6 +126,10 @@ def __init__(self, config: PlatformConfig): # Each entry: (room, event, timestamp) self._pending_megolm: list = [] + # Thread participation tracking (for require_mention bypass) + self._bot_participated_threads: set = self._load_participated_threads() + self._MAX_TRACKED_THREADS = 500 + def _is_duplicate_event(self, event_id) -> bool: """Return True if this event was already processed. Tracks the ID otherwise.""" if not event_id: @@ -902,6 +909,32 @@ async def _on_room_message(self, room: Any, event: Any) -> None: if relates_to.get("rel_type") == "m.thread": thread_id = relates_to.get("event_id") + # Require-mention gating. + if not is_dm: + free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()} + require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + is_free_room = room.room_id in free_rooms + in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads) + + formatted_body = source_content.get("formatted_body") + if require_mention and not is_free_room and not in_bot_thread: + if not self._is_bot_mentioned(body, formatted_body): + return + + # Strip mention from body when present. + if self._is_bot_mentioned(body, source_content.get("formatted_body")): + body = self._strip_mention(body) + if not body: + return + + # Auto-thread: create a thread for non-DM, non-threaded messages. + if not is_dm and not thread_id: + auto_thread = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + if auto_thread: + thread_id = event.event_id + self._track_thread(thread_id) + # Reply-to detection. reply_to = None in_reply_to = relates_to.get("m.in_reply_to", {}) @@ -946,6 +979,9 @@ async def _on_room_message(self, room: Any, event: Any) -> None: reply_to_message_id=reply_to, ) + if thread_id: + self._track_thread(thread_id) + await self.handle_message(msg_event) async def _on_room_message_media(self, room: Any, event: Any) -> None: @@ -1031,6 +1067,27 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: if relates_to.get("rel_type") == "m.thread": thread_id = relates_to.get("event_id") + # Require-mention gating (media messages). + if not is_dm: + free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") + free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()} + require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + is_free_room = room.room_id in free_rooms + in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads) + + if require_mention and not is_free_room and not in_bot_thread: + # Media messages have no formatted_body; check plain body only. + formatted_body = source_content.get("formatted_body") + if not self._is_bot_mentioned(body, formatted_body): + return + + # Auto-thread: create a thread for non-DM, non-threaded messages. + if not is_dm and not thread_id: + auto_thread = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + if auto_thread: + thread_id = event.event_id + self._track_thread(thread_id) + # For voice messages, cache audio locally for transcription tools. # Use the authenticated nio client to download (Matrix requires auth for media). media_urls = [http_url] if http_url else None @@ -1079,6 +1136,9 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: media_types=media_types, ) + if thread_id: + self._track_thread(thread_id) + await self.handle_message(msg_event) async def _on_invite(self, room: Any, event: Any) -> None: @@ -1166,6 +1226,82 @@ async def _refresh_dm_cache(self) -> None: for rid in self._joined_rooms } + # ------------------------------------------------------------------ + # Thread participation tracking + # ------------------------------------------------------------------ + + @staticmethod + def _thread_state_path() -> Path: + """Path to the persisted thread participation set.""" + from hermes_cli.config import get_hermes_home + return get_hermes_home() / "matrix_threads.json" + + @classmethod + def _load_participated_threads(cls) -> set: + """Load persisted thread IDs from disk.""" + path = cls._thread_state_path() + try: + if path.exists(): + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + return set(data) + except Exception as e: + logger.debug("Could not load matrix thread state: %s", e) + return set() + + def _save_participated_threads(self) -> None: + """Persist the current thread set to disk (best-effort).""" + path = self._thread_state_path() + try: + thread_list = list(self._bot_participated_threads) + if len(thread_list) > self._MAX_TRACKED_THREADS: + thread_list = thread_list[-self._MAX_TRACKED_THREADS:] + self._bot_participated_threads = set(thread_list) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(thread_list), encoding="utf-8") + except Exception as e: + logger.debug("Could not save matrix thread state: %s", e) + + def _track_thread(self, thread_id: str) -> None: + """Add a thread to the participation set and persist.""" + if thread_id not in self._bot_participated_threads: + self._bot_participated_threads.add(thread_id) + self._save_participated_threads() + + # ------------------------------------------------------------------ + # Mention detection helpers + # ------------------------------------------------------------------ + + def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool: + """Return True if the bot is mentioned in the message.""" + if not body and not formatted_body: + return False + # Check for full @user:server in body + if self._user_id and self._user_id in body: + return True + # Check for localpart with word boundaries (case-insensitive) + if self._user_id and ":" in self._user_id: + localpart = self._user_id.split(":")[0].lstrip("@") + if localpart and re.search(r'\b' + re.escape(localpart) + r'\b', body, re.IGNORECASE): + return True + # Check formatted_body for Matrix pill + if formatted_body and self._user_id: + if f"matrix.to/#/{self._user_id}" in formatted_body: + return True + return False + + def _strip_mention(self, body: str) -> str: + """Remove bot mention from message body.""" + # Remove full @user:server + if self._user_id: + body = body.replace(self._user_id, "") + # If still contains localpart mention, remove it + if self._user_id and ":" in self._user_id: + localpart = self._user_id.split(":")[0].lstrip("@") + if localpart: + body = re.sub(r'\b' + re.escape(localpart) + r'\b', '', body, flags=re.IGNORECASE) + return body.strip() + def _get_display_name(self, room: Any, user_id: str) -> str: """Get a user's display name in a room, falling back to user_id.""" if room and hasattr(room, "users"): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 491995e17e5b..00d0923d2f83 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -42,6 +42,7 @@ "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM", + "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", }) import yaml @@ -1008,6 +1009,30 @@ def ensure_hermes_home(): "password": False, "category": "messaging", }, + "MATRIX_REQUIRE_MENTION": { + "description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.", + "prompt": "Require @mention in rooms (true/false)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, + "MATRIX_FREE_RESPONSE_ROOMS": { + "description": "Comma-separated Matrix room IDs where bot responds without @mention", + "prompt": "Free-response room IDs (comma-separated)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, + "MATRIX_AUTO_THREAD": { + "description": "Auto-create threads for messages in Matrix rooms (default: true)", + "prompt": "Auto-create threads in rooms (true/false)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, "GATEWAY_ALLOW_ALL_USERS": { "description": "Allow all users to interact with messaging bots (true/false). Default: false.", "prompt": "Allow all users (true/false)", diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py new file mode 100644 index 000000000000..f8d90f281e58 --- /dev/null +++ b/tests/gateway/test_matrix_mention.py @@ -0,0 +1,458 @@ +"""Tests for Matrix require-mention gating and auto-thread features.""" + +import json +import sys +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_nio_mock(): + """Install a mock nio module when matrix-nio isn't available.""" + if "nio" in sys.modules and hasattr(sys.modules["nio"], "__file__"): + return + nio_mod = MagicMock() + nio_mod.MegolmEvent = type("MegolmEvent", (), {}) + nio_mod.RoomMessageText = type("RoomMessageText", (), {}) + nio_mod.RoomMessageImage = type("RoomMessageImage", (), {}) + nio_mod.RoomMessageAudio = type("RoomMessageAudio", (), {}) + nio_mod.RoomMessageVideo = type("RoomMessageVideo", (), {}) + nio_mod.RoomMessageFile = type("RoomMessageFile", (), {}) + nio_mod.DownloadResponse = type("DownloadResponse", (), {}) + nio_mod.MemoryDownloadResponse = type("MemoryDownloadResponse", (), {}) + nio_mod.InviteMemberEvent = type("InviteMemberEvent", (), {}) + sys.modules.setdefault("nio", nio_mod) + + +_ensure_nio_mock() + + +def _make_adapter(tmp_path=None): + """Create a MatrixAdapter with mocked config.""" + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@hermes:example.org", + }, + ) + adapter = MatrixAdapter(config) + adapter.handle_message = AsyncMock() + adapter._startup_ts = time.time() - 10 # avoid startup grace filter + return adapter + + +def _make_room(room_id="!room1:example.org", member_count=5, is_dm=False): + """Create a fake Matrix room.""" + room = SimpleNamespace( + room_id=room_id, + member_count=member_count, + users={}, + ) + return room + + +def _make_event( + body, + sender="@alice:example.org", + event_id="$evt1", + formatted_body=None, + thread_id=None, +): + """Create a fake RoomMessageText event.""" + content = {"body": body, "msgtype": "m.text"} + if formatted_body: + content["formatted_body"] = formatted_body + content["format"] = "org.matrix.custom.html" + + relates_to = {} + if thread_id: + relates_to["rel_type"] = "m.thread" + relates_to["event_id"] = thread_id + if relates_to: + content["m.relates_to"] = relates_to + + return SimpleNamespace( + sender=sender, + event_id=event_id, + server_timestamp=int(time.time() * 1000), + body=body, + source={"content": content}, + ) + + +# --------------------------------------------------------------------------- +# Mention detection helpers +# --------------------------------------------------------------------------- + + +class TestIsBotMentioned: + def setup_method(self): + self.adapter = _make_adapter() + + def test_full_user_id_in_body(self): + assert self.adapter._is_bot_mentioned("hey @hermes:example.org help") + + def test_localpart_in_body(self): + assert self.adapter._is_bot_mentioned("hermes can you help?") + + def test_localpart_case_insensitive(self): + assert self.adapter._is_bot_mentioned("HERMES can you help?") + + def test_matrix_pill_in_formatted_body(self): + html = 'Hermes help' + assert self.adapter._is_bot_mentioned("Hermes help", html) + + def test_no_mention(self): + assert not self.adapter._is_bot_mentioned("hello everyone") + + def test_empty_body(self): + assert not self.adapter._is_bot_mentioned("") + + def test_partial_localpart_no_match(self): + # "hermesbot" should not match word-boundary check for "hermes" + assert not self.adapter._is_bot_mentioned("hermesbot is here") + + +class TestStripMention: + def setup_method(self): + self.adapter = _make_adapter() + + def test_strip_full_user_id(self): + result = self.adapter._strip_mention("@hermes:example.org help me") + assert result == "help me" + + def test_strip_localpart(self): + result = self.adapter._strip_mention("hermes help me") + assert result == "help me" + + def test_strip_returns_empty_for_mention_only(self): + result = self.adapter._strip_mention("@hermes:example.org") + assert result == "" + + +# --------------------------------------------------------------------------- +# Require-mention gating in _on_room_message +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_require_mention_default_ignores_unmentioned(monkeypatch): + """Default (require_mention=true): messages without mention are ignored.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello everyone") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_require_mention_default_processes_mentioned(monkeypatch): + """Default: messages with mention are processed, mention stripped.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + event = _make_event("@hermes:example.org help me") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.text == "help me" + + +@pytest.mark.asyncio +async def test_require_mention_html_pill(monkeypatch): + """Bot mentioned via HTML pill should be processed.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + formatted = 'Hermes help' + event = _make_event("Hermes help", formatted_body=formatted) + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_dm_always_responds(monkeypatch): + """DMs always respond regardless of mention setting.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + # member_count=2 triggers DM detection + room = _make_room(member_count=2) + event = _make_event("hello without mention") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_free_response_room(monkeypatch): + """Free-response rooms bypass mention requirement.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room(room_id="!room1:example.org") + event = _make_event("hello without mention") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_bot_participated_thread(monkeypatch): + """Threads with prior bot participation bypass mention requirement.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + adapter._bot_participated_threads.add("$thread1") + + room = _make_room() + event = _make_event("hello without mention", thread_id="$thread1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_disabled(monkeypatch): + """MATRIX_REQUIRE_MENTION=false: all messages processed.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello without mention") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.text == "hello without mention" + + +# --------------------------------------------------------------------------- +# Auto-thread in _on_room_message +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auto_thread_default_creates_thread(monkeypatch): + """Default (auto_thread=true): sets thread_id to event.event_id.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello", event_id="$msg1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id == "$msg1" + + +@pytest.mark.asyncio +async def test_auto_thread_preserves_existing_thread(monkeypatch): + """If message is already in a thread, thread_id is not overridden.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + adapter._bot_participated_threads.add("$thread_root") + room = _make_room() + event = _make_event("reply in thread", thread_id="$thread_root") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id == "$thread_root" + + +@pytest.mark.asyncio +async def test_auto_thread_skips_dm(monkeypatch): + """DMs should not get auto-threaded.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room(member_count=2) + event = _make_event("hello dm", event_id="$dm1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id is None + + +@pytest.mark.asyncio +async def test_auto_thread_disabled(monkeypatch): + """MATRIX_AUTO_THREAD=false: thread_id stays None.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello", event_id="$msg1") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.source.thread_id is None + + +@pytest.mark.asyncio +async def test_auto_thread_tracks_participation(monkeypatch): + """Auto-created threads are tracked in _bot_participated_threads.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false") + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + adapter = _make_adapter() + room = _make_room() + event = _make_event("hello", event_id="$msg1") + + with patch.object(adapter, "_save_participated_threads"): + await adapter._on_room_message(room, event) + + assert "$msg1" in adapter._bot_participated_threads + + +# --------------------------------------------------------------------------- +# Thread persistence +# --------------------------------------------------------------------------- + + +class TestThreadPersistence: + def test_empty_state_file(self, tmp_path, monkeypatch): + """No state file → empty set.""" + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: tmp_path / "matrix_threads.json"), + ) + adapter = _make_adapter() + loaded = adapter._load_participated_threads() + assert loaded == set() + + def test_track_thread_persists(self, tmp_path, monkeypatch): + """_track_thread writes to disk.""" + state_path = tmp_path / "matrix_threads.json" + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: state_path), + ) + adapter = _make_adapter() + adapter._track_thread("$thread_abc") + + data = json.loads(state_path.read_text()) + assert "$thread_abc" in data + + def test_threads_survive_reload(self, tmp_path, monkeypatch): + """Persisted threads are loaded by a new adapter instance.""" + state_path = tmp_path / "matrix_threads.json" + state_path.write_text(json.dumps(["$t1", "$t2"])) + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: state_path), + ) + adapter = _make_adapter() + assert "$t1" in adapter._bot_participated_threads + assert "$t2" in adapter._bot_participated_threads + + def test_cap_max_tracked_threads(self, tmp_path, monkeypatch): + """Thread set is trimmed to _MAX_TRACKED_THREADS.""" + state_path = tmp_path / "matrix_threads.json" + monkeypatch.setattr( + "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + staticmethod(lambda: state_path), + ) + adapter = _make_adapter() + adapter._MAX_TRACKED_THREADS = 5 + + for i in range(10): + adapter._bot_participated_threads.add(f"$t{i}") + adapter._save_participated_threads() + + data = json.loads(state_path.read_text()) + assert len(data) == 5 + + +# --------------------------------------------------------------------------- +# YAML config bridge +# --------------------------------------------------------------------------- + + +class TestMatrixConfigBridge: + def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path): + """Matrix YAML config should bridge to env vars.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False) + + yaml_content = { + "matrix": { + "require_mention": False, + "free_response_rooms": ["!room1:example.org", "!room2:example.org"], + "auto_thread": False, + } + } + + import os + import yaml + + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.dump(yaml_content)) + + # Simulate the bridge logic from gateway/config.py + yaml_cfg = yaml.safe_load(config_file.read_text()) + matrix_cfg = yaml_cfg.get("matrix", {}) + if isinstance(matrix_cfg, dict): + if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()) + frc = matrix_cfg.get("free_response_rooms") + if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", str(frc)) + if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"): + monkeypatch.setenv("MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower()) + + assert os.getenv("MATRIX_REQUIRE_MENTION") == "false" + assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org" + assert os.getenv("MATRIX_AUTO_THREAD") == "false" + + def test_env_vars_take_precedence_over_yaml(self, monkeypatch): + """Env vars should not be overwritten by YAML values.""" + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true") + + import os + yaml_cfg = {"matrix": {"require_mention": False}} + matrix_cfg = yaml_cfg.get("matrix", {}) + if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"): + monkeypatch.setenv("MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()) + + assert os.getenv("MATRIX_REQUIRE_MENTION") == "true" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 2b0a84211207..8917072a491f 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -232,6 +232,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `MATRIX_ALLOWED_USERS` | Comma-separated Matrix user IDs allowed to message the bot (e.g. `@alice:matrix.org`) | | `MATRIX_HOME_ROOM` | Room ID for proactive message delivery (e.g. `!abc123:matrix.org`) | | `MATRIX_ENCRYPTION` | Enable end-to-end encryption (`true`/`false`, default: `false`) | +| `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. | +| `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` | +| `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) | | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) | | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) | | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) | diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index 70b8855a2401..943751c12602 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -17,8 +17,9 @@ Before setup, here's the part most people want to know: how Hermes behaves once | Context | Behavior | |---------|----------| | **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. | -| **Rooms** | Hermes responds to all messages in rooms it has joined. Room invites are auto-accepted. | -| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. | +| **Rooms** | By default, Hermes requires an `@mention` to respond. Set `MATRIX_REQUIRE_MENTION=false` or add room IDs to `MATRIX_FREE_RESPONSE_ROOMS` for free-response rooms. Room invites are auto-accepted. | +| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. | +| **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. | | **Shared rooms with multiple users** | By default, Hermes isolates session history per user inside the room. Two people talking in the same room do not share one transcript unless you explicitly disable that. | :::tip @@ -51,6 +52,30 @@ Shared sessions can be useful for a collaborative room, but they also mean: - one person's long tool-heavy task can bloat everyone else's context - one person's in-flight run can interrupt another person's follow-up in the same room +### Mention and Threading Configuration + +You can configure mention and auto-threading behavior via environment variables or `config.yaml`: + +```yaml +matrix: + require_mention: true # Require @mention in rooms (default: true) + free_response_rooms: # Rooms exempt from mention requirement + - "!abc123:matrix.org" + auto_thread: true # Auto-create threads for responses (default: true) +``` + +Or via environment variables: + +```bash +MATRIX_REQUIRE_MENTION=true +MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org +MATRIX_AUTO_THREAD=true +``` + +:::note +If you are upgrading from a version that did not have `MATRIX_REQUIRE_MENTION`, the bot previously responded to all messages in rooms. To preserve that behavior, set `MATRIX_REQUIRE_MENTION=false`. +::: + This guide walks you through the full setup process — from creating your bot account to sending your first message. ## Step 1: Create a Bot Account From 2556cfdab12ce7aac4c6c182b628a654ffd1509e Mon Sep 17 00:00:00 2001 From: Fran Fitzpatrick Date: Sat, 4 Apr 2026 13:41:50 -0500 Subject: [PATCH 007/213] fix(gateway): match Discord mention-stripping behavior in Matrix adapter Move mention stripping outside the `if not is_dm` guard so mentions are stripped in DMs too. Remove the bare-mention early return so a message containing only a mention passes through as empty string, matching Discord's behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/matrix.py | 13 ++++++----- tests/gateway/test_matrix_mention.py | 34 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 9216d5f2d515..4f77e920a87f 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -922,11 +922,9 @@ async def _on_room_message(self, room: Any, event: Any) -> None: if not self._is_bot_mentioned(body, formatted_body): return - # Strip mention from body when present. - if self._is_bot_mentioned(body, source_content.get("formatted_body")): - body = self._strip_mention(body) - if not body: - return + # Strip mention from body when present (including in DMs). + if self._is_bot_mentioned(body, source_content.get("formatted_body")): + body = self._strip_mention(body) # Auto-thread: create a thread for non-DM, non-threaded messages. if not is_dm and not thread_id: @@ -1076,11 +1074,14 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads) if require_mention and not is_free_room and not in_bot_thread: - # Media messages have no formatted_body; check plain body only. formatted_body = source_content.get("formatted_body") if not self._is_bot_mentioned(body, formatted_body): return + # Strip mention from body when present (including in DMs). + if self._is_bot_mentioned(body, source_content.get("formatted_body")): + body = self._strip_mention(body) + # Auto-thread: create a thread for non-DM, non-threaded messages. if not is_dm and not thread_id: auto_thread = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ("true", "1", "yes") diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index f8d90f281e58..dee7586d228e 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -207,6 +207,40 @@ async def test_require_mention_dm_always_responds(monkeypatch): adapter.handle_message.assert_awaited_once() +@pytest.mark.asyncio +async def test_dm_strips_mention(monkeypatch): + """DMs strip mention from body, matching Discord behavior.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room(member_count=2) + event = _make_event("@hermes:example.org help me") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.text == "help me" + + +@pytest.mark.asyncio +async def test_bare_mention_passes_empty_string(monkeypatch): + """A message that is only a mention should pass through as empty, not be dropped.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + room = _make_room() + event = _make_event("@hermes:example.org") + + await adapter._on_room_message(room, event) + adapter.handle_message.assert_awaited_once() + msg = adapter.handle_message.await_args.args[0] + assert msg.text == "" + + @pytest.mark.asyncio async def test_require_mention_free_response_room(monkeypatch): """Free-response rooms bypass mention requirement.""" From 55bbf8caba44cb6fd87a756b699448d3549d6ff3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:33:20 -0700 Subject: [PATCH 008/213] fix: include approval metadata in terminal tool results (#5141) When a dangerous command is approved (gateway, CLI, or smart approval), the terminal tool now includes an 'approval' field in the result JSON so the model knows approval was requested and granted. Previously the model only saw normal command output with no indication that approval happened, causing it to hallucinate that the approval system didn't fire. Changes: - approval.py: Return user_approved/description in all 3 approval paths (gateway blocking, CLI interactive, smart approval) - terminal_tool.py: Capture approval metadata and inject into both foreground and background command results --- tools/approval.py | 9 ++++++--- tools/terminal_tool.py | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/tools/approval.py b/tools/approval.py index ab2a10927f34..c47672b8bb37 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -724,7 +724,8 @@ def check_all_command_guards(command: str, env_type: str, logger.debug("Smart approval: auto-approved '%s' (%s)", command[:60], combined_desc_for_llm) return {"approved": True, "message": None, - "smart_approved": True} + "smart_approved": True, + "description": combined_desc_for_llm} elif verdict == "deny": combined_desc_for_llm = "; ".join(desc for _, desc, _ in warnings) return { @@ -819,7 +820,8 @@ def check_all_command_guards(command: str, env_type: str, approve_permanent(key) save_permanent_allowlist(_permanent_approved) - return {"approved": True, "message": None} + return {"approved": True, "message": None, + "user_approved": True, "description": combined_desc} # Fallback: no gateway callback registered (e.g. cron, batch). # Return approval_required for backward compat. @@ -865,4 +867,5 @@ def check_all_command_guards(command: str, env_type: str, approve_permanent(key) save_permanent_allowlist(_permanent_approved) - return {"approved": True, "message": None} + return {"approved": True, "message": None, + "user_approved": True, "description": combined_desc} diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 92581dbc4f2a..31d7d77c6733 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1058,6 +1058,7 @@ def terminal_tool( # Pre-exec security checks (tirith + dangerous command detection) # Skip check if force=True (user has confirmed they want to run it) + approval_note = None if not force: approval = _check_all_guards(command, env_type) if not approval["approved"]: @@ -1084,6 +1085,13 @@ def terminal_tool( "error": approval.get("message", fallback_msg), "status": "blocked" }, ensure_ascii=False) + # Track whether approval was explicitly granted by the user + if approval.get("user_approved"): + desc = approval.get("description", "flagged as dangerous") + approval_note = f"Command required approval ({desc}) and was approved by the user." + elif approval.get("smart_approved"): + desc = approval.get("description", "flagged as dangerous") + approval_note = f"Command was flagged ({desc}) and auto-approved by smart approval." # Prepare command for execution if background: @@ -1121,6 +1129,8 @@ def terminal_tool( "exit_code": 0, "error": None, } + if approval_note: + result_data["approval"] = approval_note # Transparent timeout clamping note max_timeout = effective_timeout @@ -1232,11 +1242,14 @@ def terminal_tool( from agent.redact import redact_sensitive_text output = redact_sensitive_text(output.strip()) if output else "" - return json.dumps({ + result_dict = { "output": output, "exit_code": returncode, "error": None - }, ensure_ascii=False) + } + if approval_note: + result_dict["approval"] = approval_note + return json.dumps(result_dict, ensure_ascii=False) except Exception as e: import traceback From 96e96a79ad10ab52f6fc80fca9f123707e808f76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:55:13 -0700 Subject: [PATCH 009/213] fix: --yolo and other flags silently dropped when placed before 'chat' subcommand (#5145) When --yolo, -w, -s, -r, -c, and --pass-session-id exist on both the parent parser and the 'chat' subparser with explicit defaults (default=False or default=None), argparse's subparser initialization overwrites the parent's parsed value. So 'hermes --yolo chat' silently drops --yolo, making it appear broken. Fix: use default=argparse.SUPPRESS on all duplicated arguments in the chat subparser. SUPPRESS means 'don't set this attribute if the user didn't explicitly provide it', so the parent parser's value survives through. Affected flags: --yolo, --worktree/-w, --skills/-s, --pass-session-id, --resume/-r, --continue/-c. Adds 15 regression tests covering flag-before-subcommand, flag-after-subcommand, no-subcommand, and env var propagation scenarios. --- hermes_cli/main.py | 11 +- .../test_argparse_flag_propagation.py | 172 ++++++++++++++++++ 2 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_cli/test_argparse_flag_propagation.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5150bfa1a7e7..3befd835cdfb 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4033,7 +4033,7 @@ def main(): chat_parser.add_argument( "-s", "--skills", action="append", - default=None, + default=argparse.SUPPRESS, help="Preload one or more skills for the session (repeat flag or comma-separate)" ) chat_parser.add_argument( @@ -4055,6 +4055,7 @@ def main(): chat_parser.add_argument( "--resume", "-r", metavar="SESSION_ID", + default=argparse.SUPPRESS, help="Resume a previous session by ID (shown on exit)" ) chat_parser.add_argument( @@ -4062,14 +4063,14 @@ def main(): dest="continue_last", nargs="?", const=True, - default=None, + default=argparse.SUPPRESS, metavar="SESSION_NAME", help="Resume a session by name, or the most recent if no name given" ) chat_parser.add_argument( "--worktree", "-w", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Run in an isolated git worktree (for parallel agents on the same repo)" ) chat_parser.add_argument( @@ -4088,13 +4089,13 @@ def main(): chat_parser.add_argument( "--yolo", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Bypass all dangerous command approval prompts (use at your own risk)" ) chat_parser.add_argument( "--pass-session-id", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Include the session ID in the agent's system prompt" ) chat_parser.add_argument( diff --git a/tests/hermes_cli/test_argparse_flag_propagation.py b/tests/hermes_cli/test_argparse_flag_propagation.py new file mode 100644 index 000000000000..388f3aef50ae --- /dev/null +++ b/tests/hermes_cli/test_argparse_flag_propagation.py @@ -0,0 +1,172 @@ +"""Tests for parent→subparser flag propagation. + +When flags like --yolo, -w, -s exist on both the parent parser and the 'chat' +subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat') +must not silently drop the flag value. + +Regression test for: argparse subparser default=False overwriting parent's +parsed True when the same argument is defined on both parsers. + +Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags, +so the subparser only sets the attribute when the user explicitly provides it. +""" + +import argparse +import os +import sys +from unittest.mock import patch + +import pytest + + +def _build_parser(): + """Build the hermes argument parser from the real code. + + We import the real main() and extract the parser it builds. + Since main() is a large function that does much more than parse args, + we replicate just the parser structure here to avoid side effects. + """ + parser = argparse.ArgumentParser(prog="hermes") + parser.add_argument("--resume", "-r", metavar="SESSION", default=None) + parser.add_argument( + "--continue", "-c", dest="continue_last", nargs="?", + const=True, default=None, metavar="SESSION_NAME", + ) + parser.add_argument("--worktree", "-w", action="store_true", default=False) + parser.add_argument("--skills", "-s", action="append", default=None) + parser.add_argument("--yolo", action="store_true", default=False) + parser.add_argument("--pass-session-id", action="store_true", default=False) + + subparsers = parser.add_subparsers(dest="command") + chat = subparsers.add_parser("chat") + # These MUST use argparse.SUPPRESS to avoid overwriting parent values + chat.add_argument("--yolo", action="store_true", + default=argparse.SUPPRESS) + chat.add_argument("--worktree", "-w", action="store_true", + default=argparse.SUPPRESS) + chat.add_argument("--skills", "-s", action="append", + default=argparse.SUPPRESS) + chat.add_argument("--pass-session-id", action="store_true", + default=argparse.SUPPRESS) + chat.add_argument("--resume", "-r", metavar="SESSION_ID", + default=argparse.SUPPRESS) + chat.add_argument( + "--continue", "-c", dest="continue_last", nargs="?", + const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME", + ) + return parser + + +class TestFlagBeforeSubcommand: + """Flags placed before 'chat' must propagate through.""" + + def test_yolo_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["--yolo", "chat"]) + assert getattr(args, "yolo", False) is True + + def test_worktree_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["-w", "chat"]) + assert getattr(args, "worktree", False) is True + + def test_skills_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["-s", "myskill", "chat"]) + assert getattr(args, "skills", None) == ["myskill"] + + def test_pass_session_id_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["--pass-session-id", "chat"]) + assert getattr(args, "pass_session_id", False) is True + + def test_resume_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["-r", "abc123", "chat"]) + assert getattr(args, "resume", None) == "abc123" + + +class TestFlagAfterSubcommand: + """Flags placed after 'chat' must still work.""" + + def test_yolo_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "--yolo"]) + assert getattr(args, "yolo", False) is True + + def test_worktree_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "-w"]) + assert getattr(args, "worktree", False) is True + + def test_skills_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "-s", "myskill"]) + assert getattr(args, "skills", None) == ["myskill"] + + def test_resume_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "-r", "abc123"]) + assert getattr(args, "resume", None) == "abc123" + + +class TestNoSubcommandDefaults: + """When no subcommand is given, flags must work and defaults must hold.""" + + def test_yolo_no_subcommand(self): + parser = _build_parser() + args = parser.parse_args(["--yolo"]) + assert args.yolo is True + assert args.command is None + + def test_defaults_no_flags(self): + parser = _build_parser() + args = parser.parse_args([]) + assert getattr(args, "yolo", False) is False + assert getattr(args, "worktree", False) is False + assert getattr(args, "skills", None) is None + assert getattr(args, "resume", None) is None + + def test_defaults_chat_no_flags(self): + parser = _build_parser() + args = parser.parse_args(["chat"]) + # With SUPPRESS, these fall through to parent defaults + assert getattr(args, "yolo", False) is False + assert getattr(args, "worktree", False) is False + assert getattr(args, "skills", None) is None + + +class TestYoloEnvVar: + """Verify --yolo sets HERMES_YOLO_MODE regardless of flag position. + + This tests the actual cmd_chat logic pattern (getattr → os.environ). + """ + + @pytest.fixture(autouse=True) + def _clean_env(self): + os.environ.pop("HERMES_YOLO_MODE", None) + yield + os.environ.pop("HERMES_YOLO_MODE", None) + + def _simulate_cmd_chat_yolo_check(self, args): + """Replicate the exact check from cmd_chat in main.py.""" + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" + + def test_yolo_before_chat_sets_env(self): + parser = _build_parser() + args = parser.parse_args(["--yolo", "chat"]) + self._simulate_cmd_chat_yolo_check(args) + assert os.environ.get("HERMES_YOLO_MODE") == "1" + + def test_yolo_after_chat_sets_env(self): + parser = _build_parser() + args = parser.parse_args(["chat", "--yolo"]) + self._simulate_cmd_chat_yolo_check(args) + assert os.environ.get("HERMES_YOLO_MODE") == "1" + + def test_no_yolo_no_env(self): + parser = _build_parser() + args = parser.parse_args(["chat"]) + self._simulate_cmd_chat_yolo_check(args) + assert os.environ.get("HERMES_YOLO_MODE") is None From 5879b3ef82c01e865601618403c8524ec74ce108 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:55:44 -0700 Subject: [PATCH 010/213] fix: move pre_llm_call plugin context to user message, preserve prompt cache (#5146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin context from pre_llm_call hooks was injected into the system prompt, breaking the prompt cache prefix every turn when content changed (typical for memory plugins). Now all plugin context goes into the current turn's user message — the system prompt stays identical across turns, preserving cached tokens. The system prompt is reserved for Hermes internals. Plugins contribute context alongside the user's input. Also adds comprehensive documentation for all 6 plugin hooks: pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, on_session_start, on_session_end — each with full callback signatures, parameter tables, firing conditions, and examples. Supersedes #5138 which identified the same cache-busting bug and proposed an uncached system suffix approach. This fix goes further by removing system prompt injection entirely. Co-identified-by: OutThisLife (PR #5138) --- hermes_cli/plugins.py | 14 +- run_agent.py | 46 ++- tests/test_plugins.py | 125 ++++++ website/docs/guides/build-a-hermes-plugin.md | 122 +++++- website/docs/user-guide/features/hooks.md | 391 +++++++++++++++++-- website/docs/user-guide/features/plugins.md | 12 +- 6 files changed, 653 insertions(+), 57 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 141923b0fc14..dfb0b584f2a2 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -441,8 +441,18 @@ def invoke_hook(self, hook_name: str, **kwargs: Any) -> List[Any]: plugin cannot break the core agent loop. Returns a list of non-``None`` return values from callbacks. - This allows hooks like ``pre_llm_call`` to contribute context - that the agent core can collect and inject. + + For ``pre_llm_call``, callbacks may return a dict describing + context to inject into the current turn's user message:: + + {"context": "recalled text..."} + "recalled text..." # plain string, equivalent + + Context is ALWAYS injected into the user message, never the + system prompt. This preserves the prompt cache prefix — the + system prompt stays identical across turns so cached tokens + are reused. All injected context is ephemeral — never + persisted to session DB. """ callbacks = self._hooks.get(hook_name, []) results: List[Any] = [] diff --git a/run_agent.py b/run_agent.py index 97f95d273a21..130e4abd63a6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6648,10 +6648,17 @@ def run_conversation( # Plugin hook: pre_llm_call # Fired once per turn before the tool-calling loop. Plugins can - # return a dict with a ``context`` key whose value is a string - # that will be appended to the ephemeral system prompt for every - # API call in this turn (not persisted to session DB or cache). - _plugin_turn_context = "" + # return a dict with a ``context`` key (or a plain string) whose + # value is appended to the current turn's user message. + # + # Context is ALWAYS injected into the user message, never the + # system prompt. This preserves the prompt cache prefix — the + # system prompt stays identical across turns so cached tokens + # are reused. The system prompt is Hermes's territory; plugins + # contribute context alongside the user's input. + # + # All injected context is ephemeral (not persisted to session DB). + _plugin_user_context = "" try: from hermes_cli.plugins import invoke_hook as _invoke_hook _pre_results = _invoke_hook( @@ -6663,14 +6670,14 @@ def run_conversation( model=self.model, platform=getattr(self, "platform", None) or "", ) - _ctx_parts = [] + _ctx_parts: list[str] = [] for r in _pre_results: if isinstance(r, dict) and r.get("context"): _ctx_parts.append(str(r["context"])) elif isinstance(r, str) and r.strip(): _ctx_parts.append(r) if _ctx_parts: - _plugin_turn_context = "\n\n".join(_ctx_parts) + _plugin_user_context = "\n\n".join(_ctx_parts) except Exception as exc: logger.warning("pre_llm_call hook failed: %s", exc) @@ -6758,11 +6765,21 @@ def run_conversation( for idx, msg in enumerate(messages): api_msg = msg.copy() - # External memory provider prefetch: inject cached recalled context - if idx == current_turn_user_idx and msg.get("role") == "user" and _ext_prefetch_cache: - _base = api_msg.get("content", "") - if isinstance(_base, str): - api_msg["content"] = _base + "\n\n" + _ext_prefetch_cache + # Inject ephemeral context into the current turn's user message. + # Sources: memory manager prefetch + plugin pre_llm_call hooks + # with target="user_message" (the default). Both are + # API-call-time only — the original message in `messages` is + # never mutated, so nothing leaks into session persistence. + if idx == current_turn_user_idx and msg.get("role") == "user": + _injections = [] + if _ext_prefetch_cache: + _injections.append(_ext_prefetch_cache) + if _plugin_user_context: + _injections.append(_plugin_user_context) + if _injections: + _base = api_msg.get("content", "") + if isinstance(_base, str): + api_msg["content"] = _base + "\n\n" + "\n\n".join(_injections) # For ALL assistant messages, pass reasoning back to the API # This ensures multi-turn reasoning context is preserved @@ -6796,9 +6813,10 @@ def run_conversation( effective_system = active_system_prompt or "" if self.ephemeral_system_prompt: effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip() - # Plugin context from pre_llm_call hooks — ephemeral, not cached. - if _plugin_turn_context: - effective_system = (effective_system + "\n\n" + _plugin_turn_context).strip() + # NOTE: Plugin context from pre_llm_call hooks is injected into the + # user message (see injection block above), NOT the system prompt. + # This is intentional — system prompt modifications break the prompt + # cache prefix. The system prompt is reserved for Hermes internals. if effective_system: api_messages = [{"role": "system", "content": effective_system}] + api_messages diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 0da5b640d720..cba1a777d3f5 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -403,6 +403,131 @@ def test_list_with_plugins(self, tmp_path, monkeypatch): +class TestPreLlmCallTargetRouting: + """Tests for pre_llm_call hook return format with target-aware routing. + + The routing logic lives in run_agent.py, but the return format is collected + by invoke_hook(). These tests verify the return format works correctly and + that downstream code can route based on the 'target' key. + """ + + def _make_pre_llm_plugin(self, plugins_dir, name, return_expr): + """Create a plugin that returns a specific value from pre_llm_call.""" + _make_plugin_dir( + plugins_dir, name, + register_body=( + f'ctx.register_hook("pre_llm_call", lambda **kw: {return_expr})' + ), + ) + + def test_context_dict_returned(self, tmp_path, monkeypatch): + """Plugin returning a context dict is collected by invoke_hook.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + self._make_pre_llm_plugin( + plugins_dir, "basic_plugin", + '{"context": "basic context"}', + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + results = mgr.invoke_hook( + "pre_llm_call", session_id="s1", user_message="hi", + conversation_history=[], is_first_turn=True, model="test", + ) + assert len(results) == 1 + assert results[0]["context"] == "basic context" + assert "target" not in results[0] + + def test_plain_string_return(self, tmp_path, monkeypatch): + """Plain string returns are collected as-is (routing treats them as user_message).""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + self._make_pre_llm_plugin( + plugins_dir, "str_plugin", + '"plain string context"', + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + results = mgr.invoke_hook( + "pre_llm_call", session_id="s1", user_message="hi", + conversation_history=[], is_first_turn=True, model="test", + ) + assert len(results) == 1 + assert results[0] == "plain string context" + + def test_multiple_plugins_context_collected(self, tmp_path, monkeypatch): + """Multiple plugins returning context are all collected.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + self._make_pre_llm_plugin( + plugins_dir, "aaa_memory", + '{"context": "memory context"}', + ) + self._make_pre_llm_plugin( + plugins_dir, "bbb_guardrail", + '{"context": "guardrail text"}', + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + results = mgr.invoke_hook( + "pre_llm_call", session_id="s1", user_message="hi", + conversation_history=[], is_first_turn=True, model="test", + ) + assert len(results) == 2 + contexts = [r["context"] for r in results] + assert "memory context" in contexts + assert "guardrail text" in contexts + + def test_routing_logic_all_to_user_message(self, tmp_path, monkeypatch): + """Simulate the routing logic from run_agent.py. + + All plugin context — dicts and plain strings — ends up in a single + user message context string. There is no system_prompt target. + """ + plugins_dir = tmp_path / "hermes_test" / "plugins" + self._make_pre_llm_plugin( + plugins_dir, "aaa_mem", + '{"context": "memory A"}', + ) + self._make_pre_llm_plugin( + plugins_dir, "bbb_guard", + '{"context": "rule B"}', + ) + self._make_pre_llm_plugin( + plugins_dir, "ccc_plain", + '"plain text C"', + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + results = mgr.invoke_hook( + "pre_llm_call", session_id="s1", user_message="hi", + conversation_history=[], is_first_turn=True, model="test", + ) + + # Replicate run_agent.py routing logic — everything goes to user msg + _ctx_parts = [] + for r in results: + if isinstance(r, dict) and r.get("context"): + _ctx_parts.append(str(r["context"])) + elif isinstance(r, str) and r.strip(): + _ctx_parts.append(r) + + assert _ctx_parts == ["memory A", "rule B", "plain text C"] + _plugin_user_context = "\n\n".join(_ctx_parts) + assert "memory A" in _plugin_user_context + assert "rule B" in _plugin_user_context + assert "plain text C" in _plugin_user_context + + # NOTE: TestPluginCommands removed – register_command() was never implemented # in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands, # commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index b3f6df959c5f..a4361fc7f254 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -362,24 +362,124 @@ ctx.register_tool( def register(ctx): ctx.register_hook("pre_tool_call", before_any_tool) ctx.register_hook("post_tool_call", after_any_tool) + ctx.register_hook("pre_llm_call", inject_memory) ctx.register_hook("on_session_start", on_new_session) ctx.register_hook("on_session_end", on_session_end) ``` -Available hooks: +### Hook reference -| Hook | When | Arguments | Return | -|------|------|-----------|--------| -| `pre_tool_call` | Before any tool runs | `tool_name`, `args`, `task_id` | — | -| `post_tool_call` | After any tool returns | `tool_name`, `args`, `result`, `task_id` | — | -| `pre_llm_call` | Once per turn, before the LLM loop | `session_id`, `user_message`, `conversation_history`, `is_first_turn`, `model`, `platform` | `{"context": "..."}` | -| `post_llm_call` | Once per turn, after the LLM loop | `session_id`, `user_message`, `assistant_response`, `conversation_history`, `model`, `platform` | — | -| `on_session_start` | New session created (first turn only) | `session_id`, `model`, `platform` | — | -| `on_session_end` | End of every `run_conversation` call | `session_id`, `completed`, `interrupted`, `model`, `platform` | — | +Each hook is documented in full on the **[Event Hooks reference](/docs/user-guide/features/hooks#plugin-hooks)** — callback signatures, parameter tables, exactly when each fires, and examples. Here's the summary: -Most hooks are fire-and-forget observers. The exception is `pre_llm_call`: if a callback returns a dict with a `"context"` key (or a plain string), the value is appended to the ephemeral system prompt for the current turn. This allows memory plugins to inject recalled context without touching core code. +| Hook | Fires when | Callback signature | Returns | +|------|-----------|-------------------|---------| +| [`pre_tool_call`](/docs/user-guide/features/hooks#pre_tool_call) | Before any tool executes | `tool_name: str, args: dict, task_id: str` | ignored | +| [`post_tool_call`](/docs/user-guide/features/hooks#post_tool_call) | After any tool returns | `tool_name: str, args: dict, result: str, task_id: str` | ignored | +| [`pre_llm_call`](/docs/user-guide/features/hooks#pre_llm_call) | Once per turn, before the tool-calling loop | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [context injection](#pre_llm_call-context-injection) | +| [`post_llm_call`](/docs/user-guide/features/hooks#post_llm_call) | Once per turn, after the tool-calling loop (successful turns only) | `session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str` | ignored | +| [`on_session_start`](/docs/user-guide/features/hooks#on_session_start) | New session created (first turn only) | `session_id: str, model: str, platform: str` | ignored | +| [`on_session_end`](/docs/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | ignored | -If a hook crashes, it's logged and skipped; other hooks and the agent continue normally. +Most hooks are fire-and-forget observers — their return values are ignored. The exception is `pre_llm_call`, which can inject context into the conversation. + +All callbacks should accept `**kwargs` for forward compatibility. If a hook callback crashes, it's logged and skipped. Other hooks and the agent continue normally. + +### `pre_llm_call` context injection + +This is the only hook whose return value matters. When a `pre_llm_call` callback returns a dict with a `"context"` key (or a plain string), Hermes injects that text into the **current turn's user message**. This is the mechanism for memory plugins, RAG integrations, guardrails, and any plugin that needs to provide the model with additional context. + +#### Return format + +```python +# Dict with context key +return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"} + +# Plain string (equivalent to the dict form above) +return "Recalled memories:\n- User prefers dark mode" + +# Return None or don't return → no injection (observer-only) +return None +``` + +Any non-None, non-empty return with a `"context"` key (or a plain non-empty string) is collected and appended to the user message for the current turn. + +#### How injection works + +Injected context is appended to the **user message**, not the system prompt. This is a deliberate design choice: + +- **Prompt cache preservation** — the system prompt stays identical across turns. Anthropic and OpenRouter cache the system prompt prefix, so keeping it stable saves 75%+ on input tokens in multi-turn conversations. If plugins modified the system prompt, every turn would be a cache miss. +- **Ephemeral** — the injection happens at API call time only. The original user message in the conversation history is never mutated, and nothing is persisted to the session database. +- **The system prompt is Hermes's territory** — it contains model-specific guidance, tool enforcement rules, personality instructions, and cached skill content. Plugins contribute context alongside the user's input, not by altering the agent's core instructions. + +#### Example: Memory recall plugin + +```python +"""Memory plugin — recalls relevant context from a vector store.""" + +import httpx + +MEMORY_API = "https://your-memory-api.example.com" + +def recall_context(session_id, user_message, is_first_turn, **kwargs): + """Called before each LLM turn. Returns recalled memories.""" + try: + resp = httpx.post(f"{MEMORY_API}/recall", json={ + "session_id": session_id, + "query": user_message, + }, timeout=3) + memories = resp.json().get("results", []) + if not memories: + return None # nothing to inject + + text = "Recalled context from previous sessions:\n" + text += "\n".join(f"- {m['text']}" for m in memories) + return {"context": text} + except Exception: + return None # fail silently, don't break the agent + +def register(ctx): + ctx.register_hook("pre_llm_call", recall_context) +``` + +#### Example: Guardrails plugin + +```python +"""Guardrails plugin — enforces content policies.""" + +POLICY = """You MUST follow these content policies for this session: +- Never generate code that accesses the filesystem outside the working directory +- Always warn before executing destructive operations +- Refuse requests involving personal data extraction""" + +def inject_guardrails(**kwargs): + """Injects policy text into every turn.""" + return {"context": POLICY} + +def register(ctx): + ctx.register_hook("pre_llm_call", inject_guardrails) +``` + +#### Example: Observer-only hook (no injection) + +```python +"""Analytics plugin — tracks turn metadata without injecting context.""" + +import logging +logger = logging.getLogger(__name__) + +def log_turn(session_id, user_message, model, is_first_turn, **kwargs): + """Fires before each LLM call. Returns None — no context injected.""" + logger.info("Turn: session=%s model=%s first=%s msg_len=%d", + session_id, model, is_first_turn, len(user_message or "")) + # No return → no injection + +def register(ctx): + ctx.register_hook("pre_llm_call", log_turn) +``` + +#### Multiple plugins returning context + +When multiple plugins return context from `pre_llm_call`, their outputs are joined with double newlines and appended to the user message together. The order follows plugin discovery order (alphabetical by plugin directory name). ### Distribute via pip diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index 87c7f984676b..c1c7ef05bf73 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -219,42 +219,385 @@ Gateway hooks only fire in the **gateway** (Telegram, Discord, Slack, WhatsApp). ```python def register(ctx): - ctx.register_hook("pre_tool_call", my_callback) - ctx.register_hook("post_tool_call", my_callback) + ctx.register_hook("pre_tool_call", my_tool_observer) + ctx.register_hook("post_tool_call", my_tool_logger) + ctx.register_hook("pre_llm_call", my_memory_callback) + ctx.register_hook("post_llm_call", my_sync_callback) + ctx.register_hook("on_session_start", my_init_callback) + ctx.register_hook("on_session_end", my_cleanup_callback) ``` -### Available Plugin Hooks +**General rules for all hooks:** -| Hook | Fires when | Callback receives | -|------|-----------|-------------------| -| `pre_tool_call` | Before any tool executes | `tool_name`, `args`, `task_id` | -| `post_tool_call` | After any tool returns | `tool_name`, `args`, `result`, `task_id` | -| `pre_llm_call` | Before LLM API request | `session_id`, `user_message`, `conversation_history`, `is_first_turn`, `model`, `platform` | -| `post_llm_call` | After LLM API response | `session_id`, `user_message`, `assistant_response`, `conversation_history`, `model`, `platform` | -| `on_session_start` | Session begins | `session_id`, `model`, `platform` | -| `on_session_end` | Session ends | `session_id`, `completed`, `interrupted`, `model`, `platform` | +- Callbacks receive **keyword arguments**. Always accept `**kwargs` for forward compatibility — new parameters may be added in future versions without breaking your plugin. +- If a callback **crashes**, it's logged and skipped. Other hooks and the agent continue normally. A misbehaving plugin can never break the agent. +- All hooks are **fire-and-forget observers** whose return values are ignored — except `pre_llm_call`, which can [inject context](#pre_llm_call). -Callbacks receive keyword arguments matching the columns above: +### Quick reference + +| Hook | Fires when | Returns | +|------|-----------|---------| +| [`pre_tool_call`](#pre_tool_call) | Before any tool executes | ignored | +| [`post_tool_call`](#post_tool_call) | After any tool returns | ignored | +| [`pre_llm_call`](#pre_llm_call) | Once per turn, before the tool-calling loop | context injection | +| [`post_llm_call`](#post_llm_call) | Once per turn, after the tool-calling loop | ignored | +| [`on_session_start`](#on_session_start) | New session created (first turn only) | ignored | +| [`on_session_end`](#on_session_end) | Session ends | ignored | + +--- + +### `pre_tool_call` + +Fires **immediately before** every tool execution — built-in tools and plugin tools alike. + +**Callback signature:** + +```python +def my_callback(tool_name: str, args: dict, task_id: str, **kwargs): +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `tool_name` | `str` | Name of the tool about to execute (e.g. `"terminal"`, `"web_search"`, `"read_file"`) | +| `args` | `dict` | The arguments the model passed to the tool | +| `task_id` | `str` | Session/task identifier. Empty string if not set. | + +**Fires:** In `model_tools.py`, inside `handle_function_call()`, before the tool's handler runs. Fires once per tool call — if the model calls 3 tools in parallel, this fires 3 times. + +**Return value:** Ignored. + +**Use cases:** Logging, audit trails, tool call counters, blocking dangerous operations (print a warning), rate limiting. + +**Example — tool call audit log:** ```python -def my_callback(**kwargs): - tool = kwargs["tool_name"] - args = kwargs["args"] - # ... +import json, logging +from datetime import datetime + +logger = logging.getLogger(__name__) + +def audit_tool_call(tool_name, args, task_id, **kwargs): + logger.info("TOOL_CALL session=%s tool=%s args=%s", + task_id, tool_name, json.dumps(args)[:200]) + +def register(ctx): + ctx.register_hook("pre_tool_call", audit_tool_call) ``` -### Example: Block Dangerous Tools +**Example — warn on dangerous tools:** ```python -# ~/.hermes/plugins/tool-guard/__init__.py -BLOCKED = {"terminal", "write_file"} +DANGEROUS = {"terminal", "write_file", "patch"} -def guard(**kwargs): - if kwargs["tool_name"] in BLOCKED: - print(f"⚠ Blocked tool call: {kwargs['tool_name']}") +def warn_dangerous(tool_name, **kwargs): + if tool_name in DANGEROUS: + print(f"⚠ Executing potentially dangerous tool: {tool_name}") def register(ctx): - ctx.register_hook("pre_tool_call", guard) + ctx.register_hook("pre_tool_call", warn_dangerous) ``` -See the **[Plugins guide](/docs/user-guide/features/plugins)** for full details on creating plugins. +--- + +### `post_tool_call` + +Fires **immediately after** every tool execution returns. + +**Callback signature:** + +```python +def my_callback(tool_name: str, args: dict, result: str, task_id: str, **kwargs): +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `tool_name` | `str` | Name of the tool that just executed | +| `args` | `dict` | The arguments the model passed to the tool | +| `result` | `str` | The tool's return value (always a JSON string) | +| `task_id` | `str` | Session/task identifier. Empty string if not set. | + +**Fires:** In `model_tools.py`, inside `handle_function_call()`, after the tool's handler returns. Fires once per tool call. Does **not** fire if the tool raised an unhandled exception (the error is caught and returned as an error JSON string instead, and `post_tool_call` fires with that error string as `result`). + +**Return value:** Ignored. + +**Use cases:** Logging tool results, metrics collection, tracking tool success/failure rates, sending notifications when specific tools complete. + +**Example — track tool usage metrics:** + +```python +from collections import Counter +import json + +_tool_counts = Counter() +_error_counts = Counter() + +def track_metrics(tool_name, result, **kwargs): + _tool_counts[tool_name] += 1 + try: + parsed = json.loads(result) + if "error" in parsed: + _error_counts[tool_name] += 1 + except (json.JSONDecodeError, TypeError): + pass + +def register(ctx): + ctx.register_hook("post_tool_call", track_metrics) +``` + +--- + +### `pre_llm_call` + +Fires **once per turn**, before the tool-calling loop begins. This is the **only hook whose return value is used** — it can inject context into the current turn's user message. + +**Callback signature:** + +```python +def my_callback(session_id: str, user_message: str, conversation_history: list, + is_first_turn: bool, model: str, platform: str, **kwargs): +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `session_id` | `str` | Unique identifier for the current session | +| `user_message` | `str` | The user's original message for this turn (before any skill injection) | +| `conversation_history` | `list` | Copy of the full message list (OpenAI format: `[{"role": "user", "content": "..."}]`) | +| `is_first_turn` | `bool` | `True` if this is the first turn of a new session, `False` on subsequent turns | +| `model` | `str` | The model identifier (e.g. `"anthropic/claude-sonnet-4.6"`) | +| `platform` | `str` | Where the session is running: `"cli"`, `"telegram"`, `"discord"`, etc. | + +**Fires:** In `run_agent.py`, inside `run_conversation()`, after context compression but before the main `while` loop. Fires once per `run_conversation()` call (i.e. once per user turn), not once per API call within the tool loop. + +**Return value:** If the callback returns a dict with a `"context"` key, or a plain non-empty string, the text is appended to the current turn's user message. Return `None` for no injection. + +```python +# Inject context +return {"context": "Recalled memories:\n- User likes Python\n- Working on hermes-agent"} + +# Plain string (equivalent) +return "Recalled memories:\n- User likes Python" + +# No injection +return None +``` + +**Where context is injected:** Always the **user message**, never the system prompt. This preserves the prompt cache — the system prompt stays identical across turns, so cached tokens are reused. The system prompt is Hermes's territory (model guidance, tool enforcement, personality, skills). Plugins contribute context alongside the user's input. + +All injected context is **ephemeral** — added at API call time only. The original user message in the conversation history is never mutated, and nothing is persisted to the session database. + +When **multiple plugins** return context, their outputs are joined with double newlines in plugin discovery order (alphabetical by directory name). + +**Use cases:** Memory recall, RAG context injection, guardrails, per-turn analytics. + +**Example — memory recall:** + +```python +import httpx + +MEMORY_API = "https://your-memory-api.example.com" + +def recall(session_id, user_message, is_first_turn, **kwargs): + try: + resp = httpx.post(f"{MEMORY_API}/recall", json={ + "session_id": session_id, + "query": user_message, + }, timeout=3) + memories = resp.json().get("results", []) + if not memories: + return None + text = "Recalled context:\n" + "\n".join(f"- {m['text']}" for m in memories) + return {"context": text} + except Exception: + return None + +def register(ctx): + ctx.register_hook("pre_llm_call", recall) +``` + +**Example — guardrails:** + +```python +POLICY = "Never execute commands that delete files without explicit user confirmation." + +def guardrails(**kwargs): + return {"context": POLICY} + +def register(ctx): + ctx.register_hook("pre_llm_call", guardrails) +``` + +--- + +### `post_llm_call` + +Fires **once per turn**, after the tool-calling loop completes and the agent has produced a final response. Only fires on **successful** turns — does not fire if the turn was interrupted. + +**Callback signature:** + +```python +def my_callback(session_id: str, user_message: str, assistant_response: str, + conversation_history: list, model: str, platform: str, **kwargs): +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `session_id` | `str` | Unique identifier for the current session | +| `user_message` | `str` | The user's original message for this turn | +| `assistant_response` | `str` | The agent's final text response for this turn | +| `conversation_history` | `list` | Copy of the full message list after the turn completed | +| `model` | `str` | The model identifier | +| `platform` | `str` | Where the session is running | + +**Fires:** In `run_agent.py`, inside `run_conversation()`, after the tool loop exits with a final response. Guarded by `if final_response and not interrupted` — so it does **not** fire when the user interrupts mid-turn or the agent hits the iteration limit without producing a response. + +**Return value:** Ignored. + +**Use cases:** Syncing conversation data to an external memory system, computing response quality metrics, logging turn summaries, triggering follow-up actions. + +**Example — sync to external memory:** + +```python +import httpx + +MEMORY_API = "https://your-memory-api.example.com" + +def sync_memory(session_id, user_message, assistant_response, **kwargs): + try: + httpx.post(f"{MEMORY_API}/store", json={ + "session_id": session_id, + "user": user_message, + "assistant": assistant_response, + }, timeout=5) + except Exception: + pass # best-effort + +def register(ctx): + ctx.register_hook("post_llm_call", sync_memory) +``` + +**Example — track response lengths:** + +```python +import logging +logger = logging.getLogger(__name__) + +def log_response_length(session_id, assistant_response, model, **kwargs): + logger.info("RESPONSE session=%s model=%s chars=%d", + session_id, model, len(assistant_response or "")) + +def register(ctx): + ctx.register_hook("post_llm_call", log_response_length) +``` + +--- + +### `on_session_start` + +Fires **once** when a brand-new session is created. Does **not** fire on session continuation (when the user sends a second message in an existing session). + +**Callback signature:** + +```python +def my_callback(session_id: str, model: str, platform: str, **kwargs): +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `session_id` | `str` | Unique identifier for the new session | +| `model` | `str` | The model identifier | +| `platform` | `str` | Where the session is running | + +**Fires:** In `run_agent.py`, inside `run_conversation()`, during the first turn of a new session — specifically after the system prompt is built but before the tool loop starts. The check is `if not conversation_history` (no prior messages = new session). + +**Return value:** Ignored. + +**Use cases:** Initializing session-scoped state, warming caches, registering the session with an external service, logging session starts. + +**Example — initialize a session cache:** + +```python +_session_caches = {} + +def init_session(session_id, model, platform, **kwargs): + _session_caches[session_id] = { + "model": model, + "platform": platform, + "tool_calls": 0, + "started": __import__("datetime").datetime.now().isoformat(), + } + +def register(ctx): + ctx.register_hook("on_session_start", init_session) +``` + +--- + +### `on_session_end` + +Fires at the **very end** of every `run_conversation()` call, regardless of outcome. Also fires from the CLI's exit handler if the agent was mid-turn when the user quit. + +**Callback signature:** + +```python +def my_callback(session_id: str, completed: bool, interrupted: bool, + model: str, platform: str, **kwargs): +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `session_id` | `str` | Unique identifier for the session | +| `completed` | `bool` | `True` if the agent produced a final response, `False` otherwise | +| `interrupted` | `bool` | `True` if the turn was interrupted (user sent new message, `/stop`, or quit) | +| `model` | `str` | The model identifier | +| `platform` | `str` | Where the session is running | + +**Fires:** In two places: +1. **`run_agent.py`** — at the end of every `run_conversation()` call, after all cleanup. Always fires, even if the turn errored. +2. **`cli.py`** — in the CLI's atexit handler, but **only** if the agent was mid-turn (`_agent_running=True`) when the exit occurred. This catches Ctrl+C and `/exit` during processing. In this case, `completed=False` and `interrupted=True`. + +**Return value:** Ignored. + +**Use cases:** Flushing buffers, closing connections, persisting session state, logging session duration, cleanup of resources initialized in `on_session_start`. + +**Example — flush and cleanup:** + +```python +_session_caches = {} + +def cleanup_session(session_id, completed, interrupted, **kwargs): + cache = _session_caches.pop(session_id, None) + if cache: + # Flush accumulated data to disk or external service + status = "completed" if completed else ("interrupted" if interrupted else "failed") + print(f"Session {session_id} ended: {status}, {cache['tool_calls']} tool calls") + +def register(ctx): + ctx.register_hook("on_session_end", cleanup_session) +``` + +**Example — session duration tracking:** + +```python +import time, logging +logger = logging.getLogger(__name__) + +_start_times = {} + +def on_start(session_id, **kwargs): + _start_times[session_id] = time.time() + +def on_end(session_id, completed, interrupted, **kwargs): + start = _start_times.pop(session_id, None) + if start: + duration = time.time() - start + logger.info("SESSION_DURATION session=%s seconds=%.1f completed=%s interrupted=%s", + session_id, duration, completed, interrupted) + +def register(ctx): + ctx.register_hook("on_session_start", on_start) + ctx.register_hook("on_session_end", on_end) +``` + +--- + +See the **[Build a Plugin guide](/docs/guides/build-a-hermes-plugin)** for the full walkthrough including tool schemas, handlers, and advanced hook patterns. diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 1b10faff74a5..6855efa979bc 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -103,12 +103,12 @@ Plugins can register callbacks for these lifecycle events. See the **[Event Hook | Hook | Fires when | |------|-----------| -| `pre_tool_call` | Before any tool executes | -| `post_tool_call` | After any tool returns | -| `pre_llm_call` | Once per turn, before the LLM loop — can return `{"context": "..."}` to inject into the system prompt | -| `post_llm_call` | Once per turn, after the LLM loop completes | -| `on_session_start` | New session created (first turn only) | -| `on_session_end` | End of every `run_conversation` call | +| [`pre_tool_call`](/docs/user-guide/features/hooks#pre_tool_call) | Before any tool executes | +| [`post_tool_call`](/docs/user-guide/features/hooks#post_tool_call) | After any tool returns | +| [`pre_llm_call`](/docs/user-guide/features/hooks#pre_llm_call) | Once per turn, before the LLM loop — can return `{"context": "..."}` to [inject context into the user message](/docs/user-guide/features/hooks#pre_llm_call) | +| [`post_llm_call`](/docs/user-guide/features/hooks#post_llm_call) | Once per turn, after the LLM loop (successful turns only) | +| [`on_session_start`](/docs/user-guide/features/hooks#on_session_start) | New session created (first turn only) | +| [`on_session_end`](/docs/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit handler | ## Managing plugins From aa475aef315f51a61f2887f24e9befd0657304cc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:57:24 -0700 Subject: [PATCH 011/213] feat: add exit code context for common CLI tools in terminal results (#5144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When commands like grep, diff, test, or find return non-zero exit codes that aren't actual errors (grep 1 = no matches, diff 1 = files differ), the model wastes turns investigating non-problems. This adds an exit_code_meaning field to the terminal JSON result that explains informational exit codes, so the agent can move on instead of debugging. Covers grep/rg/ag/ack (no matches), diff (files differ), find (partial access), test/[ (condition false), curl (timeouts, DNS, HTTP errors), and git (context-dependent). Correctly extracts the last command from pipelines and chains, strips full paths and env var assignments. The exit_code field itself is unchanged — this is purely additive context. --- tests/tools/test_terminal_exit_semantics.py | 152 ++++++++++++++++++++ tools/terminal_tool.py | 82 ++++++++++- 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_terminal_exit_semantics.py diff --git a/tests/tools/test_terminal_exit_semantics.py b/tests/tools/test_terminal_exit_semantics.py new file mode 100644 index 000000000000..f375f6f2e1ff --- /dev/null +++ b/tests/tools/test_terminal_exit_semantics.py @@ -0,0 +1,152 @@ +"""Tests for terminal command exit code semantic interpretation.""" + +import pytest + +from tools.terminal_tool import _interpret_exit_code + + +class TestInterpretExitCode: + """Test _interpret_exit_code returns correct notes for known command semantics.""" + + # ---- exit code 0 always returns None ---- + + def test_success_returns_none(self): + assert _interpret_exit_code("grep foo bar", 0) is None + assert _interpret_exit_code("diff a b", 0) is None + assert _interpret_exit_code("test -f /etc/passwd", 0) is None + + # ---- grep / rg family: exit 1 = no matches ---- + + @pytest.mark.parametrize("cmd", [ + "grep 'pattern' file.txt", + "egrep 'pattern' file.txt", + "fgrep 'pattern' file.txt", + "rg 'foo' .", + "ag 'foo' .", + "ack 'foo' .", + ]) + def test_grep_family_no_matches(self, cmd): + result = _interpret_exit_code(cmd, 1) + assert result is not None + assert "no matches" in result.lower() + + def test_grep_real_error_no_note(self): + """grep exit 2+ is a real error — should return None.""" + assert _interpret_exit_code("grep 'foo' bar", 2) is None + assert _interpret_exit_code("rg 'foo' .", 2) is None + + # ---- diff: exit 1 = files differ ---- + + def test_diff_files_differ(self): + result = _interpret_exit_code("diff file1 file2", 1) + assert result is not None + assert "differ" in result.lower() + + def test_colordiff_files_differ(self): + result = _interpret_exit_code("colordiff file1 file2", 1) + assert result is not None + assert "differ" in result.lower() + + def test_diff_real_error_no_note(self): + assert _interpret_exit_code("diff a b", 2) is None + + # ---- test / [: exit 1 = condition false ---- + + def test_test_condition_false(self): + result = _interpret_exit_code("test -f /nonexistent", 1) + assert result is not None + assert "false" in result.lower() + + def test_bracket_condition_false(self): + result = _interpret_exit_code("[ -f /nonexistent ]", 1) + assert result is not None + assert "false" in result.lower() + + # ---- find: exit 1 = partial success ---- + + def test_find_partial_success(self): + result = _interpret_exit_code("find . -name '*.py'", 1) + assert result is not None + assert "inaccessible" in result.lower() + + # ---- curl: various informational codes ---- + + def test_curl_timeout(self): + result = _interpret_exit_code("curl https://example.com", 28) + assert result is not None + assert "timed out" in result.lower() + + def test_curl_connection_refused(self): + result = _interpret_exit_code("curl http://localhost:99999", 7) + assert result is not None + assert "connect" in result.lower() + + # ---- git: exit 1 is context-dependent ---- + + def test_git_diff_exit_1(self): + result = _interpret_exit_code("git diff HEAD~1", 1) + assert result is not None + assert "normal" in result.lower() + + # ---- pipeline / chain handling ---- + + def test_pipeline_last_command(self): + """In a pipeline, the last command determines the exit code.""" + result = _interpret_exit_code("ls -la | grep 'pattern'", 1) + assert result is not None + assert "no matches" in result.lower() + + def test_and_chain_last_command(self): + result = _interpret_exit_code("cd /tmp && grep foo bar", 1) + assert result is not None + assert "no matches" in result.lower() + + def test_semicolon_chain_last_command(self): + result = _interpret_exit_code("cat file; diff a b", 1) + assert result is not None + assert "differ" in result.lower() + + def test_or_chain_last_command(self): + result = _interpret_exit_code("false || grep foo bar", 1) + assert result is not None + assert "no matches" in result.lower() + + # ---- full paths ---- + + def test_full_path_command(self): + result = _interpret_exit_code("/usr/bin/grep 'foo' bar", 1) + assert result is not None + assert "no matches" in result.lower() + + # ---- env var prefix ---- + + def test_env_var_prefix_stripped(self): + result = _interpret_exit_code("LANG=C grep 'foo' bar", 1) + assert result is not None + assert "no matches" in result.lower() + + def test_multiple_env_vars(self): + result = _interpret_exit_code("FOO=1 BAR=2 grep 'foo' bar", 1) + assert result is not None + assert "no matches" in result.lower() + + # ---- unknown commands return None ---- + + @pytest.mark.parametrize("cmd", [ + "python3 script.py", + "rm -rf /tmp/test", + "npm test", + "make build", + "cargo build", + ]) + def test_unknown_commands_return_none(self, cmd): + assert _interpret_exit_code(cmd, 1) is None + + # ---- edge cases ---- + + def test_empty_command(self): + assert _interpret_exit_code("", 1) is None + + def test_only_env_vars(self): + """Command with only env var assignments, no actual command.""" + assert _interpret_exit_code("FOO=bar", 1) is None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 31d7d77c6733..26591ceedb25 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -35,6 +35,7 @@ import logging import os import platform +import re import time import threading import atexit @@ -899,6 +900,78 @@ def _atexit_cleanup(): atexit.register(_atexit_cleanup) +# ============================================================================= +# Exit Code Context for Common CLI Tools +# ============================================================================= +# Many Unix commands use non-zero exit codes for informational purposes, not +# to indicate failure. The model sees a raw exit_code=1 from `grep` and +# wastes a turn investigating something that just means "no matches". +# This lookup adds a human-readable note so the agent can move on. + +def _interpret_exit_code(command: str, exit_code: int) -> str | None: + """Return a human-readable note when a non-zero exit code is non-erroneous. + + Returns None when the exit code is 0 or genuinely signals an error. + The note is appended to the tool result so the model doesn't waste + turns investigating expected exit codes. + """ + if exit_code == 0: + return None + + # Extract the last command in a pipeline/chain — that determines the + # exit code. Handles `cmd1 && cmd2`, `cmd1 | cmd2`, `cmd1; cmd2`. + # Deliberately simple: split on shell operators and take the last piece. + segments = re.split(r'\s*(?:\|\||&&|[|;])\s*', command) + last_segment = (segments[-1] if segments else command).strip() + + # Get base command name (first word), stripping env var assignments + # like VAR=val cmd ... + words = last_segment.split() + base_cmd = "" + for w in words: + if "=" in w and not w.startswith("-"): + continue # skip VAR=val + base_cmd = w.split("/")[-1] # handle /usr/bin/grep -> grep + break + + if not base_cmd: + return None + + # Command-specific semantics + semantics: dict[str, dict[int, str]] = { + # grep/rg/ag/ack: 1=no matches found (normal), 2+=real error + "grep": {1: "No matches found (not an error)"}, + "egrep": {1: "No matches found (not an error)"}, + "fgrep": {1: "No matches found (not an error)"}, + "rg": {1: "No matches found (not an error)"}, + "ag": {1: "No matches found (not an error)"}, + "ack": {1: "No matches found (not an error)"}, + # diff: 1=files differ (expected), 2+=real error + "diff": {1: "Files differ (expected, not an error)"}, + "colordiff": {1: "Files differ (expected, not an error)"}, + # find: 1=some dirs inaccessible but results may still be valid + "find": {1: "Some directories were inaccessible (partial results may still be valid)"}, + # test/[: 1=condition is false (expected) + "test": {1: "Condition evaluated to false (expected, not an error)"}, + "[": {1: "Condition evaluated to false (expected, not an error)"}, + # curl: common non-error codes + "curl": { + 6: "Could not resolve host", + 7: "Failed to connect to host", + 22: "HTTP response code indicated error (e.g. 404, 500)", + 28: "Operation timed out", + }, + # git: 1 is context-dependent but often normal (e.g. git diff with changes) + "git": {1: "Non-zero exit (often normal — e.g. 'git diff' returns 1 when files differ)"}, + } + + cmd_semantics = semantics.get(base_cmd) + if cmd_semantics and exit_code in cmd_semantics: + return cmd_semantics[exit_code] + + return None + + def terminal_tool( command: str, background: bool = False, @@ -1242,13 +1315,20 @@ def terminal_tool( from agent.redact import redact_sensitive_text output = redact_sensitive_text(output.strip()) if output else "" + # Interpret non-zero exit codes that aren't real errors + # (e.g. grep=1 means "no matches", diff=1 means "files differ") + exit_note = _interpret_exit_code(command, returncode) + result_dict = { "output": output, "exit_code": returncode, - "error": None + "error": None, } if approval_note: result_dict["approval"] = approval_note + if exit_note: + result_dict["exit_code_meaning"] = exit_note + return json.dumps(result_dict, ensure_ascii=False) except Exception as e: From 931624feda96be2c2a8bdd0ea48c6c5d2f3db87b Mon Sep 17 00:00:00 2001 From: memosr Date: Sat, 4 Apr 2026 16:58:15 -0700 Subject: [PATCH 012/213] fix(security): guard cron script against path traversal and redact output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relative script paths resolved against HERMES_HOME/scripts/ were not validated to stay within that directory. Paths like '../../etc/passwd' could escape and be executed as Python. Fix: resolve the path and verify it stays within scripts_dir using Path.relative_to(). Also apply redact_sensitive_text() to script stdout before LLM injection — same pattern as execute_code sandbox output. Cherry-picked from PR #5093 by memosr (fixes 1 and 3; absolute path restriction dropped as too restrictive for the feature's design intent). --- cron/scheduler.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index b014799837e3..8b977f422c98 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -248,7 +248,13 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: path = Path(script_path).expanduser() if not path.is_absolute(): # Resolve relative paths against HERMES_HOME/scripts/ - path = get_hermes_home() / "scripts" / path + scripts_dir = get_hermes_home() / "scripts" + path = (scripts_dir / path).resolve() + # Guard against path traversal (e.g. "../../etc/passwd") + try: + path.relative_to(scripts_dir.resolve()) + except ValueError: + return False, f"Script path escapes the scripts directory: {script_path!r}" if not path.exists(): return False, f"Script not found: {path}" @@ -274,6 +280,13 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: parts.append(f"stdout:\n{stdout}") return False, "\n".join(parts) + # Redact any secrets that may appear in script output before + # they are injected into the LLM prompt context. + try: + from agent.redact import redact_sensitive_text + stdout = redact_sensitive_text(stdout) + except Exception: + pass return True, stdout except subprocess.TimeoutExpired: From ff544526cd37e19e1512752fdc660eacb8a454d2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:00:50 -0700 Subject: [PATCH 013/213] docs(skill): comprehensive claude-code skill rewrite v2.0 (#5155) Major rewrite of the claude-code orchestration skill from 94 to 460 lines. Based on official docs research, community guides, and live experimentation. Key additions: - Two orchestration modes: Print mode (-p) vs Interactive PTY via tmux - Detailed PTY dialog handling (trust + permissions bypass patterns) - Print mode deep dive: JSON output, piped input, session resumption, --json-schema, --bare mode for CI - Complete flag reference (20+ flags organized by category) - Interactive session patterns with tmux send-keys/capture-pane - Claude's slash commands and keyboard shortcuts reference - CLAUDE.md, hooks, custom subagents, MCP, custom commands docs - Cost/performance tips (effort levels, budget caps, context mgmt) - 10 specific pitfalls discovered through live testing - 10 rules for Hermes agents orchestrating Claude Code --- .../autonomous-ai-agents/claude-code/SKILL.md | 460 ++++++++++++++++-- 1 file changed, 413 insertions(+), 47 deletions(-) diff --git a/skills/autonomous-ai-agents/claude-code/SKILL.md b/skills/autonomous-ai-agents/claude-code/SKILL.md index 5c8d6e17f4bb..6717a389b2f2 100644 --- a/skills/autonomous-ai-agents/claude-code/SKILL.md +++ b/skills/autonomous-ai-agents/claude-code/SKILL.md @@ -1,94 +1,460 @@ --- name: claude-code description: Delegate coding tasks to Claude Code (Anthropic's CLI agent). Use for building features, refactoring, PR reviews, and iterative coding. Requires the claude CLI installed. -version: 1.0.0 -author: Hermes Agent +version: 2.0.0 +author: Hermes Agent + Teknium license: MIT metadata: hermes: - tags: [Coding-Agent, Claude, Anthropic, Code-Review, Refactoring] - related_skills: [codex, hermes-agent] + tags: [Coding-Agent, Claude, Anthropic, Code-Review, Refactoring, PTY, Automation] + related_skills: [codex, hermes-agent, opencode] --- -# Claude Code +# Claude Code — Hermes Orchestration Guide -Delegate coding tasks to [Claude Code](https://docs.anthropic.com/en/docs/claude-code) via the Hermes terminal. Claude Code is Anthropic's autonomous coding agent CLI. +Delegate coding tasks to [Claude Code](https://code.claude.com/docs/en/cli-reference) (Anthropic's autonomous coding agent CLI) via the Hermes terminal. Claude Code v2.x can read files, write code, run shell commands, spawn subagents, and manage git workflows autonomously. ## Prerequisites -- Claude Code installed: `npm install -g @anthropic-ai/claude-code` -- Authenticated: run `claude` once to log in -- Use `pty=true` in terminal calls — Claude Code is an interactive terminal app +- **Install:** `npm install -g @anthropic-ai/claude-code` +- **Auth:** run `claude` once to log in (browser OAuth for Pro/Max, or set `ANTHROPIC_API_KEY`) +- **Version check:** `claude --version` (requires v2.x+) -## One-Shot Tasks +## Two Orchestration Modes + +Hermes interacts with Claude Code in two fundamentally different ways. Choose based on the task. + +### Mode 1: Print Mode (`-p`) — Non-Interactive (PREFERRED for most tasks) + +Print mode runs a one-shot task, returns the result, and exits. No PTY needed. No interactive prompts. This is the cleanest integration path. ``` -terminal(command="claude 'Add error handling to the API calls'", workdir="/path/to/project", pty=true) +terminal(command="claude -p 'Add error handling to all API calls in src/' --allowedTools 'Read,Edit' --max-turns 10", workdir="/path/to/project", timeout=120) ``` -For quick scratch work: +**When to use print mode:** +- One-shot coding tasks (fix a bug, add a feature, refactor) +- CI/CD automation and scripting +- Structured data extraction with `--json-schema` +- Piped input processing (`cat file | claude -p "analyze this"`) +- Any task where you don't need multi-turn conversation + +**Print mode skips ALL interactive dialogs** — no workspace trust prompt, no permission confirmations. This makes it ideal for automation. + +### Mode 2: Interactive PTY via tmux — Multi-Turn Sessions + +Interactive mode gives you a full conversational REPL where you can send follow-up prompts, use slash commands, and watch Claude work in real time. **Requires tmux orchestration.** + ``` -terminal(command="cd $(mktemp -d) && git init && claude 'Build a REST API for todos'", pty=true) +# Start a tmux session +terminal(command="tmux new-session -d -s claude-work -x 140 -y 40") + +# Launch Claude Code inside it +terminal(command="tmux send-keys -t claude-work 'cd /path/to/project && claude' Enter") + +# Wait for startup, then send your task +# (after ~3-5 seconds for the welcome screen) +terminal(command="sleep 5 && tmux send-keys -t claude-work 'Refactor the auth module to use JWT tokens' Enter") + +# Monitor progress by capturing the pane +terminal(command="sleep 15 && tmux capture-pane -t claude-work -p -S -50") + +# Send follow-up tasks +terminal(command="tmux send-keys -t claude-work 'Now add unit tests for the new JWT code' Enter") + +# Exit when done +terminal(command="tmux send-keys -t claude-work '/exit' Enter") ``` -## Background Mode (Long Tasks) +**When to use interactive mode:** +- Multi-turn iterative work (refactor → review → fix → test cycle) +- Tasks requiring human-in-the-loop decisions +- Exploratory coding sessions +- When you need to use Claude's slash commands (`/compact`, `/review`, `/model`) + +## PTY Dialog Handling (CRITICAL for Interactive Mode) + +Claude Code presents up to two confirmation dialogs on first launch. You MUST handle these via tmux send-keys: -For tasks that take minutes, use background mode so you can monitor progress: +### Dialog 1: Workspace Trust (first visit to a directory) +``` +❯ 1. Yes, I trust this folder ← DEFAULT (just press Enter) + 2. No, exit +``` +**Handling:** `tmux send-keys -t Enter` — default selection is correct. + +### Dialog 2: Bypass Permissions Warning (only with --dangerously-skip-permissions) +``` +❯ 1. No, exit ← DEFAULT (WRONG choice!) + 2. Yes, I accept +``` +**Handling:** Must navigate DOWN first, then Enter: +``` +tmux send-keys -t Down && sleep 0.3 && tmux send-keys -t Enter +``` +### Robust Dialog Handling Pattern ``` -# Start in background with PTY -terminal(command="claude 'Refactor the auth module to use JWT'", workdir="~/project", background=true, pty=true) -# Returns session_id +# Launch with permissions bypass +terminal(command="tmux send-keys -t claude-work 'claude --dangerously-skip-permissions \"your task\"' Enter") -# Monitor progress -process(action="poll", session_id="") -process(action="log", session_id="") +# Handle trust dialog (Enter for default "Yes") +terminal(command="sleep 4 && tmux send-keys -t claude-work Enter") -# Send input if Claude asks a question -process(action="submit", session_id="", data="yes") +# Handle permissions dialog (Down then Enter for "Yes, I accept") +terminal(command="sleep 3 && tmux send-keys -t claude-work Down && sleep 0.3 && tmux send-keys -t claude-work Enter") -# Kill if needed -process(action="kill", session_id="") +# Now wait for Claude to work +terminal(command="sleep 15 && tmux capture-pane -t claude-work -p -S -60") ``` -## PR Reviews +**Note:** After the first trust acceptance for a directory, the trust dialog won't appear again. Only the permissions dialog recurs each time you use `--dangerously-skip-permissions`. -Clone to a temp directory to avoid modifying the working tree: +## Print Mode Deep Dive +### Structured JSON Output ``` -terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && claude 'Review this PR against main. Check for bugs, security issues, and style.'", pty=true) +terminal(command="claude -p 'Analyze auth.py for security issues' --output-format json --max-turns 5", workdir="/project", timeout=120) ``` -Or use git worktrees: +Returns a JSON object with: +```json +{ + "type": "result", + "subtype": "success", + "result": "The analysis text...", + "session_id": "75e2167f-...", + "num_turns": 3, + "total_cost_usd": 0.0787, + "duration_ms": 10276, + "stop_reason": "end_turn", + "terminal_reason": "completed", + "usage": { "input_tokens": 5, "output_tokens": 603, ... } +} ``` -terminal(command="git worktree add /tmp/pr-42 pr-42-branch", workdir="~/project") -terminal(command="claude 'Review the changes in this branch vs main'", workdir="/tmp/pr-42", pty=true) + +Use `session_id` to resume later. `num_turns` shows how many agentic loops it took. `total_cost_usd` tracks spend. + +### Piped Input ``` +# Pipe a file for analysis +terminal(command="cat src/auth.py | claude -p 'Review this code for bugs' --max-turns 1", timeout=60) -## Parallel Work +# Pipe multiple files +terminal(command="cat src/*.py | claude -p 'Find all TODO comments' --max-turns 1", timeout=60) -Spawn multiple Claude Code instances for independent tasks: +# Pipe command output +terminal(command="git diff HEAD~3 | claude -p 'Summarize these changes' --max-turns 1", timeout=60) +``` +### JSON Schema for Structured Extraction +``` +terminal(command="claude -p 'List all functions in src/' --output-format json --json-schema '{\"type\":\"object\",\"properties\":{\"functions\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"functions\"]}' --max-turns 5", workdir="/project", timeout=90) ``` -terminal(command="claude 'Fix the login bug'", workdir="/tmp/issue-1", background=true, pty=true) -terminal(command="claude 'Add unit tests for auth'", workdir="/tmp/issue-2", background=true, pty=true) -# Monitor all -process(action="list") +Parse `structured_output` from the JSON result. + +### Session Continuation ``` +# Start a task +terminal(command="claude -p 'Start refactoring the database layer' --output-format json --max-turns 10 > /tmp/session.json", workdir="/project", timeout=180) -## Key Flags +# Resume with session ID +terminal(command="claude -p 'Continue and add connection pooling' --resume $(cat /tmp/session.json | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"session_id\"])') --max-turns 5", workdir="/project", timeout=120) +# Or resume the most recent session in the same directory +terminal(command="claude -p 'What did you do last time?' --continue --max-turns 1", workdir="/project", timeout=30) +``` + +### Bare Mode for CI/Scripting +``` +terminal(command="claude --bare -p 'Run all tests and report failures' --allowedTools 'Read,Bash' --max-turns 10", workdir="/project", timeout=180) +``` + +`--bare` skips hooks, plugins, MCP discovery, and CLAUDE.md loading. Fastest startup. Requires `ANTHROPIC_API_KEY` (skips OAuth). + +## Key Flags Reference + +### Essential Flags +| Flag | Effect | Mode | +|------|--------|------| +| `-p, --print` | Non-interactive one-shot mode | Both | +| `-c, --continue` | Resume most recent conversation | Both | +| `-r, --resume ` | Resume specific session by ID | Both | +| `--model ` | Model selection: `sonnet`, `opus`, `haiku`, or full name | Both | +| `--effort ` | Reasoning depth: `low`, `medium`, `high`, `max` | Both | +| `--max-turns ` | Limit agentic loops (prevents runaway) | Print only | +| `--max-budget-usd ` | Cap API spend in dollars | Print only | + +### Permission & Safety Flags | Flag | Effect | |------|--------| -| `claude 'prompt'` | One-shot task, exits when done | -| `claude --dangerously-skip-permissions` | Auto-approve all file changes | -| `claude --model ` | Use a specific model | +| `--dangerously-skip-permissions` | Auto-approve ALL tool use (file writes, bash, etc.) | +| `--permission-mode ` | `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions` | +| `--allowedTools ` | Whitelist specific tools: `"Read,Edit,Bash"` | +| `--disallowedTools ` | Blacklist specific tools | + +### Output & Integration Flags +| Flag | Effect | +|------|--------| +| `--output-format ` | `text` (default), `json` (structured), `stream-json` (streaming) | +| `--json-schema ` | Force structured JSON output matching a schema | +| `--verbose` | Full turn-by-turn output | +| `--bare` | Skip hooks/plugins/MCP/CLAUDE.md for fast scripting | +| `--append-system-prompt ` | Add instructions to the system prompt (preserves built-ins) | +| `--system-prompt ` | REPLACE the entire system prompt (use --append instead usually) | +| `--add-dir ` | Grant access to additional directories | +| `-w, --worktree ` | Run in an isolated git worktree | + +### Tool Name Syntax for --allowedTools +- `Read` — file reading +- `Edit` — file editing +- `Write` — file creation +- `Bash` — shell commands +- `Bash(git *)` — only git commands +- `Bash(npm run lint:*)` — pattern matching +- `WebSearch` — web search capability + +## Interactive Session Patterns + +### Multi-Turn Development Cycle +``` +# 1. Create tmux session +terminal(command="tmux new-session -d -s dev -x 140 -y 40") + +# 2. Launch Claude in project +terminal(command="tmux send-keys -t dev 'cd ~/myproject && claude' Enter") +terminal(command="sleep 5") # Wait for welcome screen + +# 3. First task: implement feature +terminal(command="tmux send-keys -t dev 'Implement a caching layer for the API client in src/client.py' Enter") +terminal(command="sleep 30 && tmux capture-pane -t dev -p -S -60") # Check progress + +# 4. Follow-up: add tests +terminal(command="tmux send-keys -t dev 'Now write comprehensive tests for the cache' Enter") +terminal(command="sleep 20 && tmux capture-pane -t dev -p -S -40") + +# 5. Follow-up: run tests +terminal(command="tmux send-keys -t dev 'Run the tests and fix any failures' Enter") +terminal(command="sleep 20 && tmux capture-pane -t dev -p -S -40") + +# 6. Compact context if running long +terminal(command="tmux send-keys -t dev '/compact focus on the caching implementation' Enter") + +# 7. Exit +terminal(command="tmux send-keys -t dev '/exit' Enter") +terminal(command="sleep 2 && tmux kill-session -t dev") +``` + +### Monitoring Long Operations +``` +# Periodic capture to check if Claude is still working or waiting for input +terminal(command="tmux capture-pane -t dev -p -S -10") +``` + +Look for these indicators: +- `❯` at bottom = waiting for your input (Claude is done or asking a question) +- `●` lines = Claude is actively using tools (reading, writing, running commands) +- `⏵⏵ bypass permissions on` = status bar indicator +- `◐ medium · /effort` = current effort level + +### Using Claude's Built-In Slash Commands (Interactive Only) +| Command | Purpose | +|---------|---------| +| `/compact [focus]` | Summarize context to save tokens (add focus topic) | +| `/clear` | Wipe conversation history | +| `/model` | Switch models mid-session | +| `/review` | Request code review of current changes | +| `/init` | Create CLAUDE.md for the project | +| `/memory` | Edit CLAUDE.md directly | +| `/context` | Visualize context window usage | +| `/vim` | Enable vim-style editing | +| `/exit` or Ctrl+D | End session | + +### Keyboard Shortcuts (Interactive Only) +| Key | Action | +|-----|--------| +| `Tab` | Toggle Extended Thinking mode | +| `Shift+Tab` | Cycle permission modes | +| `Ctrl+C` | Cancel current generation | +| `Ctrl+R` | Search command history | +| `Esc Esc` | Rewind conversation or code | +| `!` prefix | Execute bash directly (e.g., `!npm test`) | +| `@` prefix | Reference files (e.g., `@./src/api/`) | +| `#` prefix | Quick add to CLAUDE.md memory | + +## PR Review Pattern + +### Quick Review (Print Mode) +``` +terminal(command="cd /path/to/repo && git diff main...feature-branch | claude -p 'Review this diff for bugs, security issues, and style problems. Be thorough.' --max-turns 1", timeout=60) +``` + +### Deep Review (Interactive + Worktree) +``` +terminal(command="tmux new-session -d -s review -x 140 -y 40") +terminal(command="tmux send-keys -t review 'cd /path/to/repo && claude -w pr-review' Enter") +terminal(command="sleep 5 && tmux send-keys -t review Enter") # Trust dialog +terminal(command="sleep 2 && tmux send-keys -t review 'Review all changes vs main. Check for bugs, security issues, race conditions, and missing tests.' Enter") +terminal(command="sleep 30 && tmux capture-pane -t review -p -S -60") +``` + +### PR Review from Number +``` +terminal(command="claude -p 'Review this PR thoroughly' --from-pr 42 --max-turns 10", workdir="/path/to/repo", timeout=120) +``` + +## Parallel Claude Instances + +Run multiple independent Claude tasks simultaneously: + +``` +# Task 1: Fix backend +terminal(command="tmux new-session -d -s task1 -x 140 -y 40 && tmux send-keys -t task1 'cd ~/project && claude -p \"Fix the auth bug in src/auth.py\" --allowedTools \"Read,Edit\" --max-turns 10' Enter") + +# Task 2: Write tests +terminal(command="tmux new-session -d -s task2 -x 140 -y 40 && tmux send-keys -t task2 'cd ~/project && claude -p \"Write integration tests for the API endpoints\" --allowedTools \"Read,Write,Bash\" --max-turns 15' Enter") + +# Task 3: Update docs +terminal(command="tmux new-session -d -s task3 -x 140 -y 40 && tmux send-keys -t task3 'cd ~/project && claude -p \"Update README.md with the new API endpoints\" --allowedTools \"Read,Edit\" --max-turns 5' Enter") + +# Monitor all +terminal(command="sleep 30 && for s in task1 task2 task3; do echo '=== '$s' ==='; tmux capture-pane -t $s -p -S -5 2>/dev/null; done") +``` + +## CLAUDE.md — Project Context File + +Claude Code auto-loads `CLAUDE.md` from the project root. Use it to persist project context: + +```markdown +# Project: My API + +## Architecture +- FastAPI backend with SQLAlchemy ORM +- PostgreSQL database, Redis cache +- pytest for testing with 90% coverage target + +## Key Commands +- `make test` — run full test suite +- `make lint` — ruff + mypy +- `make dev` — start dev server on :8000 + +## Code Standards +- Type hints on all public functions +- Docstrings in Google style +- 2-space indentation for YAML, 4-space for Python +- No wildcard imports +``` + +Global context: `~/.claude/CLAUDE.md` (applies to all projects). + +## Hooks — Automation on Events + +Configure in `.claude/settings.json` or `~/.claude/settings.json`: + +```json +{ + "hooks": { + "PostToolUse": [{ + "matcher": "Write(*.py)", + "hooks": [{"type": "command", "command": "ruff check --fix $CLAUDE_FILE_PATHS"}] + }], + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'rm -rf'; then echo 'Blocked!' && exit 2; fi"}] + }], + "Stop": [{ + "hooks": [{"type": "command", "command": "echo 'Claude finished a response' >> /tmp/claude-activity.log"}] + }] + } +} +``` + +**Hook types:** UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SubagentStop, PreCompact, SessionStart. + +**Environment variables in hooks:** `CLAUDE_PROJECT_DIR`, `CLAUDE_FILE_PATHS`, `CLAUDE_TOOL_INPUT`. + +## Custom Subagents + +Define specialized agents in `.claude/agents/`: + +```markdown +# .claude/agents/security-reviewer.md +--- +name: security-reviewer +description: Security-focused code review +model: opus +tools: [Read, Bash] +--- +You are a senior security engineer. Review code for: +- Injection vulnerabilities (SQL, XSS, command injection) +- Authentication/authorization flaws +- Secrets in code +- Unsafe deserialization +``` + +Invoke via: `@security-reviewer review the auth module` + +## MCP Integration + +Add external tool servers: +``` +terminal(command="claude mcp add github -- npx @modelcontextprotocol/server-github", timeout=30) +terminal(command="claude mcp add postgres -- npx @anthropic-ai/server-postgres --connection-string postgresql://localhost/mydb", timeout=30) +``` + +Scopes: `-s user` (global), `-s local` (project, gitignored), `-s project` (team-shared). + +## Custom Slash Commands + +Create `.claude/commands/.md` for project shortcuts: + +```markdown +# .claude/commands/deploy.md +Run the deploy pipeline: +1. Run all tests +2. Build the Docker image +3. Push to registry +4. Update the staging deployment +Environment: $ARGUMENTS (default: staging) +``` + +Usage in interactive session: `/deploy production` + +Parameterized with `$ARGUMENTS` for dynamic input. + +## Cost & Performance Tips + +1. **Use `--max-turns`** in print mode to prevent runaway loops. Start with 5-10 for most tasks. +2. **Use `--max-budget-usd`** for cost caps. Note: minimum ~$0.05 for system prompt cache creation. +3. **Use `--effort low`** for simple tasks (faster, cheaper). `high` or `max` for complex reasoning. +4. **Use `--bare`** for CI/scripting to skip plugin/hook discovery overhead. +5. **Use `--allowedTools`** to restrict to only what's needed (e.g., `Read` only for reviews). +6. **Use `/compact`** in interactive sessions when context gets large (precision drops at 70% context usage, hallucinations spike at 85%). +7. **Pipe input** instead of having Claude read files when you just need analysis of known content. +8. **Use `--model haiku`** for simple tasks (cheaper) and `--model opus` for complex multi-step work. + +## Pitfalls & Gotchas + +1. **Interactive mode REQUIRES tmux** — Claude Code is a full TUI app. Using `pty=true` alone in Hermes terminal works but tmux gives you `capture-pane` for monitoring and `send-keys` for input, which is essential for orchestration. +2. **`--dangerously-skip-permissions` dialog defaults to "No, exit"** — you must send Down then Enter to accept. Print mode (`-p`) skips this entirely. +3. **`--max-budget-usd` minimum is ~$0.05** — system prompt cache creation alone costs this much. Setting lower will error immediately. +4. **`--max-turns` is print-mode only** — ignored in interactive sessions. +5. **Claude may use `python` instead of `python3`** — on systems without a `python` symlink, Claude's bash commands will fail on first try but it self-corrects. +6. **Session resumption requires same directory** — `--continue` finds the most recent session for the current working directory. +7. **`--json-schema` needs enough `--max-turns`** — Claude must read files before producing structured output, which takes multiple turns. +8. **Trust dialog only appears once per directory** — first-time only, then cached. +9. **Background tmux sessions persist** — always clean up with `tmux kill-session -t ` when done. -## Rules +## Rules for Hermes Agents -1. **Always use `pty=true`** — Claude Code is an interactive terminal app and will hang without a PTY -2. **Use `workdir`** — keep the agent focused on the right directory -3. **Background for long tasks** — use `background=true` and monitor with `process` tool -4. **Don't interfere** — monitor with `poll`/`log`, don't kill sessions because they're slow -5. **Report results** — after completion, check what changed and summarize for the user +1. **Prefer print mode (`-p`) for single tasks** — cleaner, no dialog handling, structured output +2. **Use tmux for multi-turn interactive work** — the only reliable way to orchestrate the TUI +3. **Always set `workdir`** — keep Claude focused on the right project directory +4. **Set `--max-turns` in print mode** — prevents infinite loops and runaway costs +5. **Monitor tmux sessions** — use `tmux capture-pane -t -p -S -50` to check progress +6. **Look for the `❯` prompt** — indicates Claude is waiting for input (done or asking a question) +7. **Clean up tmux sessions** — kill them when done to avoid resource leaks +8. **Report results to user** — after completion, summarize what Claude did and what changed +9. **Don't kill slow sessions** — Claude may be doing multi-step work; check progress instead +10. **Use `--allowedTools`** — restrict capabilities to what the task actually needs From c8220e69a11e16db050f450eeebdad9bc521eac9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:05:27 -0700 Subject: [PATCH 014/213] fix: strip MEDIA: directives from streamed gateway messages (#5152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When streaming is enabled, the GatewayStreamConsumer sends raw text chunks directly to the platform without post-processing. This causes MEDIA:/path/to/file tags and [[audio_as_voice]] directives to appear as visible text in the user's chat instead of being stripped. The non-streaming path already handles this correctly via extract_media() in base.py, but the streaming path was missing equivalent cleanup. Add _clean_for_display() to GatewayStreamConsumer that strips MEDIA: tags and internal markers before any text reaches the platform. The actual media file delivery is unaffected — _deliver_media_from_response() in gateway/run.py still extracts files from the agent's final_response (separate from the stream consumer's display text). Reported by Ao [FotM] on Discord. --- gateway/stream_consumer.py | 32 +++++ tests/gateway/test_stream_consumer.py | 179 ++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 tests/gateway/test_stream_consumer.py diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 4a3cf744afd6..7f4a73d0b776 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -18,6 +18,7 @@ import asyncio import logging import queue +import re import time from dataclasses import dataclass from typing import Any, Optional @@ -156,8 +157,39 @@ async def run(self) -> None: except Exception as e: logger.error("Stream consumer error: %s", e) + # Pattern to strip MEDIA: tags (including optional surrounding quotes). + # Matches the simple cleanup regex used by the non-streaming path in + # gateway/platforms/base.py for post-processing. + _MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''') + + @staticmethod + def _clean_for_display(text: str) -> str: + """Strip MEDIA: directives and internal markers from text before display. + + The streaming path delivers raw text chunks that may include + ``MEDIA:`` tags and ``[[audio_as_voice]]`` directives meant for + the platform adapter's post-processing. The actual media files are + delivered separately via ``_deliver_media_from_response()`` after the + stream finishes — we just need to hide the raw directives from the + user. + """ + if "MEDIA:" not in text and "[[audio_as_voice]]" not in text: + return text + cleaned = text.replace("[[audio_as_voice]]", "") + cleaned = GatewayStreamConsumer._MEDIA_RE.sub("", cleaned) + # Collapse excessive blank lines left behind by removed tags + cleaned = re.sub(r'\n{3,}', '\n\n', cleaned) + # Strip trailing whitespace/newlines but preserve leading content + return cleaned.rstrip() + async def _send_or_edit(self, text: str) -> None: """Send or edit the streaming message.""" + # Strip MEDIA: directives so they don't appear as visible text. + # Media files are delivered as native attachments after the stream + # finishes (via _deliver_media_from_response in gateway/run.py). + text = self._clean_for_display(text) + if not text.strip(): + return try: if self._message_id is not None: if self._edit_supported: diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py new file mode 100644 index 000000000000..1234307ca212 --- /dev/null +++ b/tests/gateway/test_stream_consumer.py @@ -0,0 +1,179 @@ +"""Tests for GatewayStreamConsumer — media directive stripping in streaming.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig + + +# ── _clean_for_display unit tests ──────────────────────────────────────── + + +class TestCleanForDisplay: + """Verify MEDIA: directives and internal markers are stripped from display text.""" + + def test_no_media_passthrough(self): + """Text without MEDIA: passes through unchanged.""" + text = "Here is your analysis of the image." + assert GatewayStreamConsumer._clean_for_display(text) == text + + def test_media_tag_stripped(self): + """Basic MEDIA: tag is removed.""" + text = "Here is the image\nMEDIA:/tmp/hermes/image.png" + result = GatewayStreamConsumer._clean_for_display(text) + assert "MEDIA:" not in result + assert "Here is the image" in result + + def test_media_tag_with_space(self): + """MEDIA: tag with space after colon is removed.""" + text = "Audio generated\nMEDIA: /home/user/.hermes/audio_cache/voice.mp3" + result = GatewayStreamConsumer._clean_for_display(text) + assert "MEDIA:" not in result + assert "Audio generated" in result + + def test_media_tag_with_quotes(self): + """MEDIA: tags wrapped in quotes or backticks are removed.""" + for wrapper in ['`MEDIA:/path/file.png`', '"MEDIA:/path/file.png"', "'MEDIA:/path/file.png'"]: + text = f"Result: {wrapper}" + result = GatewayStreamConsumer._clean_for_display(text) + assert "MEDIA:" not in result, f"Failed for wrapper: {wrapper}" + + def test_audio_as_voice_stripped(self): + """[[audio_as_voice]] directive is removed.""" + text = "[[audio_as_voice]]\nMEDIA:/tmp/voice.ogg" + result = GatewayStreamConsumer._clean_for_display(text) + assert "[[audio_as_voice]]" not in result + assert "MEDIA:" not in result + + def test_multiple_media_tags(self): + """Multiple MEDIA: tags are all removed.""" + text = "Here are two files:\nMEDIA:/tmp/a.png\nMEDIA:/tmp/b.jpg" + result = GatewayStreamConsumer._clean_for_display(text) + assert "MEDIA:" not in result + assert "Here are two files:" in result + + def test_excessive_newlines_collapsed(self): + """Blank lines left by removed tags are collapsed.""" + text = "Before\n\n\nMEDIA:/tmp/file.png\n\n\nAfter" + result = GatewayStreamConsumer._clean_for_display(text) + # Should not have 3+ consecutive newlines + assert "\n\n\n" not in result + + def test_media_only_response(self): + """Response that is entirely MEDIA: tags returns empty/whitespace.""" + text = "MEDIA:/tmp/image.png" + result = GatewayStreamConsumer._clean_for_display(text) + assert result.strip() == "" + + def test_media_mid_sentence(self): + """MEDIA: tag embedded in prose is stripped cleanly.""" + text = "I generated this image MEDIA:/tmp/art.png for you." + result = GatewayStreamConsumer._clean_for_display(text) + assert "MEDIA:" not in result + assert "generated" in result + assert "for you." in result + + def test_preserves_non_media_colons(self): + """Normal colons and text with 'MEDIA' as a word aren't stripped.""" + text = "The media: files are stored in /tmp. Use social MEDIA carefully." + result = GatewayStreamConsumer._clean_for_display(text) + # "MEDIA:" in upper case without a path won't match \S+ (space follows) + # But "media:" is lowercase so won't match either + assert result == text + + +# ── Integration: _send_or_edit strips MEDIA: ───────────────────────────── + + +class TestSendOrEditMediaStripping: + """Verify _send_or_edit strips MEDIA: before sending to the platform.""" + + @pytest.mark.asyncio + async def test_first_send_strips_media(self): + """Initial send removes MEDIA: tags from visible text.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + adapter.send = AsyncMock(return_value=send_result) + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer(adapter, "chat_123") + await consumer._send_or_edit("Here is your image\nMEDIA:/tmp/test.png") + + adapter.send.assert_called_once() + sent_text = adapter.send.call_args[1]["content"] + assert "MEDIA:" not in sent_text + assert "Here is your image" in sent_text + + @pytest.mark.asyncio + async def test_edit_strips_media(self): + """Edit call removes MEDIA: tags from visible text.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + edit_result = SimpleNamespace(success=True) + adapter.send = AsyncMock(return_value=send_result) + adapter.edit_message = AsyncMock(return_value=edit_result) + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer(adapter, "chat_123") + # First send + await consumer._send_or_edit("Starting response...") + # Edit with MEDIA: tag + await consumer._send_or_edit("Here is the result\nMEDIA:/tmp/image.png") + + adapter.edit_message.assert_called_once() + edited_text = adapter.edit_message.call_args[1]["content"] + assert "MEDIA:" not in edited_text + + @pytest.mark.asyncio + async def test_media_only_skips_send(self): + """If text is entirely MEDIA: tags, the send is skipped.""" + adapter = MagicMock() + adapter.send = AsyncMock() + adapter.MAX_MESSAGE_LENGTH = 4096 + + consumer = GatewayStreamConsumer(adapter, "chat_123") + await consumer._send_or_edit("MEDIA:/tmp/image.png") + + adapter.send.assert_not_called() + + +# ── Integration: full stream run ───────────────────────────────────────── + + +class TestStreamRunMediaStripping: + """End-to-end: deltas with MEDIA: produce clean visible text.""" + + @pytest.mark.asyncio + async def test_stream_with_media_tag(self): + """Full stream run strips MEDIA: from the final visible message.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + edit_result = SimpleNamespace(success=True) + adapter.send = AsyncMock(return_value=send_result) + adapter.edit_message = AsyncMock(return_value=edit_result) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Feed deltas + consumer.on_delta("Here is your generated image\n") + consumer.on_delta("MEDIA:/home/user/.hermes/cache/images/abc123.png") + consumer.finish() + + await consumer.run() + + # Verify the final text sent/edited doesn't contain MEDIA: + all_calls = [] + for call in adapter.send.call_args_list: + all_calls.append(call[1].get("content", "")) + for call in adapter.edit_message.call_args_list: + all_calls.append(call[1].get("content", "")) + + for sent_text in all_calls: + assert "MEDIA:" not in sent_text, f"MEDIA: leaked into display: {sent_text!r}" + + assert consumer.already_sent From 85cefc7a5aaf05eba97984bafca6d7959179d9f5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:05:34 -0700 Subject: [PATCH 015/213] fix(telegram): prevent duplicate message delivery on send timeout (#5153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimedOut is a subclass of NetworkError in python-telegram-bot. The inner retry loop in send() and the outer _send_with_retry() in base.py both treated it as a transient connection error and retried — but send_message is not idempotent. When the request reaches Telegram but the HTTP response times out, the message is already delivered. Retrying sends duplicates. Worst case: up to 9 copies (inner 3x × outer 3x). Inner loop (telegram.py): - Import TimedOut separately, isinstance-check before generic NetworkError retry (same pattern as BadRequest carve-out from #3390) - Re-raise immediately — no retry - Mark as retryable=False in outer exception handler Outer loop (base.py): - Remove 'timeout', 'timed out', 'readtimeout', 'writetimeout' from _RETRYABLE_ERROR_PATTERNS (read/write timeouts are delivery-ambiguous) - Add 'connecttimeout' (safe — connection never established) - Keep 'network' (other platforms still need it) - Add _is_timeout_error() + early return to prevent plain-text fallback on timeout errors (would also cause duplicate delivery) Connection errors (ConnectionReset, ConnectError, etc.) are still retried — these fail before the request reaches the server. Credit: tmdgusya (PR #3899), barun1997 (PR #3904) for identifying the bug and proposing fixes. Closes #3899, closes #3904. --- gateway/platforms/base.py | 32 ++++++++-- gateway/platforms/telegram.py | 17 +++++- tests/gateway/test_send_retry.py | 61 +++++++++++++++++-- .../gateway/test_telegram_thread_fallback.py | 33 ++++++++++ 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 51a50c8cd22d..a023a972ec1e 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -377,23 +377,26 @@ class SendResult: message_id: Optional[str] = None error: Optional[str] = None raw_response: Any = None - retryable: bool = False # True for transient errors (network, timeout) — base will retry automatically + retryable: bool = False # True for transient connection errors — base will retry automatically -# Error substrings that indicate a transient network failure worth retrying +# Error substrings that indicate a transient *connection* failure worth retrying. +# "timeout" / "timed out" / "readtimeout" / "writetimeout" are intentionally +# excluded: a read/write timeout on a non-idempotent call (e.g. send_message) +# means the request may have reached the server — retrying risks duplicate +# delivery. "connecttimeout" is safe because the connection was never +# established. Platforms that know a timeout is safe to retry should set +# SendResult.retryable = True explicitly. _RETRYABLE_ERROR_PATTERNS = ( "connecterror", "connectionerror", "connectionreset", "connectionrefused", - "timeout", - "timed out", + "connecttimeout", "network", "broken pipe", "remotedisconnected", "eoferror", - "readtimeout", - "writetimeout", ) @@ -927,6 +930,18 @@ def _is_retryable_error(error: Optional[str]) -> bool: lowered = error.lower() return any(pat in lowered for pat in _RETRYABLE_ERROR_PATTERNS) + @staticmethod + def _is_timeout_error(error: Optional[str]) -> bool: + """Return True if the error string indicates a read/write timeout. + + Timeout errors are NOT retryable and should NOT trigger plain-text + fallback — the request may have already been delivered. + """ + if not error: + return False + lowered = error.lower() + return "timed out" in lowered or "readtimeout" in lowered or "writetimeout" in lowered + async def _send_with_retry( self, chat_id: str, @@ -958,6 +973,11 @@ async def _send_with_retry( error_str = result.error or "" is_network = result.retryable or self._is_retryable_error(error_str) + # Timeout errors are not safe to retry (message may have been + # delivered) and not formatting errors — return the failure as-is. + if not is_network and self._is_timeout_error(error_str): + return result + if is_network: # Retry with exponential backoff for transient errors for attempt in range(1, max_retries + 1): diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 12ef561b5cdf..e406451e7d8e 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -772,6 +772,11 @@ async def send( except ImportError: _BadReq = None # type: ignore[assignment,misc] + try: + from telegram.error import TimedOut as _TimedOut + except (ImportError, AttributeError): + _TimedOut = None # type: ignore[assignment,misc] + for i, chunk in enumerate(chunks): should_thread = self._should_thread_reply(reply_to, i) reply_to_id = int(reply_to) if should_thread else None @@ -833,6 +838,11 @@ async def send( continue # Other BadRequest errors are permanent — don't retry raise + # TimedOut is also a subclass of NetworkError but + # indicates the request may have reached the server — + # retrying risks duplicate message delivery. + if _TimedOut and isinstance(send_err, _TimedOut): + raise if _send_attempt < 2: wait = 2 ** _send_attempt logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s", @@ -850,7 +860,12 @@ async def send( except Exception as e: logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True) - return SendResult(success=False, error=str(e)) + # TimedOut means the request may have reached Telegram — + # mark as non-retryable so _send_with_retry() doesn't re-send. + _to = locals().get("_TimedOut") + err_str = str(e).lower() + is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str + return SendResult(success=False, error=str(e), retryable=not is_timeout) async def edit_message( self, diff --git a/tests/gateway/test_send_retry.py b/tests/gateway/test_send_retry.py index 4005f4071996..62945d9f4d9a 100644 --- a/tests/gateway/test_send_retry.py +++ b/tests/gateway/test_send_retry.py @@ -72,6 +72,43 @@ def test_bad_request_not_retryable(self): def test_case_insensitive(self): assert _StubAdapter._is_retryable_error("CONNECTERROR: host unreachable") + def test_timeout_not_retryable(self): + assert not _StubAdapter._is_retryable_error("ReadTimeout: request timed out") + + def test_timed_out_not_retryable(self): + assert not _StubAdapter._is_retryable_error("Timed out waiting for response") + + def test_connect_timeout_is_retryable(self): + assert _StubAdapter._is_retryable_error("ConnectTimeout: connection timed out") + + +# --------------------------------------------------------------------------- +# _is_timeout_error +# --------------------------------------------------------------------------- + +class TestIsTimeoutError: + def test_none_is_not_timeout(self): + assert not _StubAdapter._is_timeout_error(None) + + def test_empty_is_not_timeout(self): + assert not _StubAdapter._is_timeout_error("") + + def test_timed_out(self): + assert _StubAdapter._is_timeout_error("Timed out waiting for response") + + def test_read_timeout(self): + assert _StubAdapter._is_timeout_error("ReadTimeout: request timed out") + + def test_write_timeout(self): + assert _StubAdapter._is_timeout_error("WriteTimeout: send stalled") + + def test_connect_timeout_not_flagged(self): + """ConnectTimeout is a connection error, not a delivery-ambiguous timeout.""" + assert not _StubAdapter._is_timeout_error("ConnectTimeout: host unreachable") + + def test_connection_error_not_timeout(self): + assert not _StubAdapter._is_timeout_error("ConnectionError: host unreachable") + # --------------------------------------------------------------------------- # _send_with_retry — success on first attempt @@ -112,17 +149,33 @@ async def test_retries_on_connect_error_and_succeeds(self): assert len(adapter._send_calls) == 2 # initial + 1 retry @pytest.mark.asyncio - async def test_retries_on_timeout_and_succeeds(self): + async def test_timeout_not_retried_to_prevent_duplicates(self): + """ReadTimeout is NOT retried because the request may have reached + the server — retrying a non-idempotent send risks duplicate delivery. + It also skips plain-text fallback (timeout is not a formatting issue).""" adapter = _StubAdapter() adapter._send_results = [ SendResult(success=False, error="ReadTimeout: request timed out"), - SendResult(success=False, error="ReadTimeout: request timed out"), + ] + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + result = await adapter._send_with_retry("chat1", "hello", max_retries=3, base_delay=0) + # No retry, no fallback — timeout returns failure immediately + mock_sleep.assert_not_called() + assert not result.success + assert len(adapter._send_calls) == 1 + + @pytest.mark.asyncio + async def test_connect_timeout_still_retried(self): + """ConnectTimeout is safe to retry — the connection was never established.""" + adapter = _StubAdapter() + adapter._send_results = [ + SendResult(success=False, error="ConnectTimeout: connection timed out"), SendResult(success=True, message_id="ok"), ] with patch("asyncio.sleep", new_callable=AsyncMock): - result = await adapter._send_with_retry("chat1", "hello", max_retries=3, base_delay=0) + result = await adapter._send_with_retry("chat1", "hello", max_retries=2, base_delay=0) assert result.success - assert len(adapter._send_calls) == 3 + assert len(adapter._send_calls) == 2 @pytest.mark.asyncio async def test_retryable_flag_respected(self): diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index e2817d834000..735744e4e15a 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -33,11 +33,16 @@ class FakeBadRequest(FakeNetworkError): pass +class FakeTimedOut(FakeNetworkError): + pass + + # Build a fake telegram module tree so the adapter's internal imports work _fake_telegram = types.ModuleType("telegram") _fake_telegram_error = types.ModuleType("telegram.error") _fake_telegram_error.NetworkError = FakeNetworkError _fake_telegram_error.BadRequest = FakeBadRequest +_fake_telegram_error.TimedOut = FakeTimedOut _fake_telegram.error = _fake_telegram_error _fake_telegram_constants = types.ModuleType("telegram.constants") _fake_telegram_constants.ParseMode = SimpleNamespace(MARKDOWN_V2="MarkdownV2") @@ -168,6 +173,34 @@ async def mock_send_message(**kwargs): assert attempt[0] == 3 # Two retries then success +@pytest.mark.asyncio +async def test_send_does_not_retry_timeout(): + """TimedOut (subclass of NetworkError) should NOT be retried in send(). + + The request may have already been delivered to the user — retrying + would send duplicate messages. + """ + adapter = _make_adapter() + + attempt = [0] + + async def mock_send_message(**kwargs): + attempt[0] += 1 + raise FakeTimedOut("Timed out waiting for Telegram response") + + adapter._bot = SimpleNamespace(send_message=mock_send_message) + + result = await adapter.send( + chat_id="123", + content="test message", + ) + + assert result.success is False + assert "Timed out" in result.error + # CRITICAL: only 1 attempt — no retry for TimedOut + assert attempt[0] == 1 + + @pytest.mark.asyncio async def test_thread_fallback_only_fires_once(): """After clearing thread_id, subsequent chunks should also use None.""" From 0fd3de2674bd08fa82f0357381348004927c6f94 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:15:57 -0700 Subject: [PATCH 016/213] =?UTF-8?q?docs(skill):=20claude-code=20v2.2=20?= =?UTF-8?q?=E2=80=94=20add=20cheat=20sheet=20commands,=20env=20vars,=20rul?= =?UTF-8?q?es,=20advanced=20features=20(#5158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the claude-code skill with content from official docs and community cheat sheets that was missing from v2.0: Slash commands: /cost, /btw, /plan, /loop, /batch, /security-review, /resume, /effort (with auto level), /mcp, /release-notes, /voice details Keyboard shortcuts: Alt+P (model), Alt+T (thinking), Alt+O (fast mode), Ctrl+V (paste image), Ctrl+O (transcript), Ctrl+G (external editor) Ultrathink keyword for max reasoning on a specific turn Rules directory: .claude/rules/*.md and ~/.claude/rules/*.md Auto-memory: ~/.claude/projects//memory/ (25KB/200 lines limit) Environment variables: CLAUDE_CODE_EFFORT_LEVEL, MAX_THINKING_TOKENS, CLAUDE_CODE_NO_FLICKER, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB MCP limits: 2KB tool desc cap, maxResultSizeChars 500K, transport types Reorganized slash commands into Session/Development/Configuration groups Reorganized keyboard shortcuts into Controls/Toggles/Multiline groups --- .../autonomous-ai-agents/claude-code/SKILL.md | 536 ++++++++++++++---- 1 file changed, 410 insertions(+), 126 deletions(-) diff --git a/skills/autonomous-ai-agents/claude-code/SKILL.md b/skills/autonomous-ai-agents/claude-code/SKILL.md index 6717a389b2f2..0b39b5c2f41d 100644 --- a/skills/autonomous-ai-agents/claude-code/SKILL.md +++ b/skills/autonomous-ai-agents/claude-code/SKILL.md @@ -1,7 +1,7 @@ --- name: claude-code description: Delegate coding tasks to Claude Code (Anthropic's CLI agent). Use for building features, refactoring, PR reviews, and iterative coding. Requires the claude CLI installed. -version: 2.0.0 +version: 2.2.0 author: Hermes Agent + Teknium license: MIT metadata: @@ -18,7 +18,12 @@ Delegate coding tasks to [Claude Code](https://code.claude.com/docs/en/cli-refer - **Install:** `npm install -g @anthropic-ai/claude-code` - **Auth:** run `claude` once to log in (browser OAuth for Pro/Max, or set `ANTHROPIC_API_KEY`) +- **Console auth:** `claude auth login --console` for API key billing +- **SSO auth:** `claude auth login --sso` for Enterprise +- **Check status:** `claude auth status` (JSON) or `claude auth status --text` (human-readable) +- **Health check:** `claude doctor` — checks auto-updater and installation health - **Version check:** `claude --version` (requires v2.x+) +- **Update:** `claude update` or `claude upgrade` ## Two Orchestration Modes @@ -110,6 +115,30 @@ terminal(command="sleep 15 && tmux capture-pane -t claude-work -p -S -60") **Note:** After the first trust acceptance for a directory, the trust dialog won't appear again. Only the permissions dialog recurs each time you use `--dangerously-skip-permissions`. +## CLI Subcommands + +| Subcommand | Purpose | +|------------|---------| +| `claude` | Start interactive REPL | +| `claude "query"` | Start REPL with initial prompt | +| `claude -p "query"` | Print mode (non-interactive, exits when done) | +| `cat file \| claude -p "query"` | Pipe content as stdin context | +| `claude -c` | Continue the most recent conversation in this directory | +| `claude -r "id"` | Resume a specific session by ID or name | +| `claude auth login` | Sign in (add `--console` for API billing, `--sso` for Enterprise) | +| `claude auth status` | Check login status (returns JSON; `--text` for human-readable) | +| `claude mcp add -- ` | Add an MCP server | +| `claude mcp list` | List configured MCP servers | +| `claude mcp remove ` | Remove an MCP server | +| `claude agents` | List configured agents | +| `claude doctor` | Run health checks on installation and auto-updater | +| `claude update` / `claude upgrade` | Update Claude Code to latest version | +| `claude remote-control` | Start server to control Claude from claude.ai or mobile app | +| `claude install [target]` | Install native build (stable, latest, or specific version) | +| `claude setup-token` | Set up long-lived auth token (requires subscription) | +| `claude plugin` / `claude plugins` | Manage Claude Code plugins | +| `claude auto-mode` | Inspect auto mode classifier configuration | + ## Print Mode Deep Dive ### Structured JSON Output @@ -129,11 +158,33 @@ Returns a JSON object with: "duration_ms": 10276, "stop_reason": "end_turn", "terminal_reason": "completed", - "usage": { "input_tokens": 5, "output_tokens": 603, ... } + "usage": { "input_tokens": 5, "output_tokens": 603, ... }, + "modelUsage": { "claude-sonnet-4-6": { "costUSD": 0.078, "contextWindow": 200000 } } } ``` -Use `session_id` to resume later. `num_turns` shows how many agentic loops it took. `total_cost_usd` tracks spend. +**Key fields:** `session_id` for resumption, `num_turns` for agentic loop count, `total_cost_usd` for spend tracking, `subtype` for success/error detection (`success`, `error_max_turns`, `error_budget`). + +### Streaming JSON Output +For real-time token streaming, use `stream-json` with `--verbose`: +``` +terminal(command="claude -p 'Write a summary' --output-format stream-json --verbose --include-partial-messages", timeout=60) +``` + +Returns newline-delimited JSON events. Filter with jq for live text: +``` +claude -p "Explain X" --output-format stream-json --verbose --include-partial-messages | \ + jq -rj 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text' +``` + +Stream events include `system/api_retry` with `attempt`, `max_retries`, and `error` fields (e.g., `rate_limit`, `billing_error`). + +### Bidirectional Streaming +For real-time input AND output streaming: +``` +claude -p "task" --input-format stream-json --output-format stream-json --replay-user-messages +``` +`--replay-user-messages` re-emits user messages on stdout for acknowledgment. ### Piped Input ``` @@ -152,7 +203,7 @@ terminal(command="git diff HEAD~3 | claude -p 'Summarize these changes' --max-tu terminal(command="claude -p 'List all functions in src/' --output-format json --json-schema '{\"type\":\"object\",\"properties\":{\"functions\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"functions\"]}' --max-turns 5", workdir="/project", timeout=90) ``` -Parse `structured_output` from the JSON result. +Parse `structured_output` from the JSON result. Claude validates output against the schema before returning. ### Session Continuation ``` @@ -164,6 +215,9 @@ terminal(command="claude -p 'Continue and add connection pooling' --resume $(cat # Or resume the most recent session in the same directory terminal(command="claude -p 'What did you do last time?' --continue --max-turns 1", workdir="/project", timeout=30) + +# Fork a session (new ID, keeps history) +terminal(command="claude -p 'Try a different approach' --resume --fork-session --max-turns 10", workdir="/project", timeout=120) ``` ### Bare Mode for CI/Scripting @@ -173,115 +227,243 @@ terminal(command="claude --bare -p 'Run all tests and report failures' --allowed `--bare` skips hooks, plugins, MCP discovery, and CLAUDE.md loading. Fastest startup. Requires `ANTHROPIC_API_KEY` (skips OAuth). -## Key Flags Reference +To selectively load context in bare mode: +| To load | Flag | +|---------|------| +| System prompt additions | `--append-system-prompt "text"` or `--append-system-prompt-file path` | +| Settings | `--settings ` | +| MCP servers | `--mcp-config ` | +| Custom agents | `--agents ''` | + +### Fallback Model for Overload +``` +terminal(command="claude -p 'task' --fallback-model haiku --max-turns 5", timeout=90) +``` +Automatically falls back to the specified model when the default is overloaded (print mode only). -### Essential Flags -| Flag | Effect | Mode | -|------|--------|------| -| `-p, --print` | Non-interactive one-shot mode | Both | -| `-c, --continue` | Resume most recent conversation | Both | -| `-r, --resume ` | Resume specific session by ID | Both | -| `--model ` | Model selection: `sonnet`, `opus`, `haiku`, or full name | Both | -| `--effort ` | Reasoning depth: `low`, `medium`, `high`, `max` | Both | -| `--max-turns ` | Limit agentic loops (prevents runaway) | Print only | -| `--max-budget-usd ` | Cap API spend in dollars | Print only | +## Complete CLI Flags Reference -### Permission & Safety Flags +### Session & Environment +| Flag | Effect | +|------|--------| +| `-p, --print` | Non-interactive one-shot mode (exits when done) | +| `-c, --continue` | Resume most recent conversation in current directory | +| `-r, --resume ` | Resume specific session by ID or name (interactive picker if no ID) | +| `--fork-session` | When resuming, create new session ID instead of reusing original | +| `--session-id ` | Use a specific UUID for the conversation | +| `--no-session-persistence` | Don't save session to disk (print mode only) | +| `--add-dir ` | Grant Claude access to additional working directories | +| `-w, --worktree [name]` | Run in an isolated git worktree at `.claude/worktrees/` | +| `--tmux` | Create a tmux session for the worktree (requires `--worktree`) | +| `--ide` | Auto-connect to a valid IDE on startup | +| `--chrome` / `--no-chrome` | Enable/disable Chrome browser integration for web testing | +| `--from-pr [number]` | Resume session linked to a specific GitHub PR | +| `--file ` | File resources to download at startup (format: `file_id:relative_path`) | + +### Model & Performance | Flag | Effect | |------|--------| -| `--dangerously-skip-permissions` | Auto-approve ALL tool use (file writes, bash, etc.) | +| `--model ` | Model selection: `sonnet`, `opus`, `haiku`, or full name like `claude-sonnet-4-6` | +| `--effort ` | Reasoning depth: `low`, `medium`, `high`, `max`, `auto` | Both | +| `--max-turns ` | Limit agentic loops (print mode only; prevents runaway) | +| `--max-budget-usd ` | Cap API spend in dollars (print mode only) | +| `--fallback-model ` | Auto-fallback when default model is overloaded (print mode only) | +| `--betas ` | Beta headers to include in API requests (API key users only) | + +### Permission & Safety +| Flag | Effect | +|------|--------| +| `--dangerously-skip-permissions` | Auto-approve ALL tool use (file writes, bash, network, etc.) | +| `--allow-dangerously-skip-permissions` | Enable bypass as an *option* without enabling it by default | | `--permission-mode ` | `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions` | -| `--allowedTools ` | Whitelist specific tools: `"Read,Edit,Bash"` | -| `--disallowedTools ` | Blacklist specific tools | +| `--allowedTools ` | Whitelist specific tools (comma or space-separated) | +| `--disallowedTools ` | Blacklist specific tools | +| `--tools ` | Override built-in tool set (`""` = none, `"default"` = all, or tool names) | -### Output & Integration Flags +### Output & Input Format | Flag | Effect | |------|--------| -| `--output-format ` | `text` (default), `json` (structured), `stream-json` (streaming) | +| `--output-format ` | `text` (default), `json` (single result object), `stream-json` (newline-delimited) | +| `--input-format ` | `text` (default) or `stream-json` (real-time streaming input) | | `--json-schema ` | Force structured JSON output matching a schema | | `--verbose` | Full turn-by-turn output | -| `--bare` | Skip hooks/plugins/MCP/CLAUDE.md for fast scripting | -| `--append-system-prompt ` | Add instructions to the system prompt (preserves built-ins) | -| `--system-prompt ` | REPLACE the entire system prompt (use --append instead usually) | -| `--add-dir ` | Grant access to additional directories | -| `-w, --worktree ` | Run in an isolated git worktree | +| `--include-partial-messages` | Include partial message chunks as they arrive (stream-json + print) | +| `--replay-user-messages` | Re-emit user messages on stdout (stream-json bidirectional) | -### Tool Name Syntax for --allowedTools -- `Read` — file reading -- `Edit` — file editing -- `Write` — file creation -- `Bash` — shell commands -- `Bash(git *)` — only git commands -- `Bash(npm run lint:*)` — pattern matching -- `WebSearch` — web search capability +### System Prompt & Context +| Flag | Effect | +|------|--------| +| `--append-system-prompt ` | **Add** to the default system prompt (preserves built-in capabilities) | +| `--append-system-prompt-file ` | **Add** file contents to the default system prompt | +| `--system-prompt ` | **Replace** the entire system prompt (use --append instead usually) | +| `--system-prompt-file ` | **Replace** the system prompt with file contents | +| `--bare` | Skip hooks, plugins, MCP discovery, CLAUDE.md, OAuth (fastest startup) | +| `--agents ''` | Define custom subagents dynamically as JSON | +| `--mcp-config ` | Load MCP servers from JSON file (repeatable) | +| `--strict-mcp-config` | Only use MCP servers from `--mcp-config`, ignoring all other MCP configs | +| `--settings ` | Load additional settings from a JSON file or inline JSON | +| `--setting-sources ` | Comma-separated sources to load: `user`, `project`, `local` | +| `--plugin-dir ` | Load plugins from directories for this session only | +| `--disable-slash-commands` | Disable all skills/slash commands | + +### Debugging +| Flag | Effect | +|------|--------| +| `-d, --debug [filter]` | Enable debug logging with optional category filter (e.g., `"api,hooks"`, `"!1p,!file"`) | +| `--debug-file ` | Write debug logs to file (implicitly enables debug mode) | -## Interactive Session Patterns +### Agent Teams +| Flag | Effect | +|------|--------| +| `--teammate-mode ` | How agent teams display: `auto`, `in-process`, or `tmux` | +| `--brief` | Enable `SendUserMessage` tool for agent-to-user communication | -### Multi-Turn Development Cycle +### Tool Name Syntax for --allowedTools / --disallowedTools +``` +Read # All file reading +Edit # File editing (existing files) +Write # File creation (new files) +Bash # All shell commands +Bash(git *) # Only git commands +Bash(git commit *) # Only git commit commands +Bash(npm run lint:*) # Pattern matching with wildcards +WebSearch # Web search capability +WebFetch # Web page fetching +mcp____ # Specific MCP tool ``` -# 1. Create tmux session -terminal(command="tmux new-session -d -s dev -x 140 -y 40") -# 2. Launch Claude in project -terminal(command="tmux send-keys -t dev 'cd ~/myproject && claude' Enter") -terminal(command="sleep 5") # Wait for welcome screen +## Settings & Configuration -# 3. First task: implement feature -terminal(command="tmux send-keys -t dev 'Implement a caching layer for the API client in src/client.py' Enter") -terminal(command="sleep 30 && tmux capture-pane -t dev -p -S -60") # Check progress +### Settings Hierarchy (highest to lowest priority) +1. **CLI flags** — override everything +2. **Local project:** `.claude/settings.local.json` (personal, gitignored) +3. **Project:** `.claude/settings.json` (shared, git-tracked) +4. **User:** `~/.claude/settings.json` (global) -# 4. Follow-up: add tests -terminal(command="tmux send-keys -t dev 'Now write comprehensive tests for the cache' Enter") -terminal(command="sleep 20 && tmux capture-pane -t dev -p -S -40") +### Permissions in Settings +```json +{ + "permissions": { + "allow": ["Bash(npm run lint:*)", "WebSearch", "Read"], + "ask": ["Write(*.ts)", "Bash(git push*)"], + "deny": ["Read(.env)", "Bash(rm -rf *)"] + } +} +``` -# 5. Follow-up: run tests -terminal(command="tmux send-keys -t dev 'Run the tests and fix any failures' Enter") -terminal(command="sleep 20 && tmux capture-pane -t dev -p -S -40") +### Memory Files (CLAUDE.md) Hierarchy +1. **Global:** `~/.claude/CLAUDE.md` — applies to all projects +2. **Project:** `./CLAUDE.md` — project-specific context (git-tracked) +3. **Local:** `.claude/CLAUDE.local.md` — personal project overrides (gitignored) -# 6. Compact context if running long -terminal(command="tmux send-keys -t dev '/compact focus on the caching implementation' Enter") +Use the `#` prefix in interactive mode to quickly add to memory: `# Always use 2-space indentation`. -# 7. Exit -terminal(command="tmux send-keys -t dev '/exit' Enter") -terminal(command="sleep 2 && tmux kill-session -t dev") -``` +## Interactive Session: Slash Commands -### Monitoring Long Operations +### Session & Context +| Command | Purpose | +|---------|---------| +| `/help` | Show all commands (including custom and MCP commands) | +| `/compact [focus]` | Compress context to save tokens; CLAUDE.md survives compaction. E.g., `/compact focus on auth logic` | +| `/clear` | Wipe conversation history for a fresh start | +| `/context` | Visualize context usage as a colored grid with optimization tips | +| `/cost` | View token usage with per-model and cache-hit breakdowns | +| `/resume` | Switch to or resume a different session | +| `/rewind` | Revert to a previous checkpoint in conversation or code | +| `/btw ` | Ask a side question without adding to context cost | +| `/status` | Show version, connectivity, and session info | +| `/todos` | List tracked action items from the conversation | +| `/exit` or `Ctrl+D` | End session | + +### Development & Review +| Command | Purpose | +|---------|---------| +| `/review` | Request code review of current changes | +| `/security-review` | Perform security analysis of current changes | +| `/plan [description]` | Enter Plan mode with auto-start for task planning | +| `/loop [interval]` | Schedule recurring tasks within the session | +| `/batch` | Auto-create worktrees for large parallel changes (5-30 worktrees) | + +### Configuration & Tools +| Command | Purpose | +|---------|---------| +| `/model [model]` | Switch models mid-session (use arrow keys to adjust effort) | +| `/effort [level]` | Set reasoning effort: `low`, `medium`, `high`, `max`, or `auto` | +| `/init` | Create a CLAUDE.md file for project memory | +| `/memory` | Open CLAUDE.md for editing | +| `/config` | Open interactive settings configuration | +| `/permissions` | View/update tool permissions | +| `/agents` | Manage specialized subagents | +| `/mcp` | Interactive UI to manage MCP servers | +| `/add-dir` | Add additional working directories (useful for monorepos) | +| `/usage` | Show plan limits and rate limit status | +| `/voice` | Enable push-to-talk voice mode (20 languages; hold Space to record, release to send) | +| `/release-notes` | Interactive picker for version release notes | + +### Custom Slash Commands +Create `.claude/commands/.md` (project-shared) or `~/.claude/commands/.md` (personal): + +```markdown +# .claude/commands/deploy.md +Run the deploy pipeline: +1. Run all tests +2. Build the Docker image +3. Push to registry +4. Update the $ARGUMENTS environment (default: staging) ``` -# Periodic capture to check if Claude is still working or waiting for input -terminal(command="tmux capture-pane -t dev -p -S -10") + +Usage: `/deploy production` — `$ARGUMENTS` is replaced with the user's input. + +### Skills (Natural Language Invocation) +Unlike slash commands (manually invoked), skills in `.claude/skills/` are markdown guides that Claude invokes automatically via natural language when the task matches: + +```markdown +# .claude/skills/database-migration.md +When asked to create or modify database migrations: +1. Use Alembic for migration generation +2. Always create a rollback function +3. Test migrations against a local database copy ``` -Look for these indicators: -- `❯` at bottom = waiting for your input (Claude is done or asking a question) -- `●` lines = Claude is actively using tools (reading, writing, running commands) -- `⏵⏵ bypass permissions on` = status bar indicator -- `◐ medium · /effort` = current effort level +## Interactive Session: Keyboard Shortcuts -### Using Claude's Built-In Slash Commands (Interactive Only) -| Command | Purpose | -|---------|---------| -| `/compact [focus]` | Summarize context to save tokens (add focus topic) | -| `/clear` | Wipe conversation history | -| `/model` | Switch models mid-session | -| `/review` | Request code review of current changes | -| `/init` | Create CLAUDE.md for the project | -| `/memory` | Edit CLAUDE.md directly | -| `/context` | Visualize context window usage | -| `/vim` | Enable vim-style editing | -| `/exit` or Ctrl+D | End session | +### General Controls +| Key | Action | +|-----|--------| +| `Ctrl+C` | Cancel current input or generation | +| `Ctrl+D` | Exit session | +| `Ctrl+R` | Reverse search command history | +| `Ctrl+B` | Background a running task | +| `Ctrl+V` | Paste image into conversation | +| `Ctrl+O` | Transcript mode — see Claude's thinking process | +| `Ctrl+G` or `Ctrl+X Ctrl+E` | Open prompt in external editor | +| `Esc Esc` | Rewind conversation or code state / summarize | + +### Mode Toggles +| Key | Action | +|-----|--------| +| `Shift+Tab` | Cycle permission modes (Normal → Auto-Accept → Plan) | +| `Alt+P` | Switch model | +| `Alt+T` | Toggle thinking mode | +| `Alt+O` | Toggle Fast Mode | -### Keyboard Shortcuts (Interactive Only) +### Multiline Input | Key | Action | |-----|--------| -| `Tab` | Toggle Extended Thinking mode | -| `Shift+Tab` | Cycle permission modes | -| `Ctrl+C` | Cancel current generation | -| `Ctrl+R` | Search command history | -| `Esc Esc` | Rewind conversation or code | -| `!` prefix | Execute bash directly (e.g., `!npm test`) | -| `@` prefix | Reference files (e.g., `@./src/api/`) | -| `#` prefix | Quick add to CLAUDE.md memory | +| `\` + `Enter` | Quick newline | +| `Shift+Enter` | Newline (alternative) | +| `Ctrl+J` | Newline (alternative) | + +### Input Prefixes +| Prefix | Action | +|--------|--------| +| `!` | Execute bash directly, bypassing AI (e.g., `!npm test`). Use `!` alone to toggle shell mode. | +| `@` | Reference files/directories with autocomplete (e.g., `@./src/api/`) | +| `#` | Quick add to CLAUDE.md memory (e.g., `# Use 2-space indentation`) | +| `/` | Slash commands | + +### Pro Tip: "ultrathink" +Use the keyword "ultrathink" in your prompt for maximum reasoning effort on a specific turn. This triggers the deepest thinking mode regardless of the current `/effort` setting. ## PR Review Pattern @@ -304,6 +486,12 @@ terminal(command="sleep 30 && tmux capture-pane -t review -p -S -60") terminal(command="claude -p 'Review this PR thoroughly' --from-pr 42 --max-turns 10", workdir="/path/to/repo", timeout=120) ``` +### Claude Worktree with tmux +``` +terminal(command="claude -w feature-x --tmux", workdir="/path/to/repo") +``` +Creates an isolated git worktree at `.claude/worktrees/feature-x` AND a tmux session for it. Uses iTerm2 native panes when available; add `--tmux=classic` for traditional tmux. + ## Parallel Claude Instances Run multiple independent Claude tasks simultaneously: @@ -312,7 +500,7 @@ Run multiple independent Claude tasks simultaneously: # Task 1: Fix backend terminal(command="tmux new-session -d -s task1 -x 140 -y 40 && tmux send-keys -t task1 'cd ~/project && claude -p \"Fix the auth bug in src/auth.py\" --allowedTools \"Read,Edit\" --max-turns 10' Enter") -# Task 2: Write tests +# Task 2: Write tests terminal(command="tmux new-session -d -s task2 -x 140 -y 40 && tmux send-keys -t task2 'cd ~/project && claude -p \"Write integration tests for the API endpoints\" --allowedTools \"Read,Write,Bash\" --max-turns 15' Enter") # Task 3: Update docs @@ -346,11 +534,57 @@ Claude Code auto-loads `CLAUDE.md` from the project root. Use it to persist proj - No wildcard imports ``` -Global context: `~/.claude/CLAUDE.md` (applies to all projects). +**Be specific.** Instead of "Write good code", use "Use 2-space indentation for JS" or "Name test files with `.test.ts` suffix." Specific instructions save correction cycles. + +### Rules Directory (Modular CLAUDE.md) +For projects with many rules, use the rules directory instead of one massive CLAUDE.md: +- **Project rules:** `.claude/rules/*.md` — team-shared, git-tracked +- **User rules:** `~/.claude/rules/*.md` — personal, global + +Each `.md` file in the rules directory is loaded as additional context. This is cleaner than cramming everything into a single CLAUDE.md. + +### Auto-Memory +Claude automatically stores learned project context in `~/.claude/projects//memory/`. +- **Limit:** 25KB or 200 lines per project +- This is separate from CLAUDE.md — it's Claude's own notes about the project, accumulated across sessions + +## Custom Subagents + +Define specialized agents in `.claude/agents/` (project), `~/.claude/agents/` (personal), or via `--agents` CLI flag (session): + +### Agent Location Priority +1. `.claude/agents/` — project-level, team-shared +2. `--agents` CLI flag — session-specific, dynamic +3. `~/.claude/agents/` — user-level, personal + +### Creating an Agent +```markdown +# .claude/agents/security-reviewer.md +--- +name: security-reviewer +description: Security-focused code review +model: opus +tools: [Read, Bash] +--- +You are a senior security engineer. Review code for: +- Injection vulnerabilities (SQL, XSS, command injection) +- Authentication/authorization flaws +- Secrets in code +- Unsafe deserialization +``` + +Invoke via: `@security-reviewer review the auth module` + +### Dynamic Agents via CLI +``` +terminal(command="claude --agents '{\"reviewer\": {\"description\": \"Reviews code\", \"prompt\": \"You are a code reviewer focused on performance\"}}' -p 'Use @reviewer to check auth.py'", timeout=120) +``` + +Claude can orchestrate multiple agents: "Use @db-expert to optimize queries, then @security to audit the changes." ## Hooks — Automation on Events -Configure in `.claude/settings.json` or `~/.claude/settings.json`: +Configure in `.claude/settings.json` (project) or `~/.claude/settings.json` (global): ```json { @@ -370,58 +604,102 @@ Configure in `.claude/settings.json` or `~/.claude/settings.json`: } ``` -**Hook types:** UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SubagentStop, PreCompact, SessionStart. - -**Environment variables in hooks:** `CLAUDE_PROJECT_DIR`, `CLAUDE_FILE_PATHS`, `CLAUDE_TOOL_INPUT`. +### All 8 Hook Types +| Hook | When it fires | Common use | +|------|--------------|------------| +| `UserPromptSubmit` | Before Claude processes a user prompt | Input validation, logging | +| `PreToolUse` | Before tool execution | Security gates, block dangerous commands (exit 2 = block) | +| `PostToolUse` | After a tool finishes | Auto-format code, run linters | +| `Notification` | On permission requests or input waits | Desktop notifications, alerts | +| `Stop` | When Claude finishes a response | Completion logging, status updates | +| `SubagentStop` | When a subagent completes | Agent orchestration | +| `PreCompact` | Before context memory is cleared | Backup session transcripts | +| `SessionStart` | When a session begins | Load dev context (e.g., `git status`) | + +### Hook Environment Variables +| Variable | Content | +|----------|---------| +| `CLAUDE_PROJECT_DIR` | Current project path | +| `CLAUDE_FILE_PATHS` | Files being modified | +| `CLAUDE_TOOL_INPUT` | Tool parameters as JSON | + +### Security Hook Examples +```json +{ + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'rm -rf|git push.*--force|:(){ :|:& };:'; then echo 'Dangerous command blocked!' && exit 2; fi"}] + }] +} +``` -## Custom Subagents +## MCP Integration -Define specialized agents in `.claude/agents/`: +Add external tool servers for databases, APIs, and services: -```markdown -# .claude/agents/security-reviewer.md ---- -name: security-reviewer -description: Security-focused code review -model: opus -tools: [Read, Bash] ---- -You are a senior security engineer. Review code for: -- Injection vulnerabilities (SQL, XSS, command injection) -- Authentication/authorization flaws -- Secrets in code -- Unsafe deserialization ``` +# GitHub integration +terminal(command="claude mcp add -s user github -- npx @modelcontextprotocol/server-github", timeout=30) -Invoke via: `@security-reviewer review the auth module` +# PostgreSQL queries +terminal(command="claude mcp add -s local postgres -- npx @anthropic-ai/server-postgres --connection-string postgresql://localhost/mydb", timeout=30) -## MCP Integration +# Puppeteer for web testing +terminal(command="claude mcp add puppeteer -- npx @anthropic-ai/server-puppeteer", timeout=30) +``` -Add external tool servers: +### MCP Scopes +| Flag | Scope | Storage | +|------|-------|---------| +| `-s user` | Global (all projects) | `~/.claude.json` | +| `-s local` | This project (personal) | `.claude/settings.local.json` (gitignored) | +| `-s project` | This project (team-shared) | `.claude/settings.json` (git-tracked) | + +### MCP in Print/CI Mode ``` -terminal(command="claude mcp add github -- npx @modelcontextprotocol/server-github", timeout=30) -terminal(command="claude mcp add postgres -- npx @anthropic-ai/server-postgres --connection-string postgresql://localhost/mydb", timeout=30) +terminal(command="claude --bare -p 'Query database' --mcp-config mcp-servers.json --strict-mcp-config", timeout=60) ``` +`--strict-mcp-config` ignores all MCP servers except those from `--mcp-config`. -Scopes: `-s user` (global), `-s local` (project, gitignored), `-s project` (team-shared). +Reference MCP resources in chat: `@github:issue://123` -## Custom Slash Commands +### MCP Limits & Tuning +- **Tool descriptions:** 2KB cap per server for tool descriptions and server instructions +- **Result size:** Default capped; use `maxResultSizeChars` annotation to allow up to **500K** characters for large outputs +- **Output tokens:** `export MAX_MCP_OUTPUT_TOKENS=50000` — cap output from MCP servers to prevent context flooding +- **Transports:** `stdio` (local process), `http` (remote), `sse` (server-sent events) -Create `.claude/commands/.md` for project shortcuts: +## Monitoring Interactive Sessions -```markdown -# .claude/commands/deploy.md -Run the deploy pipeline: -1. Run all tests -2. Build the Docker image -3. Push to registry -4. Update the staging deployment -Environment: $ARGUMENTS (default: staging) +### Reading the TUI Status +``` +# Periodic capture to check if Claude is still working or waiting for input +terminal(command="tmux capture-pane -t dev -p -S -10") ``` -Usage in interactive session: `/deploy production` - -Parameterized with `$ARGUMENTS` for dynamic input. +Look for these indicators: +- `❯` at bottom = waiting for your input (Claude is done or asking a question) +- `●` lines = Claude is actively using tools (reading, writing, running commands) +- `⏵⏵ bypass permissions on` = status bar showing permissions mode +- `◐ medium · /effort` = current effort level in status bar +- `ctrl+o to expand` = tool output was truncated (can be expanded interactively) + +### Context Window Health +Use `/context` in interactive mode to see a colored grid of context usage. Key thresholds: +- **< 70%** — Normal operation, full precision +- **70-85%** — Precision starts dropping, consider `/compact` +- **> 85%** — Hallucination risk spikes significantly, use `/compact` or `/clear` + +## Environment Variables + +| Variable | Effect | +|----------|--------| +| `ANTHROPIC_API_KEY` | API key for authentication (alternative to OAuth) | +| `CLAUDE_CODE_EFFORT_LEVEL` | Default effort: `low`, `medium`, `high`, `max`, or `auto` | +| `MAX_THINKING_TOKENS` | Cap thinking tokens (set to `0` to disable thinking entirely) | +| `MAX_MCP_OUTPUT_TOKENS` | Cap output from MCP servers (default varies; set e.g., `50000`) | +| `CLAUDE_CODE_NO_FLICKER=1` | Enable alt-screen rendering to eliminate terminal flicker | +| `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | Strip credentials from sub-processes for security | ## Cost & Performance Tips @@ -430,9 +708,12 @@ Parameterized with `$ARGUMENTS` for dynamic input. 3. **Use `--effort low`** for simple tasks (faster, cheaper). `high` or `max` for complex reasoning. 4. **Use `--bare`** for CI/scripting to skip plugin/hook discovery overhead. 5. **Use `--allowedTools`** to restrict to only what's needed (e.g., `Read` only for reviews). -6. **Use `/compact`** in interactive sessions when context gets large (precision drops at 70% context usage, hallucinations spike at 85%). +6. **Use `/compact`** in interactive sessions when context gets large. 7. **Pipe input** instead of having Claude read files when you just need analysis of known content. 8. **Use `--model haiku`** for simple tasks (cheaper) and `--model opus` for complex multi-step work. +9. **Use `--fallback-model haiku`** in print mode to gracefully handle model overload. +10. **Start new sessions for distinct tasks** — sessions last 5 hours; fresh context is more efficient. +11. **Use `--no-session-persistence`** in CI to avoid accumulating saved sessions on disk. ## Pitfalls & Gotchas @@ -445,6 +726,9 @@ Parameterized with `$ARGUMENTS` for dynamic input. 7. **`--json-schema` needs enough `--max-turns`** — Claude must read files before producing structured output, which takes multiple turns. 8. **Trust dialog only appears once per directory** — first-time only, then cached. 9. **Background tmux sessions persist** — always clean up with `tmux kill-session -t ` when done. +10. **Slash commands (like `/commit`) only work in interactive mode** — in `-p` mode, describe the task in natural language instead. +11. **`--bare` skips OAuth** — requires `ANTHROPIC_API_KEY` env var or an `apiKeyHelper` in settings. +12. **Context degradation is real** — AI output quality measurably degrades above 70% context window usage. Monitor with `/context` and proactively `/compact`. ## Rules for Hermes Agents From 5b003ca4a00403f02590e362fd7ba7062adfaa3d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:10:16 -0700 Subject: [PATCH 017/213] test(redact): add regression tests for lowercase variable redaction (#4367) (#5185) Add 5 regression tests from PR #4476 (gnanam1990) to prevent re-introducing the IGNORECASE bug that caused lowercase Python/TypeScript variable assignments to be incorrectly redacted as secrets. The core fix landed in 6367e1c4. Tests cover: - Lowercase Python variable with 'token' in name - Lowercase Python variable with 'api_key' in name - TypeScript 'await' not treated as secret value - TypeScript 'secret' variable assignment - 'export' prefix preserved for uppercase env vars Co-authored-by: gnanam1990 --- tests/agent/test_redact.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 6b7cfa586c5e..83b1b4d1a135 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -82,6 +82,38 @@ def test_path_unchanged(self): result = redact_sensitive_text(text) assert result == text + def test_lowercase_python_variable_token_unchanged(self): + # Regression: #4367 — lowercase 'token' assignment must not be redacted + text = "before_tokens = response.usage.prompt_tokens" + result = redact_sensitive_text(text) + assert result == text + + def test_lowercase_python_variable_api_key_unchanged(self): + # Regression: #4367 — lowercase 'api_key' must not be redacted + text = "api_key = config.get('api_key')" + result = redact_sensitive_text(text) + assert result == text + + def test_typescript_await_token_unchanged(self): + # Regression: #4367 — 'await' keyword must not be redacted as a secret value + text = "const token = await getToken();" + result = redact_sensitive_text(text) + assert result == text + + def test_typescript_await_secret_unchanged(self): + # Regression: #4367 — similar pattern with 'secret' variable + text = "const secret = await fetchSecret();" + result = redact_sensitive_text(text) + assert result == text + + def test_export_whitespace_preserved(self): + # Regression: #4367 — whitespace before uppercase env var must be preserved + text = "export SECRET_TOKEN=mypassword" + result = redact_sensitive_text(text) + assert result.startswith("export ") + assert "SECRET_TOKEN=" in result + assert "mypassword" not in result + class TestJsonFields: def test_json_api_key(self): From 560c6ae1433f42b0919cc7f9caf7e1b5d5b66c6d Mon Sep 17 00:00:00 2001 From: Lume Date: Sun, 5 Apr 2026 07:32:04 +0100 Subject: [PATCH 018/213] feat: add _should_sanitize_tool_calls() method Adds a centralized method to determine when tool_calls need sanitization for strict APIs. Returns True for all APIs except codex_responses mode. This prevents 400 errors from providers like Fireworks that reject unknown fields (call_id, response_item_id) in tool_calls. --- run_agent.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/run_agent.py b/run_agent.py index 130e4abd63a6..5b51bc433b7b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5345,6 +5345,19 @@ def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict: ] return api_msg + def _should_sanitize_tool_calls(self) -> bool: + """Determine if tool_calls need sanitization for strict APIs. + + Codex Responses API uses fields like call_id and response_item_id + that are not part of the standard Chat Completions schema. These + fields must be stripped when calling any other API to avoid + validation errors (400 Bad Request). + + Returns: + bool: True if sanitization is needed (non-Codex API), False otherwise. + """ + return self.api_mode != "codex_responses" + def flush_memories(self, messages: list = None, min_turns: int = None): """Give the model one turn to persist memories before context is lost. From 7f6e509199f97e2216831d961910b49b6f50863e Mon Sep 17 00:00:00 2001 From: Lume Date: Sun, 5 Apr 2026 07:32:21 +0100 Subject: [PATCH 019/213] refactor: use _should_sanitize_tool_calls in flush_memories() Replaces hardcoded Mistral check with the new _should_sanitize_tool_calls() method. This ensures tool_calls are sanitized for all strict APIs, not just Mistral. Prevents 400 errors from Fireworks and other providers. --- run_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 5b51bc433b7b..564508d5880b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5396,7 +5396,7 @@ def flush_memories(self, messages: list = None, min_turns: int = None): try: # Build API messages for the flush call - _is_strict_api = "api.mistral.ai" in self._base_url_lower + _needs_sanitize = self._should_sanitize_tool_calls() api_messages = [] for msg in messages: api_msg = msg.copy() @@ -5407,7 +5407,7 @@ def flush_memories(self, messages: list = None, min_turns: int = None): api_msg.pop("reasoning", None) api_msg.pop("finish_reason", None) api_msg.pop("_flush_sentinel", None) - if _is_strict_api: + if _needs_sanitize: self._sanitize_tool_calls_for_strict_api(api_msg) api_messages.append(api_msg) From 234c01f69057f1ca4a221320873b1ff180b88689 Mon Sep 17 00:00:00 2001 From: Lume Date: Sun, 5 Apr 2026 07:32:31 +0100 Subject: [PATCH 020/213] refactor: use _should_sanitize_tool_calls in _handle_max_iterations() Replaces hardcoded Mistral check with the new _should_sanitize_tool_calls() method. Ensures summary generation works correctly with Fireworks and other strict APIs that reject unknown tool_call fields. --- run_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 564508d5880b..e4b82b0733de 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6282,13 +6282,13 @@ def _handle_max_iterations(self, messages: list, api_call_count: int) -> str: try: # Build API messages, stripping internal-only fields # (finish_reason, reasoning) that strict APIs like Mistral reject with 422 - _is_strict_api = "api.mistral.ai" in self._base_url_lower + _needs_sanitize = self._should_sanitize_tool_calls() api_messages = [] for msg in messages: api_msg = msg.copy() for internal_field in ("reasoning", "finish_reason"): api_msg.pop(internal_field, None) - if _is_strict_api: + if _needs_sanitize: self._sanitize_tool_calls_for_strict_api(api_msg) api_messages.append(api_msg) From d90035835bb23ce8978bdb7e7ff39e5a59c72e03 Mon Sep 17 00:00:00 2001 From: Lume Date: Sun, 5 Apr 2026 07:32:50 +0100 Subject: [PATCH 021/213] refactor: use _should_sanitize_tool_calls in run_conversation() Replaces hardcoded Mistral check with the new _should_sanitize_tool_calls() method. Updates comment to mention Fireworks alongside Mistral as strict APIs requiring tool_call field sanitization. --- run_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index e4b82b0733de..d374e080f491 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6810,10 +6810,10 @@ def run_conversation( if "finish_reason" in api_msg: api_msg.pop("finish_reason") # Strip Codex Responses API fields (call_id, response_item_id) for - # strict providers like Mistral that reject unknown fields with 422. + # strict providers like Mistral, Fireworks, etc. that reject unknown fields. # Uses new dicts so the internal messages list retains the fields # for Codex Responses compatibility. - if "api.mistral.ai" in self._base_url_lower: + if self._should_sanitize_tool_calls(): self._sanitize_tool_calls_for_strict_api(api_msg) # Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context # The signature field helps maintain reasoning continuity From 9be2b180641d29f59130c972efe28364e4d4ce66 Mon Sep 17 00:00:00 2001 From: Lume Date: Sun, 5 Apr 2026 07:33:10 +0100 Subject: [PATCH 022/213] test: add test for _should_sanitize_tool_calls() Adds test verifying that: - Codex mode returns False (no sanitization needed) - Chat completions mode returns True (sanitization needed) - Anthropic mode returns True (sanitization needed) This ensures strict APIs like Fireworks receive properly sanitized tool_calls. --- tests/test_provider_parity.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_provider_parity.py b/tests/test_provider_parity.py index 0d36a89baaa1..0029376abba3 100644 --- a/tests/test_provider_parity.py +++ b/tests/test_provider_parity.py @@ -137,6 +137,23 @@ def test_strips_codex_only_tool_call_fields_from_chat_messages(self, monkeypatch assert messages[1]["tool_calls"][0]["response_item_id"] == "fc_123" assert "codex_reasoning_items" in messages[1] + def test_should_sanitize_tool_calls_codex_vs_chat(self, monkeypatch): + """Codex API should NOT sanitize, all other APIs should sanitize.""" + # Codex mode should NOT need sanitization + codex_agent = _make_agent(monkeypatch, "openrouter") + codex_agent.api_mode = "codex_responses" + assert codex_agent._should_sanitize_tool_calls() is False + + # Chat completions mode should need sanitization + chat_agent = _make_agent(monkeypatch, "openrouter") + chat_agent.api_mode = "chat_completions" + assert chat_agent._should_sanitize_tool_calls() is True + + # Anthropic mode should need sanitization + anthropic_agent = _make_agent(monkeypatch, "openrouter") + anthropic_agent.api_mode = "anthropic_messages" + assert anthropic_agent._should_sanitize_tool_calls() is True + class TestDeveloperRoleSwap: """GPT-5 and Codex models should get 'developer' instead of 'system' role.""" From 8545343cba26870601e38b0996214e63beef286c Mon Sep 17 00:00:00 2001 From: Lume Date: Sun, 5 Apr 2026 07:33:54 +0100 Subject: [PATCH 023/213] test: add strict API validation tests for Fireworks compatibility Adds comprehensive tests verifying: - Fireworks-compatible messages after sanitization - Codex mode preserves fields for Responses API replay - Fireworks provider triggers sanitization correctly - Codex responses mode correctly skips sanitization Prevents regression of 400 validation errors on strict APIs. --- tests/test_strict_api_validation.py | 144 ++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/test_strict_api_validation.py diff --git a/tests/test_strict_api_validation.py b/tests/test_strict_api_validation.py new file mode 100644 index 000000000000..a4a53d97db3c --- /dev/null +++ b/tests/test_strict_api_validation.py @@ -0,0 +1,144 @@ +"""Test validation error prevention for strict APIs (Fireworks, etc.)""" + +import sys +import types +from unittest.mock import patch, MagicMock + +import pytest + +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + +from run_agent import AIAgent + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _tool_defs(*names): + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +class _FakeOpenAI: + def __init__(self, **kw): + self.api_key = kw.get("api_key", "test") + self.base_url = kw.get("base_url", "http://test") + + def close(self): + pass + + +def _make_agent(monkeypatch, provider, api_mode="chat_completions", base_url="https://openrouter.ai/api/v1"): + monkeypatch.setattr("run_agent.get_tool_definitions", lambda **kw: _tool_defs("web_search", "terminal")) + monkeypatch.setattr("run_agent.check_toolset_requirements", lambda: {}) + monkeypatch.setattr("run_agent.OpenAI", _FakeOpenAI) + return AIAgent( + api_key="test", + base_url=base_url, + provider=provider, + api_mode=api_mode, + max_iterations=4, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + +class TestStrictApiValidation: + """Verify tool_call field sanitization prevents 400 errors on strict APIs.""" + + def test_fireworks_compatible_messages_after_sanitization(self, monkeypatch): + """Messages should be Fireworks-compatible after sanitization.""" + agent = _make_agent(monkeypatch, "openrouter") + agent.api_mode = "chat_completions" # Fireworks uses chat completions + + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "Checking now.", + "tool_calls": [ + { + "id": "call_123", + "call_id": "call_123", # Codex-only field + "response_item_id": "fc_123", # Codex-only field + "type": "function", + "function": {"name": "terminal", "arguments": '{"command":"pwd"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_123", "content": "/tmp"}, + ] + + # After _build_api_kwargs, Codex fields should be stripped + kwargs = agent._build_api_kwargs(messages) + + assistant_msg = kwargs["messages"][1] + tool_call = assistant_msg["tool_calls"][0] + + # Fireworks rejects these fields + assert "call_id" not in tool_call + assert "response_item_id" not in tool_call + # Standard fields should remain + assert tool_call["id"] == "call_123" + assert tool_call["function"]["name"] == "terminal" + + def test_codex_preserves_fields_for_replay(self, monkeypatch): + """Codex mode should preserve fields for Responses API replay.""" + agent = _make_agent(monkeypatch, "openrouter") + agent.api_mode = "codex_responses" + + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "Checking now.", + "tool_calls": [ + { + "id": "call_123", + "call_id": "call_123", + "response_item_id": "fc_123", + "type": "function", + "function": {"name": "terminal", "arguments": '{"command":"pwd"}'}, + } + ], + }, + ] + + # In Codex mode, original messages should NOT be mutated + assert messages[1]["tool_calls"][0]["call_id"] == "call_123" + assert messages[1]["tool_calls"][0]["response_item_id"] == "fc_123" + + def test_sanitize_method_with_fireworks_provider(self, monkeypatch): + """Simulating Fireworks provider should trigger sanitization.""" + agent = _make_agent( + monkeypatch, + "fireworks", + api_mode="chat_completions", + base_url="https://api.fireworks.ai/inference/v1" + ) + + # Should sanitize for Fireworks (chat_completions mode) + assert agent._should_sanitize_tool_calls() is True + + def test_no_sanitize_for_codex_responses(self, monkeypatch): + """Codex responses mode should NOT sanitize.""" + agent = _make_agent( + monkeypatch, + "openai", + api_mode="codex_responses", + base_url="https://api.openai.com/v1" + ) + + # Should NOT sanitize for Codex + assert agent._should_sanitize_tool_calls() is False From ed4a605696b54522cb8c88f7c89a8544c32165fd Mon Sep 17 00:00:00 2001 From: Lume Date: Sun, 5 Apr 2026 07:34:48 +0100 Subject: [PATCH 024/213] docs: update docstring to mention Fireworks strict validation Updates _sanitize_tool_calls_for_strict_api docstring to explicitly mention Fireworks alongside Mistral as strict APIs requiring sanitization. Also documents the specific fields that are stripped (call_id, response_item_id). --- run_agent.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/run_agent.py b/run_agent.py index d374e080f491..80fc340a3ebc 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5324,15 +5324,18 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict: """Strip Codex Responses API fields from tool_calls for strict providers. - Providers like Mistral strictly validate the Chat Completions schema - and reject unknown fields (call_id, response_item_id) with 422. - These fields are preserved in the internal message history — this - method only modifies the outgoing API copy. + Providers like Mistral, Fireworks, and other strict OpenAI-compatible APIs + validate the Chat Completions schema and reject unknown fields (call_id, + response_item_id) with 400 or 422 errors. These fields are preserved in + the internal message history — this method only modifies the outgoing + API copy. Creates new tool_call dicts rather than mutating in-place, so the original messages list retains call_id/response_item_id for Codex Responses API compatibility (e.g. if the session falls back to a Codex provider later). + + Fields stripped: call_id, response_item_id """ tool_calls = api_msg.get("tool_calls") if not isinstance(tool_calls, list): From 65952ac00c66b2724402a5528185f24ac94ca982 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:03:20 +0530 Subject: [PATCH 025/213] Honor provider reset windows in pooled credential failover Persist structured exhaustion metadata from provider errors, use explicit reset timestamps when available, and expose label-based credential targeting in the auth CLI. This keeps long-lived Codex cooldowns from being misreported as one-hour waits and avoids forcing operators to manage entries by list position alone. Constraint: Existing credential pool JSON needs to remain backward compatible with stored entries that only record status code and timestamp Constraint: Runtime recovery must keep the existing retry-then-rotate semantics for 429s while enriching pool state with provider metadata Rejected: Add a separate credential scheduler subsystem | too large for the Hermes pool architecture and unnecessary for this fix Rejected: Only change CLI formatting | would leave runtime rotation blind to resets_at and preserve the serial-failure behavior Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve structured rate-limit metadata when new providers expose reset hints; do not collapse back to status-code-only exhaustion tracking Tested: Focused pytest slice for auth commands, credential pool recovery, and routing (272 passed); py_compile on changed Python files; hermes -w auth list/remove smoke test with temporary HERMES_HOME Not-tested: Full repository pytest suite, broader gateway/integration flows outside the touched auth and pool paths --- agent/credential_pool.py | 174 ++++++++++++++++++++++++-- hermes_cli/auth_commands.py | 51 +++++--- hermes_cli/main.py | 6 +- run_agent.py | 76 ++++++++++- tests/test_auth_commands.py | 84 ++++++++++++- tests/test_credential_pool.py | 33 +++++ tests/test_credential_pool_routing.py | 6 +- tests/test_run_agent.py | 58 ++++++++- 8 files changed, 446 insertions(+), 42 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 2cf9efe56206..cfdf9b2aca28 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -8,7 +8,9 @@ import time import uuid import os +import re from dataclasses import dataclass, fields, replace +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import OPENROUTER_BASE_URL @@ -95,6 +97,9 @@ class PooledCredential: last_status: Optional[str] = None last_status_at: Optional[float] = None last_error_code: Optional[int] = None + last_error_reason: Optional[str] = None + last_error_message: Optional[str] = None + last_error_reset_at: Optional[float] = None base_url: Optional[str] = None expires_at: Optional[str] = None expires_at_ms: Optional[int] = None @@ -129,7 +134,14 @@ def from_dict(cls, provider: str, payload: Dict[str, Any]) -> "PooledCredential" return cls(provider=provider, **data) def to_dict(self) -> Dict[str, Any]: - _ALWAYS_EMIT = {"last_status", "last_status_at", "last_error_code"} + _ALWAYS_EMIT = { + "last_status", + "last_status_at", + "last_error_code", + "last_error_reason", + "last_error_message", + "last_error_reset_at", + } result: Dict[str, Any] = {} for field_def in fields(self): if field_def.name in ("provider", "extra"): @@ -180,6 +192,85 @@ def _exhausted_ttl(error_code: Optional[int]) -> int: return EXHAUSTED_TTL_DEFAULT_SECONDS +def _parse_absolute_timestamp(value: Any) -> Optional[float]: + """Best-effort parse for provider reset timestamps. + + Accepts epoch seconds, epoch milliseconds, and ISO-8601 strings. + Returns seconds since epoch. + """ + if value is None or value == "": + return None + if isinstance(value, (int, float)): + numeric = float(value) + if numeric <= 0: + return None + return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + numeric = float(raw) + except ValueError: + numeric = None + if numeric is not None: + return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + +def _extract_retry_delay_seconds(message: str) -> Optional[float]: + if not message: + return None + delay_match = re.search(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", message, re.IGNORECASE) + if delay_match: + value = float(delay_match.group(1)) + return value / 1000.0 if delay_match.group(2).lower() == "ms" else value + sec_match = re.search(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", message, re.IGNORECASE) + if sec_match: + return float(sec_match.group(1)) + return None + + +def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not isinstance(error_context, dict): + return {} + normalized: Dict[str, Any] = {} + reason = error_context.get("reason") + if isinstance(reason, str) and reason.strip(): + normalized["reason"] = reason.strip() + message = error_context.get("message") + if isinstance(message, str) and message.strip(): + normalized["message"] = message.strip() + reset_at = ( + error_context.get("reset_at") + or error_context.get("resets_at") + or error_context.get("retry_until") + ) + parsed_reset_at = _parse_absolute_timestamp(reset_at) + if parsed_reset_at is None and isinstance(message, str): + retry_delay_seconds = _extract_retry_delay_seconds(message) + if retry_delay_seconds is not None: + parsed_reset_at = time.time() + retry_delay_seconds + if parsed_reset_at is not None: + normalized["reset_at"] = parsed_reset_at + return normalized + + +def _exhausted_until(entry: PooledCredential) -> Optional[float]: + if entry.last_status != STATUS_EXHAUSTED: + return None + reset_at = _parse_absolute_timestamp(getattr(entry, "last_error_reset_at", None)) + if reset_at is not None: + return reset_at + if entry.last_status_at: + return entry.last_status_at + _exhausted_ttl(entry.last_error_code) + return None + + def _normalize_custom_pool_name(name: str) -> str: """Normalize a custom provider name for use as a pool key suffix.""" return name.strip().lower().replace(" ", "-") @@ -292,12 +383,21 @@ def _persist(self) -> None: [entry.to_dict() for entry in self._entries], ) - def _mark_exhausted(self, entry: PooledCredential, status_code: Optional[int]) -> PooledCredential: + def _mark_exhausted( + self, + entry: PooledCredential, + status_code: Optional[int], + error_context: Optional[Dict[str, Any]] = None, + ) -> PooledCredential: + normalized_error = _normalize_error_context(error_context) updated = replace( entry, last_status=STATUS_EXHAUSTED, last_status_at=time.time(), last_error_code=status_code, + last_error_reason=normalized_error.get("reason"), + last_error_message=normalized_error.get("message"), + last_error_reset_at=normalized_error.get("reset_at"), ) self._replace_entry(entry, updated) self._persist() @@ -462,7 +562,15 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po self._mark_exhausted(entry, None) return None - updated = replace(updated, last_status=STATUS_OK, last_status_at=None, last_error_code=None) + updated = replace( + updated, + last_status=STATUS_OK, + last_status_at=None, + last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, + ) self._replace_entry(entry, updated) self._persist() return updated @@ -522,11 +630,19 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal entry = synced cleared_any = True if entry.last_status == STATUS_EXHAUSTED: - ttl = _exhausted_ttl(entry.last_error_code) - if entry.last_status_at and now - entry.last_status_at < ttl: + exhausted_until = _exhausted_until(entry) + if exhausted_until is not None and now < exhausted_until: continue if clear_expired: - cleared = replace(entry, last_status=STATUS_OK, last_status_at=None, last_error_code=None) + cleared = replace( + entry, + last_status=STATUS_OK, + last_status_at=None, + last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, + ) self._replace_entry(entry, cleared) entry = cleared cleared_any = True @@ -576,12 +692,17 @@ def peek(self) -> Optional[PooledCredential]: available = self._available_entries() return available[0] if available else None - def mark_exhausted_and_rotate(self, *, status_code: Optional[int]) -> Optional[PooledCredential]: + def mark_exhausted_and_rotate( + self, + *, + status_code: Optional[int], + error_context: Optional[Dict[str, Any]] = None, + ) -> Optional[PooledCredential]: with self._lock: entry = self.current() or self._select_unlocked() if entry is None: return None - self._mark_exhausted(entry, status_code) + self._mark_exhausted(entry, status_code, error_context) self._current_id = None return self._select_unlocked() @@ -603,7 +724,17 @@ def reset_statuses(self) -> int: new_entries = [] for entry in self._entries: if entry.last_status or entry.last_status_at or entry.last_error_code: - new_entries.append(replace(entry, last_status=None, last_status_at=None, last_error_code=None)) + new_entries.append( + replace( + entry, + last_status=None, + last_status_at=None, + last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, + ) + ) count += 1 else: new_entries.append(entry) @@ -625,6 +756,31 @@ def remove_index(self, index: int) -> Optional[PooledCredential]: self._current_id = None return removed + def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]: + raw = str(target or "").strip() + if not raw: + return None, None, "No credential target provided." + if raw.isdigit(): + index = int(raw) + if 1 <= index <= len(self._entries): + return index, self._entries[index - 1], None + return None, None, f"No credential #{index}." + + for idx, entry in enumerate(self._entries, start=1): + if entry.id == raw: + return idx, entry, None + + label_matches = [ + (idx, entry) + for idx, entry in enumerate(self._entries, start=1) + if entry.label.strip().lower() == raw.lower() + ] + if len(label_matches) == 1: + return label_matches[0][0], label_matches[0][1], None + if len(label_matches) > 1: + return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.' + return None, None, f'No credential matching "{raw}".' + def add_entry(self, entry: PooledCredential) -> PooledCredential: entry = replace(entry, priority=_next_priority(self._entries)) self._entries.append(entry) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 0963877461b7..62b9562f3c9f 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -20,12 +20,12 @@ STRATEGY_LEAST_USED, SUPPORTED_POOL_STRATEGIES, PooledCredential, + _exhausted_until, _normalize_custom_pool_name, get_pool_strategy, label_from_token, list_custom_pool_providers, load_pool, - _exhausted_ttl, ) import hermes_cli.auth as auth_mod from hermes_cli.auth import PROVIDER_REGISTRY @@ -113,21 +113,27 @@ def _display_source(source: str) -> str: def _format_exhausted_status(entry) -> str: if entry.last_status != STATUS_EXHAUSTED: return "" + reason = getattr(entry, "last_error_reason", None) + reason_text = f" {reason}" if isinstance(reason, str) and reason.strip() else "" code = f" ({entry.last_error_code})" if entry.last_error_code else "" - if not entry.last_status_at: - return f" exhausted{code}" - remaining = max(0, int(math.ceil((entry.last_status_at + _exhausted_ttl(entry.last_error_code)) - time.time()))) + exhausted_until = _exhausted_until(entry) + if exhausted_until is None: + return f" exhausted{reason_text}{code}" + remaining = max(0, int(math.ceil(exhausted_until - time.time()))) if remaining <= 0: - return f" exhausted{code} (ready to retry)" + return f" exhausted{reason_text}{code} (ready to retry)" minutes, seconds = divmod(remaining, 60) hours, minutes = divmod(minutes, 60) - if hours: + days, hours = divmod(hours, 24) + if days: + wait = f"{days}d {hours}h" + elif hours: wait = f"{hours}h {minutes}m" elif minutes: wait = f"{minutes}m {seconds}s" else: wait = f"{seconds}s" - return f" exhausted{code} ({wait} left)" + return f" exhausted{reason_text}{code} ({wait} left)" def auth_add_command(args) -> None: @@ -277,11 +283,16 @@ def auth_list_command(args) -> None: def auth_remove_command(args) -> None: provider = _normalize_provider(getattr(args, "provider", "")) - index = int(getattr(args, "index")) + target = getattr(args, "target", None) + if target is None: + target = getattr(args, "index", None) pool = load_pool(provider) + index, matched, error = pool.resolve_target(target) + if matched is None or index is None: + raise SystemExit(f"{error} Provider: {provider}.") removed = pool.remove_index(index) if removed is None: - raise SystemExit(f"No credential #{index} for provider {provider}.") + raise SystemExit(f'No credential matching "{target}" for provider {provider}.') print(f"Removed {provider} credential #{index} ({removed.label})") @@ -369,8 +380,16 @@ def _interactive_add() -> None: else: auth_type = "api_key" + label = None + try: + typed_label = input("Label / account name (optional): ").strip() + except (EOFError, KeyboardInterrupt): + return + if typed_label: + label = typed_label + auth_add_command(SimpleNamespace( - provider=provider, auth_type=auth_type, label=None, api_key=None, + provider=provider, auth_type=auth_type, label=label, api_key=None, portal_url=None, inference_url=None, client_id=None, scope=None, no_browser=False, timeout=None, insecure=False, ca_bundle=None, )) @@ -386,22 +405,16 @@ def _interactive_remove() -> None: # Show entries with indices for i, e in enumerate(pool.entries(), 1): exhausted = _format_exhausted_status(e) - print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted}") + print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted} [id:{e.id}]") try: - raw = input("Remove # (or blank to cancel): ").strip() + raw = input("Remove #, id, or label (blank to cancel): ").strip() except (EOFError, KeyboardInterrupt): return if not raw: return - try: - index = int(raw) - except ValueError: - print("Invalid number.") - return - - auth_remove_command(SimpleNamespace(provider=provider, index=index)) + auth_remove_command(SimpleNamespace(provider=provider, target=raw)) def _interactive_reset() -> None: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3befd835cdfb..91f97d45058b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3943,7 +3943,7 @@ def main(): hermes logout Clear stored authentication hermes auth add Add a pooled credential hermes auth list List pooled credentials - hermes auth remove

Remove pooled credential by index + hermes auth remove

Remove pooled credential by index, id, or label hermes auth reset Clear exhaustion status for a provider hermes model Select default model hermes config View configuration @@ -4333,9 +4333,9 @@ def main(): auth_add.add_argument("--ca-bundle", help="Custom CA bundle for OAuth login") auth_list = auth_subparsers.add_parser("list", help="List pooled credentials") auth_list.add_argument("provider", nargs="?", help="Optional provider filter") - auth_remove = auth_subparsers.add_parser("remove", help="Remove a pooled credential by index") + auth_remove = auth_subparsers.add_parser("remove", help="Remove a pooled credential by index, id, or label") auth_remove.add_argument("provider", help="Provider id") - auth_remove.add_argument("index", type=int, help="1-based credential index") + auth_remove.add_argument("target", help="Credential index, entry id, or exact label") auth_reset = auth_subparsers.add_parser("reset", help="Clear exhaustion status for all credentials for a provider") auth_reset.add_argument("provider", help="Provider id") auth_parser.set_defaults(func=cmd_auth) diff --git a/run_agent.py b/run_agent.py index 80fc340a3ebc..34e4ca71e873 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2138,6 +2138,73 @@ def _clean_error_message(self, error_msg: str) -> str: return cleaned + @staticmethod + def _extract_api_error_context(error: Exception) -> Dict[str, Any]: + """Extract structured rate-limit details from provider errors.""" + context: Dict[str, Any] = {} + + body = getattr(error, "body", None) + payload = None + if isinstance(body, dict): + payload = body.get("error") if isinstance(body.get("error"), dict) else body + if isinstance(payload, dict): + reason = payload.get("code") or payload.get("error") + if isinstance(reason, str) and reason.strip(): + context["reason"] = reason.strip() + message = payload.get("message") or payload.get("error_description") + if isinstance(message, str) and message.strip(): + context["message"] = message.strip() + for key in ("resets_at", "reset_at"): + value = payload.get(key) + if value not in (None, ""): + context["reset_at"] = value + break + retry_after = payload.get("retry_after") + if retry_after not in (None, "") and "reset_at" not in context: + try: + context["reset_at"] = time.time() + float(retry_after) + except (TypeError, ValueError): + pass + + response = getattr(error, "response", None) + headers = getattr(response, "headers", None) + if headers: + retry_after = headers.get("retry-after") or headers.get("Retry-After") + if retry_after and "reset_at" not in context: + try: + context["reset_at"] = time.time() + float(retry_after) + except (TypeError, ValueError): + pass + ratelimit_reset = headers.get("x-ratelimit-reset") + if ratelimit_reset and "reset_at" not in context: + context["reset_at"] = ratelimit_reset + + if "message" not in context: + raw_message = str(error).strip() + if raw_message: + context["message"] = raw_message[:500] + + if "reset_at" not in context: + message = context.get("message") or "" + if isinstance(message, str): + import re as _re + + delay_match = _re.search(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", message, _re.IGNORECASE) + if delay_match: + value = float(delay_match.group(1)) + seconds = value / 1000.0 if delay_match.group(2).lower() == "ms" else value + context["reset_at"] = time.time() + seconds + else: + sec_match = _re.search( + r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", + message, + _re.IGNORECASE, + ) + if sec_match: + context["reset_at"] = time.time() + float(sec_match.group(1)) + + return context + def _dump_api_request_debug( self, api_kwargs: Dict[str, Any], @@ -3846,6 +3913,7 @@ def _recover_with_credential_pool( *, status_code: Optional[int], has_retried_429: bool, + error_context: Optional[Dict[str, Any]] = None, ) -> tuple[bool, bool]: """Attempt credential recovery via pool rotation. @@ -3860,7 +3928,7 @@ def _recover_with_credential_pool( return False, has_retried_429 if status_code == 402: - next_entry = pool.mark_exhausted_and_rotate(status_code=402) + next_entry = pool.mark_exhausted_and_rotate(status_code=402, error_context=error_context) if next_entry is not None: logger.info(f"Credential 402 (billing) — rotated to pool entry {getattr(next_entry, 'id', '?')}") self._swap_credential(next_entry) @@ -3870,7 +3938,7 @@ def _recover_with_credential_pool( if status_code == 429: if not has_retried_429: return False, True - next_entry = pool.mark_exhausted_and_rotate(status_code=429) + next_entry = pool.mark_exhausted_and_rotate(status_code=429, error_context=error_context) if next_entry is not None: logger.info(f"Credential 429 (rate limit) — rotated to pool entry {getattr(next_entry, 'id', '?')}") self._swap_credential(next_entry) @@ -3885,7 +3953,7 @@ def _recover_with_credential_pool( return True, has_retried_429 # Refresh failed — rotate to next credential instead of giving up. # The failed entry is already marked exhausted by try_refresh_current(). - next_entry = pool.mark_exhausted_and_rotate(status_code=401) + next_entry = pool.mark_exhausted_and_rotate(status_code=401, error_context=error_context) if next_entry is not None: logger.info(f"Credential 401 (refresh failed) — rotated to pool entry {getattr(next_entry, 'id', '?')}") self._swap_credential(next_entry) @@ -7377,9 +7445,11 @@ def _stop_spinner(): # prompt or prefill. Fall through to normal error path. status_code = getattr(api_error, "status_code", None) + error_context = self._extract_api_error_context(api_error) recovered_with_pool, has_retried_429 = self._recover_with_credential_pool( status_code=status_code, has_retried_429=has_retried_429, + error_context=error_context, ) if recovered_with_pool: continue diff --git a/tests/test_auth_commands.py b/tests/test_auth_commands.py index c55629404614..bd40cb885549 100644 --- a/tests/test_auth_commands.py +++ b/tests/test_auth_commands.py @@ -4,6 +4,7 @@ import base64 import json +from datetime import datetime, timezone import pytest @@ -224,7 +225,7 @@ def test_auth_remove_reindexes_priorities(tmp_path, monkeypatch): class _Args: provider = "anthropic" - index = 1 + target = "1" auth_remove_command(_Args()) @@ -235,6 +236,49 @@ class _Args: assert entries[0]["priority"] == 0 +def test_auth_remove_accepts_label_target(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-1", + "label": "work-account", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "tok-1", + }, + { + "id": "cred-2", + "label": "personal-account", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "tok-2", + }, + ] + }, + }, + ) + + from hermes_cli.auth_commands import auth_remove_command + + class _Args: + provider = "openai-codex" + target = "personal-account" + + auth_remove_command(_Args()) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + entries = payload["credential_pool"]["openai-codex"] + assert len(entries) == 1 + assert entries[0]["label"] == "work-account" + + def test_auth_reset_clears_provider_statuses(tmp_path, monkeypatch, capsys): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store( @@ -389,3 +433,41 @@ class _Args: out = capsys.readouterr().out assert "exhausted (429)" in out assert "59m 30s left" in out + + +def test_auth_list_prefers_explicit_reset_time(monkeypatch, capsys): + from hermes_cli.auth_commands import auth_list_command + + class _Entry: + id = "cred-1" + label = "weekly" + auth_type = "oauth" + source = "manual:device_code" + last_status = "exhausted" + last_error_code = 429 + last_error_reason = "device_code_exhausted" + last_error_message = "Weekly credits exhausted." + last_error_reset_at = "2026-04-12T10:30:00Z" + last_status_at = 1000.0 + + class _Pool: + def entries(self): + return [_Entry()] + + def peek(self): + return None + + monkeypatch.setattr("hermes_cli.auth_commands.load_pool", lambda provider: _Pool()) + monkeypatch.setattr( + "hermes_cli.auth_commands.time.time", + lambda: datetime(2026, 4, 5, 10, 30, tzinfo=timezone.utc).timestamp(), + ) + + class _Args: + provider = "openai-codex" + + auth_list_command(_Args()) + + out = capsys.readouterr().out + assert "device_code_exhausted" in out + assert "7d 0h left" in out diff --git a/tests/test_credential_pool.py b/tests/test_credential_pool.py index 14302ab13f7e..ff6e037be306 100644 --- a/tests/test_credential_pool.py +++ b/tests/test_credential_pool.py @@ -214,6 +214,39 @@ def test_exhausted_entry_resets_after_ttl(tmp_path, monkeypatch): assert entry.last_status == "ok" +def test_explicit_reset_timestamp_overrides_default_429_ttl(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-1", + "label": "weekly-reset", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "tok-1", + "last_status": "exhausted", + "last_status_at": time.time() - 7200, + "last_error_code": 429, + "last_error_reason": "device_code_exhausted", + "last_error_reset_at": time.time() + 7 * 24 * 60 * 60, + } + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("openai-codex") + assert pool.has_available() is False + assert pool.select() is None + + def test_mark_exhausted_and_rotate_persists_status(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store( diff --git a/tests/test_credential_pool_routing.py b/tests/test_credential_pool_routing.py index f4006a236ff3..38f5c6dfd058 100644 --- a/tests/test_credential_pool_routing.py +++ b/tests/test_credential_pool_routing.py @@ -275,7 +275,7 @@ def _make_agent_with_pool(self, pool_entries=3): # mark_exhausted_and_rotate returns next entry until exhausted self._rotation_index = 0 - def rotate(status_code=None): + def rotate(status_code=None, error_context=None): self._rotation_index += 1 if self._rotation_index < pool_entries: return entries[self._rotation_index] @@ -307,7 +307,7 @@ def test_second_429_rotates_to_next(self): ) assert recovered is True assert has_retried is False # reset after rotation - pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=429) + pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=429, error_context=None) agent._swap_credential.assert_called_once_with(entries[1]) def test_pool_exhaustion_returns_false(self): @@ -333,7 +333,7 @@ def test_402_immediate_rotation(self): ) assert recovered is True assert has_retried is False - pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=402) + pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=402, error_context=None) def test_no_pool_returns_false(self): """No pool should return (False, unchanged).""" diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 9217117e2570..963ee56f313f 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1956,8 +1956,9 @@ class _Pool: def current(self): return current - def mark_exhausted_and_rotate(self, *, status_code): + def mark_exhausted_and_rotate(self, *, status_code, error_context=None): assert status_code == 402 + assert error_context is None return next_entry agent._credential_pool = _Pool() @@ -1979,8 +1980,9 @@ class _Pool: def current(self): return SimpleNamespace(label="primary") - def mark_exhausted_and_rotate(self, *, status_code): + def mark_exhausted_and_rotate(self, *, status_code, error_context=None): assert status_code == 429 + assert error_context is None return next_entry agent._credential_pool = _Pool() @@ -2030,8 +2032,9 @@ class _Pool: def try_refresh_current(self): return None # refresh failed - def mark_exhausted_and_rotate(self, *, status_code): + def mark_exhausted_and_rotate(self, *, status_code, error_context=None): assert status_code == 401 + assert error_context is None return next_entry agent._credential_pool = _Pool() @@ -2053,7 +2056,8 @@ class _Pool: def try_refresh_current(self): return None - def mark_exhausted_and_rotate(self, *, status_code): + def mark_exhausted_and_rotate(self, *, status_code, error_context=None): + assert error_context is None return None # no more credentials agent._credential_pool = _Pool() @@ -2067,6 +2071,52 @@ def mark_exhausted_and_rotate(self, *, status_code): assert recovered is False agent._swap_credential.assert_not_called() + def test_extract_api_error_context_uses_reset_timestamp_and_reason(self, agent): + response = SimpleNamespace(headers={}) + error = SimpleNamespace( + body={ + "error": { + "code": "device_code_exhausted", + "message": "Weekly credits exhausted.", + "resets_at": "2026-04-12T10:30:00Z", + } + }, + response=response, + ) + + context = agent._extract_api_error_context(error) + + assert context["reason"] == "device_code_exhausted" + assert context["message"] == "Weekly credits exhausted." + assert context["reset_at"] == "2026-04-12T10:30:00Z" + + def test_recover_with_pool_passes_error_context_on_rotated_429(self, agent): + next_entry = SimpleNamespace(label="secondary") + captured = {} + + class _Pool: + def current(self): + return SimpleNamespace(label="primary") + + def mark_exhausted_and_rotate(self, *, status_code, error_context=None): + captured["status_code"] = status_code + captured["error_context"] = error_context + return next_entry + + agent._credential_pool = _Pool() + agent._swap_credential = MagicMock() + + recovered, retry_same = agent._recover_with_credential_pool( + status_code=429, + has_retried_429=True, + error_context={"reason": "device_code_exhausted", "reset_at": "2026-04-12T10:30:00Z"}, + ) + + assert recovered is True + assert retry_same is False + assert captured["status_code"] == 429 + assert captured["error_context"]["reason"] == "device_code_exhausted" + class TestMaxTokensParam: """Verify _max_tokens_param returns the correct key for each provider.""" From 4437354198dcd282841da7ca60f9cdf3553965cc Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:16:20 +0530 Subject: [PATCH 026/213] Preserve numeric credential labels in auth removal Resolve exact label matches before treating digit-only input as a positional index so destructive auth removal does not mis-target credentials named with numeric labels. Constraint: The CLI remove path must keep supporting existing index-based usage while adding safer label targeting Rejected: Ban numeric labels | labels are free-form and existing users may already rely on them Confidence: high Scope-risk: narrow Reversibility: clean Directive: When a destructive command accepts multiple identifier forms, prefer exact identity matches before fallback parsing heuristics Tested: Focused pytest slice for auth commands, credential pool recovery, and routing (273 passed); py_compile on changed Python files Not-tested: Full repository pytest suite --- agent/credential_pool.py | 10 ++++---- tests/test_auth_commands.py | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index cfdf9b2aca28..311abea98f7f 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -760,11 +760,6 @@ def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCre raw = str(target or "").strip() if not raw: return None, None, "No credential target provided." - if raw.isdigit(): - index = int(raw) - if 1 <= index <= len(self._entries): - return index, self._entries[index - 1], None - return None, None, f"No credential #{index}." for idx, entry in enumerate(self._entries, start=1): if entry.id == raw: @@ -779,6 +774,11 @@ def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCre return label_matches[0][0], label_matches[0][1], None if len(label_matches) > 1: return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.' + if raw.isdigit(): + index = int(raw) + if 1 <= index <= len(self._entries): + return index, self._entries[index - 1], None + return None, None, f"No credential #{index}." return None, None, f'No credential matching "{raw}".' def add_entry(self, entry: PooledCredential) -> PooledCredential: diff --git a/tests/test_auth_commands.py b/tests/test_auth_commands.py index bd40cb885549..a89bd408117b 100644 --- a/tests/test_auth_commands.py +++ b/tests/test_auth_commands.py @@ -279,6 +279,56 @@ class _Args: assert entries[0]["label"] == "work-account" +def test_auth_remove_prefers_exact_numeric_label_over_index(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-a", + "label": "first", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "tok-a", + }, + { + "id": "cred-b", + "label": "2", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "tok-b", + }, + { + "id": "cred-c", + "label": "third", + "auth_type": "oauth", + "priority": 2, + "source": "manual:device_code", + "access_token": "tok-c", + }, + ] + }, + }, + ) + + from hermes_cli.auth_commands import auth_remove_command + + class _Args: + provider = "openai-codex" + target = "2" + + auth_remove_command(_Args()) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + labels = [entry["label"] for entry in payload["credential_pool"]["openai-codex"]] + assert labels == ["first", "third"] + + def test_auth_reset_clears_provider_statuses(tmp_path, monkeypatch, capsys): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store( From 441ec4880291bf9d1a341080003f67d7f2b7ff44 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 5 Apr 2026 00:19:00 -0700 Subject: [PATCH 027/213] style: use module-level re import instead of local import re as _re --- run_agent.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/run_agent.py b/run_agent.py index 34e4ca71e873..b66f5f9669f6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2187,18 +2187,16 @@ def _extract_api_error_context(error: Exception) -> Dict[str, Any]: if "reset_at" not in context: message = context.get("message") or "" if isinstance(message, str): - import re as _re - - delay_match = _re.search(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", message, _re.IGNORECASE) + delay_match = re.search(r"quotaResetDelay[:\s\"]+(\\d+(?:\\.\\d+)?)(ms|s)", message, re.IGNORECASE) if delay_match: value = float(delay_match.group(1)) seconds = value / 1000.0 if delay_match.group(2).lower() == "ms" else value context["reset_at"] = time.time() + seconds else: - sec_match = _re.search( + sec_match = re.search( r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", message, - _re.IGNORECASE, + re.IGNORECASE, ) if sec_match: context["reset_at"] = time.time() + float(sec_match.group(1)) From 0c54da8aafd9e63f625beb2f749169838c50890e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:28:58 -0700 Subject: [PATCH 028/213] feat(gateway): live-stream /update output + interactive prompt buttons (#5180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): live-stream /update output + forward interactive prompts Adds real-time output streaming and interactive prompt forwarding for the gateway /update command, so users on Telegram/Discord/etc see the full update progress and can respond to prompts (stash restore, config migration) without needing terminal access. Changes: hermes_cli/main.py: - Add --gateway flag to 'hermes update' argparse - Add _gateway_prompt() file-based IPC function that writes .update_prompt.json and polls for .update_response - Modify _restore_stashed_changes() to accept optional input_fn parameter for gateway mode prompt forwarding - cmd_update() uses _gateway_prompt when --gateway is set, enabling interactive stash restore and config migration prompts gateway/run.py: - _handle_update_command: spawn with --gateway flag and PYTHONUNBUFFERED=1 for real-time output flushing - Store session_key in .update_pending.json for cross-restart session matching - Add _update_prompt_pending dict to track sessions awaiting update prompt responses - Replace _watch_for_update_completion with _watch_update_progress: streams output chunks every ~4s, detects .update_prompt.json and forwards prompts to the user, handles completion/failure/timeout - Add update prompt interception in _handle_message: when a prompt is pending, the user's next message is written to .update_response instead of being processed normally - Preserve _send_update_notification as legacy fallback for post-restart cases where adapter isn't available yet File-based IPC protocol: - .update_prompt.json: written by update process with prompt text, default value, and unique ID - .update_response: written by gateway with user's answer - .update_output.txt: existing, now streamed in real-time - .update_exit_code: existing completion marker Tests: 16 new tests covering _gateway_prompt IPC, output streaming, prompt detection/forwarding, message interception, and cleanup. * feat: interactive buttons for update prompts (Telegram + Discord) Telegram: Inline keyboard with ✓ Yes / ✗ No buttons. Clicking a button answers the callback query, edits the message to show the choice, and writes .update_response directly. CallbackQueryHandler registered on the update_prompt: prefix. Discord: UpdatePromptView (discord.ui.View) with green Yes / red No buttons. Follows the ExecApprovalView pattern — auth check, embed color update, disabled-after-click. Writes .update_response on click. All platforms: /approve and /deny (and /yes, /no) now work as shorthand for yes/no when an update prompt is pending. The text fallback message instructs users to use these commands. Raw message interception still works as a fallback for non-command responses. Gateway watcher checks adapter for send_update_prompt method (class-level check to avoid MagicMock false positives) and falls back to text prompt with /approve instructions when unavailable. * fix: block /update on non-messaging platforms (API, webhooks, ACP) Add _UPDATE_ALLOWED_PLATFORMS frozenset that explicitly lists messaging platforms where /update is permitted. API server, webhook, and ACP platforms get a clear error directing them to run hermes update from the terminal instead. ACP and API server already don't reach _handle_message (separate codepaths), and webhooks have distinct session keys that can't collide with messaging sessions. This guard is belt-and-suspenders. --- gateway/platforms/discord.py | 110 ++++++ gateway/platforms/telegram.py | 74 +++- gateway/run.py | 249 ++++++++++++- hermes_cli/main.py | 83 ++++- tests/gateway/test_update_command.py | 2 +- tests/gateway/test_update_streaming.py | 496 +++++++++++++++++++++++++ 6 files changed, 996 insertions(+), 18 deletions(-) create mode 100644 tests/gateway/test_update_streaming.py diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 21fa69b6eb72..6e828ed8ea37 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1932,6 +1932,37 @@ async def send_exec_approval( except Exception as e: return SendResult(success=False, error=str(e)) + async def send_update_prompt( + self, chat_id: str, prompt: str, default: str = "", + session_key: str = "", + ) -> SendResult: + """Send an interactive button-based update prompt (Yes / No). + + Used by the gateway ``/update`` watcher when ``hermes update --gateway`` + needs user input (stash restore, config migration). + """ + if not self._client or not DISCORD_AVAILABLE: + return SendResult(success=False, error="Not connected") + try: + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + + default_hint = f" (default: {default})" if default else "" + embed = discord.Embed( + title="⚕ Update Needs Your Input", + description=f"{prompt}{default_hint}", + color=discord.Color.gold(), + ) + view = UpdatePromptView( + session_key=session_key, + allowed_user_ids=self._allowed_user_ids, + ) + msg = await channel.send(embed=embed, view=view) + return SendResult(success=True, message_id=str(msg.id)) + except Exception as e: + return SendResult(success=False, error=str(e)) + def _get_parent_channel_id(self, channel: Any) -> Optional[str]: """Return the parent channel ID for a Discord thread-like channel, if present.""" parent = getattr(channel, "parent", None) @@ -2344,3 +2375,82 @@ async def on_timeout(self): self.resolved = True for child in self.children: child.disabled = True + + class UpdatePromptView(discord.ui.View): + """Interactive Yes/No buttons for ``hermes update`` prompts. + + Clicking a button writes the answer to ``.update_response`` so the + detached update process can pick it up. Only authorized users can + click. Times out after 5 minutes (the update process also has a + 5-minute timeout on its side). + """ + + def __init__(self, session_key: str, allowed_user_ids: set): + super().__init__(timeout=300) + self.session_key = session_key + self.allowed_user_ids = allowed_user_ids + self.resolved = False + + def _check_auth(self, interaction: discord.Interaction) -> bool: + if not self.allowed_user_ids: + return True + return str(interaction.user.id) in self.allowed_user_ids + + async def _respond( + self, interaction: discord.Interaction, answer: str, + color: discord.Color, label: str, + ): + if self.resolved: + await interaction.response.send_message( + "Already answered~", ephemeral=True + ) + return + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + self.resolved = True + + # Update embed + embed = interaction.message.embeds[0] if interaction.message.embeds else None + if embed: + embed.color = color + embed.set_footer(text=f"{label} by {interaction.user.display_name}") + + for child in self.children: + child.disabled = True + await interaction.response.edit_message(embed=embed, view=self) + + # Write response file + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + response_path = home / ".update_response" + tmp = response_path.with_suffix(".tmp") + tmp.write_text(answer) + tmp.replace(response_path) + logger.info( + "Discord update prompt answered '%s' by %s", + answer, interaction.user.display_name, + ) + except Exception as exc: + logger.error("Failed to write update response: %s", exc) + + @discord.ui.button(label="Yes", style=discord.ButtonStyle.green, emoji="✓") + async def yes_btn( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._respond(interaction, "y", discord.Color.green(), "Yes") + + @discord.ui.button(label="No", style=discord.ButtonStyle.red, emoji="✗") + async def no_btn( + self, interaction: discord.Interaction, button: discord.ui.Button + ): + await self._respond(interaction, "n", discord.Color.red(), "No") + + async def on_timeout(self): + self.resolved = True + for child in self.children: + child.disabled = True diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index e406451e7d8e..9e78282be079 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -17,10 +17,11 @@ logger = logging.getLogger(__name__) try: - from telegram import Update, Bot, Message + from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ( Application, CommandHandler, + CallbackQueryHandler, MessageHandler as TelegramMessageHandler, ContextTypes, filters, @@ -33,8 +34,11 @@ Update = Any Bot = Any Message = Any + InlineKeyboardButton = Any + InlineKeyboardMarkup = Any Application = Any CommandHandler = Any + CallbackQueryHandler = Any TelegramMessageHandler = Any HTTPXRequest = Any filters = None @@ -543,6 +547,8 @@ async def connect(self) -> bool: filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL, self._handle_media_message )) + # Handle inline keyboard button callbacks (update prompts) + self._app.add_handler(CallbackQueryHandler(self._handle_callback_query)) # Start polling — retry initialize() for transient TLS resets try: @@ -950,6 +956,72 @@ async def edit_message( ) return SendResult(success=False, error=str(e)) + async def send_update_prompt( + self, chat_id: str, prompt: str, default: str = "", + session_key: str = "", + ) -> SendResult: + """Send an inline-keyboard update prompt (Yes / No buttons). + + Used by the gateway ``/update`` watcher when ``hermes update --gateway`` + needs user input (stash restore, config migration). + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + try: + default_hint = f" (default: {default})" if default else "" + text = f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}" + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"), + InlineKeyboardButton("✗ No", callback_data="update_prompt:n"), + ] + ]) + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning("[%s] send_update_prompt failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + async def _handle_callback_query( + self, update: "Update", context: "ContextTypes.DEFAULT_TYPE" + ) -> None: + """Handle inline keyboard button clicks (update prompts).""" + query = update.callback_query + if not query or not query.data: + return + data = query.data + if not data.startswith("update_prompt:"): + return + answer = data.split(":", 1)[1] # "y" or "n" + await query.answer(text=f"Sent '{answer}' to the update process.") + # Edit the message to show the choice and remove buttons + label = "Yes" if answer == "y" else "No" + try: + await query.edit_message_text( + text=f"⚕ Update prompt answered: *{label}*", + parse_mode=ParseMode.MARKDOWN, + reply_markup=None, + ) + except Exception: + pass # non-fatal if edit fails + # Write the response file + try: + from hermes_constants import get_hermes_home + home = get_hermes_home() + response_path = home / ".update_response" + tmp = response_path.with_suffix(".tmp") + tmp.write_text(answer) + tmp.replace(response_path) + logger.info("Telegram update prompt answered '%s' by user %s", + answer, getattr(query.from_user, "id", "unknown")) + except Exception as exc: + logger.error("Failed to write update response from callback: %s", exc) + async def send_voice( self, chat_id: str, diff --git a/gateway/run.py b/gateway/run.py index 33bfa1d79fc1..3c1c230163de 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -517,6 +517,10 @@ def __init__(self, config: Optional[GatewayConfig] = None): # Key: Platform enum, Value: {"config": platform_config, "attempts": int, "next_retry": float} self._failed_platforms: Dict[Platform, Dict[str, Any]] = {} + # Track pending /update prompt responses per session. + # Key: session_key, Value: True when a prompt is waiting for user input. + self._update_prompt_pending: Dict[str, bool] = {} + # Persistent Honcho managers keyed by gateway session key. # This preserves write_frequency="session" semantics across short-lived # per-message AIAgent instances. @@ -1737,6 +1741,35 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: self.pairing_store._record_rate_limit(platform_name, source.user_id) return None + # Intercept messages that are responses to a pending /update prompt. + # The update process (detached) wrote .update_prompt.json; the watcher + # forwarded it to the user; now the user's reply goes back via + # .update_response so the update process can continue. + _quick_key = self._session_key_for_source(source) + _update_prompts = getattr(self, "_update_prompt_pending", {}) + if _update_prompts.get(_quick_key): + raw = (event.text or "").strip() + # Accept /approve and /deny as shorthand for yes/no + cmd = event.get_command() + if cmd in ("approve", "yes"): + response_text = "y" + elif cmd in ("deny", "no"): + response_text = "n" + else: + response_text = raw + if response_text: + response_path = _hermes_home / ".update_response" + try: + tmp = response_path.with_suffix(".tmp") + tmp.write_text(response_text) + tmp.replace(response_path) + except OSError as e: + logger.warning("Failed to write update response: %s", e) + return f"✗ Failed to send response to update process: {e}" + _update_prompts.pop(_quick_key, None) + label = response_text if len(response_text) <= 20 else response_text[:20] + "…" + return f"✓ Sent `{label}` to the update process." + # PRIORITY handling when an agent is already running for this session. # Default behavior is to interrupt immediately so user text/stop messages # are handled with minimal latency. @@ -1744,7 +1777,6 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Special case: Telegram/photo bursts often arrive as multiple near- # simultaneous updates. Do NOT interrupt for photo-only follow-ups here; # let the adapter-level batching/queueing logic absorb them. - _quick_key = self._session_key_for_source(source) # Staleness eviction: if an entry has been in _running_agents for # longer than the agent timeout, it's a leaked lock from a hung or @@ -4929,6 +4961,15 @@ async def _handle_deny_command(self, event: MessageEvent) -> str: logger.info("User denied %d dangerous command(s) via /deny", count) return f"❌ Command{'s' if count > 1 else ''} denied{count_msg}." + # Platforms where /update is allowed. ACP, API server, and webhooks are + # programmatic interfaces that should not trigger system updates. + _UPDATE_ALLOWED_PLATFORMS = frozenset({ + Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, + Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, + Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, + Platform.FEISHU, Platform.WECOM, Platform.LOCAL, + }) + async def _handle_update_command(self, event: MessageEvent) -> str: """Handle /update command — update Hermes Agent to the latest version. @@ -4943,6 +4984,11 @@ async def _handle_update_command(self, event: MessageEvent) -> str: from datetime import datetime from hermes_cli.config import is_managed, format_managed_message + # Block non-messaging platforms (API server, webhooks, ACP) + platform = event.source.platform + if platform not in self._UPDATE_ALLOWED_PLATFORMS: + return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal." + if is_managed(): return f"✗ {format_managed_message('update Hermes Agent')}" @@ -4964,10 +5010,12 @@ async def _handle_update_command(self, event: MessageEvent) -> str: pending_path = _hermes_home / ".update_pending.json" output_path = _hermes_home / ".update_output.txt" exit_code_path = _hermes_home / ".update_exit_code" + session_key = self._session_key_for_source(event.source) pending = { "platform": event.source.platform.value, "chat_id": event.source.chat_id, "user_id": event.source.user_id, + "session_key": session_key, "timestamp": datetime.now().isoformat(), } _tmp_pending = pending_path.with_suffix(".tmp") @@ -4975,12 +5023,18 @@ async def _handle_update_command(self, event: MessageEvent) -> str: _tmp_pending.replace(pending_path) exit_code_path.unlink(missing_ok=True) - # Spawn `hermes update` detached so it survives gateway restart. + # Spawn `hermes update --gateway` detached so it survives gateway restart. + # --gateway enables file-based IPC for interactive prompts (stash + # restore, config migration) so the gateway can forward them to the + # user instead of silently skipping them. # Use setsid for portable session detach (works under system services # where systemd-run --user fails due to missing D-Bus session). + # PYTHONUNBUFFERED ensures output is flushed line-by-line so the + # gateway can stream it to the messenger in near-real-time. hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd) update_cmd = ( - f"{hermes_cmd_str} update > {shlex.quote(str(output_path))} 2>&1; " + f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway" + f" > {shlex.quote(str(output_path))} 2>&1; " f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}" ) try: @@ -5007,7 +5061,7 @@ async def _handle_update_command(self, event: MessageEvent) -> str: return f"✗ Failed to start update: {e}" self._schedule_update_notification_watch() - return "⚕ Starting Hermes update… I'll notify you when it's done." + return "⚕ Starting Hermes update… I'll stream progress here." def _schedule_update_notification_watch(self) -> None: """Ensure a background task is watching for update completion.""" @@ -5017,39 +5071,210 @@ def _schedule_update_notification_watch(self) -> None: try: self._update_notification_task = asyncio.create_task( - self._watch_for_update_completion() + self._watch_update_progress() ) except RuntimeError: logger.debug("Skipping update notification watcher: no running event loop") - async def _watch_for_update_completion( + async def _watch_update_progress( self, poll_interval: float = 2.0, + stream_interval: float = 4.0, timeout: float = 1800.0, ) -> None: - """Wait for ``hermes update`` to finish, then send its notification.""" + """Watch ``hermes update --gateway``, streaming output + forwarding prompts. + + Polls ``.update_output.txt`` for new content and sends chunks to the + user periodically. Detects ``.update_prompt.json`` (written by the + update process when it needs user input) and forwards the prompt to + the messenger. The user's next message is intercepted by + ``_handle_message`` and written to ``.update_response``. + """ + import json + import re as _re + pending_path = _hermes_home / ".update_pending.json" claimed_path = _hermes_home / ".update_pending.claimed.json" + output_path = _hermes_home / ".update_output.txt" exit_code_path = _hermes_home / ".update_exit_code" + prompt_path = _hermes_home / ".update_prompt.json" + loop = asyncio.get_running_loop() deadline = loop.time() + timeout - while (pending_path.exists() or claimed_path.exists()) and loop.time() < deadline: - if exit_code_path.exists(): + # Resolve the adapter and chat_id for sending messages + adapter = None + chat_id = None + session_key = None + for path in (claimed_path, pending_path): + if path.exists(): + try: + pending = json.loads(path.read_text()) + platform_str = pending.get("platform") + chat_id = pending.get("chat_id") + session_key = pending.get("session_key") + if platform_str and chat_id: + platform = Platform(platform_str) + adapter = self.adapters.get(platform) + # Fallback session key if not stored (old pending files) + if not session_key: + session_key = f"{platform_str}:{chat_id}" + break + except Exception: + pass + + if not adapter or not chat_id: + logger.warning("Update watcher: cannot resolve adapter/chat_id, falling back to completion-only") + # Fall back to old behavior: wait for exit code and send final notification + while (pending_path.exists() or claimed_path.exists()) and loop.time() < deadline: + if exit_code_path.exists(): + await self._send_update_notification() + return + await asyncio.sleep(poll_interval) + if (pending_path.exists() or claimed_path.exists()) and not exit_code_path.exists(): + exit_code_path.write_text("124") await self._send_update_notification() + return + + def _strip_ansi(text: str) -> str: + return _re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text) + + bytes_sent = 0 + last_stream_time = loop.time() + buffer = "" + + async def _flush_buffer() -> None: + """Send buffered output to the user.""" + nonlocal buffer, last_stream_time + if not buffer.strip(): + buffer = "" + return + # Chunk to fit message limits (Telegram: 4096, others: generous) + clean = _strip_ansi(buffer).strip() + buffer = "" + last_stream_time = loop.time() + if not clean: return + # Split into chunks if too long + max_chunk = 3500 + chunks = [clean[i:i + max_chunk] for i in range(0, len(clean), max_chunk)] + for chunk in chunks: + try: + await adapter.send(chat_id, f"```\n{chunk}\n```") + except Exception as e: + logger.debug("Update stream send failed: %s", e) + + while loop.time() < deadline: + # Check for completion + if exit_code_path.exists(): + # Read any remaining output + if output_path.exists(): + try: + content = output_path.read_text() + if len(content) > bytes_sent: + buffer += content[bytes_sent:] + bytes_sent = len(content) + except OSError: + pass + await _flush_buffer() + + # Send final status + try: + exit_code_raw = exit_code_path.read_text().strip() or "1" + exit_code = int(exit_code_raw) + if exit_code == 0: + await adapter.send(chat_id, "✅ Hermes update finished.") + else: + await adapter.send(chat_id, "❌ Hermes update failed (exit code {}).".format(exit_code)) + logger.info("Update finished (exit=%s), notified %s", exit_code, session_key) + except Exception as e: + logger.warning("Update final notification failed: %s", e) + + # Cleanup + for p in (pending_path, claimed_path, output_path, + exit_code_path, prompt_path): + p.unlink(missing_ok=True) + (_hermes_home / ".update_response").unlink(missing_ok=True) + self._update_prompt_pending.pop(session_key, None) + return + + # Check for new output + if output_path.exists(): + try: + content = output_path.read_text() + if len(content) > bytes_sent: + buffer += content[bytes_sent:] + bytes_sent = len(content) + except OSError: + pass + + # Flush buffer periodically + if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval: + await _flush_buffer() + + # Check for prompts + if prompt_path.exists() and session_key: + try: + prompt_data = json.loads(prompt_path.read_text()) + prompt_text = prompt_data.get("prompt", "") + default = prompt_data.get("default", "") + if prompt_text: + # Flush any buffered output first so the user sees + # context before the prompt + await _flush_buffer() + # Try platform-native buttons first (Discord, Telegram) + sent_buttons = False + if getattr(type(adapter), "send_update_prompt", None) is not None: + try: + await adapter.send_update_prompt( + chat_id=chat_id, + prompt=prompt_text, + default=default, + session_key=session_key, + ) + sent_buttons = True + except Exception as btn_err: + logger.debug("Button-based update prompt failed: %s", btn_err) + if not sent_buttons: + default_hint = f" (default: {default})" if default else "" + await adapter.send( + chat_id, + f"⚕ **Update needs your input:**\n\n" + f"{prompt_text}{default_hint}\n\n" + f"Reply `/approve` (yes) or `/deny` (no), " + f"or type your answer directly." + ) + self._update_prompt_pending[session_key] = True + logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80]) + except (json.JSONDecodeError, OSError) as e: + logger.debug("Failed to read update prompt: %s", e) + await asyncio.sleep(poll_interval) - if (pending_path.exists() or claimed_path.exists()) and not exit_code_path.exists(): - logger.warning("Update watcher timed out waiting for completion marker") + # Timeout + if not exit_code_path.exists(): + logger.warning("Update watcher timed out after %.0fs", timeout) exit_code_path.write_text("124") - await self._send_update_notification() + await _flush_buffer() + try: + await adapter.send(chat_id, "❌ Hermes update timed out after 30 minutes.") + except Exception: + pass + for p in (pending_path, claimed_path, output_path, + exit_code_path, prompt_path): + p.unlink(missing_ok=True) + (_hermes_home / ".update_response").unlink(missing_ok=True) + self._update_prompt_pending.pop(session_key, None) async def _send_update_notification(self) -> bool: """If an update finished, notify the user. Returns False when the update is still running so a caller can retry later. Returns True after a definitive send/skip decision. + + This is the legacy notification path used when the streaming watcher + cannot resolve the adapter (e.g. after a gateway restart where the + platform hasn't reconnected yet). """ import json import re as _re diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 91f97d45058b..a6907d044d4d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2554,6 +2554,57 @@ def _clear_bytecode_cache(root: Path) -> int: return removed +def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) -> str: + """File-based IPC prompt for gateway mode. + + Writes a prompt marker file so the gateway can forward the question to the + user, then polls for a response file. Falls back to *default* on timeout. + + Used by ``hermes update --gateway`` so interactive prompts (stash restore, + config migration) are forwarded to the messenger instead of being silently + skipped. + """ + import json as _json + import uuid as _uuid + from hermes_constants import get_hermes_home + + home = get_hermes_home() + prompt_path = home / ".update_prompt.json" + response_path = home / ".update_response" + + # Clean any stale response file + response_path.unlink(missing_ok=True) + + payload = { + "prompt": prompt_text, + "default": default, + "id": str(_uuid.uuid4()), + } + tmp = prompt_path.with_suffix(".tmp") + tmp.write_text(_json.dumps(payload)) + tmp.replace(prompt_path) + + # Poll for response + import time as _time + deadline = _time.monotonic() + timeout + while _time.monotonic() < deadline: + if response_path.exists(): + try: + answer = response_path.read_text().strip() + response_path.unlink(missing_ok=True) + prompt_path.unlink(missing_ok=True) + return answer if answer else default + except (OSError, ValueError): + pass + _time.sleep(0.5) + + # Timeout — clean up and use default + prompt_path.unlink(missing_ok=True) + response_path.unlink(missing_ok=True) + print(f" (no response after {int(timeout)}s, using default: {default!r})") + return default + + def _update_via_zip(args): """Update Hermes Agent by downloading a ZIP archive. @@ -2747,6 +2798,7 @@ def _restore_stashed_changes( cwd: Path, stash_ref: str, prompt_user: bool = False, + input_fn=None, ) -> bool: if prompt_user: print() @@ -2754,7 +2806,10 @@ def _restore_stashed_changes( print(" Restoring them may reapply local customizations onto the updated codebase.") print(" Review the result afterward if Hermes behaves unexpectedly.") print("Restore local changes now? [Y/n]") - response = input().strip().lower() + if input_fn is not None: + response = input_fn("Restore local changes now? [Y/n]", "y") + else: + response = input().strip().lower() if response not in ("", "y", "yes"): print("Skipped restoring local changes.") print("Your changes are still preserved in git stash.") @@ -3185,6 +3240,10 @@ def cmd_update(args): if is_managed(): managed_error("update Hermes Agent") return + + gateway_mode = getattr(args, "gateway", False) + # In gateway mode, use file-based IPC for prompts instead of stdin + gw_input_fn = (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None print("⚕ Updating Hermes Agent...") print() @@ -3281,7 +3340,9 @@ def cmd_update(args): else: auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) - prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty() + prompt_for_restore = auto_stash_ref is not None and ( + gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()) + ) # Check if there are updates result = subprocess.run( @@ -3300,6 +3361,7 @@ def cmd_update(args): _restore_stashed_changes( git_cmd, PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore, + input_fn=gw_input_fn, ) if current_branch not in ("main", "HEAD"): subprocess.run( @@ -3351,6 +3413,7 @@ def cmd_update(args): PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore, + input_fn=gw_input_fn, ) _invalidate_update_cache() @@ -3490,7 +3553,11 @@ def cmd_update(args): print(f" ℹ️ {len(missing_config)} new config option(s) available") print() - if not (sys.stdin.isatty() and sys.stdout.isatty()): + if gateway_mode: + response = _gateway_prompt( + "Would you like to configure new options now? [Y/n]", "n" + ).strip().lower() + elif not (sys.stdin.isatty() and sys.stdout.isatty()): print(" ℹ Non-interactive session — skipping config migration prompt.") print(" Run 'hermes config migrate' later to apply any new config/env options.") response = "n" @@ -3502,11 +3569,15 @@ def cmd_update(args): if response in ('', 'y', 'yes'): print() - results = migrate_config(interactive=True, quiet=False) + # In gateway mode, run auto-migrations only (no input() prompts + # for API keys which would hang the detached process). + results = migrate_config(interactive=not gateway_mode, quiet=False) if results["env_added"] or results["config_added"]: print() print("✓ Configuration updated!") + if gateway_mode and missing_env: + print(" ℹ API keys require manual entry: hermes config migrate") else: print() print("Skipped. Run 'hermes config migrate' later to configure.") @@ -5247,6 +5318,10 @@ def cmd_claw(args): help="Update Hermes Agent to the latest version", description="Pull the latest changes from git and reinstall dependencies" ) + update_parser.add_argument( + "--gateway", action="store_true", default=False, + help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)" + ) update_parser.set_defaults(func=cmd_update) # ========================================================================= diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py index 0fc774a0ab33..05be88c2c65b 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -330,7 +330,7 @@ async def test_returns_user_friendly_message(self, tmp_path): patch("subprocess.Popen"): result = await runner._handle_update_command(event) - assert "notify you when it's done" in result + assert "stream progress" in result # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py new file mode 100644 index 000000000000..8a2cefbbb6cc --- /dev/null +++ b/tests/gateway/test_update_streaming.py @@ -0,0 +1,496 @@ +"""Tests for /update live streaming, prompt forwarding, and gateway IPC. + +Tests the new --gateway mode for hermes update, including: +- _gateway_prompt() file-based IPC +- _watch_update_progress() output streaming and prompt detection +- Message interception for update prompt responses +- _restore_stashed_changes() with input_fn parameter +""" + +import json +import os +import time +import asyncio +from pathlib import Path +from unittest.mock import patch, MagicMock, AsyncMock + +import pytest + +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +def _make_event(text="/update", platform=Platform.TELEGRAM, + user_id="12345", chat_id="67890"): + """Build a MessageEvent for testing.""" + source = SessionSource( + platform=platform, + user_id=user_id, + chat_id=chat_id, + user_name="testuser", + ) + return MessageEvent(text=text, source=source) + + +def _make_runner(hermes_home=None): + """Create a bare GatewayRunner without calling __init__.""" + from gateway.run import GatewayRunner + runner = object.__new__(GatewayRunner) + runner.adapters = {} + runner._voice_mode = {} + runner._update_prompt_pending = {} + runner._running_agents = {} + runner._running_agents_ts = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._failed_platforms = {} + return runner + + +# --------------------------------------------------------------------------- +# _gateway_prompt (file-based IPC in main.py) +# --------------------------------------------------------------------------- + + +class TestGatewayPrompt: + """Tests for _gateway_prompt() function.""" + + def test_writes_prompt_file_and_reads_response(self, tmp_path): + """Writes .update_prompt.json, reads .update_response, returns answer.""" + import threading + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + # Simulate the response arriving after a short delay + def write_response(): + time.sleep(0.3) + (hermes_home / ".update_response").write_text("y") + + thread = threading.Thread(target=write_response) + thread.start() + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from hermes_cli.main import _gateway_prompt + result = _gateway_prompt("Restore? [Y/n]", "y", timeout=5.0) + + thread.join() + assert result == "y" + # Both files should be cleaned up + assert not (hermes_home / ".update_prompt.json").exists() + assert not (hermes_home / ".update_response").exists() + + def test_prompt_file_content(self, tmp_path): + """Verifies the prompt JSON structure.""" + import threading + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + prompt_data = None + + def capture_and_respond(): + nonlocal prompt_data + prompt_path = hermes_home / ".update_prompt.json" + for _ in range(20): + if prompt_path.exists(): + prompt_data = json.loads(prompt_path.read_text()) + (hermes_home / ".update_response").write_text("n") + return + time.sleep(0.1) + + thread = threading.Thread(target=capture_and_respond) + thread.start() + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from hermes_cli.main import _gateway_prompt + _gateway_prompt("Configure now? [Y/n]", "n", timeout=5.0) + + thread.join() + assert prompt_data is not None + assert prompt_data["prompt"] == "Configure now? [Y/n]" + assert prompt_data["default"] == "n" + assert "id" in prompt_data + + def test_timeout_returns_default(self, tmp_path): + """Returns default when no response within timeout.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from hermes_cli.main import _gateway_prompt + result = _gateway_prompt("test?", "default_val", timeout=0.5) + + assert result == "default_val" + + def test_empty_response_returns_default(self, tmp_path): + """Empty response file returns default.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / ".update_response").write_text("") + + # Write prompt file so the function starts polling + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from hermes_cli.main import _gateway_prompt + # Pre-create the response + result = _gateway_prompt("test?", "default_val", timeout=2.0) + + assert result == "default_val" + + +# --------------------------------------------------------------------------- +# _restore_stashed_changes with input_fn +# --------------------------------------------------------------------------- + + +class TestRestoreStashWithInputFn: + """Tests for _restore_stashed_changes with the input_fn parameter.""" + + def test_uses_input_fn_when_provided(self, tmp_path): + """When input_fn is provided, it's called instead of input().""" + from hermes_cli.main import _restore_stashed_changes + + captured_args = [] + + def fake_input_fn(prompt, default=""): + captured_args.append((prompt, default)) + return "n" + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout="", stderr="" + ) + result = _restore_stashed_changes( + ["git"], tmp_path, "abc123", + prompt_user=True, + input_fn=fake_input_fn, + ) + + assert len(captured_args) == 1 + assert "Restore" in captured_args[0][0] + assert result is False # user declined + + def test_input_fn_yes_proceeds_with_restore(self, tmp_path): + """When input_fn returns 'y', stash apply is attempted.""" + from hermes_cli.main import _restore_stashed_changes + + call_count = [0] + + def fake_run(*args, **kwargs): + call_count[0] += 1 + mock = MagicMock() + mock.returncode = 0 + mock.stdout = "" + mock.stderr = "" + return mock + + with patch("subprocess.run", side_effect=fake_run): + _restore_stashed_changes( + ["git"], tmp_path, "abc123", + prompt_user=True, + input_fn=lambda p, d="": "y", + ) + + # Should have called git stash apply + git diff --name-only + assert call_count[0] >= 2 + + +# --------------------------------------------------------------------------- +# Update command spawns --gateway flag +# --------------------------------------------------------------------------- + + +class TestUpdateCommandGatewayFlag: + """Verify the gateway spawns hermes update --gateway.""" + + @pytest.mark.asyncio + async def test_spawns_with_gateway_flag(self, tmp_path): + """The spawned update command includes --gateway and PYTHONUNBUFFERED.""" + runner = _make_runner() + event = _make_event() + + fake_root = tmp_path / "project" + fake_root.mkdir() + (fake_root / ".git").mkdir() + (fake_root / "gateway").mkdir() + (fake_root / "gateway" / "run.py").touch() + fake_file = str(fake_root / "gateway" / "run.py") + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + mock_popen = MagicMock() + with patch("gateway.run._hermes_home", hermes_home), \ + patch("gateway.run.__file__", fake_file), \ + patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \ + patch("subprocess.Popen", mock_popen): + result = await runner._handle_update_command(event) + + # Check the bash command string contains --gateway and PYTHONUNBUFFERED + call_args = mock_popen.call_args[0][0] + cmd_string = call_args[-1] if isinstance(call_args, list) else str(call_args) + assert "--gateway" in cmd_string + assert "PYTHONUNBUFFERED" in cmd_string + assert "stream progress" in result + + +# --------------------------------------------------------------------------- +# _watch_update_progress — output streaming +# --------------------------------------------------------------------------- + + +class TestWatchUpdateProgress: + """Tests for _watch_update_progress() streaming output.""" + + @pytest.mark.asyncio + async def test_streams_output_to_adapter(self, tmp_path): + """New output is sent to the adapter periodically.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = {"platform": "telegram", "chat_id": "111", "user_id": "222", + "session_key": "agent:main:telegram:dm:111"} + (hermes_home / ".update_pending.json").write_text(json.dumps(pending)) + # Write output + (hermes_home / ".update_output.txt").write_text("→ Fetching updates...\n") + + mock_adapter = AsyncMock() + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + # Write exit code after a brief delay + async def write_exit_code(): + await asyncio.sleep(0.3) + (hermes_home / ".update_output.txt").write_text( + "→ Fetching updates...\n✓ Code updated!\n" + ) + (hermes_home / ".update_exit_code").write_text("0") + + with patch("gateway.run._hermes_home", hermes_home): + task = asyncio.create_task(write_exit_code()) + await runner._watch_update_progress( + poll_interval=0.1, + stream_interval=0.2, + timeout=5.0, + ) + await task + + # Should have sent at least the output and a success message + assert mock_adapter.send.call_count >= 1 + all_sent = " ".join(str(c) for c in mock_adapter.send.call_args_list) + assert "update finished" in all_sent.lower() + + @pytest.mark.asyncio + async def test_detects_and_forwards_prompt(self, tmp_path): + """Detects .update_prompt.json and sends it to the user.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = {"platform": "telegram", "chat_id": "111", "user_id": "222", + "session_key": "agent:main:telegram:dm:111"} + (hermes_home / ".update_pending.json").write_text(json.dumps(pending)) + (hermes_home / ".update_output.txt").write_text("output\n") + + mock_adapter = AsyncMock() + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + # Write a prompt, then respond and finish + async def simulate_prompt_cycle(): + await asyncio.sleep(0.3) + prompt = {"prompt": "Restore local changes? [Y/n]", "default": "y", "id": "test1"} + (hermes_home / ".update_prompt.json").write_text(json.dumps(prompt)) + # Simulate user responding + await asyncio.sleep(0.5) + (hermes_home / ".update_response").write_text("y") + (hermes_home / ".update_prompt.json").unlink(missing_ok=True) + await asyncio.sleep(0.3) + (hermes_home / ".update_exit_code").write_text("0") + + with patch("gateway.run._hermes_home", hermes_home): + task = asyncio.create_task(simulate_prompt_cycle()) + await runner._watch_update_progress( + poll_interval=0.1, + stream_interval=0.2, + timeout=10.0, + ) + await task + + # Check that the prompt was forwarded + all_sent = [str(c) for c in mock_adapter.send.call_args_list] + prompt_found = any("Restore local changes" in s for s in all_sent) + assert prompt_found, f"Prompt not forwarded. Sent: {all_sent}" + # Check session was marked as having pending prompt + # (may be cleared by the time we check since update finished) + + @pytest.mark.asyncio + async def test_cleans_up_on_completion(self, tmp_path): + """All marker files are cleaned up when update finishes.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = {"platform": "telegram", "chat_id": "111", "user_id": "222", + "session_key": "agent:main:telegram:dm:111"} + pending_path = hermes_home / ".update_pending.json" + output_path = hermes_home / ".update_output.txt" + exit_code_path = hermes_home / ".update_exit_code" + pending_path.write_text(json.dumps(pending)) + output_path.write_text("done\n") + exit_code_path.write_text("0") + + mock_adapter = AsyncMock() + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + with patch("gateway.run._hermes_home", hermes_home): + await runner._watch_update_progress( + poll_interval=0.1, + stream_interval=0.2, + timeout=5.0, + ) + + assert not pending_path.exists() + assert not output_path.exists() + assert not exit_code_path.exists() + + @pytest.mark.asyncio + async def test_failure_exit_code(self, tmp_path): + """Non-zero exit code sends failure message.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = {"platform": "telegram", "chat_id": "111", "user_id": "222", + "session_key": "agent:main:telegram:dm:111"} + (hermes_home / ".update_pending.json").write_text(json.dumps(pending)) + (hermes_home / ".update_output.txt").write_text("error occurred\n") + (hermes_home / ".update_exit_code").write_text("1") + + mock_adapter = AsyncMock() + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + with patch("gateway.run._hermes_home", hermes_home): + await runner._watch_update_progress( + poll_interval=0.1, + stream_interval=0.2, + timeout=5.0, + ) + + all_sent = " ".join(str(c) for c in mock_adapter.send.call_args_list) + assert "failed" in all_sent.lower() + + @pytest.mark.asyncio + async def test_falls_back_when_adapter_unavailable(self, tmp_path): + """Falls back to legacy notification when adapter can't be resolved.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + # Platform doesn't match any adapter + pending = {"platform": "discord", "chat_id": "111", "user_id": "222"} + (hermes_home / ".update_pending.json").write_text(json.dumps(pending)) + (hermes_home / ".update_output.txt").write_text("done\n") + (hermes_home / ".update_exit_code").write_text("0") + + # Only telegram adapter available + mock_adapter = AsyncMock() + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + with patch("gateway.run._hermes_home", hermes_home): + await runner._watch_update_progress( + poll_interval=0.1, + stream_interval=0.2, + timeout=5.0, + ) + + # Should not crash; legacy notification handles this case + + +# --------------------------------------------------------------------------- +# Message interception for update prompts +# --------------------------------------------------------------------------- + + +class TestUpdatePromptInterception: + """Tests for update prompt response interception in _handle_message.""" + + @pytest.mark.asyncio + async def test_intercepts_response_when_prompt_pending(self, tmp_path): + """When _update_prompt_pending is set, the next message writes .update_response.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + event = _make_event(text="y", chat_id="67890") + # The session key uses the full format from build_session_key + session_key = "agent:main:telegram:dm:67890" + runner._update_prompt_pending[session_key] = True + + # Mock authorization and _session_key_for_source + runner._is_user_authorized = MagicMock(return_value=True) + runner._session_key_for_source = MagicMock(return_value=session_key) + + with patch("gateway.run._hermes_home", hermes_home): + result = await runner._handle_message(event) + + assert result is not None + assert "Sent" in result + response_path = hermes_home / ".update_response" + assert response_path.exists() + assert response_path.read_text() == "y" + # Should clear the pending flag + assert session_key not in runner._update_prompt_pending + + @pytest.mark.asyncio + async def test_normal_message_when_no_prompt_pending(self, tmp_path): + """Messages pass through normally when no prompt is pending.""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + event = _make_event(text="hello", chat_id="67890") + + # No pending prompt + runner._is_user_authorized = MagicMock(return_value=True) + + # The message should flow through to normal processing; + # we just verify it doesn't get intercepted + session_key = "agent:main:telegram:dm:67890" + assert session_key not in runner._update_prompt_pending + + +# --------------------------------------------------------------------------- +# cmd_update --gateway flag +# --------------------------------------------------------------------------- + + +class TestCmdUpdateGatewayMode: + """Tests for cmd_update with --gateway flag.""" + + def test_gateway_flag_enables_gateway_prompt_for_stash(self, tmp_path): + """With --gateway, stash restore uses _gateway_prompt instead of input().""" + from hermes_cli.main import _restore_stashed_changes + + # Use input_fn to verify the gateway path is taken + calls = [] + + def fake_input(prompt, default=""): + calls.append(prompt) + return "n" + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + _restore_stashed_changes( + ["git"], tmp_path, "abc123", + prompt_user=True, + input_fn=fake_input, + ) + + assert len(calls) == 1 + assert "Restore" in calls[0] + + def test_gateway_flag_parsed(self): + """The --gateway flag is accepted by the update subparser.""" + # Verify the argparse parser accepts --gateway by checking cmd_update + # receives gateway=True when the flag is set + from types import SimpleNamespace + args = SimpleNamespace(gateway=True) + assert args.gateway is True From cb63b5f381a95709ff2c7e36acc187b699a042f2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:42:55 -0700 Subject: [PATCH 029/213] feat(skills): add popular-web-designs skill with 54 website design systems (#5194) Curated collection of production-quality design system specifications extracted from real websites (sourced from VoltAgent/awesome-design-md). Each template captures a site's complete visual language: colors, typography, components, layout, shadows, responsive behavior, and agent-ready CSS values. Hermes-specific adaptations in every template: - Google Fonts CDN link tags for proprietary font substitutes - CSS font-family stacks with proper fallbacks - Integration notes for write_file + generative-widgets workflow - browser_vision verification reminders SKILL.md includes categorized catalog, font substitution reference table, HTML generation pattern, and design-to-use-case matching guide. Sites: Airbnb, Airtable, Apple, BMW, Cal.com, Claude, Clay, ClickHouse, Cohere, Coinbase, Composio, Cursor, ElevenLabs, Expo, Figma, Framer, HashiCorp, IBM, Intercom, Kraken, Linear, Lovable, Minimax, Mintlify, Miro, Mistral AI, MongoDB, Notion, NVIDIA, Ollama, OpenCode, Pinterest, PostHog, Raycast, Replicate, Resend, Revolut, RunwayML, Sanity, Sentry, SpaceX, Spotify, Stripe, Supabase, Superhuman, Together AI, Uber, Vercel, VoltAgent, Warp, Webflow, Wise, xAI, Zapier --- skills/creative/popular-web-designs/SKILL.md | 207 ++++++++++ .../popular-web-designs/templates/airbnb.md | 259 ++++++++++++ .../popular-web-designs/templates/airtable.md | 102 +++++ .../popular-web-designs/templates/apple.md | 326 +++++++++++++++ .../popular-web-designs/templates/bmw.md | 193 +++++++++ .../popular-web-designs/templates/cal.md | 272 +++++++++++++ .../popular-web-designs/templates/claude.md | 325 +++++++++++++++ .../popular-web-designs/templates/clay.md | 317 +++++++++++++++ .../templates/clickhouse.md | 294 ++++++++++++++ .../popular-web-designs/templates/cohere.md | 279 +++++++++++++ .../popular-web-designs/templates/coinbase.md | 142 +++++++ .../popular-web-designs/templates/composio.md | 320 +++++++++++++++ .../popular-web-designs/templates/cursor.md | 322 +++++++++++++++ .../templates/elevenlabs.md | 278 +++++++++++++ .../popular-web-designs/templates/expo.md | 294 ++++++++++++++ .../popular-web-designs/templates/figma.md | 233 +++++++++++ .../popular-web-designs/templates/framer.md | 259 ++++++++++++ .../templates/hashicorp.md | 291 ++++++++++++++ .../popular-web-designs/templates/ibm.md | 345 ++++++++++++++++ .../popular-web-designs/templates/intercom.md | 159 ++++++++ .../popular-web-designs/templates/kraken.md | 138 +++++++ .../templates/linear.app.md | 380 ++++++++++++++++++ .../popular-web-designs/templates/lovable.md | 311 ++++++++++++++ .../popular-web-designs/templates/minimax.md | 270 +++++++++++++ .../popular-web-designs/templates/mintlify.md | 339 ++++++++++++++++ .../popular-web-designs/templates/miro.md | 121 ++++++ .../templates/mistral.ai.md | 274 +++++++++++++ .../popular-web-designs/templates/mongodb.md | 279 +++++++++++++ .../popular-web-designs/templates/notion.md | 322 +++++++++++++++ .../popular-web-designs/templates/nvidia.md | 306 ++++++++++++++ .../popular-web-designs/templates/ollama.md | 280 +++++++++++++ .../templates/opencode.ai.md | 294 ++++++++++++++ .../templates/pinterest.md | 243 +++++++++++ .../popular-web-designs/templates/posthog.md | 269 +++++++++++++ .../popular-web-designs/templates/raycast.md | 281 +++++++++++++ .../templates/replicate.md | 274 +++++++++++++ .../popular-web-designs/templates/resend.md | 316 +++++++++++++++ .../popular-web-designs/templates/revolut.md | 198 +++++++++ .../popular-web-designs/templates/runwayml.md | 257 ++++++++++++ .../popular-web-designs/templates/sanity.md | 370 +++++++++++++++++ .../popular-web-designs/templates/sentry.md | 275 +++++++++++++ .../popular-web-designs/templates/spacex.md | 207 ++++++++++ .../popular-web-designs/templates/spotify.md | 259 ++++++++++++ .../popular-web-designs/templates/stripe.md | 335 +++++++++++++++ .../popular-web-designs/templates/supabase.md | 268 ++++++++++++ .../templates/superhuman.md | 265 ++++++++++++ .../templates/together.ai.md | 276 +++++++++++++ .../popular-web-designs/templates/uber.md | 308 ++++++++++++++ .../popular-web-designs/templates/vercel.md | 323 +++++++++++++++ .../templates/voltagent.md | 336 ++++++++++++++++ .../popular-web-designs/templates/warp.md | 266 ++++++++++++ .../popular-web-designs/templates/webflow.md | 105 +++++ .../popular-web-designs/templates/wise.md | 186 +++++++++ .../popular-web-designs/templates/x.ai.md | 270 +++++++++++++ .../popular-web-designs/templates/zapier.md | 341 ++++++++++++++++ 55 files changed, 14759 insertions(+) create mode 100644 skills/creative/popular-web-designs/SKILL.md create mode 100644 skills/creative/popular-web-designs/templates/airbnb.md create mode 100644 skills/creative/popular-web-designs/templates/airtable.md create mode 100644 skills/creative/popular-web-designs/templates/apple.md create mode 100644 skills/creative/popular-web-designs/templates/bmw.md create mode 100644 skills/creative/popular-web-designs/templates/cal.md create mode 100644 skills/creative/popular-web-designs/templates/claude.md create mode 100644 skills/creative/popular-web-designs/templates/clay.md create mode 100644 skills/creative/popular-web-designs/templates/clickhouse.md create mode 100644 skills/creative/popular-web-designs/templates/cohere.md create mode 100644 skills/creative/popular-web-designs/templates/coinbase.md create mode 100644 skills/creative/popular-web-designs/templates/composio.md create mode 100644 skills/creative/popular-web-designs/templates/cursor.md create mode 100644 skills/creative/popular-web-designs/templates/elevenlabs.md create mode 100644 skills/creative/popular-web-designs/templates/expo.md create mode 100644 skills/creative/popular-web-designs/templates/figma.md create mode 100644 skills/creative/popular-web-designs/templates/framer.md create mode 100644 skills/creative/popular-web-designs/templates/hashicorp.md create mode 100644 skills/creative/popular-web-designs/templates/ibm.md create mode 100644 skills/creative/popular-web-designs/templates/intercom.md create mode 100644 skills/creative/popular-web-designs/templates/kraken.md create mode 100644 skills/creative/popular-web-designs/templates/linear.app.md create mode 100644 skills/creative/popular-web-designs/templates/lovable.md create mode 100644 skills/creative/popular-web-designs/templates/minimax.md create mode 100644 skills/creative/popular-web-designs/templates/mintlify.md create mode 100644 skills/creative/popular-web-designs/templates/miro.md create mode 100644 skills/creative/popular-web-designs/templates/mistral.ai.md create mode 100644 skills/creative/popular-web-designs/templates/mongodb.md create mode 100644 skills/creative/popular-web-designs/templates/notion.md create mode 100644 skills/creative/popular-web-designs/templates/nvidia.md create mode 100644 skills/creative/popular-web-designs/templates/ollama.md create mode 100644 skills/creative/popular-web-designs/templates/opencode.ai.md create mode 100644 skills/creative/popular-web-designs/templates/pinterest.md create mode 100644 skills/creative/popular-web-designs/templates/posthog.md create mode 100644 skills/creative/popular-web-designs/templates/raycast.md create mode 100644 skills/creative/popular-web-designs/templates/replicate.md create mode 100644 skills/creative/popular-web-designs/templates/resend.md create mode 100644 skills/creative/popular-web-designs/templates/revolut.md create mode 100644 skills/creative/popular-web-designs/templates/runwayml.md create mode 100644 skills/creative/popular-web-designs/templates/sanity.md create mode 100644 skills/creative/popular-web-designs/templates/sentry.md create mode 100644 skills/creative/popular-web-designs/templates/spacex.md create mode 100644 skills/creative/popular-web-designs/templates/spotify.md create mode 100644 skills/creative/popular-web-designs/templates/stripe.md create mode 100644 skills/creative/popular-web-designs/templates/supabase.md create mode 100644 skills/creative/popular-web-designs/templates/superhuman.md create mode 100644 skills/creative/popular-web-designs/templates/together.ai.md create mode 100644 skills/creative/popular-web-designs/templates/uber.md create mode 100644 skills/creative/popular-web-designs/templates/vercel.md create mode 100644 skills/creative/popular-web-designs/templates/voltagent.md create mode 100644 skills/creative/popular-web-designs/templates/warp.md create mode 100644 skills/creative/popular-web-designs/templates/webflow.md create mode 100644 skills/creative/popular-web-designs/templates/wise.md create mode 100644 skills/creative/popular-web-designs/templates/x.ai.md create mode 100644 skills/creative/popular-web-designs/templates/zapier.md diff --git a/skills/creative/popular-web-designs/SKILL.md b/skills/creative/popular-web-designs/SKILL.md new file mode 100644 index 000000000000..41e43145a7d0 --- /dev/null +++ b/skills/creative/popular-web-designs/SKILL.md @@ -0,0 +1,207 @@ +--- +name: popular-web-designs +description: > + 54 production-quality design systems extracted from real websites. Load a template + to generate HTML/CSS that matches the visual identity of sites like Stripe, Linear, + Vercel, Notion, Airbnb, and more. Each template includes colors, typography, components, + layout rules, and ready-to-use CSS values. +version: 1.0.0 +author: Hermes Agent + Teknium (design systems sourced from VoltAgent/awesome-design-md) +license: MIT +tags: [design, css, html, ui, web-development, design-systems, templates] +triggers: + - build a page that looks like + - make it look like stripe + - design like linear + - vercel style + - create a UI + - web design + - landing page + - dashboard design + - website styled like +--- + +# Popular Web Designs + +54 real-world design systems ready for use when generating HTML/CSS. Each template captures a +site's complete visual language: color palette, typography hierarchy, component styles, spacing +system, shadows, responsive behavior, and practical agent prompts with exact CSS values. + +## How to Use + +1. Pick a design from the catalog below +2. Load it: `skill_view(name="popular-web-designs", file_path="templates/.md")` +3. Use the design tokens and component specs when generating HTML +4. Pair with the `generative-widgets` skill to serve the result via cloudflared tunnel + +Each template includes a **Hermes Implementation Notes** block at the top with: +- CDN font substitute and Google Fonts `` tag (ready to paste) +- CSS font-family stacks for primary and monospace +- Reminders to use `write_file` for HTML creation and `browser_vision` for verification + +## HTML Generation Pattern + +```html + + + + + + Page Title + + + + + + + + +``` + +Write the file with `write_file`, serve with the `generative-widgets` workflow (cloudflared tunnel), +and verify the result with `browser_vision` to confirm visual accuracy. + +## Font Substitution Reference + +Most sites use proprietary fonts unavailable via CDN. Each template maps to a Google Fonts +substitute that preserves the design's character. Common mappings: + +| Proprietary Font | CDN Substitute | Character | +|---|---|---| +| Geist / Geist Sans | Geist (on Google Fonts) | Geometric, compressed tracking | +| Geist Mono | Geist Mono (on Google Fonts) | Clean monospace, ligatures | +| sohne-var (Stripe) | Source Sans 3 | Light weight elegance | +| Berkeley Mono | JetBrains Mono | Technical monospace | +| Airbnb Cereal VF | DM Sans | Rounded, friendly geometric | +| Circular (Spotify) | DM Sans | Geometric, warm | +| figmaSans | Inter | Clean humanist | +| Pin Sans (Pinterest) | DM Sans | Friendly, rounded | +| NVIDIA-EMEA | Inter (or Arial system) | Industrial, clean | +| CoinbaseDisplay/Sans | DM Sans | Geometric, trustworthy | +| UberMove | DM Sans | Bold, tight | +| HashiCorp Sans | Inter | Enterprise, neutral | +| waldenburgNormal (Sanity) | Space Grotesk | Geometric, slightly condensed | +| IBM Plex Sans/Mono | IBM Plex Sans/Mono | Available on Google Fonts | +| Rubik (Sentry) | Rubik | Available on Google Fonts | + +When a template's CDN font matches the original (Inter, IBM Plex, Rubik, Geist), no +substitution loss occurs. When a substitute is used (DM Sans for Circular, Source Sans 3 +for sohne-var), follow the template's weight, size, and letter-spacing values closely — +those carry more visual identity than the specific font face. + +## Design Catalog + +### AI & Machine Learning + +| Template | Site | Style | +|---|---|---| +| `claude.md` | Anthropic Claude | Warm terracotta accent, clean editorial layout | +| `cohere.md` | Cohere | Vibrant gradients, data-rich dashboard aesthetic | +| `elevenlabs.md` | ElevenLabs | Dark cinematic UI, audio-waveform aesthetics | +| `minimax.md` | Minimax | Bold dark interface with neon accents | +| `mistral.ai.md` | Mistral AI | French-engineered minimalism, purple-toned | +| `ollama.md` | Ollama | Terminal-first, monochrome simplicity | +| `opencode.ai.md` | OpenCode AI | Developer-centric dark theme, full monospace | +| `replicate.md` | Replicate | Clean white canvas, code-forward | +| `runwayml.md` | RunwayML | Cinematic dark UI, media-rich layout | +| `together.ai.md` | Together AI | Technical, blueprint-style design | +| `voltagent.md` | VoltAgent | Void-black canvas, emerald accent, terminal-native | +| `x.ai.md` | xAI | Stark monochrome, futuristic minimalism, full monospace | + +### Developer Tools & Platforms + +| Template | Site | Style | +|---|---|---| +| `cursor.md` | Cursor | Sleek dark interface, gradient accents | +| `expo.md` | Expo | Dark theme, tight letter-spacing, code-centric | +| `linear.app.md` | Linear | Ultra-minimal dark-mode, precise, purple accent | +| `lovable.md` | Lovable | Playful gradients, friendly dev aesthetic | +| `mintlify.md` | Mintlify | Clean, green-accented, reading-optimized | +| `posthog.md` | PostHog | Playful branding, developer-friendly dark UI | +| `raycast.md` | Raycast | Sleek dark chrome, vibrant gradient accents | +| `resend.md` | Resend | Minimal dark theme, monospace accents | +| `sentry.md` | Sentry | Dark dashboard, data-dense, pink-purple accent | +| `supabase.md` | Supabase | Dark emerald theme, code-first developer tool | +| `superhuman.md` | Superhuman | Premium dark UI, keyboard-first, purple glow | +| `vercel.md` | Vercel | Black and white precision, Geist font system | +| `warp.md` | Warp | Dark IDE-like interface, block-based command UI | +| `zapier.md` | Zapier | Warm orange, friendly illustration-driven | + +### Infrastructure & Cloud + +| Template | Site | Style | +|---|---|---| +| `clickhouse.md` | ClickHouse | Yellow-accented, technical documentation style | +| `composio.md` | Composio | Modern dark with colorful integration icons | +| `hashicorp.md` | HashiCorp | Enterprise-clean, black and white | +| `mongodb.md` | MongoDB | Green leaf branding, developer documentation focus | +| `sanity.md` | Sanity | Red accent, content-first editorial layout | +| `stripe.md` | Stripe | Signature purple gradients, weight-300 elegance | + +### Design & Productivity + +| Template | Site | Style | +|---|---|---| +| `airtable.md` | Airtable | Colorful, friendly, structured data aesthetic | +| `cal.md` | Cal.com | Clean neutral UI, developer-oriented simplicity | +| `clay.md` | Clay | Organic shapes, soft gradients, art-directed layout | +| `figma.md` | Figma | Vibrant multi-color, playful yet professional | +| `framer.md` | Framer | Bold black and blue, motion-first, design-forward | +| `intercom.md` | Intercom | Friendly blue palette, conversational UI patterns | +| `miro.md` | Miro | Bright yellow accent, infinite canvas aesthetic | +| `notion.md` | Notion | Warm minimalism, serif headings, soft surfaces | +| `pinterest.md` | Pinterest | Red accent, masonry grid, image-first layout | +| `webflow.md` | Webflow | Blue-accented, polished marketing site aesthetic | + +### Fintech & Crypto + +| Template | Site | Style | +|---|---|---| +| `coinbase.md` | Coinbase | Clean blue identity, trust-focused, institutional feel | +| `kraken.md` | Kraken | Purple-accented dark UI, data-dense dashboards | +| `revolut.md` | Revolut | Sleek dark interface, gradient cards, fintech precision | +| `wise.md` | Wise | Bright green accent, friendly and clear | + +### Enterprise & Consumer + +| Template | Site | Style | +|---|---|---| +| `airbnb.md` | Airbnb | Warm coral accent, photography-driven, rounded UI | +| `apple.md` | Apple | Premium white space, SF Pro, cinematic imagery | +| `bmw.md` | BMW | Dark premium surfaces, precise engineering aesthetic | +| `ibm.md` | IBM | Carbon design system, structured blue palette | +| `nvidia.md` | NVIDIA | Green-black energy, technical power aesthetic | +| `spacex.md` | SpaceX | Stark black and white, full-bleed imagery, futuristic | +| `spotify.md` | Spotify | Vibrant green on dark, bold type, album-art-driven | +| `uber.md` | Uber | Bold black and white, tight type, urban energy | + +## Choosing a Design + +Match the design to the content: + +- **Developer tools / dashboards:** Linear, Vercel, Supabase, Raycast, Sentry +- **Documentation / content sites:** Mintlify, Notion, Sanity, MongoDB +- **Marketing / landing pages:** Stripe, Framer, Apple, SpaceX +- **Dark mode UIs:** Linear, Cursor, ElevenLabs, Warp, Superhuman +- **Light / clean UIs:** Vercel, Stripe, Notion, Cal.com, Replicate +- **Playful / friendly:** PostHog, Figma, Lovable, Zapier, Miro +- **Premium / luxury:** Apple, BMW, Stripe, Superhuman, Revolut +- **Data-dense / dashboards:** Sentry, Kraken, Cohere, ClickHouse +- **Monospace / terminal aesthetic:** Ollama, OpenCode, x.ai, VoltAgent \ No newline at end of file diff --git a/skills/creative/popular-web-designs/templates/airbnb.md b/skills/creative/popular-web-designs/templates/airbnb.md new file mode 100644 index 000000000000..fb233553208c --- /dev/null +++ b/skills/creative/popular-web-designs/templates/airbnb.md @@ -0,0 +1,259 @@ +# Design System: Airbnb + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Airbnb's website is a warm, photography-forward marketplace that feels like flipping through a travel magazine where every page invites you to book. The design operates on a foundation of pure white (`#ffffff`) with the iconic Rausch Red (`#ff385c`) — named after Airbnb's first street address — serving as the singular brand accent. The result is a clean, airy canvas where listing photography, category icons, and the red CTA button are the only sources of color. + +The typography uses Airbnb Cereal VF — a custom variable font that's warm and approachable, with rounded terminals that echo the brand's "belong anywhere" philosophy. The font operates in a tight weight range: 500 (medium) for most UI, 600 (semibold) for emphasis, and 700 (bold) for primary headings. Slight negative letter-spacing (-0.18px to -0.44px) on headings creates a cozy, intimate reading experience rather than the compressed efficiency of tech companies. + +What distinguishes Airbnb is its palette-based token system (`--palette-*`) and multi-layered shadow approach. The primary card shadow uses a three-layer stack (`rgba(0,0,0,0.02) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 6px, rgba(0,0,0,0.1) 0px 4px 8px`) that creates a subtle, warm lift. Combined with generous border-radius (8px–32px), circular navigation controls (50%), and a category pill bar with horizontal scrolling, the interface feels tactile and inviting — designed for browsing, not commanding. + +**Key Characteristics:** +- Pure white canvas with Rausch Red (`#ff385c`) as singular brand accent +- Airbnb Cereal VF — custom variable font with warm, rounded terminals +- Palette-based token system (`--palette-*`) for systematic color management +- Three-layer card shadows: border ring + soft blur + stronger blur +- Generous border-radius: 8px buttons, 14px badges, 20px cards, 32px large elements +- Circular navigation controls (50% radius) +- Photography-first listing cards — images are the hero content +- Near-black text (`#222222`) — warm, not cold +- Luxe Purple (`#460479`) and Plus Magenta (`#92174d`) for premium tiers + +## 2. Color Palette & Roles + +### Primary Brand +- **Rausch Red** (`#ff385c`): `--palette-bg-primary-core`, primary CTA, brand accent, active states +- **Deep Rausch** (`#e00b41`): `--palette-bg-tertiary-core`, pressed/dark variant of brand red +- **Error Red** (`#c13515`): `--palette-text-primary-error`, error text on light +- **Error Dark** (`#b32505`): `--palette-text-secondary-error-hover`, error hover + +### Premium Tiers +- **Luxe Purple** (`#460479`): `--palette-bg-primary-luxe`, Airbnb Luxe tier branding +- **Plus Magenta** (`#92174d`): `--palette-bg-primary-plus`, Airbnb Plus tier branding + +### Text Scale +- **Near Black** (`#222222`): `--palette-text-primary`, primary text — warm, not cold +- **Focused Gray** (`#3f3f3f`): `--palette-text-focused`, focused state text +- **Secondary Gray** (`#6a6a6a`): Secondary text, descriptions +- **Disabled** (`rgba(0,0,0,0.24)`): `--palette-text-material-disabled`, disabled state +- **Link Disabled** (`#929292`): `--palette-text-link-disabled`, disabled links + +### Interactive +- **Legal Blue** (`#428bff`): `--palette-text-legal`, legal links, informational +- **Border Gray** (`#c1c1c1`): Border color for cards and dividers +- **Light Surface** (`#f2f2f2`): Circular navigation buttons, secondary surfaces + +### Surface & Shadows +- **Pure White** (`#ffffff`): Page background, card surfaces +- **Card Shadow** (`rgba(0,0,0,0.02) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 6px, rgba(0,0,0,0.1) 0px 4px 8px`): Three-layer warm lift +- **Hover Shadow** (`rgba(0,0,0,0.08) 0px 4px 12px`): Button hover elevation + +## 3. Typography Rules + +### Font Family +- **Primary**: `Airbnb Cereal VF`, fallbacks: `Circular, -apple-system, system-ui, Roboto, Helvetica Neue` +- **OpenType Features**: `"salt"` (stylistic alternates) on specific caption elements + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Section Heading | Airbnb Cereal VF | 28px (1.75rem) | 700 | 1.43 | normal | Primary headings | +| Card Heading | Airbnb Cereal VF | 22px (1.38rem) | 600 | 1.18 (tight) | -0.44px | Category/card titles | +| Card Heading Medium | Airbnb Cereal VF | 22px (1.38rem) | 500 | 1.18 (tight) | -0.44px | Lighter variant | +| Sub-heading | Airbnb Cereal VF | 21px (1.31rem) | 700 | 1.43 | normal | Bold sub-headings | +| Feature Title | Airbnb Cereal VF | 20px (1.25rem) | 600 | 1.20 (tight) | -0.18px | Feature headings | +| UI Medium | Airbnb Cereal VF | 16px (1.00rem) | 500 | 1.25 (tight) | normal | Nav, emphasized text | +| UI Semibold | Airbnb Cereal VF | 16px (1.00rem) | 600 | 1.25 (tight) | normal | Strong emphasis | +| Button | Airbnb Cereal VF | 16px (1.00rem) | 500 | 1.25 (tight) | normal | Button labels | +| Body / Link | Airbnb Cereal VF | 14px (0.88rem) | 400 | 1.43 | normal | Standard body | +| Body Medium | Airbnb Cereal VF | 14px (0.88rem) | 500 | 1.29 (tight) | normal | Medium body | +| Caption Salt | Airbnb Cereal VF | 14px (0.88rem) | 600 | 1.43 | normal | `"salt"` feature | +| Small | Airbnb Cereal VF | 13px (0.81rem) | 400 | 1.23 (tight) | normal | Descriptions | +| Tag | Airbnb Cereal VF | 12px (0.75rem) | 400–700 | 1.33 | normal | Tags, prices | +| Badge | Airbnb Cereal VF | 11px (0.69rem) | 600 | 1.18 (tight) | normal | `"salt"` feature | +| Micro Uppercase | Airbnb Cereal VF | 8px (0.50rem) | 700 | 1.25 (tight) | 0.32px | `text-transform: uppercase` | + +### Principles +- **Warm weight range**: 500–700 dominate. No weight 300 or 400 for headings — Airbnb's type is always at least medium weight, creating a warm, confident voice. +- **Negative tracking on headings**: -0.18px to -0.44px letter-spacing on display creates intimate, cozy headings rather than cold, compressed ones. +- **"salt" OpenType feature**: Stylistic alternates on specific UI elements (badges, captions) create subtle glyph variations that add visual interest. +- **Variable font precision**: Cereal VF enables continuous weight interpolation, though the design system uses discrete stops at 500, 600, and 700. + +## 4. Component Stylings + +### Buttons + +**Primary Dark** +- Background: `#222222` (near-black, not pure black) +- Text: `#ffffff` +- Padding: 0px 24px +- Radius: 8px +- Hover: transitions to error/brand accent via `var(--accent-bg-error)` +- Focus: `0 0 0 2px var(--palette-grey1000)` ring + scale(0.92) + +**Circular Nav** +- Background: `#f2f2f2` +- Text: `#222222` +- Radius: 50% (circle) +- Hover: shadow `rgba(0,0,0,0.08) 0px 4px 12px` + translateX(50%) +- Active: 4px white border ring + focus shadow +- Focus: scale(0.92) shrink animation + +### Cards & Containers +- Background: `#ffffff` +- Radius: 14px (badges), 20px (cards/buttons), 32px (large) +- Shadow: `rgba(0,0,0,0.02) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 6px, rgba(0,0,0,0.1) 0px 4px 8px` (three-layer) +- Listing cards: full-width photography on top, details below +- Carousel controls: circular 50% buttons + +### Inputs +- Search: `#222222` text +- Focus: `var(--palette-bg-primary-error)` background tint + `0 0 0 2px` ring +- Radius: depends on context (search bar uses pill-like rounding) + +### Navigation +- White sticky header with search bar centered +- Airbnb logo (Rausch Red) left-aligned +- Category filter pills: horizontal scroll below search +- Circular nav controls for carousel navigation +- "Become a Host" text link, avatar/menu right-aligned + +### Image Treatment +- Listing photography fills card top with generous height +- Image carousel with dot indicators +- Heart/wishlist icon overlay on images +- 8px–14px radius on contained images + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 3px, 4px, 6px, 8px, 10px, 11px, 12px, 15px, 16px, 22px, 24px, 32px + +### Grid & Container +- Full-width header with centered search +- Category pill bar: horizontal scrollable row +- Listing grid: responsive multi-column (3–5 columns on desktop) +- Full-width footer with link columns + +### Whitespace Philosophy +- **Travel-magazine spacing**: Generous vertical padding between sections creates a leisurely browsing pace — you're meant to scroll slowly, like browsing a magazine. +- **Photography density**: Listing cards are packed relatively tightly, but each image is large enough to feel immersive. +- **Search bar prominence**: The search bar gets maximum vertical space in the header — finding your destination is the primary action. + +### Border Radius Scale +- Subtle (4px): Small links +- Standard (8px): Buttons, tabs, search elements +- Badge (14px): Status badges, labels +- Card (20px): Feature cards, large buttons +- Large (32px): Large containers, hero elements +- Circle (50%): Nav controls, avatars, icons + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page background, text blocks | +| Card (Level 1) | `rgba(0,0,0,0.02) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 6px, rgba(0,0,0,0.1) 0px 4px 8px` | Listing cards, search bar | +| Hover (Level 2) | `rgba(0,0,0,0.08) 0px 4px 12px` | Button hover, interactive lift | +| Active Focus (Level 3) | `rgb(255,255,255) 0px 0px 0px 4px` + focus ring | Active/focused elements | + +**Shadow Philosophy**: Airbnb's three-layer shadow system creates a warm, natural lift. Layer 1 (`0px 0px 0px 1px` at 0.02 opacity) is an ultra-subtle border. Layer 2 (`0px 2px 6px` at 0.04) provides soft ambient shadow. Layer 3 (`0px 4px 8px` at 0.1) adds the primary lift. This graduated approach creates shadows that feel like natural light rather than CSS effects. + +## 7. Do's and Don'ts + +### Do +- Use `#222222` (warm near-black) for text — never pure `#000000` +- Apply Rausch Red (`#ff385c`) only for primary CTAs and brand moments — it's the singular accent +- Use Airbnb Cereal VF at weight 500–700 — the warm weight range is intentional +- Apply the three-layer card shadow for all elevated surfaces +- Use generous border-radius: 8px for buttons, 20px for cards, 50% for controls +- Use photography as the primary visual content — listings are image-first +- Apply negative letter-spacing (-0.18px to -0.44px) on headings for intimacy +- Use circular (50%) buttons for carousel/navigation controls + +### Don't +- Don't use pure black (`#000000`) for text — always `#222222` (warm) +- Don't apply Rausch Red to backgrounds or large surfaces — it's an accent only +- Don't use thin font weights (300, 400) for headings — 500 minimum +- Don't use heavy shadows (>0.1 opacity as primary layer) — keep them warm and graduated +- Don't use sharp corners (0–4px) on cards — the generous rounding (20px+) is core +- Don't introduce additional brand colors beyond the Rausch/Luxe/Plus system +- Don't override the palette token system — use `--palette-*` variables consistently + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <375px | Single column, compact search | +| Mobile | 375–550px | Standard mobile listing grid | +| Tablet Small | 550–744px | 2-column listings | +| Tablet | 744–950px | Search bar expansion | +| Desktop Small | 950–1128px | 3-column listings | +| Desktop | 1128–1440px | 4-column grid, full header | +| Large Desktop | 1440–1920px | 5-column grid | +| Ultra-wide | >1920px | Maximum grid width | + +*Note: Airbnb has 61 detected breakpoints — one of the most granular responsive systems observed, reflecting their obsession with layout at every possible screen size.* + +### Touch Targets +- Circular nav buttons: adequate 50% radius sizing +- Listing cards: full-card tap target on mobile +- Search bar: prominently sized for thumb interaction +- Category pills: horizontally scrollable with generous padding + +### Collapsing Strategy +- Listing grid: 5 → 4 → 3 → 2 → 1 columns +- Search: expanded bar → compact bar → overlay +- Category pills: horizontal scroll at all sizes +- Navigation: full header → mobile simplified +- Map: side panel → overlay/toggle + +### Image Behavior +- Listing photos: carousel with swipe on mobile +- Responsive image sizing with aspect ratio maintained +- Heart overlay positioned consistently across sizes +- Photo quality adjusts based on viewport + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Pure White (`#ffffff`) +- Text: Near Black (`#222222`) +- Brand accent: Rausch Red (`#ff385c`) +- Secondary text: `#6a6a6a` +- Disabled: `rgba(0,0,0,0.24)` +- Card border: `rgba(0,0,0,0.02) 0px 0px 0px 1px` +- Card shadow: full three-layer stack +- Button surface: `#f2f2f2` + +### Example Component Prompts +- "Create a listing card: white background, 20px radius. Three-layer shadow: rgba(0,0,0,0.02) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 6px, rgba(0,0,0,0.1) 0px 4px 8px. Photo area on top (16:10 ratio), details below: 16px Airbnb Cereal VF weight 600 title, 14px weight 400 description in #6a6a6a." +- "Design search bar: white background, full card shadow, 32px radius on container. Search text at 14px Cereal VF weight 400. Red search button (#ff385c, 50% radius, white icon)." +- "Build category pill bar: horizontal scrollable row. Each pill: 14px Cereal VF weight 600, #222222 text, bottom border on active. Circular prev/next arrows (#f2f2f2 bg, 50% radius)." +- "Create a CTA button: #222222 background, white text, 8px radius, 16px Cereal VF weight 500, 0px 24px padding. Hover: brand red accent." +- "Design a heart/wishlist button: transparent background, 50% radius, white heart icon with dark shadow outline." + +### Iteration Guide +1. Start with white — the photography provides all the color +2. Rausch Red (#ff385c) is the singular accent — use sparingly for CTAs only +3. Near-black (#222222) for text — the warmth matters +4. Three-layer shadows create natural, warm lift — always use all three layers +5. Generous radius: 8px buttons, 20px cards, 50% controls +6. Cereal VF at 500–700 weight — no thin weights for any heading +7. Photography is hero — every listing card is image-first diff --git a/skills/creative/popular-web-designs/templates/airtable.md b/skills/creative/popular-web-designs/templates/airtable.md new file mode 100644 index 000000000000..1807f7ea8460 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/airtable.md @@ -0,0 +1,102 @@ +# Design System: Airtable + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Airtable's website is a clean, enterprise-friendly platform that communicates "sophisticated simplicity" through a white canvas with deep navy text (`#181d26`) and Airtable Blue (`#1b61c9`) as the primary interactive accent. The Haas font family (display + text variants) creates a Swiss-precision typography system with positive letter-spacing throughout. + +**Key Characteristics:** +- White canvas with deep navy text (`#181d26`) +- Airtable Blue (`#1b61c9`) as primary CTA and link color +- Haas + Haas Groot Disp dual font system +- Positive letter-spacing on body text (0.08px–0.28px) +- 12px radius buttons, 16px–32px for cards +- Multi-layer blue-tinted shadow: `rgba(45,127,249,0.28) 0px 1px 3px` +- Semantic theme tokens: `--theme_*` CSS variable naming + +## 2. Color Palette & Roles + +### Primary +- **Deep Navy** (`#181d26`): Primary text +- **Airtable Blue** (`#1b61c9`): CTA buttons, links +- **White** (`#ffffff`): Primary surface +- **Spotlight** (`rgba(249,252,255,0.97)`): `--theme_button-text-spotlight` + +### Semantic +- **Success Green** (`#006400`): `--theme_success-text` +- **Weak Text** (`rgba(4,14,32,0.69)`): `--theme_text-weak` +- **Secondary Active** (`rgba(7,12,20,0.82)`): `--theme_button-text-secondary-active` + +### Neutral +- **Dark Gray** (`#333333`): Secondary text +- **Mid Blue** (`#254fad`): Link/accent blue variant +- **Border** (`#e0e2e6`): Card borders +- **Light Surface** (`#f8fafc`): Subtle surface + +### Shadows +- **Blue-tinted** (`rgba(0,0,0,0.32) 0px 0px 1px, rgba(0,0,0,0.08) 0px 0px 2px, rgba(45,127,249,0.28) 0px 1px 3px, rgba(0,0,0,0.06) 0px 0px 0px 0.5px inset`) +- **Soft** (`rgba(15,48,106,0.05) 0px 0px 20px`) + +## 3. Typography Rules + +### Font Families +- **Primary**: `Haas`, fallbacks: `-apple-system, system-ui, Segoe UI, Roboto` +- **Display**: `Haas Groot Disp`, fallback: `Haas` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | +|------|------|------|--------|-------------|----------------| +| Display Hero | Haas | 48px | 400 | 1.15 | normal | +| Display Bold | Haas Groot Disp | 48px | 900 | 1.50 | normal | +| Section Heading | Haas | 40px | 400 | 1.25 | normal | +| Sub-heading | Haas | 32px | 400–500 | 1.15–1.25 | normal | +| Card Title | Haas | 24px | 400 | 1.20–1.30 | 0.12px | +| Feature | Haas | 20px | 400 | 1.25–1.50 | 0.1px | +| Body | Haas | 18px | 400 | 1.35 | 0.18px | +| Body Medium | Haas | 16px | 500 | 1.30 | 0.08–0.16px | +| Button | Haas | 16px | 500 | 1.25–1.30 | 0.08px | +| Caption | Haas | 14px | 400–500 | 1.25–1.35 | 0.07–0.28px | + +## 4. Component Stylings + +### Buttons +- **Primary Blue**: `#1b61c9`, white text, 16px 24px padding, 12px radius +- **White**: white bg, `#181d26` text, 12px radius, 1px border white +- **Cookie Consent**: `#1b61c9` bg, 2px radius (sharp) + +### Cards: `1px solid #e0e2e6`, 16px–24px radius +### Inputs: Standard Haas styling + +## 5. Layout +- Spacing: 1–48px (8px base) +- Radius: 2px (small), 12px (buttons), 16px (cards), 24px (sections), 32px (large), 50% (circles) + +## 6. Depth +- Blue-tinted multi-layer shadow system +- Soft ambient: `rgba(15,48,106,0.05) 0px 0px 20px` + +## 7. Do's and Don'ts +### Do: Use Airtable Blue for CTAs, Haas with positive tracking, 12px radius buttons +### Don't: Skip positive letter-spacing, use heavy shadows + +## 8. Responsive Behavior +Breakpoints: 425–1664px (23 breakpoints) + +## 9. Agent Prompt Guide +- Text: Deep Navy (`#181d26`) +- CTA: Airtable Blue (`#1b61c9`) +- Background: White (`#ffffff`) +- Border: `#e0e2e6` diff --git a/skills/creative/popular-web-designs/templates/apple.md b/skills/creative/popular-web-designs/templates/apple.md new file mode 100644 index 000000000000..c8c7cef6479b --- /dev/null +++ b/skills/creative/popular-web-designs/templates/apple.md @@ -0,0 +1,326 @@ +# Design System: Apple + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `system-ui` | **Mono:** `SF Mono (system)` +> - **Font stack (CSS):** `font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'SF Mono (system)', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Apple's website is a masterclass in controlled drama — vast expanses of pure black and near-white serve as cinematic backdrops for products that are photographed as if they were sculptures in a gallery. The design philosophy is reductive to its core: every pixel exists in service of the product, and the interface itself retreats until it becomes invisible. This is not minimalism as aesthetic preference; it is minimalism as reverence for the object. + +The typography anchors everything. San Francisco (SF Pro Display for large sizes, SF Pro Text for body) is Apple's proprietary typeface, engineered with optical sizing that automatically adjusts letterforms depending on point size. At display sizes (56px), weight 600 with a tight line-height of 1.07 and subtle negative letter-spacing (-0.28px) creates headlines that feel machined rather than typeset — precise, confident, and unapologetically direct. At body sizes (17px), the tracking loosens slightly (-0.374px) and line-height opens to 1.47, creating a reading rhythm that is comfortable without ever feeling slack. + +The color story is starkly binary. Product sections alternate between pure black (`#000000`) backgrounds with white text and light gray (`#f5f5f7`) backgrounds with near-black text (`#1d1d1f`). This creates a cinematic pacing — dark sections feel immersive and premium, light sections feel open and informational. The only chromatic accent is Apple Blue (`#0071e3`), reserved exclusively for interactive elements: links, buttons, and focus states. This singular accent color in a sea of neutrals gives every clickable element unmistakable visibility. + +**Key Characteristics:** +- SF Pro Display/Text with optical sizing — letterforms adapt automatically to size context +- Binary light/dark section rhythm: black (`#000000`) alternating with light gray (`#f5f5f7`) +- Single accent color: Apple Blue (`#0071e3`) reserved exclusively for interactive elements +- Product-as-hero photography on solid color fields — no gradients, no textures, no distractions +- Extremely tight headline line-heights (1.07-1.14) creating compressed, billboard-like impact +- Full-width section layout with centered content — the viewport IS the canvas +- Pill-shaped CTAs (980px radius) creating soft, approachable action buttons +- Generous whitespace between sections allowing each product moment to breathe + +## 2. Color Palette & Roles + +### Primary +- **Pure Black** (`#000000`): Hero section backgrounds, immersive product showcases. The darkest canvas for the brightest products. +- **Light Gray** (`#f5f5f7`): Alternate section backgrounds, informational areas. Not white — the slight blue-gray tint prevents sterility. +- **Near Black** (`#1d1d1f`): Primary text on light backgrounds, dark button fills. Slightly warmer than pure black for comfortable reading. + +### Interactive +- **Apple Blue** (`#0071e3`): `--sk-focus-color`, primary CTA backgrounds, focus rings. The ONLY chromatic color in the interface. +- **Link Blue** (`#0066cc`): `--sk-body-link-color`, inline text links. Slightly darker than Apple Blue for text-level readability. +- **Bright Blue** (`#2997ff`): Links on dark backgrounds. Higher luminance for contrast on black sections. + +### Text +- **White** (`#ffffff`): Text on dark backgrounds, button text on blue/dark CTAs. +- **Near Black** (`#1d1d1f`): Primary body text on light backgrounds. +- **Black 80%** (`rgba(0, 0, 0, 0.8)`): Secondary text, nav items on light backgrounds. Slightly softened. +- **Black 48%** (`rgba(0, 0, 0, 0.48)`): Tertiary text, disabled states, carousel controls. + +### Surface & Dark Variants +- **Dark Surface 1** (`#272729`): Card backgrounds in dark sections. +- **Dark Surface 2** (`#262628`): Subtle surface variation in dark contexts. +- **Dark Surface 3** (`#28282a`): Elevated cards on dark backgrounds. +- **Dark Surface 4** (`#2a2a2d`): Highest dark surface elevation. +- **Dark Surface 5** (`#242426`): Deepest dark surface tone. + +### Button States +- **Button Active** (`#ededf2`): Active/pressed state for light buttons. +- **Button Default Light** (`#fafafc`): Search/filter button backgrounds. +- **Overlay** (`rgba(210, 210, 215, 0.64)`): Media control scrims, overlays. +- **White 32%** (`rgba(255, 255, 255, 0.32)`): Hover state on dark modal close buttons. + +### Shadows +- **Card Shadow** (`rgba(0, 0, 0, 0.22) 3px 5px 30px 0px`): Soft, diffused elevation for product cards. Offset and wide blur create a natural, photographic shadow. + +## 3. Typography Rules + +### Font Family +- **Display**: `SF Pro Display`, with fallbacks: `SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif` +- **Body**: `SF Pro Text`, with fallbacks: `SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif` +- SF Pro Display is used at 20px and above; SF Pro Text is optimized for 19px and below. + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | SF Pro Display | 56px (3.50rem) | 600 | 1.07 (tight) | -0.28px | Product launch headlines, maximum impact | +| Section Heading | SF Pro Display | 40px (2.50rem) | 600 | 1.10 (tight) | normal | Feature section titles | +| Tile Heading | SF Pro Display | 28px (1.75rem) | 400 | 1.14 (tight) | 0.196px | Product tile headlines | +| Card Title | SF Pro Display | 21px (1.31rem) | 700 | 1.19 (tight) | 0.231px | Bold card headings | +| Sub-heading | SF Pro Display | 21px (1.31rem) | 400 | 1.19 (tight) | 0.231px | Regular card headings | +| Nav Heading | SF Pro Text | 34px (2.13rem) | 600 | 1.47 | -0.374px | Large navigation headings | +| Sub-nav | SF Pro Text | 24px (1.50rem) | 300 | 1.50 | normal | Light sub-navigation text | +| Body | SF Pro Text | 17px (1.06rem) | 400 | 1.47 | -0.374px | Standard reading text | +| Body Emphasis | SF Pro Text | 17px (1.06rem) | 600 | 1.24 (tight) | -0.374px | Emphasized body text, labels | +| Button Large | SF Pro Text | 18px (1.13rem) | 300 | 1.00 (tight) | normal | Large button text, light weight | +| Button | SF Pro Text | 17px (1.06rem) | 400 | 2.41 (relaxed) | normal | Standard button text | +| Link | SF Pro Text | 14px (0.88rem) | 400 | 1.43 | -0.224px | Body links, "Learn more" | +| Caption | SF Pro Text | 14px (0.88rem) | 400 | 1.29 (tight) | -0.224px | Secondary text, descriptions | +| Caption Bold | SF Pro Text | 14px (0.88rem) | 600 | 1.29 (tight) | -0.224px | Emphasized captions | +| Micro | SF Pro Text | 12px (0.75rem) | 400 | 1.33 | -0.12px | Fine print, footnotes | +| Micro Bold | SF Pro Text | 12px (0.75rem) | 600 | 1.33 | -0.12px | Bold fine print | +| Nano | SF Pro Text | 10px (0.63rem) | 400 | 1.47 | -0.08px | Legal text, smallest size | + +### Principles +- **Optical sizing as philosophy**: SF Pro automatically switches between Display and Text optical sizes. Display versions have wider letter spacing and thinner strokes optimized for large sizes; Text versions are tighter and sturdier for small sizes. This means the font literally changes its DNA based on context. +- **Weight restraint**: The scale spans 300 (light) to 700 (bold) but most text lives at 400 (regular) and 600 (semibold). Weight 300 appears only on large decorative text. Weight 700 is rare, used only for bold card titles. +- **Negative tracking at all sizes**: Unlike most systems that only track headlines, Apple applies subtle negative letter-spacing even at body sizes (-0.374px at 17px, -0.224px at 14px, -0.12px at 12px). This creates universally tight, efficient text. +- **Extreme line-height range**: Headlines compress to 1.07 while body text opens to 1.47, and some button contexts stretch to 2.41. This dramatic range creates clear visual hierarchy through rhythm alone. + +## 4. Component Stylings + +### Buttons + +**Primary Blue (CTA)** +- Background: `#0071e3` (Apple Blue) +- Text: `#ffffff` +- Padding: 8px 15px +- Radius: 8px +- Border: 1px solid transparent +- Font: SF Pro Text, 17px, weight 400 +- Hover: background brightens slightly +- Active: `#ededf2` background shift +- Focus: `2px solid var(--sk-focus-color, #0071E3)` outline +- Use: Primary call-to-action ("Buy", "Shop iPhone") + +**Primary Dark** +- Background: `#1d1d1f` +- Text: `#ffffff` +- Padding: 8px 15px +- Radius: 8px +- Font: SF Pro Text, 17px, weight 400 +- Use: Secondary CTA, dark variant + +**Pill Link (Learn More / Shop)** +- Background: transparent +- Text: `#0066cc` (light bg) or `#2997ff` (dark bg) +- Radius: 980px (full pill) +- Border: 1px solid `#0066cc` +- Font: SF Pro Text, 14px-17px +- Hover: underline decoration +- Use: "Learn more" and "Shop" links — the signature Apple inline CTA + +**Filter / Search Button** +- Background: `#fafafc` +- Text: `rgba(0, 0, 0, 0.8)` +- Padding: 0px 14px +- Radius: 11px +- Border: 3px solid `rgba(0, 0, 0, 0.04)` +- Focus: `2px solid var(--sk-focus-color, #0071E3)` outline +- Use: Search bars, filter controls + +**Media Control** +- Background: `rgba(210, 210, 215, 0.64)` +- Text: `rgba(0, 0, 0, 0.48)` +- Radius: 50% (circular) +- Active: scale(0.9), background shifts +- Focus: `2px solid var(--sk-focus-color, #0071e3)` outline, white bg, black text +- Use: Play/pause, carousel arrows + +### Cards & Containers +- Background: `#f5f5f7` (light) or `#272729`-`#2a2a2d` (dark) +- Border: none (borders are rare in Apple's system) +- Radius: 5px-8px +- Shadow: `rgba(0, 0, 0, 0.22) 3px 5px 30px 0px` for elevated product cards +- Content: centered, generous padding +- Hover: no standard hover state — cards are static, links within them are interactive + +### Navigation +- Background: `rgba(0, 0, 0, 0.8)` (translucent dark) with `backdrop-filter: saturate(180%) blur(20px)` +- Height: 48px (compact) +- Text: `#ffffff` at 12px, weight 400 +- Active: underline on hover +- Logo: Apple logomark (SVG) centered or left-aligned, 17x48px viewport +- Mobile: collapses to hamburger with full-screen overlay menu +- The nav floats above content, maintaining its dark translucent glass regardless of section background + +### Image Treatment +- Products on solid-color fields (black or white) — no backgrounds, no context, just the object +- Full-bleed section images that span the entire viewport width +- Product photography at extremely high resolution with subtle shadows +- Lifestyle images confined to rounded-corner containers (12px+ radius) + +### Distinctive Components + +**Product Hero Module** +- Full-viewport-width section with solid background (black or `#f5f5f7`) +- Product name as the primary headline (SF Pro Display, 56px, weight 600) +- One-line descriptor below in lighter weight +- Two pill CTAs side by side: "Learn more" (outline) and "Buy" / "Shop" (filled) + +**Product Grid Tile** +- Square or near-square card on contrasting background +- Product image dominating 60-70% of the tile +- Product name + one-line description below +- "Learn more" and "Shop" link pair at bottom + +**Feature Comparison Strip** +- Horizontal scroll of product variants +- Each variant as a vertical card with image, name, and key specs +- Minimal chrome — the products speak for themselves + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 4px, 5px, 6px, 7px, 8px, 9px, 10px, 11px, 14px, 15px, 17px, 20px, 24px +- Notable characteristic: the scale is dense at small sizes (2-11px) with granular 1px increments, then jumps in larger steps. This allows precise micro-adjustments for typography and icon alignment. + +### Grid & Container +- Max content width: approximately 980px (the recurring "980px radius" in pill buttons echoes this width) +- Hero: full-viewport-width sections with centered content block +- Product grids: 2-3 column layouts within centered container +- Single-column for hero moments — one product, one message, full attention +- No visible grid lines or gutters — spacing creates implied structure + +### Whitespace Philosophy +- **Cinematic breathing room**: Each product section occupies a full viewport height (or close to it). The whitespace between products is not empty — it is the pause between scenes in a film. +- **Vertical rhythm through color blocks**: Rather than using spacing alone to separate sections, Apple uses alternating background colors (black, `#f5f5f7`, white). Each color change signals a new "scene." +- **Compression within, expansion between**: Text blocks are tightly set (negative letter-spacing, tight line-heights) while the space surrounding them is vast. This creates a tension between density and openness. + +### Border Radius Scale +- Micro (5px): Small containers, link tags +- Standard (8px): Buttons, product cards, image containers +- Comfortable (11px): Search inputs, filter buttons +- Large (12px): Feature panels, lifestyle image containers +- Full Pill (980px): CTA links ("Learn more", "Shop"), navigation pills +- Circle (50%): Media controls (play/pause, arrows) + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, solid background | Standard content sections, text blocks | +| Navigation Glass | `backdrop-filter: saturate(180%) blur(20px)` on `rgba(0,0,0,0.8)` | Sticky navigation bar — the glass effect | +| Subtle Lift (Level 1) | `rgba(0, 0, 0, 0.22) 3px 5px 30px 0px` | Product cards, floating elements | +| Media Control | `rgba(210, 210, 215, 0.64)` background with scale transforms | Play/pause buttons, carousel controls | +| Focus (Accessibility) | `2px solid #0071e3` outline | Keyboard focus on all interactive elements | + +**Shadow Philosophy**: Apple uses shadow extremely sparingly. The primary shadow (`3px 5px 30px` with 0.22 opacity) is soft, wide, and offset — mimicking a diffused studio light casting a natural shadow beneath a physical object. This reinforces the "product as physical sculpture" metaphor. Most elements have NO shadow at all; elevation comes from background color contrast (dark card on darker background, or light card on slightly different gray). + +### Decorative Depth +- Navigation glass: the translucent, blurred navigation bar is the most recognizable depth element, creating a sense of floating UI above scrolling content +- Section color transitions: depth is implied by the alternation between black and light gray sections rather than by shadows +- Product photography shadows: the products themselves cast shadows in their photography, so the UI doesn't need to add synthetic ones + +## 7. Do's and Don'ts + +### Do +- Use SF Pro Display at 20px+ and SF Pro Text below 20px — respect the optical sizing boundary +- Apply negative letter-spacing at all text sizes (not just headlines) — Apple tracks tight universally +- Use Apple Blue (`#0071e3`) ONLY for interactive elements — it must be the singular accent +- Alternate between black and light gray (`#f5f5f7`) section backgrounds for cinematic rhythm +- Use 980px pill radius for CTA links — the signature Apple link shape +- Keep product imagery on solid-color fields with no competing visual elements +- Use the translucent dark glass (`rgba(0,0,0,0.8)` + blur) for sticky navigation +- Compress headline line-heights to 1.07-1.14 — Apple headlines are famously tight + +### Don't +- Don't introduce additional accent colors — the entire chromatic budget is spent on blue +- Don't use heavy shadows or multiple shadow layers — Apple's shadow system is one soft diffused shadow or nothing +- Don't use borders on cards or containers — Apple almost never uses visible borders (except on specific buttons) +- Don't apply wide letter-spacing to SF Pro — it is designed to run tight at every size +- Don't use weight 800 or 900 — the maximum is 700 (bold), and even that is rare +- Don't add textures, patterns, or gradients to backgrounds — solid colors only +- Don't make the navigation opaque — the glass blur effect is essential to the Apple UI identity +- Don't center-align body text — Apple body copy is left-aligned; only headlines center +- Don't use rounded corners larger than 12px on rectangular elements (980px is for pills only) + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Small Mobile | <360px | Minimum supported, single column | +| Mobile | 360-480px | Standard mobile layout | +| Mobile Large | 480-640px | Wider single column, larger images | +| Tablet Small | 640-834px | 2-column product grids begin | +| Tablet | 834-1024px | Full tablet layout, expanded nav | +| Desktop Small | 1024-1070px | Standard desktop layout begins | +| Desktop | 1070-1440px | Full layout, max content width | +| Large Desktop | >1440px | Centered with generous margins | + +### Touch Targets +- Primary CTAs: 8px 15px padding creating ~44px touch height +- Navigation links: 48px height with adequate spacing +- Media controls: 50% radius circular buttons, minimum 44x44px +- "Learn more" pills: generous padding for comfortable tapping + +### Collapsing Strategy +- Hero headlines: 56px Display → 40px → 28px on mobile, maintaining tight line-height proportionally +- Product grids: 3-column → 2-column → single column stacked +- Navigation: full horizontal nav → compact mobile menu (hamburger) +- Product hero modules: full-bleed maintained at all sizes, text scales down +- Section backgrounds: maintain full-width color blocks at all breakpoints — the cinematic rhythm never breaks +- Image sizing: products scale proportionally, never crop — the product silhouette is sacred + +### Image Behavior +- Product photography maintains aspect ratio at all breakpoints +- Hero product images scale down but stay centered +- Full-bleed section backgrounds persist at every size +- Lifestyle images may crop on mobile but maintain their rounded corners +- Lazy loading for below-fold product images + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Apple Blue (`#0071e3`) +- Page background (light): `#f5f5f7` +- Page background (dark): `#000000` +- Heading text (light): `#1d1d1f` +- Heading text (dark): `#ffffff` +- Body text: `rgba(0, 0, 0, 0.8)` on light, `#ffffff` on dark +- Link (light bg): `#0066cc` +- Link (dark bg): `#2997ff` +- Focus ring: `#0071e3` +- Card shadow: `rgba(0, 0, 0, 0.22) 3px 5px 30px 0px` + +### Example Component Prompts +- "Create a hero section on black background. Headline at 56px SF Pro Display weight 600, line-height 1.07, letter-spacing -0.28px, color white. One-line subtitle at 21px SF Pro Display weight 400, line-height 1.19, color white. Two pill CTAs: 'Learn more' (transparent bg, white text, 1px solid white border, 980px radius) and 'Buy' (Apple Blue #0071e3 bg, white text, 8px radius, 8px 15px padding)." +- "Design a product card: #f5f5f7 background, 8px border-radius, no border, no shadow. Product image top 60% of card on solid background. Title at 28px SF Pro Display weight 400, letter-spacing 0.196px, line-height 1.14. Description at 14px SF Pro Text weight 400, color rgba(0,0,0,0.8). 'Learn more' and 'Shop' links in #0066cc at 14px." +- "Build the Apple navigation: sticky, 48px height, background rgba(0,0,0,0.8) with backdrop-filter: saturate(180%) blur(20px). Links at 12px SF Pro Text weight 400, white text. Apple logo left, links centered, search and bag icons right." +- "Create an alternating section layout: first section black bg with white text and centered product image, second section #f5f5f7 bg with #1d1d1f text. Each section near full-viewport height with 56px headline and two pill CTAs below." +- "Design a 'Learn more' link: text #0066cc on light bg or #2997ff on dark bg, 14px SF Pro Text, underline on hover. After the text, include a right-arrow chevron character (>). Wrap in a container with 980px border-radius for pill shape when used as a standalone CTA." + +### Iteration Guide +1. Every interactive element gets Apple Blue (`#0071e3`) — no other accent colors +2. Section backgrounds alternate: black for immersive moments, `#f5f5f7` for informational moments +3. Typography optical sizing: SF Pro Display at 20px+, SF Pro Text below — never mix +4. Negative letter-spacing at all sizes: -0.28px at 56px, -0.374px at 17px, -0.224px at 14px, -0.12px at 12px +5. The navigation glass effect (translucent dark + blur) is non-negotiable — it defines the Apple web experience +6. Products always appear on solid color fields — never on gradients, textures, or lifestyle backgrounds in hero modules +7. Shadow is rare and always soft: `3px 5px 30px 0.22 opacity` or nothing at all +8. Pill CTAs use 980px radius — this creates the signature Apple rounded-rectangle-that-looks-like-a-capsule shape diff --git a/skills/creative/popular-web-designs/templates/bmw.md b/skills/creative/popular-web-designs/templates/bmw.md new file mode 100644 index 000000000000..0b8dab2b3ef8 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/bmw.md @@ -0,0 +1,193 @@ +# Design System: BMW + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +BMW's website is automotive engineering made visual — a design system that communicates precision, performance, and German industrial confidence. The page alternates between deep dark hero sections (featuring full-bleed automotive photography) and clean white content areas, creating a cinematic rhythm reminiscent of a luxury car showroom where vehicles are lit against darkness. The BMW CI2020 design language (their corporate identity refresh) defines every element. + +The typography is built on BMWTypeNextLatin — a proprietary typeface in two variants: BMWTypeNextLatin Light (weight 300) for massive uppercase display headings, and BMWTypeNextLatin Regular for body and UI text. The 60px uppercase headline at weight 300 is the defining typographic gesture — light-weight type that whispers authority rather than shouting it. The fallback stack includes Helvetica and Japanese fonts (Hiragino, Meiryo), reflecting BMW's global presence. + +What makes BMW distinctive is its CSS variable-driven theming system. Context-aware variables (`--site-context-highlight-color: #1c69d4`, `--site-context-focus-color: #0653b6`, `--site-context-metainfo-color: #757575`) suggest a design system built for multi-brand, multi-context deployment where colors can be swapped globally. The blue highlight color (`#1c69d4`) is BMW's signature blue — used sparingly for interactive elements and focus states, never decoratively. Zero border-radius was detected — BMW's design is angular, sharp-cornered, and uncompromisingly geometric. + +**Key Characteristics:** +- BMWTypeNextLatin Light (weight 300) uppercase for display — whispered authority +- BMW Blue (`#1c69d4`) as singular accent — used only for interactive elements +- Zero border-radius detected — angular, sharp-cornered, industrial geometry +- Dark hero photography + white content sections — showroom lighting rhythm +- CSS variable-driven theming: `--site-context-*` tokens for brand flexibility +- Weight 900 for navigation emphasis — extreme contrast with 300 display +- Tight line-heights (1.15–1.30) throughout — compressed, efficient, German engineering +- Full-bleed automotive photography as primary visual content + +## 2. Color Palette & Roles + +### Primary Brand +- **Pure White** (`#ffffff`): `--site-context-theme-color`, primary surface, card backgrounds +- **BMW Blue** (`#1c69d4`): `--site-context-highlight-color`, primary interactive accent +- **BMW Focus Blue** (`#0653b6`): `--site-context-focus-color`, keyboard focus and active states + +### Neutral Scale +- **Near Black** (`#262626`): Primary text on light surfaces, dark link text +- **Meta Gray** (`#757575`): `--site-context-metainfo-color`, secondary text, metadata +- **Silver** (`#bbbbbb`): Tertiary text, muted links, footer elements + +### Interactive States +- All links hover to white (`#ffffff`) — suggesting primarily dark-surface navigation +- Text links use underline: none on hover — clean interaction + +### Shadows +- Minimal shadow system — depth through photography and dark/light section contrast + +## 3. Typography Rules + +### Font Families +- **Display Light**: `BMWTypeNextLatin Light`, fallbacks: `Helvetica, Arial, Hiragino Kaku Gothic ProN, Hiragino Sans, Meiryo` +- **Body / UI**: `BMWTypeNextLatin`, same fallback stack + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Notes | +|------|------|------|--------|-------------|-------| +| Display Hero | BMWTypeNextLatin Light | 60px (3.75rem) | 300 | 1.30 (tight) | `text-transform: uppercase` | +| Section Heading | BMWTypeNextLatin | 32px (2.00rem) | 400 | 1.30 (tight) | Major section titles | +| Nav Emphasis | BMWTypeNextLatin | 18px (1.13rem) | 900 | 1.30 (tight) | Navigation bold items | +| Body | BMWTypeNextLatin | 16px (1.00rem) | 400 | 1.15 (tight) | Standard body text | +| Button Bold | BMWTypeNextLatin | 16px (1.00rem) | 700 | 1.20–2.88 | CTA buttons | +| Button | BMWTypeNextLatin | 16px (1.00rem) | 400 | 1.15 (tight) | Standard buttons | + +### Principles +- **Light display, heavy navigation**: Weight 300 for hero headlines creates whispered elegance; weight 900 for navigation creates stark authority. This extreme weight contrast (300 vs 900) is the signature typographic tension. +- **Universal uppercase display**: The 60px hero is always uppercase — creating a monumental, architectural quality. +- **Tight everything**: Line-heights from 1.15 to 1.30 across the entire system. Nothing breathes — every line is compressed, efficient, German-engineered. +- **Single font family**: BMWTypeNextLatin handles everything from 60px display to 16px body — unity through one typeface at different weights. + +## 4. Component Stylings + +### Buttons +- Text: 16px BMWTypeNextLatin, weight 700 for primary, 400 for secondary +- Line-height: 1.15–2.88 (large variation suggests padding-driven sizing) +- Border: white bottom-border on dark surfaces (`1px solid #ffffff`) +- No border-radius — sharp rectangular buttons + +### Cards & Containers +- No border-radius — all containers are sharp-cornered rectangles +- White backgrounds on light sections +- Dark backgrounds for hero/feature sections +- No visible borders on most elements + +### Navigation +- BMWTypeNextLatin 18px weight 900 for primary nav links +- White text on dark header +- BMW logo 54x54px +- Hover: remains white, text-decoration none +- "Home" text link in header + +### Image Treatment +- Full-bleed automotive photography +- Dark cinematic lighting +- Edge-to-edge hero images +- Car photography as primary visual content + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 5px, 8px, 10px, 12px, 15px, 16px, 20px, 24px, 30px, 32px, 40px, 45px, 56px, 60px + +### Grid & Container +- Full-width hero photography +- Centered content sections +- Footer: multi-column link grid + +### Whitespace Philosophy +- **Showroom pacing**: Dark hero sections with generous padding create the feeling of walking through a showroom where each vehicle is spotlit in its own space. +- **Compressed content**: Body text areas use tight line-heights and compact spacing — information-dense, no waste. + +### Border Radius Scale +- **None detected.** BMW uses sharp corners exclusively — every element is a precise rectangle. This is the most angular design system analyzed. + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Photography (Level 0) | Full-bleed dark imagery | Hero backgrounds | +| Flat (Level 1) | White surface, no shadow | Content sections | +| Focus (Accessibility) | BMW Focus Blue (`#0653b6`) | Focus states | + +**Shadow Philosophy**: BMW uses virtually no shadows. Depth is created entirely through the contrast between dark photographic sections and white content sections — the automotive lighting does the elevation work. + +## 7. Do's and Don'ts + +### Do +- Use BMWTypeNextLatin Light (300) uppercase for all display headings +- Keep ALL corners sharp (0px radius) — angular geometry is non-negotiable +- Use BMW Blue (`#1c69d4`) only for interactive elements — never decoratively +- Apply weight 900 for navigation emphasis — the extreme weight contrast is intentional +- Use full-bleed automotive photography for hero sections +- Keep line-heights tight (1.15–1.30) throughout +- Use `--site-context-*` CSS variables for theming + +### Don't +- Don't round corners — zero radius is the BMW identity +- Don't use BMW Blue for backgrounds or large surfaces — it's an accent only +- Don't use medium font weights (500–600) — the system uses 300, 400, 700, 900 extremes +- Don't add decorative elements — the photography and typography carry everything +- Don't use relaxed line-heights — BMW text is always compressed +- Don't lighten the dark hero sections — the contrast with white IS the design + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <375px | Minimum supported | +| Mobile | 375–480px | Single column | +| Mobile Large | 480–640px | Slight adjustments | +| Tablet Small | 640–768px | 2-column begins | +| Tablet | 768–920px | Standard tablet | +| Desktop Small | 920–1024px | Desktop layout begins | +| Desktop | 1024–1280px | Standard desktop | +| Large Desktop | 1280–1440px | Expanded | +| Ultra-wide | 1440–1600px | Maximum layout | + +### Collapsing Strategy +- Hero: 60px → scales down, maintains uppercase +- Navigation: horizontal → hamburger +- Photography: full-bleed maintained at all sizes +- Content sections: stack vertically +- Footer: multi-column → stacked + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Pure White (`#ffffff`) +- Text: Near Black (`#262626`) +- Secondary text: Meta Gray (`#757575`) +- Accent: BMW Blue (`#1c69d4`) +- Focus: BMW Focus Blue (`#0653b6`) +- Muted: Silver (`#bbbbbb`) + +### Example Component Prompts +- "Create a hero: full-width dark automotive photography background. Heading at 60px BMWTypeNextLatin Light weight 300, uppercase, line-height 1.30, white text. No border-radius anywhere." +- "Design navigation: dark background. BMWTypeNextLatin 18px weight 900 for links, white text. BMW logo 54x54. Sharp rectangular layout." +- "Build a button: 16px BMWTypeNextLatin weight 700, line-height 1.20. Sharp corners (0px radius). White bottom border on dark surface." +- "Create content section: white background. Heading at 32px weight 400, line-height 1.30, #262626. Body at 16px weight 400, line-height 1.15." + +### Iteration Guide +1. Zero border-radius — every corner is sharp, no exceptions +2. Weight extremes: 300 (display), 400 (body), 700 (buttons), 900 (nav) +3. BMW Blue for interactive only — never as background or decoration +4. Photography carries emotion — the UI is pure precision +5. Tight line-heights everywhere — 1.15 to 1.30 is the range diff --git a/skills/creative/popular-web-designs/templates/cal.md b/skills/creative/popular-web-designs/templates/cal.md new file mode 100644 index 000000000000..e650380042a8 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/cal.md @@ -0,0 +1,272 @@ +# Design System: Cal.com + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `Roboto Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Cal.com's website is a masterclass in monochromatic restraint — a grayscale world where boldness comes not from color but from the sheer confidence of black text on white space. Inspired by Uber's minimal aesthetic, the palette is deliberately stripped of hue: near-black headings (`#242424`), mid-gray secondary text (`#898989`), and pure white surfaces. Color is treated as a foreign substance — when it appears (a rare blue link, a green trust badge), it feels like a controlled accent in an otherwise black-and-white photograph. + +Cal Sans, the brand's custom geometric display typeface designed by Mark Davis, is the visual centerpiece. Letters are intentionally spaced extremely close at large sizes, creating dense, architectural headlines that feel like they're carved into the page. At 64px and 48px, Cal Sans headings sit at weight 600 with a tight 1.10 line-height — confident, compressed, and immediately recognizable. For body text, the system switches to Inter, providing "rock-solid" readability that complements Cal Sans's display personality. The typography pairing creates a clear division: Cal Sans speaks, Inter explains. + +The elevation system is notably sophisticated for a minimal site — 11 shadow definitions create a nuanced depth hierarchy using multi-layered shadows that combine ring borders (`0px 0px 0px 1px`), soft diffused shadows, and inset highlights. This shadow-first approach to depth (rather than border-first) gives surfaces a subtle three-dimensionality that feels modern and polished. Built on Framer with a border-radius scale from 2px to 9999px (pill), Cal.com balances geometric precision with soft, rounded interactive elements. + +**Key Characteristics:** +- Purely grayscale brand palette — no brand colors, boldness through monochrome +- Cal Sans custom geometric display font with extremely tight default letter-spacing +- Multi-layered shadow system (11 definitions) with ring borders + diffused shadows + inset highlights +- Cal Sans for headings, Inter for body — clean typographic division +- Wide border-radius scale from 2px to 9999px (pill) — versatile rounding +- White canvas with near-black (#242424) text — maximum contrast, zero decoration +- Product screenshots as primary visual content — the scheduling UI sells itself +- Built on Framer platform + +## 2. Color Palette & Roles + +### Primary +- **Charcoal** (`#242424`): Primary heading and button text — Cal.com's signature near-black, warmer than pure black +- **Midnight** (`#111111`): Deepest text/overlay color — used at 50% opacity for subtle overlays +- **White** (`#ffffff`): Primary background and surface — the dominant canvas + +### Secondary & Accent +- **Link Blue** (`#0099ff`): In-text links with underline decoration — the only blue in the system, reserved strictly for hyperlinks +- **Focus Ring** (`#3b82f6` at 50% opacity): Keyboard focus indicator — accessibility-only, invisible in normal interaction +- **Default Link** (`#0000ee`): Browser-default link color on some elements — unmodified, signaling openness + +### Surface & Background +- **Pure White** (`#ffffff`): Primary page background and card surfaces +- **Light Gray** (approx `#f5f5f5`): Subtle section differentiation — barely visible tint +- **Mid Gray** (`#898989`): Secondary text, descriptions, and muted labels + +### Neutrals & Text +- **Charcoal** (`#242424`): Headlines, buttons, primary UI text +- **Midnight** (`#111111`): Deep black for high-contrast links and nav text +- **Mid Gray** (`#898989`): Descriptions, secondary labels, muted content +- **Pure Black** (`#000000`): Certain link text elements +- **Border Gray** (approx `rgba(34, 42, 53, 0.08–0.10)`): Shadow-based borders using ring shadows instead of CSS borders + +### Semantic & Accent +- Cal.com is deliberately colorless for brand elements — "a grayscale brand to emphasise on boldness and professionalism" +- Product UI screenshots show color (blues, greens in the scheduling interface), but the marketing site itself stays monochrome +- The philosophy mirrors Uber's approach: let the content carry color, the frame stays neutral + +### Gradient System +- No gradients on the marketing site — the design is fully flat and monochrome +- Depth is achieved entirely through shadows, not color transitions + +## 3. Typography Rules + +### Font Family +- **Display**: `Cal Sans` — custom geometric sans-serif by Mark Davis. Open-source, available on Google Fonts and GitHub. Extremely tight default letter-spacing designed for large headlines. Has 6 character variants (Cc, j, t, u, 0, 1) +- **Body**: `Inter` — "rock-solid" standard body font. Fallback: `Inter Placeholder` +- **UI Light**: `Cal Sans UI Variable Light` — light-weight variant (300) for softer UI text with -0.2px letter-spacing +- **UI Medium**: `Cal Sans UI Medium` — medium-weight variant (500) for emphasized captions +- **Mono**: `Roboto Mono` — for code blocks and technical content +- **Tertiary**: `Matter Regular` / `Matter SemiBold` / `Matter Medium` — additional body fonts for specific contexts + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Cal Sans | 64px | 600 | 1.10 | 0px | Maximum impact, tight default spacing | +| Section Heading | Cal Sans | 48px | 600 | 1.10 | 0px | Large section titles | +| Feature Heading | Cal Sans | 24px | 600 | 1.30 | 0px | Feature block headlines | +| Sub-heading | Cal Sans | 20px | 600 | 1.20 | +0.2px | Positive spacing for readability at smaller size | +| Sub-heading Alt | Cal Sans | 20px | 600 | 1.50 | 0px | Relaxed line-height variant | +| Card Title | Cal Sans | 16px | 600 | 1.10 | 0px | Smallest Cal Sans usage | +| Caption Label | Cal Sans | 12px | 600 | 1.50 | 0px | Small labels in Cal Sans | +| Body Light | Cal Sans UI Light | 18px | 300 | 1.30 | -0.2px | Light-weight body intro text | +| Body Light Standard | Cal Sans UI Light | 16px | 300 | 1.50 | -0.2px | Light-weight body text | +| Caption Light | Cal Sans UI Light | 14px | 300 | 1.40–1.50 | -0.2 to -0.28px | Light captions and descriptions | +| UI Label | Inter | 16px | 600 | 1.00 | 0px | UI buttons and nav labels | +| Caption Inter | Inter | 14px | 500 | 1.14 | 0px | Small UI text | +| Micro | Inter | 12px | 500 | 1.00 | 0px | Smallest Inter text | +| Code | Roboto Mono | 14px | 600 | 1.00 | 0px | Code snippets, technical text | +| Body Matter | Matter Regular | 14px | 400 | 1.14 | 0px | Alternate body text (product UI) | + +### Principles +- **Cal Sans at large, Inter at small**: Cal Sans is exclusively for headings and display — never for body text. The system enforces this division strictly +- **Tight by default, space when small**: Cal Sans letters are "intentionally spaced to be extremely close" at large sizes. At 20px and below, positive letter-spacing (+0.2px) must be applied to prevent cramming +- **Weight 300 body variant**: Cal Sans UI Variable Light at 300 weight creates an elegant, airy body text that contrasts with the dense 600-weight headlines +- **Weight 600 dominance**: Nearly all Cal Sans usage is at weight 600 (semi-bold) — the font was designed to perform at this weight +- **Negative tracking on light text**: Cal Sans UI Light uses -0.2px to -0.28px letter-spacing, subtly tightening the already-compact letterforms + +## 4. Component Stylings + +### Buttons +- **Dark Primary**: `#242424` (or `#1e1f23`) background, white text, 6–8px radius. Hover: opacity reduction to 0.7. The signature CTA — maximally dark on white +- **White/Ghost**: White background with shadow-ring border, dark text. Uses the multi-layered shadow system for subtle elevation +- **Pill**: 9999px radius for rounded pill-shaped actions and badges +- **Compact**: 4px padding, small text — utility actions within product UI +- **Inset highlight**: Some buttons feature `rgba(255, 255, 255, 0.15) 0px 2px 0px inset` — a subtle inner-top highlight creating a 3D pressed effect + +### Cards & Containers +- **Shadow Card**: White background, multi-layered shadow — `rgba(19, 19, 22, 0.7) 0px 1px 5px -4px, rgba(34, 42, 53, 0.08) 0px 0px 0px 1px, rgba(34, 42, 53, 0.05) 0px 4px 8px 0px`. The ring shadow (0px 0px 0px 1px) acts as a shadow-border +- **Product UI Cards**: Screenshots of the scheduling interface displayed in card containers with shadow elevation +- **Radius**: 8px for standard cards, 12px for larger containers, 16px for prominent sections +- **Hover**: Likely subtle shadow deepening or scale transform + +### Inputs & Forms +- **Select dropdown**: White background, `#000000` text, 1px solid `rgb(118, 118, 118)` border +- **Focus**: Uses Framer's focus outline system (`--framer-focus-outline`) +- **Text input**: 8px radius, standard border treatment +- **Minimal form presence**: The marketing site prioritizes CTA buttons over complex forms + +### Navigation +- **Top nav**: White/transparent background, Cal Sans links at near-black +- **Nav text**: `#111111` (Midnight) for primary links, `#000000` for emphasis +- **CTA button**: Dark Primary in the nav — high contrast call-to-action +- **Mobile**: Collapses to hamburger with simplified navigation +- **Sticky**: Fixed on scroll + +### Image Treatment +- **Product screenshots**: Large scheduling UI screenshots — the product is the primary visual +- **Trust logos**: Grayscale company logos in a horizontal trust bar +- **Aspect ratios**: Wide landscape for product UI screenshots +- **No decorative imagery**: No illustrations, photos, or abstract graphics — pure product + typography + +## 5. Layout Principles + +### Spacing System +- **Base unit**: 8px +- **Scale**: 1px, 2px, 3px, 4px, 6px, 8px, 12px, 16px, 20px, 24px, 28px, 80px, 96px +- **Section padding**: 80px–96px vertical between major sections (generous) +- **Card padding**: 12px–24px internal +- **Component gaps**: 4px–8px between related elements +- **Notable jump**: From 28px to 80px — a deliberate gap emphasizing the section-level spacing tier + +### Grid & Container +- **Max width**: ~1200px content container, centered +- **Column patterns**: Full-width hero, centered text blocks, 2-3 column feature grids +- **Feature showcase**: Product screenshots flanked by description text +- **Breakpoints**: 98px, 640px, 768px, 810px, 1024px, 1199px — Framer-generated + +### Whitespace Philosophy +- **Lavish section spacing**: 80px–96px between sections creates a breathable, premium feel +- **Product-first content**: Screenshots dominate the visual space — minimal surrounding decoration +- **Centered headlines**: Cal Sans headings centered with generous margins above and below + +### Border Radius Scale +- **2px**: Subtle rounding on inline elements +- **4px**: Small UI components +- **6px–7px**: Buttons, small cards, images +- **8px**: Standard interactive elements — buttons, inputs, images +- **12px**: Medium containers — links, larger cards, images +- **16px**: Large section containers +- **29px**: Special rounded elements +- **100px**: Large rounding — nearly circular on small elements +- **1000px**: Very large rounding +- **9999px**: Full pill shape — badges, links + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Level 0 (Flat) | No shadow | Page canvas, basic text containers | +| Level 1 (Inset) | `rgba(0,0,0,0.16) 0px 1px 1.9px 0px inset` | Pressed/recessed elements, input wells | +| Level 2 (Ring + Soft) | `rgba(19,19,22,0.7) 0px 1px 5px -4px, rgba(34,42,53,0.08) 0px 0px 0px 1px, rgba(34,42,53,0.05) 0px 4px 8px` | Cards, containers — the workhorse shadow | +| Level 3 (Ring + Soft Alt) | `rgba(36,36,36,0.7) 0px 1px 5px -4px, rgba(36,36,36,0.05) 0px 4px 8px` | Alt card elevation without ring border | +| Level 4 (Inset Highlight) | `rgba(255,255,255,0.15) 0px 2px 0px inset` or `rgb(255,255,255) 0px 2px 0px inset` | Button inner highlight — 3D pressed effect | +| Level 5 (Soft Only) | `rgba(34,42,53,0.05) 0px 4px 8px` | Subtle ambient shadow | + +### Shadow Philosophy +Cal.com's shadow system is the most sophisticated element of the design — 11 shadow definitions using a multi-layered compositing technique: +- **Ring borders**: `0px 0px 0px 1px` shadows act as borders, avoiding CSS `border` entirely. This creates hairline containment without affecting layout +- **Diffused soft shadows**: `0px 4px 8px` at 5% opacity add gentle ambient depth +- **Sharp contact shadows**: `0px 1px 5px -4px` at 70% opacity create tight bottom-edge shadows for grounding +- **Inset highlights**: White inset shadows at the top of buttons create a subtle 3D bevel +- Shadows are composed in comma-separated stacks — each surface gets 2-3 layered shadow definitions working together + +### Decorative Depth +- No gradients or glow effects +- All depth comes from the sophisticated shadow compositing system +- The overall effect is subtle but precise — surfaces feel like physical cards sitting on a table + +## 7. Do's and Don'ts + +### Do +- Use Cal Sans exclusively for headings (24px+) and never for body text — it's a display font with tight default spacing +- Apply positive letter-spacing (+0.2px) when using Cal Sans below 24px — the font cramps at small sizes without it +- Maintain the grayscale palette — boldness comes from contrast, not color +- Use the multi-layered shadow system for card elevation — ring shadow + diffused shadow + contact shadow +- Keep backgrounds pure white — the monochrome philosophy requires a clean canvas +- Use Inter for all body text at weight 300–600 — it's the reliable counterpart to Cal Sans's display personality +- Let product screenshots be the visual content — no illustrations, no decorative graphics +- Apply generous section spacing (80px–96px) — the breathing room is essential to the premium feel + +### Don't +- Use Cal Sans for body text or text below 16px — it wasn't designed for extended reading +- Add brand colors — Cal.com is intentionally grayscale, color is reserved for links and UI states only +- Use CSS borders when shadows can achieve the same containment — the ring-shadow technique is the system's approach +- Apply negative letter-spacing to Cal Sans at small sizes — it needs positive spacing (+0.2px) below 24px +- Create heavy, dark shadows — Cal.com's shadows are subtle (5% opacity diffused) with sharp contact edges +- Use illustrations, abstract graphics, or decorative elements — the visual language is typography + product UI only +- Mix Cal Sans weights — the font is designed for weight 600, other weights break the intended character +- Reduce section spacing below 48px — the generous whitespace is core to the premium monochrome aesthetic + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, hero text ~36px, stacked features, hamburger nav | +| Tablet Small | 640px–768px | 2-column begins for some elements | +| Tablet | 768px–810px | Layout adjustments, fuller grid | +| Tablet Large | 810px–1024px | Multi-column feature grids | +| Desktop | 1024px–1199px | Full layout, expanded navigation | +| Large Desktop | >1199px | Max-width container, centered content | + +### Touch Targets +- Buttons: 8px radius with comfortable padding (10px+ vertical) +- Nav links: Dark text with adequate spacing +- Mobile CTAs: Full-width dark buttons for easy thumb access +- Pill badges: 9999px radius creates large, tappable targets + +### Collapsing Strategy +- **Navigation**: Full horizontal nav → hamburger on mobile +- **Hero**: 64px Cal Sans display → ~36px on mobile +- **Feature grids**: Multi-column → 2-column → single stacked column +- **Product screenshots**: Scale within containers, maintaining aspect ratios +- **Section spacing**: Reduces from 80px–96px to ~48px on mobile + +### Image Behavior +- Product screenshots scale responsively +- Trust logos reflow to multi-row grid on mobile +- No art direction changes — same compositions at all sizes +- Images use 7px–12px border-radius for consistent rounded corners + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Text: Charcoal (`#242424`) +- Deep Text: Midnight (`#111111`) +- Secondary Text: Mid Gray (`#898989`) +- Background: Pure White (`#ffffff`) +- Link: Link Blue (`#0099ff`) +- CTA Button: Charcoal (`#242424`) bg, white text +- Shadow Border: `rgba(34, 42, 53, 0.08)` ring + +### Example Component Prompts +- "Create a hero section with white background, 64px Cal Sans heading at weight 600, line-height 1.10, #242424 text, centered layout with a dark CTA button (#242424, 8px radius, white text)" +- "Design a scheduling card with white background, multi-layered shadow (0px 1px 5px -4px rgba(19,19,22,0.7), 0px 0px 0px 1px rgba(34,42,53,0.08), 0px 4px 8px rgba(34,42,53,0.05)), 12px radius" +- "Build a navigation bar with white background, Inter links at 14px weight 500 in #111111, a dark CTA button (#242424), sticky positioning" +- "Create a trust bar with grayscale company logos, horizontally centered, 16px gap between logos, on white background" +- "Design a feature section with 48px Cal Sans heading (weight 600, #242424), 16px Inter body text (weight 300, #898989, line-height 1.50), and a product screenshot with 12px radius and the card shadow" + +### Iteration Guide +When refining existing screens generated with this design system: +1. Verify headings use Cal Sans at weight 600, body uses Inter — never mix them +2. Check that the palette is purely grayscale — if you see brand colors, remove them +3. Ensure card elevation uses the multi-layered shadow stack, not CSS borders +4. Confirm section spacing is generous (80px+) — if sections feel cramped, add more space +5. The overall tone should feel like a clean, professional scheduling tool — monochrome confidence without any decorative flourishes diff --git a/skills/creative/popular-web-designs/templates/claude.md b/skills/creative/popular-web-designs/templates/claude.md new file mode 100644 index 000000000000..9e1414827ba7 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/claude.md @@ -0,0 +1,325 @@ +# Design System: Claude (Anthropic) + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Claude's interface is a literary salon reimagined as a product page — warm, unhurried, and quietly intellectual. The entire experience is built on a parchment-toned canvas (`#f5f4ed`) that deliberately evokes the feeling of high-quality paper rather than a digital surface. Where most AI product pages lean into cold, futuristic aesthetics, Claude's design radiates human warmth, as if the AI itself has good taste in interior design. + +The signature move is the custom Anthropic Serif typeface — a medium-weight serif with generous proportions that gives every headline the gravitas of a book title. Combined with organic, hand-drawn-feeling illustrations in terracotta (`#c96442`), black, and muted green, the visual language says "thoughtful companion" rather than "powerful tool." The serif headlines breathe at tight-but-comfortable line-heights (1.10–1.30), creating a cadence that feels more like reading an essay than scanning a product page. + +What makes Claude's design truly distinctive is its warm neutral palette. Every gray has a yellow-brown undertone (`#5e5d59`, `#87867f`, `#4d4c48`) — there are no cool blue-grays anywhere. Borders are cream-tinted (`#f0eee6`, `#e8e6dc`), shadows use warm transparent blacks, and even the darkest surfaces (`#141413`, `#30302e`) carry a barely perceptible olive warmth. This chromatic consistency creates a space that feels lived-in and trustworthy. + +**Key Characteristics:** +- Warm parchment canvas (`#f5f4ed`) evoking premium paper, not screens +- Custom Anthropic type family: Serif for headlines, Sans for UI, Mono for code +- Terracotta brand accent (`#c96442`) — warm, earthy, deliberately un-tech +- Exclusively warm-toned neutrals — every gray has a yellow-brown undertone +- Organic, editorial illustrations replacing typical tech iconography +- Ring-based shadow system (`0px 0px 0px 1px`) creating border-like depth without visible borders +- Magazine-like pacing with generous section spacing and serif-driven hierarchy + +## 2. Color Palette & Roles + +### Primary +- **Anthropic Near Black** (`#141413`): The primary text color and dark-theme surface — not pure black but a warm, almost olive-tinted dark that's gentler on the eyes. The warmest "black" in any major tech brand. +- **Terracotta Brand** (`#c96442`): The core brand color — a burnt orange-brown used for primary CTA buttons, brand moments, and the signature accent. Deliberately earthy and un-tech. +- **Coral Accent** (`#d97757`): A lighter, warmer variant of the brand color used for text accents, links on dark surfaces, and secondary emphasis. + +### Secondary & Accent +- **Error Crimson** (`#b53333`): A deep, warm red for error states — serious without being alarming. +- **Focus Blue** (`#3898ec`): Standard blue for input focus rings — the only cool color in the entire system, used purely for accessibility. + +### Surface & Background +- **Parchment** (`#f5f4ed`): The primary page background — a warm cream with a yellow-green tint that feels like aged paper. The emotional foundation of the entire design. +- **Ivory** (`#faf9f5`): The lightest surface — used for cards and elevated containers on the Parchment background. Barely distinguishable but creates subtle layering. +- **Pure White** (`#ffffff`): Reserved for specific button surfaces and maximum-contrast elements. +- **Warm Sand** (`#e8e6dc`): Button backgrounds and prominent interactive surfaces — a noticeably warm light gray. +- **Dark Surface** (`#30302e`): Dark-theme containers, nav borders, and elevated dark elements — warm charcoal. +- **Deep Dark** (`#141413`): Dark-theme page background and primary dark surface. + +### Neutrals & Text +- **Charcoal Warm** (`#4d4c48`): Button text on light warm surfaces — the go-to dark-on-light text. +- **Olive Gray** (`#5e5d59`): Secondary body text — a distinctly warm medium-dark gray. +- **Stone Gray** (`#87867f`): Tertiary text, footnotes, and de-emphasized metadata. +- **Dark Warm** (`#3d3d3a`): Dark text links and emphasized secondary text. +- **Warm Silver** (`#b0aea5`): Text on dark surfaces — a warm, parchment-tinted light gray. + +### Semantic & Accent +- **Border Cream** (`#f0eee6`): Standard light-theme border — barely visible warm cream, creating the gentlest possible containment. +- **Border Warm** (`#e8e6dc`): Prominent borders, section dividers, and emphasized containment on light surfaces. +- **Border Dark** (`#30302e`): Standard border on dark surfaces — maintains the warm tone. +- **Ring Warm** (`#d1cfc5`): Shadow ring color for button hover/focus states. +- **Ring Subtle** (`#dedc01`): Secondary ring variant for lighter interactive surfaces. +- **Ring Deep** (`#c2c0b6`): Deeper ring for active/pressed states. + +### Gradient System +- Claude's design is **gradient-free** in the traditional sense. Depth and visual richness come from the interplay of warm surface tones, organic illustrations, and light/dark section alternation. The warm palette itself creates a "gradient" effect as the eye moves through cream → sand → stone → charcoal → black sections. + +## 3. Typography Rules + +### Font Family +- **Headline**: `Anthropic Serif`, with fallback: `Georgia` +- **Body / UI**: `Anthropic Sans`, with fallback: `Arial` +- **Code**: `Anthropic Mono`, with fallback: `Arial` + +*Note: These are custom typefaces. For external implementations, Georgia serves as the serif substitute and system-ui/Inter as the sans substitute.* + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | Anthropic Serif | 64px (4rem) | 500 | 1.10 (tight) | normal | Maximum impact, book-title presence | +| Section Heading | Anthropic Serif | 52px (3.25rem) | 500 | 1.20 (tight) | normal | Feature section anchors | +| Sub-heading Large | Anthropic Serif | 36–36.8px (~2.3rem) | 500 | 1.30 | normal | Secondary section markers | +| Sub-heading | Anthropic Serif | 32px (2rem) | 500 | 1.10 (tight) | normal | Card titles, feature names | +| Sub-heading Small | Anthropic Serif | 25–25.6px (~1.6rem) | 500 | 1.20 | normal | Smaller section titles | +| Feature Title | Anthropic Serif | 20.8px (1.3rem) | 500 | 1.20 | normal | Small feature headings | +| Body Serif | Anthropic Serif | 17px (1.06rem) | 400 | 1.60 (relaxed) | normal | Serif body text (editorial passages) | +| Body Large | Anthropic Sans | 20px (1.25rem) | 400 | 1.60 (relaxed) | normal | Intro paragraphs | +| Body / Nav | Anthropic Sans | 17px (1.06rem) | 400–500 | 1.00–1.60 | normal | Navigation links, UI text | +| Body Standard | Anthropic Sans | 16px (1rem) | 400–500 | 1.25–1.60 | normal | Standard body, button text | +| Body Small | Anthropic Sans | 15px (0.94rem) | 400–500 | 1.00–1.60 | normal | Compact body text | +| Caption | Anthropic Sans | 14px (0.88rem) | 400 | 1.43 | normal | Metadata, descriptions | +| Label | Anthropic Sans | 12px (0.75rem) | 400–500 | 1.25–1.60 | 0.12px | Badges, small labels | +| Overline | Anthropic Sans | 10px (0.63rem) | 400 | 1.60 | 0.5px | Uppercase overline labels | +| Micro | Anthropic Sans | 9.6px (0.6rem) | 400 | 1.60 | 0.096px | Smallest text | +| Code | Anthropic Mono | 15px (0.94rem) | 400 | 1.60 | -0.32px | Inline code, terminal | + +### Principles +- **Serif for authority, sans for utility**: Anthropic Serif carries all headline content with medium weight (500), giving every heading the gravitas of a published title. Anthropic Sans handles all functional UI text — buttons, labels, navigation — with quiet efficiency. +- **Single weight for serifs**: All Anthropic Serif headings use weight 500 — no bold, no light. This creates a consistent "voice" across all headline sizes, as if the same author wrote every heading. +- **Relaxed body line-height**: Most body text uses 1.60 line-height — significantly more generous than typical tech sites (1.4–1.5). This creates a reading experience closer to a book than a dashboard. +- **Tight-but-not-compressed headings**: Line-heights of 1.10–1.30 for headings are tight but never claustrophobic. The serif letterforms need breathing room that sans-serif fonts don't. +- **Micro letter-spacing on labels**: Small sans text (12px and below) uses deliberate letter-spacing (0.12px–0.5px) to maintain readability at tiny sizes. + +## 4. Component Stylings + +### Buttons + +**Warm Sand (Secondary)** +- Background: Warm Sand (`#e8e6dc`) +- Text: Charcoal Warm (`#4d4c48`) +- Padding: 0px 12px 0px 8px (asymmetric — icon-first layout) +- Radius: comfortably rounded (8px) +- Shadow: ring-based (`#e8e6dc 0px 0px 0px 0px, #d1cfc5 0px 0px 0px 1px`) +- The workhorse button — warm, unassuming, clearly interactive + +**White Surface** +- Background: Pure White (`#ffffff`) +- Text: Anthropic Near Black (`#141413`) +- Padding: 8px 16px 8px 12px +- Radius: generously rounded (12px) +- Hover: shifts to secondary background color +- Clean, elevated button for light surfaces + +**Dark Charcoal** +- Background: Dark Surface (`#30302e`) +- Text: Ivory (`#faf9f5`) +- Padding: 0px 12px 0px 8px +- Radius: comfortably rounded (8px) +- Shadow: ring-based (`#30302e 0px 0px 0px 0px, ring 0px 0px 0px 1px`) +- The inverted variant for dark-on-light emphasis + +**Brand Terracotta** +- Background: Terracotta Brand (`#c96442`) +- Text: Ivory (`#faf9f5`) +- Radius: 8–12px +- Shadow: ring-based (`#c96442 0px 0px 0px 0px, #c96442 0px 0px 0px 1px`) +- The primary CTA — the only button with chromatic color + +**Dark Primary** +- Background: Anthropic Near Black (`#141413`) +- Text: Warm Silver (`#b0aea5`) +- Padding: 9.6px 16.8px +- Radius: generously rounded (12px) +- Border: thin solid Dark Surface (`1px solid #30302e`) +- Used on dark theme surfaces + +### Cards & Containers +- Background: Ivory (`#faf9f5`) or Pure White (`#ffffff`) on light surfaces; Dark Surface (`#30302e`) on dark +- Border: thin solid Border Cream (`1px solid #f0eee6`) on light; `1px solid #30302e` on dark +- Radius: comfortably rounded (8px) for standard cards; generously rounded (16px) for featured; very rounded (32px) for hero containers and embedded media +- Shadow: whisper-soft (`rgba(0,0,0,0.05) 0px 4px 24px`) for elevated content +- Ring shadow: `0px 0px 0px 1px` patterns for interactive card states +- Section borders: `1px 0px 0px` (top-only) for list item separators + +### Inputs & Forms +- Text: Anthropic Near Black (`#141413`) +- Padding: 1.6px 12px (very compact vertical) +- Border: standard warm borders +- Focus: ring with Focus Blue (`#3898ec`) border-color — the only cool color moment +- Radius: generously rounded (12px) + +### Navigation +- Sticky top nav with warm background +- Logo: Claude wordmark in Anthropic Near Black +- Links: mix of Near Black (`#141413`), Olive Gray (`#5e5d59`), and Dark Warm (`#3d3d3a`) +- Nav border: `1px solid #30302e` (dark) or `1px solid #f0eee6` (light) +- CTA: Terracotta Brand button or White Surface button +- Hover: text shifts to foreground-primary, no decoration + +### Image Treatment +- Product screenshots showing the Claude chat interface +- Generous border-radius on media (16–32px) +- Embedded video players with rounded corners +- Dark UI screenshots provide contrast against warm light canvas +- Organic, hand-drawn illustrations for conceptual sections + +### Distinctive Components + +**Model Comparison Cards** +- Opus 4.5, Sonnet 4.5, Haiku 4.5 presented in a clean card grid +- Each model gets a bordered card with name, description, and capability badges +- Border Warm (`#e8e6dc`) separation between items + +**Organic Illustrations** +- Hand-drawn-feeling vector illustrations in terracotta, black, and muted green +- Abstract, conceptual rather than literal product diagrams +- The primary visual personality — no other AI company uses this style + +**Dark/Light Section Alternation** +- The page alternates between Parchment light and Near Black dark sections +- Creates a reading rhythm like chapters in a book +- Each section feels like a distinct environment + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 3px, 4px, 6px, 8px, 10px, 12px, 16px, 20px, 24px, 30px +- Button padding: asymmetric (0px 12px 0px 8px) or balanced (8px 16px) +- Card internal padding: approximately 24–32px +- Section vertical spacing: generous (estimated 80–120px between major sections) + +### Grid & Container +- Max container width: approximately 1200px, centered +- Hero: centered with editorial layout +- Feature sections: single-column or 2–3 column card grids +- Model comparison: clean 3-column grid +- Full-width dark sections breaking the container for emphasis + +### Whitespace Philosophy +- **Editorial pacing**: Each section breathes like a magazine spread — generous top/bottom margins create natural reading pauses. +- **Serif-driven rhythm**: The serif headings establish a literary cadence that demands more whitespace than sans-serif designs. +- **Content island approach**: Sections alternate between light and dark environments, creating distinct "rooms" for each message. + +### Border Radius Scale +- Sharp (4px): Minimal inline elements +- Subtly rounded (6–7.5px): Small buttons, secondary interactive elements +- Comfortably rounded (8–8.5px): Standard buttons, cards, containers +- Generously rounded (12px): Primary buttons, input fields, nav elements +- Very rounded (16px): Featured containers, video players, tab lists +- Highly rounded (24px): Tag-like elements, highlighted containers +- Maximum rounded (32px): Hero containers, embedded media, large cards + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Parchment background, inline text | +| Contained (Level 1) | `1px solid #f0eee6` (light) or `1px solid #30302e` (dark) | Standard cards, sections | +| Ring (Level 2) | `0px 0px 0px 1px` ring shadows using warm grays | Interactive cards, buttons, hover states | +| Whisper (Level 3) | `rgba(0,0,0,0.05) 0px 4px 24px` | Elevated feature cards, product screenshots | +| Inset (Level 4) | `inset 0px 0px 0px 1px` at 15% opacity | Active/pressed button states | + +**Shadow Philosophy**: Claude communicates depth through **warm-toned ring shadows** rather than traditional drop shadows. The signature `0px 0px 0px 1px` pattern creates a border-like halo that's softer than an actual border — it's a shadow pretending to be a border, or a border that's technically a shadow. When drop shadows do appear, they're extremely soft (0.05 opacity, 24px blur) — barely visible lifts that suggest floating rather than casting. + +### Decorative Depth +- **Light/Dark alternation**: The most dramatic depth effect comes from alternating between Parchment (`#f5f4ed`) and Near Black (`#141413`) sections — entire sections shift elevation by changing the ambient light level. +- **Warm ring halos**: Button and card interactions use ring shadows that match the warm palette — never cool-toned or generic gray. + +## 7. Do's and Don'ts + +### Do +- Use Parchment (`#f5f4ed`) as the primary light background — the warm cream tone IS the Claude personality +- Use Anthropic Serif at weight 500 for all headlines — the single-weight consistency is intentional +- Use Terracotta Brand (`#c96442`) only for primary CTAs and the highest-signal brand moments +- Keep all neutrals warm-toned — every gray should have a yellow-brown undertone +- Use ring shadows (`0px 0px 0px 1px`) for interactive element states instead of drop shadows +- Maintain the editorial serif/sans hierarchy — serif for content headlines, sans for UI +- Use generous body line-height (1.60) for a literary reading experience +- Alternate between light and dark sections to create chapter-like page rhythm +- Apply generous border-radius (12–32px) for a soft, approachable feel + +### Don't +- Don't use cool blue-grays anywhere — the palette is exclusively warm-toned +- Don't use bold (700+) weight on Anthropic Serif — weight 500 is the ceiling for serifs +- Don't introduce saturated colors beyond Terracotta — the palette is deliberately muted +- Don't use sharp corners (< 6px radius) on buttons or cards — softness is core to the identity +- Don't apply heavy drop shadows — depth comes from ring shadows and background color shifts +- Don't use pure white (`#ffffff`) as a page background — Parchment (`#f5f4ed`) or Ivory (`#faf9f5`) are always warmer +- Don't use geometric/tech-style illustrations — Claude's illustrations are organic and hand-drawn-feeling +- Don't reduce body line-height below 1.40 — the generous spacing supports the editorial personality +- Don't use monospace fonts for non-code content — Anthropic Mono is strictly for code +- Don't mix in sans-serif for headlines — the serif/sans split is the typographic identity + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Small Mobile | <479px | Minimum layout, stacked everything, compact typography | +| Mobile | 479–640px | Single column, hamburger nav, reduced heading sizes | +| Large Mobile | 640–767px | Slightly wider content area | +| Tablet | 768–991px | 2-column grids begin, condensed nav | +| Desktop | 992px+ | Full multi-column layout, expanded nav, maximum hero typography (64px) | + +### Touch Targets +- Buttons use generous padding (8–16px vertical minimum) +- Navigation links adequately spaced for thumb navigation +- Card surfaces serve as large touch targets +- Minimum recommended: 44x44px + +### Collapsing Strategy +- **Navigation**: Full horizontal nav collapses to hamburger on mobile +- **Feature sections**: Multi-column → stacked single column +- **Hero text**: 64px → 36px → ~25px progressive scaling +- **Model cards**: 3-column → stacked vertical +- **Section padding**: Reduces proportionally but maintains editorial rhythm +- **Illustrations**: Scale proportionally, maintain aspect ratios + +### Image Behavior +- Product screenshots scale proportionally within rounded containers +- Illustrations maintain quality at all sizes +- Video embeds maintain 16:9 aspect ratio with rounded corners +- No art direction changes between breakpoints + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Brand CTA: "Terracotta Brand (#c96442)" +- Page Background: "Parchment (#f5f4ed)" +- Card Surface: "Ivory (#faf9f5)" +- Primary Text: "Anthropic Near Black (#141413)" +- Secondary Text: "Olive Gray (#5e5d59)" +- Tertiary Text: "Stone Gray (#87867f)" +- Borders (light): "Border Cream (#f0eee6)" +- Dark Surface: "Dark Surface (#30302e)" + +### Example Component Prompts +- "Create a hero section on Parchment (#f5f4ed) with a headline at 64px Anthropic Serif weight 500, line-height 1.10. Use Anthropic Near Black (#141413) text. Add a subtitle in Olive Gray (#5e5d59) at 20px Anthropic Sans with 1.60 line-height. Place a Terracotta Brand (#c96442) CTA button with Ivory text, 12px radius." +- "Design a feature card on Ivory (#faf9f5) with a 1px solid Border Cream (#f0eee6) border and comfortably rounded corners (8px). Title in Anthropic Serif at 25px weight 500, description in Olive Gray (#5e5d59) at 16px Anthropic Sans. Add a whisper shadow (rgba(0,0,0,0.05) 0px 4px 24px)." +- "Build a dark section on Anthropic Near Black (#141413) with Ivory (#faf9f5) headline text in Anthropic Serif at 52px weight 500. Use Warm Silver (#b0aea5) for body text. Borders in Dark Surface (#30302e)." +- "Create a button in Warm Sand (#e8e6dc) with Charcoal Warm (#4d4c48) text, 8px radius, and a ring shadow (0px 0px 0px 1px #d1cfc5). Padding: 0px 12px 0px 8px." +- "Design a model comparison grid with three cards on Ivory surfaces. Each card gets a Border Warm (#e8e6dc) top border, model name in Anthropic Serif at 25px, and description in Olive Gray at 15px Anthropic Sans." + +### Iteration Guide +1. Focus on ONE component at a time +2. Reference specific color names — "use Olive Gray (#5e5d59)" not "make it gray" +3. Always specify warm-toned variants — no cool grays +4. Describe serif vs sans usage explicitly — "Anthropic Serif for the heading, Anthropic Sans for the label" +5. For shadows, use "ring shadow (0px 0px 0px 1px)" or "whisper shadow" — never generic "drop shadow" +6. Specify the warm background — "on Parchment (#f5f4ed)" or "on Near Black (#141413)" +7. Keep illustrations organic and conceptual — describe "hand-drawn-feeling" style diff --git a/skills/creative/popular-web-designs/templates/clay.md b/skills/creative/popular-web-designs/templates/clay.md new file mode 100644 index 000000000000..30038b56eb6b --- /dev/null +++ b/skills/creative/popular-web-designs/templates/clay.md @@ -0,0 +1,317 @@ +# Design System: Clay + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Clay's website is a warm, playful celebration of color that treats B2B data enrichment like a craft rather than an enterprise chore. The design language is built on a foundation of warm cream backgrounds (`#faf9f7`) and oat-toned borders (`#dad4c8`, `#eee9df`) that give every surface the tactile quality of handmade paper. Against this artisanal canvas, a vivid swatch palette explodes with personality — Matcha green, Slushie cyan, Lemon gold, Ube purple, Pomegranate pink, Blueberry navy, and Dragonfruit magenta — each named like flavors at a juice bar, not colors in an enterprise UI kit. + +The typography is anchored by Roobert, a geometric sans-serif with character, loaded with an extensive set of OpenType stylistic sets (`"ss01"`, `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"`) that give the text a distinctive, slightly quirky personality. At display scale (80px, weight 600), Roobert uses aggressive negative letter-spacing (-3.2px) that compresses headlines into punchy, billboard-like statements. Space Mono serves as the monospace companion for code and technical labels, completing the craft-meets-tech duality. + +What makes Clay truly distinctive is its hover micro-animations: buttons on hover rotate slightly (`rotateZ(-8deg)`), translate upward (`translateY(-80%)`), change background to a contrasting swatch color, and cast a hard offset shadow (`rgb(0,0,0) -7px 7px`). This playful hover behavior — where a button literally tilts and jumps on interaction — creates a sense of physical delight that's rare in B2B software. Combined with generously rounded containers (24px–40px radius), dashed borders alongside solid ones, and a multi-layer shadow system that includes inset highlights, Clay feels like a design system that was made by people who genuinely enjoy making things. + +**Key Characteristics:** +- Warm cream canvas (`#faf9f7`) with oat-toned borders (`#dad4c8`) — artisanal, not clinical +- Named swatch palette: Matcha, Slushie, Lemon, Ube, Pomegranate, Blueberry, Dragonfruit +- Roobert font with 5 OpenType stylistic sets — quirky geometric character +- Playful hover animations: rotateZ(-8deg) + translateY(-80%) + hard offset shadow +- Space Mono for code and technical labels +- Generous border radius: 24px cards, 40px sections, 1584px pills +- Mixed border styles: solid + dashed in the same interface +- Multi-layer shadow with inset highlight: `0px 1px 1px` + `-1px inset` + `-0.5px` + +## 2. Color Palette & Roles + +### Primary +- **Clay Black** (`#000000`): Text, headings, pricing card text, `--_theme--pricing-cards---text` +- **Pure White** (`#ffffff`): Card backgrounds, button backgrounds, inverse text +- **Warm Cream** (`#faf9f7`): Page background — the warm, paper-like canvas + +### Swatch Palette — Named Colors + +**Matcha (Green)** +- **Matcha 300** (`#84e7a5`): `--_swatches---color--matcha-300`, light green accent +- **Matcha 600** (`#078a52`): `--_swatches---color--matcha-600`, mid green +- **Matcha 800** (`#02492a`): `--_swatches---color--matcha-800`, deep green for dark sections + +**Slushie (Cyan)** +- **Slushie 500** (`#3bd3fd`): `--_swatches---color--slushie-500`, bright cyan accent +- **Slushie 800** (`#0089ad`): `--_swatches---color--slushie-800`, deep teal + +**Lemon (Gold)** +- **Lemon 400** (`#f8cc65`): `--_swatches---color--lemon-400`, warm pale gold +- **Lemon 500** (`#fbbd41`): `--_swatches---color--lemon-500`, primary gold +- **Lemon 700** (`#d08a11`): `--_swatches---color--lemon-700`, deep amber +- **Lemon 800** (`#9d6a09`): `--_swatches---color--lemon-800`, dark amber + +**Ube (Purple)** +- **Ube 300** (`#c1b0ff`): `--_swatches---color--ube-300`, soft lavender +- **Ube 800** (`#43089f`): `--_swatches---color--ube-800`, deep purple +- **Ube 900** (`#32037d`): `--_swatches---color--ube-900`, darkest purple + +**Pomegranate (Pink/Red)** +- **Pomegranate 400** (`#fc7981`): `--_swatches---color--pomegranate-400`, warm coral-pink + +**Blueberry (Navy Blue)** +- **Blueberry 800** (`#01418d`): `--_swatches---color--blueberry-800`, deep navy + +### Neutral Scale (Warm) +- **Warm Silver** (`#9f9b93`): Secondary/muted text, footer links +- **Warm Charcoal** (`#55534e`): Tertiary text, dark muted links +- **Dark Charcoal** (`#333333`): Link text on light backgrounds + +### Surface & Border +- **Oat Border** (`#dad4c8`): Primary border — warm, cream-toned structural lines +- **Oat Light** (`#eee9df`): Secondary lighter border +- **Cool Border** (`#e6e8ec`): Cool-toned border for contrast sections +- **Dark Border** (`#525a69`): Border on dark sections +- **Light Frost** (`#eff1f3`): Subtle button background (at 0% opacity on hover) + +### Badges +- **Badge Blue Bg** (`#f0f8ff`): Blue-tinted badge surface +- **Badge Blue Text** (`#3859f9`): Vivid blue badge text +- **Focus Ring** (`rgb(20, 110, 245) solid 2px`): Accessibility focus indicator + +### Shadows +- **Clay Shadow** (`rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px 1px inset, rgba(0,0,0,0.05) 0px -0.5px 1px`): Multi-layer with inset highlight — the signature +- **Hard Offset** (`rgb(0,0,0) -7px 7px`): Hover state — playful hard shadow + +## 3. Typography Rules + +### Font Families +- **Primary**: `Roobert`, fallback: `Arial` +- **Monospace**: `Space Mono` +- **OpenType Features**: `"ss01"`, `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"` on all Roobert text (display uses all 5; body/UI uses `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"`) + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Roobert | 80px (5.00rem) | 600 | 1.00 (tight) | -3.2px | All 5 stylistic sets | +| Display Secondary | Roobert | 60px (3.75rem) | 600 | 1.00 (tight) | -2.4px | All 5 stylistic sets | +| Section Heading | Roobert | 44px (2.75rem) | 600 | 1.10 (tight) | -0.88px to -1.32px | All 5 stylistic sets | +| Card Heading | Roobert | 32px (2.00rem) | 600 | 1.10 (tight) | -0.64px | All 5 stylistic sets | +| Feature Title | Roobert | 20px (1.25rem) | 600 | 1.40 | -0.4px | All 5 stylistic sets | +| Sub-heading | Roobert | 20px (1.25rem) | 500 | 1.50 | -0.16px | 4 stylistic sets (no ss01) | +| Body Large | Roobert | 20px (1.25rem) | 400 | 1.40 | normal | 4 stylistic sets | +| Body | Roobert | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.36px | 4 stylistic sets | +| Body Standard | Roobert | 16px (1.00rem) | 400 | 1.50 | normal | 4 stylistic sets | +| Body Medium | Roobert | 16px (1.00rem) | 500 | 1.20–1.40 | -0.16px to -0.32px | 4–5 stylistic sets | +| Button | Roobert | 16px (1.00rem) | 500 | 1.50 | -0.16px | 4 stylistic sets | +| Button Large | Roobert | 24px (1.50rem) | 400 | 1.50 | normal | 4 stylistic sets | +| Button Small | Roobert | 12.8px (0.80rem) | 500 | 1.50 | -0.128px | 4 stylistic sets | +| Nav Link | Roobert | 15px (0.94rem) | 500 | 1.60 (relaxed) | normal | 4 stylistic sets | +| Caption | Roobert | 14px (0.88rem) | 400 | 1.50–1.60 | -0.14px | 4 stylistic sets | +| Small | Roobert | 12px (0.75rem) | 400 | 1.50 | normal | 4 stylistic sets | +| Uppercase Label | Roobert | 12px (0.75rem) | 600 | 1.20 (tight) | 1.08px | `text-transform: uppercase`, 4 sets | +| Badge | Roobert | 9.6px | 600 | — | — | Pill badges | + +### Principles +- **Five stylistic sets as identity**: The combination of `"ss01"`, `"ss03"`, `"ss10"`, `"ss11"`, `"ss12"` on Roobert creates a distinctive typographic personality. `ss01` is reserved for headings and emphasis — body text omits it, creating a subtle hierarchy through glyph variation. +- **Aggressive display compression**: -3.2px at 80px, -2.4px at 60px — the most compressed display tracking alongside the most generous body spacing (1.60 line-height), creating dramatic contrast. +- **Weight 600 for headings, 500 for UI, 400 for body**: Clean three-tier system where each weight has a strict role. +- **Uppercase labels with positive tracking**: 12px uppercase at 1.08px letter-spacing creates the systematic wayfinding pattern. + +## 4. Component Stylings + +### Buttons + +**Primary (Transparent with Hover Animation)** +- Background: transparent (`rgba(239, 241, 243, 0)`) +- Text: `#000000` +- Padding: 6.4px 12.8px +- Border: none (or `1px solid #717989` for outlined variant) +- Hover: background shifts to swatch color (e.g., `#434346`), text to white, `rotateZ(-8deg)`, `translateY(-80%)`, hard shadow `rgb(0,0,0) -7px 7px` +- Focus: `rgb(20, 110, 245) solid 2px` outline + +**White Solid** +- Background: `#ffffff` +- Text: `#000000` +- Padding: 6.4px +- Hover: oat-200 swatch color, animated rotation + shadow +- Use: Primary CTA on colored sections + +**Ghost Outlined** +- Background: transparent +- Text: `#000000` +- Padding: 8px +- Border: `1px solid #717989` +- Radius: 4px +- Hover: dragonfruit swatch color, white text, animated rotation + +### Cards & Containers +- Background: `#ffffff` on cream canvas +- Border: `1px solid #dad4c8` (warm oat) or `1px dashed #dad4c8` +- Radius: 12px (standard cards), 24px (feature cards/images), 40px (section containers/footer) +- Shadow: `rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px 1px inset, rgba(0,0,0,0.05) 0px -0.5px 1px` +- Colorful section backgrounds using swatch palette (matcha, slushie, ube, lemon) + +### Inputs & Forms +- Text: `#000000` +- Border: `1px solid #717989` +- Radius: 4px +- Focus: `rgb(20, 110, 245) solid 2px` outline + +### Navigation +- Sticky top nav on cream background +- Roobert 15px weight 500 for nav links +- Clay logo left-aligned +- CTA buttons right-aligned with pill radius +- Border bottom: `1px solid #dad4c8` +- Mobile: hamburger collapse at 767px + +### Image Treatment +- Product screenshots in white cards with oat borders +- Colorful illustrated sections with swatch background colors +- 8px–24px radius on images +- Full-width colorful section backgrounds + +### Distinctive Components + +**Swatch Color Sections** +- Full-width sections with swatch-colored backgrounds (matcha green, slushie cyan, ube purple, lemon gold) +- White text on dark swatches, black text on light swatches +- Each section tells a distinct product story through its color + +**Playful Hover Buttons** +- Rotate -8deg + translate upward on hover +- Hard offset shadow (`-7px 7px`) instead of soft blur +- Background transitions to contrasting swatch color +- Creates a physical, toy-like interaction quality + +**Dashed Border Elements** +- Dashed borders (`1px dashed #dad4c8`) alongside solid borders +- Used for secondary containers and decorative elements +- Adds a hand-drawn, craft-like quality + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 6.4px, 8px, 12px, 12.8px, 16px, 18px, 20px, 24px + +### Grid & Container +- Max content width centered +- Feature sections alternate between white cards and colorful swatch backgrounds +- Card grids: 2–3 columns on desktop +- Full-width colorful sections break the grid +- Footer with generous 40px radius container + +### Whitespace Philosophy +- **Warm, generous breathing**: The cream background provides a warm rest between content blocks. Spacing is generous but not austere — it feels inviting, like a well-set table. +- **Color as spatial rhythm**: The alternating swatch-colored sections create visual rhythm through hue rather than just whitespace. Each color section is its own "room." +- **Craft-like density inside cards**: Within cards, content is compact and well-organized, contrasting with the generous outer spacing. + +### Border Radius Scale +- Sharp (4px): Ghost buttons, inputs +- Standard (8px): Small cards, images, links +- Badge (11px): Tag badges +- Card (12px): Standard cards, buttons +- Feature (24px): Feature cards, images, panels +- Section (40px): Large sections, footer, containers +- Pill (1584px): CTAs, pill-shaped buttons + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, cream canvas | Page background | +| Clay Shadow (Level 1) | `rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px inset, rgba(0,0,0,0.05) 0px -0.5px` | Cards, buttons — multi-layer with inset highlight | +| Hover Hard (Level 2) | `rgb(0,0,0) -7px 7px` | Hover state — playful hard offset shadow | +| Focus (Level 3) | `rgb(20, 110, 245) solid 2px` | Keyboard focus ring | + +**Shadow Philosophy**: Clay's shadow system is uniquely three-layered: a downward cast (`0px 1px 1px`), an upward inset highlight (`0px -1px 1px inset`), and a subtle edge (`0px -0.5px 1px`). This creates a "pressed into clay" quality where elements feel both raised AND embedded — like a clay tablet where content is stamped into the surface. The hover hard shadow (`-7px 7px`) is deliberately retro-graphic, referencing print-era drop shadows and adding physical playfulness. + +### Decorative Depth +- Full-width swatch-colored sections create dramatic depth through color contrast +- Dashed borders add visual texture alongside solid borders +- Product illustrations with warm, organic art style + +## 7. Do's and Don'ts + +### Do +- Use warm cream (`#faf9f7`) as the page background — the warmth is the identity +- Apply all 5 OpenType stylistic sets on Roobert headings: `"ss01", "ss03", "ss10", "ss11", "ss12"` +- Use the named swatch palette (Matcha, Slushie, Lemon, Ube, Pomegranate, Blueberry) for section backgrounds +- Apply the playful hover animation: `rotateZ(-8deg)`, `translateY(-80%)`, hard shadow `-7px 7px` +- Use warm oat borders (`#dad4c8`) — not neutral gray +- Mix solid and dashed borders for visual variety +- Use generous radius: 24px for cards, 40px for sections +- Use weight 600 exclusively for headings, 500 for UI, 400 for body + +### Don't +- Don't use cool gray backgrounds — the warm cream (`#faf9f7`) is non-negotiable +- Don't use neutral gray borders (`#ccc`, `#ddd`) — always use the warm oat tones +- Don't mix more than 2 swatch colors in the same section +- Don't skip the OpenType stylistic sets — they define Roobert's character +- Don't use subtle hover effects — the rotation + hard shadow is the signature interaction +- Don't use small border radius (<12px) on feature cards — the generous rounding is structural +- Don't use standard shadows (blur-based) — Clay uses hard offset and multi-layer inset +- Don't forget the uppercase labels with 1.08px tracking — they're the wayfinding system + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <479px | Single column, tight padding | +| Mobile | 479–767px | Standard mobile, stacked layout | +| Tablet | 768–991px | 2-column grids, condensed nav | +| Desktop | 992px+ | Full layout, 3-column grids, expanded sections | + +### Touch Targets +- Buttons: minimum 6.4px + 12.8px padding for adequate touch area +- Nav links: 15px font with generous spacing +- Mobile: full-width buttons for easy tapping + +### Collapsing Strategy +- Hero: 80px → 60px → smaller display text +- Navigation: horizontal → hamburger at 767px +- Feature sections: multi-column → stacked +- Colorful sections: maintain full-width but compress padding +- Card grids: 3-column → 2-column → single column + +### Image Behavior +- Product screenshots scale proportionally +- Colorful section illustrations adapt to viewport width +- Rounded corners maintained across breakpoints + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Warm Cream (`#faf9f7`) +- Text: Clay Black (`#000000`) +- Secondary text: Warm Silver (`#9f9b93`) +- Border: Oat Border (`#dad4c8`) +- Green accent: Matcha 600 (`#078a52`) +- Cyan accent: Slushie 500 (`#3bd3fd`) +- Gold accent: Lemon 500 (`#fbbd41`) +- Purple accent: Ube 800 (`#43089f`) +- Pink accent: Pomegranate 400 (`#fc7981`) + +### Example Component Prompts +- "Create a hero on warm cream (#faf9f7) background. Headline at 80px Roobert weight 600, line-height 1.00, letter-spacing -3.2px, OpenType 'ss01 ss03 ss10 ss11 ss12', black text. Subtitle at 20px weight 400, line-height 1.40, #9f9b93 text. Two buttons: white solid pill (12px radius) and ghost outlined (4px radius, 1px solid #717989)." +- "Design a colorful section with Matcha 800 (#02492a) background. Heading at 44px Roobert weight 600, letter-spacing -1.32px, white text. Body at 18px weight 400, line-height 1.60, #84e7a5 text. White card inset with oat border (#dad4c8), 24px radius." +- "Build a button with playful hover: default transparent background, black text, 16px Roobert weight 500. On hover: background #434346, text white, transform rotateZ(-8deg) translateY(-80%), hard shadow rgb(0,0,0) -7px 7px." +- "Create a card: white background, 1px solid #dad4c8 border, 24px radius. Shadow: rgba(0,0,0,0.1) 0px 1px 1px, rgba(0,0,0,0.04) 0px -1px 1px inset. Title at 32px Roobert weight 600, letter-spacing -0.64px." +- "Design an uppercase label: 12px Roobert weight 600, text-transform uppercase, letter-spacing 1.08px, OpenType 'ss03 ss10 ss11 ss12'." + +### Iteration Guide +1. Start with warm cream (#faf9f7) — never cool white +2. Swatch colors are for full sections, not small accents — go bold with matcha, slushie, ube +3. Oat borders (#dad4c8) everywhere — dashed variants for decoration +4. OpenType stylistic sets are mandatory — they make Roobert look like Roobert +5. Hover animations are the signature — rotation + hard shadow, not subtle fades +6. Generous radius: 24px cards, 40px sections — nothing looks sharp or corporate +7. Three weights: 600 (headings), 500 (UI), 400 (body) — strict roles diff --git a/skills/creative/popular-web-designs/templates/clickhouse.md b/skills/creative/popular-web-designs/templates/clickhouse.md new file mode 100644 index 000000000000..67dc1ed22a60 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/clickhouse.md @@ -0,0 +1,294 @@ +# Design System: ClickHouse + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +ClickHouse's interface is a high-performance cockpit rendered in acid yellow-green on obsidian black — a design that screams "speed" before you read a single word. The entire experience lives in darkness: pure black backgrounds (`#000000`) with dark charcoal cards (`#414141` borders) creating a terminal-grade aesthetic where the only chromatic interruption is the signature neon yellow-green (`#faff69`) that slashes across CTAs, borders, and highlighted moments like a highlighter pen on a dark console. + +The typography is aggressively heavy — Inter at weight 900 (Black) for the hero headline at 96px creates text blocks that feel like they have physical mass. This "database for AI" site communicates raw power through visual weight: thick type, high-contrast neon accents, and performance stats displayed as oversized numbers. There's nothing subtle about ClickHouse's design, and that's entirely the point — it mirrors the product's promise of extreme speed and performance. + +What makes ClickHouse distinctive is the electrifying tension between the near-black canvas and the neon yellow-green accent. This color combination (`#faff69` on `#000000`) creates one of the highest-contrast pairings in any tech brand, making every CTA button, every highlighted card, and every accent border impossible to miss. Supporting this is a forest green (`#166534`) for secondary CTAs that adds depth to the action hierarchy without competing with the neon. + +**Key Characteristics:** +- Pure black canvas (#000000) with neon yellow-green (#faff69) accent — maximum contrast +- Extra-heavy display typography: Inter at weight 900 (Black) up to 96px +- Dark charcoal card system with #414141 borders at 80% opacity +- Forest green (#166534) secondary CTA buttons +- Performance stats as oversized display numbers +- Uppercase labels with wide letter-spacing (1.4px) for navigation structure +- Active/pressed state shifts text to pale yellow (#f4f692) +- All links hover to neon yellow-green — unified interactive signal +- Inset shadows on select elements creating "pressed into the surface" depth + +## 2. Color Palette & Roles + +### Primary +- **Neon Volt** (`#faff69`): The signature brand color — a vivid acid yellow-green that's the sole chromatic accent on the black canvas. Used for primary CTAs, accent borders, link hovers, and highlighted moments. +- **Forest Green** (`#166534`): Secondary CTA color — a deep, saturated green for "Get Started" and primary action buttons that need distinction from the neon. +- **Dark Forest** (`#14572f`): A darker green variant for borders and secondary accents. + +### Secondary & Accent +- **Pale Yellow** (`#f4f692`): Active/pressed state text color — a softer, more muted version of Neon Volt for state feedback. +- **Border Olive** (`#4f5100`): A dark olive-yellow for ghost button borders — the neon's muted sibling. +- **Olive Dark** (`#161600`): The darkest neon-tinted color for subtle brand text. + +### Surface & Background +- **Pure Black** (`#000000`): The primary page background — absolute black for maximum contrast. +- **Near Black** (`#141414`): Button backgrounds and slightly elevated dark surfaces. +- **Charcoal** (`#414141`): The primary border color at 80% opacity — the workhorse for card and container containment. +- **Deep Charcoal** (`#343434`): Darker border variant for subtle division lines. +- **Hover Gray** (`#3a3a3a`): Button hover state background — slightly lighter than Near Black. + +### Neutrals & Text +- **Pure White** (`#ffffff`): Primary text on dark surfaces. +- **Silver** (`#a0a0a0`): Secondary body text and muted content. +- **Mid Gray** (`#585858` at 28%): Subtle gray overlay for depth effects. +- **Border Gray** (`#e5e7eb`): Light border variant (used in rare light contexts). + +### Gradient System +- **None in the traditional sense.** ClickHouse uses flat color blocks and high-contrast borders. The "gradient" is the contrast itself — neon yellow-green against pure black creates a visual intensity that gradients would dilute. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Inter` (Next.js optimized variant `__Inter_d1b8ee`) +- **Secondary Display**: `Basier` (`__basier_a58b65`), with fallbacks: `Arial, Helvetica` +- **Code**: `Inconsolata` (`__Inconsolata_a25f62`) + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Mega | Inter | 96px (6rem) | 900 | 1.00 (tight) | normal | Maximum impact, extra-heavy | +| Display / Hero | Inter | 72px (4.5rem) | 700 | 1.00 (tight) | normal | Section hero titles | +| Feature Heading | Basier | 36px (2.25rem) | 600 | 1.30 (tight) | normal | Feature section anchors | +| Sub-heading | Inter / Basier | 24px (1.5rem) | 600–700 | 1.17–1.38 | normal | Card headings | +| Feature Title | Inter / Basier | 20px (1.25rem) | 600–700 | 1.40 | normal | Small feature titles | +| Body Large | Inter | 18px (1.13rem) | 400–700 | 1.56 | normal | Intro paragraphs, button text | +| Body / Button | Inter | 16px (1rem) | 400–700 | 1.50 | normal | Standard body, nav, buttons | +| Caption | Inter | 14px (0.88rem) | 400–700 | 1.43 | normal | Metadata, descriptions, links | +| Uppercase Label | Inter | 14px (0.88rem) | 600 | 1.43 | 1.4px | Section overlines, wide-tracked | +| Code | Inconsolata | 16px (1rem) | 600 | 1.50 | normal | Code blocks, commands | +| Small | Inter | 12px (0.75rem) | 500 | 1.33 | normal | Smallest text | +| Micro | Inter | 11.2px (0.7rem) | 500 | 1.79 (relaxed) | normal | Tags, tiny labels | + +### Principles +- **Weight 900 is the weapon**: The display headline uses Inter Black (900) — a weight most sites never touch. Combined with 96px size, this creates text with a physical, almost architectural presence. +- **Full weight spectrum**: The system uses 400, 500, 600, 700, and 900 — covering the full gamut. Weight IS hierarchy. +- **Uppercase with maximum tracking**: Section overlines use 1.4px letter-spacing — wider than most systems — creating bold structural labels that stand out against the dense dark background. +- **Dual sans-serif**: Inter handles display and body; Basier handles feature section headings at 600 weight. This creates a subtle personality shift between "data/performance" (Inter) and "product/feature" (Basier) contexts. + +## 4. Component Stylings + +### Buttons + +**Neon Primary** +- Background: Neon Volt (`#faff69`) +- Text: Near Black (`#151515`) +- Padding: 0px 16px +- Radius: sharp (4px) +- Border: `1px solid #faff69` +- Hover: background shifts to dark (`rgb(29, 29, 29)`), text stays +- Active: text shifts to Pale Yellow (`#f4f692`) +- The eye-catching CTA — neon on black + +**Dark Solid** +- Background: Near Black (`#141414`) +- Text: Pure White (`#ffffff`) +- Padding: 12px 16px +- Radius: 4px or 8px +- Border: `1px solid #141414` +- Hover: bg shifts to Hover Gray (`#3a3a3a`), text to 80% opacity +- Active: text to Pale Yellow +- The standard action button + +**Forest Green** +- Background: Forest Green (`#166534`) +- Text: Pure White (`#ffffff`) +- Padding: 12px 16px +- Border: `1px solid #141414` +- Hover: same dark shift +- Active: Pale Yellow text +- The "Get Started" / primary conversion button + +**Ghost / Outlined** +- Background: transparent +- Text: Pure White (`#ffffff`) +- Padding: 0px 32px +- Radius: 4px +- Border: `1px solid #4f5100` (olive-tinted) +- Hover: dark bg shift +- Active: Pale Yellow text +- Secondary actions with neon-tinted border + +**Pill Toggle** +- Background: transparent +- Radius: pill (9999px) +- Used for toggle/switch elements + +### Cards & Containers +- Background: transparent or Near Black +- Border: `1px solid rgba(65, 65, 65, 0.8)` — the signature charcoal containment +- Radius: 4px (small elements) or 8px (cards, containers) +- Shadow Level 1: subtle (`rgba(0,0,0,0.1) 0px 1px 3px, rgba(0,0,0,0.1) 0px 1px 2px -1px`) +- Shadow Level 2: medium (`rgba(0,0,0,0.1) 0px 10px 15px -3px, rgba(0,0,0,0.1) 0px 4px 6px -4px`) +- Shadow Level 3: inset (`rgba(0,0,0,0.06) 0px 4px 4px, rgba(0,0,0,0.14) 0px 4px 25px inset`) — the "pressed" effect +- Neon-highlighted cards: selected/active cards get neon yellow-green border or accent + +### Navigation +- Dark nav on black background +- Logo: ClickHouse wordmark + icon in yellow/neon +- Links: white text, hover to Neon Volt (#faff69) +- CTA: Neon Volt button or Forest Green button +- Uppercase labels for categories + +### Distinctive Components + +**Performance Stats** +- Oversized numbers (72px+, weight 700–900) +- Brief descriptions beneath +- High-contrast neon accents on key metrics +- The primary visual proof of performance claims + +**Neon-Highlighted Card** +- Standard dark card with neon yellow-green border highlight +- Creates "selected" or "featured" treatment +- The accent border makes the card pop against the dark canvas + +**Code Blocks** +- Dark surface with Inconsolata at weight 600 +- Neon and white syntax highlighting +- Terminal-like aesthetic + +**Trust Bar** +- Company logos on dark background +- Monochrome/white logo treatment +- Horizontal layout + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 6px, 7px, 8px, 10px, 12px, 16px, 20px, 24px, 25px, 32px, 40px, 44px, 48px, 64px +- Button padding: 12px 16px (standard), 0px 16px (compact), 0px 32px (wide ghost) +- Section vertical spacing: generous (48–64px) + +### Grid & Container +- Max container width: up to 2200px (extra-wide) with responsive scaling +- Hero: full-width dark with massive typography +- Feature sections: multi-column card grids with dark borders +- Stats: horizontal metric bar +- Full-dark page — no light sections + +### Whitespace Philosophy +- **Dark void as canvas**: The pure black background provides infinite depth — elements float in darkness. +- **Dense information**: Feature cards and stats are packed with data, reflecting the database product's performance focus. +- **Neon highlights as wayfinding**: Yellow-green accents guide the eye through the dark interface like runway lights. + +### Border Radius Scale +- Sharp (4px): Buttons, badges, small elements, code blocks +- Comfortable (8px): Cards, containers, dividers +- Pill (9999px): Toggle buttons, status indicators + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Black background, text blocks | +| Bordered (Level 1) | `1px solid rgba(65,65,65,0.8)` | Standard cards, containers | +| Subtle (Level 2) | `0px 1px 3px rgba(0,0,0,0.1)` | Subtle card lift | +| Elevated (Level 3) | `0px 10px 15px -3px rgba(0,0,0,0.1)` | Feature cards, hover states | +| Pressed/Inset (Level 4) | `0px 4px 25px rgba(0,0,0,0.14) inset` | Active/pressed elements — "sunk into the surface" | +| Neon Highlight (Level 5) | Neon Volt border (`#faff69`) | Featured/selected cards, maximum emphasis | + +**Shadow Philosophy**: ClickHouse uses shadows on a black canvas, where they're barely visible — they exist more for subtle dimensionality than obvious elevation. The most distinctive depth mechanism is the **inset shadow** (Level 4), which creates a "pressed into the surface" effect unique to ClickHouse. The neon border highlight (Level 5) is the primary attention-getting depth mechanism. + +## 7. Do's and Don'ts + +### Do +- Use Neon Volt (#faff69) as the sole chromatic accent — it must pop against pure black +- Use Inter at weight 900 for hero display text — the extreme weight IS the personality +- Keep everything on pure black (#000000) — never use dark gray as the page background +- Use charcoal borders (rgba(65,65,65,0.8)) for all card containment +- Apply Forest Green (#166534) for primary CTA buttons — distinct from neon for action hierarchy +- Show performance stats as oversized display numbers — it's the core visual argument +- Use uppercase with wide letter-spacing (1.4px) for section labels +- Apply Pale Yellow (#f4f692) for active/pressed text states +- Link hovers should ALWAYS shift to Neon Volt — unified interactive feedback + +### Don't +- Don't introduce additional colors — the palette is strictly black, neon, green, and gray +- Don't use the neon as a background fill — it's an accent and border color only (except on CTA buttons) +- Don't reduce display weight below 700 — heavy weight is core to the personality +- Don't use light/white backgrounds anywhere — the entire experience is dark +- Don't round corners beyond 8px — the sharp geometry reflects database precision +- Don't use soft/diffused shadows on black — they're invisible. Use border-based depth instead +- Don't skip the inset shadow on active states — the "pressed" effect is distinctive +- Don't use warm neutrals — all grays are perfectly neutral + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, stacked cards | +| Small Tablet | 640–768px | Minor adjustments | +| Tablet | 768–1024px | 2-column grids | +| Desktop | 1024–1280px | Standard layout | +| Large Desktop | 1280–1536px | Expanded content | +| Ultra-wide | 1536–2200px | Maximum container width | + +### Touch Targets +- Buttons with 12px 16px padding minimum +- Card surfaces as touch targets +- Adequate nav link spacing + +### Collapsing Strategy +- **Hero text**: 96px → 72px → 48px → 36px +- **Feature grids**: Multi-column → 2 → 1 column +- **Stats**: Horizontal → stacked +- **Navigation**: Full → hamburger + +### Image Behavior +- Product screenshots maintain aspect ratio +- Code blocks use horizontal scroll on narrow screens +- All images on dark backgrounds + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Brand Accent: "Neon Volt (#faff69)" +- Page Background: "Pure Black (#000000)" +- CTA Green: "Forest Green (#166534)" +- Card Border: "Charcoal (rgba(65,65,65,0.8))" +- Primary Text: "Pure White (#ffffff)" +- Secondary Text: "Silver (#a0a0a0)" +- Active State: "Pale Yellow (#f4f692)" +- Button Surface: "Near Black (#141414)" + +### Example Component Prompts +- "Create a hero section on Pure Black (#000000) with a massive headline at 96px Inter weight 900, line-height 1.0. Pure White text. Add a Neon Volt (#faff69) CTA button (dark text, 4px radius, 0px 16px padding) and a ghost button (transparent, 1px solid #4f5100 border)." +- "Design a feature card on black with 1px solid rgba(65,65,65,0.8) border and 8px radius. Title at 24px Inter weight 700, body at 16px in Silver (#a0a0a0). Add a neon-highlighted variant with 1px solid #faff69 border." +- "Build a performance stats bar: large numbers at 72px Inter weight 700 in Pure White. Brief descriptions at 14px in Silver. On black background." +- "Create a Forest Green (#166534) CTA button: white text, 12px 16px padding, 4px radius, 1px solid #141414 border. Hover: bg shifts to #3a3a3a, text to 80% opacity." +- "Design an uppercase section label: 14px Inter weight 600, letter-spacing 1.4px, uppercase. Silver (#a0a0a0) text on black background." + +### Iteration Guide +1. Keep everything on pure black — no dark gray alternatives +2. Neon Volt (#faff69) is for accents and CTAs only — never large backgrounds +3. Weight 900 for hero, 700 for headings, 600 for labels, 400-500 for body +4. Active states use Pale Yellow (#f4f692) — not just opacity changes +5. All links hover to Neon Volt — consistent interactive feedback +6. Charcoal borders (rgba(65,65,65,0.8)) are the primary depth mechanism diff --git a/skills/creative/popular-web-designs/templates/cohere.md b/skills/creative/popular-web-designs/templates/cohere.md new file mode 100644 index 000000000000..d43a012e2568 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/cohere.md @@ -0,0 +1,279 @@ +# Design System: Cohere + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Cohere's interface is a polished enterprise command deck — confident, clean, and designed to make AI feel like serious infrastructure rather than a consumer toy. The experience lives on a bright white canvas where content is organized into generously rounded cards (22px radius) that create an organic, cloud-like containment language. This is a site that speaks to CTOs and enterprise architects: professional without being cold, sophisticated without being intimidating. + +The design language bridges two worlds with a dual-typeface system: CohereText, a custom display serif with tight tracking, gives headlines the gravitas of a technology manifesto, while Unica77 Cohere Web handles all body and UI text with geometric Swiss precision. This serif/sans pairing creates a "confident authority meets engineering clarity" personality that perfectly reflects an enterprise AI platform. + +Color is used with extreme restraint — the interface is almost entirely black-and-white with cool gray borders (`#d9d9dd`, `#e5e7eb`). Purple-violet appears only in photographic hero bands, gradient sections, and the interactive blue (`#1863dc`) that signals hover and focus states. This chromatic restraint means that when color DOES appear — in product screenshots, enterprise photography, and the deep purple section — it carries maximum visual weight. + +**Key Characteristics:** +- Bright white canvas with cool gray containment borders +- 22px signature border-radius — the distinctive "Cohere card" roundness +- Dual custom typeface: CohereText (display serif) + Unica77 (body sans) +- Enterprise-grade chromatic restraint: black, white, cool grays, minimal purple-blue accent +- Deep purple/violet hero sections providing dramatic contrast +- Ghost/transparent buttons that shift to blue on hover +- Enterprise photography showing diverse real-world applications +- CohereMono for code and technical labels with uppercase transforms + +## 2. Color Palette & Roles + +### Primary +- **Cohere Black** (`#000000`): Primary headline text and maximum-emphasis elements. +- **Near Black** (`#212121`): Standard body link color — slightly softer than pure black. +- **Deep Dark** (`#17171c`): A blue-tinted near-black for navigation and dark-section text. + +### Secondary & Accent +- **Interaction Blue** (`#1863dc`): The primary interactive accent — appears on button hover, focus states, and active links. The sole chromatic action color. +- **Ring Blue** (`#4c6ee6` at 50%): Tailwind ring color for keyboard focus indicators. +- **Focus Purple** (`#9b60aa`): Input focus border color — a muted violet. + +### Surface & Background +- **Pure White** (`#ffffff`): The primary page background and card surface. +- **Snow** (`#fafafa`): Subtle elevated surfaces and light-section backgrounds. +- **Lightest Gray** (`#f2f2f2`): Card borders and the softest containment lines. + +### Neutrals & Text +- **Muted Slate** (`#93939f`): De-emphasized footer links and tertiary text — a cool-toned gray with a slight blue-violet tint. +- **Border Cool** (`#d9d9dd`): Standard section and list-item borders — a cool, slightly purple-tinted gray. +- **Border Light** (`#e5e7eb`): Lighter border variant — Tailwind's standard gray-200. + +### Gradient System +- **Purple-Violet Hero Band**: Deep purple gradient sections that create dramatic contrast against the white canvas. These appear as full-width bands housing product screenshots and key messaging. +- **Dark Footer Gradient**: The page transitions through deep purple/charcoal to the black footer, creating a "dusk" effect. + +## 3. Typography Rules + +### Font Family +- **Display**: `CohereText`, with fallbacks: `Space Grotesk, Inter, ui-sans-serif, system-ui` +- **Body / UI**: `Unica77 Cohere Web`, with fallbacks: `Inter, Arial, ui-sans-serif, system-ui` +- **Code**: `CohereMono`, with fallbacks: `Arial, ui-sans-serif, system-ui` +- **Icons**: `CohereIconDefault` (custom icon font) + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | CohereText | 72px (4.5rem) | 400 | 1.00 (tight) | -1.44px | Maximum impact, serif authority | +| Display Secondary | CohereText | 60px (3.75rem) | 400 | 1.00 (tight) | -1.2px | Large section headings | +| Section Heading | Unica77 | 48px (3rem) | 400 | 1.20 (tight) | -0.48px | Feature section titles | +| Sub-heading | Unica77 | 32px (2rem) | 400 | 1.20 (tight) | -0.32px | Card headings, feature names | +| Feature Title | Unica77 | 24px (1.5rem) | 400 | 1.30 | normal | Smaller section titles | +| Body Large | Unica77 | 18px (1.13rem) | 400 | 1.40 | normal | Intro paragraphs | +| Body / Button | Unica77 | 16px (1rem) | 400 | 1.50 | normal | Standard body, button text | +| Button Medium | Unica77 | 14px (0.88rem) | 500 | 1.71 (relaxed) | normal | Smaller buttons, emphasized labels | +| Caption | Unica77 | 14px (0.88rem) | 400 | 1.40 | normal | Metadata, descriptions | +| Uppercase Label | Unica77 / CohereMono | 14px (0.88rem) | 400 | 1.40 | 0.28px | Uppercase section labels | +| Small | Unica77 | 12px (0.75rem) | 400 | 1.40 | normal | Smallest text, footer links | +| Code Micro | CohereMono | 8px (0.5rem) | 400 | 1.40 | 0.16px | Tiny uppercase code labels | + +### Principles +- **Serif for declaration, sans for utility**: CohereText carries the brand voice at display scale — its serif terminals give headlines the authority of published research. Unica77 handles everything functional with Swiss-geometric neutrality. +- **Negative tracking at scale**: CohereText uses -1.2px to -1.44px letter-spacing at 60–72px, creating dense, impactful text blocks. +- **Single body weight**: Nearly all Unica77 usage is weight 400. Weight 500 appears only for small button emphasis. The system relies on size and spacing, not weight contrast. +- **Uppercase code labels**: CohereMono uses uppercase with positive letter-spacing (0.16–0.28px) for technical tags and section markers. + +## 4. Component Stylings + +### Buttons + +**Ghost / Transparent** +- Background: transparent (`rgba(255, 255, 255, 0)`) +- Text: Cohere Black (`#000000`) +- No border visible +- Hover: text shifts to Interaction Blue (`#1863dc`), opacity 0.8 +- Focus: solid 2px outline in Interaction Blue +- The primary button style — invisible until interacted with + +**Dark Solid** +- Background: dark/black +- Text: Pure White +- For CTA on light surfaces +- Pill-shaped or standard radius + +**Outlined** +- Border-based containment +- Used in secondary actions + +### Cards & Containers +- Background: Pure White (`#ffffff`) +- Border: thin solid Lightest Gray (`1px solid #f2f2f2`) for subtle cards; Cool Border (`#d9d9dd`) for emphasized +- Radius: **22px** — the signature Cohere radius for primary cards, images, and dialog containers. Also 4px, 8px, 16px, 20px for smaller elements +- Shadow: minimal — Cohere relies on background color and borders rather than shadows +- Special: `0px 0px 22px 22px` radius (bottom-only rounding) for section containers +- Dialog: 8px radius for modal/dialog boxes + +### Inputs & Forms +- Text: white on dark input, black on light +- Focus border: Focus Purple (`#9b60aa`) with `1px solid` +- Focus shadow: red ring (`rgb(179, 0, 0) 0px 0px 0px 2px`) — likely for error state indication +- Focus outline: Interaction Blue solid 2px + +### Navigation +- Clean horizontal nav on white or dark background +- Logo: Cohere wordmark (custom SVG) +- Links: Dark text at 16px Unica77 +- CTA: Dark solid button +- Mobile: hamburger collapse + +### Image Treatment +- Enterprise photography with diverse subjects and environments +- Purple-tinted hero photography for dramatic sections +- Product UI screenshots on dark surfaces +- Images with 22px radius matching card system +- Full-bleed purple gradient sections + +### Distinctive Components + +**22px Card System** +- The 22px border-radius is Cohere's visual signature +- All primary cards, images, and containers use this radius +- Creates a cloud-like, organic softness that's distinctive from the typical 8–12px + +**Enterprise Trust Bar** +- Company logos displayed in a horizontal strip +- Demonstrates enterprise adoption +- Clean, monochrome logo treatment + +**Purple Hero Bands** +- Full-width deep purple sections housing product showcases +- Create dramatic visual breaks in the white page flow +- Product screenshots float within the purple environment + +**Uppercase Code Tags** +- CohereMono in uppercase with letter-spacing +- Used as section markers and categorization labels +- Creates a technical, structured information hierarchy + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 6px, 8px, 10px, 12px, 16px, 20px, 22px, 24px, 28px, 32px, 36px, 40px, 56px, 60px +- Button padding varies by variant +- Card internal padding: approximately 24–32px +- Section vertical spacing: generous (56–60px between sections) + +### Grid & Container +- Max container width: up to 2560px (very wide) with responsive scaling +- Hero: centered with dramatic typography +- Feature sections: multi-column card grids +- Enterprise sections: full-width purple bands +- 26 breakpoints detected — extremely granular responsive system + +### Whitespace Philosophy +- **Enterprise clarity**: Each section presents one clear proposition with breathing room between. +- **Photography as hero**: Large photographic sections provide visual interest without requiring decorative design elements. +- **Card grouping**: Related content is grouped into 22px-rounded cards, creating natural information clusters. + +### Border Radius Scale +- Sharp (4px): Navigation elements, small tags, pagination +- Comfortable (8px): Dialog boxes, secondary containers, small cards +- Generous (16px): Featured containers, medium cards +- Large (20px): Large feature cards +- Signature (22px): Primary cards, hero images, main containers — THE Cohere radius +- Pill (9999px): Buttons, tags, status indicators + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background, text blocks | +| Bordered (Level 1) | `1px solid #f2f2f2` or `#d9d9dd` | Standard cards, list separators | +| Purple Band (Level 2) | Full-width dark purple background | Hero sections, feature showcases | + +**Shadow Philosophy**: Cohere is nearly shadow-free. Depth is communicated through **background color contrast** (white cards on purple bands, white surface on snow), **border containment** (cool gray borders), and the dramatic **light-to-dark section alternation**. When elements need elevation, they achieve it through being white-on-dark rather than through shadow casting. + +## 7. Do's and Don'ts + +### Do +- Use 22px border-radius on all primary cards and containers — it's the visual signature +- Use CohereText for display headings (72px, 60px) with negative letter-spacing +- Use Unica77 for all body and UI text at weight 400 +- Keep the palette black-and-white with cool gray borders +- Use Interaction Blue (#1863dc) only for hover/focus interactive states +- Use deep purple sections for dramatic visual breaks and product showcases +- Apply uppercase + letter-spacing on CohereMono for section labels +- Maintain enterprise-appropriate photography with diverse subjects + +### Don't +- Don't use border-radius other than 22px on primary cards — the signature radius matters +- Don't introduce warm colors — the palette is strictly cool-toned +- Don't use heavy shadows — depth comes from color contrast and borders +- Don't use bold (700+) weight on body text — 400–500 is the range +- Don't skip the serif/sans hierarchy — CohereText for headlines, Unica77 for body +- Don't use purple as a surface color for cards — purple is reserved for full-width sections +- Don't reduce section spacing below 40px — enterprise layouts need breathing room +- Don't use decoration on buttons by default — ghost/transparent is the base state + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Small Mobile | <425px | Compact layout, minimal spacing | +| Mobile | 425–640px | Single column, stacked cards | +| Large Mobile | 640–768px | Minor spacing adjustments | +| Tablet | 768–1024px | 2-column grids begin | +| Desktop | 1024–1440px | Full multi-column layout | +| Large Desktop | 1440–2560px | Maximum container width | + +*26 breakpoints detected — one of the most granularly responsive sites in the dataset.* + +### Touch Targets +- Buttons adequately sized for touch interaction +- Navigation links with comfortable spacing +- Card surfaces as touch targets + +### Collapsing Strategy +- **Navigation**: Full nav collapses to hamburger +- **Feature grids**: Multi-column → 2-column → single column +- **Hero text**: 72px → 48px → 32px progressive scaling +- **Purple sections**: Maintain full-width, content stacks +- **Card grids**: 3 → 2 → 1 column + +### Image Behavior +- Photography scales proportionally within 22px-radius containers +- Product screenshots maintain aspect ratio +- Purple sections scale background proportionally + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Text: "Cohere Black (#000000)" +- Page Background: "Pure White (#ffffff)" +- Secondary Text: "Near Black (#212121)" +- Hover Accent: "Interaction Blue (#1863dc)" +- Muted Text: "Muted Slate (#93939f)" +- Card Borders: "Lightest Gray (#f2f2f2)" +- Section Borders: "Border Cool (#d9d9dd)" + +### Example Component Prompts +- "Create a hero section on Pure White (#ffffff) with CohereText at 72px weight 400, line-height 1.0, letter-spacing -1.44px. Cohere Black text. Subtitle in Unica77 at 18px weight 400, line-height 1.4." +- "Design a feature card with 22px border-radius, 1px solid Lightest Gray (#f2f2f2) border on white. Title in Unica77 at 32px, letter-spacing -0.32px. Body in Unica77 at 16px, Muted Slate (#93939f)." +- "Build a ghost button: transparent background, Cohere Black text in Unica77 at 16px. On hover, text shifts to Interaction Blue (#1863dc) with 0.8 opacity. Focus: 2px solid Interaction Blue outline." +- "Create a deep purple full-width section with white text. CohereText at 60px for the heading. Product screenshot floats within using 22px border-radius." +- "Design a section label using CohereMono at 14px, uppercase, letter-spacing 0.28px. Muted Slate (#93939f) text." + +### Iteration Guide +1. Focus on ONE component at a time +2. Always use 22px radius for primary cards — "the Cohere card roundness" +3. Specify the typeface — CohereText for headlines, Unica77 for body, CohereMono for labels +4. Interactive elements use Interaction Blue (#1863dc) on hover only +5. Keep surfaces white with cool gray borders — no warm tones +6. Purple is for full-width sections, never card backgrounds diff --git a/skills/creative/popular-web-designs/templates/coinbase.md b/skills/creative/popular-web-designs/templates/coinbase.md new file mode 100644 index 000000000000..45d3803b015e --- /dev/null +++ b/skills/creative/popular-web-designs/templates/coinbase.md @@ -0,0 +1,142 @@ +# Design System: Coinbase + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Coinbase's website is a clean, trustworthy crypto platform that communicates financial reliability through a blue-and-white binary palette. The design uses Coinbase Blue (`#0052ff`) — a deep, saturated blue — as the singular brand accent against white and near-black surfaces. The proprietary font family includes CoinbaseDisplay for hero headlines, CoinbaseSans for UI text, CoinbaseText for body reading, and CoinbaseIcons for iconography — a comprehensive four-font system. + +The button system uses a distinctive 56px radius for pill-shaped CTAs with hover transitions to a lighter blue (`#578bfa`). The design alternates between white content sections and dark (`#0a0b0d`, `#282b31`) feature sections, creating a professional, financial-grade interface. + +**Key Characteristics:** +- Coinbase Blue (`#0052ff`) as singular brand accent +- Four-font proprietary family: Display, Sans, Text, Icons +- 56px radius pill buttons with blue hover transition +- Near-black (`#0a0b0d`) dark sections + white light sections +- 1.00 line-height on display headings — ultra-tight +- Cool gray secondary surface (`#eef0f3`) with blue tint +- `text-transform: lowercase` on some button labels — unusual + +## 2. Color Palette & Roles + +### Primary +- **Coinbase Blue** (`#0052ff`): Primary brand, links, CTA borders +- **Pure White** (`#ffffff`): Primary light surface +- **Near Black** (`#0a0b0d`): Text, dark section backgrounds +- **Cool Gray Surface** (`#eef0f3`): Secondary button background + +### Interactive +- **Hover Blue** (`#578bfa`): Button hover background +- **Link Blue** (`#0667d0`): Secondary link color +- **Muted Blue** (`#5b616e`): Border color at 20% opacity + +### Surface +- **Dark Card** (`#282b31`): Dark button/card backgrounds +- **Light Surface** (`rgba(247,247,247,0.88)`): Subtle surface + +## 3. Typography Rules + +### Font Families +- **Display**: `CoinbaseDisplay` — hero headlines +- **UI / Sans**: `CoinbaseSans` — buttons, headings, nav +- **Body**: `CoinbaseText` — reading text +- **Icons**: `CoinbaseIcons` — icon font + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Notes | +|------|------|------|--------|-------------|-------| +| Display Hero | CoinbaseDisplay | 80px | 400 | 1.00 (tight) | Maximum impact | +| Display Secondary | CoinbaseDisplay | 64px | 400 | 1.00 | Sub-hero | +| Display Third | CoinbaseDisplay | 52px | 400 | 1.00 | Third tier | +| Section Heading | CoinbaseSans | 36px | 400 | 1.11 (tight) | Feature sections | +| Card Title | CoinbaseSans | 32px | 400 | 1.13 | Card headings | +| Feature Title | CoinbaseSans | 18px | 600 | 1.33 | Feature emphasis | +| Body Bold | CoinbaseSans | 16px | 700 | 1.50 | Strong body | +| Body Semibold | CoinbaseSans | 16px | 600 | 1.25 | Buttons, nav | +| Body | CoinbaseText | 18px | 400 | 1.56 | Standard reading | +| Body Small | CoinbaseText | 16px | 400 | 1.50 | Secondary reading | +| Button | CoinbaseSans | 16px | 600 | 1.20 | +0.16px tracking | +| Caption | CoinbaseSans | 14px | 600–700 | 1.50 | Metadata | +| Small | CoinbaseSans | 13px | 600 | 1.23 | Tags | + +## 4. Component Stylings + +### Buttons + +**Primary Pill (56px radius)** +- Background: `#eef0f3` or `#282b31` +- Radius: 56px +- Border: `1px solid` matching background +- Hover: `#578bfa` (light blue) +- Focus: `2px solid black` outline + +**Full Pill (100000px radius)** +- Used for maximum pill shape + +**Blue Bordered** +- Border: `1px solid #0052ff` +- Background: transparent + +### Cards & Containers +- Radius: 8px–40px range +- Borders: `1px solid rgba(91,97,110,0.2)` + +## 5. Layout Principles + +### Spacing System +- Base: 8px +- Scale: 1px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 15px, 16px, 20px, 24px, 25px, 32px, 48px + +### Border Radius Scale +- Small (4px–8px): Article links, small cards +- Standard (12px–16px): Cards, menus +- Large (24px–32px): Feature containers +- XL (40px): Large buttons/containers +- Pill (56px): Primary CTAs +- Full (100000px): Maximum pill + +## 6. Depth & Elevation + +Minimal shadow system — depth from color contrast between dark/light sections. + +## 7. Do's and Don'ts + +### Do +- Use Coinbase Blue (#0052ff) for primary interactive elements +- Apply 56px radius for all CTA buttons +- Use CoinbaseDisplay for hero headings only +- Alternate dark (#0a0b0d) and white sections + +### Don't +- Don't use the blue decoratively — it's functional only +- Don't use sharp corners on CTAs — 56px minimum + +## 8. Responsive Behavior + +Breakpoints: 400px, 576px, 640px, 768px, 896px, 1280px, 1440px, 1600px + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Brand: Coinbase Blue (`#0052ff`) +- Background: White (`#ffffff`) +- Dark surface: `#0a0b0d` +- Secondary surface: `#eef0f3` +- Hover: `#578bfa` +- Text: `#0a0b0d` + +### Example Component Prompts +- "Create hero: white background. CoinbaseDisplay 80px, line-height 1.00. Pill CTA (#eef0f3, 56px radius). Hover: #578bfa." +- "Build dark section: #0a0b0d background. CoinbaseDisplay 64px white text. Blue accent link (#0052ff)." diff --git a/skills/creative/popular-web-designs/templates/composio.md b/skills/creative/popular-web-designs/templates/composio.md new file mode 100644 index 000000000000..2a9e09db1c3a --- /dev/null +++ b/skills/creative/popular-web-designs/templates/composio.md @@ -0,0 +1,320 @@ +# Design System: Composio + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Composio's interface is a nocturnal command center — a dense, developer-focused darkness punctuated by electric cyan and deep cobalt signals. The entire experience is built on an almost-pure-black canvas (`#0f0f0f`) where content floats within barely-visible containment borders, creating the feeling of a high-tech control panel rather than a traditional marketing page. It's a site that whispers authority to developers who live in dark terminals. + +The visual language leans heavily into the aesthetic of code editors and terminal windows. JetBrains Mono appears alongside the geometric precision of abcDiatype, reinforcing the message that this is a tool built *by* developers *for* developers. Decorative elements are restrained but impactful — subtle cyan-blue gradient glows emanate from cards and sections like bioluminescent organisms in deep water, while hard-offset shadows (`4px 4px`) on select elements add a raw, brutalist edge that prevents the design from feeling sterile. + +What makes Composio distinctive is its tension between extreme minimalism and strategic bursts of luminous color. The site never shouts — headings use tight line-heights (0.87) that compress text into dense, authoritative blocks. Color is rationed like a rare resource: white text for primary content, semi-transparent white (`rgba(255,255,255,0.5-0.6)`) for secondary, and brand blue (`#0007cd`) or electric cyan (`#00ffff`) reserved exclusively for interactive moments and accent glows. + +**Key Characteristics:** +- Pitch-black canvas with near-invisible white-border containment (4-12% opacity) +- Dual-font identity: geometric sans-serif (abcDiatype) for content, monospace (JetBrains Mono) for technical credibility +- Ultra-tight heading line-heights (0.87-1.0) creating compressed, impactful text blocks +- Bioluminescent accent strategy — cyan and blue glows that feel like they're emitting light from within +- Hard-offset brutalist shadows (`4px 4px`) on select interactive elements +- Monochrome hierarchy with color used only at the highest-signal moments +- Developer-terminal aesthetic that bridges marketing and documentation + +## 2. Color Palette & Roles + +### Primary +- **Composio Cobalt** (`#0007cd`): The core brand color — a deep, saturated blue used sparingly for high-priority interactive elements and brand moments. It anchors the identity with quiet intensity. + +### Secondary & Accent +- **Electric Cyan** (`#00ffff`): The attention-grabbing accent — used at low opacity (`rgba(0,255,255,0.12)`) for glowing button backgrounds and card highlights. At full saturation, it serves as the energetic counterpoint to the dark canvas. +- **Signal Blue** (`#0089ff` / `rgb(0,137,255)`): Used for select button borders and interactive focus states, bridging the gap between Cobalt and Cyan. +- **Ocean Blue** (`#0096ff` / `rgb(0,150,255)`): Accent border color on CTA buttons, slightly warmer than Signal Blue. + +### Surface & Background +- **Void Black** (`#0f0f0f`): The primary page background — not pure black, but a hair warmer, reducing eye strain on dark displays. +- **Pure Black** (`#000000`): Used for card interiors and deep-nested containers, creating a subtle depth distinction from the page background. +- **Charcoal** (`#2c2c2c` / `rgb(44,44,44)`): Used for secondary button borders and divider lines on dark surfaces. + +### Neutrals & Text +- **Pure White** (`#ffffff`): Primary heading and high-emphasis text color on dark surfaces. +- **Muted Smoke** (`#444444`): De-emphasized body text, metadata, and tertiary content. +- **Ghost White** (`rgba(255,255,255,0.6)`): Secondary body text and link labels — visible but deliberately receded. +- **Whisper White** (`rgba(255,255,255,0.5)`): Tertiary button text and placeholder content. +- **Phantom White** (`rgba(255,255,255,0.2)`): Subtle button backgrounds and deeply receded UI chrome. + +### Semantic & Accent +- **Border Mist 12** (`rgba(255,255,255,0.12)`): Highest-opacity border treatment — used for prominent card edges and content separators. +- **Border Mist 10** (`rgba(255,255,255,0.10)`): Standard container borders on dark surfaces. +- **Border Mist 08** (`rgba(255,255,255,0.08)`): Subtle section dividers and secondary card edges. +- **Border Mist 06** (`rgba(255,255,255,0.06)`): Near-invisible containment borders for background groupings. +- **Border Mist 04** (`rgba(255,255,255,0.04)`): The faintest border — used for atmospheric separation only. +- **Light Border** (`#e0e0e0` / `rgb(224,224,224)`): Reserved for light-surface contexts (rare on this site). + +### Gradient System +- **Cyan Glow**: Radial gradients using `#00ffff` at very low opacity, creating bioluminescent halos behind cards and feature sections. +- **Blue-to-Black Fade**: Linear gradients from Composio Cobalt (`#0007cd`) fading into Void Black (`#0f0f0f`), used in hero backgrounds and section transitions. +- **White Fog**: Bottom-of-page gradient transitioning from dark to a diffused white/gray, creating an atmospheric "horizon line" effect near the footer. + +## 3. Typography Rules + +### Font Family +- **Primary**: `abcDiatype`, with fallbacks: `abcDiatype Fallback, ui-sans-serif, system-ui, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji` +- **Monospace**: `JetBrains Mono`, with fallbacks: `JetBrains Mono Fallback, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New` +- **System Monospace** (fallback): `Menlo`, `monospace` for smallest inline code + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | abcDiatype | 64px (4rem) | 400 | 0.87 (ultra-tight) | normal | Massive, compressed headings | +| Section Heading | abcDiatype | 48px (3rem) | 400 | 1.00 (tight) | normal | Major feature section titles | +| Sub-heading Large | abcDiatype | 40px (2.5rem) | 400 | 1.00 (tight) | normal | Secondary section markers | +| Sub-heading | abcDiatype | 28px (1.75rem) | 400 | 1.20 (tight) | normal | Card titles, feature names | +| Card Title | abcDiatype | 24px (1.5rem) | 500 | 1.20 (tight) | normal | Medium-emphasis card headings | +| Feature Label | abcDiatype | 20px (1.25rem) | 500 | 1.20 (tight) | normal | Smaller card titles, labels | +| Body Large | abcDiatype | 18px (1.125rem) | 400 | 1.20 (tight) | normal | Intro paragraphs | +| Body / Button | abcDiatype | 16px (1rem) | 400 | 1.50 | normal | Standard body text, nav links, buttons | +| Body Small | abcDiatype | 15px (0.94rem) | 400 | 1.63 (relaxed) | normal | Longer-form body text | +| Caption | abcDiatype | 14px (0.875rem) | 400 | 1.63 (relaxed) | normal | Descriptions, metadata | +| Label | abcDiatype | 13px (0.81rem) | 500 | 1.50 | normal | UI labels, badges | +| Tag / Overline | abcDiatype | 12px (0.75rem) | 500 | 1.00 (tight) | 0.3px | Uppercase overline labels | +| Micro | abcDiatype | 12px (0.75rem) | 400 | 1.00 (tight) | 0.3px | Smallest sans-serif text | +| Code Body | JetBrains Mono | 16px (1rem) | 400 | 1.50 | -0.32px | Inline code, terminal output | +| Code Small | JetBrains Mono | 14px (0.875rem) | 400 | 1.50 | -0.28px | Code snippets, technical labels | +| Code Caption | JetBrains Mono | 12px (0.75rem) | 400 | 1.50 | -0.28px | Small code references | +| Code Overline | JetBrains Mono | 14px (0.875rem) | 400 | 1.43 | 0.7px | Uppercase technical labels | +| Code Micro | JetBrains Mono | 11px (0.69rem) | 400 | 1.33 | 0.55px | Tiny uppercase code tags | +| Code Nano | JetBrains Mono | 9-10px | 400 | 1.33 | 0.45-0.5px | Smallest monospace text | + +### Principles +- **Compression creates authority**: Heading line-heights are drastically tight (0.87-1.0), making large text feel dense and commanding rather than airy and decorative. +- **Dual personality**: abcDiatype carries the marketing voice — geometric, precise, friendly. JetBrains Mono carries the technical voice — credible, functional, familiar to developers. +- **Weight restraint**: Almost everything is weight 400 (regular). Weight 500 (medium) is reserved for small labels, badges, and select card titles. Weight 700 (bold) appears only in microscopic system-monospace contexts. +- **Negative letter-spacing on code**: JetBrains Mono uses negative letter-spacing (-0.28px to -0.98px) for dense, compact code blocks that feel like a real IDE. +- **Uppercase is earned**: The `uppercase` + `letter-spacing` treatment is reserved exclusively for tiny overline labels and technical tags — never for headings. + +## 4. Component Stylings + +### Buttons + +**Primary CTA (White Fill)** +- Background: Pure White (`#ffffff`) +- Text: Near Black (`oklch(0.145 0 0)`) +- Padding: comfortable (8px 24px) +- Border: none +- Radius: subtly rounded (likely 4px based on token scale) +- Hover: likely subtle opacity reduction or slight gray shift + +**Cyan Accent CTA** +- Background: Electric Cyan at 12% opacity (`rgba(0,255,255,0.12)`) +- Text: Near Black (`oklch(0.145 0 0)`) +- Padding: comfortable (8px 24px) +- Border: thin solid Ocean Blue (`1px solid rgb(0,150,255)`) +- Radius: subtly rounded (4px) +- Creates a "glowing from within" effect on dark backgrounds + +**Ghost / Outline (Signal Blue)** +- Background: transparent +- Text: Near Black (`oklch(0.145 0 0)`) +- Padding: balanced (10px) +- Border: thin solid Signal Blue (`1px solid rgb(0,137,255)`) +- Hover: likely fill or border color shift + +**Ghost / Outline (Charcoal)** +- Background: transparent +- Text: Near Black (`oklch(0.145 0 0)`) +- Padding: balanced (10px) +- Border: thin solid Charcoal (`1px solid rgb(44,44,44)`) +- For secondary/tertiary actions on dark surfaces + +**Phantom Button** +- Background: Phantom White (`rgba(255,255,255,0.2)`) +- Text: Whisper White (`rgba(255,255,255,0.5)`) +- No visible border +- Used for deeply de-emphasized actions + +### Cards & Containers +- Background: Pure Black (`#000000`) or transparent +- Border: white at very low opacity, ranging from Border Mist 04 (`rgba(255,255,255,0.04)`) to Border Mist 12 (`rgba(255,255,255,0.12)`) depending on prominence +- Radius: barely rounded corners (2px for inline elements, 4px for content cards) +- Shadow: select cards use the hard-offset brutalist shadow (`rgba(0,0,0,0.15) 4px 4px 0px 0px`) — a distinctive design choice that adds raw depth +- Elevation shadow: deeper containers use soft diffuse shadow (`rgba(0,0,0,0.5) 0px 8px 32px`) +- Hover behavior: likely subtle border opacity increase or faint glow effect + +### Inputs & Forms +- No explicit input token data extracted — inputs likely follow the dark-surface pattern with: + - Background: transparent or Pure Black + - Border: Border Mist 10 (`rgba(255,255,255,0.10)`) + - Focus: border shifts to Signal Blue (`#0089ff`) or Electric Cyan + - Text: Pure White with Ghost White placeholder + +### Navigation +- Sticky top nav bar on dark/black background +- Logo (white SVG): Composio wordmark on the left +- Nav links: Pure White (`#ffffff`) at standard body size (16px, abcDiatype) +- CTA button in the nav: White Fill Primary style +- Mobile: collapses to hamburger menu, single-column layout +- Subtle bottom border on nav (Border Mist 06-08) + +### Image Treatment +- Dark-themed product screenshots and UI mockups dominate +- Images sit within bordered containers matching the card system +- Blue/cyan gradient glows behind or beneath feature images +- No visible border-radius on images beyond container rounding (4px) +- Full-bleed within their card containers + +### Distinctive Components + +**Stats/Metrics Display** +- Large monospace numbers (JetBrains Mono) — "10k+" style +- Tight layout with subtle label text beneath + +**Code Blocks / Terminal Previews** +- Dark containers with JetBrains Mono +- Syntax-highlighted content +- Subtle bordered containers (Border Mist 10) + +**Integration/Partner Logos Grid** +- Grid layout of tool logos on dark surface +- Contained within bordered card +- Demonstrates ecosystem breadth + +**"COMPOSIO" Brand Display** +- Oversized brand typography — likely the largest text on the page +- Used as a section divider/brand statement +- Stark white on black + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 6px, 8px, 10px, 12px, 14px, 16px, 18px, 20px, 24px, 30px, 32px, 40px +- Component padding: typically 10px (buttons) to 24px (CTA buttons horizontal) +- Section padding: generous vertical spacing (estimated 80-120px between major sections) +- Card internal padding: approximately 24-32px + +### Grid & Container +- Max container width: approximately 1200px, centered +- Content sections use single-column or 2-3 column grids for feature cards +- Hero: centered single-column with maximum impact +- Feature sections: asymmetric layouts mixing text blocks with product screenshots + +### Whitespace Philosophy +- **Breathing room between sections**: Large vertical gaps create distinct "chapters" in the page scroll. +- **Dense within components**: Cards and text blocks are internally compact (tight line-heights, minimal internal padding), creating focused information nodes. +- **Contrast-driven separation**: Rather than relying solely on whitespace, Composio uses border opacity differences and subtle background shifts to delineate content zones. + +### Border Radius Scale +- Nearly squared (2px): Inline code spans, small tags, pre blocks — the sharpest treatment, conveying technical precision +- Subtly rounded (4px): Content cards, images, standard containers — the workhorse radius +- Pill-shaped (37px): Select buttons and badges — creates a softer, more approachable feel for key CTAs +- Full round (9999px+): Circular elements, avatar-like containers, decorative dots + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background, inline text | +| Contained (Level 1) | Border Mist 04-08, no shadow | Background groupings, subtle sections | +| Card (Level 2) | Border Mist 10-12, no shadow | Standard content cards, code blocks | +| Brutalist (Level 3) | Hard offset shadow (`4px 4px`, 15% black) | Select interactive cards, distinctive feature highlights | +| Floating (Level 4) | Soft diffuse shadow (`0px 8px 32px`, 50% black) | Modals, overlays, deeply elevated content | + +**Shadow Philosophy**: Composio uses shadows sparingly and with deliberate contrast. The hard-offset brutalist shadow is the signature — it breaks the sleek darkness with a raw, almost retro-computing feel. The soft diffuse shadow is reserved for truly floating elements. Most depth is communicated through border opacity gradations rather than shadows. + +### Decorative Depth +- **Cyan Glow Halos**: Radial gradient halos using Electric Cyan at low opacity behind feature cards and images. Creates a "screen glow" effect as if the UI elements are emitting light. +- **Blue-Black Gradient Washes**: Linear gradients from Composio Cobalt to Void Black used as section backgrounds, adding subtle color temperature shifts. +- **White Fog Horizon**: A gradient from dark to diffused white/gray at the bottom of the page, creating an atmospheric "dawn" effect before the footer. + +## 7. Do's and Don'ts + +### Do +- Use Void Black (`#0f0f0f`) as the primary page background — never pure white for main surfaces +- Keep heading line-heights ultra-tight (0.87-1.0) for compressed, authoritative text blocks +- Use white-opacity borders (4-12%) for containment — they're more important than shadows here +- Reserve Electric Cyan (`#00ffff`) for high-signal moments only — CTAs, glows, interactive accents +- Pair abcDiatype with JetBrains Mono to reinforce the developer-tool identity +- Use the hard-offset shadow (`4px 4px`) intentionally on select elements for brutalist personality +- Keep button text dark (`oklch(0.145 0 0)`) even on the darkest backgrounds — buttons carry their own surface +- Layer opacity-based borders to create subtle depth without shadows +- Use uppercase + letter-spacing only for tiny overline labels (12px or smaller) + +### Don't +- Don't use bright backgrounds or light surfaces as primary containers +- Don't apply heavy shadows everywhere — depth comes from border opacity, not box-shadow +- Don't use Composio Cobalt (`#0007cd`) as a text color — it's too dark on dark and too saturated on light +- Don't increase heading line-heights beyond 1.2 — the compressed feel is core to the identity +- Don't use bold (700) weight for body or heading text — 400-500 is the ceiling +- Don't mix warm colors — the palette is strictly cool (blue, cyan, white, black) +- Don't use border-radius larger than 4px on content cards — the precision of near-square corners is intentional +- Don't place Electric Cyan at full opacity on large surfaces — it's an accent, used at 12% max for backgrounds +- Don't use decorative serif or handwritten fonts — the entire identity is geometric sans + monospace +- Don't skip the monospace font for technical content — JetBrains Mono is not decorative, it's a credibility signal + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <768px | Single column, hamburger nav, full-width cards, reduced section padding, hero text scales down to ~28-40px | +| Tablet | 768-1024px | 2-column grid for cards, condensed nav, slightly reduced hero text | +| Desktop | 1024-1440px | Full multi-column layout, expanded nav with all links visible, large hero typography (64px) | +| Large Desktop | >1440px | Max-width container centered, generous horizontal margins | + +### Touch Targets +- Minimum touch target: 44x44px for all interactive elements +- Buttons use comfortable padding (8px 24px minimum) ensuring adequate touch area +- Nav links spaced with sufficient gap for thumb navigation + +### Collapsing Strategy +- **Navigation**: Full horizontal nav on desktop collapses to hamburger on mobile +- **Feature grids**: 3-column → 2-column → single-column stacking +- **Hero text**: 64px → 40px → 28px progressive scaling +- **Section padding**: Reduces proportionally but maintains generous vertical rhythm +- **Cards**: Stack vertically on mobile with full-width treatment +- **Code blocks**: Horizontal scroll on smaller viewports rather than wrapping + +### Image Behavior +- Product screenshots scale proportionally within their containers +- Dark-themed images maintain contrast on the dark background at all sizes +- Gradient glow effects scale with container size +- No visible art direction changes between breakpoints — same crops, proportional scaling + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: "Pure White (#ffffff)" +- Page Background: "Void Black (#0f0f0f)" +- Brand Accent: "Composio Cobalt (#0007cd)" +- Glow Accent: "Electric Cyan (#00ffff)" +- Heading Text: "Pure White (#ffffff)" +- Body Text: "Ghost White (rgba(255,255,255,0.6))" +- Card Border: "Border Mist 10 (rgba(255,255,255,0.10))" +- Button Border: "Signal Blue (#0089ff)" + +### Example Component Prompts +- "Create a feature card with a near-black background (#000000), barely visible white border at 10% opacity, subtly rounded corners (4px), and a hard-offset shadow (4px right, 4px down, 15% black). Use Pure White for the title in abcDiatype at 24px weight 500, and Ghost White (60% opacity) for the description at 16px." +- "Design a primary CTA button with a solid white background, near-black text, comfortable padding (8px vertical, 24px horizontal), and subtly rounded corners. Place it next to a secondary button with transparent background, Signal Blue border, and matching padding." +- "Build a hero section on Void Black (#0f0f0f) with a massive heading at 64px, line-height 0.87, in abcDiatype. Center the text. Add a subtle blue-to-black gradient glow behind the content. Include a white CTA button and a cyan-accented secondary button below." +- "Create a code snippet display using JetBrains Mono at 14px with -0.28px letter-spacing on a black background. Add a Border Mist 10 border (rgba(255,255,255,0.10)) and 4px radius. Show syntax-highlighted content with white and cyan text." +- "Design a navigation bar on Void Black with the Composio wordmark in white on the left, 4-5 nav links in white abcDiatype at 16px, and a white-fill CTA button on the right. Add a Border Mist 06 bottom border." + +### Iteration Guide +When refining existing screens generated with this design system: +1. Focus on ONE component at a time +2. Reference specific color names and hex codes from this document — "use Ghost White (rgba(255,255,255,0.6))" not "make it lighter" +3. Use natural language descriptions — "make the border barely visible" = Border Mist 04-06 +4. Describe the desired "feel" alongside specific measurements — "compressed and authoritative heading at 48px with line-height 1.0" +5. For glow effects, specify "Electric Cyan at 12% opacity as a radial gradient behind the element" +6. Always specify which font — abcDiatype for marketing, JetBrains Mono for technical/code content diff --git a/skills/creative/popular-web-designs/templates/cursor.md b/skills/creative/popular-web-designs/templates/cursor.md new file mode 100644 index 000000000000..b51600775d6b --- /dev/null +++ b/skills/creative/popular-web-designs/templates/cursor.md @@ -0,0 +1,322 @@ +# Design System: Cursor + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Cursor's website is a study in warm minimalism meets code-editor elegance. The entire experience is built on a warm off-white canvas (`#f2f1ed`) with dark warm-brown text (`#26251e`) -- not pure black, not neutral gray, but a deeply warm near-black with a yellowish undertone that evokes old paper, ink, and craft. This warmth permeates every surface: backgrounds lean toward cream (`#e6e5e0`, `#ebeae5`), borders dissolve into transparent warm overlays using `oklab` color space, and even the error state (`#cf2d56`) carries warmth rather than clinical red. The result feels more like a premium print publication than a tech website. + +The custom CursorGothic font is the typographic signature -- a gothic sans-serif with aggressive negative letter-spacing at display sizes (-2.16px at 72px) that creates a compressed, engineered feel. As a secondary voice, the jjannon serif font (with OpenType `"cswh"` contextual swash alternates) provides literary counterpoint for body copy and editorial passages. The monospace voice comes from berkeleyMono, a refined coding font that connects the marketing site to Cursor's core identity as a code editor. This three-font system (gothic display, serif body, mono code) gives Cursor one of the most typographically rich palettes in developer tooling. + +The border system is particularly distinctive -- Cursor uses `oklab()` color space for border colors, applying warm brown at various alpha levels (0.1, 0.2, 0.55) to create borders that feel organic rather than mechanical. The signature border color `oklab(0.263084 -0.00230259 0.0124794 / 0.1)` is not a simple rgba value but a perceptually uniform color that maintains visual consistency across different backgrounds. + +**Key Characteristics:** +- CursorGothic with aggressive negative letter-spacing (-2.16px at 72px, -0.72px at 36px) for compressed display headings +- jjannon serif for body text with OpenType `"cswh"` (contextual swash alternates) +- berkeleyMono for code and technical labels +- Warm off-white background (`#f2f1ed`) instead of pure white -- the entire system is warm-shifted +- Primary text color `#26251e` (warm near-black with yellow undertone) +- Accent orange `#f54e00` for brand highlight and links +- oklab-space borders at various alpha levels for perceptually uniform edge treatment +- Pill-shaped elements with extreme radius (33.5M px, effectively full-pill) +- 8px base spacing system with fine-grained sub-8px increments (1.5px, 2px, 2.5px, 3px, 4px, 5px, 6px) + +## 2. Color Palette & Roles + +### Primary +- **Cursor Dark** (`#26251e`): Primary text, headings, dark UI surfaces. A warm near-black with distinct yellow-brown undertone -- the defining color of the system. +- **Cursor Cream** (`#f2f1ed`): Page background, primary surface. Not white but a warm cream that sets the entire warm tone. +- **Cursor Light** (`#e6e5e0`): Secondary surface, button backgrounds, card fills. A slightly warmer, slightly darker cream. +- **Pure White** (`#ffffff`): Used sparingly for maximum contrast elements and specific surface highlights. +- **True Black** (`#000000`): Minimal use, specific code/console contexts. + +### Accent +- **Cursor Orange** (`#f54e00`): Brand accent, `--color-accent`. A vibrant red-orange used for primary CTAs, active links, and brand moments. Warm and urgent. +- **Gold** (`#c08532`): Secondary accent, warm gold for premium or highlighted contexts. + +### Semantic +- **Error** (`#cf2d56`): `--color-error`. A warm crimson-rose rather than cold red. +- **Success** (`#1f8a65`): `--color-success`. A muted teal-green, warm-shifted. + +### Timeline / Feature Colors +- **Thinking** (`#dfa88f`): Warm peach for "thinking" state in AI timeline. +- **Grep** (`#9fc9a2`): Soft sage green for search/grep operations. +- **Read** (`#9fbbe0`): Soft blue for file reading operations. +- **Edit** (`#c0a8dd`): Soft lavender for editing operations. + +### Surface Scale +- **Surface 100** (`#f7f7f4`): Lightest button/card surface, barely tinted. +- **Surface 200** (`#f2f1ed`): Primary page background. +- **Surface 300** (`#ebeae5`): Button default background, subtle emphasis. +- **Surface 400** (`#e6e5e0`): Card backgrounds, secondary surfaces. +- **Surface 500** (`#e1e0db`): Tertiary button background, deeper emphasis. + +### Border Colors +- **Border Primary** (`oklab(0.263084 -0.00230259 0.0124794 / 0.1)`): Standard border, 10% warm brown in oklab space. +- **Border Medium** (`oklab(0.263084 -0.00230259 0.0124794 / 0.2)`): Emphasized border, 20% warm brown. +- **Border Strong** (`rgba(38, 37, 30, 0.55)`): Strong borders, table rules. +- **Border Solid** (`#26251e`): Full-opacity dark border for maximum contrast. +- **Border Light** (`#f2f1ed`): Light border matching page background. + +### Shadows & Depth +- **Card Shadow** (`rgba(0,0,0,0.14) 0px 28px 70px, rgba(0,0,0,0.1) 0px 14px 32px, oklab(0.263084 -0.00230259 0.0124794 / 0.1) 0px 0px 0px 1px`): Heavy elevated card with warm oklab border ring. +- **Ambient Shadow** (`rgba(0,0,0,0.02) 0px 0px 16px, rgba(0,0,0,0.008) 0px 0px 8px`): Subtle ambient glow for floating elements. + +## 3. Typography Rules + +### Font Family +- **Display/Headlines**: `CursorGothic`, with fallbacks: `CursorGothic Fallback, system-ui, Helvetica Neue, Helvetica, Arial` +- **Body/Editorial**: `jjannon`, with fallbacks: `Iowan Old Style, Palatino Linotype, URW Palladio L, P052, ui-serif, Georgia, Cambria, Times New Roman, Times` +- **Code/Technical**: `berkeleyMono`, with fallbacks: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New` +- **UI/System**: `system-ui`, with fallbacks: `-apple-system, Segoe UI, Helvetica Neue, Arial` +- **Icons**: `CursorIcons16` (icon font at 14px and 12px) +- **OpenType Features**: `"cswh"` on jjannon body text, `"ss09"` on CursorGothic buttons/captions + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | CursorGothic | 72px (4.50rem) | 400 | 1.10 (tight) | -2.16px | Maximum compression, hero statements | +| Section Heading | CursorGothic | 36px (2.25rem) | 400 | 1.20 (tight) | -0.72px | Feature sections, CTA headlines | +| Sub-heading | CursorGothic | 26px (1.63rem) | 400 | 1.25 (tight) | -0.325px | Card headings, sub-sections | +| Title Small | CursorGothic | 22px (1.38rem) | 400 | 1.30 (tight) | -0.11px | Smaller titles, list headings | +| Body Serif | jjannon | 19.2px (1.20rem) | 500 | 1.50 | normal | Editorial body with `"cswh"` | +| Body Serif SM | jjannon | 17.28px (1.08rem) | 400 | 1.35 | normal | Standard body text, descriptions | +| Body Sans | CursorGothic | 16px (1.00rem) | 400 | 1.50 | normal/0.08px | UI body text | +| Button Label | CursorGothic | 14px (0.88rem) | 400 | 1.00 (tight) | normal | Primary button text | +| Button Caption | CursorGothic | 14px (0.88rem) | 400 | 1.50 | 0.14px | Secondary button with `"ss09"` | +| Caption | CursorGothic | 11px (0.69rem) | 400-500 | 1.50 | normal | Small captions, metadata | +| System Heading | system-ui | 20px (1.25rem) | 700 | 1.55 | normal | System UI headings | +| System Caption | system-ui | 13px (0.81rem) | 500-600 | 1.33 | normal | System UI labels | +| System Micro | system-ui | 11px (0.69rem) | 500 | 1.27 (tight) | 0.048px | Uppercase micro labels | +| Mono Body | berkeleyMono | 12px (0.75rem) | 400 | 1.67 (relaxed) | normal | Code blocks | +| Mono Small | berkeleyMono | 11px (0.69rem) | 400 | 1.33 | -0.275px | Inline code, terminal | +| Lato Heading | Lato | 16px (1.00rem) | 600 | 1.33 | normal | Lato section headings | +| Lato Caption | Lato | 14px (0.88rem) | 400-600 | 1.33 | normal | Lato captions | +| Lato Micro | Lato | 12px (0.75rem) | 400-600 | 1.27 (tight) | 0.053px | Lato small labels | + +### Principles +- **Gothic compression for impact**: CursorGothic at display sizes uses -2.16px letter-spacing at 72px, progressively relaxing: -0.72px at 36px, -0.325px at 26px, -0.11px at 22px, normal at 16px and below. The tracking creates a sense of precision engineering. +- **Serif for soul**: jjannon provides literary warmth. The `"cswh"` feature adds contextual swash alternates that give body text a calligraphic quality. +- **Three typographic voices**: Gothic (display/UI), serif (editorial/body), mono (code/technical). Each serves a distinct communication purpose. +- **Weight restraint**: CursorGothic uses weight 400 almost exclusively, relying on size and tracking for hierarchy rather than weight. System-ui components use 500-700 for functional emphasis. + +## 4. Component Stylings + +### Buttons + +**Primary (Warm Surface)** +- Background: `#ebeae5` (Surface 300) +- Text: `#26251e` (Cursor Dark) +- Padding: 10px 12px 10px 14px +- Radius: 8px +- Outline: none +- Hover: text shifts to `var(--color-error)` (`#cf2d56`) +- Focus shadow: `rgba(0,0,0,0.1) 0px 4px 12px` +- Use: Primary actions, main CTAs + +**Secondary Pill** +- Background: `#e6e5e0` (Surface 400) +- Text: `oklab(0.263 / 0.6)` (60% warm brown) +- Padding: 3px 8px +- Radius: full pill (33.5M px) +- Hover: text shifts to `var(--color-error)` +- Use: Tags, filters, secondary actions + +**Tertiary Pill** +- Background: `#e1e0db` (Surface 500) +- Text: `oklab(0.263 / 0.6)` (60% warm brown) +- Radius: full pill +- Use: Active filter state, selected tags + +**Ghost (Transparent)** +- Background: `rgba(38, 37, 30, 0.06)` (6% warm brown) +- Text: `rgba(38, 37, 30, 0.55)` (55% warm brown) +- Padding: 6px 12px +- Use: Tertiary actions, dismiss buttons + +**Light Surface** +- Background: `#f7f7f4` (Surface 100) or `#f2f1ed` (Surface 200) +- Text: `#26251e` or `oklab(0.263 / 0.9)` (90%) +- Padding: 0px 8px 1px 12px +- Use: Dropdown triggers, subtle interactive elements + +### Cards & Containers +- Background: `#e6e5e0` or `#f2f1ed` +- Border: `1px solid oklab(0.263 / 0.1)` (warm brown at 10%) +- Radius: 8px (standard), 4px (compact), 10px (featured) +- Shadow: `rgba(0,0,0,0.14) 0px 28px 70px, rgba(0,0,0,0.1) 0px 14px 32px` for elevated cards +- Hover: shadow intensification + +### Inputs & Forms +- Background: transparent or surface +- Text: `#26251e` +- Padding: 8px 8px 6px (textarea) +- Border: `1px solid oklab(0.263 / 0.1)` +- Focus: border shifts to `oklab(0.263 / 0.2)` or accent orange + +### Navigation +- Clean horizontal nav on warm cream background +- Cursor logotype left-aligned (~96x24px) +- Links: 14px CursorGothic or system-ui, weight 500 +- CTA button: warm surface with Cursor Dark text +- Tab navigation: bottom border `1px solid oklab(0.263 / 0.1)` with active tab differentiation + +### Image Treatment +- Code editor screenshots with `1px solid oklab(0.263 / 0.1)` border +- Rounded corners: 8px standard +- AI chat/timeline screenshots dominate feature sections +- Warm gradient or solid cream backgrounds behind hero images + +### Distinctive Components + +**AI Timeline** +- Vertical timeline showing AI operations: thinking (peach), grep (sage), read (blue), edit (lavender) +- Each step uses its semantic color with matching text +- Connected with vertical lines +- Core visual metaphor for Cursor's AI-first coding experience + +**Code Editor Previews** +- Dark code editor screenshots with warm cream border frame +- berkeleyMono for code text +- Syntax highlighting using timeline colors + +**Pricing Cards** +- Warm surface backgrounds with bordered containers +- Feature lists using jjannon serif for readability +- CTA buttons with accent orange or primary dark styling + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Fine scale: 1.5px, 2px, 2.5px, 3px, 4px, 5px, 6px (sub-8px for micro-adjustments) +- Standard scale: 8px, 10px, 12px, 14px (derived from extraction) +- Extended scale (inferred): 16px, 24px, 32px, 48px, 64px, 96px +- Notable: fine-grained sub-8px increments for precise icon/text alignment + +### Grid & Container +- Max content width: approximately 1200px +- Hero: centered single-column with generous top padding (80-120px) +- Feature sections: 2-3 column grids for cards and features +- Full-width sections with warm cream or slightly darker backgrounds +- Sidebar layouts for documentation and settings pages + +### Whitespace Philosophy +- **Warm negative space**: The cream background means whitespace has warmth and texture, unlike cold white minimalism. Large empty areas feel cozy rather than clinical. +- **Compressed text, open layout**: Aggressive negative letter-spacing on CursorGothic headlines is balanced by generous surrounding margins. Text is dense; space around it breathes. +- **Section variation**: Alternating surface tones (cream → lighter cream → cream) create subtle section differentiation without harsh boundaries. + +### Border Radius Scale +- Micro (1.5px): Fine detail elements +- Small (2px): Inline elements, code spans +- Medium (3px): Small containers, inline badges +- Standard (4px): Cards, images, compact buttons +- Comfortable (8px): Primary buttons, cards, menus +- Featured (10px): Larger containers, featured cards +- Full Pill (33.5M px / 9999px): Pill buttons, tags, badges + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page background, text blocks | +| Border Ring (Level 1) | `oklab(0.263 / 0.1) 0px 0px 0px 1px` | Standard card/container border (warm oklab) | +| Border Medium (Level 1b) | `oklab(0.263 / 0.2) 0px 0px 0px 1px` | Emphasized borders, active states | +| Ambient (Level 2) | `rgba(0,0,0,0.02) 0px 0px 16px, rgba(0,0,0,0.008) 0px 0px 8px` | Floating elements, subtle glow | +| Elevated Card (Level 3) | `rgba(0,0,0,0.14) 0px 28px 70px, rgba(0,0,0,0.1) 0px 14px 32px, oklab ring` | Modals, popovers, elevated cards | +| Focus | `rgba(0,0,0,0.1) 0px 4px 12px` on button focus | Interactive focus feedback | + +**Shadow Philosophy**: Cursor's depth system is built around two ideas. First, borders use perceptually uniform oklab color space rather than rgba, ensuring warm brown borders look consistent across different background tones. Second, elevation shadows use dramatically large blur values (28px, 70px) with moderate opacity (0.14, 0.1), creating a diffused, atmospheric lift rather than hard-edged drop shadows. Cards don't feel like they float above the page -- they feel like the page has gently opened a space for them. + +### Decorative Depth +- Warm cream surface variations create subtle tonal depth without shadows +- oklab borders at 10% and 20% create a spectrum of edge definition +- No harsh divider lines -- section separation through background tone shifts and spacing + +## 7. Interaction & Motion + +### Hover States +- Buttons: text color shifts to `--color-error` (`#cf2d56`) on hover -- a distinctive warm crimson that signals interactivity +- Links: color shift to accent orange (`#f54e00`) or underline decoration with `rgba(38, 37, 30, 0.4)` +- Cards: shadow intensification on hover (ambient → elevated) + +### Focus States +- Shadow-based focus: `rgba(0,0,0,0.1) 0px 4px 12px` for depth-based focus indication +- Border focus: `oklab(0.263 / 0.2)` (20% border) for input/form focus +- Consistent warm tone in all focus states -- no cold blue focus rings + +### Transitions +- Color transitions: 150ms ease for text/background color changes +- Shadow transitions: 200ms ease for elevation changes +- Transform: subtle scale or translate for interactive feedback + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <600px | Single column, reduced padding, stacked navigation | +| Tablet Small | 600-768px | 2-column grids begin | +| Tablet | 768-900px | Expanded card grids, sidebar appears | +| Desktop Small | 900-1279px | Full layout forming | +| Desktop | >1279px | Full layout, maximum content width | + +### Touch Targets +- Buttons use comfortable padding (6px-14px vertical, 8px-14px horizontal) +- Pill buttons maintain tap-friendly sizing with 3px-10px padding +- Navigation links at 14px with adequate spacing for touch + +### Collapsing Strategy +- Hero: 72px CursorGothic → 36px → 26px on smaller screens, maintaining proportional letter-spacing +- Navigation: horizontal links → hamburger menu on mobile +- Feature cards: 3-column → 2-column → single column stacked +- Code editor screenshots: maintain aspect ratio, may shrink with border treatment preserved +- Timeline visualization: horizontal → vertical stacking +- Section spacing: 80px+ → 48px → 32px on mobile + +### Image Behavior +- Editor screenshots maintain warm border treatment at all sizes +- AI timeline adapts from horizontal to vertical layout +- Product screenshots use responsive images with consistent border radius +- Full-width hero images scale proportionally + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA background: `#ebeae5` (warm cream button) +- Page background: `#f2f1ed` (warm off-white) +- Text color: `#26251e` (warm near-black) +- Secondary text: `rgba(38, 37, 30, 0.55)` (55% warm brown) +- Accent: `#f54e00` (orange) +- Error/hover: `#cf2d56` (warm crimson) +- Success: `#1f8a65` (muted teal) +- Border: `oklab(0.263084 -0.00230259 0.0124794 / 0.1)` or `rgba(38, 37, 30, 0.1)` as fallback + +### Example Component Prompts +- "Create a hero section on `#f2f1ed` warm cream background. Headline at 72px CursorGothic weight 400, line-height 1.10, letter-spacing -2.16px, color `#26251e`. Subtitle at 17.28px jjannon weight 400, line-height 1.35, color `rgba(38,37,30,0.55)`. Primary CTA button (`#ebeae5` bg, 8px radius, 10px 14px padding) with hover text shift to `#cf2d56`." +- "Design a card: `#e6e5e0` background, border `1px solid rgba(38,37,30,0.1)`. Radius 8px. Title at 22px CursorGothic weight 400, letter-spacing -0.11px. Body at 17.28px jjannon weight 400, color `rgba(38,37,30,0.55)`. Use `#f54e00` for link accents." +- "Build a pill tag: `#e6e5e0` background, `rgba(38,37,30,0.6)` text, full-pill radius (9999px), 3px 8px padding, 14px CursorGothic weight 400." +- "Create navigation: sticky `#f2f1ed` background with backdrop-filter blur. 14px system-ui weight 500 for links, `#26251e` text. CTA button right-aligned with `#ebeae5` bg and 8px radius. Bottom border `1px solid rgba(38,37,30,0.1)`." +- "Design an AI timeline showing four steps: Thinking (`#dfa88f`), Grep (`#9fc9a2`), Read (`#9fbbe0`), Edit (`#c0a8dd`). Each step: 14px system-ui label + 16px CursorGothic description + vertical connecting line in `rgba(38,37,30,0.1)`." + +### Iteration Guide +1. Always use warm tones -- `#f2f1ed` background, `#26251e` text, never pure white/black for primary surfaces +2. Letter-spacing scales with font size for CursorGothic: -2.16px at 72px, -0.72px at 36px, -0.325px at 26px, normal at 16px +3. Use `rgba(38, 37, 30, alpha)` as a CSS-compatible fallback for oklab borders +4. Three fonts, three voices: CursorGothic (display/UI), jjannon (editorial), berkeleyMono (code) +5. Pill shapes (9999px radius) for tags and filters; 8px radius for primary buttons and cards +6. Hover states use `#cf2d56` text color -- the warm crimson shift is a signature interaction +7. Shadows use large blur values (28px, 70px) for diffused atmospheric depth +8. The sub-8px spacing scale (1.5, 2, 2.5, 3, 4, 5, 6px) is critical for icon/text micro-alignment diff --git a/skills/creative/popular-web-designs/templates/elevenlabs.md b/skills/creative/popular-web-designs/templates/elevenlabs.md new file mode 100644 index 000000000000..2a7fd35e227c --- /dev/null +++ b/skills/creative/popular-web-designs/templates/elevenlabs.md @@ -0,0 +1,278 @@ +# Design System: ElevenLabs + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +ElevenLabs' website is a study in restrained elegance — a near-white canvas (`#ffffff`, `#f5f5f5`) where typography and subtle shadows do all the heavy lifting. The design feels like a premium audio product brochure: clean, spacious, and confident enough to let the content speak (literally, given ElevenLabs makes voice AI). There's an almost Apple-like quality to the whitespace strategy, but warmer — the occasional warm stone tint (`#f5f2ef`, `#777169`) prevents the purity from feeling clinical. + +The typography system is built on a fascinating duality: Waldenburg at weight 300 (light) for display headings creates ethereal, whisper-thin titles that feel like sound waves rendered in type — delicate, precise, and surprisingly impactful at large sizes. This light-weight display approach is the design's signature — where most sites use bold headings to grab attention, ElevenLabs uses lightness to create intrigue. Inter handles all body and UI text with workmanlike reliability, using slight positive letter-spacing (0.14px–0.18px) that gives body text an airy, well-spaced quality. WaldenburgFH appears as a bold uppercase variant for specific button labels. + +What makes ElevenLabs distinctive is its multi-layered shadow system. Rather than simple box-shadows, elements use complex stacks: inset border-shadows (`rgba(0,0,0,0.075) 0px 0px 0px 0.5px inset`), outline shadows (`rgba(0,0,0,0.06) 0px 0px 0px 1px`), and soft elevation shadows (`rgba(0,0,0,0.04) 0px 4px 4px`) — all at remarkably low opacities. The result is a design where surfaces seem to barely exist, floating just above the page with the lightest possible touch. Pill-shaped buttons (9999px) with warm-tinted backgrounds (`rgba(245,242,239,0.8)`) and warm shadows (`rgba(78,50,23,0.04)`) add a tactile, physical quality. + +**Key Characteristics:** +- Near-white canvas with warm undertones (`#f5f5f5`, `#f5f2ef`) +- Waldenburg weight 300 (light) for display — ethereal, whisper-thin headings +- Inter with positive letter-spacing (0.14–0.18px) for body — airy readability +- Multi-layered shadow stacks at sub-0.1 opacity — surfaces barely exist +- Pill buttons (9999px) with warm stone-tinted backgrounds +- WaldenburgFH bold uppercase for specific CTA labels +- Warm shadow tints: `rgba(78, 50, 23, 0.04)` — shadows have color, not just darkness +- Geist Mono / ui-monospace for code snippets + +## 2. Color Palette & Roles + +### Primary +- **Pure White** (`#ffffff`): Primary background, card surfaces, button backgrounds +- **Light Gray** (`#f5f5f5`): Secondary surface, subtle section differentiation +- **Warm Stone** (`#f5f2ef`): Button background (at 80% opacity) — the warm signature +- **Black** (`#000000`): Primary text, headings, dark buttons + +### Neutral Scale +- **Dark Gray** (`#4e4e4e`): Secondary text, descriptions +- **Warm Gray** (`#777169`): Tertiary text, muted links, decorative underlines +- **Near White** (`#f6f6f6`): Alternate light surface + +### Interactive +- **Grid Cyan** (`#7fffff`): `--grid-column-bg`, at 25% opacity — decorative grid overlay +- **Ring Blue** (`rgb(147 197 253 / 0.5)`): `--tw-ring-color`, focus ring +- **Border Light** (`#e5e5e5`): Explicit borders +- **Border Subtle** (`rgba(0, 0, 0, 0.05)`): Ultra-subtle bottom borders + +### Shadows +- **Inset Border** (`rgba(0,0,0,0.075) 0px 0px 0px 0.5px inset`): Internal edge definition +- **Inset Dark** (`rgba(0,0,0,0.1) 0px 0px 0px 0.5px inset`): Stronger inset variant +- **Outline Ring** (`rgba(0,0,0,0.06) 0px 0px 0px 1px`): Shadow-as-border +- **Soft Elevation** (`rgba(0,0,0,0.04) 0px 4px 4px`): Gentle lift +- **Card Shadow** (`rgba(0,0,0,0.4) 0px 0px 1px, rgba(0,0,0,0.04) 0px 4px 4px`): Button/card elevation +- **Warm Shadow** (`rgba(78,50,23,0.04) 0px 6px 16px`): Warm-tinted button shadow +- **Edge Shadow** (`rgba(0,0,0,0.08) 0px 0px 0px 0.5px`): Subtle edge definition +- **Inset Ring** (`rgba(0,0,0,0.1) 0px 0px 0px 1px inset`): Strong inset border + +## 3. Typography Rules + +### Font Families +- **Display**: `Waldenburg`, fallback: `Waldenburg Fallback` +- **Display Bold**: `WaldenburgFH`, fallback: `WaldenburgFH Fallback` +- **Body / UI**: `Inter`, fallback: `Inter Fallback` +- **Monospace**: `Geist Mono` or `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Waldenburg | 48px (3.00rem) | 300 | 1.08 (tight) | -0.96px | Whisper-thin, ethereal | +| Section Heading | Waldenburg | 36px (2.25rem) | 300 | 1.17 (tight) | normal | Light display | +| Card Heading | Waldenburg | 32px (2.00rem) | 300 | 1.13 (tight) | normal | Light card titles | +| Body Large | Inter | 20px (1.25rem) | 400 | 1.35 | normal | Introductions | +| Body | Inter | 18px (1.13rem) | 400 | 1.44–1.60 | 0.18px | Standard reading text | +| Body Standard | Inter | 16px (1.00rem) | 400 | 1.50 | 0.16px | UI text | +| Body Medium | Inter | 16px (1.00rem) | 500 | 1.50 | 0.16px | Emphasized body | +| Nav / UI | Inter | 15px (0.94rem) | 500 | 1.33–1.47 | 0.15px | Navigation links | +| Button | Inter | 15px (0.94rem) | 500 | 1.47 | normal | Button labels | +| Button Uppercase | WaldenburgFH | 14px (0.88rem) | 700 | 1.10 (tight) | 0.7px | `text-transform: uppercase` | +| Caption | Inter | 14px (0.88rem) | 400–500 | 1.43–1.50 | 0.14px | Metadata | +| Small | Inter | 13px (0.81rem) | 500 | 1.38 | normal | Tags, badges | +| Code | Geist Mono | 13px (0.81rem) | 400 | 1.85 (relaxed) | normal | Code blocks | +| Micro | Inter | 12px (0.75rem) | 500 | 1.33 | normal | Tiny labels | +| Tiny | Inter | 10px (0.63rem) | 400 | 1.60 (relaxed) | normal | Fine print | + +### Principles +- **Light as the hero weight**: Waldenburg at 300 is the defining typographic choice. Where other design systems use bold for impact, ElevenLabs uses lightness — thin strokes that feel like audio waveforms, creating intrigue through restraint. +- **Positive letter-spacing on body**: Inter uses +0.14px to +0.18px tracking across body text, creating an airy, well-spaced reading rhythm that contrasts with the tight display tracking (-0.96px). +- **WaldenburgFH for emphasis**: A bold (700) uppercase variant of Waldenburg appears only in specific CTA button labels with 0.7px letter-spacing — the one place where the type system gets loud. +- **Monospace as ambient**: Geist Mono at relaxed line-height (1.85) for code blocks feels unhurried and readable. + +## 4. Component Stylings + +### Buttons + +**Primary Black Pill** +- Background: `#000000` +- Text: `#ffffff` +- Padding: 0px 14px +- Radius: 9999px (full pill) +- Use: Primary CTA + +**White Pill (Shadow-bordered)** +- Background: `#ffffff` +- Text: `#000000` +- Radius: 9999px +- Shadow: `rgba(0,0,0,0.4) 0px 0px 1px, rgba(0,0,0,0.04) 0px 4px 4px` +- Use: Secondary CTA on white + +**Warm Stone Pill** +- Background: `rgba(245, 242, 239, 0.8)` (warm translucent) +- Text: `#000000` +- Padding: 12px 20px 12px 14px (asymmetric) +- Radius: 30px +- Shadow: `rgba(78, 50, 23, 0.04) 0px 6px 16px` (warm-tinted) +- Use: Featured CTA, hero action — the signature warm button + +**Uppercase Waldenburg Button** +- Font: WaldenburgFH 14px weight 700 +- Text-transform: uppercase +- Letter-spacing: 0.7px +- Use: Specific bold CTA labels + +### Cards & Containers +- Background: `#ffffff` +- Border: `1px solid #e5e5e5` or shadow-as-border +- Radius: 16px–24px +- Shadow: multi-layer stack (inset + outline + elevation) +- Content: product screenshots, code examples, audio waveform previews + +### Inputs & Forms +- Textarea: padding 12px 20px, transparent text at default +- Select: white background, standard styling +- Radio: standard with tw-ring focus +- Focus: `var(--tw-ring-offset-shadow)` ring system + +### Navigation +- Clean white sticky header +- Inter 15px weight 500 for nav links +- Pill CTAs right-aligned (black primary, white secondary) +- Mobile: hamburger collapse at 1024px + +### Image Treatment +- Product screenshots and audio waveform visualizations +- Warm gradient backgrounds in feature sections +- 20px–24px radius on image containers +- Full-width sections alternating white and light gray + +### Distinctive Components + +**Audio Waveform Sections** +- Colorful gradient backgrounds showcasing voice AI capabilities +- Warm amber, blue, and green gradients behind product demos +- Screenshots of the ElevenLabs product interface + +**Warm Stone CTA Block** +- `rgba(245,242,239,0.8)` background with warm shadow +- Asymmetric padding (more right padding) +- Creates a physical, tactile quality unique to ElevenLabs + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 3px, 4px, 8px, 9px, 10px, 11px, 12px, 16px, 18px, 20px, 24px, 28px, 32px, 40px + +### Grid & Container +- Centered content with generous max-width +- Single-column hero, expanding to feature grids +- Full-width gradient sections for product showcases +- White card grids on light gray backgrounds + +### Whitespace Philosophy +- **Apple-like generosity**: Massive vertical spacing between sections creates a premium, unhurried pace. Each section is an exhibit. +- **Warm emptiness**: The whitespace isn't cold — the warm stone undertones and warm shadows give empty space a tactile, physical quality. +- **Typography-led rhythm**: The light-weight Waldenburg headings create visual "whispers" that draw the eye through vast white space. + +### Border Radius Scale +- Minimal (2px): Small links, inline elements +- Subtle (4px): Nav items, tab panels, tags +- Standard (8px): Small containers +- Comfortable (10px–12px): Medium cards, dropdowns +- Card (16px): Standard cards, articles +- Large (18px–20px): Featured cards, code panels +- Section (24px): Large panels, section containers +- Warm Button (30px): Warm stone CTA +- Pill (9999px): Primary buttons, navigation pills + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page background, text blocks | +| Inset Edge (Level 0.5) | `rgba(0,0,0,0.075) 0px 0px 0px 0.5px inset, #fff 0px 0px 0px 0px inset` | Internal border definition | +| Outline Ring (Level 1) | `rgba(0,0,0,0.06) 0px 0px 0px 1px` + `rgba(0,0,0,0.04) 0px 1px 2px` + `rgba(0,0,0,0.04) 0px 2px 4px` | Shadow-as-border for cards | +| Card (Level 2) | `rgba(0,0,0,0.4) 0px 0px 1px, rgba(0,0,0,0.04) 0px 4px 4px` | Button elevation, prominent cards | +| Warm Lift (Level 3) | `rgba(78,50,23,0.04) 0px 6px 16px` | Featured CTAs — warm-tinted | +| Focus (Accessibility) | `var(--tw-ring-offset-shadow)` blue ring | Keyboard focus | + +**Shadow Philosophy**: ElevenLabs uses the most refined shadow system of any design system analyzed. Every shadow is at sub-0.1 opacity, many include both outward cast AND inward inset components, and the warm CTA shadows use an actual warm color (`rgba(78,50,23,...)`) rather than neutral black. The inset half-pixel borders (`0px 0px 0px 0.5px inset`) create edges so subtle they're felt rather than seen — surfaces define themselves through the lightest possible touch. + +## 7. Do's and Don'ts + +### Do +- Use Waldenburg weight 300 for all display headings — the lightness IS the brand +- Apply multi-layer shadows (inset + outline + elevation) at sub-0.1 opacity +- Use warm stone tints (`#f5f2ef`, `rgba(245,242,239,0.8)`) for featured elements +- Apply positive letter-spacing (+0.14px to +0.18px) on Inter body text +- Use 9999px radius for primary buttons — pill shape is standard +- Use warm-tinted shadows (`rgba(78,50,23,0.04)`) on featured CTAs +- Keep the page predominantly white with subtle gray section differentiation +- Use WaldenburgFH bold uppercase ONLY for specific CTA button labels + +### Don't +- Don't use bold (700) Waldenburg for headings — weight 300 is non-negotiable +- Don't use heavy shadows (>0.1 opacity) — the ethereal quality requires whisper-level depth +- Don't use cool gray borders — the system is warm-tinted throughout +- Don't skip the inset shadow component — half-pixel inset borders define edges +- Don't apply negative letter-spacing to body text — Inter uses positive tracking +- Don't use sharp corners (<8px) on cards — the generous radius is structural +- Don't introduce brand colors — the palette is intentionally achromatic with warm undertones +- Don't make buttons opaque and heavy — the warm translucent stone treatment is the signature + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <1024px | Single column, hamburger nav, stacked sections | +| Desktop | >1024px | Full layout, horizontal nav, multi-column grids | + +### Touch Targets +- Pill buttons with generous padding (12px–20px) +- Navigation links at 15px with adequate spacing +- Select dropdowns maintain comfortable sizing + +### Collapsing Strategy +- Navigation: horizontal → hamburger at 1024px +- Feature grids: multi-column → stacked +- Hero: maintains centered layout, font scales proportionally +- Gradient sections: full-width maintained, content stacks +- Spacing compresses proportionally + +### Image Behavior +- Product screenshots scale responsively +- Gradient backgrounds simplify on mobile +- Audio waveform previews maintain aspect ratio +- Rounded corners maintained across breakpoints + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Pure White (`#ffffff`) or Light Gray (`#f5f5f5`) +- Text: Black (`#000000`) +- Secondary text: Dark Gray (`#4e4e4e`) +- Muted text: Warm Gray (`#777169`) +- Warm surface: Warm Stone (`rgba(245, 242, 239, 0.8)`) +- Border: `#e5e5e5` or `rgba(0,0,0,0.05)` + +### Example Component Prompts +- "Create a hero on white background. Headline at 48px Waldenburg weight 300, line-height 1.08, letter-spacing -0.96px, black text. Subtitle at 18px Inter weight 400, line-height 1.60, letter-spacing 0.18px, #4e4e4e text. Two pill buttons: black (9999px, 0px 14px padding) and warm stone (rgba(245,242,239,0.8), 30px radius, 12px 20px padding, warm shadow rgba(78,50,23,0.04) 0px 6px 16px)." +- "Design a card: white background, 20px radius. Shadow: rgba(0,0,0,0.06) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 1px 2px, rgba(0,0,0,0.04) 0px 2px 4px. Title at 32px Waldenburg weight 300, body at 16px Inter weight 400 letter-spacing 0.16px, #4e4e4e." +- "Build a white pill button: white bg, 9999px radius. Shadow: rgba(0,0,0,0.4) 0px 0px 1px, rgba(0,0,0,0.04) 0px 4px 4px. Text at 15px Inter weight 500." +- "Create an uppercase CTA label: 14px WaldenburgFH weight 700, text-transform uppercase, letter-spacing 0.7px." +- "Design navigation: white sticky header. Inter 15px weight 500. Black pill CTA right-aligned. Border-bottom: rgba(0,0,0,0.05)." + +### Iteration Guide +1. Start with white — the warm undertone comes from shadows and stone surfaces, not backgrounds +2. Waldenburg 300 for headings — never bold, the lightness is the identity +3. Multi-layer shadows: always include inset + outline + elevation at sub-0.1 opacity +4. Positive letter-spacing on Inter body (+0.14px to +0.18px) — the airy reading quality +5. Warm stone CTA is the signature — `rgba(245,242,239,0.8)` with `rgba(78,50,23,0.04)` shadow +6. Pill (9999px) for buttons, generous radius (16px–24px) for cards diff --git a/skills/creative/popular-web-designs/templates/expo.md b/skills/creative/popular-web-designs/templates/expo.md new file mode 100644 index 000000000000..9fa2b8258157 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/expo.md @@ -0,0 +1,294 @@ +# Design System: Expo + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Expo's interface is a luminous, confidence-radiating developer platform built on the premise that tools for building apps should feel as polished as the apps themselves. The entire experience lives on a bright, airy canvas — a cool-tinted off-white (`#f0f0f3`) that gives the page a subtle technological coolness without the starkness of pure white. This is a site that breathes: enormous vertical spacing between sections creates a gallery-like pace where each feature gets its own "room." + +The design language is decisively monochromatic — pure black (`#000000`) headlines against the lightest possible backgrounds, with a spectrum of cool blue-grays (`#60646c`, `#b0b4ba`, `#555860`) handling all secondary communication. Color is almost entirely absent from the interface itself; when it appears, it's reserved for product screenshots, app icons, and the React universe illustration — making the actual content burst with life against the neutral canvas. + +What makes Expo distinctive is its pill-shaped geometry. Buttons, tabs, video containers, and even images use generously rounded or fully pill-shaped corners (24px–9999px), creating an organic, approachable feel that contradicts the typical sharp-edged developer tool aesthetic. Combined with tight letter-spacing on massive headlines (-1.6px to -3px at 64px), the result is a design that's simultaneously premium and friendly — like an Apple product page reimagined for developers. + +**Key Characteristics:** +- Luminous cool-white canvas (`#f0f0f3`) with gallery-like vertical spacing +- Strictly monochromatic: pure black headlines, cool blue-gray body text, no decorative color +- Pill-shaped geometry everywhere — buttons, tabs, containers, images (24px–9999px radius) +- Massive display headlines (64px) with extreme negative letter-spacing (-1.6px to -3px) +- Inter as the sole typeface, used at weights 400–900 for full expressive range +- Whisper-soft shadows that barely lift elements from the surface +- Product screenshots as the only source of color in the interface + +## 2. Color Palette & Roles + +### Primary +- **Expo Black** (`#000000`): The absolute anchor — used for primary headlines, CTA buttons, and the brand identity. Pure black on cool white creates maximum contrast without feeling aggressive. +- **Near Black** (`#1c2024`): The primary text color for body content — a barely perceptible blue-black that's softer than pure #000 for extended reading. + +### Secondary & Accent +- **Link Cobalt** (`#0d74ce`): The standard link color — a trustworthy, saturated blue that signals interactivity without competing with the monochrome hierarchy. +- **Legal Blue** (`#476cff`): A brighter, more saturated blue for legal/footer links — slightly more attention-grabbing than Link Cobalt. +- **Widget Sky** (`#47c2ff`): A light, friendly cyan-blue for widget branding elements — the brightest accent in the system. +- **Preview Purple** (`#8145b5`): A rich violet used for "preview" or beta feature indicators — creating clear visual distinction from standard content. + +### Surface & Background +- **Cloud Gray** (`#f0f0f3`): The primary page background — a cool off-white with the faintest blue-violet tint. Not warm, not sterile — precisely technological. +- **Pure White** (`#ffffff`): Card surfaces, button backgrounds, and elevated content containers. Creates a clear "lifted" distinction from Cloud Gray. +- **Widget Dark** (`#1a1a1a`): Dark surface for dark-theme widgets and overlay elements. +- **Banner Dark** (`#171717`): The darkest surface variant, used for promotional banners and high-contrast containers. + +### Neutrals & Text +- **Slate Gray** (`#60646c`): The workhorse secondary text color (305 instances). A cool blue-gray that's authoritative without being heavy. +- **Mid Slate** (`#555860`): Slightly darker than Slate, used for emphasized secondary text. +- **Silver** (`#b0b4ba`): Tertiary text, placeholders, and de-emphasized metadata. Comfortably readable but clearly receded. +- **Pewter** (`#999999`): Accordion icons and deeply de-emphasized UI elements in dark contexts. +- **Light Silver** (`#cccccc`): Arrow icons and decorative elements in dark contexts. +- **Dark Slate** (`#363a3f`): Borders on dark surfaces, switch tracks, and emphasized containment. +- **Charcoal** (`#333333`): Dark mode switch backgrounds and deep secondary surfaces. + +### Semantic & Accent +- **Warning Amber** (`#ab6400`): A warm, deep amber for warning states — deliberately not bright yellow, conveying seriousness. +- **Destructive Rose** (`#eb8e90`): A soft pink-coral for disabled destructive actions — gentler than typical red, reducing alarm fatigue. +- **Border Lavender** (`#e0e1e6`): Standard card/container borders — a cool lavender-gray that's visible without being heavy. +- **Input Border** (`#d9d9e0`): Button and form element borders — slightly warmer/darker than card borders for interactive elements. +- **Dark Focus Ring** (`#2547d0`): Deep blue for keyboard focus indicators in dark theme contexts. + +### Gradient System +- The design is notably **gradient-free** in the interface layer. Visual richness comes from product screenshots, the React universe illustration, and careful shadow layering rather than color gradients. This absence IS the design decision — gradients would undermine the clinical precision. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Inter`, with fallbacks: `-apple-system, system-ui` +- **Monospace**: `JetBrains Mono`, with fallback: `ui-monospace` +- **System Fallback**: `system-ui, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | Inter | 64px (4rem) | 700–900 | 1.10 (tight) | -1.6px to -3px | Maximum impact, extreme tracking | +| Section Heading | Inter | 48px (3rem) | 600 | 1.10 (tight) | -2px | Feature section anchors | +| Sub-heading | Inter | 20px (1.25rem) | 600 | 1.20 (tight) | -0.25px | Card titles, feature names | +| Body Large | Inter | 18px (1.13rem) | 400–500 | 1.40 | normal | Intro paragraphs, section descriptions | +| Body / Button | Inter | 16px (1rem) | 400–700 | 1.25–1.40 | normal | Standard text, nav links, buttons | +| Caption / Label | Inter | 14px (0.88rem) | 400–600 | 1.00–1.40 | normal | Descriptions, metadata, badge text | +| Tag / Small | Inter | 12px (0.75rem) | 500 | 1.00–1.60 | normal | Smallest sans-serif text, badges | +| Code Body | JetBrains Mono | 16px (1rem) | 400–600 | 1.40 | normal | Inline code, terminal commands | +| Code Caption | JetBrains Mono | 14px (0.88rem) | 400–600 | 1.40 | normal | Code snippets, technical labels | +| Code Small | JetBrains Mono | 12px (0.75rem) | 400 | 1.60 | normal | Uppercase tech tags | + +### Principles +- **One typeface, full expression**: Inter is the only sans-serif, used from weight 400 (regular) through 900 (black). This gives the design a unified voice while still achieving dramatic contrast between whisper-light body text and thundering display headlines. +- **Extreme negative tracking at scale**: Headlines at 64px use -1.6px to -3px letter-spacing, creating ultra-dense text blocks that feel like logotypes. This aggressive compression is the signature typographic move. +- **Weight as hierarchy**: 700–900 for display, 600 for headings, 500 for emphasis, 400 for body. The jumps are decisive — no ambiguous in-between weights. +- **Consistent 1.40 body line-height**: Nearly all body and UI text shares 1.40 line-height, creating a rhythmic vertical consistency. + +## 4. Component Stylings + +### Buttons + +**Primary (White on border)** +- Background: Pure White (`#ffffff`) +- Text: Near Black (`#1c2024`) +- Padding: 0px 12px (compact, content-driven height) +- Border: thin solid Input Border (`1px solid #d9d9e0`) +- Radius: subtly rounded (6px) +- Shadow: subtle combined shadow on hover +- The understated default — clean, professional, unheroic + +**Primary Pill** +- Same as Primary but with pill-shaped radius (9999px) +- Used for hero CTAs and high-emphasis actions +- The extra roundness signals "start here" + +**Dark Primary** +- Background: Expo Black (`#000000`) +- Text: Pure White (`#ffffff`) +- Pill-shaped (9999px) or generously rounded (32–36px) +- No border (black IS the border) +- The maximum-emphasis CTA — reserved for primary conversion actions + +### Cards & Containers +- Background: Pure White (`#ffffff`) — clearly lifted from Cloud Gray page +- Border: thin solid Border Lavender (`1px solid #e0e1e6`) for standard cards +- Radius: comfortably rounded (8px) for standard cards; generously rounded (16–24px) for featured containers +- Shadow Level 1: Whisper (`rgba(0,0,0,0.08) 0px 3px 6px, rgba(0,0,0,0.07) 0px 2px 4px`) — barely perceptible lift +- Shadow Level 2: Standard (`rgba(0,0,0,0.1) 0px 10px 20px, rgba(0,0,0,0.05) 0px 3px 6px`) — clear floating elevation +- Hover: likely subtle shadow deepening or background shift + +### Inputs & Forms +- Background: Pure White (`#ffffff`) +- Text: Near Black (`#1c2024`) +- Border: thin solid Input Border (`1px solid #d9d9e0`) +- Padding: 0px 12px (inline with button sizing) +- Radius: subtly rounded (6px) +- Focus: blue ring shadow via CSS custom property + +### Navigation +- Sticky top nav on transparent/blurred background +- Logo: Expo wordmark in black +- Links: Near Black (`#1c2024`) or Slate Gray (`#60646c`) at 14–16px Inter weight 500 +- CTA: Black pill button ("Sign Up") on the right +- GitHub star badge as social proof +- Status indicator ("All Systems Operational") with green dot + +### Image Treatment +- Product screenshots and device mockups are the visual heroes +- Generously rounded corners (24px) on video and image containers +- Screenshots shown in realistic device frames +- Dark UI screenshots provide contrast against the light canvas +- Full-bleed within rounded containers + +### Distinctive Components + +**Universe React Logo** +- Animated/illustrated React logo as the visual centerpiece +- Connects Expo's identity to the React ecosystem +- The only illustrative element on an otherwise photographic page + +**Device Preview Grid** +- Multiple device types (phone, tablet, web) shown simultaneously +- Demonstrates cross-platform capability visually +- Each device uses realistic device chrome + +**Status Badge** +- "All Systems Operational" pill in the nav +- Green dot + text — compact trust signal +- Pill-shaped (36px radius) + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 8px, 12px, 16px, 24px, 32px, 40px, 48px, 64px, 80px, 96px, 144px +- Button padding: 0px 12px (unusually compact — height driven by line-height) +- Card internal padding: approximately 24–32px +- Section vertical spacing: enormous (estimated 96–144px between major sections) +- Component gap: 16–24px between sibling elements + +### Grid & Container +- Max container width: approximately 1200–1400px, centered +- Hero: centered single-column with massive breathing room +- Feature sections: alternating layouts (image left/right, full-width showcases) +- Card grids: 2–3 column for feature highlights +- Full-width sections with contained inner content + +### Whitespace Philosophy +- **Gallery-like pacing**: Each section feels like its own exhibit, surrounded by vast empty space. This creates a premium, unhurried browsing experience. +- **Breathing room is the design**: The generous whitespace IS the primary design element — it communicates confidence, quality, and that each feature deserves individual attention. +- **Content islands**: Sections float as isolated "islands" in the white space, connected by scrolling rather than visual continuation. + +### Border Radius Scale +- Nearly squared (4px): Small inline elements, tags +- Subtly rounded (6px): Buttons, form inputs, combo boxes — the functional interactive radius +- Comfortably rounded (8px): Standard content cards, containers +- Generously rounded (16px): Feature tabs, content panels +- Very rounded (24px): Buttons, video/image containers, tabpanels — the signature softness +- Highly rounded (32–36px): Hero CTAs, status badges, nav buttons +- Pill-shaped (9999px): Primary action buttons, tags, avatars — maximum friendliness + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Cloud Gray page background, inline text | +| Surface (Level 1) | White bg, no shadow | Standard white cards on Cloud Gray | +| Whisper (Level 2) | `rgba(0,0,0,0.08) 0px 3px 6px` + `rgba(0,0,0,0.07) 0px 2px 4px` | Subtle card lift, hover states | +| Elevated (Level 3) | `rgba(0,0,0,0.1) 0px 10px 20px` + `rgba(0,0,0,0.05) 0px 3px 6px` | Feature showcases, product screenshots | +| Modal (Level 4) | Dark overlay (`--dialog-overlay-background-color`) + heavy shadow | Dialogs, overlays | + +**Shadow Philosophy**: Expo uses shadows as gentle whispers rather than architectural statements. The primary depth mechanism is **background color contrast** — white cards floating on Cloud Gray — rather than shadow casting. When shadows appear, they're soft, diffused, and directional (downward), creating the feeling of paper hovering millimeters above a desk. + +## 7. Do's and Don'ts + +### Do +- Use Cloud Gray (`#f0f0f3`) as the page background and Pure White (`#ffffff`) for elevated cards — the two-tone light system is essential +- Keep display headlines at extreme negative letter-spacing (-1.6px to -3px at 64px) for the signature compressed look +- Use pill-shaped (9999px) radius for primary CTA buttons — the organic shape is core to the identity +- Reserve black (`#000000`) for headlines and primary CTAs — it carries maximum authority on the light canvas +- Use Slate Gray (`#60646c`) for secondary text — it's the precise balance between readable and receded +- Maintain enormous vertical spacing between sections (96px+) — the gallery pacing defines the premium feel +- Use product screenshots as the primary visual content — the interface stays monochrome, the products bring color +- Apply Inter at the full weight range (400–900) — weight contrast IS the hierarchy + +### Don't +- Don't introduce decorative colors into the interface chrome — the monochromatic palette is intentional +- Don't use sharp corners (border-radius < 6px) on interactive elements — the pill/rounded geometry is the signature +- Don't reduce section spacing below 64px — the breathing room is the design +- Don't use heavy drop shadows — depth comes from background contrast and whisper-soft shadows +- Don't mix in additional typefaces — Inter handles everything from display to caption +- Don't use letter-spacing wider than -0.25px on body text — extreme tracking is reserved for display only +- Don't use borders heavier than 2px — containment is subtle, achieved through background color and gentle borders +- Don't add gradients to the interface — visual richness comes from content, not decoration +- Don't use saturated colors outside of semantic contexts — the palette is strictly grayscale + functional blue + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, hamburger nav, stacked cards, hero text scales to ~36px | +| Tablet | 640–1024px | 2-column grids, condensed nav, medium hero text | +| Desktop | >1024px | Full multi-column layout, expanded nav, massive hero (64px) | + +*Only one explicit breakpoint detected (640px), suggesting a fluid, container-query or min()/clamp()-based responsive system rather than fixed breakpoint snapping.* + +### Touch Targets +- Buttons use generous radius (24–36px) creating large, finger-friendly surfaces +- Navigation links spaced with adequate gap +- Status badge sized for touch (36px radius) +- Minimum recommended: 44x44px + +### Collapsing Strategy +- **Navigation**: Full horizontal nav with CTA collapses to hamburger on mobile +- **Feature sections**: Multi-column → stacked single column +- **Hero text**: 64px → ~36px progressive scaling +- **Device previews**: Grid → stacked/carousel +- **Cards**: Side-by-side → vertical stacking +- **Spacing**: Reduces proportionally but maintains generous rhythm + +### Image Behavior +- Product screenshots scale proportionally +- Device mockups may simplify or show fewer devices on mobile +- Rounded corners maintained at all sizes +- Lazy loading for below-fold content + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA / Headlines: "Expo Black (#000000)" +- Page Background: "Cloud Gray (#f0f0f3)" +- Card Surface: "Pure White (#ffffff)" +- Body Text: "Near Black (#1c2024)" +- Secondary Text: "Slate Gray (#60646c)" +- Borders: "Border Lavender (#e0e1e6)" +- Links: "Link Cobalt (#0d74ce)" +- Tertiary Text: "Silver (#b0b4ba)" + +### Example Component Prompts +- "Create a hero section on Cloud Gray (#f0f0f3) with a massive headline at 64px Inter weight 700, line-height 1.10, letter-spacing -3px. Text in Expo Black (#000000). Below, add a subtitle in Slate Gray (#60646c) at 18px. Place a black pill-shaped CTA button (9999px radius) beneath." +- "Design a feature card on Pure White (#ffffff) with a 1px solid Border Lavender (#e0e1e6) border and comfortably rounded corners (8px). Title in Near Black (#1c2024) at 20px Inter weight 600, description in Slate Gray (#60646c) at 16px. Add a whisper shadow (rgba(0,0,0,0.08) 0px 3px 6px)." +- "Build a navigation bar with Expo logo on the left, text links in Near Black (#1c2024) at 14px Inter weight 500, and a black pill CTA button on the right. Background: transparent with blur backdrop. Bottom border: 1px solid Border Lavender (#e0e1e6)." +- "Create a code block using JetBrains Mono at 14px on a Pure White surface with Border Lavender border and 8px radius. Code in Near Black, keywords in Link Cobalt (#0d74ce)." +- "Design a status badge pill (9999px radius) with a green dot and 'All Systems Operational' text in Inter 12px weight 500. Background: Pure White, border: 1px solid Input Border (#d9d9e0)." + +### Iteration Guide +1. Focus on ONE component at a time +2. Reference specific color names and hex codes — "use Slate Gray (#60646c)" not "make it gray" +3. Use radius values deliberately — 6px for buttons, 8px for cards, 24px for images, 9999px for pills +4. Describe the "feel" alongside measurements — "enormous breathing room with 96px section spacing" +5. Always specify Inter and the exact weight — weight contrast IS the hierarchy +6. For shadows, specify "whisper shadow" or "standard elevation" from the elevation table +7. Keep the interface monochrome — let product content be the color diff --git a/skills/creative/popular-web-designs/templates/figma.md b/skills/creative/popular-web-designs/templates/figma.md new file mode 100644 index 000000000000..0a1437981d33 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/figma.md @@ -0,0 +1,233 @@ +# Design System: Figma + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Figma's interface is the design tool that designed itself — a masterclass in typographic sophistication where a custom variable font (figmaSans) modulates between razor-thin (weight 320) and bold (weight 700) with stops at unusual intermediates (330, 340, 450, 480, 540) that most type systems never explore. This granular weight control gives every text element a precisely calibrated visual weight, creating hierarchy through micro-differences rather than the blunt instrument of "regular vs bold." + +The page presents a fascinating duality: the interface chrome is strictly black-and-white (literally only `#000000` and `#ffffff` detected as colors), while the hero section and product showcases explode with vibrant multi-color gradients — electric greens, bright yellows, deep purples, hot pinks. This separation means the design system itself is colorless, treating the product's colorful output as the hero content. Figma's marketing page is essentially a white gallery wall displaying colorful art. + +What makes Figma distinctive beyond the variable font is its circle-and-pill geometry. Buttons use 50px radius (pill) or 50% (perfect circle for icon buttons), creating an organic, tool-palette-like feel. The dashed-outline focus indicator (`dashed 2px`) is a deliberate design choice that echoes selection handles in the Figma editor itself — the website's UI language references the product's UI language. + +**Key Characteristics:** +- Custom variable font (figmaSans) with unusual weight stops: 320, 330, 340, 450, 480, 540, 700 +- Strictly black-and-white interface chrome — color exists only in product content +- figmaMono for uppercase technical labels with wide letter-spacing +- Pill (50px) and circular (50%) button geometry +- Dashed focus outlines echoing Figma's editor selection handles +- Vibrant multi-color hero gradients (green, yellow, purple, pink) +- OpenType `"kern"` feature enabled globally +- Negative letter-spacing throughout — even body text at -0.14px to -0.26px + +## 2. Color Palette & Roles + +### Primary +- **Pure Black** (`#000000`): All text, all solid buttons, all borders. The sole "color" of the interface. +- **Pure White** (`#ffffff`): All backgrounds, white buttons, text on dark surfaces. The other half of the binary. + +*Note: Figma's marketing site uses ONLY these two colors for its interface layer. All vibrant colors appear exclusively in product screenshots, hero gradients, and embedded content.* + +### Surface & Background +- **Pure White** (`#ffffff`): Primary page background and card surfaces. +- **Glass Black** (`rgba(0, 0, 0, 0.08)`): Subtle dark overlay for secondary circular buttons and glass effects. +- **Glass White** (`rgba(255, 255, 255, 0.16)`): Frosted glass overlay for buttons on dark/colored surfaces. + +### Gradient System +- **Hero Gradient**: A vibrant multi-stop gradient using electric green, bright yellow, deep purple, and hot pink. This gradient is the visual signature of the hero section — it represents the creative possibilities of the tool. +- **Product Section Gradients**: Individual product areas (Design, Dev Mode, Prototyping) may use distinct color themes in their showcases. + +## 3. Typography Rules + +### Font Family +- **Primary**: `figmaSans`, with fallbacks: `figmaSans Fallback, SF Pro Display, system-ui, helvetica` +- **Monospace / Labels**: `figmaMono`, with fallbacks: `figmaMono Fallback, SF Mono, menlo` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | figmaSans | 86px (5.38rem) | 400 | 1.00 (tight) | -1.72px | Maximum impact, extreme tracking | +| Section Heading | figmaSans | 64px (4rem) | 400 | 1.10 (tight) | -0.96px | Feature section titles | +| Sub-heading | figmaSans | 26px (1.63rem) | 540 | 1.35 | -0.26px | Emphasized section text | +| Sub-heading Light | figmaSans | 26px (1.63rem) | 340 | 1.35 | -0.26px | Light-weight section text | +| Feature Title | figmaSans | 24px (1.5rem) | 700 | 1.45 | normal | Bold card headings | +| Body Large | figmaSans | 20px (1.25rem) | 330–450 | 1.30–1.40 | -0.1px to -0.14px | Descriptions, intros | +| Body / Button | figmaSans | 16px (1rem) | 330–400 | 1.40–1.45 | -0.14px to normal | Standard body, nav, buttons | +| Body Light | figmaSans | 18px (1.13rem) | 320 | 1.45 | -0.26px to normal | Light-weight body text | +| Mono Label | figmaMono | 18px (1.13rem) | 400 | 1.30 (tight) | 0.54px | Uppercase section labels | +| Mono Small | figmaMono | 12px (0.75rem) | 400 | 1.00 (tight) | 0.6px | Uppercase tiny tags | + +### Principles +- **Variable font precision**: figmaSans uses weights that most systems never touch — 320, 330, 340, 450, 480, 540. This creates hierarchy through subtle weight differences rather than dramatic jumps. The difference between 330 and 340 is nearly imperceptible but structurally significant. +- **Light as the base**: Most body text uses 320–340 (lighter than typical 400 "regular"), creating an ethereal, airy reading experience that matches the design-tool aesthetic. +- **Kern everywhere**: Every text element enables OpenType `"kern"` feature — kerning is not optional, it's structural. +- **Negative tracking by default**: Even body text uses -0.1px to -0.26px letter-spacing, creating universally tight text. Display text compresses further to -0.96px and -1.72px. +- **Mono for structure**: figmaMono in uppercase with positive letter-spacing (0.54px–0.6px) creates technical signpost labels. + +## 4. Component Stylings + +### Buttons + +**Black Solid (Pill)** +- Background: Pure Black (`#000000`) +- Text: Pure White (`#ffffff`) +- Radius: circle (50%) for icon buttons +- Focus: dashed 2px outline +- Maximum emphasis + +**White Pill** +- Background: Pure White (`#ffffff`) +- Text: Pure Black (`#000000`) +- Padding: 8px 18px 10px (asymmetric vertical) +- Radius: pill (50px) +- Focus: dashed 2px outline +- Standard CTA on dark/colored surfaces + +**Glass Dark** +- Background: `rgba(0, 0, 0, 0.08)` (subtle dark overlay) +- Text: Pure Black +- Radius: circle (50%) +- Focus: dashed 2px outline +- Secondary action on light surfaces + +**Glass Light** +- Background: `rgba(255, 255, 255, 0.16)` (frosted glass) +- Text: Pure White +- Radius: circle (50%) +- Focus: dashed 2px outline +- Secondary action on dark/colored surfaces + +### Cards & Containers +- Background: Pure White +- Border: none or minimal +- Radius: 6px (small containers), 8px (images, cards, dialogs) +- Shadow: subtle to medium elevation effects +- Product screenshots as card content + +### Navigation +- Clean horizontal nav on white +- Logo: Figma wordmark in black +- Product tabs: pill-shaped (50px) tab navigation +- Links: black text, underline 1px decoration +- CTA: Black pill button +- Hover: text color via CSS variable + +### Distinctive Components + +**Product Tab Bar** +- Horizontal pill-shaped tabs (50px radius) +- Each tab represents a Figma product area (Design, Dev Mode, Prototyping, etc.) +- Active tab highlighted + +**Hero Gradient Section** +- Full-width vibrant multi-color gradient background +- White text overlay with 86px display heading +- Product screenshots floating within the gradient + +**Dashed Focus Indicators** +- All interactive elements use `dashed 2px` outline on focus +- References the selection handles in the Figma editor +- A meta-design choice connecting website and product + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 4.5px, 8px, 10px, 12px, 16px, 18px, 24px, 32px, 40px, 46px, 48px, 50px + +### Grid & Container +- Max container width: up to 1920px +- Hero: full-width gradient with centered content +- Product sections: alternating showcases +- Footer: dark full-width section +- Responsive from 559px to 1920px + +### Whitespace Philosophy +- **Gallery-like pacing**: Generous spacing lets each product section breathe as its own exhibit. +- **Color sections as visual breathing**: The gradient hero and product showcases provide chromatic relief between the monochrome interface sections. + +### Border Radius Scale +- Minimal (2px): Small link elements +- Subtle (6px): Small containers, dividers +- Comfortable (8px): Cards, images, dialogs +- Pill (50px): Tab buttons, CTAs +- Circle (50%): Icon buttons, circular elements + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page background, most text | +| Surface (Level 1) | White card on gradient/dark section | Cards, product showcases | +| Elevated (Level 2) | Subtle shadow | Floating cards, hover states | + +**Shadow Philosophy**: Figma uses shadows sparingly. The primary depth mechanisms are **background contrast** (white content on colorful/dark sections) and the inherent dimensionality of the product screenshots themselves. + +## 7. Do's and Don'ts + +### Do +- Use figmaSans with precise variable weights (320–540) — the granular weight control IS the design +- Keep the interface strictly black-and-white — color comes from product content only +- Use pill (50px) and circular (50%) geometry for all interactive elements +- Apply dashed 2px focus outlines — the signature accessibility pattern +- Enable `"kern"` feature on all text +- Use figmaMono in uppercase with positive letter-spacing for labels +- Apply negative letter-spacing throughout (-0.1px to -1.72px) + +### Don't +- Don't add interface colors — the monochrome palette is absolute +- Don't use standard font weights (400, 500, 600, 700) — use the variable font's unique stops (320, 330, 340, 450, 480, 540) +- Don't use sharp corners on buttons — pill and circular geometry only +- Don't use solid focus outlines — dashed is the signature +- Don't increase body font weight above 450 — the light-weight aesthetic is core +- Don't use positive letter-spacing on body text — it's always negative + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Small Mobile | <560px | Compact layout, stacked | +| Tablet | 560–768px | Minor adjustments | +| Small Desktop | 768–960px | 2-column layouts | +| Desktop | 960–1280px | Standard layout | +| Large Desktop | 1280–1440px | Expanded | +| Ultra-wide | 1440–1920px | Maximum width | + +### Collapsing Strategy +- Hero text: 86px → 64px → 48px +- Product tabs: horizontal scroll on mobile +- Feature sections: stacked single column +- Footer: multi-column → stacked + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Everything: "Pure Black (#000000)" and "Pure White (#ffffff)" +- Glass Dark: "rgba(0, 0, 0, 0.08)" +- Glass Light: "rgba(255, 255, 255, 0.16)" + +### Example Component Prompts +- "Create a hero on a vibrant multi-color gradient (green, yellow, purple, pink). Headline at 86px figmaSans weight 400, line-height 1.0, letter-spacing -1.72px. White text. White pill CTA button (50px radius, 8px 18px padding)." +- "Design a product tab bar with pill-shaped buttons (50px radius). Active: Black bg, white text. Inactive: transparent, black text. figmaSans at 20px weight 480." +- "Build a section label: figmaMono 18px, uppercase, letter-spacing 0.54px, black text. Kern enabled." +- "Create body text at 20px figmaSans weight 330, line-height 1.40, letter-spacing -0.14px. Pure Black on white." + +### Iteration Guide +1. Use variable font weight stops precisely: 320, 330, 340, 450, 480, 540, 700 +2. Interface is always black + white — never add colors to chrome +3. Dashed focus outlines, not solid +4. Letter-spacing is always negative on body, always positive on mono labels +5. Pill (50px) for buttons/tabs, circle (50%) for icon buttons diff --git a/skills/creative/popular-web-designs/templates/framer.md b/skills/creative/popular-web-designs/templates/framer.md new file mode 100644 index 000000000000..cbef2b6eb924 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/framer.md @@ -0,0 +1,259 @@ +# Design System: Framer + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `Azeret Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Azeret Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Framer's website is a cinematic, tool-obsessed dark canvas that radiates the confidence of a design tool built by designers who worship craft. The entire experience is drenched in pure black — not a warm charcoal or a cozy dark gray, but an absolute void (`#000000`) that makes every element, every screenshot, every typographic flourish feel like it's floating in deep space. This is a website that treats its own product UI as the hero art, embedding full-fidelity screenshots and interactive demos directly into the narrative flow. + +The typography is the signature move: GT Walsheim with aggressively tight letter-spacing (as extreme as -5.5px on 110px display text) creates headlines that feel compressed, kinetic, almost spring-loaded — like words under pressure that might expand at any moment. The transition to Inter for body text is seamless, with extensive OpenType feature usage (`cv01`, `cv05`, `cv09`, `cv11`, `ss03`, `ss07`) that gives even small text a refined, custom feel. Framer Blue (`#0099ff`) is deployed sparingly but decisively — as link color, border accents, and subtle ring shadows — creating a cold, electric throughline against the warm-less black. + +The overall effect is a nightclub for web designers: dark, precise, seductive, and unapologetically product-forward. Every section exists to showcase what the tool can do, with the website itself serving as proof of concept. + +**Key Characteristics:** +- Pure black (`#000000`) void canvas — absolute dark, not warm or gray-tinted +- GT Walsheim display font with extreme negative letter-spacing (-5.5px at 110px) +- Framer Blue (`#0099ff`) as the sole accent color — cold, electric, precise +- Pill-shaped buttons (40px–100px radius) — no sharp corners on interactive elements +- Product screenshots as hero art — the tool IS the marketing +- Frosted glass button variants using `rgba(255, 255, 255, 0.1)` on dark surfaces +- Extensive OpenType feature usage across Inter for refined micro-typography + +## 2. Color Palette & Roles + +### Primary +- **Pure Black** (`#000000`): Primary background, the void canvas that defines Framer's dark-first identity +- **Pure White** (`#ffffff`): Primary text color on dark surfaces, button text on accent backgrounds +- **Framer Blue** (`#0099ff`): Primary accent color — links, borders, ring shadows, interactive highlights + +### Secondary & Accent +- **Muted Silver** (`#a6a6a6`): Secondary text, subdued labels, dimmed descriptions on dark surfaces +- **Near Black** (`#090909`): Elevated dark surface, shadow ring color for subtle depth separation + +### Surface & Background +- **Void Black** (`#000000`): Page background, primary canvas +- **Frosted White** (`rgba(255, 255, 255, 0.1)`): Translucent button backgrounds, glass-effect surfaces on dark +- **Subtle White** (`rgba(255, 255, 255, 0.5)`): Slightly more opaque frosted elements for hover states + +### Neutrals & Text +- **Pure White** (`#ffffff`): Heading text, high-emphasis body text +- **Muted Silver** (`#a6a6a6`): Body text, descriptions, secondary information +- **Ghost White** (`rgba(255, 255, 255, 0.6)`): Tertiary text, placeholders on dark surfaces + +### Semantic & Accent +- **Framer Blue** (`#0099ff`): Links, interactive borders, focus rings +- **Blue Glow** (`rgba(0, 153, 255, 0.15)`): Focus ring shadow, subtle blue halo around interactive elements +- **Default Link Blue** (`#0000ee`): Standard browser link color (used sparingly in content areas) + +### Gradient System +- No prominent gradient usage — Framer relies on pure flat black surfaces with occasional blue-tinted glows for depth +- Subtle radial glow effects behind product screenshots using Framer Blue at very low opacity + +## 3. Typography Rules + +### Font Family +- **Display**: `GT Walsheim Framer Medium` / `GT Walsheim Medium` — custom geometric sans-serif, weight 500. Fallbacks: `GT Walsheim Framer Medium Placeholder`, system sans-serif +- **Body/UI**: `Inter Variable` / `Inter` — variable sans-serif with extensive OpenType features. Fallbacks: `Inter Placeholder`, `-apple-system`, `system-ui` +- **Accent**: `Mona Sans` — GitHub's open-source font, used for select elements at ultra-light weight (100) +- **Monospace**: `Azeret Mono` — companion mono for code and technical labels +- **Rounded**: `Open Runde` — small rounded companion font for micro-labels + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | GT Walsheim Framer Medium | 110px | 500 | 0.85 | -5.5px | Extreme negative tracking, compressed impact | +| Section Display | GT Walsheim Medium | 85px | 500 | 0.95 | -4.25px | OpenType: ss02, tnum | +| Section Heading | GT Walsheim Medium | 62px | 500 | 1.00 | -3.1px | OpenType: ss02 | +| Feature Heading | GT Walsheim Medium | 32px | 500 | 1.13 | -1px | Tightest of the smaller headings | +| Accent Display | Mona Sans | 61.5px | 100 | 1.00 | -3.1px | Ultra-light weight, ethereal | +| Card Title | Inter Variable | 24px | 400 | 1.30 | -0.01px | OpenType: cv01, cv05, cv09, cv11, ss03, ss07 | +| Feature Title | Inter | 22px | 700 | 1.20 | -0.8px | OpenType: cv05 | +| Sub-heading | Inter | 20px | 600 | 1.20 | -0.8px | OpenType: cv01, cv09 | +| Body Large | Inter Variable | 18px | 400 | 1.30 | -0.01px | OpenType: cv01, cv05, cv09, cv11, ss03, ss07 | +| Body | Inter Variable | 15px | 400 | 1.30 | -0.01px | OpenType: cv11 | +| Nav/UI | Inter Variable | 15px | 400 | 1.00 | -0.15px | OpenType: cv06, cv11, dlig, ss03 | +| Body Readable | Inter Framer Regular | 14px | 400 | 1.60 | normal | Long-form body text | +| Caption | Inter Variable | 14px | 400 | 1.40 | normal | OpenType: cv01, cv06, cv09, cv11, ss03, ss07 | +| Label | Inter | 13px | 500 | 1.60 | normal | OpenType: cv06, cv11, ss03 | +| Small Caption | Inter Variable | 12px | 400 | 1.40 | normal | OpenType: cv01, cv06, cv09, cv11, ss03, ss07 | +| Micro Code | Azeret Mono | 10.4px | 400 | 1.60 | normal | OpenType: cv06, cv11, ss03 | +| Badge | Open Runde | 9px | 600 | 1.11 | normal | OpenType: cv01, cv09 | +| Micro Uppercase | Inter Variable | 7px | 400 | 1.00 | 0.21px | uppercase transform | + +### Principles +- **Compression as personality**: GT Walsheim's extreme negative letter-spacing (-5.5px at 110px) is the defining typographic gesture — headlines feel spring-loaded, urgent, almost breathless +- **OpenType maximalism**: Inter is deployed with 6+ OpenType features simultaneously (`cv01`, `cv05`, `cv09`, `cv11`, `ss03`, `ss07`), creating a subtly custom feel even at body sizes +- **Weight restraint on display**: All GT Walsheim usage is weight 500 (medium) — never bold, never regular. This creates a confident-but-not-aggressive display tone +- **Ultra-tight line heights**: Display text at 0.85 line-height means letters nearly overlap vertically — intentional density that rewards reading at arm's length + +## 4. Component Stylings + +### Buttons +- **Frosted Pill**: `rgba(255, 255, 255, 0.1)` background, black text (`#000000`), pill shape (40px radius). The glass-effect button that lives on dark surfaces — translucent, ambient, subtle +- **Solid White Pill**: `rgb(255, 255, 255)` background, black text (`#000000`), full pill shape (100px radius), padding `10px 15px`. The primary CTA — clean, high-contrast on dark, unmissable +- **Ghost**: No visible background, white text, relies on text styling alone. Hover reveals subtle frosted background +- **Transition**: Scale-based animations (matrix transform with 0.85 scale factor), opacity transitions for reveal effects + +### Cards & Containers +- **Dark Surface Card**: Black or near-black (`#090909`) background, `rgba(0, 153, 255, 0.15) 0px 0px 0px 1px` blue ring shadow border, rounded corners (10px–15px radius) +- **Elevated Card**: Multi-layer shadow — `rgba(255, 255, 255, 0.1) 0px 0.5px 0px 0.5px` (subtle top highlight) + `rgba(0, 0, 0, 0.25) 0px 10px 30px` (deep ambient shadow) +- **Product Screenshots**: Full-width or padded within dark containers, 8px–12px border-radius for software UI previews +- **Hover**: Subtle glow increase on Framer Blue ring shadow, or brightness shift on frosted surfaces + +### Inputs & Forms +- Minimal form presence on the marketing site +- Input fields follow dark theme: dark background, subtle border, white text +- Focus state: Framer Blue (`#0099ff`) ring border, `1px solid #0099ff` +- Placeholder text in `rgba(255, 255, 255, 0.4)` + +### Navigation +- **Dark floating nav bar**: Black background with frosted glass effect, white text links +- **Nav links**: Inter at 15px, weight 400, white text with subtle hover opacity change +- **CTA button**: Pill-shaped, white or frosted, positioned at right end of nav +- **Mobile**: Collapses to hamburger menu, maintains dark theme +- **Sticky behavior**: Nav remains fixed at top on scroll + +### Image Treatment +- **Product screenshots as hero art**: Full-width embedded UI screenshots with rounded corners (8px–12px) +- **Dark-on-dark composition**: Screenshots placed on black backgrounds with subtle shadow for depth separation +- **16:9 and custom aspect ratios**: Product demos fill their containers +- **No decorative imagery**: All images are functional — showing the tool, the output, or the workflow + +### Trust & Social Proof +- Customer logos and testimonials in muted gray on dark surfaces +- Minimal ornamentation — the product screenshots serve as the trust signal + +## 5. Layout Principles + +### Spacing System +- **Base unit**: 8px +- **Scale**: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 15px, 20px, 30px, 35px +- **Section padding**: Large vertical spacing (80px–120px between sections) +- **Card padding**: 15px–30px internal padding +- **Component gaps**: 8px–20px between related elements + +### Grid & Container +- **Max width**: ~1200px container, centered +- **Column patterns**: Full-width hero, 2-column feature sections, single-column product showcases +- **Asymmetric layouts**: Feature sections often pair text (40%) with screenshot (60%) + +### Whitespace Philosophy +- **Breathe through darkness**: Generous vertical spacing between sections — the black background means whitespace manifests as void, creating dramatic pauses between content blocks +- **Dense within, spacious between**: Individual components are tightly composed (tight line-heights, compressed text) but float in generous surrounding space +- **Product-first density**: Screenshot areas are allowed to be dense and information-rich, contrasting with the sparse marketing text + +### Border Radius Scale +- **1px**: Micro-elements, nearly squared precision edges +- **5px–7px**: Small UI elements, image thumbnails — subtly softened +- **8px**: Standard component radius — code blocks, buttons, interactive elements +- **10px–12px**: Cards, product screenshots — comfortably rounded +- **15px–20px**: Large containers, feature cards — generously rounded +- **30px–40px**: Navigation pills, pagination — noticeably rounded +- **100px**: Full pill shape — primary CTAs, tag elements + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Level 0 (Flat) | No shadow, pure black surface | Page background, empty areas | +| Level 1 (Ring) | `rgba(0, 153, 255, 0.15) 0px 0px 0px 1px` | Card borders, interactive element outlines — Framer Blue glow ring | +| Level 2 (Contained) | `rgb(9, 9, 9) 0px 0px 0px 2px` | Near-black ring for subtle containment on dark surfaces | +| Level 3 (Floating) | `rgba(255, 255, 255, 0.1) 0px 0.5px 0px 0.5px, rgba(0, 0, 0, 0.25) 0px 10px 30px` | Elevated cards, floating elements — subtle white top-edge highlight + deep ambient shadow | + +### Shadow Philosophy +Framer's elevation system is inverted from traditional light-theme designs. Instead of darker shadows on light backgrounds, Framer uses: +- **Blue-tinted ring shadows** at very low opacity (0.15) for containment — a signature move that subtly brands every bordered element +- **White edge highlights** (0.5px) on the top edge of elevated elements — simulating light hitting the top surface +- **Deep ambient shadows** for true floating elements — `rgba(0, 0, 0, 0.25)` at large spread (30px) + +### Decorative Depth +- **Blue glow auras**: Subtle Framer Blue (`#0099ff`) radial gradients behind key interactive areas +- **No background blur/glassmorphism**: Despite the frosted button effect, there's no heavy glass blur usage — the translucency is achieved through simple rgba opacity + +## 7. Do's and Don'ts + +### Do +- Use pure black (`#000000`) as the primary background — not dark gray, not charcoal +- Apply extreme negative letter-spacing on GT Walsheim display text (-3px to -5.5px) +- Keep all buttons pill-shaped (40px+ radius) — never use squared or slightly-rounded buttons +- Use Framer Blue (`#0099ff`) exclusively for interactive accents — links, borders, focus states +- Deploy `rgba(255, 255, 255, 0.1)` for frosted glass surfaces on dark backgrounds +- Maintain GT Walsheim at weight 500 only — the medium weight IS the brand +- Use extensive OpenType features on Inter text (cv01, cv05, cv09, cv11, ss03, ss07) +- Let product screenshots be the visual centerpiece — the tool markets itself +- Apply blue ring shadows (`rgba(0, 153, 255, 0.15) 0px 0px 0px 1px`) for card containment + +### Don't +- Use warm dark backgrounds (no `#1a1a1a`, `#2d2d2d`, or brownish blacks) +- Apply bold (700+) weight to GT Walsheim display text — medium 500 only +- Introduce additional accent colors beyond Framer Blue — this is a one-accent-color system +- Use large border-radius on non-interactive elements (cards use 10px–15px, only buttons get 40px+) +- Add decorative imagery, illustrations, or icons — the product IS the illustration +- Use positive letter-spacing on headlines — everything is compressed, negative tracking +- Create heavy drop shadows — depth is communicated through subtle rings and minimal ambients +- Place light/white backgrounds behind content sections — the void is sacred +- Use serif or display-weight fonts — the system is geometric sans-serif only + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <809px | Single column, stacked feature sections, reduced hero text (62px→40px), hamburger nav | +| Tablet | 809px–1199px | 2-column features begin, nav links partially visible, screenshots scale down | +| Desktop | >1199px | Full layout, expanded nav with all links + CTA, 110px display hero, side-by-side features | + +### Touch Targets +- Pill buttons: minimum 40px height with 10px vertical padding — exceeds 44px WCAG minimum +- Nav links: 15px text with generous padding for touch accessibility +- Mobile CTA buttons: Full-width pills on mobile for easy thumb reach + +### Collapsing Strategy +- **Navigation**: Full horizontal nav → hamburger menu at mobile breakpoint +- **Hero text**: 110px display → 85px → 62px → ~40px across breakpoints, maintaining extreme negative tracking proportionally +- **Feature sections**: Side-by-side (text + screenshot) → stacked vertically on mobile +- **Product screenshots**: Scale responsively within containers, maintaining aspect ratios +- **Section spacing**: Reduces proportionally — 120px desktop → 60px mobile + +### Image Behavior +- Product screenshots are responsive, scaling within their container boundaries +- No art direction changes — same crops across breakpoints +- Dark background ensures screenshots maintain visual impact at any size +- Screenshots lazy-load as user scrolls into view + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Background: Void Black (`#000000`) +- Primary Text: Pure White (`#ffffff`) +- Accent/CTA: Framer Blue (`#0099ff`) +- Secondary Text: Muted Silver (`#a6a6a6`) +- Frosted Surface: Translucent White (`rgba(255, 255, 255, 0.1)`) +- Elevation Ring: Blue Glow (`rgba(0, 153, 255, 0.15)`) + +### Example Component Prompts +- "Create a hero section on pure black background with 110px GT Walsheim heading in white, letter-spacing -5.5px, line-height 0.85, and a pill-shaped white CTA button (100px radius) with black text" +- "Design a feature card on black background with a 1px Framer Blue ring shadow border (rgba(0,153,255,0.15)), 12px border-radius, white heading in Inter at 22px weight 700, and muted silver (a6a6a6) body text" +- "Build a navigation bar with black background, white Inter text links at 15px, and a frosted pill button (rgba(255,255,255,0.1) background, 40px radius) as the CTA" +- "Create a product showcase section with a full-width screenshot embedded on black, 10px border-radius, subtle multi-layer shadow (white 0.5px top highlight + rgba(0,0,0,0.25) 30px ambient)" +- "Design a pricing card using pure black surface, Framer Blue (#0099ff) accent for the selected plan border, white text hierarchy (24px Inter bold heading, 14px regular body), and a solid white pill CTA button" + +### Iteration Guide +When refining existing screens generated with this design system: +1. Focus on ONE component at a time — the dark canvas makes each element precious +2. Always verify letter-spacing on GT Walsheim headings — the extreme negative tracking is non-negotiable +3. Check that Framer Blue appears ONLY on interactive elements — never as decorative background or text color for non-links +4. Ensure all buttons are pill-shaped — any squared corner immediately breaks the Framer aesthetic +5. Test frosted glass surfaces by checking they have exactly `rgba(255, 255, 255, 0.1)` — too opaque looks like a bug, too transparent disappears diff --git a/skills/creative/popular-web-designs/templates/hashicorp.md b/skills/creative/popular-web-designs/templates/hashicorp.md new file mode 100644 index 000000000000..8b9e5533fd7f --- /dev/null +++ b/skills/creative/popular-web-designs/templates/hashicorp.md @@ -0,0 +1,291 @@ +# Design System: HashiCorp + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +HashiCorp's website is enterprise infrastructure made tangible — a design system that must communicate the complexity of cloud infrastructure management while remaining approachable. The visual language splits between two modes: a clean white light-mode for informational sections and a dramatic dark-mode (`#15181e`, `#0d0e12`) for hero areas and product showcases, creating a day/night duality that mirrors the "build in light, deploy in dark" developer workflow. + +The typography is anchored by a custom brand font (HashiCorp Sans, loaded as `__hashicorpSans_96f0ca`) that carries substantial weight — literally. Headings use 600–700 weights with tight line-heights (1.17–1.19), creating dense, authoritative text blocks that communicate enterprise confidence. The hero headline at 82px weight 600 with OpenType `"kern"` enabled is not decorative — it's infrastructure-grade typography. + +What distinguishes HashiCorp is its multi-product color system. Each product in the portfolio has its own brand color — Terraform purple (`#7b42bc`), Vault yellow (`#ffcf25`), Waypoint teal (`#14c6cb`), Vagrant blue (`#1868f2`) — and these colors appear throughout as accent tokens via a CSS custom property system (`--mds-color-*`). This creates a design system within a design system: the parent brand is black-and-white with blue accents, while each child product injects its own chromatic identity. + +The component system uses the `mds` (Markdown Design System) prefix, indicating a systematic, token-driven approach where colors, spacing, and states are all managed through CSS variables. Shadows are remarkably subtle — dual-layer micro-shadows using `rgba(97, 104, 117, 0.05)` that are nearly invisible but provide just enough depth to separate interactive surfaces from the background. + +**Key Characteristics:** +- Dual-mode: clean white sections + dramatic dark (`#15181e`) hero/product areas +- Custom HashiCorp Sans font with 600–700 weights and `"kern"` feature +- Multi-product color system via `--mds-color-*` CSS custom properties +- Product brand colors: Terraform purple, Vault yellow, Waypoint teal, Vagrant blue +- Uppercase letter-spaced captions (13px, weight 600, 1.3px letter-spacing) +- Micro-shadows: dual-layer at 0.05 opacity — depth through whisper, not shout +- Token-driven `mds` component system with semantic variable names +- Tight border radius: 2px–8px, nothing pill-shaped or circular +- System-ui fallback stack for secondary text + +## 2. Color Palette & Roles + +### Brand Primary +- **Black** (`#000000`): Primary brand color, text on light surfaces, `--mds-color-hcp-brand` +- **Dark Charcoal** (`#15181e`): Dark mode backgrounds, hero sections +- **Near Black** (`#0d0e12`): Deepest dark mode surface, form inputs on dark + +### Neutral Scale +- **Light Gray** (`#f1f2f3`): Light backgrounds, subtle surfaces +- **Mid Gray** (`#d5d7db`): Borders, button text on dark +- **Cool Gray** (`#b2b6bd`): Border accents (at 0.1–0.4 opacity) +- **Dark Gray** (`#656a76`): Helper text, secondary labels, `--mds-form-helper-text-color` +- **Charcoal** (`#3b3d45`): Secondary text on light, button borders +- **Near White** (`#efeff1`): Primary text on dark surfaces + +### Product Brand Colors +- **Terraform Purple** (`#7b42bc`): `--mds-color-terraform-button-background` +- **Vault Yellow** (`#ffcf25`): `--mds-color-vault-button-background` +- **Waypoint Teal** (`#14c6cb`): `--mds-color-waypoint-button-background-focus` +- **Waypoint Teal Hover** (`#12b6bb`): `--mds-color-waypoint-button-background-hover` +- **Vagrant Blue** (`#1868f2`): `--mds-color-vagrant-brand` +- **Purple Accent** (`#911ced`): `--mds-color-palette-purple-300` +- **Visited Purple** (`#a737ff`): `--mds-color-foreground-action-visited` + +### Semantic Colors +- **Action Blue** (`#1060ff`): Primary action links on dark +- **Link Blue** (`#2264d6`): Primary links on light +- **Bright Blue** (`#2b89ff`): Active links, hover accent +- **Amber** (`#bb5a00`): `--mds-color-palette-amber-200`, warning states +- **Amber Light** (`#fbeabf`): `--mds-color-palette-amber-100`, warning backgrounds +- **Vault Faint Yellow** (`#fff9cf`): `--mds-color-vault-radar-gradient-faint-stop` +- **Orange** (`#a9722e`): `--mds-color-unified-core-orange-6` +- **Red** (`#731e25`): `--mds-color-unified-core-red-7`, error states +- **Navy** (`#101a59`): `--mds-color-unified-core-blue-7` + +### Shadows +- **Micro Shadow** (`rgba(97, 104, 117, 0.05) 0px 1px 1px, rgba(97, 104, 117, 0.05) 0px 2px 2px`): Default card/button elevation +- **Focus Outline**: `3px solid var(--mds-color-focus-action-external)` — systematic focus ring + +## 3. Typography Rules + +### Font Families +- **Primary Brand**: `__hashicorpSans_96f0ca` (HashiCorp Sans), with fallback: `__hashicorpSans_Fallback_96f0ca` +- **System UI**: `system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | HashiCorp Sans | 82px (5.13rem) | 600 | 1.17 (tight) | normal | `"kern"` enabled | +| Section Heading | HashiCorp Sans | 52px (3.25rem) | 600 | 1.19 (tight) | normal | `"kern"` enabled | +| Feature Heading | HashiCorp Sans | 42px (2.63rem) | 700 | 1.19 (tight) | -0.42px | Negative tracking | +| Sub-heading | HashiCorp Sans | 34px (2.13rem) | 600–700 | 1.18 (tight) | normal | Feature blocks | +| Card Title | HashiCorp Sans | 26px (1.63rem) | 700 | 1.19 (tight) | normal | Card and panel headings | +| Small Title | HashiCorp Sans | 19px (1.19rem) | 700 | 1.21 (tight) | normal | Compact headings | +| Body Emphasis | HashiCorp Sans | 17px (1.06rem) | 600–700 | 1.18–1.35 | normal | Bold body text | +| Body Large | system-ui | 20px (1.25rem) | 400–600 | 1.50 | normal | Hero descriptions | +| Body | system-ui | 16px (1.00rem) | 400–500 | 1.63–1.69 (relaxed) | normal | Standard body text | +| Nav Link | system-ui | 15px (0.94rem) | 500 | 1.60 (relaxed) | normal | Navigation items | +| Small Body | system-ui | 14px (0.88rem) | 400–500 | 1.29–1.71 | normal | Secondary content | +| Caption | system-ui | 13px (0.81rem) | 400–500 | 1.23–1.69 | normal | Metadata, footer links | +| Uppercase Label | HashiCorp Sans | 13px (0.81rem) | 600 | 1.69 (relaxed) | 1.3px | `text-transform: uppercase` | + +### Principles +- **Brand/System split**: HashiCorp Sans for headings and brand-critical text; system-ui for body, navigation, and functional text. The brand font carries the weight, system-ui carries the words. +- **Kern always on**: All HashiCorp Sans text enables OpenType `"kern"` — letterfitting is non-negotiable. +- **Tight headings**: Every heading uses 1.17–1.21 line-height, creating dense, stacked text blocks that feel infrastructural — solid, load-bearing. +- **Relaxed body**: Body text uses 1.50–1.69 line-height (notably generous), creating comfortable reading rhythm beneath the dense headings. +- **Uppercase labels as wayfinding**: 13px uppercase with 1.3px letter-spacing serves as the systematic category/section marker — always HashiCorp Sans weight 600. + +## 4. Component Stylings + +### Buttons + +**Primary Dark** +- Background: `#15181e` +- Text: `#d5d7db` +- Padding: 9px 9px 9px 15px (asymmetric, more left padding) +- Radius: 5px +- Border: `1px solid rgba(178, 182, 189, 0.4)` +- Shadow: `rgba(97, 104, 117, 0.05) 0px 1px 1px, rgba(97, 104, 117, 0.05) 0px 2px 2px` +- Focus: `3px solid var(--mds-color-focus-action-external)` +- Hover: uses `--mds-color-surface-interactive` token + +**Secondary White** +- Background: `#ffffff` +- Text: `#3b3d45` +- Padding: 8px 12px +- Radius: 4px +- Hover: `--mds-color-surface-interactive` + low-shadow elevation +- Focus: `3px solid transparent` outline +- Clean, minimal appearance + +**Product-Colored Buttons** +- Terraform: background `#7b42bc` +- Vault: background `#ffcf25` (dark text) +- Waypoint: background `#14c6cb`, hover `#12b6bb` +- Each product button follows the same structural pattern but uses its brand color + +### Badges / Pills +- Background: `#42225b` (deep purple) +- Text: `#efeff1` +- Padding: 3px 7px +- Radius: 5px +- Border: `1px solid rgb(180, 87, 255)` +- Font: 16px + +### Inputs + +**Text Input (Dark Mode)** +- Background: `#0d0e12` +- Text: `#efeff1` +- Border: `1px solid rgb(97, 104, 117)` +- Padding: 11px +- Radius: 5px +- Focus: `3px solid var(--mds-color-focus-action-external)` outline + +**Checkbox** +- Background: `#0d0e12` +- Border: `1px solid rgb(97, 104, 117)` +- Radius: 3px + +### Links +- **Action Blue on Light**: `#2264d6`, hover → blue-600 variable, underline on hover +- **Action Blue on Dark**: `#1060ff` or `#2b89ff`, underline on hover +- **White on Dark**: `#ffffff`, transparent underline → visible underline on hover +- **Neutral on Light**: `#3b3d45`, transparent underline → visible underline on hover +- **Light on Dark**: `#efeff1`, similar hover pattern +- All links use `var(--wpl-blue-600)` as hover color + +### Cards & Containers +- Light mode: white background, micro-shadow elevation +- Dark mode: `#15181e` or darker surfaces +- Radius: 8px for cards and containers +- Product showcase cards with gradient borders or accent lighting + +### Navigation +- Clean horizontal nav with mega-menu dropdowns +- HashiCorp logo left-aligned +- system-ui 15px weight 500 for links +- Product categories organized by lifecycle management group +- "Get started" and "Contact us" CTAs in header +- Dark mode variant for hero sections + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 3px, 4px, 6px, 7px, 8px, 9px, 11px, 12px, 16px, 20px, 24px, 32px, 40px, 48px + +### Grid & Container +- Max content width: ~1150px (xl breakpoint) +- Full-width dark hero sections with contained content +- Card grids: 2–3 column layouts +- Generous horizontal padding at desktop scale + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <375px | Tight single column | +| Mobile | 375–480px | Standard mobile | +| Small Tablet | 480–600px | Minor adjustments | +| Tablet | 600–768px | 2-column grids begin | +| Small Desktop | 768–992px | Full nav visible | +| Desktop | 992–1120px | Standard layout | +| Large Desktop | 1120–1440px | Max-width content | +| Ultra-wide | >1440px | Centered, generous margins | + +### Whitespace Philosophy +- **Enterprise breathing room**: Generous vertical spacing between sections (48px–80px+) communicates stability and seriousness. +- **Dense headings, spacious body**: Tight line-height headings sit above relaxed body text, creating visual "weight at the top" of each section. +- **Dark as canvas**: Dark hero sections use extra vertical padding to let 3D illustrations and gradients breathe. + +### Border Radius Scale +- Minimal (2px): Links, small inline elements +- Subtle (3px): Checkboxes, small inputs +- Standard (4px): Secondary buttons +- Comfortable (5px): Primary buttons, badges, inputs +- Card (8px): Cards, containers, images + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Default surfaces, text blocks | +| Whisper (Level 1) | `rgba(97, 104, 117, 0.05) 0px 1px 1px, rgba(97, 104, 117, 0.05) 0px 2px 2px` | Cards, buttons, interactive surfaces | +| Focus (Level 2) | `3px solid var(--mds-color-focus-action-external)` outline | Focus rings — color-matched to context | + +**Shadow Philosophy**: HashiCorp uses arguably the subtlest shadow system in modern web design. The dual-layer shadows at 5% opacity are nearly invisible — they exist not to create visual depth but to signal interactivity. If you can see the shadow, it's too strong. This restraint communicates the enterprise value of stability — nothing floats, nothing is uncertain. + +## 7. Do's and Don'ts + +### Do +- Use HashiCorp Sans for headings and brand text, system-ui for body and UI text +- Enable `"kern"` on all HashiCorp Sans text +- Use product brand colors ONLY for their respective products (Terraform = purple, Vault = yellow, etc.) +- Apply uppercase labels at 13px weight 600 with 1.3px letter-spacing for section markers +- Keep shadows at the "whisper" level (0.05 opacity dual-layer) +- Use the `--mds-color-*` token system for consistent color application +- Maintain the tight-heading / relaxed-body rhythm (1.17–1.21 vs 1.50–1.69 line-heights) +- Use `3px solid` focus outlines for accessibility + +### Don't +- Don't use product brand colors outside their product context (no Terraform purple on Vault content) +- Don't increase shadow opacity above 0.1 — the whisper level is intentional +- Don't use pill-shaped buttons (>8px radius) — the sharp, minimal radius is structural +- Don't skip the `"kern"` feature on headings — the font requires it +- Don't use HashiCorp Sans for small body text — it's designed for 17px+ heading use +- Don't mix product colors in the same component — each product has one color +- Don't use pure black (`#000000`) for dark backgrounds — use `#15181e` or `#0d0e12` +- Don't forget the asymmetric button padding — 9px 9px 9px 15px is intentional + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <768px | Single column, hamburger nav, stacked CTAs | +| Tablet | 768–992px | 2-column grids, nav begins expanding | +| Desktop | 992–1150px | Full layout, mega-menu nav | +| Large | >1150px | Max-width centered, generous margins | + +### Collapsing Strategy +- Hero: 82px → 52px → 42px heading sizes +- Navigation: mega-menu → hamburger +- Product cards: 3-column → 2-column → stacked +- Dark sections maintain full-width but compress padding +- Buttons: inline → full-width stacked on mobile + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Light bg: `#ffffff`, `#f1f2f3` +- Dark bg: `#15181e`, `#0d0e12` +- Text light: `#000000`, `#3b3d45` +- Text dark: `#efeff1`, `#d5d7db` +- Links: `#2264d6` (light), `#1060ff` (dark), `#2b89ff` (active) +- Helper text: `#656a76` +- Borders: `rgba(178, 182, 189, 0.4)`, `rgb(97, 104, 117)` +- Focus: `3px solid` product-appropriate color + +### Example Component Prompts +- "Create a hero on dark background (#15181e). Headline at 82px HashiCorp Sans weight 600, line-height 1.17, kern enabled, white text. Sub-text at 20px system-ui weight 400, line-height 1.50, #d5d7db text. Two buttons: primary dark (#15181e, 5px radius, 9px 15px padding) and secondary white (#ffffff, 4px radius, 8px 12px padding)." +- "Design a product card: white background, 8px radius, dual-layer shadow at rgba(97,104,117,0.05). Title at 26px HashiCorp Sans weight 700, body at 16px system-ui weight 400 line-height 1.63." +- "Build an uppercase section label: 13px HashiCorp Sans weight 600, line-height 1.69, letter-spacing 1.3px, text-transform uppercase, #656a76 color." +- "Create a product-specific CTA button: Terraform → #7b42bc background, Vault → #ffcf25 with dark text, Waypoint → #14c6cb. All: 5px radius, 500 weight text, 16px system-ui." +- "Design a dark form: #0d0e12 input background, #efeff1 text, 1px solid rgb(97,104,117) border, 5px radius, 11px padding. Focus: 3px solid accent-color outline." + +### Iteration Guide +1. Always start with the mode decision: light (white) for informational, dark (#15181e) for hero/product +2. HashiCorp Sans for headings only (17px+), system-ui for everything else +3. Shadows are at whisper level (0.05 opacity) — if visible, reduce +4. Product colors are sacred — each product owns exactly one color +5. Focus rings are always 3px solid, color-matched to product context +6. Uppercase labels are the systematic wayfinding pattern — 13px, 600, 1.3px tracking diff --git a/skills/creative/popular-web-designs/templates/ibm.md b/skills/creative/popular-web-designs/templates/ibm.md new file mode 100644 index 000000000000..c2f62530a077 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/ibm.md @@ -0,0 +1,345 @@ +# Design System: IBM + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `IBM Plex Sans` | **Mono:** `IBM Plex Mono` +> - **Font stack (CSS):** `font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +IBM's website is the digital embodiment of enterprise authority built on the Carbon Design System — a design language so methodically structured it reads like an engineering specification rendered as a webpage. The page operates on a stark duality: a bright white (`#ffffff`) canvas with near-black (`#161616`) text, punctuated by a single, unwavering accent — IBM Blue 60 (`#0f62fe`). This isn't playful tech-startup minimalism; it's corporate precision distilled into pixels. Every element exists within Carbon's rigid 2x grid, every color maps to a semantic token, every spacing value snaps to the 8px base unit. + +The IBM Plex type family is the system's backbone. IBM Plex Sans at light weight (300) for display headlines creates an unexpectedly airy, almost delicate quality at large sizes — a deliberate counterpoint to IBM's corporate gravity. At body sizes, regular weight (400) with 0.16px letter-spacing on 14px captions introduces the meticulous micro-tracking that makes Carbon text feel engineered rather than designed. IBM Plex Mono serves code, data, and technical labels, completing the family trinity alongside the rarely-surfaced IBM Plex Serif. + +What defines IBM's visual identity beyond monochrome-plus-blue is the reliance on Carbon's component token system. Every interactive state maps to a CSS custom property prefixed with `--cds-` (Carbon Design System). Buttons don't have hardcoded colors; they reference `--cds-button-primary`, `--cds-button-primary-hover`, `--cds-button-primary-active`. This tokenized architecture means the entire visual layer is a thin skin over a deeply systematic foundation — the design equivalent of a well-typed API. + +**Key Characteristics:** +- IBM Plex Sans at weight 300 (Light) for display — corporate gravitas through typographic restraint +- IBM Plex Mono for code and technical content with consistent 0.16px letter-spacing at small sizes +- Single accent color: IBM Blue 60 (`#0f62fe`) — every interactive element, every CTA, every link +- Carbon token system (`--cds-*`) driving all semantic colors, enabling theme-switching at the variable level +- 8px spacing grid with strict adherence — no arbitrary values, everything aligns +- Flat, borderless cards on `#f4f4f4` Gray 10 surface — depth through background-color layering, not shadows +- Bottom-border inputs (not boxed) — the signature Carbon form pattern +- 0px border-radius on primary buttons — unapologetically rectangular, no softening + +## 2. Color Palette & Roles + +### Primary +- **IBM Blue 60** (`#0f62fe`): The singular interactive color. Primary buttons, links, focus states, active indicators. This is the only chromatic hue in the core UI palette. +- **White** (`#ffffff`): Page background, card surfaces, button text on blue, `--cds-background`. +- **Gray 100** (`#161616`): Primary text, headings, dark surface backgrounds, nav bar, footer. `--cds-text-primary`. + +### Neutral Scale (Gray Family) +- **Gray 100** (`#161616`): Primary text, headings, dark UI chrome, footer background. +- **Gray 90** (`#262626`): Secondary dark surfaces, hover states on dark backgrounds. +- **Gray 80** (`#393939`): Tertiary dark, active states. +- **Gray 70** (`#525252`): Secondary text, helper text, descriptions. `--cds-text-secondary`. +- **Gray 60** (`#6f6f6f`): Placeholder text, disabled text. +- **Gray 50** (`#8d8d8d`): Disabled icons, muted labels. +- **Gray 30** (`#c6c6c6`): Borders, divider lines, input bottom-borders. `--cds-border-subtle`. +- **Gray 20** (`#e0e0e0`): Subtle borders, card outlines. +- **Gray 10** (`#f4f4f4`): Secondary surface background, card fills, alternating rows. `--cds-layer-01`. +- **Gray 10 Hover** (`#e8e8e8`): Hover state for Gray 10 surfaces. + +### Interactive +- **Blue 60** (`#0f62fe`): Primary interactive — buttons, links, focus. `--cds-link-primary`, `--cds-button-primary`. +- **Blue 70** (`#0043ce`): Link hover state. `--cds-link-primary-hover`. +- **Blue 80** (`#002d9c`): Active/pressed state for blue elements. +- **Blue 10** (`#edf5ff`): Blue tint surface, selected row background. +- **Focus Blue** (`#0f62fe`): `--cds-focus` — 2px inset border on focused elements. +- **Focus Inset** (`#ffffff`): `--cds-focus-inset` — white inner ring for focus on dark backgrounds. + +### Support & Status +- **Red 60** (`#da1e28`): Error, danger. `--cds-support-error`. +- **Green 50** (`#24a148`): Success. `--cds-support-success`. +- **Yellow 30** (`#f1c21b`): Warning. `--cds-support-warning`. +- **Blue 60** (`#0f62fe`): Informational. `--cds-support-info`. + +### Dark Theme (Gray 100 Theme) +- **Background**: Gray 100 (`#161616`). `--cds-background`. +- **Layer 01**: Gray 90 (`#262626`). Card and container surfaces. +- **Layer 02**: Gray 80 (`#393939`). Elevated surfaces. +- **Text Primary**: Gray 10 (`#f4f4f4`). `--cds-text-primary`. +- **Text Secondary**: Gray 30 (`#c6c6c6`). `--cds-text-secondary`. +- **Border Subtle**: Gray 80 (`#393939`). `--cds-border-subtle`. +- **Interactive**: Blue 40 (`#78a9ff`). Links and interactive elements shift lighter for contrast. + +## 3. Typography Rules + +### Font Family +- **Primary**: `IBM Plex Sans`, with fallbacks: `Helvetica Neue, Arial, sans-serif` +- **Monospace**: `IBM Plex Mono`, with fallbacks: `Menlo, Courier, monospace` +- **Serif** (limited use): `IBM Plex Serif`, for editorial/expressive contexts +- **Icon Font**: `ibm_icons` — proprietary icon glyphs at 20px + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display 01 | IBM Plex Sans | 60px (3.75rem) | 300 (Light) | 1.17 (70px) | 0 | Maximum impact, light weight for elegance | +| Display 02 | IBM Plex Sans | 48px (3.00rem) | 300 (Light) | 1.17 (56px) | 0 | Secondary hero, responsive fallback | +| Heading 01 | IBM Plex Sans | 42px (2.63rem) | 300 (Light) | 1.19 (50px) | 0 | Expressive heading | +| Heading 02 | IBM Plex Sans | 32px (2.00rem) | 400 (Regular) | 1.25 (40px) | 0 | Section headings | +| Heading 03 | IBM Plex Sans | 24px (1.50rem) | 400 (Regular) | 1.33 (32px) | 0 | Sub-section titles | +| Heading 04 | IBM Plex Sans | 20px (1.25rem) | 600 (Semibold) | 1.40 (28px) | 0 | Card titles, feature headers | +| Heading 05 | IBM Plex Sans | 20px (1.25rem) | 400 (Regular) | 1.40 (28px) | 0 | Lighter card headings | +| Body Long 01 | IBM Plex Sans | 16px (1.00rem) | 400 (Regular) | 1.50 (24px) | 0 | Standard reading text | +| Body Long 02 | IBM Plex Sans | 16px (1.00rem) | 600 (Semibold) | 1.50 (24px) | 0 | Emphasized body, labels | +| Body Short 01 | IBM Plex Sans | 14px (0.88rem) | 400 (Regular) | 1.29 (18px) | 0.16px | Compact body, captions | +| Body Short 02 | IBM Plex Sans | 14px (0.88rem) | 600 (Semibold) | 1.29 (18px) | 0.16px | Bold captions, nav items | +| Caption 01 | IBM Plex Sans | 12px (0.75rem) | 400 (Regular) | 1.33 (16px) | 0.32px | Metadata, timestamps | +| Code 01 | IBM Plex Mono | 14px (0.88rem) | 400 (Regular) | 1.43 (20px) | 0.16px | Inline code, terminal | +| Code 02 | IBM Plex Mono | 16px (1.00rem) | 400 (Regular) | 1.50 (24px) | 0 | Code blocks | +| Mono Display | IBM Plex Mono | 42px (2.63rem) | 400 (Regular) | 1.19 (50px) | 0 | Hero mono decorative | + +### Principles +- **Light weight at display sizes**: Carbon's expressive type set uses weight 300 (Light) at 42px+. This creates a distinctive tension — the content speaks with corporate authority while the letterforms whisper with typographic lightness. +- **Micro-tracking at small sizes**: 0.16px letter-spacing at 14px and 0.32px at 12px. These seemingly negligible values are Carbon's secret weapon for readability at compact sizes — they open up the tight IBM Plex letterforms just enough. +- **Three functional weights**: 300 (display/expressive), 400 (body/reading), 600 (emphasis/UI labels). Weight 700 is intentionally absent from the production type scale. +- **Productive vs. Expressive**: Productive sets use tighter line-heights (1.29) for dense UI. Expressive sets breathe more (1.40-1.50) for marketing and editorial content. + +## 4. Component Stylings + +### Buttons + +**Primary Button (Blue)** +- Background: `#0f62fe` (Blue 60) → `--cds-button-primary` +- Text: `#ffffff` (White) +- Padding: 14px 63px 14px 15px (asymmetric — room for trailing icon) +- Border: 1px solid transparent +- Border-radius: 0px (sharp rectangle — the Carbon signature) +- Height: 48px (default), 40px (compact), 64px (expressive) +- Hover: `#0353e9` (Blue 60 Hover) → `--cds-button-primary-hover` +- Active: `#002d9c` (Blue 80) → `--cds-button-primary-active` +- Focus: `2px solid #0f62fe` inset + `1px solid #ffffff` inner + +**Secondary Button (Gray)** +- Background: `#393939` (Gray 80) +- Text: `#ffffff` +- Hover: `#4c4c4c` (Gray 70) +- Active: `#6f6f6f` (Gray 60) +- Same padding/radius as primary + +**Tertiary Button (Ghost Blue)** +- Background: transparent +- Text: `#0f62fe` (Blue 60) +- Border: 1px solid `#0f62fe` +- Hover: `#0353e9` text + Blue 10 background tint +- Border-radius: 0px + +**Ghost Button** +- Background: transparent +- Text: `#0f62fe` (Blue 60) +- Padding: 14px 16px +- Border: none +- Hover: `#e8e8e8` background tint + +**Danger Button** +- Background: `#da1e28` (Red 60) +- Text: `#ffffff` +- Hover: `#b81921` (Red 70) + +### Cards & Containers +- Background: `#ffffff` on white theme, `#f4f4f4` (Gray 10) for elevated cards +- Border: none (flat design — no border or shadow on most cards) +- Border-radius: 0px (matching the rectangular button aesthetic) +- Hover: background shifts to `#e8e8e8` (Gray 10 Hover) for clickable cards +- Content padding: 16px +- Separation: background-color layering (white → gray 10 → white) rather than shadows + +### Inputs & Forms +- Background: `#f4f4f4` (Gray 10) — `--cds-field` +- Text: `#161616` (Gray 100) +- Padding: 0px 16px (horizontal only) +- Height: 40px (default), 48px (large) +- Border: none on sides/top — `2px solid transparent` bottom +- Bottom-border active: `2px solid #161616` (Gray 100) +- Focus: `2px solid #0f62fe` (Blue 60) bottom-border — `--cds-focus` +- Error: `2px solid #da1e28` (Red 60) bottom-border +- Label: 12px IBM Plex Sans, 0.32px letter-spacing, Gray 70 +- Helper text: 12px, Gray 60 +- Placeholder: Gray 60 (`#6f6f6f`) +- Border-radius: 0px (top) — inputs are sharp-cornered + +### Navigation +- Background: `#161616` (Gray 100) — full-width dark masthead +- Height: 48px +- Logo: IBM 8-bar logo, white on dark, left-aligned +- Links: 14px IBM Plex Sans, weight 400, `#c6c6c6` (Gray 30) default +- Link hover: `#ffffff` text +- Active link: `#ffffff` with bottom-border indicator +- Platform switcher: left-aligned horizontal tabs +- Search: icon-triggered slide-out search field +- Mobile: hamburger with left-sliding panel + +### Links +- Default: `#0f62fe` (Blue 60) with no underline +- Hover: `#0043ce` (Blue 70) with underline +- Visited: remains Blue 60 (no visited state change) +- Inline links: underlined by default in body copy + +### Distinctive Components + +**Content Block (Hero/Feature)** +- Full-width alternating white/gray-10 background bands +- Headline left-aligned with 60px or 48px display type +- CTA as blue primary button with arrow icon +- Image/illustration right-aligned or below on mobile + +**Tile (Clickable Card)** +- Background: `#f4f4f4` or `#ffffff` +- Full-width bottom-border or background-shift hover +- Arrow icon bottom-right on hover +- No shadow — flatness is the identity + +**Tag / Label** +- Background: contextual color at 10% opacity (e.g., Blue 10, Red 10) +- Text: corresponding 60-grade color +- Padding: 4px 8px +- Border-radius: 24px (pill — exception to the 0px rule) +- Font: 12px weight 400 + +**Notification Banner** +- Full-width bar, typically Blue 60 or Gray 100 background +- White text, 14px +- Close/dismiss icon right-aligned + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px (Carbon 2x grid) +- Component spacing scale: 2px, 4px, 8px, 12px, 16px, 24px, 32px, 40px, 48px +- Layout spacing scale: 16px, 24px, 32px, 48px, 64px, 80px, 96px, 160px +- Mini unit: 8px (smallest usable spacing) +- Padding within components: typically 16px +- Gap between cards/tiles: 1px (hairline) or 16px (standard) + +### Grid & Container +- 16-column grid (Carbon's 2x grid system) +- Max content width: 1584px (max breakpoint) +- Column gutters: 32px (16px on mobile) +- Margin: 16px (mobile), 32px (tablet+) +- Content typically spans 8-12 columns for readable line lengths +- Full-bleed sections alternate with contained content + +### Whitespace Philosophy +- **Functional density**: Carbon favors productive density over expansive whitespace. Sections are tightly packed compared to consumer design systems — this reflects IBM's enterprise DNA. +- **Background-color zoning**: Instead of massive padding between sections, IBM uses alternating background colors (white → gray 10 → white) to create visual separation with minimal vertical space. +- **Consistent 48px rhythm**: Major section transitions use 48px vertical spacing. Hero sections may use 80px–96px. + +### Border Radius Scale +- **0px**: Primary buttons, inputs, tiles, cards — the dominant treatment. Carbon is fundamentally rectangular. +- **2px**: Occasionally on small interactive elements (tags) +- **24px**: Tags/labels (pill shape — the sole rounded exception) +- **50%**: Avatar circles, icon containers + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, `#ffffff` background | Default page surface | +| Layer 01 | No shadow, `#f4f4f4` background | Cards, tiles, alternating sections | +| Layer 02 | No shadow, `#e0e0e0` background | Elevated panels within Layer 01 | +| Raised | `0 2px 6px rgba(0,0,0,0.3)` | Dropdowns, tooltips, overflow menus | +| Overlay | `0 2px 6px rgba(0,0,0,0.3)` + dark scrim | Modal dialogs, side panels | +| Focus | `2px solid #0f62fe` inset + `1px solid #ffffff` | Keyboard focus ring | +| Bottom-border | `2px solid #161616` on bottom edge | Active input, active tab indicator | + +**Shadow Philosophy**: Carbon is deliberately shadow-averse. IBM achieves depth primarily through background-color layering — stacking surfaces of progressively darker grays rather than adding box-shadows. This creates a flat, print-inspired aesthetic where hierarchy is communicated through color value, not simulated light. Shadows are reserved exclusively for floating elements (dropdowns, tooltips, modals) where the element genuinely overlaps content. This restraint gives the rare shadow meaningful impact — when something floats in Carbon, it matters. + +## 7. Do's and Don'ts + +### Do +- Use IBM Plex Sans at weight 300 for display sizes (42px+) — the lightness is intentional +- Apply 0.16px letter-spacing on 14px body text and 0.32px on 12px captions +- Use 0px border-radius on buttons, inputs, cards, and tiles — rectangles are the system +- Reference `--cds-*` token names when implementing (e.g., `--cds-button-primary`, `--cds-text-primary`) +- Use background-color layering (white → gray 10 → gray 20) for depth instead of shadows +- Use bottom-border (not box) for input field indicators +- Maintain the 48px default button height and asymmetric padding for icon accommodation +- Apply Blue 60 (`#0f62fe`) as the sole accent — one blue to rule them all + +### Don't +- Don't round button corners — 0px radius is the Carbon identity +- Don't use shadows on cards or tiles — flatness is the point +- Don't introduce additional accent colors — IBM's system is monochromatic + blue +- Don't use weight 700 (Bold) — the scale stops at 600 (Semibold) +- Don't add letter-spacing to display-size text — tracking is only for 14px and below +- Don't box inputs with full borders — Carbon inputs use bottom-border only +- Don't use gradient backgrounds — IBM's surfaces are flat, solid colors +- Don't deviate from the 8px spacing grid — every value should be divisible by 8 (with 2px and 4px for micro-adjustments) + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Small (sm) | 320px | Single column, hamburger nav, 16px margins | +| Medium (md) | 672px | 2-column grids begin, expanded content | +| Large (lg) | 1056px | Full navigation visible, 3-4 column grids | +| X-Large (xlg) | 1312px | Maximum content density, wide layouts | +| Max | 1584px | Maximum content width, centered with margins | + +### Touch Targets +- Button height: 48px default, minimum 40px (compact) +- Navigation links: 48px row height for touch +- Input height: 40px default, 48px large +- Icon buttons: 48px square touch target +- Mobile menu items: full-width 48px rows + +### Collapsing Strategy +- Hero: 60px display → 42px → 32px heading as viewport narrows +- Navigation: full horizontal masthead → hamburger with slide-out panel +- Grid: 4-column → 2-column → single column +- Tiles/cards: horizontal grid → vertical stack +- Images: maintain aspect ratio, max-width 100% +- Footer: multi-column link groups → stacked single column +- Section padding: 48px → 32px → 16px + +### Image Behavior +- Responsive images with `max-width: 100%` +- Product illustrations scale proportionally +- Hero images may shift from side-by-side to stacked below +- Data visualizations maintain aspect ratio with horizontal scroll on mobile + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: IBM Blue 60 (`#0f62fe`) +- Background: White (`#ffffff`) +- Heading text: Gray 100 (`#161616`) +- Body text: Gray 100 (`#161616`) +- Secondary text: Gray 70 (`#525252`) +- Surface/Card: Gray 10 (`#f4f4f4`) +- Border: Gray 30 (`#c6c6c6`) +- Link: Blue 60 (`#0f62fe`) +- Link hover: Blue 70 (`#0043ce`) +- Focus ring: Blue 60 (`#0f62fe`) +- Error: Red 60 (`#da1e28`) +- Success: Green 50 (`#24a148`) + +### Example Component Prompts +- "Create a hero section on white background. Headline at 60px IBM Plex Sans weight 300, line-height 1.17, color #161616. Subtitle at 16px weight 400, line-height 1.50, color #525252, max-width 640px. Blue CTA button (#0f62fe background, #ffffff text, 0px border-radius, 48px height, 14px 63px 14px 15px padding)." +- "Design a card tile: #f4f4f4 background, 0px border-radius, 16px padding. Title at 20px IBM Plex Sans weight 600, line-height 1.40, color #161616. Body at 14px weight 400, letter-spacing 0.16px, line-height 1.29, color #525252. Hover: background shifts to #e8e8e8." +- "Build a form field: #f4f4f4 background, 0px border-radius, 40px height, 16px horizontal padding. Label above at 12px weight 400, letter-spacing 0.32px, color #525252. Bottom-border: 2px solid transparent default, 2px solid #0f62fe on focus. Placeholder: #6f6f6f." +- "Create a dark navigation bar: #161616 background, 48px height. IBM logo white left-aligned. Links at 14px IBM Plex Sans weight 400, color #c6c6c6. Hover: #ffffff text. Active: #ffffff with 2px bottom border." +- "Build a tag component: Blue 10 (#edf5ff) background, Blue 60 (#0f62fe) text, 4px 8px padding, 24px border-radius, 12px IBM Plex Sans weight 400." + +### Iteration Guide +1. Always use 0px border-radius on buttons, inputs, and cards — this is non-negotiable in Carbon +2. Letter-spacing only at small sizes: 0.16px at 14px, 0.32px at 12px — never on display text +3. Three weights: 300 (display), 400 (body), 600 (emphasis) — no bold +4. Blue 60 is the only accent color — do not introduce secondary accent hues +5. Depth comes from background-color layering (white → #f4f4f4 → #e0e0e0), not shadows +6. Inputs have bottom-border only, never fully boxed +7. Use `--cds-` prefix for token naming to stay Carbon-compatible +8. 48px is the universal interactive element height diff --git a/skills/creative/popular-web-designs/templates/intercom.md b/skills/creative/popular-web-designs/templates/intercom.md new file mode 100644 index 000000000000..9293886e789e --- /dev/null +++ b/skills/creative/popular-web-designs/templates/intercom.md @@ -0,0 +1,159 @@ +# Design System: Intercom + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Intercom's website is a warm, confident customer service platform that communicates "AI-first helpdesk" through a clean, editorial design language. The page operates on a warm off-white canvas (`#faf9f6`) with off-black (`#111111`) text, creating an intimate, magazine-like reading experience. The signature Fin Orange (`#ff5600`) — named after Intercom's AI agent — serves as the singular vibrant accent against the warm neutral palette. + +The typography uses Saans — a custom geometric sans-serif with aggressive negative letter-spacing (-2.4px at 80px, -0.48px at 24px) and a consistent 1.00 line-height across all heading sizes. This creates ultra-compressed, billboard-like headlines that feel engineered and precise. Serrif provides the serif companion for editorial moments, and SaansMono handles code and uppercase technical labels. MediumLL and LLMedium appear for specific UI contexts, creating a rich five-font ecosystem. + +What distinguishes Intercom is its remarkably sharp geometry — 4px border-radius on buttons creates near-rectangular interactive elements that feel industrial and precise, contrasting with the warm surface colors. Button hover states use `scale(1.1)` expansion, creating a physical "growing" interaction. The border system uses warm oat tones (`#dedbd6`) and oklab-based opacity values for sophisticated color management. + +**Key Characteristics:** +- Warm off-white canvas (`#faf9f6`) with oat-toned borders (`#dedbd6`) +- Saans font with extreme negative tracking (-2.4px at 80px) and 1.00 line-height +- Fin Orange (`#ff5600`) as singular brand accent +- Sharp 4px border-radius — near-rectangular buttons and elements +- Scale(1.1) hover with scale(0.85) active — physical button interaction +- SaansMono uppercase labels with wide tracking (0.6px–1.2px) +- Rich multi-color report palette (blue, green, red, pink, lime, orange) +- oklab color values for sophisticated opacity management + +## 2. Color Palette & Roles + +### Primary +- **Off Black** (`#111111`): `--color-off-black`, primary text, button backgrounds +- **Pure White** (`#ffffff`): `--wsc-color-content-primary`, primary surface +- **Warm Cream** (`#faf9f6`): Button backgrounds, card surfaces +- **Fin Orange** (`#ff5600`): `--color-fin`, primary brand accent +- **Report Orange** (`#fe4c02`): `--color-report-orange`, data visualization + +### Report Palette +- **Report Blue** (`#65b5ff`): `--color-report-blue` +- **Report Green** (`#0bdf50`): `--color-report-green` +- **Report Red** (`#c41c1c`): `--color-report-red` +- **Report Pink** (`#ff2067`): `--color-report-pink` +- **Report Lime** (`#b3e01c`): `--color-report-lime-300` +- **Green** (`#00da00`): `--color-green` +- **Deep Blue** (`#0007cb`): Deep blue accent + +### Neutral Scale (Warm) +- **Black 80** (`#313130`): `--wsc-color-black-80`, dark neutral +- **Black 60** (`#626260`): `--wsc-color-black-60`, mid neutral +- **Black 50** (`#7b7b78`): `--wsc-color-black-50`, muted text +- **Content Tertiary** (`#9c9fa5`): `--wsc-color-content-tertiary` +- **Oat Border** (`#dedbd6`): Warm border color +- **Warm Sand** (`#d3cec6`): Light warm neutral + +## 3. Typography Rules + +### Font Families +- **Primary**: `Saans`, fallbacks: `Saans Fallback, ui-sans-serif, system-ui` +- **Serif**: `Serrif`, fallbacks: `Serrif Fallback, ui-serif, Georgia` +- **Monospace**: `SaansMono`, fallbacks: `SaansMono Fallback, ui-monospace` +- **UI**: `MediumLL` / `LLMedium`, fallbacks: `system-ui, -apple-system` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | +|------|------|------|--------|-------------|----------------| +| Display Hero | Saans | 80px | 400 | 1.00 (tight) | -2.4px | +| Section Heading | Saans | 54px | 400 | 1.00 | -1.6px | +| Sub-heading | Saans | 40px | 400 | 1.00 | -1.2px | +| Card Title | Saans | 32px | 400 | 1.00 | -0.96px | +| Feature Title | Saans | 24px | 400 | 1.00 | -0.48px | +| Body Emphasis | Saans | 20px | 400 | 0.95 | -0.2px | +| Nav / UI | Saans | 18px | 400 | 1.00 | normal | +| Body | Saans | 16px | 400 | 1.50 | normal | +| Body Light | Saans | 14px | 300 | 1.40 | normal | +| Button | Saans | 16px / 14px | 400 | 1.50 / 1.43 | normal | +| Button Bold | LLMedium | 16px | 700 | 1.20 | 0.16px | +| Serif Body | Serrif | 16px | 300 | 1.40 | -0.16px | +| Mono Label | SaansMono | 12px | 400–500 | 1.00–1.30 | 0.6px–1.2px uppercase | + +## 4. Component Stylings + +### Buttons + +**Primary Dark** +- Background: `#111111` +- Text: `#ffffff` +- Padding: 0px 14px +- Radius: 4px +- Hover: white background, dark text, scale(1.1) +- Active: green background (`#2c6415`), scale(0.85) + +**Outlined** +- Background: transparent +- Text: `#111111` +- Border: `1px solid #111111` +- Radius: 4px +- Same scale hover/active behavior + +**Warm Card Button** +- Background: `#faf9f6` +- Text: `#111111` +- Padding: 16px +- Border: `1px solid oklab(... / 0.1)` + +### Cards & Containers +- Background: `#faf9f6` (warm cream) +- Border: `1px solid #dedbd6` (warm oat) +- Radius: 8px +- No visible shadows + +### Navigation +- Saans 16px for links +- Off-black text on white +- Small 4px–6px radius buttons +- Orange Fin accent for AI features + +## 5. Layout Principles + +### Spacing: 8px, 10px, 12px, 14px, 16px, 20px, 24px, 32px, 40px, 48px, 60px, 64px, 80px, 96px +### Border Radius: 4px (buttons), 6px (nav items), 8px (cards, containers) + +## 6. Depth & Elevation +Minimal shadows. Depth through warm border colors and surface tints. + +## 7. Do's and Don'ts + +### Do +- Use Saans with 1.00 line-height and negative tracking on all headings +- Apply 4px radius on buttons — sharp geometry is the identity +- Use Fin Orange (#ff5600) for AI/brand accent only +- Apply scale(1.1) hover on buttons +- Use warm neutrals (#faf9f6, #dedbd6) + +### Don't +- Don't round buttons beyond 4px +- Don't use Fin Orange decoratively +- Don't use cool gray borders — always warm oat tones +- Don't skip the negative tracking on headings + +## 8. Responsive Behavior +Breakpoints: 425px, 530px, 600px, 640px, 768px, 896px + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Text: Off Black (`#111111`) +- Background: Warm Cream (`#faf9f6`) +- Accent: Fin Orange (`#ff5600`) +- Border: Oat (`#dedbd6`) +- Muted: `#7b7b78` + +### Example Component Prompts +- "Create hero: warm cream (#faf9f6) background. Saans 80px weight 400, line-height 1.00, letter-spacing -2.4px, #111111. Dark button (#111111, 4px radius). Hover: scale(1.1), white bg." diff --git a/skills/creative/popular-web-designs/templates/kraken.md b/skills/creative/popular-web-designs/templates/kraken.md new file mode 100644 index 000000000000..875f5617f28a --- /dev/null +++ b/skills/creative/popular-web-designs/templates/kraken.md @@ -0,0 +1,138 @@ +# Design System: Kraken + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Kraken's website is a clean, trustworthy crypto exchange that uses purple as its commanding brand color. The design operates on white backgrounds with Kraken Purple (`#7132f5`, `#5741d8`, `#5b1ecf`) creating a distinctive, professional crypto identity. The proprietary Kraken-Brand font handles display headings with bold (700) weight and negative tracking, while Kraken-Product (with IBM Plex Sans fallback) serves as the UI workhorse. + +**Key Characteristics:** +- Kraken Purple (`#7132f5`) as primary brand with darker variants (`#5741d8`, `#5b1ecf`) +- Kraken-Brand (display) + Kraken-Product (UI) dual font system +- Near-black (`#101114`) text with cool blue-gray neutral scale +- 12px radius buttons (rounded but not pill) +- Subtle shadows (`rgba(0,0,0,0.03) 0px 4px 24px`) — whisper-level +- Green accent (`#149e61`) for positive/success states + +## 2. Color Palette & Roles + +### Primary +- **Kraken Purple** (`#7132f5`): Primary CTA, brand accent, links +- **Purple Dark** (`#5741d8`): Button borders, outlined variants +- **Purple Deep** (`#5b1ecf`): Deepest purple +- **Purple Subtle** (`rgba(133,91,251,0.16)`): Purple at 16% — subtle button backgrounds +- **Near Black** (`#101114`): Primary text + +### Neutral +- **Cool Gray** (`#686b82`): Primary neutral, borders at 24% opacity +- **Silver Blue** (`#9497a9`): Secondary text, muted elements +- **White** (`#ffffff`): Primary surface +- **Border Gray** (`#dedee5`): Divider borders + +### Semantic +- **Green** (`#149e61`): Success/positive at 16% opacity for badges +- **Green Dark** (`#026b3f`): Badge text + +## 3. Typography Rules + +### Font Families +- **Display**: `Kraken-Brand`, fallbacks: `IBM Plex Sans, Helvetica, Arial` +- **UI / Body**: `Kraken-Product`, fallbacks: `Helvetica Neue, Helvetica, Arial` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | +|------|------|------|--------|-------------|----------------| +| Display Hero | Kraken-Brand | 48px | 700 | 1.17 | -1px | +| Section Heading | Kraken-Brand | 36px | 700 | 1.22 | -0.5px | +| Sub-heading | Kraken-Brand | 28px | 700 | 1.29 | -0.5px | +| Feature Title | Kraken-Product | 22px | 600 | 1.20 | normal | +| Body | Kraken-Product | 16px | 400 | 1.38 | normal | +| Body Medium | Kraken-Product | 16px | 500 | 1.38 | normal | +| Button | Kraken-Product | 16px | 500–600 | 1.38 | normal | +| Caption | Kraken-Product | 14px | 400–700 | 1.43–1.71 | normal | +| Small | Kraken-Product | 12px | 400–500 | 1.33 | normal | +| Micro | Kraken-Product | 7px | 500 | 1.00 | uppercase | + +## 4. Component Stylings + +### Buttons + +**Primary Purple** +- Background: `#7132f5` +- Text: `#ffffff` +- Padding: 13px 16px +- Radius: 12px + +**Purple Outlined** +- Background: `#ffffff` +- Text: `#5741d8` +- Border: `1px solid #5741d8` +- Radius: 12px + +**Purple Subtle** +- Background: `rgba(133,91,251,0.16)` +- Text: `#7132f5` +- Padding: 8px +- Radius: 12px + +**White Button** +- Background: `#ffffff` +- Text: `#101114` +- Radius: 10px +- Shadow: `rgba(0,0,0,0.03) 0px 4px 24px` + +**Secondary Gray** +- Background: `rgba(148,151,169,0.08)` +- Text: `#101114` +- Radius: 12px + +### Badges +- Success: `rgba(20,158,97,0.16)` bg, `#026b3f` text, 6px radius +- Neutral: `rgba(104,107,130,0.12)` bg, `#484b5e` text, 8px radius + +## 5. Layout Principles + +### Spacing: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 13px, 15px, 16px, 20px, 24px, 25px +### Border Radius: 3px, 6px, 8px, 10px, 12px, 16px, 9999px, 50% + +## 6. Depth & Elevation +- Subtle: `rgba(0,0,0,0.03) 0px 4px 24px` +- Micro: `rgba(16,24,40,0.04) 0px 1px 4px` + +## 7. Do's and Don'ts + +### Do +- Use Kraken Purple (#7132f5) for CTAs and links +- Apply 12px radius on all buttons +- Use Kraken-Brand for headings, Kraken-Product for body + +### Don't +- Don't use pill buttons — 12px is the max radius for buttons +- Don't use other purples outside the defined scale + +## 8. Responsive Behavior +Breakpoints: 375px, 425px, 640px, 768px, 1024px, 1280px, 1536px + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Brand: Kraken Purple (`#7132f5`) +- Dark variant: `#5741d8` +- Text: Near Black (`#101114`) +- Secondary text: `#9497a9` +- Background: White (`#ffffff`) + +### Example Component Prompts +- "Create hero: white background. Kraken-Brand 48px weight 700, letter-spacing -1px. Purple CTA (#7132f5, 12px radius, 13px 16px padding)." diff --git a/skills/creative/popular-web-designs/templates/linear.app.md b/skills/creative/popular-web-designs/templates/linear.app.md new file mode 100644 index 000000000000..f87e8eb0b55f --- /dev/null +++ b/skills/creative/popular-web-designs/templates/linear.app.md @@ -0,0 +1,380 @@ +# Design System: Linear + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Linear's website is a masterclass in dark-mode-first product design — a near-black canvas (`#08090a`) where content emerges from darkness like starlight. The overall impression is one of extreme precision engineering: every element exists in a carefully calibrated hierarchy of luminance, from barely-visible borders (`rgba(255,255,255,0.05)`) to soft, luminous text (`#f7f8f8`). This is not a dark theme applied to a light design — it is darkness as the native medium, where information density is managed through subtle gradations of white opacity rather than color variation. + +The typography system is built entirely on Inter Variable with OpenType features `"cv01"` and `"ss03"` enabled globally, giving the typeface a cleaner, more geometric character. Inter is used at a remarkable range of weights — from 300 (light body) through 510 (medium, Linear's signature weight) to 590 (semibold emphasis). The 510 weight is particularly distinctive: it sits between regular and medium, creating a subtle emphasis that doesn't shout. At display sizes (72px, 64px, 48px), Inter uses aggressive negative letter-spacing (-1.584px to -1.056px), creating compressed, authoritative headlines that feel engineered rather than designed. Berkeley Mono serves as the monospace companion for code and technical labels, with fallbacks to ui-monospace, SF Mono, and Menlo. + +The color system is almost entirely achromatic — dark backgrounds with white/gray text — punctuated by a single brand accent: Linear's signature indigo-violet (`#5e6ad2` for backgrounds, `#7170ff` for interactive accents). This accent color is used sparingly and intentionally, appearing only on CTAs, active states, and brand elements. The border system uses ultra-thin, semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) that create structure without visual noise, like wireframes drawn in moonlight. + +**Key Characteristics:** +- Dark-mode-native: `#08090a` marketing background, `#0f1011` panel background, `#191a1b` elevated surfaces +- Inter Variable with `"cv01", "ss03"` globally — geometric alternates for a cleaner aesthetic +- Signature weight 510 (between regular and medium) for most UI text +- Aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px) +- Brand indigo-violet: `#5e6ad2` (bg) / `#7170ff` (accent) / `#828fff` (hover) — the only chromatic color in the system +- Semi-transparent white borders throughout: `rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)` +- Button backgrounds at near-zero opacity: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` +- Multi-layered shadows with inset variants for depth on dark surfaces +- Radix UI primitives as the component foundation (6 detected primitives) +- Success green (`#27a644`, `#10b981`) used only for status indicators + +## 2. Color Palette & Roles + +### Background Surfaces +- **Marketing Black** (`#010102` / `#08090a`): The deepest background — the canvas for hero sections and marketing pages. Near-pure black with an imperceptible blue-cool undertone. +- **Panel Dark** (`#0f1011`): Sidebar and panel backgrounds. One step up from the marketing black. +- **Level 3 Surface** (`#191a1b`): Elevated surface areas, card backgrounds, dropdowns. +- **Secondary Surface** (`#28282c`): The lightest dark surface — used for hover states and slightly elevated components. + +### Text & Content +- **Primary Text** (`#f7f8f8`): Near-white with a barely-warm cast. The default text color — not pure white, preventing eye strain on dark backgrounds. +- **Secondary Text** (`#d0d6e0`): Cool silver-gray for body text, descriptions, and secondary content. +- **Tertiary Text** (`#8a8f98`): Muted gray for placeholders, metadata, and de-emphasized content. +- **Quaternary Text** (`#62666d`): The most subdued text — timestamps, disabled states, subtle labels. + +### Brand & Accent +- **Brand Indigo** (`#5e6ad2`): Primary brand color — used for CTA button backgrounds, brand marks, and key interactive surfaces. +- **Accent Violet** (`#7170ff`): Brighter variant for interactive elements — links, active states, selected items. +- **Accent Hover** (`#828fff`): Lighter, more saturated variant for hover states on accent elements. +- **Security Lavender** (`#7a7fad`): Muted indigo used specifically for security-related UI elements. + +### Status Colors +- **Green** (`#27a644`): Primary success/active status. Used for "in progress" indicators. +- **Emerald** (`#10b981`): Secondary success — pill badges, completion states. + +### Border & Divider +- **Border Primary** (`#23252a`): Solid dark border for prominent separations. +- **Border Secondary** (`#34343a`): Slightly lighter solid border. +- **Border Tertiary** (`#3e3e44`): Lightest solid border variant. +- **Border Subtle** (`rgba(255,255,255,0.05)`): Ultra-subtle semi-transparent border — the default. +- **Border Standard** (`rgba(255,255,255,0.08)`): Standard semi-transparent border for cards, inputs, code blocks. +- **Line Tint** (`#141516`): Nearly invisible line for the subtlest divisions. +- **Line Tertiary** (`#18191a`): Slightly more visible divider line. + +### Light Mode Neutrals (for light theme contexts) +- **Light Background** (`#f7f8f8`): Page background in light mode. +- **Light Surface** (`#f3f4f5` / `#f5f6f7`): Subtle surface tinting. +- **Light Border** (`#d0d6e0`): Visible border in light contexts. +- **Light Border Alt** (`#e6e6e6`): Alternative lighter border. +- **Pure White** (`#ffffff`): Card surfaces, highlights. + +### Overlay +- **Overlay Primary** (`rgba(0,0,0,0.85)`): Modal/dialog backdrop — extremely dark for focus isolation. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Inter Variable`, with fallbacks: `SF Pro Display, -apple-system, system-ui, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue` +- **Monospace**: `Berkeley Mono`, with fallbacks: `ui-monospace, SF Mono, Menlo` +- **OpenType Features**: `"cv01", "ss03"` enabled globally — cv01 provides an alternate lowercase 'a' (single-story), ss03 adjusts specific letterforms for a cleaner geometric appearance. + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display XL | Inter Variable | 72px (4.50rem) | 510 | 1.00 (tight) | -1.584px | Hero headlines, maximum impact | +| Display Large | Inter Variable | 64px (4.00rem) | 510 | 1.00 (tight) | -1.408px | Secondary hero text | +| Display | Inter Variable | 48px (3.00rem) | 510 | 1.00 (tight) | -1.056px | Section headlines | +| Heading 1 | Inter Variable | 32px (2.00rem) | 400 | 1.13 (tight) | -0.704px | Major section titles | +| Heading 2 | Inter Variable | 24px (1.50rem) | 400 | 1.33 | -0.288px | Sub-section headings | +| Heading 3 | Inter Variable | 20px (1.25rem) | 590 | 1.33 | -0.24px | Feature titles, card headers | +| Body Large | Inter Variable | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.165px | Introduction text, feature descriptions | +| Body Emphasis | Inter Variable | 17px (1.06rem) | 590 | 1.60 (relaxed) | normal | Emphasized body, sub-headings in content | +| Body | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text | +| Body Medium | Inter Variable | 16px (1.00rem) | 510 | 1.50 | normal | Navigation, labels | +| Body Semibold | Inter Variable | 16px (1.00rem) | 590 | 1.50 | normal | Strong emphasis | +| Small | Inter Variable | 15px (0.94rem) | 400 | 1.60 (relaxed) | -0.165px | Secondary body text | +| Small Medium | Inter Variable | 15px (0.94rem) | 510 | 1.60 (relaxed) | -0.165px | Emphasized small text | +| Small Semibold | Inter Variable | 15px (0.94rem) | 590 | 1.60 (relaxed) | -0.165px | Strong small text | +| Small Light | Inter Variable | 15px (0.94rem) | 300 | 1.47 | -0.165px | De-emphasized body | +| Caption Large | Inter Variable | 14px (0.88rem) | 510–590 | 1.50 | -0.182px | Sub-labels, category headers | +| Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Metadata, timestamps | +| Label | Inter Variable | 12px (0.75rem) | 400–590 | 1.40 | normal | Button text, small labels | +| Micro | Inter Variable | 11px (0.69rem) | 510 | 1.40 | normal | Tiny labels | +| Tiny | Inter Variable | 10px (0.63rem) | 400–510 | 1.50 | -0.15px | Overline text, sometimes uppercase | +| Link Large | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard links | +| Link Medium | Inter Variable | 15px (0.94rem) | 510 | 2.67 | normal | Spaced navigation links | +| Link Small | Inter Variable | 14px (0.88rem) | 510 | 1.50 | normal | Compact links | +| Link Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Footer, metadata links | +| Mono Body | Berkeley Mono | 14px (0.88rem) | 400 | 1.50 | normal | Code blocks | +| Mono Caption | Berkeley Mono | 13px (0.81rem) | 400 | 1.50 | normal | Code labels | +| Mono Label | Berkeley Mono | 12px (0.75rem) | 400 | 1.40 | normal | Code metadata, sometimes uppercase | + +### Principles +- **510 is the signature weight**: Linear uses Inter Variable's 510 weight (between regular 400 and medium 500) as its default emphasis weight. This creates a subtly bolded feel without the heaviness of traditional medium or semibold. +- **Compression at scale**: Display sizes use progressively tighter letter-spacing — -1.584px at 72px, -1.408px at 64px, -1.056px at 48px, -0.704px at 32px. Below 24px, spacing relaxes toward normal. +- **OpenType as identity**: `"cv01", "ss03"` aren't decorative — they transform Inter into Linear's distinctive typeface, giving it a more geometric, purposeful character. +- **Three-tier weight system**: 400 (reading), 510 (emphasis/UI), 590 (strong emphasis). The 300 weight appears only in deliberately de-emphasized contexts. + +## 4. Component Stylings + +### Buttons + +**Ghost Button (Default)** +- Background: `rgba(255,255,255,0.02)` +- Text: `#e2e4e7` (near-white) +- Padding: comfortable +- Radius: 6px +- Border: `1px solid rgb(36, 40, 44)` +- Outline: none +- Focus shadow: `rgba(0,0,0,0.1) 0px 4px 12px` +- Use: Standard actions, secondary CTAs + +**Subtle Button** +- Background: `rgba(255,255,255,0.04)` +- Text: `#d0d6e0` (silver-gray) +- Padding: 0px 6px +- Radius: 6px +- Use: Toolbar actions, contextual buttons + +**Primary Brand Button (Inferred)** +- Background: `#5e6ad2` (brand indigo) +- Text: `#ffffff` +- Padding: 8px 16px +- Radius: 6px +- Hover: `#828fff` shift +- Use: Primary CTAs ("Start building", "Sign up") + +**Icon Button (Circle)** +- Background: `rgba(255,255,255,0.03)` or `rgba(255,255,255,0.05)` +- Text: `#f7f8f8` or `#ffffff` +- Radius: 50% +- Border: `1px solid rgba(255,255,255,0.08)` +- Use: Close, menu toggle, icon-only actions + +**Pill Button** +- Background: transparent +- Text: `#d0d6e0` +- Padding: 0px 10px 0px 5px +- Radius: 9999px +- Border: `1px solid rgb(35, 37, 42)` +- Use: Filter chips, tags, status indicators + +**Small Toolbar Button** +- Background: `rgba(255,255,255,0.05)` +- Text: `#62666d` (muted) +- Radius: 2px +- Border: `1px solid rgba(255,255,255,0.05)` +- Shadow: `rgba(0,0,0,0.03) 0px 1.2px 0px 0px` +- Font: 12px weight 510 +- Use: Toolbar actions, quick-access controls + +### Cards & Containers +- Background: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` (never solid — always translucent) +- Border: `1px solid rgba(255,255,255,0.08)` (standard) or `1px solid rgba(255,255,255,0.05)` (subtle) +- Radius: 8px (standard), 12px (featured), 22px (large panels) +- Shadow: `rgba(0,0,0,0.2) 0px 0px 0px 1px` or layered multi-shadow stacks +- Hover: subtle background opacity increase + +### Inputs & Forms + +**Text Area** +- Background: `rgba(255,255,255,0.02)` +- Text: `#d0d6e0` +- Border: `1px solid rgba(255,255,255,0.08)` +- Padding: 12px 14px +- Radius: 6px + +**Search Input** +- Background: transparent +- Text: `#f7f8f8` +- Padding: 1px 32px (icon-aware) + +**Button-style Input** +- Text: `#8a8f98` +- Padding: 1px 6px +- Radius: 5px +- Focus shadow: multi-layer stack + +### Badges & Pills + +**Success Pill** +- Background: `#10b981` +- Text: `#f7f8f8` +- Radius: 50% (circular) +- Font: 10px weight 510 +- Use: Status dots, completion indicators + +**Neutral Pill** +- Background: transparent +- Text: `#d0d6e0` +- Padding: 0px 10px 0px 5px +- Radius: 9999px +- Border: `1px solid rgb(35, 37, 42)` +- Font: 12px weight 510 +- Use: Tags, filter chips, category labels + +**Subtle Badge** +- Background: `rgba(255,255,255,0.05)` +- Text: `#f7f8f8` +- Padding: 0px 8px 0px 2px +- Radius: 2px +- Border: `1px solid rgba(255,255,255,0.05)` +- Font: 10px weight 510 +- Use: Inline labels, version tags + +### Navigation +- Dark sticky header on near-black background +- Linear logomark left-aligned (SVG icon) +- Links: Inter Variable 13–14px weight 510, `#d0d6e0` text +- Active/hover: text lightens to `#f7f8f8` +- CTA: Brand indigo button or ghost button +- Mobile: hamburger collapse +- Search: command palette trigger (`/` or `Cmd+K`) + +### Image Treatment +- Product screenshots on dark backgrounds with subtle border (`rgba(255,255,255,0.08)`) +- Top-rounded images: `12px 12px 0px 0px` radius +- Dashboard/issue previews dominate feature sections +- Subtle shadow beneath screenshots: `rgba(0,0,0,0.4) 0px 2px 4px` + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 4px, 7px, 8px, 11px, 12px, 16px, 19px, 20px, 22px, 24px, 28px, 32px, 35px +- The 7px and 11px values suggest micro-adjustments for optical alignment +- Primary rhythm: 8px, 16px, 24px, 32px (standard 8px grid) + +### Grid & Container +- Max content width: approximately 1200px +- Hero: centered single-column with generous vertical padding +- Feature sections: 2–3 column grids for feature cards +- Full-width dark sections with internal max-width constraints +- Changelog: single-column timeline layout + +### Whitespace Philosophy +- **Darkness as space**: On Linear's dark canvas, empty space isn't white — it's absence. The near-black background IS the whitespace, and content emerges from it. +- **Compressed headlines, expanded surroundings**: Display text at 72px with -1.584px tracking is dense and compressed, but sits within vast dark padding. The contrast between typographic density and spatial generosity creates tension. +- **Section isolation**: Each feature section is separated by generous vertical padding (80px+) with no visible dividers — the dark background provides natural separation. + +### Border Radius Scale +- Micro (2px): Inline badges, toolbar buttons, subtle tags +- Standard (4px): Small containers, list items +- Comfortable (6px): Buttons, inputs, functional elements +- Card (8px): Cards, dropdowns, popovers +- Panel (12px): Panels, featured cards, section containers +- Large (22px): Large panel elements +- Full Pill (9999px): Chips, filter pills, status tags +- Circle (50%): Icon buttons, avatars, status dots + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, `#010102` bg | Page background, deepest canvas | +| Subtle (Level 1) | `rgba(0,0,0,0.03) 0px 1.2px 0px` | Toolbar buttons, micro-elevation | +| Surface (Level 2) | `rgba(255,255,255,0.05)` bg + `1px solid rgba(255,255,255,0.08)` border | Cards, input fields, containers | +| Inset (Level 2b) | `rgba(0,0,0,0.2) 0px 0px 12px 0px inset` | Recessed panels, inner shadows | +| Ring (Level 3) | `rgba(0,0,0,0.2) 0px 0px 0px 1px` | Border-as-shadow technique | +| Elevated (Level 4) | `rgba(0,0,0,0.4) 0px 2px 4px` | Floating elements, dropdowns | +| Dialog (Level 5) | Multi-layer stack: `rgba(0,0,0,0) 0px 8px 2px, rgba(0,0,0,0.01) 0px 5px 2px, rgba(0,0,0,0.04) 0px 3px 2px, rgba(0,0,0,0.07) 0px 1px 1px, rgba(0,0,0,0.08) 0px 0px 1px` | Popovers, command palette, modals | +| Focus | `rgba(0,0,0,0.1) 0px 4px 12px` + additional layers | Keyboard focus on interactive elements | + +**Shadow Philosophy**: On dark surfaces, traditional shadows (dark on dark) are nearly invisible. Linear solves this by using semi-transparent white borders as the primary depth indicator. Elevation isn't communicated through shadow darkness but through background luminance steps — each level slightly increases the white opacity of the surface background (`0.02` → `0.04` → `0.05`), creating a subtle stacking effect. The inset shadow technique (`rgba(0,0,0,0.2) 0px 0px 12px 0px inset`) creates a unique "sunken" effect for recessed panels, adding dimensional depth that traditional dark themes lack. + +## 7. Do's and Don'ts + +### Do +- Use Inter Variable with `"cv01", "ss03"` on ALL text — these features are fundamental to Linear's typeface identity +- Use weight 510 as your default emphasis weight — it's Linear's signature between-weight +- Apply aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px) +- Build on near-black backgrounds: `#08090a` for marketing, `#0f1011` for panels, `#191a1b` for elevated surfaces +- Use semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) instead of solid dark borders +- Keep button backgrounds nearly transparent: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` +- Reserve brand indigo (`#5e6ad2` / `#7170ff`) for primary CTAs and interactive accents only +- Use `#f7f8f8` for primary text — not pure `#ffffff`, which would be too harsh +- Apply the luminance stacking model: deeper = darker bg, elevated = slightly lighter bg + +### Don't +- Don't use pure white (`#ffffff`) as primary text — `#f7f8f8` prevents eye strain +- Don't use solid colored backgrounds for buttons — transparency is the system (rgba white at 0.02–0.05) +- Don't apply the brand indigo decoratively — it's reserved for interactive/CTA elements only +- Don't use positive letter-spacing on display text — Inter at large sizes always runs negative +- Don't use visible/opaque borders on dark backgrounds — borders should be whisper-thin semi-transparent white +- Don't skip the OpenType features (`"cv01", "ss03"`) — without them, it's generic Inter, not Linear's Inter +- Don't use weight 700 (bold) — Linear's maximum weight is 590, with 510 as the workhorse +- Don't introduce warm colors into the UI chrome — the palette is cool gray with blue-violet accent only +- Don't use drop shadows for elevation on dark surfaces — use background luminance stepping instead + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <600px | Single column, compact padding | +| Mobile | 600–640px | Standard mobile layout | +| Tablet | 640–768px | Two-column grids begin | +| Desktop Small | 768–1024px | Full card grids, expanded padding | +| Desktop | 1024–1280px | Standard desktop, full navigation | +| Large Desktop | >1280px | Full layout, generous margins | + +### Touch Targets +- Buttons use comfortable padding with 6px radius minimum +- Navigation links at 13–14px with adequate spacing +- Pill tags have 10px horizontal padding for touch accessibility +- Icon buttons at 50% radius ensure circular, easy-to-tap targets +- Search trigger is prominently placed with generous hit area + +### Collapsing Strategy +- Hero: 72px → 48px → 32px display text, tracking adjusts proportionally +- Navigation: horizontal links + CTAs → hamburger menu at 768px +- Feature cards: 3-column → 2-column → single column stacked +- Product screenshots: maintain aspect ratio, may reduce padding +- Changelog: timeline maintains single-column through all sizes +- Footer: multi-column → stacked single column +- Section spacing: 80px+ → 48px on mobile + +### Image Behavior +- Dashboard screenshots maintain border treatment at all sizes +- Hero visuals simplify on mobile (fewer floating UI elements) +- Product screenshots use responsive sizing with consistent radius +- Dark background ensures screenshots blend naturally at any viewport + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Brand Indigo (`#5e6ad2`) +- Page Background: Marketing Black (`#08090a`) +- Panel Background: Panel Dark (`#0f1011`) +- Surface: Level 3 (`#191a1b`) +- Heading text: Primary White (`#f7f8f8`) +- Body text: Silver Gray (`#d0d6e0`) +- Muted text: Tertiary Gray (`#8a8f98`) +- Subtle text: Quaternary Gray (`#62666d`) +- Accent: Violet (`#7170ff`) +- Accent Hover: Light Violet (`#828fff`) +- Border (default): `rgba(255,255,255,0.08)` +- Border (subtle): `rgba(255,255,255,0.05)` +- Focus ring: Multi-layer shadow stack + +### Example Component Prompts +- "Create a hero section on `#08090a` background. Headline at 48px Inter Variable weight 510, line-height 1.00, letter-spacing -1.056px, color `#f7f8f8`, font-feature-settings `'cv01', 'ss03'`. Subtitle at 18px weight 400, line-height 1.60, color `#8a8f98`. Brand CTA button (`#5e6ad2`, 6px radius, 8px 16px padding) and ghost button (`rgba(255,255,255,0.02)` bg, `1px solid rgba(255,255,255,0.08)` border, 6px radius)." +- "Design a card on dark background: `rgba(255,255,255,0.02)` background, `1px solid rgba(255,255,255,0.08)` border, 8px radius. Title at 20px Inter Variable weight 590, letter-spacing -0.24px, color `#f7f8f8`. Body at 15px weight 400, color `#8a8f98`, letter-spacing -0.165px." +- "Build a pill badge: transparent background, `#d0d6e0` text, 9999px radius, 0px 10px padding, `1px solid #23252a` border, 12px Inter Variable weight 510." +- "Create navigation: dark sticky header on `#0f1011`. Inter Variable 13px weight 510 for links, `#d0d6e0` text. Brand indigo CTA `#5e6ad2` right-aligned with 6px radius. Bottom border: `1px solid rgba(255,255,255,0.05)`." +- "Design a command palette: `#191a1b` background, `1px solid rgba(255,255,255,0.08)` border, 12px radius, multi-layer shadow stack. Input at 16px Inter Variable weight 400, `#f7f8f8` text. Results list with 13px weight 510 labels in `#d0d6e0` and 12px metadata in `#62666d`." + +### Iteration Guide +1. Always set font-feature-settings `"cv01", "ss03"` on all Inter text — this is non-negotiable for Linear's look +2. Letter-spacing scales with font size: -1.584px at 72px, -1.056px at 48px, -0.704px at 32px, normal below 16px +3. Three weights: 400 (read), 510 (emphasize/navigate), 590 (announce) +4. Surface elevation via background opacity: `rgba(255,255,255, 0.02 → 0.04 → 0.05)` — never solid backgrounds on dark +5. Brand indigo (`#5e6ad2` / `#7170ff`) is the only chromatic color — everything else is grayscale +6. Borders are always semi-transparent white, never solid dark colors on dark backgrounds +7. Berkeley Mono for any code or technical content, Inter Variable for everything else diff --git a/skills/creative/popular-web-designs/templates/lovable.md b/skills/creative/popular-web-designs/templates/lovable.md new file mode 100644 index 000000000000..c9afddd23ff0 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/lovable.md @@ -0,0 +1,311 @@ +# Design System: Lovable + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Lovable's website radiates warmth through restraint. The entire page sits on a creamy, parchment-toned background (`#f7f4ed`) that immediately separates it from the cold-white conventions of most developer tool sites. This isn't minimalism for minimalism's sake — it's a deliberate choice to feel approachable, almost analog, like a well-crafted notebook. The near-black text (`#1c1c1c`) against this warm cream creates a contrast ratio that's easy on the eyes while maintaining sharp readability. + +The custom Camera Plain Variable typeface is the system's secret weapon. Unlike geometric sans-serifs that signal "tech company," Camera Plain has a humanist warmth — slightly rounded terminals, organic curves, and a comfortable reading rhythm. At display sizes (48px–60px), weight 600 with aggressive negative letter-spacing (-0.9px to -1.5px) compresses headlines into confident, editorial statements. The font uses `ui-sans-serif, system-ui` as fallbacks, acknowledging that the custom typeface carries the brand personality. + +What makes Lovable's visual system distinctive is its opacity-driven depth model. Rather than using a traditional gray scale, the system modulates `#1c1c1c` at varying opacities (0.03, 0.04, 0.4, 0.82–0.83) to create a unified tonal range. Every shade of gray on the page is technically the same hue — just more or less transparent. This creates a visual coherence that's nearly impossible to achieve with arbitrary hex values. The border system follows suit: `1px solid #eceae4` for light divisions and `1px solid rgba(28, 28, 28, 0.4)` for stronger interactive boundaries. + +**Key Characteristics:** +- Warm parchment background (`#f7f4ed`) — not white, not beige, a deliberate cream that feels hand-selected +- Camera Plain Variable typeface with humanist warmth and editorial letter-spacing at display sizes +- Opacity-driven color system: all grays derived from `#1c1c1c` at varying transparency levels +- Inset shadow technique on buttons: `rgba(255,255,255,0.2) 0px 0.5px 0px 0px inset, rgba(0,0,0,0.2) 0px 0px 0px 0.5px inset` +- Warm neutral border palette: `#eceae4` for subtle, `rgba(28,28,28,0.4)` for interactive elements +- Full-pill radius (`9999px`) used extensively for action buttons and icon containers +- Focus state uses `rgba(0,0,0,0.1) 0px 4px 12px` shadow for soft, warm emphasis +- shadcn/ui + Radix UI component primitives with Tailwind CSS utility styling + +## 2. Color Palette & Roles + +### Primary +- **Cream** (`#f7f4ed`): Page background, card surfaces, button surfaces. The foundation — warm, paper-like, human. +- **Charcoal** (`#1c1c1c`): Primary text, headings, dark button backgrounds. Not pure black — organic warmth. +- **Off-White** (`#fcfbf8`): Button text on dark backgrounds, subtle highlight. Barely distinguishable from pure white. + +### Neutral Scale (Opacity-Based) +- **Charcoal 100%** (`#1c1c1c`): Primary text, headings, dark surfaces. +- **Charcoal 83%** (`rgba(28,28,28,0.83)`): Strong secondary text. +- **Charcoal 82%** (`rgba(28,28,28,0.82)`): Body copy. +- **Muted Gray** (`#5f5f5d`): Secondary text, descriptions, captions. +- **Charcoal 40%** (`rgba(28,28,28,0.4)`): Interactive borders, button outlines. +- **Charcoal 4%** (`rgba(28,28,28,0.04)`): Subtle hover backgrounds, micro-tints. +- **Charcoal 3%** (`rgba(28,28,28,0.03)`): Barely-visible overlays, background depth. + +### Surface & Border +- **Light Cream** (`#eceae4`): Card borders, dividers, image outlines. The warm divider line. +- **Cream Surface** (`#f7f4ed`): Card backgrounds, section fills — same as page background for seamless integration. + +### Interactive +- **Ring Blue** (`#3b82f6` at 50% opacity): `--tw-ring-color`, Tailwind focus ring. +- **Focus Shadow** (`rgba(0,0,0,0.1) 0px 4px 12px`): Focus and active state shadow — soft, warm, diffused. + +### Inset Shadows +- **Button Inset** (`rgba(255,255,255,0.2) 0px 0.5px 0px 0px inset, rgba(0,0,0,0.2) 0px 0px 0px 0.5px inset, rgba(0,0,0,0.05) 0px 1px 2px 0px`): The signature multi-layer inset shadow on dark buttons. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Camera Plain Variable`, with fallbacks: `ui-sans-serif, system-ui` +- **Weight range**: 400 (body/reading), 480 (special display), 600 (headings/emphasis) +- **Feature**: Variable font with continuous weight axis — allows fine-tuned intermediary weights like 480. + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Camera Plain Variable | 60px (3.75rem) | 600 | 1.00–1.10 (tight) | -1.5px | Maximum impact, editorial | +| Display Alt | Camera Plain Variable | 60px (3.75rem) | 480 | 1.00 (tight) | normal | Lighter hero variant | +| Section Heading | Camera Plain Variable | 48px (3.00rem) | 600 | 1.00 (tight) | -1.2px | Feature section titles | +| Sub-heading | Camera Plain Variable | 36px (2.25rem) | 600 | 1.10 (tight) | -0.9px | Sub-sections | +| Card Title | Camera Plain Variable | 20px (1.25rem) | 400 | 1.25 (tight) | normal | Card headings | +| Body Large | Camera Plain Variable | 18px (1.13rem) | 400 | 1.38 | normal | Introductions | +| Body | Camera Plain Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text | +| Button | Camera Plain Variable | 16px (1.00rem) | 400 | 1.50 | normal | Button labels | +| Button Small | Camera Plain Variable | 14px (0.88rem) | 400 | 1.50 | normal | Compact buttons | +| Link | Camera Plain Variable | 16px (1.00rem) | 400 | 1.50 | normal | Underline decoration | +| Link Small | Camera Plain Variable | 14px (0.88rem) | 400 | 1.50 | normal | Footer links | +| Caption | Camera Plain Variable | 14px (0.88rem) | 400 | 1.50 | normal | Metadata, small text | + +### Principles +- **Warm humanist voice**: Camera Plain Variable gives Lovable its approachable personality. The slightly rounded terminals and organic curves contrast with the sharp geometric sans-serifs used by most developer tools. +- **Variable weight as design tool**: The font supports continuous weight values (e.g., 480), enabling nuanced hierarchy beyond standard weight stops. Weight 480 at 60px creates a display style that feels lighter than semibold but stronger than regular. +- **Compression at scale**: Headlines use negative letter-spacing (-0.9px to -1.5px) for editorial impact. Body text stays at normal tracking for comfortable reading. +- **Two weights, clear roles**: 400 (body/UI/links/buttons) and 600 (headings/emphasis). The narrow weight range creates hierarchy through size and spacing, not weight variation. + +## 4. Component Stylings + +### Buttons + +**Primary Dark (Inset Shadow)** +- Background: `#1c1c1c` +- Text: `#fcfbf8` +- Padding: 8px 16px +- Radius: 6px +- Shadow: `rgba(0,0,0,0) 0px 0px 0px 0px, rgba(0,0,0,0) 0px 0px 0px 0px, rgba(255,255,255,0.2) 0px 0.5px 0px 0px inset, rgba(0,0,0,0.2) 0px 0px 0px 0.5px inset, rgba(0,0,0,0.05) 0px 1px 2px 0px` +- Active: opacity 0.8 +- Focus: `rgba(0,0,0,0.1) 0px 4px 12px` shadow +- Use: Primary CTA ("Start Building", "Get Started") + +**Ghost / Outline** +- Background: transparent +- Text: `#1c1c1c` +- Padding: 8px 16px +- Radius: 6px +- Border: `1px solid rgba(28,28,28,0.4)` +- Active: opacity 0.8 +- Focus: `rgba(0,0,0,0.1) 0px 4px 12px` shadow +- Use: Secondary actions ("Log In", "Documentation") + +**Cream Surface** +- Background: `#f7f4ed` +- Text: `#1c1c1c` +- Padding: 8px 16px +- Radius: 6px +- No border +- Active: opacity 0.8 +- Use: Tertiary actions, toolbar buttons + +**Pill / Icon Button** +- Background: `#f7f4ed` +- Text: `#1c1c1c` +- Radius: 9999px (full pill) +- Shadow: same inset pattern as primary dark +- Opacity: 0.5 (default), 0.8 (active) +- Use: Additional actions, plan mode toggle, voice recording + +### Cards & Containers +- Background: `#f7f4ed` (matches page) +- Border: `1px solid #eceae4` +- Radius: 12px (standard), 16px (featured), 8px (compact) +- No box-shadow by default — borders define boundaries +- Image cards: `1px solid #eceae4` with 12px radius + +### Inputs & Forms +- Background: `#f7f4ed` +- Text: `#1c1c1c` +- Border: `1px solid #eceae4` +- Radius: 6px +- Focus: ring blue (`rgba(59,130,246,0.5)`) outline +- Placeholder: `#5f5f5d` + +### Navigation +- Clean horizontal nav on cream background, fixed +- Logo/wordmark left-aligned (128.75 x 22px) +- Links: Camera Plain 14–16px weight 400, `#1c1c1c` text +- CTA: dark button with inset shadow, 6px radius +- Mobile: hamburger menu with 6px radius button +- Subtle border or no border on scroll + +### Links +- Color: `#1c1c1c` +- Decoration: underline (default) +- Hover: primary accent (via CSS variable `hsl(var(--primary))`) +- No color change on hover — decoration carries the interactive signal + +### Image Treatment +- Showcase/portfolio images with `1px solid #eceae4` border +- Consistent 12px border radius on all image containers +- Soft gradient backgrounds behind hero content (warm multi-color wash) +- Gallery-style presentation for template/project showcases + +### Distinctive Components + +**AI Chat Input** +- Large prompt input area with soft borders +- Suggestion pills with `#eceae4` borders +- Voice recording / plan mode toggle buttons as pill shapes (9999px) +- Warm, inviting input area — not clinical + +**Template Gallery** +- Card grid showing project templates +- Each card: image + title, `1px solid #eceae4` border, 12px radius +- Hover: subtle shadow or border darkening +- Category labels as text links + +**Stats Bar** +- Large metrics: "0M+" pattern in 48px+ weight 600 +- Descriptive text below in muted gray +- Horizontal layout with generous spacing + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 8px, 10px, 12px, 16px, 24px, 32px, 40px, 56px, 80px, 96px, 128px, 176px, 192px, 208px +- The scale expands generously at the top end — sections use 80px–208px vertical spacing for editorial breathing room + +### Grid & Container +- Max content width: approximately 1200px (centered) +- Hero: centered single-column with massive vertical padding (96px+) +- Feature sections: 2–3 column grids +- Full-width footer with multi-column link layout +- Showcase sections with centered card grids + +### Whitespace Philosophy +- **Editorial generosity**: Lovable's spacing is lavish at section boundaries (80px–208px). The warm cream background makes these expanses feel cozy rather than empty. +- **Content-driven rhythm**: Tight internal spacing within cards (12–24px) contrasts with wide section gaps, creating a reading rhythm that alternates between focused content and visual rest. +- **Section separation**: Footer uses `1px solid #eceae4` border and 16px radius container. Sections defined by generous spacing rather than border lines. + +### Border Radius Scale +- Micro (4px): Small buttons, interactive elements +- Standard (6px): Buttons, inputs, navigation menu +- Comfortable (8px): Compact cards, divs +- Card (12px): Standard cards, image containers, templates +- Container (16px): Large containers, footer sections +- Full Pill (9999px): Action pills, icon buttons, toggles + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, cream background | Page surface, most content | +| Bordered (Level 1) | `1px solid #eceae4` | Cards, images, dividers | +| Inset (Level 2) | `rgba(255,255,255,0.2) 0px 0.5px 0px inset, rgba(0,0,0,0.2) 0px 0px 0px 0.5px inset, rgba(0,0,0,0.05) 0px 1px 2px` | Dark buttons, primary actions | +| Focus (Level 3) | `rgba(0,0,0,0.1) 0px 4px 12px` | Active/focus states | +| Ring (Accessibility) | `rgba(59,130,246,0.5)` 2px ring | Keyboard focus on inputs | + +**Shadow Philosophy**: Lovable's depth system is intentionally shallow. Instead of floating cards with dramatic drop-shadows, the system relies on warm borders (`#eceae4`) against the cream surface to create gentle containment. The only notable shadow pattern is the inset shadow on dark buttons — a subtle multi-layer technique where a white highlight line sits at the top edge while a dark ring and soft drop handle the bottom. This creates a tactile, pressed-into-surface feeling rather than a hovering-above-surface feeling. The warm focus shadow (`rgba(0,0,0,0.1) 0px 4px 12px`) is deliberately diffused and large, creating a soft glow rather than a sharp outline. + +### Decorative Depth +- Hero: soft, warm multi-color gradient wash (pinks, oranges, blues) behind hero — atmospheric, barely visible +- Footer: gradient background with warm tones transitioning to the bottom +- No harsh section dividers — spacing and background warmth handle transitions + +## 7. Do's and Don'ts + +### Do +- Use the warm cream background (`#f7f4ed`) as the page foundation — it's the brand's signature warmth +- Use Camera Plain Variable at display sizes with negative letter-spacing (-0.9px to -1.5px) +- Derive all grays from `#1c1c1c` at varying opacity levels for tonal unity +- Use the inset shadow technique on dark buttons for tactile depth +- Use `#eceae4` borders instead of shadows for card containment +- Keep the weight system narrow: 400 for body/UI, 600 for headings +- Use full-pill radius (9999px) only for action pills and icon buttons +- Apply opacity 0.8 on active states for responsive tactile feedback + +### Don't +- Don't use pure white (`#ffffff`) as a page background — the cream is intentional +- Don't use heavy box-shadows for cards — borders are the containment mechanism +- Don't introduce saturated accent colors — the palette is intentionally warm-neutral +- Don't use weight 700 (bold) — 600 is the maximum weight in the system +- Don't apply 9999px radius on rectangular buttons — pills are for icon/action toggles +- Don't use sharp focus outlines — the system uses soft shadow-based focus indicators +- Don't mix border styles — `#eceae4` for passive, `rgba(28,28,28,0.4)` for interactive +- Don't increase letter-spacing on headings — Camera Plain is designed to run tight at scale + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <600px | Tight single column, reduced padding | +| Mobile | 600–640px | Standard mobile layout | +| Tablet Small | 640–700px | 2-column grids begin | +| Tablet | 700–768px | Card grids expand | +| Desktop Small | 768–1024px | Multi-column layouts | +| Desktop | 1024–1280px | Full feature layout | +| Large Desktop | 1280–1536px | Maximum content width, generous margins | + +### Touch Targets +- Buttons: 8px 16px padding (comfortable touch) +- Navigation: adequate spacing between items +- Pill buttons: 9999px radius creates large tap-friendly targets +- Menu toggle: 6px radius button with adequate sizing + +### Collapsing Strategy +- Hero: 60px → 48px → 36px headline scaling with proportional letter-spacing +- Navigation: horizontal links → hamburger menu at 768px +- Feature cards: 3-column → 2-column → single column stacked +- Template gallery: grid → stacked vertical cards +- Stats bar: horizontal → stacked vertical +- Footer: multi-column → stacked single column +- Section spacing: 128px+ → 64px on mobile + +### Image Behavior +- Template screenshots maintain `1px solid #eceae4` border at all sizes +- 12px border radius preserved across breakpoints +- Gallery images responsive with consistent aspect ratios +- Hero gradient softens/simplifies on mobile + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Charcoal (`#1c1c1c`) +- Background: Cream (`#f7f4ed`) +- Heading text: Charcoal (`#1c1c1c`) +- Body text: Muted Gray (`#5f5f5d`) +- Border: `#eceae4` (passive), `rgba(28,28,28,0.4)` (interactive) +- Focus: `rgba(0,0,0,0.1) 0px 4px 12px` +- Button text on dark: `#fcfbf8` + +### Example Component Prompts +- "Create a hero section on cream background (#f7f4ed). Headline at 60px Camera Plain Variable weight 600, line-height 1.10, letter-spacing -1.5px, color #1c1c1c. Subtitle at 18px weight 400, line-height 1.38, color #5f5f5d. Dark CTA button (#1c1c1c bg, #fcfbf8 text, 6px radius, 8px 16px padding, inset shadow) and ghost button (transparent bg, 1px solid rgba(28,28,28,0.4) border, 6px radius)." +- "Design a card on cream (#f7f4ed) background. Border: 1px solid #eceae4. Radius 12px. No box-shadow. Title at 20px Camera Plain Variable weight 400, line-height 1.25, color #1c1c1c. Body at 14px weight 400, color #5f5f5d." +- "Build a template gallery: grid of cards with 12px radius, 1px solid #eceae4 border, cream backgrounds. Each card: image with 12px top radius, title below. Hover: subtle border darkening." +- "Create navigation: sticky on cream (#f7f4ed). Camera Plain 16px weight 400 for links, #1c1c1c text. Dark CTA button right-aligned with inset shadow. Mobile: hamburger menu with 6px radius." +- "Design a stats section: large numbers at 48px Camera Plain weight 600, letter-spacing -1.2px, #1c1c1c. Labels below at 16px weight 400, #5f5f5d. Horizontal layout with 32px gap." + +### Iteration Guide +1. Always use cream (`#f7f4ed`) as the base — never pure white +2. Derive grays from `#1c1c1c` at opacity levels rather than using distinct hex values +3. Use `#eceae4` borders for containment, not shadows +4. Letter-spacing scales with size: -1.5px at 60px, -1.2px at 48px, -0.9px at 36px, normal at 16px +5. Two weights: 400 (everything except headings) and 600 (headings) +6. The inset shadow on dark buttons is the signature detail — don't skip it +7. Camera Plain Variable at weight 480 is for special display moments only diff --git a/skills/creative/popular-web-designs/templates/minimax.md b/skills/creative/popular-web-designs/templates/minimax.md new file mode 100644 index 000000000000..77c89ed0f2fb --- /dev/null +++ b/skills/creative/popular-web-designs/templates/minimax.md @@ -0,0 +1,270 @@ +# Design System: MiniMax + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +MiniMax's website is a clean, product-showcase platform for a Chinese AI technology company that bridges consumer-friendly appeal with technical credibility. The design language is predominantly white-space-driven with a light, airy feel — pure white backgrounds (`#ffffff`) dominate, letting colorful product cards and AI model illustrations serve as the visual anchors. The overall aesthetic sits at the intersection of Apple's product marketing clarity and a playful, rounded design language that makes AI technology feel approachable. + +The typography system is notably multi-font: DM Sans serves as the primary UI workhorse, Outfit handles display headings with geometric elegance, Poppins appears for mid-tier headings, and Roboto handles data-heavy contexts. This variety reflects a brand in rapid growth — each font serves a distinct communicative purpose rather than competing for attention. The hero heading at 80px weight 500 in both DM Sans and Outfit with a tight 1.10 line-height creates a bold but not aggressive opening statement. + +What makes MiniMax distinctive is its pill-button geometry (9999px radius) for navigation and primary actions, combined with softer 8px–24px radiused cards for product showcases. The product cards themselves are richly colorful — vibrant gradients in pink, purple, orange, and blue — creating a "gallery of AI capabilities" feel. Against the white canvas, these colorful cards pop like app icons on a phone home screen, making each AI model/product feel like a self-contained creative tool. + +**Key Characteristics:** +- White-dominant layout with colorful product card accents +- Multi-font system: DM Sans (UI), Outfit (display), Poppins (mid-tier), Roboto (data) +- Pill buttons (9999px radius) for primary navigation and CTAs +- Generous rounded cards (20px–24px radius) for product showcases +- Brand blue spectrum: from `#1456f0` (brand-6) through `#3b82f6` (primary-500) to `#60a5fa` (light) +- Brand pink (`#ea5ec1`) as secondary accent +- Near-black text (`#222222`, `#18181b`) on white backgrounds +- Purple-tinted shadows (`rgba(44, 30, 116, 0.16)`) creating subtle brand-colored depth +- Dark footer section (`#181e25`) with product/company links + +## 2. Color Palette & Roles + +### Brand Primary +- **Brand Blue** (`#1456f0`): `--brand-6`, primary brand identity color +- **Sky Blue** (`#3daeff`): `--col-brand00`, lighter brand variant for accents +- **Brand Pink** (`#ea5ec1`): `--col-brand02`, secondary brand accent + +### Blue Scale (Primary) +- **Primary 200** (`#bfdbfe`): `--color-primary-200`, light blue backgrounds +- **Primary Light** (`#60a5fa`): `--color-primary-light`, active states, highlights +- **Primary 500** (`#3b82f6`): `--color-primary-500`, standard blue actions +- **Primary 600** (`#2563eb`): `--color-primary-600`, hover states +- **Primary 700** (`#1d4ed8`): `--color-primary-700`, pressed/active states +- **Brand Deep** (`#17437d`): `--brand-3`, deep blue for emphasis + +### Text Colors +- **Near Black** (`#222222`): `--col-text00`, primary text +- **Dark** (`#18181b`): Button text, headings +- **Charcoal** (`#181e25`): Dark surface text, footer background +- **Dark Gray** (`#45515e`): `--col-text04`, secondary text +- **Mid Gray** (`#8e8e93`): Tertiary text, muted labels +- **Light Gray** (`#5f5f5f`): `--brand-2`, helper text + +### Surface & Background +- **Pure White** (`#ffffff`): `--col-bg13`, primary background +- **Light Gray** (`#f0f0f0`): Secondary button backgrounds +- **Glass White** (`hsla(0, 0%, 100%, 0.4)`): `--fill-bg-white`, frosted glass overlay +- **Border Light** (`#f2f3f5`): Subtle section dividers +- **Border Gray** (`#e5e7eb`): Component borders + +### Semantic +- **Success Background** (`#e8ffea`): `--success-bg`, positive state backgrounds + +### Shadows +- **Standard** (`rgba(0, 0, 0, 0.08) 0px 4px 6px`): Default card shadow +- **Soft Glow** (`rgba(0, 0, 0, 0.08) 0px 0px 22.576px`): Ambient soft shadow +- **Brand Purple** (`rgba(44, 30, 116, 0.16) 0px 0px 15px`): Brand-tinted glow +- **Brand Purple Offset** (`rgba(44, 30, 116, 0.11) 6.5px 2px 17.5px`): Directional brand glow +- **Card Elevation** (`rgba(36, 36, 36, 0.08) 0px 12px 16px -4px`): Lifted card shadow + +## 3. Typography Rules + +### Font Families +- **Primary UI**: `DM Sans`, with fallbacks: `Helvetica Neue, Helvetica, Arial` +- **Display**: `Outfit`, with fallbacks: `Helvetica Neue, Helvetica, Arial` +- **Mid-tier**: `Poppins` +- **Data/Technical**: `Roboto`, with fallbacks: `Helvetica Neue, Helvetica, Arial` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Notes | +|------|------|------|--------|-------------|-------| +| Display Hero | DM Sans / Outfit | 80px (5.00rem) | 500 | 1.10 (tight) | Hero headlines | +| Section Heading | Outfit | 31px (1.94rem) | 600 | 1.50 | Feature section titles | +| Section Heading Alt | Roboto / DM Sans | 32px (2.00rem) | 600 | 0.88 (tight) | Compact headers | +| Card Title | Outfit | 28px (1.75rem) | 500–600 | 1.71 (relaxed) | Product card headings | +| Sub-heading | Poppins | 24px (1.50rem) | 500 | 1.50 | Mid-tier headings | +| Feature Label | Poppins | 18px (1.13rem) | 500 | 1.50 | Feature names | +| Body Large | DM Sans | 20px (1.25rem) | 500 | 1.50 | Emphasized body | +| Body | DM Sans | 16px (1.00rem) | 400–500 | 1.50 | Standard body text | +| Body Bold | DM Sans | 16px (1.00rem) | 700 | 1.50 | Strong emphasis | +| Nav/Link | DM Sans | 14px (0.88rem) | 400–500 | 1.50 | Navigation, links | +| Button Small | DM Sans | 13px (0.81rem) | 600 | 1.50 | Compact buttons | +| Caption | DM Sans / Poppins | 13px (0.81rem) | 400 | 1.70 (relaxed) | Metadata | +| Small Label | DM Sans | 12px (0.75rem) | 500–600 | 1.25–1.50 | Tags, badges | +| Micro | DM Sans / Outfit | 10px (0.63rem) | 400–500 | 1.50–1.80 | Tiny annotations | + +### Principles +- **Multi-font purpose**: DM Sans = UI workhorse (body, nav, buttons); Outfit = geometric display (headings, product names); Poppins = friendly mid-tier (sub-headings, features); Roboto = technical/data contexts. +- **Universal 1.50 line-height**: The overwhelming majority of text uses 1.50 line-height, creating a consistent reading rhythm regardless of font or size. Exceptions: display (1.10 tight) and some captions (1.70 relaxed). +- **Weight 500 as default emphasis**: Most headings use 500 (medium) rather than bold, creating a modern, approachable tone. 600 for section titles, 700 reserved for strong emphasis. +- **Compact hierarchy**: The size scale jumps from 80px display straight to 28–32px section, then 16–20px body — a deliberate compression that keeps the visual hierarchy feeling efficient. + +## 4. Component Stylings + +### Buttons + +**Pill Primary Dark** +- Background: `#181e25` +- Text: `#ffffff` +- Padding: 11px 20px +- Radius: 8px +- Use: Primary CTA ("Get Started", "Learn More") + +**Pill Nav** +- Background: `rgba(0, 0, 0, 0.05)` (subtle tint) +- Text: `#18181b` +- Radius: 9999px (full pill) +- Use: Navigation tabs, filter toggles + +**Pill White** +- Background: `#ffffff` +- Text: `rgba(24, 30, 37, 0.8)` +- Radius: 9999px +- Opacity: 0.5 (default state) +- Use: Secondary nav, inactive tabs + +**Secondary Light** +- Background: `#f0f0f0` +- Text: `#333333` +- Padding: 11px 20px +- Radius: 8px +- Use: Secondary actions + +### Product Cards +- Background: Vibrant gradients (pink/purple/orange/blue) +- Radius: 20px–24px (generous rounding) +- Shadow: `rgba(44, 30, 116, 0.16) 0px 0px 15px` (brand purple glow) +- Content: Product name, model version, descriptive text +- Each card has its own color palette matching the product identity + +### AI Product Cards (Matrix) +- Background: white with subtle shadow +- Radius: 13px–16px +- Shadow: `rgba(0, 0, 0, 0.08) 0px 4px 6px` +- Icon/illustration centered above product name +- Product name in DM Sans 14–16px weight 500 + +### Links +- **Primary**: `#18181b` or `#181e25`, underline on dark text +- **Secondary**: `#8e8e93`, muted for less emphasis +- **On Dark**: `rgba(255, 255, 255, 0.8)` for footer and dark sections + +### Navigation +- Clean horizontal nav on white background +- MiniMax logo left-aligned (red accent in logo) +- DM Sans 14px weight 500 for nav items +- Pill-shaped active indicators (9999px radius) +- "Login" text link, minimal right-side actions +- Sticky header behavior + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 6px, 8px, 10px, 11px, 14px, 16px, 24px, 32px, 40px, 50px, 64px, 80px + +### Grid & Container +- Max content width centered on page +- Product card grids: horizontal scroll or 3–4 column layout +- Full-width white sections with contained content +- Dark footer at full-width + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <768px | Single column, stacked cards | +| Tablet | 768–1024px | 2-column grids | +| Desktop | >1024px | Full layout, horizontal card scrolls | + +### Whitespace Philosophy +- **Gallery spacing**: Products are presented like gallery items with generous white space between cards, letting each AI model breathe as its own showcase. +- **Section rhythm**: Large vertical gaps (64px–80px) between major sections create distinct "chapters" of content. +- **Card breathing**: Product cards use internal padding of 16px–24px with ample whitespace around text. + +### Border Radius Scale +- Minimal (4px): Small tags, micro badges +- Standard (8px): Buttons, small cards +- Comfortable (11px–13px): Medium cards, panels +- Generous (16px–20px): Large product cards +- Large (22px–24px): Hero product cards, major containers +- Pill (30px–32px): Badge pills, rounded panels +- Full (9999px): Buttons, nav tabs + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | White background, text blocks | +| Subtle (Level 1) | `rgba(0, 0, 0, 0.08) 0px 4px 6px` | Standard cards, containers | +| Ambient (Level 2) | `rgba(0, 0, 0, 0.08) 0px 0px 22.576px` | Soft glow around elements | +| Brand Glow (Level 3) | `rgba(44, 30, 116, 0.16) 0px 0px 15px` | Featured product cards | +| Elevated (Level 4) | `rgba(36, 36, 36, 0.08) 0px 12px 16px -4px` | Lifted cards, hover states | + +**Shadow Philosophy**: MiniMax uses a distinctive purple-tinted shadow (`rgba(44, 30, 116, ...)`) for featured elements, creating a subtle brand-color glow that connects the shadow system to the blue brand identity. Standard shadows use neutral black but at low opacity (0.08), keeping everything feeling light and airy. The directional shadow variant (6.5px offset) adds dimensional interest to hero product cards. + +## 7. Do's and Don'ts + +### Do +- Use white as the dominant background — let product cards provide the color +- Apply pill radius (9999px) for navigation tabs and toggle buttons +- Use generous border radius (20px–24px) for product showcase cards +- Employ the purple-tinted shadow for featured/hero product cards +- Keep body text at DM Sans weight 400–500 — heavier weights for buttons only +- Use Outfit for display headings, DM Sans for everything functional +- Maintain the universal 1.50 line-height across body text +- Let colorful product illustrations/gradients serve as the primary visual interest + +### Don't +- Don't add colored backgrounds to main content sections — white is structural +- Don't use sharp corners (0–4px radius) on product cards — the rounded aesthetic is core +- Don't apply the brand pink (`#ea5ec1`) to text or buttons — it's for logo and decorative accents only +- Don't mix more than one display font per section (Outfit OR Poppins, not both) +- Don't use weight 700 for headings — 500–600 is the range, 700 is reserved for strong emphasis in body text +- Don't darken shadows beyond 0.16 opacity — the light, airy feel requires restraint +- Don't use Roboto for headings — it's the data/technical context font only + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <768px | Single column, stacked product cards, hamburger nav | +| Tablet | 768–1024px | 2-column product grids, condensed spacing | +| Desktop | >1024px | Full horizontal card layouts, expanded spacing | + +### Collapsing Strategy +- Hero: 80px → responsive scaling to ~40px on mobile +- Product card grid: horizontal scroll → 2-column → single column stacked +- Navigation: horizontal → hamburger menu +- Footer: multi-column → stacked sections +- Spacing: 64–80px gaps → 32–40px on mobile + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: `#ffffff` (primary), `#181e25` (dark/footer) +- Text: `#222222` (primary), `#45515e` (secondary), `#8e8e93` (muted) +- Brand Blue: `#1456f0` (brand), `#3b82f6` (primary-500), `#2563eb` (hover) +- Brand Pink: `#ea5ec1` (accent only) +- Borders: `#e5e7eb`, `#f2f3f5` + +### Example Component Prompts +- "Create a hero section on white background. Headline at 80px Outfit weight 500, line-height 1.10, near-black (#222222) text. Sub-text at 16px DM Sans weight 400, line-height 1.50, #45515e. Dark CTA button (#181e25, 8px radius, 11px 20px padding, white text)." +- "Design a product card grid: white cards with 20px border-radius, shadow rgba(44,30,116,0.16) 0px 0px 15px. Product name at 28px Outfit weight 600. Internal gradient background for the product illustration area." +- "Build navigation bar: white background, DM Sans 14px weight 500 for links, #18181b text. Pill-shaped active tab (9999px radius, rgba(0,0,0,0.05) background). MiniMax logo left-aligned." +- "Create an AI product matrix: 4-column grid of cards with 13px radius, subtle shadow rgba(0,0,0,0.08) 0px 4px 6px. Centered icon above product name in DM Sans 16px weight 500." +- "Design footer on dark (#181e25) background. Product links in DM Sans 14px, rgba(255,255,255,0.8). Multi-column layout." + +### Iteration Guide +1. Start with white — color comes from product cards and illustrations only +2. Pill buttons (9999px) for nav/tabs, standard radius (8px) for CTA buttons +3. Purple-tinted shadows for featured cards, neutral shadows for everything else +4. DM Sans handles 70% of text — Outfit is display-only, Poppins is mid-tier only +5. Keep weights moderate (500–600 for headings) — the brand tone is confident but approachable +6. Large radius cards (20–24px) for products, smaller radius (8–13px) for UI elements diff --git a/skills/creative/popular-web-designs/templates/mintlify.md b/skills/creative/popular-web-designs/templates/mintlify.md new file mode 100644 index 000000000000..5ea730d29d11 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/mintlify.md @@ -0,0 +1,339 @@ +# Design System: Mintlify + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `Geist Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Mintlify's website is a study in documentation-as-product design — a white, airy, information-rich surface that treats clarity as its highest aesthetic value. The page opens with a luminous white (`#ffffff`) background, near-black (`#0d0d0d`) text, and a signature green brand accent (`#18E299`) that signals freshness and intelligence without dominating the palette. The overall mood is calm, confident, and engineered for legibility — a design system that whispers "we care about your developer experience" in every pixel. + +The Inter font family carries the entire typographic load. At display sizes (40–64px), it uses tight negative letter-spacing (-0.8px to -1.28px) and semibold weight (600), creating headlines that feel focused and compressed like well-written documentation headers. Body text at 16–18px with 150% line-height provides generous reading comfort. Geist Mono appears exclusively for code and technical labels — uppercase, tracked-out, small — the voice of the terminal inside the marketing page. + +What distinguishes Mintlify from other documentation platforms is its atmospheric gradient hero. A soft, cloud-like green-to-white gradient wash behind the hero content creates a sense of ethereal intelligence — documentation that floats above the noise. Below the hero, the page settles into a disciplined alternation of white sections separated by subtle 5% opacity borders. Cards use generous padding (24px+) with large radii (16px–24px) and whisper-thin borders, creating containers that feel open rather than boxed. + +**Key Characteristics:** +- Inter with tight negative tracking at display sizes (-0.8px to -1.28px) — compressed yet readable +- Geist Mono for code labels: uppercase, 12px, tracked-out, the terminal voice +- Brand green (`#18E299`) used sparingly — CTAs, hover states, focus rings, and accent touches +- Atmospheric gradient hero with cloud-like green-white wash +- Ultra-round corners: 16px for containers, 24px for featured cards, full-round (9999px) for buttons and pills +- Subtle 5% opacity borders (`rgba(0,0,0,0.05)`) creating barely-there separation +- 8px base spacing system with generous section padding (48px–96px) +- Clean white canvas — no gray backgrounds, no color sections, depth through borders and whitespace alone + +## 2. Color Palette & Roles + +### Primary +- **Near Black** (`#0d0d0d`): Primary text, headings, dark surfaces. Not pure black — the micro-softness improves reading comfort. +- **Pure White** (`#ffffff`): Page background, card surfaces, input backgrounds. +- **Brand Green** (`#18E299`): The signature accent — CTAs, links on hover, focus rings, brand identity. + +### Secondary Accents +- **Brand Green Light** (`#d4fae8`): Tinted green surface for badges, hover states, subtle backgrounds. +- **Brand Green Deep** (`#0fa76e`): Darker green for text on light-green badges, hover states on brand elements. +- **Warm Amber** (`#c37d0d`): Warning states, caution badges — `--twoslash-warn-bg`. +- **Soft Blue** (`#3772cf`): Tag backgrounds, informational annotations — `--twoslash-tag-bg`. +- **Error Red** (`#d45656`): Error states, destructive actions — `--twoslash-error-bg`. + +### Neutral Scale +- **Gray 900** (`#0d0d0d`): Primary heading text, nav links. +- **Gray 700** (`#333333`): Secondary text, descriptions, body copy. +- **Gray 500** (`#666666`): Tertiary text, muted labels. +- **Gray 400** (`#888888`): Placeholder text, disabled states, code annotations. +- **Gray 200** (`#e5e5e5`): Borders, dividers, card outlines. +- **Gray 100** (`#f5f5f5`): Subtle surface backgrounds, hover states. +- **Gray 50** (`#fafafa`): Near-white surface tint. + +### Interactive +- **Link Default** (`#0d0d0d`): Links match text color, relying on underline/context. +- **Link Hover** (`#18E299`): Brand green on hover — `var(--color-brand)`. +- **Focus Ring** (`#18E299`): Brand green focus outline for inputs and interactive elements. + +### Surface & Overlay +- **Card Background** (`#ffffff`): White cards on white background, separated by borders. +- **Border Subtle** (`rgba(0,0,0,0.05)`): 5% black opacity borders — the primary separation mechanism. +- **Border Medium** (`rgba(0,0,0,0.08)`): Slightly stronger borders for interactive elements. +- **Input Border Focus** (`var(--color-brand)`): Green ring on focused inputs. + +### Shadows & Depth +- **Card Shadow** (`rgba(0,0,0,0.03) 0px 2px 4px`): Barely-there ambient shadow for subtle lift. +- **Button Shadow** (`rgba(0,0,0,0.06) 0px 1px 2px`): Micro-shadow for button depth. +- **No heavy shadows**: Mintlify relies on borders, not shadows, for depth. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Inter`, with fallback: `Inter Fallback, system-ui, -apple-system, sans-serif` +- **Monospace**: `Geist Mono`, with fallback: `Geist Mono Fallback, ui-monospace, SFMono-Regular, monospace` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Inter | 64px (4.00rem) | 600 | 1.15 (tight) | -1.28px | Maximum impact, hero headlines | +| Section Heading | Inter | 40px (2.50rem) | 600 | 1.10 (tight) | -0.8px | Feature section titles | +| Sub-heading | Inter | 24px (1.50rem) | 500 | 1.30 (tight) | -0.24px | Card headings, sub-sections | +| Card Title | Inter | 20px (1.25rem) | 600 | 1.30 (tight) | -0.2px | Feature card titles | +| Card Title Light | Inter | 20px (1.25rem) | 500 | 1.30 (tight) | -0.2px | Secondary card headings | +| Body Large | Inter | 18px (1.13rem) | 400 | 1.50 | normal | Hero descriptions, introductions | +| Body | Inter | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text | +| Body Medium | Inter | 16px (1.00rem) | 500 | 1.50 | normal | Navigation, emphasized text | +| Button | Inter | 15px (0.94rem) | 500 | 1.50 | normal | Button labels | +| Link | Inter | 14px (0.88rem) | 500 | 1.50 | normal | Navigation links, small CTAs | +| Caption | Inter | 14px (0.88rem) | 400–500 | 1.50–1.71 | normal | Metadata, descriptions | +| Label Uppercase | Inter | 13px (0.81rem) | 500 | 1.50 | 0.65px | `text-transform: uppercase`, section labels | +| Small | Inter | 13px (0.81rem) | 400–500 | 1.50 | -0.26px | Small body text | +| Mono Code | Geist Mono | 12px (0.75rem) | 500 | 1.50 | 0.6px | `text-transform: uppercase`, technical labels | +| Mono Badge | Geist Mono | 12px (0.75rem) | 600 | 1.50 | 0.6px | `text-transform: uppercase`, status badges | +| Mono Micro | Geist Mono | 10px (0.63rem) | 500 | 1.50 | normal | `text-transform: uppercase`, tiny labels | + +### Principles +- **Tight tracking at display sizes**: Inter at 40–64px uses -0.8px to -1.28px letter-spacing. This compression creates headlines that feel deliberate and space-efficient — documentation headings, not billboard copy. +- **Relaxed reading at body sizes**: 16–18px body text uses normal tracking with 150% line-height, creating generous reading lanes. Documentation demands comfort. +- **Two-font system**: Inter for all human-readable content, Geist Mono exclusively for technical/code contexts. The boundary is strict — no mixing. +- **Uppercase as hierarchy signal**: Section labels and technical tags use uppercase + positive tracking (0.6px–0.65px) as a clear visual delimiter between content types. +- **Three weights**: 400 (body/reading), 500 (UI/navigation/emphasis), 600 (headings/titles). No bold (700) in the system. + +## 4. Component Stylings + +### Buttons + +**Primary Brand (Full-round)** +- Background: `#0d0d0d` (near-black) +- Text: `#ffffff` +- Padding: 8px 24px +- Radius: 9999px (full pill) +- Font: Inter 15px weight 500 +- Shadow: `rgba(0,0,0,0.06) 0px 1px 2px` +- Hover: opacity 0.9 +- Use: Primary CTA ("Get Started", "Start Building") + +**Secondary / Ghost (Full-round)** +- Background: `#ffffff` +- Text: `#0d0d0d` +- Padding: 4.5px 12px +- Radius: 9999px (full pill) +- Border: `1px solid rgba(0,0,0,0.08)` +- Font: Inter 15px weight 500 +- Hover: opacity 0.9 +- Use: Secondary actions ("Request Demo", "View Docs") + +**Transparent / Nav Button** +- Background: transparent +- Text: `#0d0d0d` +- Padding: 5px 6px +- Radius: 8px +- Border: none or `1px solid rgba(0,0,0,0.05)` +- Use: Navigation items, icon buttons + +**Brand Accent Button** +- Background: `#18E299` +- Text: `#0d0d0d` +- Padding: 8px 24px +- Radius: 9999px +- Use: Special promotional CTAs + +### Cards & Containers + +**Standard Card** +- Background: `#ffffff` +- Border: `1px solid rgba(0,0,0,0.05)` +- Radius: 16px +- Padding: 24px +- Shadow: `rgba(0,0,0,0.03) 0px 2px 4px` +- Hover: subtle border darkening to `rgba(0,0,0,0.08)` + +**Featured Card** +- Background: `#ffffff` +- Border: `1px solid rgba(0,0,0,0.05)` +- Radius: 24px +- Padding: 32px +- Inner content areas may have their own 16px radius containers + +**Logo/Trust Card** +- Background: `#fafafa` or `#ffffff` +- Border: `1px solid rgba(0,0,0,0.05)` +- Radius: 16px +- Centered logo/icon with consistent sizing + +### Inputs & Forms + +**Email Input** +- Background: transparent or `#ffffff` +- Text: `#0d0d0d` +- Padding: 0px 12px (height controlled by line-height) +- Border: `1px solid rgba(0,0,0,0.08)` +- Radius: 9999px (full pill, matching buttons) +- Focus: `1px solid var(--color-brand)` + `outline: 1px solid var(--color-brand)` +- Placeholder: `#888888` + +### Navigation +- Clean horizontal nav on white, sticky with backdrop blur +- Brand logotype left-aligned +- Links: Inter 14–15px weight 500, `#0d0d0d` text +- Hover: color shifts to brand green `var(--color-brand)` +- CTA: dark pill button right-aligned ("Get Started") +- Mobile: hamburger menu collapse at 768px + +### Image Treatment +- Product screenshots with subtle 1px borders +- Rounded containers: 16px–24px radius +- Atmospheric gradient backgrounds behind hero images +- Cloud/sky imagery with soft green tinting + +### Distinctive Components + +**Atmospheric Hero** +- Full-width gradient wash: soft green-to-white cloud-like gradient +- Centered headline with tight tracking +- Subtitle in muted gray +- Dual CTA buttons (dark primary + ghost secondary) +- The gradient creates a sense of elevation and intelligence + +**Trust Bar / Logo Grid** +- "Loved by your favorite companies" section +- Company logos in muted grayscale +- Grid or horizontal layout with consistent sizing +- Subtle border separation between logos + +**Feature Cards with Icons** +- Icon or illustration at top +- Title at 20px weight 600 +- Description at 14–16px in gray +- Consistent padding and border treatment +- Grid layout: 2–3 columns on desktop + +**CTA Footer Section** +- Dark or gradient background +- Large headline: "Make documentation your winning advantage" +- Email input with pill styling +- Brand green accent on CTAs + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 4px, 5px, 6px, 7px, 8px, 10px, 12px, 16px, 24px, 32px, 48px, 64px +- Section padding: 48px–96px vertical +- Card padding: 24px–32px +- Component gaps: 8px–16px + +### Grid & Container +- Max content width: approximately 1200px +- Hero: centered single-column with generous top padding (96px+) +- Feature sections: 2–3 column CSS Grid for cards +- Full-width sections with contained content +- Consistent horizontal padding: 24px (mobile) to 32px (desktop) + +### Whitespace Philosophy +- **Documentation-grade breathing room**: Every element has generous surrounding whitespace. Mintlify sells documentation, so the marketing page itself demonstrates reading comfort. +- **Sections as chapters**: Each feature section is a self-contained unit with 48px–96px vertical padding, creating clear "chapter breaks." +- **Content density is low**: Unlike developer tools that pack the page, Mintlify uses 1–2 key messages per section with supporting imagery. + +### Border Radius Scale +- Small (4px): Inline code, small tags, tooltips +- Medium (8px): Nav buttons, transparent buttons, small containers +- Standard (16px): Cards, content containers, image wrappers +- Large (24px): Featured cards, hero containers, section panels +- Full Pill (9999px): Buttons, inputs, badges, pills — the signature shape + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background, text blocks | +| Subtle Border (Level 1) | `1px solid rgba(0,0,0,0.05)` | Standard card borders, dividers | +| Medium Border (Level 1b) | `1px solid rgba(0,0,0,0.08)` | Interactive elements, input borders | +| Ambient Shadow (Level 2) | `rgba(0,0,0,0.03) 0px 2px 4px` | Cards with subtle lift | +| Button Shadow (Level 2b) | `rgba(0,0,0,0.06) 0px 1px 2px` | Button micro-depth | +| Focus Ring (Accessibility) | `1px solid #18E299` outline | Focused inputs, active interactive elements | + +**Shadow Philosophy**: Mintlify barely uses shadows. The depth system is almost entirely border-driven — ultra-subtle 5% opacity borders create separation without visual weight. When shadows appear, they're atmospheric whispers (`0.03 opacity, 2px blur, 4px spread`) that add the barest sense of lift. This restraint keeps the page feeling flat and paper-like — appropriate for a documentation company whose product is about clarity and readability. + +### Decorative Depth +- Hero gradient: atmospheric green-white cloud gradient behind hero content +- No background color alternation — white on white throughout +- Depth comes from border opacity variation (5% → 8%) and whitespace + +## 7. Dark Mode + +### Color Inversions +- **Background**: `#0d0d0d` (near-black) +- **Text Primary**: `#ededed` (near-white) +- **Text Secondary**: `#a0a0a0` (muted gray) +- **Brand Green**: `#18E299` (unchanged — the green works on both backgrounds) +- **Border**: `rgba(255,255,255,0.08)` (white at 8% opacity) +- **Card Background**: `#141414` (slightly lighter than page) +- **Shadow**: `rgba(0,0,0,0.4) 0px 2px 4px` (stronger shadow for contrast) + +### Key Adjustments +- Buttons invert: white background dark text becomes dark background light text +- Badge backgrounds shift to deeper tones with lighter text +- Focus ring remains brand green +- Hero gradient shifts to dark-tinted green atmospheric wash + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <768px | Single column, stacked layout, hamburger nav | +| Tablet | 768–1024px | Two-column grids begin, expanded padding | +| Desktop | >1024px | Full layout, 3-column grids, maximum content width | + +### Touch Targets +- Buttons with full-pill shape have comfortable 8px+ vertical padding +- Navigation links spaced with adequate 16px+ gaps +- Mobile menu provides full-width tap targets + +### Collapsing Strategy +- Hero: 64px → 40px headline, maintains tight tracking proportionally +- Navigation: horizontal links + CTA → hamburger menu at 768px +- Feature cards: 3-column → 2-column → single column stacked +- Section spacing: 96px → 48px on mobile +- Footer: multi-column → stacked single column +- Trust bar: grid → horizontal scroll or stacked + +### Image Behavior +- Product screenshots maintain aspect ratio with responsive containers +- Hero gradient simplifies on mobile +- Full-width sections maintain edge-to-edge treatment + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Near Black (`#0d0d0d`) +- Background: Pure White (`#ffffff`) +- Heading text: Near Black (`#0d0d0d`) +- Body text: Gray 700 (`#333333`) +- Border: `rgba(0,0,0,0.05)` (5% opacity) +- Brand accent: Green (`#18E299`) +- Link hover: Brand Green (`#18E299`) +- Focus ring: Brand Green (`#18E299`) + +### Example Component Prompts +- "Create a hero section on white background with atmospheric green-white gradient wash. Headline at 64px Inter weight 600, line-height 1.15, letter-spacing -1.28px, color #0d0d0d. Subtitle at 18px Inter weight 400, line-height 1.50, color #666666. Dark pill CTA (#0d0d0d, 9999px radius, 8px 24px padding) and ghost pill button (white, 1px solid rgba(0,0,0,0.08), 9999px radius)." +- "Design a card: white background, 1px solid rgba(0,0,0,0.05) border, 16px radius, 24px padding, shadow rgba(0,0,0,0.03) 0px 2px 4px. Title at 20px Inter weight 600, letter-spacing -0.2px. Body at 14px weight 400, #666666." +- "Build a pill badge: #d4fae8 background, #0fa76e text, 9999px radius, 4px 12px padding, 13px Inter weight 500, uppercase." +- "Create navigation: white sticky header with backdrop-filter blur(12px). Inter 15px weight 500 for links, #0d0d0d text. Dark pill CTA 'Get Started' right-aligned, 9999px radius. Bottom border: 1px solid rgba(0,0,0,0.05)." +- "Design a trust section showing company logos in muted gray. Grid layout with 16px radius containers, 1px border at 5% opacity. Label above: 'Loved by your favorite companies' at 13px Inter weight 500, uppercase, tracking 0.65px." + +### Iteration Guide +1. Always use full-pill radius (9999px) for buttons and inputs — this is Mintlify's signature shape +2. Keep borders at 5% opacity (`rgba(0,0,0,0.05)`) — stronger borders break the airy feeling +3. Letter-spacing scales with font size: -1.28px at 64px, -0.8px at 40px, -0.24px at 24px, normal at 16px +4. Three weights only: 400 (read), 500 (interact), 600 (announce) +5. Brand green (`#18E299`) is used sparingly — CTAs and hover states only, never for decorative fills +6. Geist Mono uppercase for technical labels, Inter for everything else +7. Section padding is generous: 64px–96px on desktop, 48px on mobile +8. No gray background sections — white throughout, separation through borders and whitespace diff --git a/skills/creative/popular-web-designs/templates/miro.md b/skills/creative/popular-web-designs/templates/miro.md new file mode 100644 index 000000000000..4b3b86d6940f --- /dev/null +++ b/skills/creative/popular-web-designs/templates/miro.md @@ -0,0 +1,121 @@ +# Design System: Miro + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Miro's website is a clean, collaborative-tool-forward platform that communicates "visual thinking" through generous whitespace, pastel accent colors, and a confident geometric font. The design uses a predominantly white canvas with near-black text (`#1c1c1e`) and a distinctive pastel color palette — coral, rose, teal, orange, yellow, moss — each representing different collaboration contexts. + +The typography uses Roobert PRO Medium as the primary display font with OpenType character variants (`"blwf", "cv03", "cv04", "cv09", "cv11"`) and negative letter-spacing (-1.68px at 56px). Noto Sans handles body text with its own stylistic set (`"liga" 0, "ss01", "ss04", "ss05"`). The design is built with Framer, giving it smooth animations and modern component patterns. + +**Key Characteristics:** +- White canvas with near-black (`#1c1c1e`) text +- Roobert PRO Medium with multiple OpenType character variants +- Pastel accent palette: coral, rose, teal, orange, yellow, moss (light + dark pairs) +- Blue 450 (`#5b76fe`) as primary interactive color +- Success green (`#00b473`) for positive states +- Generous border-radius: 8px–50px range +- Framer-built with smooth motion patterns +- Ring shadow border: `rgb(224,226,232) 0px 0px 0px 1px` + +## 2. Color Palette & Roles + +### Primary +- **Near Black** (`#1c1c1e`): Primary text +- **White** (`#ffffff`): `--tw-color-white`, primary surface +- **Blue 450** (`#5b76fe`): `--tw-color-blue-450`, primary interactive +- **Actionable Pressed** (`#2a41b6`): `--tw-color-actionable-pressed` + +### Pastel Accents (Light/Dark pairs) +- **Coral**: Light `#ffc6c6` / Dark `#600000` +- **Rose**: Light `#ffd8f4` / Dark (implied) +- **Teal**: Light `#c3faf5` / Dark `#187574` +- **Orange**: Light `#ffe6cd` +- **Yellow**: Dark `#746019` +- **Moss**: Dark `#187574` +- **Pink** (`#fde0f0`): Soft pink surface +- **Red** (`#fbd4d4`): Light red surface +- **Dark Red** (`#e3c5c5`): Muted red + +### Semantic +- **Success** (`#00b473`): `--tw-color-success-accent` + +### Neutral +- **Slate** (`#555a6a`): Secondary text +- **Input Placeholder** (`#a5a8b5`): `--tw-color-input-placeholder` +- **Border** (`#c7cad5`): Button borders +- **Ring** (`rgb(224,226,232)`): Shadow-as-border + +## 3. Typography Rules + +### Font Families +- **Display**: `Roobert PRO Medium`, fallback: Placeholder — `"blwf", "cv03", "cv04", "cv09", "cv11"` +- **Display Variants**: `Roobert PRO SemiBold`, `Roobert PRO SemiBold Italic`, `Roobert PRO` +- **Body**: `Noto Sans` — `"liga" 0, "ss01", "ss04", "ss05"` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | +|------|------|------|--------|-------------|----------------| +| Display Hero | Roobert PRO Medium | 56px | 400 | 1.15 | -1.68px | +| Section Heading | Roobert PRO Medium | 48px | 400 | 1.15 | -1.44px | +| Card Title | Roobert PRO Medium | 24px | 400 | 1.15 | -0.72px | +| Sub-heading | Noto Sans | 22px | 400 | 1.35 | -0.44px | +| Feature | Roobert PRO Medium | 18px | 600 | 1.35 | normal | +| Body | Noto Sans | 18px | 400 | 1.45 | normal | +| Body Standard | Noto Sans | 16px | 400–600 | 1.50 | -0.16px | +| Button | Roobert PRO Medium | 17.5px | 700 | 1.29 | 0.175px | +| Caption | Roobert PRO Medium | 14px | 400 | 1.71 | normal | +| Small | Roobert PRO Medium | 12px | 400 | 1.15 | -0.36px | +| Micro Uppercase | Roobert PRO | 10.5px | 400 | 0.90 | uppercase | + +## 4. Component Stylings + +### Buttons +- Outlined: transparent bg, `1px solid #c7cad5`, 8px radius, 7px 12px padding +- White circle: 50% radius, white bg with shadow +- Blue primary (implied from interactive color) + +### Cards: 12px–24px radius, pastel backgrounds +### Inputs: white bg, `1px solid #e9eaef`, 8px radius, 16px padding + +## 5. Layout Principles +- Spacing: 1–24px base scale +- Radius: 8px (buttons), 10px–12px (cards), 20px–24px (panels), 40px–50px (large containers) +- Ring shadow: `rgb(224,226,232) 0px 0px 0px 1px` + +## 6. Depth & Elevation +Minimal — ring shadow + pastel surface contrast + +## 7. Do's and Don'ts +### Do +- Use pastel light/dark pairs for feature sections +- Apply Roobert PRO with OpenType character variants +- Use Blue 450 (#5b76fe) for interactive elements +### Don't +- Don't use heavy shadows +- Don't mix more than 2 pastel accents per section + +## 8. Responsive Behavior +Breakpoints: 425px, 576px, 768px, 896px, 1024px, 1200px, 1280px, 1366px, 1700px, 1920px + +## 9. Agent Prompt Guide +### Quick Color Reference +- Text: Near Black (`#1c1c1e`) +- Background: White (`#ffffff`) +- Interactive: Blue 450 (`#5b76fe`) +- Success: `#00b473` +- Border: `#c7cad5` +### Example Component Prompts +- "Create hero: white background. Roobert PRO Medium 56px, line-height 1.15, letter-spacing -1.68px. Blue CTA (#5b76fe). Outlined secondary (1px solid #c7cad5, 8px radius)." diff --git a/skills/creative/popular-web-designs/templates/mistral.ai.md b/skills/creative/popular-web-designs/templates/mistral.ai.md new file mode 100644 index 000000000000..122da4a48773 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/mistral.ai.md @@ -0,0 +1,274 @@ +# Design System: Mistral AI + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Mistral AI's interface is a sun-drenched landscape rendered in code — a warm, bold, unapologetically European design that trades the typical blue-screen AI aesthetic for golden amber, burnt orange, and the feeling of late-afternoon light in southern France. Every surface glows with warmth: backgrounds fade from pale cream to deep amber, shadows carry golden undertones (`rgba(127, 99, 21, ...)`), and the brand's signature orange (`#fa520f`) burns through the page like a signal fire. + +The design language is maximalist in its warmth but minimalist in its structure. Huge display headlines (82px) crash into the viewport with aggressive negative tracking (-2.05px), creating text blocks that feel like billboards or protest posters — declarations rather than descriptions. The typography uses Arial (likely a custom font with Arial as fallback) at extreme sizes, creating a raw, unadorned voice that says "we build frontier AI" with no decoration needed. + +What makes Mistral distinctive is the complete commitment to a warm color temperature. The signature "block" identity — a gradient system flowing from bright yellow (`#ffd900`) through amber (`#ffa110`) to burnt orange (`#fa520f`) — creates a visual identity that's immediately recognizable. Even the shadows are warm, using amber-tinted blacks instead of cool grays. Combined with dramatic landscape photography in golden tones, the design feels less like a tech company and more like a European luxury brand that happens to build language models. + +**Key Characteristics:** +- Golden-amber color universe: every tone from pale cream (#fffaeb) to burnt orange (#fa520f) +- Massive display typography (82px) with aggressive negative letter-spacing (-2.05px) +- Warm golden shadow system using amber-tinted rgba values +- The Mistral "M" block identity — a gradient from yellow to orange +- Dramatic landscape photography in warm golden tones +- Uppercase typography used strategically for section labels and CTAs +- Near-zero border-radius — sharp, architectural geometry +- French-European confidence: bold, warm, declarative + +## 2. Color Palette & Roles + +### Primary +- **Mistral Orange** (`#fa520f`): The core brand color — a vivid, saturated orange-red that anchors the entire identity. Used for primary emphasis, the brand block, and the highest-signal moments. +- **Mistral Flame** (`#fb6424`): A slightly warmer, lighter variant of the brand orange used for secondary brand moments and hover states. +- **Block Orange** (`#ff8105`): A pure orange used in the gradient block system — warmer and less red than Mistral Orange. + +### Secondary & Accent +- **Sunshine 900** (`#ff8a00`): Deep golden amber — the darkest sunshine tone, used for strong accent moments. +- **Sunshine 700** (`#ffa110`): Warm amber-gold — the core sunshine accent for backgrounds and interactive elements. +- **Sunshine 500** (`#ffb83e`): Medium golden — balanced warmth for mid-level emphasis. +- **Sunshine 300** (`#ffd06a`): Light golden — for subtle warm tints and secondary backgrounds. +- **Block Gold** (`#ffe295`): Pale gold — soft background accents and gentle warmth. +- **Bright Yellow** (`#ffd900`): The brightest tone in the gradient — used at the "top" of the block identity. + +### Surface & Background +- **Warm Ivory** (`#fffaeb`): The lightest page background — barely tinted with warmth, the foundation canvas. +- **Cream** (`#fff0c2`): The primary warm surface and secondary button background — noticeably golden. +- **Pure White** (`#ffffff`): Used for maximum contrast elements and popover surfaces. +- **Mistral Black** (`#1f1f1f`): The primary dark surface for buttons, text, and dark sections. +- **Accent Orange** (defined as `hsl(17, 96%, 52%)`): The functional accent color for interactive states. + +### Neutrals & Text +- **Mistral Black** (`#1f1f1f`): Primary text color and dark button backgrounds — a near-black that's warmer than pure #000. +- **Black Tint** (defined as `hsl(0, 0%, 24%)`): A medium dark gray for secondary text on light backgrounds. +- **Pure White** (`#ffffff`): Text on dark surfaces and CTA labels. + +### Semantic & Accent +- **Input Border** (defined as `hsl(240, 5.9%, 90%)`): A cool-tinted light gray for form borders — one of the few cool tones in the system. +- **White Overlay** (`oklab(1, 0, 0 / 0.088–0.1)`): Semi-transparent white for frosted glass effects and button overlays. + +### Gradient System +- **Mistral Block Gradient**: The signature identity — a multi-step gradient flowing through Yellow (`#ffd900`) → Gold (`#ffe295`) → Amber (`#ffa110`) → Orange (`#ff8105`) → Flame (`#fb6424`) → Mistral Orange (`#fa520f`). This gradient appears in the logo blocks, section backgrounds, and decorative elements. +- **Golden Landscape Wash**: Photography and backgrounds use warm amber overlays creating a consistent golden temperature across the page. +- **Warm Shadow Cascade**: Multi-layered golden shadows that build depth with amber-tinted transparency rather than gray. + +## 3. Typography Rules + +### Font Family +- **Primary**: Likely a custom font (Font Source detected) with `Arial` as fallback, and extended stack: `ui-sans-serif, system-ui, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | Arial (custom) | 82px (5.13rem) | 400 | 1.00 (tight) | -2.05px | Maximum impact, billboard scale | +| Section Heading | Arial (custom) | 56px (3.5rem) | 400 | 0.95 (ultra-tight) | normal | Feature section anchors | +| Sub-heading Large | Arial (custom) | 48px (3rem) | 400 | 0.95 (ultra-tight) | normal | Secondary section titles | +| Sub-heading | Arial (custom) | 32px (2rem) | 400 | 1.15 (tight) | normal | Card headings, feature names | +| Card Title | Arial (custom) | 30px (1.88rem) | 400 | 1.20 (tight) | normal | Mid-level headings | +| Feature Title | Arial (custom) | 24px (1.5rem) | 400 | 1.33 | normal | Small headings | +| Body / Button | Arial (custom) | 16px (1rem) | 400 | 1.50 | normal | Standard body, button text | +| Button Uppercase | Arial (custom) | 16px (1rem) | 400 | 1.50 | normal | Uppercase CTA labels | +| Caption / Link | Arial (custom) | 14px (0.88rem) | 400 | 1.43 | normal | Metadata, secondary links | + +### Principles +- **Single weight, maximum impact**: The entire system uses weight 400 (regular) — even at 82px. This creates a surprisingly elegant effect where the size alone carries authority without needing bold weight. +- **Ultra-tight at scale**: Line-heights of 0.95–1.00 at display sizes create text blocks where ascenders nearly touch descenders from the line above — creating dense, poster-like composition. +- **Aggressive tracking on display**: -2.05px letter-spacing at 82px compresses the hero text into a monolithic block. +- **Uppercase as emphasis**: Strategic `text-transform: uppercase` on button labels and section markers creates a formal, European signage quality. +- **No weight variation**: Unlike most systems that use 300–700 weight range, Mistral uses 400 everywhere. Hierarchy comes from size and color, never weight. + +## 4. Component Stylings + +### Buttons + +**Cream Surface** +- Background: Cream (`#fff0c2`) +- Text: Mistral Black (`#1f1f1f`) +- No visible border +- The warm, inviting secondary CTA + +**Dark Solid** +- Background: Mistral Black (`#1f1f1f`) +- Text: Pure White (`#ffffff`) +- Padding: 12px (all sides) +- No visible border +- The primary action button — dark on warm + +**Ghost / Transparent** +- Background: transparent with slight dark overlay (`oklab(0, 0, 0 / 0.1)`) +- Text: Mistral Black (`#1f1f1f`) +- Opacity: 0.4 +- For secondary/de-emphasized actions + +**Text / Underline** +- Background: transparent +- Text: Mistral Black (`#1f1f1f`) +- Padding: 8px 0px 0px (top-only) +- Minimal styling — text link as button +- For tertiary navigation actions + +### Cards & Containers +- Background: Warm Ivory (`#fffaeb`), Cream (`#fff0c2`), or Pure White +- Border: minimal to none — containers defined by background color +- Radius: near-zero — sharp, architectural corners +- Shadow: warm golden multi-layer (`rgba(127, 99, 21, 0.12) -8px 16px 39px, rgba(127, 99, 21, 0.1) -33px 64px 72px, rgba(127, 99, 21, 0.06) -73px 144px 97px, ...`) — a dramatic, cascading warm shadow +- Distinctive: the golden shadow creates a "golden hour" lighting effect + +### Inputs & Forms +- Border: `hsl(240, 5.9%, 90%)` — the sole cool-toned element +- Focus: accent color ring +- Minimal styling consistent with sparse aesthetic + +### Navigation +- Transparent nav overlaying the warm hero +- Logo: Mistral "M" wordmark +- Links: Dark text (white on dark sections) +- CTA: Dark solid button or cream surface button +- Minimal, wide-spaced layout + +### Image Treatment +- Dramatic landscape photography in warm golden tones +- The winding road through golden hills — a recurring visual motif +- The Mistral "M" rendered at large scale on golden backgrounds +- Warm color grading on all photography +- Full-bleed sections with photography + +### Distinctive Components + +**Mistral Block Identity** +- A row of colored blocks forming the gradient: yellow → amber → orange → burnt orange +- Each block gets progressively more orange/red +- The visual DNA of the brand — recognizable at any size + +**Golden Shadow Cards** +- Cards elevated with warm amber multi-layered shadows +- 5 layers of shadow from 16px to 400px offset +- Creates a "floating in golden light" effect unique to Mistral + +**Dark Footer Gradient** +- Footer transitions from warm amber to dark through a dramatic gradient +- Creates a "sunset" effect as the page ends + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 4px, 8px, 10px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 64px, 80px, 98px, 100px +- Button padding: 12px or 8px 0px (compact) +- Section vertical spacing: very generous (80px–100px) + +### Grid & Container +- Max container width: approximately 1280px, centered +- Hero: full-width with massive typography overlaying warm backgrounds +- Feature sections: wide-format layouts with dramatic imagery +- Card grids: 2–3 column layouts + +### Whitespace Philosophy +- **Bold declarations**: Huge headlines surrounded by generous whitespace create billboard-like impact — each statement gets its own breathing space. +- **Warm void**: Empty space itself feels warm because the backgrounds are tinted ivory/cream rather than pure white. +- **Photography as space-filler**: Large landscape images serve double duty as content and decorative whitespace. + +### Border Radius Scale +- Near-zero: The dominant radius — sharp, architectural corners on most elements +- This extreme sharpness contrasts with the warmth of the colors, creating a tension between soft color and hard geometry. + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page backgrounds, text blocks | +| Golden Float (Level 1) | Multi-layer warm shadow (5 layers, 12%→0% opacity, amber-tinted) | Feature cards, product showcases, elevated content | + +**Shadow Philosophy**: Mistral uses a single but extraordinarily complex shadow — **five cascading layers** of amber-tinted shadow (`rgba(127, 99, 21, ...)`) that build from a close 16px offset to a distant 400px offset. The result is a rich, warm, "golden hour" lighting effect that makes elevated elements look like they're bathed in afternoon sunlight. This is the most distinctive shadow system in any major AI brand. + +## 7. Do's and Don'ts + +### Do +- Use the warm color spectrum exclusively: ivory, cream, amber, gold, orange +- Keep display typography at 82px+ with -2.05px letter-spacing for hero sections +- Use the Mistral block gradient (yellow → amber → orange) for brand moments +- Apply warm golden shadows (amber-tinted rgba) for elevated elements +- Use Mistral Black (#1f1f1f) for text — never pure #000000 +- Keep font weight at 400 throughout — let size and color carry hierarchy +- Use sharp, architectural corners — near-zero border-radius +- Apply uppercase on button labels and section markers for European formality +- Use warm landscape photography with golden color grading + +### Don't +- Don't introduce cool colors (blue, green, purple) — the palette is exclusively warm +- Don't use bold (700+) weight — 400 is the only weight +- Don't round corners — the sharp geometry is intentional +- Don't use cool-toned shadows — shadows must carry amber warmth +- Don't use pure white as a page background — always warm-tinted (#fffaeb minimum) +- Don't reduce hero text below 48px on desktop — the billboard scale is core +- Don't use more than 2 font weights — size variation replaces weight variation +- Don't add gradients outside the warm spectrum — no blue-to-purple, no cool transitions +- Don't use generic gray for text — even neutrals should be warm-tinted + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, stacked everything, hero text reduces to ~32px | +| Tablet | 640–768px | Minor layout adjustments | +| Small Desktop | 768–1024px | 2-column layouts begin | +| Desktop | 1024–1280px | Full layout with maximum typography scale | + +### Touch Targets +- Buttons use generous padding (12px minimum) +- Navigation elements adequately spaced +- Cards serve as large touch targets + +### Collapsing Strategy +- **Navigation**: Collapses to hamburger on mobile +- **Hero text**: 82px → 56px → 48px → 32px progressive scaling +- **Feature sections**: Multi-column → stacked +- **Photography**: Scales proportionally, may crop on mobile +- **Block identity**: Scales down proportionally + +### Image Behavior +- Landscape photography scales proportionally +- Warm color grading maintained at all sizes +- Block gradient elements resize fluidly +- No art direction changes — same warm composition at all sizes + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Brand Orange: "Mistral Orange (#fa520f)" +- Page Background: "Warm Ivory (#fffaeb)" +- Warm Surface: "Cream (#fff0c2)" +- Primary Text: "Mistral Black (#1f1f1f)" +- Sunshine Amber: "Sunshine 700 (#ffa110)" +- Bright Gold: "Bright Yellow (#ffd900)" +- Text on Dark: "Pure White (#ffffff)" + +### Example Component Prompts +- "Create a hero section on Warm Ivory (#fffaeb) with a massive headline at 82px Arial weight 400, line-height 1.0, letter-spacing -2.05px. Mistral Black (#1f1f1f) text. Add a dark solid CTA button (#1f1f1f bg, white text, 12px padding, sharp corners) and a cream secondary button (#fff0c2 bg)." +- "Design a feature card on Cream (#fff0c2) with sharp corners (no border-radius). Apply the golden shadow system: rgba(127, 99, 21, 0.12) -8px 16px 39px as the primary layer. Title at 32px weight 400, body at 16px." +- "Build the Mistral block identity: a row of colored blocks from Bright Yellow (#ffd900) through Sunshine 700 (#ffa110) to Mistral Orange (#fa520f). Sharp corners, no gaps." +- "Create a dark footer section on Mistral Black (#1f1f1f) with Pure White (#ffffff) text. Footer links at 14px. Add a warm gradient from Sunshine 700 (#ffa110) at the top fading to Mistral Black." + +### Iteration Guide +1. Keep the warm temperature — "shift toward amber" not "shift toward gray" +2. Use size for hierarchy — 82px → 56px → 48px → 32px → 24px → 16px +3. Never add border-radius — sharp corners only +4. Shadows are always warm: "golden shadow with amber tones" +5. Font weight is always 400 — describe emphasis through size and color diff --git a/skills/creative/popular-web-designs/templates/mongodb.md b/skills/creative/popular-web-designs/templates/mongodb.md new file mode 100644 index 000000000000..ec230ed24d6b --- /dev/null +++ b/skills/creative/popular-web-designs/templates/mongodb.md @@ -0,0 +1,279 @@ +# Design System: MongoDB + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `Source Code Pro` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Source Code Pro', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +MongoDB's website is a deep-forest-meets-terminal experience — a design system rooted in the darkest teal-black (`#001e2b`) that evokes both the density of a database and the depth of a forest canopy. Against this near-black canvas, a striking neon green (`#00ed64`) pulses as the brand accent — bright enough to feel electric, organic enough to feel alive. This isn't the cold neon of cyberpunk; it's the bioluminescent green of something growing in the dark. + +The typography system is architecturally ambitious: MongoDB Value Serif for massive hero headlines (96px) creates an editorial, authoritative presence — serif type at database-company scale is a bold choice that says "we're not just another tech company." Euclid Circular A handles the heavy lifting of body and UI text with an unusually wide weight range (300–700), while Source Code Pro serves as the code and label font with distinctive uppercase treatments featuring very wide letter-spacing (1px–3px). This three-font system creates a hierarchy that spans editorial elegance → geometric professionalism → engineering precision. + +What makes MongoDB distinctive is its dual-mode design: a dark hero/feature section world (`#001e2b` with neon green accents) and a light content world (white with teal-gray borders `#b8c4c2`). The transition between these modes creates dramatic contrast. The shadow system uses teal-tinted dark shadows (`rgba(0, 30, 43, 0.12)`) that maintain the forest-dark atmosphere even on light surfaces. Buttons use pill shapes (100px–999px radius) with MongoDB Green borders (`#00684a`), and the entire component system references the LeafyGreen design system. + +**Key Characteristics:** +- Deep teal-black backgrounds (`#001e2b`) — forest-dark, not space-dark +- Neon MongoDB Green (`#00ed64`) as the singular brand accent — electric and organic +- MongoDB Value Serif for hero headlines — editorial authority at tech scale +- Euclid Circular A for body with weight 300 (light) as a distinctive body weight +- Source Code Pro with wide uppercase letter-spacing (1px–3px) for technical labels +- Teal-tinted shadows: `rgba(0, 30, 43, 0.12)` — shadows carry the forest color +- Dual-mode: dark teal hero sections + light white content sections +- Pill buttons (100px radius) with green borders (`#00684a`) +- Link Blue (`#006cfa`) and hover transition to `#3860be` + +## 2. Color Palette & Roles + +### Primary Brand +- **Forest Black** (`#001e2b`): Primary dark background — the deepest teal-black +- **MongoDB Green** (`#00ed64`): Primary brand accent — neon green for highlights, underlines, gradients +- **Dark Green** (`#00684a`): Button borders, link text on light — muted green for functional use + +### Interactive +- **Action Blue** (`#006cfa`): Secondary accent — links, interactive highlights +- **Hover Blue** (`#3860be`): All link hover states transition to this blue +- **Teal Active** (`#1eaedb`): Button hover background — bright teal + +### Neutral Scale +- **Deep Teal** (`#1c2d38`): Dark button backgrounds, secondary dark surfaces +- **Teal Gray** (`#3d4f58`): Dark borders on dark surfaces +- **Dark Slate** (`#21313c`): Dark link text variant +- **Cool Gray** (`#5c6c75`): Muted text on dark, secondary button text +- **Silver Teal** (`#b8c4c2`): Borders on light surfaces, dividers +- **Light Input** (`#e8edeb`): Input text on dark surfaces +- **Pure White** (`#ffffff`): Light section background, button text on dark +- **Black** (`#000000`): Text on light surfaces, darkest elements + +### Shadows +- **Forest Shadow** (`rgba(0, 30, 43, 0.12) 0px 26px 44px, rgba(0, 0, 0, 0.13) 0px 7px 13px`): Primary card elevation — teal-tinted +- **Standard Shadow** (`rgba(0, 0, 0, 0.15) 0px 3px 20px`): General elevation +- **Subtle Shadow** (`rgba(0, 0, 0, 0.1) 0px 2px 4px`): Light card lift + +## 3. Typography Rules + +### Font Families +- **Display Serif**: `MongoDB Value Serif` — editorial hero headlines +- **Body / UI**: `Euclid Circular A` — geometric sans-serif workhorse +- **Code / Labels**: `Source Code Pro` — monospace with uppercase label treatments +- **Fallbacks**: `Akzidenz-Grotesk Std` (with CJK: Noto Sans KR/SC/JP), `Times`, `Arial`, `system-ui` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | MongoDB Value Serif | 96px (6.00rem) | 400 | 1.20 (tight) | normal | Serif authority | +| Display Secondary | MongoDB Value Serif | 64px (4.00rem) | 400 | 1.00 (tight) | normal | Serif sub-hero | +| Section Heading | Euclid Circular A | 36px (2.25rem) | 500 | 1.33 | normal | Geometric precision | +| Sub-heading | Euclid Circular A | 24px (1.50rem) | 500 | 1.33 | normal | Feature titles | +| Body Large | Euclid Circular A | 20px (1.25rem) | 400 | 1.60 (relaxed) | normal | Introductions | +| Body | Euclid Circular A | 18px (1.13rem) | 400 | 1.33 | normal | Standard body | +| Body Light | Euclid Circular A | 16px (1.00rem) | 300 | 1.50–2.00 | normal | Light-weight reading text | +| Nav / UI | Euclid Circular A | 16px (1.00rem) | 500 | 1.00–1.88 | 0.16px | Navigation, emphasized | +| Body Bold | Euclid Circular A | 15px (0.94rem) | 700 | 1.50 | normal | Strong emphasis | +| Button | Euclid Circular A | 13.5px–16px | 500–700 | 1.00 | 0.135px–0.9px | CTA labels | +| Caption | Euclid Circular A | 14px (0.88rem) | 400 | 1.71 (relaxed) | normal | Metadata | +| Small | Euclid Circular A | 11px (0.69rem) | 600 | 1.82 (relaxed) | 0.2px | Tags, annotations | +| Code Heading | Source Code Pro | 40px (2.50rem) | 400 | 1.60 (relaxed) | normal | Code showcase titles | +| Code Body | Source Code Pro | 16px (1.00rem) | 400 | 1.50 | normal | Code blocks | +| Code Label | Source Code Pro | 14px (0.88rem) | 400–500 | 1.14 (tight) | 1px–2px | `text-transform: uppercase` | +| Code Micro | Source Code Pro | 9px (0.56rem) | 600 | 2.67 (relaxed) | 2.5px | `text-transform: uppercase` | + +### Principles +- **Serif for authority**: MongoDB Value Serif at hero scale creates an editorial presence unusual in tech — it communicates that MongoDB is an institution, not a startup. +- **Weight 300 as body default**: Euclid Circular A uses light (300) for body text, creating an airy reading experience that contrasts with the dense, dark backgrounds. +- **Wide-tracked monospace labels**: Source Code Pro uppercase at 1px–3px letter-spacing creates technical signposts that feel like database field labels — systematic, structured, classified. +- **Four-weight range**: 300 (light body) → 400 (standard) → 500 (UI/nav) → 700 (bold CTA) — a wider range than most systems, enabling fine-grained hierarchy. + +## 4. Component Stylings + +### Buttons + +**Primary Green (Dark Surface)** +- Background: `#00684a` (muted MongoDB green) +- Text: `#000000` +- Radius: 50% (circular) or 100px (pill) +- Border: `1px solid #00684a` +- Shadow: `rgba(0,0,0,0.06) 0px 1px 6px` +- Hover: scale 1.1 +- Active: scale 0.85 + +**Dark Teal Button** +- Background: `#1c2d38` +- Text: `#5c6c75` +- Radius: 100px (pill) +- Border: `1px solid #3d4f58` +- Hover: background `#1eaedb`, text white, translateX(5px) + +**Outlined Button (Light Surface)** +- Background: transparent +- Text: `#001e2b` +- Border: `1px solid #b8c4c2` +- Radius: 4px–8px +- Hover: background tint + +### Cards & Containers +- Light mode: white background with `1px solid #b8c4c2` border +- Dark mode: `#001e2b` or `#1c2d38` background with `1px solid #3d4f58` +- Radius: 16px (standard), 24px (medium), 48px (large/hero) +- Shadow: `rgba(0,30,43,0.12) 0px 26px 44px` (forest-tinted) +- Image containers: 30px–32px radius + +### Inputs & Forms +- Textarea: text `#e8edeb`, padding 12px 12px 12px 8px +- Borders: `1px solid #b8c4c2` on light, `1px solid #3d4f58` on dark +- Input radius: 4px + +### Navigation +- Dark header on forest-black background +- Euclid Circular A 16px weight 500 for nav links +- MongoDB logo (leaf icon + wordmark) left-aligned +- Green CTA pill buttons right-aligned +- Mega-menu dropdowns with product categories + +### Image Treatment +- Dashboard screenshots on dark backgrounds +- Green-accented UI elements in screenshots +- 30px–32px radius on image containers +- Full-width dark sections for product showcases + +### Distinctive Components + +**Neon Green Accent Underlines** +- `0px 2px 2px 0px solid #00ed64` — bottom + right border creating accent underlines +- Used on feature headings and highlighted text +- Also appears as `#006cfa` (blue) variant + +**Source Code Label System** +- 14px uppercase Source Code Pro with 1px–2px letter-spacing +- Used as section category markers above headings +- Creates a "database field label" aesthetic + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 4px, 7px, 8px, 10px, 12px, 14px, 15px, 16px, 18px, 20px, 24px, 32px + +### Grid & Container +- Max content width centered +- Dark hero section with contained content +- Light content sections below +- Card grids: 2–3 columns +- Full-width dark footer + +### Whitespace Philosophy +- **Dramatic mode transitions**: The shift from dark teal sections to white content creates built-in visual breathing through contrast, not just space. +- **Generous dark sections**: Dark hero and feature areas use extra vertical padding (80px+) to let the forest-dark background breathe. +- **Compact light sections**: White content areas are denser, with tighter card grids and less vertical spacing. + +### Border Radius Scale +- Minimal (1px–2px): Small spans, badges +- Subtle (4px): Inputs, small buttons +- Standard (8px): Cards, links +- Card (16px): Standard cards, containers +- Toggle (20px): Switch elements +- Large (24px): Large panels +- Image (30px–32px): Image containers +- Hero (48px): Hero cards +- Pill (100px–999px): Buttons, navigation pills +- Full (9999px): Maximum pill + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Default surfaces | +| Subtle (Level 1) | `rgba(0,0,0,0.1) 0px 2px 4px` | Light card lift | +| Standard (Level 2) | `rgba(0,0,0,0.15) 0px 3px 9px` | Standard cards | +| Prominent (Level 3) | `rgba(0,0,0,0.15) 0px 3px 20px` | Elevated panels | +| Forest (Level 4) | `rgba(0,30,43,0.12) 0px 26px 44px, rgba(0,0,0,0.13) 0px 7px 13px` | Hero cards — teal-tinted | + +**Shadow Philosophy**: MongoDB's shadow system is unique in that the primary elevation shadow uses `rgba(0, 30, 43, 0.12)` — a teal-tinted shadow that carries the forest-dark brand color into the depth system. This means even on white surfaces, shadows feel like they belong to the MongoDB color world rather than being generic neutral black. + +## 7. Do's and Don'ts + +### Do +- Use `#001e2b` (forest-black) for dark sections — not pure black +- Apply MongoDB Green (`#00ed64`) sparingly for maximum electric impact +- Use MongoDB Value Serif ONLY for hero/display headings — Euclid Circular A for everything else +- Apply Source Code Pro uppercase with wide tracking (1px–3px) for technical labels +- Use teal-tinted shadows (`rgba(0,30,43,0.12)`) for primary card elevation +- Maintain the dark/light section duality — dramatic contrast between modes +- Use weight 300 for body text — the light weight is the readable voice +- Apply pill radius (100px) to primary action buttons + +### Don't +- Don't use pure black (`#000000`) for dark backgrounds — always use teal-black (`#001e2b`) +- Don't use MongoDB Green (`#00ed64`) on backgrounds — it's an accent for text, underlines, and small highlights +- Don't use standard gray shadows — always use teal-tinted (`rgba(0,30,43,...)`) +- Don't apply serif font to body text — MongoDB Value Serif is hero-only +- Don't use narrow letter-spacing on Source Code Pro labels — the wide tracking IS the identity +- Don't mix dark and light section treatments within the same section +- Don't use warm colors — the palette is strictly cool (teal, green, blue) +- Don't forget the green accent underlines — they're the signature decorative element + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <425px | Tight single column | +| Mobile | 425–768px | Standard mobile | +| Tablet | 768–1024px | 2-column grids begin | +| Desktop | 1024–1280px | Standard layout | +| Large Desktop | 1280–1440px | Expanded layout | +| Ultra-wide | >1440px | Maximum width, generous margins | + +### Touch Targets +- Pill buttons with generous padding +- Navigation links at 16px with adequate spacing +- Card surfaces as full-area touch targets + +### Collapsing Strategy +- Hero: MongoDB Value Serif 96px → 64px → scales further +- Navigation: horizontal mega-menu → hamburger +- Feature cards: multi-column → stacked +- Dark/light sections maintain their mode at all sizes +- Source Code Pro labels maintain uppercase treatment + +### Image Behavior +- Dashboard screenshots scale proportionally +- Dark section backgrounds maintained full-width +- Image radius maintained across breakpoints + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Dark background: Forest Black (`#001e2b`) +- Brand accent: MongoDB Green (`#00ed64`) +- Functional green: Dark Green (`#00684a`) +- Link blue: Action Blue (`#006cfa`) +- Text on light: Black (`#000000`) +- Text on dark: White (`#ffffff`) or Light Input (`#e8edeb`) +- Border light: Silver Teal (`#b8c4c2`) +- Border dark: Teal Gray (`#3d4f58`) + +### Example Component Prompts +- "Create a hero on forest-black (#001e2b) background. Headline at 96px MongoDB Value Serif weight 400, line-height 1.20, white text with 'potential' highlighted in MongoDB Green (#00ed64). Subtitle at 18px Euclid Circular A weight 400. Green pill CTA (#00684a, 100px radius). Neon green gradient glow behind product screenshot." +- "Design a card on white background: 1px solid #b8c4c2 border, 16px radius, shadow rgba(0,30,43,0.12) 0px 26px 44px. Title at 24px Euclid Circular A weight 500. Body at 16px weight 300. Source Code Pro 14px uppercase label above title with 2px letter-spacing." +- "Build a dark section: #001e2b background, 1px solid #3d4f58 border on cards. White text. MongoDB Green (#00ed64) accent underlines on headings using bottom-border 2px solid." +- "Create technical label: Source Code Pro 14px, text-transform uppercase, letter-spacing 2px, weight 500, #00ed64 color on dark background." +- "Design a pill button: #1c2d38 background, 1px solid #3d4f58 border, 100px radius, #5c6c75 text. Hover: #1eaedb background, white text, translateX(5px)." + +### Iteration Guide +1. Start with the mode decision: dark (#001e2b) for hero/features, white for content +2. MongoDB Green (#00ed64) is electric — use once per section for maximum impact +3. Serif headlines (MongoDB Value Serif) create the editorial authority — never use for body +4. Weight 300 body text creates the airy reading experience — don't default to 400 +5. Source Code Pro uppercase with wide tracking for technical labels — the database voice +6. Teal-tinted shadows keep everything in the MongoDB color world diff --git a/skills/creative/popular-web-designs/templates/notion.md b/skills/creative/popular-web-designs/templates/notion.md new file mode 100644 index 000000000000..627fe67743a3 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/notion.md @@ -0,0 +1,322 @@ +# Design System: Notion + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Notion's website embodies the philosophy of the tool itself: a blank canvas that gets out of your way. The design system is built on warm neutrals rather than cold grays, creating a distinctly approachable minimalism that feels like quality paper rather than sterile glass. The page canvas is pure white (`#ffffff`) but the text isn't pure black -- it's a warm near-black (`rgba(0,0,0,0.95)`) that softens the reading experience imperceptibly. The warm gray scale (`#f6f5f4`, `#31302e`, `#615d59`, `#a39e98`) carries subtle yellow-brown undertones, giving the interface a tactile, almost analog warmth. + +The custom NotionInter font (a modified Inter) is the backbone of the system. At display sizes (64px), it uses aggressive negative letter-spacing (-2.125px), creating headlines that feel compressed and precise. The weight range is broader than typical systems: 400 for body, 500 for UI elements, 600 for semi-bold labels, and 700 for display headings. OpenType features `"lnum"` (lining numerals) and `"locl"` (localized forms) are enabled on larger text, adding typographic sophistication that rewards close reading. + +What makes Notion's visual language distinctive is its border philosophy. Rather than heavy borders or shadows, Notion uses ultra-thin `1px solid rgba(0,0,0,0.1)` borders -- borders that exist as whispers, barely perceptible division lines that create structure without weight. The shadow system is equally restrained: multi-layer stacks with cumulative opacity never exceeding 0.05, creating depth that's felt rather than seen. + +**Key Characteristics:** +- NotionInter (modified Inter) with negative letter-spacing at display sizes (-2.125px at 64px) +- Warm neutral palette: grays carry yellow-brown undertones (`#f6f5f4` warm white, `#31302e` warm dark) +- Near-black text via `rgba(0,0,0,0.95)` -- not pure black, creating micro-warmth +- Ultra-thin borders: `1px solid rgba(0,0,0,0.1)` throughout -- whisper-weight division +- Multi-layer shadow stacks with sub-0.05 opacity for barely-there depth +- Notion Blue (`#0075de`) as the singular accent color for CTAs and interactive elements +- Pill badges (9999px radius) with tinted blue backgrounds for status indicators +- 8px base spacing unit with an organic, non-rigid scale + +## 2. Color Palette & Roles + +### Primary +- **Notion Black** (`rgba(0,0,0,0.95)` / `#000000f2`): Primary text, headings, body copy. The 95% opacity softens pure black without sacrificing readability. +- **Pure White** (`#ffffff`): Page background, card surfaces, button text on blue. +- **Notion Blue** (`#0075de`): Primary CTA, link color, interactive accent -- the only saturated color in the core UI chrome. + +### Brand Secondary +- **Deep Navy** (`#213183`): Secondary brand color, used sparingly for emphasis and dark feature sections. +- **Active Blue** (`#005bab`): Button active/pressed state -- darker variant of Notion Blue. + +### Warm Neutral Scale +- **Warm White** (`#f6f5f4`): Background surface tint, section alternation, subtle card fill. The yellow undertone is key. +- **Warm Dark** (`#31302e`): Dark surface background, dark section text. Warmer than standard grays. +- **Warm Gray 500** (`#615d59`): Secondary text, descriptions, muted labels. +- **Warm Gray 300** (`#a39e98`): Placeholder text, disabled states, caption text. + +### Semantic Accent Colors +- **Teal** (`#2a9d99`): Success states, positive indicators. +- **Green** (`#1aae39`): Confirmation, completion badges. +- **Orange** (`#dd5b00`): Warning states, attention indicators. +- **Pink** (`#ff64c8`): Decorative accent, feature highlights. +- **Purple** (`#391c57`): Premium features, deep accents. +- **Brown** (`#523410`): Earthy accent, warm feature sections. + +### Interactive +- **Link Blue** (`#0075de`): Primary link color with underline-on-hover. +- **Link Light Blue** (`#62aef0`): Lighter link variant for dark backgrounds. +- **Focus Blue** (`#097fe8`): Focus ring on interactive elements. +- **Badge Blue Bg** (`#f2f9ff`): Pill badge background, tinted blue surface. +- **Badge Blue Text** (`#097fe8`): Pill badge text, darker blue for readability. + +### Shadows & Depth +- **Card Shadow** (`rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.84688px, rgba(0,0,0,0.02) 0px 0.8px 2.925px, rgba(0,0,0,0.01) 0px 0.175px 1.04062px`): Multi-layer card elevation. +- **Deep Shadow** (`rgba(0,0,0,0.01) 0px 1px 3px, rgba(0,0,0,0.02) 0px 3px 7px, rgba(0,0,0,0.02) 0px 7px 15px, rgba(0,0,0,0.04) 0px 14px 28px, rgba(0,0,0,0.05) 0px 23px 52px`): Five-layer deep elevation for modals and featured content. +- **Whisper Border** (`1px solid rgba(0,0,0,0.1)`): Standard division border -- cards, dividers, sections. + +## 3. Typography Rules + +### Font Family +- **Primary**: `NotionInter`, with fallbacks: `Inter, -apple-system, system-ui, Segoe UI, Helvetica, Apple Color Emoji, Arial, Segoe UI Emoji, Segoe UI Symbol` +- **OpenType Features**: `"lnum"` (lining numerals) and `"locl"` (localized forms) enabled on display and heading text. + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | NotionInter | 64px (4.00rem) | 700 | 1.00 (tight) | -2.125px | Maximum compression, billboard headlines | +| Display Secondary | NotionInter | 54px (3.38rem) | 700 | 1.04 (tight) | -1.875px | Secondary hero, feature headlines | +| Section Heading | NotionInter | 48px (3.00rem) | 700 | 1.00 (tight) | -1.5px | Feature section titles, with `"lnum"` | +| Sub-heading Large | NotionInter | 40px (2.50rem) | 700 | 1.50 | normal | Card headings, feature sub-sections | +| Sub-heading | NotionInter | 26px (1.63rem) | 700 | 1.23 (tight) | -0.625px | Section sub-titles, content headers | +| Card Title | NotionInter | 22px (1.38rem) | 700 | 1.27 (tight) | -0.25px | Feature cards, list titles | +| Body Large | NotionInter | 20px (1.25rem) | 600 | 1.40 | -0.125px | Introductions, feature descriptions | +| Body | NotionInter | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text | +| Body Medium | NotionInter | 16px (1.00rem) | 500 | 1.50 | normal | Navigation, emphasized UI text | +| Body Semibold | NotionInter | 16px (1.00rem) | 600 | 1.50 | normal | Strong labels, active states | +| Body Bold | NotionInter | 16px (1.00rem) | 700 | 1.50 | normal | Headlines at body size | +| Nav / Button | NotionInter | 15px (0.94rem) | 600 | 1.33 | normal | Navigation links, button text | +| Caption | NotionInter | 14px (0.88rem) | 500 | 1.43 | normal | Metadata, secondary labels | +| Caption Light | NotionInter | 14px (0.88rem) | 400 | 1.43 | normal | Body captions, descriptions | +| Badge | NotionInter | 12px (0.75rem) | 600 | 1.33 | 0.125px | Pill badges, tags, status labels | +| Micro Label | NotionInter | 12px (0.75rem) | 400 | 1.33 | 0.125px | Small metadata, timestamps | + +### Principles +- **Compression at scale**: NotionInter at display sizes uses -2.125px letter-spacing at 64px, progressively relaxing to -0.625px at 26px and normal at 16px. The compression creates density at headlines while maintaining readability at body sizes. +- **Four-weight system**: 400 (body/reading), 500 (UI/interactive), 600 (emphasis/navigation), 700 (headings/display). The broader weight range compared to most systems allows nuanced hierarchy. +- **Warm scaling**: Line height tightens as size increases -- 1.50 at body (16px), 1.23-1.27 at sub-headings, 1.00-1.04 at display. This creates denser, more impactful headlines. +- **Badge micro-tracking**: The 12px badge text uses positive letter-spacing (0.125px) -- the only positive tracking in the system, creating wider, more legible small text. + +## 4. Component Stylings + +### Buttons + +**Primary Blue** +- Background: `#0075de` (Notion Blue) +- Text: `#ffffff` +- Padding: 8px 16px +- Radius: 4px (subtle) +- Border: `1px solid transparent` +- Hover: background darkens to `#005bab` +- Active: scale(0.9) transform +- Focus: `2px solid` focus outline, `var(--shadow-level-200)` shadow +- Use: Primary CTA ("Get Notion free", "Try it") + +**Secondary / Tertiary** +- Background: `rgba(0,0,0,0.05)` (translucent warm gray) +- Text: `#000000` (near-black) +- Padding: 8px 16px +- Radius: 4px +- Hover: text color shifts, scale(1.05) +- Active: scale(0.9) transform +- Use: Secondary actions, form submissions + +**Ghost / Link Button** +- Background: transparent +- Text: `rgba(0,0,0,0.95)` +- Decoration: underline on hover +- Use: Tertiary actions, inline links + +**Pill Badge Button** +- Background: `#f2f9ff` (tinted blue) +- Text: `#097fe8` +- Padding: 4px 8px +- Radius: 9999px (full pill) +- Font: 12px weight 600 +- Use: Status badges, feature labels, "New" tags + +### Cards & Containers +- Background: `#ffffff` +- Border: `1px solid rgba(0,0,0,0.1)` (whisper border) +- Radius: 12px (standard cards), 16px (featured/hero cards) +- Shadow: `rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.84688px, rgba(0,0,0,0.02) 0px 0.8px 2.925px, rgba(0,0,0,0.01) 0px 0.175px 1.04062px` +- Hover: subtle shadow intensification +- Image cards: 12px top radius, image fills top half + +### Inputs & Forms +- Background: `#ffffff` +- Text: `rgba(0,0,0,0.9)` +- Border: `1px solid #dddddd` +- Padding: 6px +- Radius: 4px +- Focus: blue outline ring +- Placeholder: warm gray `#a39e98` + +### Navigation +- Clean horizontal nav on white, not sticky +- Brand logo left-aligned (33x34px icon + wordmark) +- Links: NotionInter 15px weight 500-600, near-black text +- Hover: color shift to `var(--color-link-primary-text-hover)` +- CTA: blue pill button ("Get Notion free") right-aligned +- Mobile: hamburger menu collapse +- Product dropdowns with multi-level categorized menus + +### Image Treatment +- Product screenshots with `1px solid rgba(0,0,0,0.1)` border +- Top-rounded images: `12px 12px 0px 0px` radius +- Dashboard/workspace preview screenshots dominate feature sections +- Warm gradient backgrounds behind hero illustrations (decorative character illustrations) + +### Distinctive Components + +**Feature Cards with Illustrations** +- Large illustrative headers (The Great Wave, product UI screenshots) +- 12px radius card with whisper border +- Title at 22px weight 700, description at 16px weight 400 +- Warm white (`#f6f5f4`) background variant for alternating sections + +**Trust Bar / Logo Grid** +- Company logos (trusted teams section) in their brand colors +- Horizontal scroll or grid layout with team counts +- Metric display: large number + description pattern + +**Metric Cards** +- Large number display (e.g., "$4,200 ROI") +- NotionInter 40px+ weight 700 for the metric +- Description below in warm gray body text +- Whisper-bordered card container + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 3px, 4px, 5px, 6px, 7px, 8px, 11px, 12px, 14px, 16px, 24px, 32px +- Non-rigid organic scale with fractional values (5.6px, 6.4px) for micro-adjustments + +### Grid & Container +- Max content width: approximately 1200px +- Hero: centered single-column with generous top padding (80-120px) +- Feature sections: 2-3 column grids for cards +- Full-width warm white (`#f6f5f4`) section backgrounds for alternation +- Code/dashboard screenshots as contained with whisper border + +### Whitespace Philosophy +- **Generous vertical rhythm**: 64-120px between major sections. Notion lets content breathe with vast vertical padding. +- **Warm alternation**: White sections alternate with warm white (`#f6f5f4`) sections, creating gentle visual rhythm without harsh color breaks. +- **Content-first density**: Body text blocks are compact (line-height 1.50) but surrounded by ample margin, creating islands of readable content in a sea of white space. + +### Border Radius Scale +- Micro (4px): Buttons, inputs, functional interactive elements +- Subtle (5px): Links, list items, menu items +- Standard (8px): Small cards, containers, inline elements +- Comfortable (12px): Standard cards, feature containers, image tops +- Large (16px): Hero cards, featured content, promotional blocks +- Full Pill (9999px): Badges, pills, status indicators +- Circle (100%): Tab indicators, avatars + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background, text blocks | +| Whisper (Level 1) | `1px solid rgba(0,0,0,0.1)` | Standard borders, card outlines, dividers | +| Soft Card (Level 2) | 4-layer shadow stack (max opacity 0.04) | Content cards, feature blocks | +| Deep Card (Level 3) | 5-layer shadow stack (max opacity 0.05, 52px blur) | Modals, featured panels, hero elements | +| Focus (Accessibility) | `2px solid var(--focus-color)` outline | Keyboard focus on all interactive elements | + +**Shadow Philosophy**: Notion's shadow system uses multiple layers with extremely low individual opacity (0.01 to 0.05) that accumulate into soft, natural-looking elevation. The 4-layer card shadow spans from 1.04px to 18px blur, creating a gradient of depth rather than a single hard shadow. The 5-layer deep shadow extends to 52px blur at 0.05 opacity, producing ambient occlusion that feels like natural light rather than computer-generated depth. This layered approach makes elements feel embedded in the page rather than floating above it. + +### Decorative Depth +- Hero section: decorative character illustrations (playful, hand-drawn style) +- Section alternation: white to warm white (`#f6f5f4`) background shifts +- No hard section borders -- separation comes from background color changes and spacing + +## 7. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <400px | Tight single column, minimal padding | +| Mobile | 400-600px | Standard mobile, stacked layout | +| Tablet Small | 600-768px | 2-column grids begin | +| Tablet | 768-1080px | Full card grids, expanded padding | +| Desktop Small | 1080-1200px | Standard desktop layout | +| Desktop | 1200-1440px | Full layout, maximum content width | +| Large Desktop | >1440px | Centered, generous margins | + +### Touch Targets +- Buttons use comfortable padding (8px-16px vertical) +- Navigation links at 15px with adequate spacing +- Pill badges have 8px horizontal padding for tap targets +- Mobile menu toggle uses standard hamburger button + +### Collapsing Strategy +- Hero: 64px display -> scales to 40px -> 26px on mobile, maintains proportional letter-spacing +- Navigation: horizontal links + blue CTA -> hamburger menu +- Feature cards: 3-column -> 2-column -> single column stacked +- Product screenshots: maintain aspect ratio with responsive images +- Trust bar logos: grid -> horizontal scroll on mobile +- Footer: multi-column -> stacked single column +- Section spacing: 80px+ -> 48px on mobile + +### Image Behavior +- Workspace screenshots maintain whisper border at all sizes +- Hero illustrations scale proportionally +- Product screenshots use responsive images with consistent border radius +- Full-width warm white sections maintain edge-to-edge treatment + +## 8. Accessibility & States + +### Focus System +- All interactive elements receive visible focus indicators +- Focus outline: `2px solid` with focus color + shadow level 200 +- Tab navigation supported throughout all interactive components +- High contrast text: near-black on white exceeds WCAG AAA (>14:1 ratio) + +### Interactive States +- **Default**: Standard appearance with whisper borders +- **Hover**: Color shift on text, scale(1.05) on buttons, underline on links +- **Active/Pressed**: scale(0.9) transform, darker background variant +- **Focus**: Blue outline ring with shadow reinforcement +- **Disabled**: Warm gray (`#a39e98`) text, reduced opacity + +### Color Contrast +- Primary text (rgba(0,0,0,0.95)) on white: ~18:1 ratio +- Secondary text (#615d59) on white: ~5.5:1 ratio (WCAG AA) +- Blue CTA (#0075de) on white: ~4.6:1 ratio (WCAG AA for large text) +- Badge text (#097fe8) on badge bg (#f2f9ff): ~4.5:1 ratio (WCAG AA for large text) + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Notion Blue (`#0075de`) +- Background: Pure White (`#ffffff`) +- Alt Background: Warm White (`#f6f5f4`) +- Heading text: Near-Black (`rgba(0,0,0,0.95)`) +- Body text: Near-Black (`rgba(0,0,0,0.95)`) +- Secondary text: Warm Gray 500 (`#615d59`) +- Muted text: Warm Gray 300 (`#a39e98`) +- Border: `1px solid rgba(0,0,0,0.1)` +- Link: Notion Blue (`#0075de`) +- Focus ring: Focus Blue (`#097fe8`) + +### Example Component Prompts +- "Create a hero section on white background. Headline at 64px NotionInter weight 700, line-height 1.00, letter-spacing -2.125px, color rgba(0,0,0,0.95). Subtitle at 20px weight 600, line-height 1.40, color #615d59. Blue CTA button (#0075de, 4px radius, 8px 16px padding, white text) and ghost button (transparent bg, near-black text, underline on hover)." +- "Design a card: white background, 1px solid rgba(0,0,0,0.1) border, 12px radius. Use shadow stack: rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.85px, rgba(0,0,0,0.02) 0px 0.8px 2.93px, rgba(0,0,0,0.01) 0px 0.175px 1.04px. Title at 22px NotionInter weight 700, letter-spacing -0.25px. Body at 16px weight 400, color #615d59." +- "Build a pill badge: #f2f9ff background, #097fe8 text, 9999px radius, 4px 8px padding, 12px NotionInter weight 600, letter-spacing 0.125px." +- "Create navigation: white header. NotionInter 15px weight 600 for links, near-black text. Blue pill CTA 'Get Notion free' right-aligned (#0075de bg, white text, 4px radius)." +- "Design an alternating section layout: white sections alternate with warm white (#f6f5f4) sections. Each section has 64-80px vertical padding, max-width 1200px centered. Section heading at 48px weight 700, line-height 1.00, letter-spacing -1.5px." + +### Iteration Guide +1. Always use warm neutrals -- Notion's grays have yellow-brown undertones (#f6f5f4, #31302e, #615d59, #a39e98), never blue-gray +2. Letter-spacing scales with font size: -2.125px at 64px, -1.875px at 54px, -0.625px at 26px, normal at 16px +3. Four weights: 400 (read), 500 (interact), 600 (emphasize), 700 (announce) +4. Borders are whispers: 1px solid rgba(0,0,0,0.1) -- never heavier +5. Shadows use 4-5 layers with individual opacity never exceeding 0.05 +6. The warm white (#f6f5f4) section background is essential for visual rhythm +7. Pill badges (9999px) for status/tags, 4px radius for buttons and inputs +8. Notion Blue (#0075de) is the only saturated color in core UI -- use it sparingly for CTAs and links diff --git a/skills/creative/popular-web-designs/templates/nvidia.md b/skills/creative/popular-web-designs/templates/nvidia.md new file mode 100644 index 000000000000..848038f6022d --- /dev/null +++ b/skills/creative/popular-web-designs/templates/nvidia.md @@ -0,0 +1,306 @@ +# Design System: NVIDIA + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +NVIDIA's website is a high-contrast, technology-forward experience that communicates raw computational power through design restraint. The page is built on a stark black (`#000000`) and white (`#ffffff`) foundation, punctuated by NVIDIA's signature green (`#76b900`) -- a color so specific it functions as a brand fingerprint. This is not the lush green of nature; it's the electric, lime-shifted green of GPU-rendered light, a color that sits between chartreuse and kelly green and immediately signals "NVIDIA" to anyone in technology. + +The custom NVIDIA-EMEA font family (with Arial and Helvetica fallbacks) creates a clean, industrial typographic voice. Headings at 36px bold with tight 1.25 line-height create dense, authoritative blocks of text. The font lacks the geometric playfulness of Silicon Valley sans-serifs -- it's European, pragmatic, and engineering-focused. Body text runs at 15-16px, comfortable for reading but not generous, maintaining the sense that screen real estate is optimized like GPU memory. + +What distinguishes NVIDIA's design from other dark-background tech sites is the disciplined use of the green accent. The `#76b900` appears in borders (`2px solid #76b900`), link underlines (`underline 2px rgb(118, 185, 0)`), and CTAs -- but never as backgrounds or large surface areas on the main content. The green is a signal, not a surface. Combined with a deep shadow system (`rgba(0, 0, 0, 0.3) 0px 0px 5px`) and minimal border radius (1-2px), the overall effect is of precision engineering hardware rendered in pixels. + +**Key Characteristics:** +- NVIDIA Green (`#76b900`) as pure accent -- borders, underlines, and interactive highlights only +- Black (`#000000`) dominant background with white (`#ffffff`) text on dark sections +- NVIDIA-EMEA custom font with Arial/Helvetica fallback -- industrial, European, clean +- Tight line-heights (1.25 for headings) creating dense, authoritative text blocks +- Minimal border radius (1-2px) -- sharp, engineered corners throughout +- Green-bordered buttons (`2px solid #76b900`) as primary interactive pattern +- Font Awesome 6 Pro/Sharp icon system at weight 900 for sharp iconography +- Multi-framework architecture (PrimeReact, Fluent UI, Element Plus) enabling rich interactive components + +## 2. Color Palette & Roles + +### Primary Brand +- **NVIDIA Green** (`#76b900`): The signature -- borders, link underlines, CTA outlines, active indicators. Never used as large surface fills. +- **True Black** (`#000000`): Primary page background, text on light surfaces, dominant tone. +- **Pure White** (`#ffffff`): Text on dark backgrounds, light section backgrounds, card surfaces. + +### Extended Brand Palette +- **NVIDIA Green Light** (`#bff230`): Bright lime accent for highlights and hover states. +- **Orange 400** (`#df6500`): Warm accent for alerts, featured badges, or energy-related contexts. +- **Yellow 300** (`#ef9100`): Secondary warm accent, product category highlights. +- **Yellow 050** (`#feeeb2`): Light warm surface for callout backgrounds. + +### Status & Semantic +- **Red 500** (`#e52020`): Error states, destructive actions, critical alerts. +- **Red 800** (`#650b0b`): Deep red for severe warning backgrounds. +- **Green 500** (`#3f8500`): Success states, positive indicators (darker than brand green). +- **Blue 700** (`#0046a4`): Informational accents, link hover alternative. + +### Decorative +- **Purple 800** (`#4d1368`): Deep purple for gradient ends, premium/AI contexts. +- **Purple 100** (`#f9d4ff`): Light purple surface tint. +- **Fuchsia 700** (`#8c1c55`): Rich accent for special promotions or featured content. + +### Neutral Scale +- **Gray 300** (`#a7a7a7`): Muted text, disabled labels. +- **Gray 400** (`#898989`): Secondary text, metadata. +- **Gray 500** (`#757575`): Tertiary text, placeholders, footers. +- **Gray Border** (`#5e5e5e`): Subtle borders, divider lines. +- **Near Black** (`#1a1a1a`): Dark surfaces, card backgrounds on black pages. + +### Interactive States +- **Link Default (dark bg)** (`#ffffff`): White links on dark backgrounds. +- **Link Default (light bg)** (`#000000`): Black links with green underline on light backgrounds. +- **Link Hover** (`#3860be`): Blue shift on hover across all link variants. +- **Button Hover** (`#1eaedb`): Teal highlight for button hover states. +- **Button Active** (`#007fff`): Bright blue for active/pressed button states. +- **Focus Ring** (`#000000 solid 2px`): Black outline for keyboard focus. + +### Shadows & Depth +- **Card Shadow** (`rgba(0, 0, 0, 0.3) 0px 0px 5px 0px`): Subtle ambient shadow for elevated cards. + +## 3. Typography Rules + +### Font Family +- **Primary**: `NVIDIA-EMEA`, with fallbacks: `Arial, Helvetica, sans-serif` +- **Icon Font**: `Font Awesome 6 Pro` (weight 900 for solid icons, 700 for regular) +- **Icon Sharp**: `Font Awesome 6 Sharp` (weight 300 for light icons, 400 for regular) + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | NVIDIA-EMEA | 36px (2.25rem) | 700 | 1.25 (tight) | normal | Maximum impact headlines | +| Section Heading | NVIDIA-EMEA | 24px (1.50rem) | 700 | 1.25 (tight) | normal | Section titles, card headings | +| Sub-heading | NVIDIA-EMEA | 22px (1.38rem) | 400 | 1.75 (relaxed) | normal | Feature descriptions, subtitles | +| Card Title | NVIDIA-EMEA | 20px (1.25rem) | 700 | 1.25 (tight) | normal | Card and module headings | +| Body Large | NVIDIA-EMEA | 18px (1.13rem) | 700 | 1.67 (relaxed) | normal | Emphasized body, lead paragraphs | +| Body | NVIDIA-EMEA | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text | +| Body Bold | NVIDIA-EMEA | 16px (1.00rem) | 700 | 1.50 | normal | Strong labels, nav items | +| Body Small | NVIDIA-EMEA | 15px (0.94rem) | 400 | 1.67 (relaxed) | normal | Secondary content, descriptions | +| Body Small Bold | NVIDIA-EMEA | 15px (0.94rem) | 700 | 1.50 | normal | Emphasized secondary content | +| Button Large | NVIDIA-EMEA | 18px (1.13rem) | 700 | 1.25 (tight) | normal | Primary CTA buttons | +| Button | NVIDIA-EMEA | 16px (1.00rem) | 700 | 1.25 (tight) | normal | Standard buttons | +| Button Compact | NVIDIA-EMEA | 14.4px (0.90rem) | 700 | 1.00 (tight) | 0.144px | Small/compact buttons | +| Link | NVIDIA-EMEA | 14px (0.88rem) | 700 | 1.43 | normal | Navigation links | +| Link Uppercase | NVIDIA-EMEA | 14px (0.88rem) | 700 | 1.43 | normal | `text-transform: uppercase`, nav labels | +| Caption | NVIDIA-EMEA | 14px (0.88rem) | 600 | 1.50 | normal | Metadata, timestamps | +| Caption Small | NVIDIA-EMEA | 12px (0.75rem) | 400 | 1.25 (tight) | normal | Fine print, legal | +| Micro Label | NVIDIA-EMEA | 10px (0.63rem) | 700 | 1.50 | normal | `text-transform: uppercase`, tiny badges | +| Micro | NVIDIA-EMEA | 11px (0.69rem) | 700 | 1.00 (tight) | normal | Smallest UI text | + +### Principles +- **Bold as the default voice**: NVIDIA leans heavily on weight 700 for headings, buttons, links, and labels. The 400 weight is reserved for body text and descriptions -- everything else is bold, projecting confidence and authority. +- **Tight headings, relaxed body**: Heading line-height is consistently 1.25 (tight), while body text relaxes to 1.50-1.67. This contrast creates visual density at the top of content blocks and comfortable readability in paragraphs. +- **Uppercase for navigation**: Link labels use `text-transform: uppercase` with weight 700, creating a navigation voice that reads like hardware specification labels. +- **No decorative tracking**: Letter-spacing is normal throughout, except for compact buttons (0.144px). The font itself carries the industrial character without manipulation. + +## 4. Component Stylings + +### Buttons + +**Primary (Green Border)** +- Background: `transparent` +- Text: `#000000` +- Padding: 11px 13px +- Border: `2px solid #76b900` +- Radius: 2px +- Font: 16px weight 700 +- Hover: background `#1eaedb`, text `#ffffff` +- Active: background `#007fff`, text `#ffffff`, border `1px solid #003eff`, scale(1) +- Focus: background `#1eaedb`, text `#ffffff`, outline `#000000 solid 2px`, opacity 0.9 +- Use: Primary CTA ("Learn More", "Explore Solutions") + +**Secondary (Green Border Thin)** +- Background: transparent +- Border: `1px solid #76b900` +- Radius: 2px +- Use: Secondary actions, alternative CTAs + +**Compact / Inline** +- Font: 14.4px weight 700 +- Letter-spacing: 0.144px +- Line-height: 1.00 +- Use: Inline CTAs, compact navigation + +### Cards & Containers +- Background: `#ffffff` (light) or `#1a1a1a` (dark sections) +- Border: none (clean edges) or `1px solid #5e5e5e` +- Radius: 2px +- Shadow: `rgba(0, 0, 0, 0.3) 0px 0px 5px 0px` for elevated cards +- Hover: shadow intensification +- Padding: 16-24px internal + +### Links +- **On Dark Background**: `#ffffff`, no underline, hover shifts to `#3860be` +- **On Light Background**: `#000000` or `#1a1a1a`, underline `2px solid #76b900`, hover shifts to `#3860be`, underline removed +- **Green Links**: `#76b900`, hover shifts to `#3860be` +- **Muted Links**: `#666666`, hover shifts to `#3860be` + +### Navigation +- Dark black background (`#000000`) +- Logo left-aligned, prominent NVIDIA wordmark +- Links: NVIDIA-EMEA 14px weight 700 uppercase, `#ffffff` +- Hover: color shift, no underline change +- Mega-menu dropdowns for product categories +- Sticky on scroll with backdrop + +### Image Treatment +- Product/GPU renders as hero images, often full-width +- Screenshot images with subtle shadow for depth +- Green gradient overlays on dark hero sections +- Circular avatar containers with 50% radius + +### Distinctive Components + +**Product Cards** +- Clean white or dark card with minimal radius (2px) +- Green accent border or underline on title +- Bold heading + lighter description pattern +- CTA with green border at bottom + +**Tech Spec Tables** +- Industrial grid layouts +- Alternating row backgrounds (subtle gray shift) +- Bold labels, regular values +- Green highlights for key metrics + +**Cookie/Consent Banner** +- Fixed bottom positioning +- Rounded buttons (2px radius) +- Gray border treatments + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 7px, 8px, 9px, 10px, 11px, 12px, 13px, 15px +- Primary padding values: 8px, 11px, 13px, 16px, 24px, 32px +- Section spacing: 48-80px vertical padding + +### Grid & Container +- Max content width: approximately 1200px (contained) +- Full-width hero sections with contained text +- Feature sections: 2-3 column grids for product cards +- Single-column for article/blog content +- Sidebar layouts for documentation + +### Whitespace Philosophy +- **Purposeful density**: NVIDIA uses tighter spacing than typical SaaS sites, reflecting the density of technical content. White space exists to separate concepts, not to create luxury emptiness. +- **Section rhythm**: Dark sections alternate with white sections, using background color (not just spacing) to separate content blocks. +- **Card density**: Product cards sit close together with 16-20px gaps, creating a catalog feel rather than a gallery feel. + +### Border Radius Scale +- Micro (1px): Inline spans, tiny elements +- Standard (2px): Buttons, cards, containers, inputs -- the default for nearly everything +- Circle (50%): Avatar images, circular tab indicators + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page backgrounds, inline text | +| Subtle (Level 1) | `rgba(0,0,0,0.3) 0px 0px 5px 0px` | Standard cards, modals | +| Border (Level 1b) | `1px solid #5e5e5e` | Content dividers, section borders | +| Green accent (Level 2) | `2px solid #76b900` | Active elements, CTAs, selected items | +| Focus (Accessibility) | `2px solid #000000` outline | Keyboard focus ring | + +**Shadow Philosophy**: NVIDIA's depth system is minimal and utilitarian. There is essentially one shadow value -- a 5px ambient blur at 30% opacity -- used sparingly for cards and modals. The primary depth signal is not shadow but _color contrast_: black backgrounds next to white sections, green borders on black surfaces. This creates hardware-like visual layering where depth comes from material difference, not simulated light. + +### Decorative Depth +- Green gradient washes behind hero content +- Dark-to-darker gradients (black to near-black) for section transitions +- No glassmorphism or blur effects -- clarity over atmosphere + +## 7. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <375px | Compact single column, reduced padding | +| Mobile | 375-425px | Standard mobile layout | +| Mobile Large | 425-600px | Wider mobile, some 2-col hints | +| Tablet Small | 600-768px | 2-column grids begin | +| Tablet | 768-1024px | Full card grids, expanded nav | +| Desktop | 1024-1350px | Standard desktop layout | +| Large Desktop | >1350px | Maximum content width, generous margins | + +### Touch Targets +- Buttons use 11px 13px padding for comfortable tap targets +- Navigation links at 14px uppercase with adequate spacing +- Green-bordered buttons provide high-contrast touch targets on dark backgrounds +- Mobile: hamburger menu collapse with full-screen overlay + +### Collapsing Strategy +- Hero: 36px heading scales down proportionally +- Navigation: full horizontal nav collapses to hamburger menu at ~1024px +- Product cards: 3-column to 2-column to single column stacked +- Footer: multi-column grid collapses to single stacked column +- Section spacing: 64-80px reduces to 32-48px on mobile +- Images: maintain aspect ratio, scale to container width + +### Image Behavior +- GPU/product renders maintain high resolution at all sizes +- Hero images scale proportionally with viewport +- Card images use consistent aspect ratios +- Full-bleed dark sections maintain edge-to-edge treatment + +## 8. Responsive Behavior (Extended) + +### Typography Scaling +- Display 36px scales to ~24px on mobile +- Section headings 24px scale to ~20px on mobile +- Body text maintains 15-16px across all breakpoints +- Button text maintains 16px for consistent tap targets + +### Dark/Light Section Strategy +- Dark sections (black bg, white text) alternate with light sections (white bg, black text) +- The green accent remains consistent across both surface types +- On dark: links are white, underlines are green +- On light: links are black, underlines are green +- This alternation creates natural scroll rhythm and content grouping + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary accent: NVIDIA Green (`#76b900`) +- Background dark: True Black (`#000000`) +- Background light: Pure White (`#ffffff`) +- Heading text (dark bg): White (`#ffffff`) +- Heading text (light bg): Black (`#000000`) +- Body text (light bg): Black (`#000000`) or Near Black (`#1a1a1a`) +- Body text (dark bg): White (`#ffffff`) or Gray 300 (`#a7a7a7`) +- Link hover: Blue (`#3860be`) +- Border accent: `2px solid #76b900` +- Button hover: Teal (`#1eaedb`) + +### Example Component Prompts +- "Create a hero section on black background. Headline at 36px NVIDIA-EMEA weight 700, line-height 1.25, color #ffffff. Subtitle at 18px weight 400, line-height 1.67, color #a7a7a7. CTA button with transparent background, 2px solid #76b900 border, 2px radius, 11px 13px padding, text #ffffff. Hover: background #1eaedb, text white." +- "Design a product card: white background, 2px border-radius, box-shadow rgba(0,0,0,0.3) 0px 0px 5px. Title at 20px NVIDIA-EMEA weight 700, line-height 1.25, color #000000. Body at 15px weight 400, line-height 1.67, color #757575. Green underline accent on title: border-bottom 2px solid #76b900." +- "Build a navigation bar: #000000 background, sticky top. NVIDIA logo left-aligned. Links at 14px NVIDIA-EMEA weight 700 uppercase, color #ffffff. Hover: color #3860be. Green-bordered CTA button right-aligned." +- "Create a dark feature section: #000000 background. Section label at 14px weight 700 uppercase, color #76b900. Heading at 24px weight 700, color #ffffff. Description at 16px weight 400, color #a7a7a7. Three product cards in a row with 20px gap." +- "Design a footer: #000000 background. Multi-column layout with link groups. Links at 14px weight 400, color #a7a7a7. Hover: color #76b900. Bottom bar with legal text at 12px, color #757575." + +### Iteration Guide +1. Always use `#76b900` as accent, never as a background fill -- it's a signal color for borders, underlines, and highlights +2. Buttons are transparent with green borders by default -- filled backgrounds appear only on hover/active states +3. Weight 700 is the dominant voice for all interactive and heading elements; 400 is only for body paragraphs +4. Border radius is 2px for everything -- this sharp, minimal rounding is core to the industrial aesthetic +5. Dark sections use white text; light sections use black text -- green accent works identically on both +6. Link hover is always `#3860be` (blue) regardless of the link's default color +7. Line-height 1.25 for headings, 1.50-1.67 for body text -- maintain this contrast for visual hierarchy +8. Navigation uses uppercase 14px bold -- this hardware-label typography is part of the brand voice diff --git a/skills/creative/popular-web-designs/templates/ollama.md b/skills/creative/popular-web-designs/templates/ollama.md new file mode 100644 index 000000000000..8e516db58b87 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/ollama.md @@ -0,0 +1,280 @@ +# Design System: Ollama + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Ollama's interface is radical minimalism taken to its logical conclusion — a pure-white void where content floats without decoration, shadow, or color. The design philosophy mirrors the product itself: strip away everything unnecessary until only the essential tool remains. This is the digital equivalent of a Dieter Rams object — every pixel earns its place, and the absence of design IS the design. + +The entire page exists in pure grayscale. There is zero chromatic color in the interface — no brand blue, no accent green, no semantic red. The only colors that exist are shades between pure black (`#000000`) and pure white (`#ffffff`), creating a monochrome environment that lets the user's mental model of "open models" remain uncolored by brand opinion. The Ollama llama mascot, rendered in simple black line art, is the only illustration — and even it's monochrome. + +What makes Ollama distinctive is the combination of SF Pro Rounded (Apple's rounded system font) with an exclusively pill-shaped geometry (9999px radius on everything interactive). The rounded letterforms + rounded buttons + rounded containers create a cohesive "softness language" that makes a developer CLI tool feel approachable and friendly rather than intimidating. This is minimalism with warmth — not cold Swiss-style grid minimalism, but the kind where the edges are literally softened. + +**Key Characteristics:** +- Pure white canvas with zero chromatic color — completely grayscale +- SF Pro Rounded headlines creating a distinctively Apple-like softness +- Binary border-radius system: 12px (containers) or 9999px (everything interactive) +- Zero shadows — depth comes exclusively from background color shifts and borders +- Pill-shaped geometry on all interactive elements (buttons, tabs, inputs, tags) +- The Ollama llama as the sole illustration — black line art, no color +- Extreme content restraint — the homepage is short, focused, and uncluttered + +## 2. Color Palette & Roles + +### Primary +- **Pure Black** (`#000000`): Primary headlines, primary links, and the darkest text. The only "color" that demands attention. +- **Near Black** (`#262626`): Button text on light surfaces, secondary headline weight. +- **Darkest Surface** (`#090909`): The darkest possible surface — barely distinguishable from pure black, used for footer or dark containers. + +### Surface & Background +- **Pure White** (`#ffffff`): The primary page background — not off-white, not cream, pure white. Button surfaces for secondary actions. +- **Snow** (`#fafafa`): The subtlest possible surface distinction from white — used for section backgrounds and barely-elevated containers. +- **Light Gray** (`#e5e5e5`): Button backgrounds, borders, and the primary containment color. The workhorse neutral. + +### Neutrals & Text +- **Stone** (`#737373`): Secondary body text, footer links, and de-emphasized content. The primary "muted" tone. +- **Mid Gray** (`#525252`): Emphasized secondary text, slightly darker than Stone. +- **Silver** (`#a3a3a3`): Tertiary text, placeholders, and deeply de-emphasized metadata. +- **Button Text Dark** (`#404040`): Specific to white-surface button text. + +### Semantic & Accent +- **Ring Blue** (`#3b82f6` at 50%): The ONLY non-gray color in the entire system — Tailwind's default focus ring, used exclusively for keyboard accessibility. Never visible in normal interaction flow. +- **Border Light** (`#d4d4d4`): A slightly darker gray for white-surface button borders. + +### Gradient System +- **None.** Ollama uses absolutely no gradients. Visual separation comes from flat color blocks and single-pixel borders. This is a deliberate, almost philosophical design choice. + +## 3. Typography Rules + +### Font Family +- **Display**: `SF Pro Rounded`, with fallbacks: `system-ui, -apple-system, system-ui` +- **Body / UI**: `ui-sans-serif`, with fallbacks: `system-ui, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji` +- **Monospace**: `ui-monospace`, with fallbacks: `SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New` + +*Note: SF Pro Rounded is Apple's system font — it renders with rounded terminals on macOS/iOS and falls back to the system sans-serif on other platforms.* + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | SF Pro Rounded | 48px (3rem) | 500 | 1.00 (tight) | normal | Maximum impact, rounded letterforms | +| Section Heading | SF Pro Rounded | 36px (2.25rem) | 500 | 1.11 (tight) | normal | Feature section titles | +| Sub-heading | SF Pro Rounded / ui-sans-serif | 30px (1.88rem) | 400–500 | 1.20 (tight) | normal | Card headings, feature names | +| Card Title | ui-sans-serif | 24px (1.5rem) | 400 | 1.33 | normal | Medium emphasis headings | +| Body Large | ui-sans-serif | 18px (1.13rem) | 400–500 | 1.56 | normal | Hero descriptions, button text | +| Body / Link | ui-sans-serif | 16px (1rem) | 400–500 | 1.50 | normal | Standard body text, navigation | +| Caption | ui-sans-serif | 14px (0.88rem) | 400 | 1.43 | normal | Metadata, descriptions | +| Small | ui-sans-serif | 12px (0.75rem) | 400 | 1.33 | normal | Smallest sans-serif text | +| Code Body | ui-monospace | 16px (1rem) | 400 | 1.50 | normal | Inline code, commands | +| Code Caption | ui-monospace | 14px (0.88rem) | 400 | 1.43 | normal | Code snippets, secondary | +| Code Small | ui-monospace | 12px (0.75rem) | 400–700 | 1.63 | normal | Tags, labels | + +### Principles +- **Rounded display, standard body**: SF Pro Rounded carries display headlines with its distinctive rounded terminals, while the standard system sans handles all body text. The rounded font IS the brand expression. +- **Weight restraint**: Only two weights matter — 400 (regular) for body and 500 (medium) for headings. No bold, no light, no black weight. This extreme restraint reinforces the minimal philosophy. +- **Tight display, comfortable body**: Headlines compress to 1.0 line-height, while body text relaxes to 1.43–1.56. The contrast creates clear hierarchy without needing weight contrast. +- **Monospace for developer identity**: Code blocks and terminal commands appear throughout as primary content, using the system monospace stack. + +## 4. Component Stylings + +### Buttons + +**Gray Pill (Primary)** +- Background: Light Gray (`#e5e5e5`) +- Text: Near Black (`#262626`) +- Padding: 10px 24px +- Border: thin solid Light Gray (`1px solid #e5e5e5`) +- Radius: pill-shaped (9999px) +- The primary action button — understated, grayscale, always pill-shaped + +**White Pill (Secondary)** +- Background: Pure White (`#ffffff`) +- Text: Button Text Dark (`#404040`) +- Padding: 10px 24px +- Border: thin solid Border Light (`1px solid #d4d4d4`) +- Radius: pill-shaped (9999px) +- Secondary action — visually lighter than Gray Pill + +**Black Pill (CTA)** +- Background: Pure Black (`#000000`) +- Text: Pure White (`#ffffff`) +- Radius: pill-shaped (9999px) +- Inferred from "Create account" and "Explore" buttons +- Maximum emphasis — black on white + +### Cards & Containers +- Background: Pure White or Snow (`#fafafa`) +- Border: thin solid Light Gray (`1px solid #e5e5e5`) when needed +- Radius: comfortably rounded (12px) — the ONLY non-pill radius in the system +- Shadow: **none** — zero shadows on any element +- Hover: likely subtle background shift or border darkening + +### Inputs & Forms +- Background: Pure White +- Border: `1px solid #e5e5e5` +- Radius: pill-shaped (9999px) — search inputs and form fields are pill-shaped +- Focus: Ring Blue (`#3b82f6` at 50%) ring +- Placeholder: Silver (`#a3a3a3`) + +### Navigation +- Clean horizontal nav with minimal elements +- Logo: Ollama llama icon + wordmark in black +- Links: "Models", "Docs", "Pricing" in black at 16px, weight 400 +- Search bar: pill-shaped with placeholder text +- Right side: "Sign in" link + "Download" black pill CTA +- No borders, no background — transparent nav on white page + +### Image Treatment +- The Ollama llama mascot is the only illustration — black line art on white +- Code screenshots/terminal outputs shown in bordered containers (12px radius) +- Integration logos displayed as simple icons in a grid +- No photographs, no gradients, no decorative imagery + +### Distinctive Components + +**Tab Pills** +- Pill-shaped tab selectors (e.g., "Coding" | "OpenClaw") +- Active: Light Gray bg; Inactive: transparent +- All pill-shaped (9999px) + +**Model Tags** +- Small pill-shaped tags (e.g., "ollama", "launch", "claude") +- Light Gray background, dark text +- The primary way to browse models + +**Terminal Command Block** +- Monospace code showing `ollama run` commands +- Minimal styling — just a bordered 12px-radius container +- Copy button integrated + +**Integration Grid** +- Grid of integration logos (Codex, Claude Code, OpenCode, LangChain, etc.) +- Each in a bordered pill or card with icon + name +- Tabbed by category (Coding, Documents & RAG, Automation, Chat) + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 4px, 6px, 8px, 9px, 10px, 12px, 14px, 16px, 20px, 24px, 32px, 40px, 48px, 88px, 112px +- Button padding: 10px 24px (consistent across all buttons) +- Card internal padding: approximately 24–32px +- Section vertical spacing: very generous (88px–112px) + +### Grid & Container +- Max container width: approximately 1024–1280px, centered +- Hero: centered single-column with llama illustration +- Feature sections: 2-column layout (text left, code right) +- Integration grid: responsive multi-column +- Footer: clean single-row + +### Whitespace Philosophy +- **Emptiness as luxury**: The page is remarkably short and sparse — no feature section overstays its welcome. Each concept gets minimal but sufficient space. +- **Content density is low by design**: Where other AI companies pack feature after feature, Ollama presents three ideas (run models, use with apps, integrations) and stops. +- **The white space IS the brand**: Pure white space with zero decoration communicates "this tool gets out of your way." + +### Border Radius Scale +- Comfortably rounded (12px): The sole container radius — code blocks, cards, panels +- Pill-shaped (9999px): Everything interactive — buttons, tabs, inputs, tags, badges + +*This binary system is extreme and distinctive. There is no 4px, no 8px, no gradient of roundness. Elements are either containers (12px) or interactive (pill).* + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background, most content | +| Bordered (Level 1) | `1px solid #e5e5e5` | Cards, code blocks, buttons | + +**Shadow Philosophy**: Ollama uses **zero shadows**. This is not an oversight — it's a deliberate design decision. Every other major AI product site uses at least subtle shadows. Ollama's flat, shadowless approach creates a paper-like experience where elements are distinguished purely by background color and single-pixel borders. Depth is communicated through **content hierarchy and typography weight**, not visual layering. + +## 7. Do's and Don'ts + +### Do +- Use pure white (`#ffffff`) as the page background — never off-white or cream +- Use pill-shaped (9999px) radius on all interactive elements — buttons, tabs, inputs, tags +- Use 12px radius on all non-interactive containers — code blocks, cards, panels +- Keep the palette strictly grayscale — no chromatic colors except the blue focus ring +- Use SF Pro Rounded at weight 500 for display headings — the rounded terminals are the brand expression +- Maintain zero shadows — depth comes from borders and background shifts only +- Keep content density low — each section should present one clear idea +- Use monospace for terminal commands and code — it's primary content, not decoration +- Keep all buttons at 10px 24px padding with pill shape — consistency is absolute + +### Don't +- Don't introduce any chromatic color — no brand blue, no accent green, no warm tones +- Don't use border-radius between 12px and 9999px — the system is binary +- Don't add shadows to any element — the flat aesthetic is intentional +- Don't use font weights above 500 — no bold, no black weight +- Don't add decorative illustrations beyond the llama mascot +- Don't use gradients anywhere — flat blocks and borders only +- Don't overcomplicate the layout — two columns maximum, no complex grids +- Don't use borders heavier than 1px — containment is always the lightest possible touch +- Don't add hover animations or transitions — interactions should feel instant and direct + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, stacked everything, hamburger nav | +| Small Tablet | 640–768px | Minor adjustments to spacing | +| Tablet | 768–850px | 2-column layouts begin | +| Desktop | 850–1024px | Standard layout, expanded features | +| Large Desktop | 1024–1280px | Maximum content width | + +### Touch Targets +- All buttons are pill-shaped with generous padding (10px 24px) +- Navigation links at comfortable 16px size +- Minimum touch area easily exceeds 44x44px + +### Collapsing Strategy +- **Navigation**: Collapses to hamburger menu on mobile +- **Feature sections**: 2-column → stacked single column +- **Hero text**: 48px → 36px → 30px progressive scaling +- **Integration grid**: Multi-column → 2-column → single column +- **Code blocks**: Horizontal scroll maintained + +### Image Behavior +- Llama mascot scales proportionally +- Code blocks maintain monospace formatting +- Integration icons reflow to fewer columns +- No art direction changes + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Text: "Pure Black (#000000)" +- Page Background: "Pure White (#ffffff)" +- Secondary Text: "Stone (#737373)" +- Button Background: "Light Gray (#e5e5e5)" +- Borders: "Light Gray (#e5e5e5)" +- Muted Text: "Silver (#a3a3a3)" +- Dark Text: "Near Black (#262626)" +- Subtle Surface: "Snow (#fafafa)" + +### Example Component Prompts +- "Create a hero section on pure white (#ffffff) with an illustration centered above a headline at 48px SF Pro Rounded weight 500, line-height 1.0. Use Pure Black (#000000) text. Below, add a black pill-shaped CTA button (9999px radius, 10px 24px padding) and a gray pill button." +- "Design a code block with a 12px border-radius, 1px solid Light Gray (#e5e5e5) border on white background. Use ui-monospace at 16px for the terminal command. No shadow." +- "Build a tab bar with pill-shaped tabs (9999px radius). Active tab: Light Gray (#e5e5e5) background, Near Black (#262626) text. Inactive: transparent background, Stone (#737373) text." +- "Create an integration card grid. Each card is a bordered pill (9999px radius) or a 12px-radius card with 1px solid #e5e5e5 border. Icon + name inside. Grid of 4 columns on desktop." +- "Design a navigation bar: transparent background, no border. Ollama logo on the left, 3 text links (Pure Black, 16px, weight 400), pill search input in the center, 'Sign in' text link and black pill 'Download' button on the right." + +### Iteration Guide +1. Focus on ONE component at a time +2. Keep all values grayscale — "Stone (#737373)" not "use a light color" +3. Always specify pill (9999px) or container (12px) radius — nothing in between +4. Shadows are always zero — never add them +5. Weight is always 400 or 500 — never bold +6. If something feels too decorated, remove it — less is always more for Ollama diff --git a/skills/creative/popular-web-designs/templates/opencode.ai.md b/skills/creative/popular-web-designs/templates/opencode.ai.md new file mode 100644 index 000000000000..445b699d63e4 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/opencode.ai.md @@ -0,0 +1,294 @@ +# Design System: OpenCode + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `JetBrains Mono` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'JetBrains Mono', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +OpenCode's website embodies a terminal-native, monospace-first aesthetic that reflects its identity as an open source AI coding agent. The entire visual system is built on a stark dark-on-light contrast using a near-black background (`#201d1d`) with warm off-white text (`#fdfcfc`). This isn't a generic dark theme -- it's a warm, slightly reddish-brown dark that feels like a sophisticated terminal emulator rather than a cold IDE. The warm undertone in both the darks and lights (notice the subtle red channel in `#201d1d` -- rgb(32, 29, 29)) creates a cohesive, lived-in quality. + +Berkeley Mono is the sole typeface, establishing an unapologetic monospace identity. Every element -- headings, body text, buttons, navigation -- shares this single font family, creating a unified "everything is code" philosophy. The heading at 38px bold with 1.50 line-height is generous and readable, while body text at 16px with weight 500 provides a slightly heavier-than-normal reading weight that enhances legibility on screen. The monospace grid naturally enforces alignment and rhythm across the layout. + +The color system is deliberately minimal. The primary palette consists of just three functional tones: the warm near-black (`#201d1d`), a medium warm gray (`#9a9898`), and a bright off-white (`#fdfcfc`). Semantic colors borrow from the Apple HIG palette -- blue accent (`#007aff`), red danger (`#ff3b30`), green success (`#30d158`), orange warning (`#ff9f0a`) -- giving the interface familiar, trustworthy signal colors without adding brand complexity. Borders use a subtle warm transparency (`rgba(15, 0, 0, 0.12)`) that ties into the warm undertone of the entire system. + +**Key Characteristics:** +- Berkeley Mono as the sole typeface -- monospace everywhere, no sans-serif or serif voices +- Warm near-black primary (`#201d1d`) with reddish-brown undertone, not pure black +- Off-white text (`#fdfcfc`) with warm tint, not pure white +- Minimal 4px border radius throughout -- sharp, utilitarian corners +- 8px base spacing system scaling up to 96px +- Apple HIG-inspired semantic colors (blue, red, green, orange) +- Transparent warm borders using `rgba(15, 0, 0, 0.12)` +- Email input with generous 20px padding and 6px radius -- the most generous component radius +- Single button variant: dark background, light text, tight vertical padding (4px 20px) +- Underlined links as default link style, reinforcing the text-centric identity + +## 2. Color Palette & Roles + +### Primary +- **OpenCode Dark** (`#201d1d`): Primary background, button fills, link text. A warm near-black with subtle reddish-brown warmth -- rgb(32, 29, 29). +- **OpenCode Light** (`#fdfcfc`): Primary text on dark surfaces, button text. A barely-warm off-white that avoids clinical pure white. +- **Mid Gray** (`#9a9898`): Secondary text, muted links. A neutral warm gray that bridges dark and light. + +### Secondary +- **Dark Surface** (`#302c2c`): Slightly lighter than primary dark, used for elevated surfaces and subtle differentiation. +- **Border Gray** (`#646262`): Stronger borders, outline rings on interactive elements. +- **Light Surface** (`#f1eeee`): Light mode surface, subtle background variation. + +### Accent +- **Accent Blue** (`#007aff`): Primary accent, links, interactive highlights. Apple system blue. +- **Accent Blue Hover** (`#0056b3`): Darker blue for hover states. +- **Accent Blue Active** (`#004085`): Deepest blue for pressed/active states. + +### Semantic +- **Danger Red** (`#ff3b30`): Error states, destructive actions. Apple system red. +- **Danger Hover** (`#d70015`): Darker red for hover on danger elements. +- **Danger Active** (`#a50011`): Deepest red for pressed danger states. +- **Success Green** (`#30d158`): Success states, positive feedback. Apple system green. +- **Warning Orange** (`#ff9f0a`): Warning states, caution signals. Apple system orange. +- **Warning Hover** (`#cc7f08`): Darker orange for hover on warning elements. +- **Warning Active** (`#995f06`): Deepest orange for pressed warning states. + +### Text Scale +- **Text Muted** (`#6e6e73`): Muted labels, disabled text, placeholder content. +- **Text Secondary** (`#424245`): Secondary text on light backgrounds, captions. + +### Border +- **Border Warm** (`rgba(15, 0, 0, 0.12)`): Primary border color, warm transparent black with red tint. +- **Border Tab** (`#9a9898`): Tab underline border, 2px solid bottom. +- **Border Outline** (`#646262`): 1px solid outline border for containers. + +## 3. Typography Rules + +### Font Family +- **Universal**: `Berkeley Mono`, with fallbacks: `IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace` + +### Hierarchy + +| Role | Size | Weight | Line Height | Notes | +|------|------|--------|-------------|-------| +| Heading 1 | 38px (2.38rem) | 700 | 1.50 | Hero headlines, page titles | +| Heading 2 | 16px (1.00rem) | 700 | 1.50 | Section titles, bold emphasis | +| Body | 16px (1.00rem) | 400 | 1.50 | Standard body text, paragraphs | +| Body Medium | 16px (1.00rem) | 500 | 1.50 | Links, button text, nav items | +| Body Tight | 16px (1.00rem) | 500 | 1.00 (tight) | Compact labels, tab items | +| Caption | 14px (0.88rem) | 400 | 2.00 (relaxed) | Footnotes, metadata, small labels | + +### Principles +- **One font, one voice**: Berkeley Mono is used exclusively. There is no typographic variation between display, body, and code -- everything speaks in the same monospace register. Hierarchy is achieved through size and weight alone. +- **Weight as hierarchy**: 700 for headings, 500 for interactive/medium emphasis, 400 for body text. Three weight levels create the entire hierarchy. +- **Generous line-height**: 1.50 as the standard line-height gives text room to breathe within the monospace grid. The relaxed 2.00 line-height on captions creates clear visual separation. +- **Tight for interaction**: Interactive elements (tabs, compact labels) use 1.00 line-height for dense, clickable targets. + +## 4. Component Stylings + +### Buttons + +**Primary (Dark Fill)** +- Background: `#201d1d` (OpenCode Dark) +- Text: `#fdfcfc` (OpenCode Light) +- Padding: 4px 20px +- Radius: 4px +- Font: 16px Berkeley Mono, weight 500, line-height 2.00 (relaxed) +- Outline: `rgb(253, 252, 252) none 0px` +- Use: Primary CTAs, main actions + +### Inputs + +**Email Input** +- Background: `#f8f7f7` (light neutral) +- Text: `#201d1d` +- Border: `1px solid rgba(15, 0, 0, 0.12)` +- Padding: 20px +- Radius: 6px +- Font: Berkeley Mono, standard size +- Use: Form fields, email capture + +### Links + +**Default Link** +- Color: `#201d1d` +- Decoration: underline 1px +- Font-weight: 500 +- Use: Primary text links in body content + +**Light Link** +- Color: `#fdfcfc` +- Decoration: none +- Use: Links on dark backgrounds, navigation + +**Muted Link** +- Color: `#9a9898` +- Decoration: none +- Use: Footer links, secondary navigation + +### Tabs + +**Tab Navigation** +- Border-bottom: `2px solid #9a9898` (active tab indicator) +- Font: 16px, weight 500, line-height 1.00 +- Use: Section switching, content filtering + +### Navigation +- Clean horizontal layout with Berkeley Mono throughout +- Brand logotype left-aligned in monospace +- Links at 16px weight 500 with underline decoration +- Dark background matching page background +- No backdrop blur or transparency -- solid surfaces only + +### Image Treatment +- Terminal/code screenshots as hero imagery +- Dark terminal aesthetic with monospace type +- Minimal borders, content speaks for itself + +### Distinctive Components + +**Terminal Hero** +- Full-width dark terminal window as hero element +- ASCII art / stylized logo within terminal frame +- Monospace command examples with syntax highlighting +- Reinforces the CLI-first identity of the product + +**Feature List** +- Bulleted feature items with Berkeley Mono text +- Weight 500 for feature names, 400 for descriptions +- Tight vertical spacing between items +- No cards or borders -- pure text layout + +**Email Capture** +- Light background input (`#f8f7f7`) contrasting dark page +- Generous 20px padding for comfortable typing +- 6px radius -- the roundest element in the system +- Newsletter/waitlist pattern + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Fine scale: 1px, 2px, 4px (sub-8px for borders and micro-adjustments) +- Standard scale: 8px, 12px, 16px, 20px, 24px +- Extended scale: 32px, 40px, 48px, 64px, 80px, 96px +- The system follows a clean 4/8px grid with consistent doubling + +### Grid & Container +- Max content width: approximately 800-900px (narrow, reading-optimized) +- Single-column layout as the primary pattern +- Centered content with generous horizontal margins +- Hero section: full-width dark terminal element +- Feature sections: single-column text blocks +- Footer: multi-column link grid + +### Whitespace Philosophy +- **Monospace rhythm**: The fixed-width nature of Berkeley Mono creates a natural vertical grid. Line-heights of 1.50 and 2.00 maintain consistent rhythm. +- **Narrow and focused**: Content is constrained to a narrow column, creating generous side margins that focus attention on the text. +- **Sections through spacing**: No decorative dividers. Sections are separated by generous vertical spacing (48-96px) rather than borders or background changes. + +### Border Radius Scale +- Micro (4px): Default for all elements -- buttons, containers, badges +- Input (6px): Form inputs get slightly more roundness +- The entire system uses just two radius values, reinforcing the utilitarian aesthetic + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Default state for most elements | +| Border Subtle (Level 1) | `1px solid rgba(15, 0, 0, 0.12)` | Section dividers, input borders, horizontal rules | +| Border Tab (Level 2) | `2px solid #9a9898` bottom only | Active tab indicator | +| Border Outline (Level 3) | `1px solid #646262` | Container outlines, elevated elements | + +**Shadow Philosophy**: OpenCode's depth system is intentionally flat. There are no box-shadows in the extracted tokens -- zero shadow values were detected. Depth is communicated exclusively through border treatments and background color shifts. This flatness is consistent with the terminal aesthetic: terminals don't have shadows, and neither does OpenCode. The three border levels (transparent warm, tab indicator, solid outline) create sufficient visual hierarchy without any elevation illusion. + +### Decorative Depth +- Background color shifts between `#201d1d` and `#302c2c` create subtle surface differentiation +- Transparent borders at 12% opacity provide barely-visible structure +- The warm reddish tint in border colors (`rgba(15, 0, 0, 0.12)`) ties borders to the overall warm dark palette +- No gradients, no blurs, no ambient effects -- pure flat terminal aesthetic + +## 7. Interaction & Motion + +### Hover States +- Links: color shift from default to accent blue (`#007aff`) or underline style change +- Buttons: subtle background lightening or border emphasis +- Accent blue provides a three-stage hover sequence: `#007aff` → `#0056b3` → `#004085` (default → hover → active) +- Danger red: `#ff3b30` → `#d70015` → `#a50011` +- Warning orange: `#ff9f0a` → `#cc7f08` → `#995f06` + +### Focus States +- Border-based focus: increased border opacity or solid border color +- No shadow-based focus rings -- consistent with the flat, no-shadow aesthetic +- Keyboard focus likely uses outline or border color shift to accent blue + +### Transitions +- Minimal transitions expected -- terminal-inspired interfaces favor instant state changes +- Color transitions: 100-150ms for subtle state feedback +- No scale, rotate, or complex transform animations + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, reduced padding, heading scales down | +| Tablet | 640-1024px | Content width expands, slight padding increase | +| Desktop | >1024px | Full content width (~800-900px centered), maximum whitespace | + +### Touch Targets +- Buttons with 4px 20px padding provide adequate horizontal touch area +- Input fields with 20px padding ensure comfortable mobile typing +- Tab items at 16px with tight line-height may need mobile adaptation + +### Collapsing Strategy +- Hero heading: 38px → 28px → 24px on smaller screens +- Navigation: horizontal links → hamburger/drawer on mobile +- Feature lists: maintain single-column, reduce horizontal padding +- Terminal hero: maintain full-width, reduce internal padding +- Footer columns: multi-column → stacked single column +- Section spacing: 96px → 64px → 48px on mobile + +### Image Behavior +- Terminal screenshots maintain aspect ratio and border treatment +- Full-width elements scale proportionally +- Monospace type maintains readability at all sizes due to fixed-width nature + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Page background: `#201d1d` (warm near-black) +- Primary text: `#fdfcfc` (warm off-white) +- Secondary text: `#9a9898` (warm gray) +- Muted text: `#6e6e73` +- Accent: `#007aff` (blue) +- Danger: `#ff3b30` (red) +- Success: `#30d158` (green) +- Warning: `#ff9f0a` (orange) +- Button bg: `#201d1d`, button text: `#fdfcfc` +- Border: `rgba(15, 0, 0, 0.12)` (warm transparent) +- Input bg: `#f8f7f7`, input border: `rgba(15, 0, 0, 0.12)` + +### Example Component Prompts +- "Create a hero section on `#201d1d` warm dark background. Headline at 38px Berkeley Mono weight 700, line-height 1.50, color `#fdfcfc`. Subtitle at 16px weight 400, color `#9a9898`. Primary CTA button (`#201d1d` bg with `1px solid #646262` border, 4px radius, 4px 20px padding, `#fdfcfc` text at weight 500)." +- "Design a feature list: single-column on `#201d1d` background. Feature name at 16px Berkeley Mono weight 700, color `#fdfcfc`. Description at 16px weight 400, color `#9a9898`. No cards, no borders -- pure text with 16px vertical gap between items." +- "Build an email capture form: `#f8f7f7` background input, `1px solid rgba(15, 0, 0, 0.12)` border, 6px radius, 20px padding. Adjacent dark button (`#201d1d` bg, `#fdfcfc` text, 4px radius, 4px 20px padding). Berkeley Mono throughout." +- "Create navigation: sticky `#201d1d` background. 16px Berkeley Mono weight 500 for links, `#fdfcfc` text. Brand name left-aligned in monospace. Links with underline decoration. No blur, no transparency -- solid dark surface." +- "Design a footer: `#201d1d` background, multi-column link grid. Links at 16px Berkeley Mono weight 400, color `#9a9898`. Section headers at weight 700. Border-top `1px solid rgba(15, 0, 0, 0.12)` separator." + +### Iteration Guide +1. Berkeley Mono is the only font -- never introduce a second typeface. Size and weight create all hierarchy. +2. Keep surfaces flat: no shadows, no gradients, no blur effects. Use borders and background shifts only. +3. The warm undertone matters: use `#201d1d` not `#000000`, use `#fdfcfc` not `#ffffff`. The reddish warmth is subtle but essential. +4. Border radius is 4px everywhere except inputs (6px). Never use rounded pills or large radii. +5. Semantic colors follow Apple HIG: `#007aff` blue, `#ff3b30` red, `#30d158` green, `#ff9f0a` orange. Each has hover and active darkened variants. +6. Three-stage interaction: default → hover (darkened) → active (deeply darkened) for all semantic colors. +7. Borders use `rgba(15, 0, 0, 0.12)` -- a warm transparent dark, not neutral gray. This ties borders to the warm palette. +8. Spacing follows an 8px grid: 8, 16, 24, 32, 40, 48, 64, 80, 96px. Use 4px for fine adjustments only. diff --git a/skills/creative/popular-web-designs/templates/pinterest.md b/skills/creative/popular-web-designs/templates/pinterest.md new file mode 100644 index 000000000000..bcddf7e2d238 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/pinterest.md @@ -0,0 +1,243 @@ +# Design System: Pinterest + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Pinterest's website is a warm, inspiration-driven canvas that treats visual discovery like a lifestyle magazine. The design operates on a soft, slightly warm white background with Pinterest Red (`#e60023`) as the singular, bold brand accent. Unlike the cool blues of most tech platforms, Pinterest's neutral scale has a distinctly warm undertone — grays lean toward olive/sand (`#91918c`, `#62625b`, `#e5e5e0`) rather than cool steel, creating a cozy, craft-like atmosphere that invites browsing. + +The typography uses Pin Sans — a custom proprietary font with a broad fallback stack including Japanese fonts, reflecting Pinterest's global reach. At display scale (70px, weight 600), Pin Sans creates large, inviting headlines. At smaller sizes, the system is compact: buttons at 12px, captions at 12–14px. The CSS variable naming system (`--comp-*`, `--sema-*`, `--base-*`) reveals a sophisticated three-tier design token architecture: component-level, semantic-level, and base-level tokens. + +What distinguishes Pinterest is its generous border-radius system (12px–40px, plus 50% for circles) and warm-tinted button backgrounds. The secondary button (`#e5e5e0`) has a distinctly warm, sand-like tone rather than cold gray. The primary red button uses 16px radius — rounded but not pill-shaped. Combined with warm badge backgrounds (`hsla(60,20%,98%,.5)` — a subtle yellow-warm wash) and photography-dominant layouts, the result is a design that feels handcrafted and personal, not corporate and sterile. + +**Key Characteristics:** +- Warm white canvas with olive/sand-toned neutrals — cozy, not clinical +- Pinterest Red (`#e60023`) as singular bold accent — never subtle, always confident +- Pin Sans custom font with global fallback stack (including CJK) +- Three-tier token architecture: `--comp-*` / `--sema-*` / `--base-*` +- Warm secondary surfaces: sand gray (`#e5e5e0`), warm badge (`hsla(60,20%,98%,.5)`) +- Generous border-radius: 16px standard, up to 40px for large containers +- Photography-first content — pins/images are the primary visual element +- Dark near-purple text (`#211922`) — warm, with a hint of plum + +## 2. Color Palette & Roles + +### Primary Brand +- **Pinterest Red** (`#e60023`): Primary CTA, brand accent — bold, confident red +- **Green 700** (`#103c25`): `--base-color-green-700`, success/nature accent +- **Green 700 Hover** (`#0b2819`): `--base-color-hover-green-700`, pressed green + +### Text +- **Plum Black** (`#211922`): Primary text — warm near-black with plum undertone +- **Black** (`#000000`): Secondary text, button text +- **Olive Gray** (`#62625b`): Secondary descriptions, muted text +- **Warm Silver** (`#91918c`): `--comp-button-color-text-transparent-disabled`, disabled text, input borders +- **White** (`#ffffff`): Text on dark/colored surfaces + +### Interactive +- **Focus Blue** (`#435ee5`): `--comp-button-color-border-focus-outer-transparent`, focus rings +- **Performance Purple** (`#6845ab`): `--sema-color-hover-icon-performance-plus`, performance features +- **Recommendation Purple** (`#7e238b`): `--sema-color-hover-text-recommendation`, AI recommendation +- **Link Blue** (`#2b48d4`): Link text color +- **Facebook Blue** (`#0866ff`): `--facebook-background-color`, social login +- **Pressed Blue** (`#617bff`): `--base-color-pressed-blue-200`, pressed state + +### Surface & Border +- **Sand Gray** (`#e5e5e0`): Secondary button background — warm, craft-like +- **Warm Light** (`#e0e0d9`): Circular button backgrounds, badges +- **Warm Wash** (`hsla(60, 20%, 98%, 0.5)`): `--comp-badge-color-background-wash-light`, subtle warm badge bg +- **Fog** (`#f6f6f3`): Light surface (at 50% opacity) +- **Border Disabled** (`#c8c8c1`): `--sema-color-border-disabled`, disabled borders +- **Hover Gray** (`#bcbcb3`): `--base-color-hover-grayscale-150`, hover border +- **Dark Surface** (`#33332e`): Dark section backgrounds + +### Semantic +- **Error Red** (`#9e0a0a`): Checkbox/form error states + +## 3. Typography Rules + +### Font Family +- **Primary**: `Pin Sans`, fallbacks: `-apple-system, system-ui, Segoe UI, Roboto, Oxygen-Sans, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, Helvetica, ヒラギノ角ゴ Pro W3, メイリオ, Meiryo, MS Pゴシック, Arial` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Pin Sans | 70px (4.38rem) | 600 | normal | normal | Maximum impact | +| Section Heading | Pin Sans | 28px (1.75rem) | 700 | normal | -1.2px | Negative tracking | +| Body | Pin Sans | 16px (1.00rem) | 400 | 1.40 | normal | Standard reading | +| Caption Bold | Pin Sans | 14px (0.88rem) | 700 | normal | normal | Strong metadata | +| Caption | Pin Sans | 12px (0.75rem) | 400–500 | 1.50 | normal | Small text, tags | +| Button | Pin Sans | 12px (0.75rem) | 400 | normal | normal | Button labels | + +### Principles +- **Compact type scale**: The range is 12px–70px with a dramatic jump — most functional text is 12–16px, creating a dense, app-like information hierarchy. +- **Warm weight distribution**: 600–700 for headings, 400–500 for body. No ultra-light weights — the type always feels substantial. +- **Negative tracking on headings**: -1.2px on 28px headings creates cozy, intimate section titles. +- **Single font family**: Pin Sans handles everything — no secondary display or monospace font detected. + +## 4. Component Stylings + +### Buttons + +**Primary Red** +- Background: `#e60023` (Pinterest Red) +- Text: `#000000` (black — unusual choice for contrast on red) +- Padding: 6px 14px +- Radius: 16px (generously rounded, not pill) +- Border: `2px solid rgba(255, 255, 255, 0)` (transparent) +- Focus: semantic border + outline via CSS variables + +**Secondary Sand** +- Background: `#e5e5e0` (warm sand gray) +- Text: `#000000` +- Padding: 6px 14px +- Radius: 16px +- Focus: same semantic border system + +**Circular Action** +- Background: `#e0e0d9` (warm light) +- Text: `#211922` (plum black) +- Radius: 50% (circle) +- Use: Pin actions, navigation controls + +**Ghost / Transparent** +- Background: transparent +- Text: `#000000` +- No border +- Use: Tertiary actions + +### Cards & Containers +- Photography-first pin cards with generous radius (12px–20px) +- No traditional box-shadow on most cards +- White or warm fog backgrounds +- 8px white thick border on some image containers + +### Inputs +- Email input: white background, `1px solid #91918c` border, 16px radius, 11px 15px padding +- Focus: semantic border + outline system via CSS variables + +### Navigation +- Clean header on white or warm background +- Pinterest logo + search bar centered +- Pin Sans 16px for nav links +- Pinterest Red accents for active states + +### Image Treatment +- Pin-style masonry grid (signature Pinterest layout) +- Rounded corners: 12px–20px on images +- Photography as primary content — every pin is an image +- Thick white borders (8px) on featured image containers + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 4px, 6px, 7px, 8px, 10px, 11px, 12px, 16px, 18px, 20px, 22px, 24px, 32px, 80px, 100px +- Large jumps: 32px → 80px → 100px for section spacing + +### Grid & Container +- Masonry grid for pin content (signature layout) +- Centered content sections with generous max-width +- Full-width dark footer +- Search bar as primary navigation element + +### Whitespace Philosophy +- **Inspiration density**: The masonry grid packs pins tightly — the content density IS the value proposition. Whitespace exists between sections, not within the grid. +- **Breathing above, density below**: Hero/feature sections get generous padding; the pin grid is compact and immersive. + +### Border Radius Scale +- Standard (12px): Small cards, links +- Button (16px): Buttons, inputs, medium cards +- Comfortable (20px): Feature cards +- Large (28px): Large containers +- Section (32px): Tab elements, large panels +- Hero (40px): Hero containers, large feature blocks +- Circle (50%): Action buttons, tab indicators + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Default — pins rely on content, not shadow | +| Subtle (Level 1) | Minimal shadow (from tokens) | Elevated overlays, dropdowns | +| Focus (Accessibility) | `--sema-color-border-focus-outer-default` ring | Focus states | + +**Shadow Philosophy**: Pinterest uses minimal shadows. The masonry grid relies on content (photography) to create visual interest rather than elevation effects. Depth comes from the warmth of surface colors and the generous rounding of containers. + +## 7. Do's and Don'ts + +### Do +- Use warm neutrals (`#e5e5e0`, `#e0e0d9`, `#91918c`) — the warm olive/sand tone is the identity +- Apply Pinterest Red (`#e60023`) only for primary CTAs — it's bold and singular +- Use Pin Sans exclusively — one font for everything +- Apply generous border-radius: 16px for buttons/inputs, 20px+ for cards +- Keep the masonry grid dense — content density is the value +- Use warm badge backgrounds (`hsla(60,20%,98%,.5)`) for subtle warm washes +- Use `#211922` (plum black) for primary text — it's warmer than pure black + +### Don't +- Don't use cool gray neutrals — always warm/olive-toned +- Don't use pure black (`#000000`) as primary text — use plum black (`#211922`) +- Don't use pill-shaped buttons — 16px radius is rounded but not pill +- Don't add heavy shadows — Pinterest is flat by design, depth from content +- Don't use small border-radius (<12px) on cards — the generous rounding is core +- Don't introduce additional brand colors — red + warm neutrals is the complete palette +- Don't use thin font weights — Pin Sans at 400 minimum + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <576px | Single column, compact layout | +| Mobile Large | 576–768px | 2-column pin grid | +| Tablet | 768–890px | Expanded grid | +| Desktop Small | 890–1312px | Standard masonry grid | +| Desktop | 1312–1440px | Full layout | +| Large Desktop | 1440–1680px | Expanded grid columns | +| Ultra-wide | >1680px | Maximum grid density | + +### Collapsing Strategy +- Pin grid: 5+ columns → 3 → 2 → 1 +- Navigation: search bar + icons → simplified mobile nav +- Feature sections: side-by-side → stacked +- Hero: 70px → scales down proportionally +- Footer: dark multi-column → stacked + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Brand: Pinterest Red (`#e60023`) +- Background: White (`#ffffff`) +- Text: Plum Black (`#211922`) +- Secondary text: Olive Gray (`#62625b`) +- Button surface: Sand Gray (`#e5e5e0`) +- Border: Warm Silver (`#91918c`) +- Focus: Focus Blue (`#435ee5`) + +### Example Component Prompts +- "Create a hero: white background. Headline at 70px Pin Sans weight 600, plum black (#211922). Red CTA button (#e60023, 16px radius, 6px 14px padding). Secondary sand button (#e5e5e0, 16px radius)." +- "Design a pin card: white background, 16px radius, no shadow. Photography fills top, 16px Pin Sans weight 400 description below in #62625b." +- "Build a circular action button: #e0e0d9 background, 50% radius, #211922 icon." +- "Create an input field: white background, 1px solid #91918c, 16px radius, 11px 15px padding. Focus: blue outline via semantic tokens." +- "Design the dark footer: #33332e background. Pinterest script logo in white. 12px Pin Sans links in #91918c." + +### Iteration Guide +1. Warm neutrals everywhere — olive/sand grays, never cool steel +2. Pinterest Red for CTAs only — bold and singular +3. 16px radius on buttons/inputs, 20px+ on cards — generous but not pill +4. Pin Sans is the only font — compact at 12px for UI, 70px for display +5. Photography carries the design — the UI stays warm and minimal +6. Plum black (#211922) for text — warmer than pure black diff --git a/skills/creative/popular-web-designs/templates/posthog.md b/skills/creative/popular-web-designs/templates/posthog.md new file mode 100644 index 000000000000..16498375ff3d --- /dev/null +++ b/skills/creative/popular-web-designs/templates/posthog.md @@ -0,0 +1,269 @@ +# Design System: PostHog + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +PostHog's website feels like a startup's internal wiki that escaped into the wild — warm, irreverent, and deliberately anti-corporate. The background isn't the expected crisp white or dark void of developer tools; it's a warm, sage-tinted cream (`#fdfdf8`) that gives every surface a handmade, paper-like quality. Colors lean into earthy olive greens and muted sage rather than the conventional blues and purples of the SaaS world. It's as if someone designed a developer analytics platform inside a cozy garden shed. + +The personality is the star: hand-drawn hedgehog illustrations, quirky action figures, and playful imagery replace the stock photography and abstract gradients typical of B2B SaaS. IBM Plex Sans Variable serves as the typographic foundation — a font with genuine technical credibility (created by IBM, widely used in developer contexts) deployed here with bold weights (700, 800) on headings and generous line-heights on body text. The typography says "we're serious engineers" while everything around it says "but we don't take ourselves too seriously." + +The interaction design carries the same spirit: hover states flash PostHog Orange (`#F54E00`) text — a hidden brand color that doesn't appear at rest but surprises on interaction. Dark near-black buttons (`#1e1f23`) use opacity reduction on hover rather than color shifts, and active states scale slightly. The border system uses sage-tinted grays (`#bfc1b7`) that harmonize with the olive text palette. Built on Tailwind CSS with Radix UI and shadcn/ui primitives, the technical foundation is modern and component-driven, but the visual output is stubbornly unique. + +**Key Characteristics:** +- Warm sage/olive color palette instead of conventional blues — earthy and approachable +- IBM Plex Sans Variable font at bold weights (700/800) for headings with generous 1.50+ line-heights +- Hidden brand orange (`#F54E00`) that only appears on hover interactions — a delightful surprise +- Hand-drawn hedgehog illustrations and playful imagery — deliberately anti-corporate +- Sage-tinted borders (`#bfc1b7`) and backgrounds (`#eeefe9`) creating a unified warm-green system +- Dark near-black CTAs (`#1e1f23`) with opacity-based hover states +- Content-heavy editorial layout — the site reads like a magazine, not a typical landing page +- Tailwind CSS + Radix UI + shadcn/ui component architecture + +## 2. Color Palette & Roles + +### Primary +- **Olive Ink** (`#4d4f46`): Primary text color — a distinctive olive-gray that gives all text a warm, earthy tone +- **Deep Olive** (`#23251d`): Link text and high-emphasis headings — near-black with green undertone +- **PostHog Orange** (`#F54E00`): Hidden brand accent — appears only on hover states, a vibrant orange that surprises + +### Secondary & Accent +- **Amber Gold** (`#F7A501`): Secondary hover accent on dark buttons — warm gold that pairs with the orange +- **Gold Border** (`#b17816`): Special button borders — an amber-gold for featured CTAs +- **Focus Blue** (`#3b82f6`): Focus ring color (Tailwind default) — the only blue in the system, reserved for accessibility + +### Surface & Background +- **Warm Parchment** (`#fdfdf8`): Primary page background — warm near-white with yellow-green undertone +- **Sage Cream** (`#eeefe9`): Input backgrounds, secondary surfaces — light sage tint +- **Light Sage** (`#e5e7e0`): Button backgrounds, tertiary surfaces — muted sage-green +- **Warm Tan** (`#d4c9b8`): Featured button backgrounds — warm tan/khaki for emphasis +- **Hover White** (`#f4f4f4`): Universal hover background state + +### Neutrals & Text +- **Olive Ink** (`#4d4f46`): Primary body and UI text +- **Muted Olive** (`#65675e`): Secondary text, button labels on light backgrounds +- **Sage Placeholder** (`#9ea096`): Placeholder text, disabled states — warm sage-green +- **Sage Border** (`#bfc1b7`): Primary border color — olive-tinted gray for all borders +- **Light Border** (`#b6b7af`): Secondary border, toolbar borders — slightly darker sage + +### Semantic & Accent +- **PostHog Orange** (`#F54E00`): Hover text accent — signals interactivity and brand personality +- **Amber Gold** (`#F7A501`): Dark button hover accent — warmth signal +- **Focus Blue** (`#3b82f6` at 50% opacity): Keyboard focus rings — accessibility-only color +- **Dark Text** (`#111827`): High-contrast link text — near-black for important links + +### Gradient System +- No gradients on the marketing site — PostHog's visual language is deliberately flat and warm +- Depth is achieved through layered surfaces and border containment, not color transitions + +## 3. Typography Rules + +### Font Family +- **Display & Body**: `IBM Plex Sans Variable` — variable font (100–700+ weight range). Fallbacks: `IBM Plex Sans, -apple-system, system-ui, Avenir Next, Avenir, Segoe UI, Helvetica Neue, Helvetica, Ubuntu, Roboto, Noto, Arial` +- **Monospace**: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New` — system monospace stack +- **Code Display**: `Source Code Pro` — with fallbacks: `Menlo, Consolas, Monaco` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | IBM Plex Sans Variable | 30px | 800 | 1.20 | -0.75px | Extra-bold, tight, maximum impact | +| Section Heading | IBM Plex Sans Variable | 36px | 700 | 1.50 | 0px | Large but generous line-height | +| Feature Heading | IBM Plex Sans Variable | 24px | 700 | 1.33 | 0px | Feature section titles | +| Card Heading | IBM Plex Sans Variable | 21.4px | 700 | 1.40 | -0.54px | Slightly unusual size (scaled) | +| Sub-heading | IBM Plex Sans Variable | 20px | 700 | 1.40 | -0.5px | Content sub-sections | +| Sub-heading Uppercase | IBM Plex Sans Variable | 20px | 700 | 1.40 | 0px | Uppercase transform for labels | +| Body Emphasis | IBM Plex Sans Variable | 19.3px | 600 | 1.56 | -0.48px | Semi-bold callout text | +| Label Uppercase | IBM Plex Sans Variable | 18px | 700 | 1.50 | 0px | Uppercase category labels | +| Body Semi | IBM Plex Sans Variable | 18px | 600 | 1.56 | 0px | Semi-bold body text | +| Body | IBM Plex Sans Variable | 16px | 400 | 1.50 | 0px | Standard reading text | +| Body Medium | IBM Plex Sans Variable | 16px | 500 | 1.50 | 0px | Medium-weight body | +| Body Relaxed | IBM Plex Sans Variable | 15px | 400 | 1.71 | 0px | Relaxed line-height for long reads | +| Nav / UI | IBM Plex Sans Variable | 15px | 600 | 1.50 | 0px | Navigation and UI labels | +| Caption | IBM Plex Sans Variable | 14px | 400–700 | 1.43 | 0px | Small text, various weights | +| Small Label | IBM Plex Sans Variable | 13px | 500–700 | 1.00–1.50 | 0px | Tags, badges, micro labels | +| Micro | IBM Plex Sans Variable | 12px | 400–700 | 1.33 | 0px | Smallest text, some uppercase | +| Code | Source Code Pro | 14px | 500 | 1.43 | 0px | Code snippets and terminal | + +### Principles +- **Bold heading dominance**: Headings use 700–800 weight — PostHog's typography is confident and assertive, not whispery +- **Generous body line-heights**: Body text at 1.50–1.71 line-height creates extremely comfortable reading — the site is content-heavy and optimized for long sessions +- **Fractional sizes**: Several sizes (21.4px, 19.3px, 13.7px) suggest a fluid/scaled type system rather than fixed stops — likely computed from Tailwind's rem scale at non-standard base +- **Uppercase as category signal**: Bold uppercase labels (18px–20px weight 700) are used for product category headings — a magazine-editorial convention +- **Selective negative tracking**: Letter-spacing tightens on display text (-0.75px at 30px) but relaxes to 0px on body — headlines compress, body breathes + +## 4. Component Stylings + +### Buttons +- **Dark Primary**: `#1e1f23` background, white text, 6px radius, `10px 12px` padding. Hover: opacity 0.7 with Amber Gold text. Active: opacity 0.8 with slight scale transform. The main CTA — dark and confident +- **Sage Light**: `#e5e7e0` background, Olive Ink (`#4d4f46`) text, 4px radius, `4px` padding. Hover: `#f4f4f4` bg with PostHog Orange text. Compact utility button +- **Warm Tan Featured**: `#d4c9b8` background, black text, no visible radius. Hover: same orange text flash. Featured/premium actions +- **Input-style**: `#eeefe9` background, Sage Placeholder (`#9ea096`) text, 4px radius, 1px `#b6b7af` border. Looks like a search/filter control +- **Near-white Ghost**: `#fdfdf8` background, Olive Ink text, 4px radius, transparent 1px border. Minimal presence +- **Hover pattern**: All buttons flash PostHog Orange (`#F54E00`) or Amber Gold (`#F7A501`) text on hover — the brand's signature interaction surprise + +### Cards & Containers +- **Bordered Card**: Warm Parchment (`#fdfdf8`) or white background, 1px `#bfc1b7` border, 4px–6px radius — clean and minimal +- **Sage Surface Card**: `#eeefe9` background for secondary content containers +- **Shadow Card**: `0px 25px 50px -12px rgba(0, 0, 0, 0.25)` — a single deep shadow for elevated content (modals, dropdowns) +- **Hover**: Orange text flash on interactive cards — consistent with button behavior + +### Inputs & Forms +- **Default**: `#eeefe9` background, `#9ea096` placeholder text, 1px `#b6b7af` border, 4px radius, `2px 0px 2px 8px` padding +- **Focus**: `#3b82f6` ring at 50% opacity (Tailwind blue focus ring) +- **Text color**: `#374151` for input values — darker than primary text for readability +- **Border variations**: Multiple border patterns — some inputs use compound borders (top, left, bottom-only) + +### Navigation +- **Top nav**: Warm background, IBM Plex Sans at 15px weight 600 +- **Dropdown menus**: Rich mega-menu structure with product categories +- **Link color**: Deep Olive (`#23251d`) for nav links, underline on hover +- **CTA**: Dark Primary button (#1e1f23) in the nav — "Get started - free" +- **Mobile**: Collapses to hamburger with simplified menu + +### Image Treatment +- **Hand-drawn illustrations**: Hedgehog mascot and quirky illustrations — the signature visual element +- **Product screenshots**: UI screenshots embedded in device frames or clean containers +- **Action figures**: Playful product photography of hedgehog figurines — anti-corporate +- **Trust logos**: Enterprise logos (Airbus, GOV.UK) displayed in a muted trust bar +- **Aspect ratios**: Mixed — illustrations are irregular, screenshots are 16:9 or widescreen + +### AI Chat Widget +- Floating PostHog AI assistant with speech bubble — an interactive product demo embedded in the marketing site + +## 5. Layout Principles + +### Spacing System +- **Base unit**: 8px +- **Scale**: 2px, 4px, 6px, 8px, 10px, 12px, 16px, 18px, 24px, 32px, 34px +- **Section padding**: 32px–48px vertical between sections (compact for a content-heavy site) +- **Card padding**: 4px–12px internal (notably compact) +- **Component gaps**: 4px–8px between related elements + +### Grid & Container +- **Max width**: 1536px (largest breakpoint), with content containers likely 1200px–1280px +- **Column patterns**: Varied — single column for text content, 2-3 column grids for feature cards, asymmetric layouts for product demos +- **Breakpoints**: 13 defined — 1px, 425px, 482px, 640px, 768px, 767px, 800px, 900px, 1024px, 1076px, 1160px, 1280px, 1536px + +### Whitespace Philosophy +- **Content-dense by design**: PostHog's site is information-rich — whitespace is measured, not lavish +- **Editorial pacing**: Content sections flow like a magazine with varied layouts keeping the eye moving +- **Illustrations as breathing room**: Hand-drawn hedgehog art breaks up dense content sections naturally + +### Border Radius Scale +- **2px**: Small inline elements, tags (`span`) +- **4px**: Primary UI components — buttons, inputs, dropdowns, menu items (`button`, `div`, `combobox`) +- **6px**: Secondary containers — larger buttons, list items, card variants (`button`, `div`, `li`) +- **9999px**: Pill shape — badges, status indicators, rounded tags (`span`, `div`) + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Level 0 (Flat) | No shadow, warm parchment background | Page canvas, most surfaces | +| Level 1 (Border) | `1px solid #bfc1b7` (Sage Border) | Card containment, input borders, section dividers | +| Level 2 (Compound Border) | Multiple 1px borders on different sides | Input groupings, toolbar elements | +| Level 3 (Deep Shadow) | `0px 25px 50px -12px rgba(0, 0, 0, 0.25)` | Modals, floating elements, mega-menu dropdowns | + +### Shadow Philosophy +PostHog's elevation system is remarkably minimal — only one shadow definition exists in the entire system. Depth is communicated through: +- **Border containment**: Sage-tinted borders (`#bfc1b7`) at 1px create gentle warm separation +- **Surface color shifts**: Moving from `#fdfdf8` to `#eeefe9` to `#e5e7e0` creates layered depth without shadows +- **The single shadow**: The one defined shadow (`0 25px 50px -12px`) is reserved for floating elements — modals, dropdowns, popovers. It's a deep, dramatic shadow that creates clear separation when needed + +### Decorative Depth +- **Illustration layering**: Hand-drawn hedgehog art creates visual depth naturally +- **No gradients or glow**: The flat, warm surface system relies entirely on border and surface-color differentiation +- **No glassmorphism**: Fully opaque surfaces throughout + +## 7. Do's and Don'ts + +### Do +- Use the olive/sage color family (#4d4f46, #23251d, #bfc1b7) for text and borders — the warm green undertone is essential to the brand +- Flash PostHog Orange (#F54E00) on hover states — it's the hidden brand signature +- Use IBM Plex Sans at bold weights (700/800) for headings — the font carries technical credibility +- Keep body text at generous line-heights (1.50–1.71) — the content-heavy site demands readability +- Maintain the warm parchment background (#fdfdf8) — not pure white, never cold +- Use 4px border-radius for most UI elements — keep corners subtle and functional +- Include playful, hand-drawn illustration elements — the personality is the differentiator +- Apply opacity-based hover states (0.7 opacity) on dark buttons rather than color shifts + +### Don't +- Use blue, purple, or typical tech-SaaS colors — PostHog's palette is deliberately olive/sage +- Add heavy shadows — the system uses one shadow for floating elements only; everything else uses borders +- Make the design look "polished" or "premium" in a conventional sense — PostHog's charm is its irreverent, scrappy energy +- Use tight line-heights on body text — the generous 1.50+ spacing is essential for the content-heavy layout +- Apply large border-radius (12px+) on cards — PostHog uses 4px–6px, keeping things tight and functional +- Remove the orange hover flash — it's a core interaction pattern, not decoration +- Replace illustrations with stock photography — the hand-drawn hedgehog art is the brand +- Use pure white (#ffffff) as page background — the warm sage-cream (#fdfdf8) tint is foundational + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <425px | Single column, compact padding, stacked cards | +| Mobile | 425px–640px | Slight layout adjustments, larger touch targets | +| Tablet | 640px–768px | 2-column grids begin, nav partially visible | +| Tablet Large | 768px–1024px | Multi-column layouts, expanded navigation | +| Desktop | 1024px–1280px | Full layout, 3-column feature grids, expanded mega-menu | +| Large Desktop | 1280px–1536px | Max-width container, generous margins | +| Extra Large | >1536px | Centered container at max-width | + +### Touch Targets +- Buttons: 4px–6px radius with `4px–12px` padding — compact but usable +- Nav links: 15px text at weight 600 with adequate padding +- Mobile: Hamburger menu with simplified navigation +- Inputs: Generous vertical padding for thumb-friendly forms + +### Collapsing Strategy +- **Navigation**: Full mega-menu with dropdowns → hamburger menu on mobile +- **Feature grids**: 3-column → 2-column → single column stacked +- **Typography**: Display sizes reduce across breakpoints (30px → smaller) +- **Illustrations**: Scale within containers, some may hide on mobile for space +- **Section spacing**: Reduces proportionally while maintaining readability + +### Image Behavior +- Illustrations scale responsively within containers +- Product screenshots maintain aspect ratios +- Trust logos reflow into multi-row grids on mobile +- AI chat widget may reposition or simplify on small screens + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Text: Olive Ink (`#4d4f46`) +- Dark Text: Deep Olive (`#23251d`) +- Hover Accent: PostHog Orange (`#F54E00`) +- Dark CTA: Near-Black (`#1e1f23`) +- Button Surface: Light Sage (`#e5e7e0`) +- Page Background: Warm Parchment (`#fdfdf8`) +- Border: Sage Border (`#bfc1b7`) +- Placeholder: Sage Placeholder (`#9ea096`) + +### Example Component Prompts +- "Create a hero section on warm parchment background (#fdfdf8) with 30px IBM Plex Sans heading at weight 800, line-height 1.20, letter-spacing -0.75px, olive ink text (#4d4f46), and a dark CTA button (#1e1f23, 6px radius, white text, opacity 0.7 on hover)" +- "Design a feature card with #fdfdf8 background, 1px #bfc1b7 border, 4px radius, IBM Plex Sans heading at 20px weight 700, and 16px body text at weight 400 with 1.50 line-height in olive ink (#4d4f46)" +- "Build a navigation bar with warm background, IBM Plex Sans links at 15px weight 600 in deep olive (#23251d), underline on hover, and a dark CTA button (#1e1f23) at the right" +- "Create a button group: primary dark (#1e1f23, white text, 6px radius), secondary sage (#e5e7e0, #4d4f46 text, 4px radius), and ghost/text button — all flash #F54E00 orange text on hover" +- "Design an input field with #eeefe9 background, 1px #b6b7af border, 4px radius, #9ea096 placeholder text, focus ring in #3b82f6 at 50% opacity" + +### Iteration Guide +When refining existing screens generated with this design system: +1. Verify the background is warm parchment (#fdfdf8) not pure white — the sage-cream warmth is essential +2. Check that all text uses the olive family (#4d4f46, #23251d) not pure black or neutral gray +3. Ensure hover states flash PostHog Orange (#F54E00) — if hovering feels bland, you're missing this +4. Confirm borders use sage-tinted gray (#bfc1b7) not neutral gray — warmth runs through every element +5. The overall tone should feel like a fun, scrappy startup wiki — never corporate-polished or sterile diff --git a/skills/creative/popular-web-designs/templates/raycast.md b/skills/creative/popular-web-designs/templates/raycast.md new file mode 100644 index 000000000000..f55e41d5d30b --- /dev/null +++ b/skills/creative/popular-web-designs/templates/raycast.md @@ -0,0 +1,281 @@ +# Design System: Raycast + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `Geist Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Raycast's marketing site feels like the dark interior of a precision instrument — a Swiss watch case carved from obsidian. The background isn't just dark, it's an almost-black blue-tint (`#07080a`) that creates a sense of being inside a macOS native application rather than a website. Every surface, every border, every shadow is calibrated to evoke the feeling of a high-performance desktop utility: fast, minimal, trustworthy. + +The signature move is the layered shadow system borrowed from macOS window chrome: multi-layer box-shadows with inset highlights that simulate physical depth, as if cards and buttons are actual pressed or raised glass elements on a dark desk. Combined with Raycast Red (`#FF6363`) — deployed almost exclusively in the hero's iconic diagonal stripe pattern — the palette creates a brand that reads as "powerful tool with personality." The red doesn't dominate; it punctuates. + +Inter is used everywhere — headings, body, buttons, captions — with extensive OpenType features (`calt`, `kern`, `liga`, `ss03`) creating a consistent, readable typographic voice. The positive letter-spacing (0.2px–0.4px on body text) is unusual for a dark UI and gives the text an airy, breathable quality that counterbalances the dense, dark surfaces. GeistMono appears for code elements, reinforcing the developer-tool identity. + +**Key Characteristics:** +- Near-black blue-tinted background (`#07080a`) — not pure black, subtly blue-shifted +- macOS-native shadow system with multi-layer inset highlights simulating physical depth +- Raycast Red (`#FF6363`) as a punctuation color — hero stripes, not pervasive +- Inter with positive letter-spacing (0.2px) for an airy, readable dark-mode experience +- Radix UI component primitives powering the interaction layer +- Subtle rgba white borders (0.06–0.1 opacity) for containment on dark surfaces +- Keyboard shortcut styling with gradient key caps and heavy shadows + +## 2. Color Palette & Roles + +### Primary +- **Near-Black Blue** (`#07080a`): Primary page background — the foundational void with a subtle blue-cold undertone +- **Pure White** (`#ffffff`): Primary heading text, high-emphasis elements +- **Raycast Red** (`#FF6363` / `hsl(0, 100%, 69%)`): Brand accent — hero stripes, danger states, critical highlights + +### Secondary & Accent +- **Raycast Blue** (`hsl(202, 100%, 67%)` / ~`#55b3ff`): Interactive accent — links, focus states, selected items +- **Raycast Green** (`hsl(151, 59%, 59%)` / ~`#5fc992`): Success states, positive indicators +- **Raycast Yellow** (`hsl(43, 100%, 60%)` / ~`#ffbc33`): Warning accents, highlights +- **Blue Transparent** (`hsla(202, 100%, 67%, 0.15)`): Blue tint overlay for interactive surfaces +- **Red Transparent** (`hsla(0, 100%, 69%, 0.15)`): Red tint overlay for danger/error surfaces + +### Surface & Background +- **Deep Background** (`#07080a`): Page canvas, the darkest surface +- **Surface 100** (`#101111`): Elevated surface, card backgrounds +- **Key Start** (`#121212`): Keyboard key gradient start +- **Key End** (`#0d0d0d`): Keyboard key gradient end +- **Card Surface** (`#1b1c1e`): Badge backgrounds, tag fills, elevated containers +- **Button Foreground** (`#18191a`): Dark surface for button text on light backgrounds + +### Neutrals & Text +- **Near White** (`#f9f9f9` / `hsl(240, 11%, 96%)`): Primary body text, high-emphasis content +- **Light Gray** (`#cecece` / `#cdcdce`): Secondary body text, descriptions +- **Silver** (`#c0c0c0`): Tertiary text, subdued labels +- **Medium Gray** (`#9c9c9d`): Link default color, secondary navigation +- **Dim Gray** (`#6a6b6c`): Disabled text, low-emphasis labels +- **Dark Gray** (`#434345`): Muted borders, inactive navigation links +- **Border** (`hsl(195, 5%, 15%)` / ~`#252829`): Standard border color for cards and dividers +- **Dark Border** (`#2f3031`): Separator lines, table borders + +### Semantic & Accent +- **Error Red** (`hsl(0, 100%, 69%)`): Error states, destructive actions +- **Success Green** (`hsl(151, 59%, 59%)`): Success confirmations, positive states +- **Warning Yellow** (`hsl(43, 100%, 60%)`): Warnings, attention-needed states +- **Info Blue** (`hsl(202, 100%, 67%)`): Informational highlights, links + +### Gradient System +- **Keyboard Key Gradient**: Linear gradient from `#121212` (top) to `#0d0d0d` (bottom) — simulates physical key depth +- **Warm Glow**: `rgba(215, 201, 175, 0.05)` radial spread — subtle warm ambient glow behind featured elements + +## 3. Typography Rules + +### Font Family +- **Primary**: `Inter` — humanist sans-serif, used everywhere. Fallbacks: `Inter Fallback`, system sans-serif +- **System**: `SF Pro Text` — Apple system font for select macOS-native UI elements. Fallbacks: `SF Pro Icons`, `Inter`, `Inter Fallback` +- **Monospace**: `GeistMono` — Vercel's monospace font for code elements. Fallbacks: `ui-monospace`, `SFMono-Regular`, `Roboto Mono`, `Menlo`, `Monaco` +- **OpenType features**: `calt`, `kern`, `liga`, `ss03` enabled globally; `ss02`, `ss08` on display text; `liga` disabled (`"liga" 0`) on hero headings + +### Hierarchy + +| Role | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|--------|-------------|----------------|-------| +| Display Hero | 64px | 600 | 1.10 | 0px | OpenType: liga 0, ss02, ss08 | +| Section Display | 56px | 400 | 1.17 | 0.2px | OpenType: calt, kern, liga, ss03 | +| Section Heading | 24px | 500 | normal | 0.2px | OpenType: calt, kern, liga, ss03 | +| Card Heading | 22px | 400 | 1.15 | 0px | OpenType: calt, kern, liga, ss03 | +| Sub-heading | 20px | 500 | 1.60 | 0.2px | Relaxed line-height for readability | +| Body Large | 18px | 400 | 1.15 | 0.2px | OpenType: calt, kern, liga, ss03 | +| Body | 16px | 500 | 1.60 | 0.2px | Primary body text, relaxed rhythm | +| Body Tight | 16px | 400 | 1.15 | 0.1px | UI labels, compact contexts | +| Button | 16px | 600 | 1.15 | 0.3px | Semibold, slightly wider tracking | +| Nav Link | 16px | 500 | 1.40 | 0.3px | Links in navigation | +| Caption | 14px | 500 | 1.14 | 0.2px | Small labels, metadata | +| Caption Bold | 14px | 600 | 1.40 | 0px | Emphasized captions | +| Small | 12px | 600 | 1.33 | 0px | Badges, tags, micro-labels | +| Small Link | 12px | 400 | 1.50 | 0.4px | Footer links, fine print | +| Code | 14px (GeistMono) | 500 | 1.60 | 0.3px | Code blocks, technical content | +| Code Small | 12px (GeistMono) | 400 | 1.60 | 0.2px | Inline code, terminal output | + +### Principles +- **Positive tracking on dark**: Unlike most dark UIs that use tight or neutral letter-spacing, Raycast applies +0.2px to +0.4px — creating an airy, readable feel that compensates for the dark background +- **Weight 500 as baseline**: Most body text uses medium weight (500), not regular (400) — subtle extra heft improves legibility on dark surfaces +- **Display restraint**: Hero text at 64px/600 is confident but not oversized — Raycast avoids typographic spectacle in favor of functional elegance +- **OpenType everywhere**: `ss03` (stylistic set 3) is enabled globally across Inter, giving the typeface a slightly more geometric, tool-like quality + +## 4. Component Stylings + +### Buttons +- **Primary Pill**: Transparent background, white text, pill shape (86px radius), multi-layer inset shadow (`rgba(255, 255, 255, 0.1) 0px 1px 0px 0px inset`). Hover: opacity 0.6 +- **Secondary Button**: Transparent background, white text, 6px radius, `1px solid rgba(255, 255, 255, 0.1)` border, subtle drop shadow (`rgba(0, 0, 0, 0.03) 0px 7px 3px`). Hover: opacity 0.6 +- **Ghost Button**: No background or border, gray text (`#6a6b6c`), 86px radius, same inset shadow. Hover: opacity 0.6, text brightens to white +- **CTA (Download)**: Semi-transparent white background (`hsla(0, 0%, 100%, 0.815)`), dark text (`#18191a`), pill shape. Hover: full white background (`hsl(0, 0%, 100%)`) +- **Transition**: All buttons use opacity transition for hover rather than background-color change — a signature Raycast interaction pattern + +### Cards & Containers +- **Standard Card**: `#101111` surface, `1px solid rgba(255, 255, 255, 0.06)` border, 12px–16px border-radius +- **Elevated Card**: Ring shadow `rgb(27, 28, 30) 0px 0px 0px 1px` outer + `rgb(7, 8, 10) 0px 0px 0px 1px inset` inner — creates a double-ring containment +- **Feature Card**: 16px–20px border-radius, subtle warm glow (`rgba(215, 201, 175, 0.05) 0px 0px 20px 5px`) behind hero elements +- **Hover**: Cards brighten slightly via border opacity increase or subtle shadow enhancement + +### Inputs & Forms +- Dark input fields with `#07080a` background, `1px solid rgba(255, 255, 255, 0.08)` border, 8px border-radius +- Focus state: Border brightens, blue glow (`hsla(202, 100%, 67%, 0.15)`) ring appears +- Text: `#f9f9f9` input color, `#6a6b6c` placeholder +- Labels: `#9c9c9d` at 14px weight 500 + +### Navigation +- **Top nav**: Dark background blending with page, white text links at 16px weight 500 +- **Nav links**: Gray text (`#9c9c9d`) → white on hover, underline decoration on hover +- **CTA button**: Semi-transparent white pill at nav end +- **Mobile**: Collapses to hamburger, maintains dark theme +- **Sticky**: Nav fixed at top with subtle border separator + +### Image Treatment +- **Product screenshots**: macOS window chrome style — rounded corners (12px), deep shadows simulating floating windows +- **Full-bleed sections**: Dark screenshots blend seamlessly into the dark background +- **Hero illustration**: Diagonal stripe pattern in Raycast Red — abstract, geometric, brand-defining +- **App UI embeds**: Showing actual Raycast command palette and extensions — product as content + +### Keyboard Shortcut Keys +- **Key cap styling**: Gradient background (`#121212` → `#0d0d0d`), heavy multi-layer shadow (`rgba(0, 0, 0, 0.4) 0px 1.5px 0.5px 2.5px` + inset shadows), creating realistic physical key appearance +- Border-radius: 4px–6px for individual keys + +### Badges & Tags +- **Neutral badge**: `#1b1c1e` background, white text, 6px radius, 14px font at weight 500, `0px 6px` padding +- Compact, pill-like treatment for categorization + +## 5. Layout Principles + +### Spacing System +- **Base unit**: 8px +- **Scale**: 1px, 2px, 3px, 4px, 8px, 10px, 12px, 16px, 20px, 24px, 32px, 40px +- **Section padding**: 80px–120px vertical between major sections +- **Card padding**: 16px–32px internal spacing +- **Component gaps**: 8px–16px between related elements + +### Grid & Container +- **Max width**: ~1200px container (breakpoint at 1204px), centered +- **Column patterns**: Single-column hero, 2–3 column feature grids, full-width showcase sections +- **App showcase**: Product UI presented in centered window frames + +### Whitespace Philosophy +- **Dramatic negative space**: Sections float in vast dark void, creating cinematic pacing between features +- **Dense product, sparse marketing**: The product UI screenshots are information-dense, but the surrounding marketing copy uses minimal text with generous spacing +- **Vertical rhythm**: Consistent 24px–32px gaps between elements within sections + +### Border Radius Scale +- **2px–3px**: Micro-elements, code spans, tiny indicators +- **4px–5px**: Keyboard keys, small interactive elements +- **6px**: Buttons, badges, tags — the workhorse radius +- **8px**: Input fields, inline components +- **9px–11px**: Images, medium containers +- **12px**: Standard cards, product screenshots +- **16px**: Large cards, feature sections +- **20px**: Hero cards, prominent containers +- **86px+**: Pill buttons, nav CTAs — full pill shape + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Level 0 (Void) | No shadow, `#07080a` surface | Page background | +| Level 1 (Subtle) | `rgba(0, 0, 0, 0.28) 0px 1.189px 2.377px` | Minimal lift, inline elements | +| Level 2 (Ring) | `rgb(27, 28, 30) 0px 0px 0px 1px` outer + `rgb(7, 8, 10) 0px 0px 0px 1px inset` inner | Card containment, double-ring technique | +| Level 3 (Button) | `rgba(255, 255, 255, 0.05) 0px 1px 0px 0px inset` + `rgba(255, 255, 255, 0.25) 0px 0px 0px 1px` + `rgba(0, 0, 0, 0.2) 0px -1px 0px 0px inset` | macOS-native button press — white highlight top, dark inset bottom | +| Level 4 (Key) | 5-layer shadow stack with inset press effects | Keyboard shortcut key caps — physical 3D appearance | +| Level 5 (Floating) | `rgba(0, 0, 0, 0.5) 0px 0px 0px 2px` + `rgba(255, 255, 255, 0.19) 0px 0px 14px` + insets | Command palette, floating panels — heavy depth with glow | + +### Shadow Philosophy +Raycast's shadow system is the most macOS-native on the web. Multi-layer shadows combine: +- **Outer rings** for containment (replacing traditional borders) +- **Inset top highlights** (`rgba(255, 255, 255, 0.05–0.25)`) simulating light source from above +- **Inset bottom darks** (`rgba(0, 0, 0, 0.2)`) simulating shadow underneath +- The effect is physical: elements feel like glass or brushed metal, not flat rectangles + +### Decorative Depth +- **Warm glow**: `rgba(215, 201, 175, 0.05) 0px 0px 20px 5px` behind featured elements — a subtle warm aura on the cold dark canvas +- **Blue info glow**: `rgba(0, 153, 255, 0.15)` for interactive state emphasis +- **Red danger glow**: `rgba(255, 99, 99, 0.15)` for error/destructive state emphasis + +## 7. Do's and Don'ts + +### Do +- Use `#07080a` (not pure black) as the background — the blue-cold tint is essential to the Raycast feel +- Apply positive letter-spacing (+0.2px) on body text — this is deliberately different from most dark UIs +- Use multi-layer shadows with inset highlights for interactive elements — the macOS-native depth is signature +- Keep Raycast Red (`#FF6363`) as punctuation, not pervasive — reserve it for hero moments and error states +- Use `rgba(255, 255, 255, 0.06)` borders for card containment — barely visible, structurally essential +- Apply weight 500 as the body text baseline — medium weight improves dark-mode legibility +- Use pill shapes (86px+ radius) for primary CTAs, rectangular shapes (6px–8px) for secondary actions +- Enable OpenType features `calt`, `kern`, `liga`, `ss03` on all Inter text +- Use opacity transitions (hover: opacity 0.6) for button interactions, not color changes + +### Don't +- Use pure black (`#000000`) as the background — the blue tint differentiates Raycast from generic dark themes +- Apply negative letter-spacing on body text — Raycast deliberately uses positive spacing for readability +- Use Raycast Blue as the primary accent for everything — blue is for interactive/info, red is the brand color +- Create single-layer flat shadows — the multi-layer inset system is core to the macOS-native aesthetic +- Use regular weight (400) for body text when 500 is available — the extra weight prevents dark-mode text from feeling thin +- Mix warm and cool borders — stick to the cool gray (`hsl(195, 5%, 15%)`) border palette +- Apply heavy drop shadows without inset companions — shadows always come in pairs (outer + inset) +- Use decorative elements, gradients, or colorful backgrounds — the dark void is the stage, content is the performer + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <600px | Single column, stacked cards, hamburger nav, hero text reduces to ~40px | +| Small Tablet | 600px–768px | 2-column grid begins, nav partially visible | +| Tablet | 768px–1024px | 2–3 column features, nav expanding, screenshots scale | +| Desktop | 1024px–1200px | Full layout, all nav links visible, 64px hero display | +| Large Desktop | >1200px | Max-width container centered, generous side margins | + +### Touch Targets +- Pill buttons: 86px radius with 20px padding — well above 44px minimum +- Secondary buttons: 8px padding minimum, but border provides visual target expansion +- Nav links: 16px text with surrounding padding for accessible touch targets + +### Collapsing Strategy +- **Navigation**: Full horizontal nav → hamburger at mobile with slide-out menu +- **Hero**: 64px display → 48px → 36px across breakpoints +- **Feature grids**: 3-column → 2-column → single-column stack +- **Product screenshots**: Scale within containers, maintaining macOS window chrome proportions +- **Keyboard shortcut displays**: Simplify or hide on mobile where keyboard shortcuts are irrelevant + +### Image Behavior +- Product screenshots scale responsively within fixed-ratio containers +- Hero diagonal stripe pattern scales proportionally +- macOS window chrome rounded corners maintained at all sizes +- No lazy-loading artifacts — images are critical to the product narrative + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Background: Near-Black Blue (`#07080a`) +- Primary Text: Near White (`#f9f9f9`) +- Brand Accent: Raycast Red (`#FF6363`) +- Interactive Blue: Raycast Blue (`hsl(202, 100%, 67%)` / ~`#55b3ff`) +- Secondary Text: Medium Gray (`#9c9c9d`) +- Card Surface: Surface 100 (`#101111`) +- Border: Dark Border (`hsl(195, 5%, 15%)` / ~`#252829`) + +### Example Component Prompts +- "Create a hero section on #07080a background with 64px Inter heading (weight 600, line-height 1.1), near-white text (#f9f9f9), and a semi-transparent white pill CTA button (hsla(0,0%,100%,0.815), 86px radius, dark text #18191a)" +- "Design a feature card with #101111 background, 1px solid rgba(255,255,255,0.06) border, 16px border-radius, double-ring shadow (rgb(27,28,30) 0px 0px 0px 1px outer), 22px Inter heading, and #9c9c9d body text" +- "Build a navigation bar on dark background (#07080a), Inter links at 16px weight 500 in #9c9c9d, hover to white, and a translucent white pill button at the right end" +- "Create a keyboard shortcut display with key caps using gradient background (#121212→#0d0d0d), 5-layer shadow for physical depth, 4px radius, Inter 12px weight 600 text" +- "Design an alert card with #101111 surface, Raycast Red (#FF6363) left border accent, translucent red glow (hsla(0,100%,69%,0.15)), white heading, and #cecece description text" + +### Iteration Guide +When refining existing screens generated with this design system: +1. Check the background is `#07080a` not pure black — the blue tint is critical +2. Verify letter-spacing is positive (+0.2px) on body text — negative spacing breaks the Raycast aesthetic +3. Ensure shadows have both outer and inset layers — single-layer shadows look flat and wrong +4. Confirm Inter has OpenType features `calt`, `kern`, `liga`, `ss03` enabled +5. Test that hover states use opacity transitions (0.6) not color swaps — this is a core interaction pattern diff --git a/skills/creative/popular-web-designs/templates/replicate.md b/skills/creative/popular-web-designs/templates/replicate.md new file mode 100644 index 000000000000..e59f15650838 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/replicate.md @@ -0,0 +1,274 @@ +# Design System: Replicate + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Replicate's interface is a developer playground crackling with creative energy — a bold, high-contrast design that feels more like a music festival poster than a typical API platform. The hero section explodes with a vibrant orange-red-magenta gradient that immediately signals "this is where AI models come alive," while the body of the page grounds itself in a clean white canvas where code snippets and model galleries take center stage. + +The design personality is defined by two extreme choices: **massive display typography** (up to 128px) using the custom rb-freigeist-neue face, and **exclusively pill-shaped geometry** (9999px radius on everything). The display font is thick, bold, and confident — its heavy weight at enormous sizes creates text that feels like it's shouting with joy rather than whispering authority. Combined with basier-square for body text (a clean geometric sans) and JetBrains Mono for code, the system serves developers who want power and playfulness in equal measure. + +What makes Replicate distinctive is its community-powered energy. The model gallery with AI-generated images, the dotted-underline links, the green status badges, and the "Imagine what you can build" closing manifesto all create a space that feels alive and participatory — not a corporate product page but a launchpad for creative developers. + +**Key Characteristics:** +- Explosive orange-red-magenta gradient hero (#ea2804 brand anchor) +- Massive display typography (128px) in heavy rb-freigeist-neue +- Exclusively pill-shaped geometry: 9999px radius on EVERYTHING +- High-contrast black (#202020) and white palette with red brand accent +- Developer-community energy: model galleries, code examples, dotted-underline links +- Green status badges (#2b9a66) for live/operational indicators +- Bold/heavy font weights (600-700) creating maximum typographic impact +- Playful closing manifesto: "Imagine what you can build." + +## 2. Color Palette & Roles + +### Primary +- **Replicate Dark** (`#202020`): The primary text color and dark surface — a near-black that's the anchor of all text and borders. Slightly warmer than pure #000. +- **Replicate Red** (`#ea2804`): The core brand color — a vivid, saturated orange-red used in the hero gradient, accent borders, and high-signal moments. +- **Secondary Red** (`#dd4425`): A slightly warmer variant for button borders and link hover states. + +### Secondary & Accent +- **Status Green** (`#2b9a66`): Badge/pill background for "running" or operational status indicators. +- **GitHub Dark** (`#24292e`): A blue-tinted dark used for code block backgrounds and developer contexts. + +### Surface & Background +- **Pure White** (`#ffffff`): The primary page body background. +- **Near White** (`#fcfcfc`): Button text on dark surfaces and the lightest content. +- **Hero Gradient**: A dramatic orange → red → magenta → pink gradient for the hero section. Transitions from warm (#ea2804 family) through hot pink. + +### Neutrals & Text +- **Medium Gray** (`#646464`): Secondary body text and de-emphasized content. +- **Warm Gray** (`#4e4e4e`): Emphasized secondary text. +- **Mid Silver** (`#8d8d8d`): Tertiary text, footnotes. +- **Light Silver** (`#bbbbbb`): Dotted-underline link decoration color, muted metadata. +- **Pure Black** (`#000000`): Maximum-emphasis borders and occasional text. + +### Gradient System +- **Hero Blaze**: A dramatic multi-stop gradient flowing through orange (`#ea2804`) → red → magenta → hot pink. This gradient occupies the full hero section and is the most visually dominant element on the page. +- **Dark Sections**: Deep dark (#202020) sections with white/near-white text provide contrast against the white body. + +## 3. Typography Rules + +### Font Family +- **Display**: `rb-freigeist-neue`, with fallbacks: `ui-sans-serif, system-ui` +- **Body / UI**: `basier-square`, with fallbacks: `ui-sans-serif, system-ui` +- **Code**: `jetbrains-mono`, with fallbacks: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Mega | rb-freigeist-neue | 128px (8rem) | 700 | 1.00 (tight) | normal | The maximum: closing manifesto | +| Display / Hero | rb-freigeist-neue | 72px (4.5rem) | 700 | 1.00 (tight) | -1.8px | Hero section headline | +| Section Heading | rb-freigeist-neue | 48px (3rem) | 400–700 | 1.00 (tight) | normal | Feature section titles | +| Sub-heading | rb-freigeist-neue | 30px (1.88rem) | 600 | 1.20 (tight) | normal | Card headings | +| Sub-heading Sans | basier-square | 38.4px (2.4rem) | 400 | 0.83 (ultra-tight) | normal | Large body headings | +| Feature Title | basier-square / rb-freigeist-neue | 18px (1.13rem) | 600 | 1.56 | normal | Small section titles, labels | +| Body Large | basier-square | 20px (1.25rem) | 400 | 1.40 | normal | Intro paragraphs | +| Body / Button | basier-square | 16–18px (1–1.13rem) | 400–600 | 1.50–1.56 | normal | Standard text, buttons | +| Caption | basier-square | 14px (0.88rem) | 400–600 | 1.43 | -0.35px to normal | Metadata, descriptions | +| Small / Tag | basier-square | 12px (0.75rem) | 400 | 1.33 | normal | Tags (lowercase transform) | +| Code | jetbrains-mono | 14px (0.88rem) | 400 | 1.43 | normal | Code snippets, API examples | +| Code Small | jetbrains-mono | 11px (0.69rem) | 400 | 1.50 | normal | Tiny code references | + +### Principles +- **Heavy display, light body**: rb-freigeist-neue at 700 weight creates thundering headlines, while basier-square at 400 handles body text with quiet efficiency. The contrast is extreme and intentional. +- **128px is a real size**: The closing manifesto "Imagine what you can build." uses 128px — bigger than most mobile screens. This is the design equivalent of shouting from a rooftop. +- **Negative tracking on hero**: -1.8px letter-spacing at 72px creates dense, impactful hero text. +- **Lowercase tags**: 12px basier-square uses `text-transform: lowercase` — an unusual choice that creates a casual, developer-friendly vibe. +- **Weight 600 as emphasis**: When basier-square needs emphasis, it uses 600 (semibold) — never bold (700), which is reserved for rb-freigeist-neue display text. + +## 4. Component Stylings + +### Buttons + +**Dark Solid** +- Background: Replicate Dark (`#202020`) +- Text: Near White (`#fcfcfc`) +- Padding: 0px 4px (extremely compact) +- Outline: Replicate Dark 4px solid +- Radius: pill-shaped (implied by system) +- Maximum emphasis — dark pill on light surface + +**White Outlined** +- Background: Pure White (`#ffffff`) +- Text: Replicate Dark (`#202020`) +- Border: `1px solid #202020` +- Radius: pill-shaped +- Clean outlined pill for secondary actions + +**Transparent Glass** +- Background: `rgba(255, 255, 255, 0.1)` (frosted glass) +- Text: Replicate Dark (`#202020`) +- Padding: 6px 56px 6px 28px (asymmetric — icon/search layout) +- Border: transparent +- Outline: Light Silver (`#bbbbbb`) 1px solid +- Used for search/input-like buttons + +### Cards & Containers +- Background: Pure White or subtle gray +- Border: `1px solid #202020` for prominent containment +- Radius: pill-shaped (9999px) for badges, labels, images +- Shadow: minimal standard shadows +- Model gallery: grid of AI-generated image thumbnails +- Accent border: `1px solid #ea2804` for highlighted/featured items + +### Inputs & Forms +- Background: `rgba(255, 255, 255, 0.1)` (frosted glass) +- Text: Replicate Dark (`#202020`) +- Border: transparent with outline +- Padding: 6px 56px 6px 28px (search-bar style) + +### Navigation +- Clean horizontal nav on white +- Logo: Replicate wordmark in dark +- Links: dark text with dotted underline on hover +- CTA: Dark pill button +- GitHub link and sign-in + +### Image Treatment +- AI-generated model output images in a gallery grid +- Pill-shaped image containers (9999px) +- Full-width gradient hero section +- Product screenshots with dark backgrounds + +### Distinctive Components + +**Model Gallery Grid** +- Horizontal scrolling or grid of AI-generated images +- Each image in a pill-shaped container +- Model names and run counts displayed +- The visual heart of the community platform + +**Dotted Underline Links** +- Links use `text-decoration: underline dotted #bbbbbb` +- A distinctive, developer-notebook aesthetic +- Lighter and more casual than solid underlines + +**Status Badges** +- Status Green (`#2b9a66`) background with white text +- Pill-shaped (9999px) +- 14px font size +- Indicates model availability/operational status + +**Manifesto Section** +- "Imagine what you can build." at 128px +- Dark background with white text +- Images embedded between words +- The emotional climax of the page + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 6px, 8px, 10px, 12px, 16px, 24px, 32px, 48px, 64px, 96px, 160px, 192px +- Button padding: varies widely (0px 4px to 6px 56px) +- Section vertical spacing: very generous (96–192px) + +### Grid & Container +- Fluid width with responsive constraints +- Hero: full-width gradient with centered content +- Model gallery: multi-column responsive grid +- Feature sections: mixed layouts +- Code examples: contained dark blocks + +### Whitespace Philosophy +- **Bold and generous**: Massive spacing between sections (up to 192px) creates distinct zones. +- **Dense within galleries**: Model images are tightly packed in the grid for browsable density. +- **The gradient IS the whitespace**: The hero gradient section occupies significant vertical space as a colored void. + +### Border Radius Scale +- **Pill (9999px)**: The ONLY radius in the system. Everything interactive, every image, every badge, every label, every container uses 9999px. This is the most extreme pill-radius commitment in any major tech brand. + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | White body, text blocks | +| Bordered (Level 1) | `1px solid #202020` | Cards, buttons, containers | +| Accent Border (Level 2) | `1px solid #ea2804` | Featured/highlighted items | +| Gradient Hero (Level 3) | Full-width blaze gradient | Hero section, maximum visual impact | +| Dark Section (Level 4) | Dark bg (#202020) with light text | Manifesto, footer, feature sections | + +**Shadow Philosophy**: Replicate relies on **borders and background color** for depth rather than shadows. The `1px solid #202020` border is the primary containment mechanism. The dramatic gradient hero and dark/light section alternation provide all the depth the design needs. + +## 7. Do's and Don'ts + +### Do +- Use pill-shaped (9999px) radius on EVERYTHING — buttons, images, badges, containers +- Use rb-freigeist-neue at weight 700 for display text — go big (72px+) or go home +- Use the orange-red brand gradient for hero sections +- Use Replicate Dark (#202020) as the primary dark — not pure black +- Apply dotted underline decoration on text links (#bbbbbb) +- Use Status Green (#2b9a66) for operational/success badges +- Keep body text in basier-square at 400–600 weight +- Use JetBrains Mono for all code content +- Create a "manifesto" section with 128px type for emotional impact + +### Don't +- Don't use any border-radius other than 9999px — the pill system is absolute +- Don't use the brand red (#ea2804) as a surface/background color — it's for gradients and accent borders +- Don't reduce display text below 48px on desktop — the heavy display font needs size to breathe +- Don't use light/thin font weights on rb-freigeist-neue — 600–700 is the range +- Don't use solid underlines on links — dotted is the signature +- Don't add drop shadows — depth comes from borders and background color +- Don't use warm neutrals — the gray scale is purely neutral (#202020 → #bbbbbb) +- Don't skip the code examples — they're primary content, not decoration +- Don't make the hero gradient subtle — it should be BOLD and vibrant + +## 8. Responsive Behavior + +### Breakpoints +*No explicit breakpoints detected — likely using fluid/container-query responsive system.* + +### Touch Targets +- Pill buttons with generous padding +- Gallery images as large touch targets +- Navigation adequately spaced + +### Collapsing Strategy +- **Hero text**: 128px → 72px → 48px progressive scaling +- **Model gallery**: Grid reduces columns +- **Navigation**: Collapses to hamburger +- **Manifesto**: Scales down but maintains impact + +### Image Behavior +- AI-generated images scale within pill containers +- Gallery reflows to fewer columns on narrow screens +- Hero gradient maintained at all sizes + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Text: "Replicate Dark (#202020)" +- Page Background: "Pure White (#ffffff)" +- Brand Accent: "Replicate Red (#ea2804)" +- Secondary Text: "Medium Gray (#646464)" +- Muted/Decoration: "Light Silver (#bbbbbb)" +- Status: "Status Green (#2b9a66)" +- Dark Surface: "Replicate Dark (#202020)" + +### Example Component Prompts +- "Create a hero section with a vibrant orange-red-magenta gradient background. Headline at 72px rb-freigeist-neue weight 700, white text, -1.8px letter-spacing. Include a dark pill CTA button and a white outlined pill button." +- "Design a model card with pill-shaped (9999px) image container, model name at 16px basier-square weight 600, run count at 14px in Medium Gray. Border: 1px solid #202020." +- "Build a status badge: pill-shaped (9999px), Status Green (#2b9a66) background, white text at 14px basier-square." +- "Create a manifesto section on Replicate Dark (#202020) with 'Imagine what you can build.' at 128px rb-freigeist-neue weight 700, white text. Embed small AI-generated images between the words." +- "Design a code block: dark background (#24292e), JetBrains Mono at 14px, white text. Pill-shaped container." + +### Iteration Guide +1. Everything is pill-shaped — never specify any other border-radius +2. Display text is HEAVY — weight 700, sizes 48px+ +3. Links use dotted underline (#bbbbbb) — never solid +4. The gradient hero is the visual anchor — make it bold +5. Use basier-square for body, rb-freigeist-neue for display, JetBrains Mono for code diff --git a/skills/creative/popular-web-designs/templates/resend.md b/skills/creative/popular-web-designs/templates/resend.md new file mode 100644 index 000000000000..cdae528799d4 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/resend.md @@ -0,0 +1,316 @@ +# Design System: Resend + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Geist` | **Mono:** `Geist Mono` +> - **Font stack (CSS):** `font-family: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Resend's website is a dark, cinematic canvas that treats email infrastructure like a luxury product. The entire page is draped in pure black (`#000000`) with text that glows in near-white (`#f0f0f0`), creating a theater-like experience where content performs on a void stage. This isn't the typical developer-tool darkness — it's the controlled darkness of a photography gallery, where every element is lit with intention and nothing competes for attention. + +The typography system is the star of the show. Three carefully chosen typefaces create a hierarchy that feels both editorial and technical: Domaine Display (a Klim Type Foundry serif) appears at massive 96px for hero headlines with barely-there line-height (1.00) and negative tracking (-0.96px), creating display text that feels like a magazine cover. ABC Favorit (by Dinamo) handles section headings with an even more aggressive letter-spacing (-2.8px at 56px), giving a compressed, engineered quality to mid-tier text. Inter takes over for body and UI, providing the clean readability that lets the display fonts shine. Commit Mono rounds out the family for code blocks. + +What makes Resend distinctive is its icy, blue-tinted border system. Instead of neutral gray borders, Resend uses `rgba(214, 235, 253, 0.19)` — a frosty, slightly blue-tinted line at 19% opacity that gives every container and divider a cold, crystalline quality against the black background. Combined with pill-shaped buttons (9999px radius), multi-color accent system (orange, green, blue, yellow, red — each with its own CSS variable scale), and OpenType stylistic sets (`"ss01"`, `"ss03"`, `"ss04"`, `"ss11"`), the result is a design system that feels premium, precise, and quietly confident. + +**Key Characteristics:** +- Pure black background with near-white (`#f0f0f0`) text — theatrical, gallery-like darkness +- Three-font hierarchy: Domaine Display (serif hero), ABC Favorit (geometric sections), Inter (body/UI) +- Icy blue-tinted borders: `rgba(214, 235, 253, 0.19)` — every border has a cold, crystalline shimmer +- Multi-color accent system: orange, green, blue, yellow, red — each with numbered CSS variable scales +- Pill-shaped buttons and tags (9999px radius) with transparent backgrounds +- OpenType stylistic sets (`"ss01"`, `"ss03"`, `"ss04"`, `"ss11"`) on display fonts +- Commit Mono for code — monospace as a design element, not an afterthought +- Whisper-level shadows using blue-tinted ring: `rgba(176, 199, 217, 0.145) 0px 0px 0px 1px` + +## 2. Color Palette & Roles + +### Primary +- **Void Black** (`#000000`): Page background, the defining canvas color (95% opacity via `--color-black-12`) +- **Near White** (`#f0f0f0`): Primary text, button text, high-contrast elements +- **Pure White** (`#ffffff`): `--color-white`, maximum emphasis text, link highlights + +### Accent Scale — Orange +- **Orange 4** (`#ff5900`): `--color-orange-4`, at 22% opacity — subtle warm glow +- **Orange 10** (`#ff801f`): `--color-orange-10`, primary orange accent — warm, energetic +- **Orange 11** (`#ffa057`): `--color-orange-11`, lighter orange for secondary use + +### Accent Scale — Green +- **Green 3** (`#22ff99`): `--color-green-3`, at 12% opacity — faint emerald wash +- **Green 4** (`#11ff99`): `--color-green-4`, at 18% opacity — success indicator glow + +### Accent Scale — Blue +- **Blue 4** (`#0075ff`): `--color-blue-4`, at 34% opacity — medium blue accent +- **Blue 5** (`#0081fd`): `--color-blue-5`, at 42% opacity — stronger blue +- **Blue 10** (`#3b9eff`): `--color-blue-10`, bright blue — links, interactive elements + +### Accent Scale — Other +- **Yellow 9** (`#ffc53d`): `--color-yellow-9`, warm gold for warnings or highlights +- **Red 5** (`#ff2047`): `--color-red-5`, at 34% opacity — error states, destructive actions + +### Neutral Scale +- **Silver** (`#a1a4a5`): Secondary text, muted links, descriptions +- **Dark Gray** (`#464a4d`): Tertiary text, de-emphasized content +- **Mid Gray** (`#5c5c5c`): Hover states, subtle emphasis +- **Medium Gray** (`#494949`): Quaternary text +- **Light Gray** (`#f8f8f8`): Light mode surface (if applicable) +- **Border Gray** (`#eaeaea`): Light context borders +- **Edge Gray** (`#ececec`): Subtle borders on light surfaces +- **Mist Gray** (`#dedfdf`): Light dividers +- **Soft Gray** (`#e5e6e6`): Alternate light border + +### Surface & Overlay +- **Frost Primary** (`#fcfdff`): Primary color token (slight blue tint, 94% opacity) +- **White Hover** (`rgba(255, 255, 255, 0.28)`): Button hover state on dark +- **White 60%** (`oklab(0.999994 ... / 0.577)`): Semi-transparent white for muted text +- **White 64%** (`oklab(0.999994 ... / 0.642)`): Slightly brighter semi-transparent white + +### Borders & Shadows +- **Frost Border** (`rgba(214, 235, 253, 0.19)`): The signature — icy blue-tinted borders at 19% opacity +- **Frost Border Alt** (`rgba(217, 237, 254, 0.145)`): Slightly lighter variant for list items +- **Ring Shadow** (`rgba(176, 199, 217, 0.145) 0px 0px 0px 1px`): Blue-tinted shadow-as-border +- **Focus Ring** (`rgb(0, 0, 0) 0px 0px 0px 8px`): Heavy black focus ring +- **Subtle Shadow** (`rgba(0, 0, 0, 0.1) 0px 1px 3px, rgba(0, 0, 0, 0.1) 0px 1px 2px -1px`): Minimal card elevation + +## 3. Typography Rules + +### Font Families +- **Display Serif**: `domaine` (Domaine Display by Klim Type Foundry) — hero headlines +- **Display Sans**: `aBCFavorit` (ABC Favorit by Dinamo), fallbacks: `ui-sans-serif, system-ui` — section headings +- **Body / UI**: `inter`, fallbacks: `ui-sans-serif, system-ui` — body text, buttons, navigation +- **Monospace**: `commitMono`, fallbacks: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas` +- **Secondary**: `Helvetica` — fallback for specific UI contexts +- **System**: `-apple-system, system-ui, Segoe UI, Roboto` — embedded content + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | domaine | 96px (6.00rem) | 400 | 1.00 (tight) | -0.96px | `"ss01", "ss04", "ss11"` | +| Display Hero Mobile | domaine | 76.8px (4.80rem) | 400 | 1.00 (tight) | -0.768px | Scaled for mobile | +| Section Heading | aBCFavorit | 56px (3.50rem) | 400 | 1.20 (tight) | -2.8px | `"ss01", "ss04", "ss11"` | +| Sub-heading | aBCFavorit | 20px (1.25rem) | 400 | 1.30 (tight) | normal | `"ss01", "ss04", "ss11"` | +| Sub-heading Compact | aBCFavorit | 16px (1.00rem) | 400 | 1.50 | -0.8px | `"ss01", "ss04", "ss11"` | +| Feature Title | inter | 24px (1.50rem) | 500 | 1.50 | normal | Section sub-headings | +| Body Large | inter | 18px (1.13rem) | 400 | 1.50 | normal | Introductions | +| Body | inter | 16px (1.00rem) | 400 | 1.50 | normal | Standard body text | +| Body Semibold | inter | 16px (1.00rem) | 600 | 1.50 | normal | Emphasis, active states | +| Nav Link | aBCFavorit | 14px (0.88rem) | 500 | 1.43 | 0.35px | `"ss01", "ss03", "ss04"` — positive tracking | +| Button / Link | inter | 14px (0.88rem) | 500–600 | 1.43 | normal | Buttons, nav, CTAs | +| Caption | inter | 14px (0.88rem) | 400 | 1.60 (relaxed) | normal | Descriptions | +| Helvetica Caption | Helvetica | 14px (0.88rem) | 400–600 | 1.00–1.71 | normal | UI elements | +| Small | inter | 12px (0.75rem) | 400–500 | 1.33 | normal | Tags, meta, fine print | +| Small Uppercase | inter | 12px (0.75rem) | 500 | 1.33 | normal | `text-transform: uppercase` | +| Small Capitalize | inter | 12px (0.75rem) | 500 | 1.33 | normal | `text-transform: capitalize` | +| Code Body | commitMono | 16px (1.00rem) | 400 | 1.50 | normal | Code blocks | +| Code Small | commitMono | 14px (0.88rem) | 400 | 1.43 | normal | Inline code | +| Code Tiny | commitMono | 12px (0.75rem) | 400 | 1.33 | normal | Small code labels | +| Heading (Helvetica) | Helvetica | 24px (1.50rem) | 400 | 1.40 | normal | Alternate heading context | + +### Principles +- **Three-font editorial hierarchy**: Domaine Display (serif, hero), ABC Favorit (geometric sans, sections), Inter (readable body). Each font has a strict role — they never cross lanes. +- **Aggressive negative tracking on display**: Domaine at -0.96px, ABC Favorit at -2.8px. The display type feels compressed, urgent, and designed — like a magazine masthead. +- **Positive tracking on nav**: ABC Favorit nav links use +0.35px letter-spacing — the only positive tracking in the system. This creates airy, spaced-out navigation text that contrasts with the compressed headings. +- **OpenType as identity**: The `"ss01"`, `"ss03"`, `"ss04"`, `"ss11"` stylistic sets are enabled on all ABC Favorit and Domaine text, activating alternate glyphs that give Resend's typography its unique character. +- **Commit Mono as design element**: The monospace font isn't hidden in code blocks — it's used prominently for code examples and technical content, treated as a first-class visual element. + +## 4. Component Stylings + +### Buttons + +**Primary Transparent Pill** +- Background: transparent +- Text: `#f0f0f0` +- Padding: 5px 12px +- Radius: 9999px (full pill) +- Border: `1px solid rgba(214, 235, 253, 0.19)` (frost border) +- Hover: background `rgba(255, 255, 255, 0.28)` (white glass) +- Use: Primary CTA on dark backgrounds + +**White Solid Pill** +- Background: `#ffffff` +- Text: `#000000` +- Padding: 5px 12px +- Radius: 9999px +- Use: High-contrast CTA ("Get started") + +**Ghost Button** +- Background: transparent +- Text: `#f0f0f0` +- Radius: 4px +- No border +- Hover: subtle background tint +- Use: Secondary actions, tab items + +### Cards & Containers +- Background: transparent or very subtle dark tint +- Border: `1px solid rgba(214, 235, 253, 0.19)` (frost border) +- Radius: 16px (standard cards), 24px (large sections/panels) +- Shadow: `rgba(176, 199, 217, 0.145) 0px 0px 0px 1px` (ring shadow) +- Dark product screenshots and code demos as card content +- No traditional box-shadow elevation + +### Inputs & Forms +- Text: `#f0f0f0` on dark, `#000000` on light +- Radius: 4px +- Focus: shadow-based ring +- Minimal styling — inherits dark theme + +### Navigation +- Sticky dark header with frost border bottom: `1px solid rgba(214, 235, 253, 0.19)` +- "Resend" wordmark left-aligned +- ABC Favorit 14px weight 500 with +0.35px tracking for nav links +- Pill CTAs right-aligned +- Mobile: hamburger collapse + +### Image Treatment +- Product screenshots and code demos dominate content sections +- Dark-themed screenshots on dark background — seamless integration +- Rounded corners: 12px–16px on images +- Full-width sections with subtle gradient overlays + +### Distinctive Components + +**Tab Navigation** +- Horizontal tabs with subtle selection indicator +- Tab items: 8px radius +- Active state with subtle background differentiation + +**Code Preview Panels** +- Dark code blocks using Commit Mono +- Frost borders (`rgba(214, 235, 253, 0.19)`) +- Syntax-highlighted with multi-color accent tokens (orange, blue, green, yellow) + +**Multi-color Accent Badges** +- Each product feature has its own accent color from the CSS variable scale +- Badges use the accent color at low opacity (12–42%) for background, full opacity for text + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 5px, 6px, 7px, 8px, 10px, 12px, 16px, 20px, 24px, 30px, 32px, 40px + +### Grid & Container +- Centered content with generous max-width +- Full-width black sections with contained inner content +- Single-column hero, expanding to feature grids below +- Code preview panels as full-width or contained showcases + +### Whitespace Philosophy +- **Cinematic black space**: The black background IS the whitespace. Generous vertical spacing (80px–120px+) between sections creates a scroll-through-darkness experience where each section emerges like a scene. +- **Tight content, vast surrounds**: Text blocks and cards are compact internally, but float in vast dark space — creating isolated "islands" of content. +- **Typography-led rhythm**: The massive display fonts (96px) create their own vertical rhythm — each headline is a visual event that anchors the surrounding space. + +### Border Radius Scale +- Sharp (4px): Buttons (ghost), inputs, small interactive elements +- Subtle (6px): Menu panels, navigation items +- Standard (8px): Tabs, content blocks +- Comfortable (10px): Accent elements +- Card (12px): Clipboard buttons, medium containers +- Large (16px): Feature cards, images, main buttons +- Section (24px): Large panels, section containers +- Pill (9999px): Primary CTAs, tags, badges + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, transparent background | Default — most elements on dark void | +| Ring (Level 1) | `rgba(176, 199, 217, 0.145) 0px 0px 0px 1px` | Shadow-as-border for cards, containers | +| Frost Border (Level 1b) | `1px solid rgba(214, 235, 253, 0.19)` | Explicit borders — buttons, dividers, tabs | +| Subtle (Level 2) | `rgba(0, 0, 0, 0.1) 0px 1px 3px, rgba(0, 0, 0, 0.1) 0px 1px 2px -1px` | Light card elevation | +| Focus (Level 3) | `rgb(0, 0, 0) 0px 0px 0px 8px` | Heavy black focus ring — accessibility | + +**Shadow Philosophy**: Resend barely uses shadows at all. On a pure black background, traditional shadows are invisible — you can't cast a shadow into the void. Instead, Resend creates depth through its signature frost borders (`rgba(214, 235, 253, 0.19)`) — thin, icy blue-tinted lines that catch light against the darkness. This creates a "glass panel floating in space" aesthetic where borders are the primary depth mechanism. + +### Decorative Depth +- Subtle warm gradient glows behind hero content (orange/amber tints) +- Product screenshots create visual depth through their own internal UI +- No gradient backgrounds — depth comes from border luminance and content contrast + +## 7. Do's and Don'ts + +### Do +- Use pure black (`#000000`) as the page background — the void is the canvas +- Apply frost borders (`rgba(214, 235, 253, 0.19)`) for all structural lines — they're the blue-tinted signature +- Use Domaine Display ONLY for hero headings (96px), ABC Favorit for section headings, Inter for everything else +- Enable OpenType `"ss01"`, `"ss04"`, `"ss11"` on Domaine and ABC Favorit text +- Apply pill radius (9999px) to primary CTAs and tags +- Use the multi-color accent scale (orange/green/blue/yellow/red) with opacity variants for context-specific highlighting +- Keep shadows at ring level (`0px 0px 0px 1px`) — on black, traditional shadows don't work +- Use +0.35px letter-spacing on ABC Favorit nav links — the only positive tracking + +### Don't +- Don't lighten the background above `#000000` — the pure black void is non-negotiable +- Don't use neutral gray borders — all borders must have the frost blue tint +- Don't apply Domaine Display to body text — it's a display-only serif +- Don't mix accent colors in the same component — each feature gets one accent color +- Don't use box-shadow for elevation on the dark background — use frost borders instead +- Don't skip the OpenType stylistic sets — they define the typographic character +- Don't use negative letter-spacing on nav links — ABC Favorit nav uses positive +0.35px +- Don't make buttons opaque on dark — transparency with frost border is the pattern + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <480px | Single column, tight padding, 76.8px hero | +| Mobile | 480–600px | Standard mobile, stacked layout | +| Desktop | >600px | Full layout, 96px hero, expanded sections | + +*Note: Resend uses a minimal breakpoint system — only 480px and 600px detected. The design is desktop-first with a clean mobile collapse.* + +### Touch Targets +- Pill buttons: adequate padding (5px 12px minimum) +- Tab items: 8px radius with comfortable hit areas +- Navigation links spaced with 0.35px tracking for visual separation + +### Collapsing Strategy +- Hero: Domaine 96px → 76.8px on mobile +- Navigation: horizontal → hamburger +- Feature sections: side-by-side → stacked +- Code panels: maintain width, horizontal scroll if needed +- Spacing compresses proportionally + +### Image Behavior +- Product screenshots maintain aspect ratio +- Dark screenshots blend seamlessly with dark background at all sizes +- Rounded corners (12px–16px) maintained across breakpoints + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Void Black (`#000000`) +- Primary text: Near White (`#f0f0f0`) +- Secondary text: Silver (`#a1a4a5`) +- Border: Frost Border (`rgba(214, 235, 253, 0.19)`) +- Orange accent: `#ff801f` +- Green accent: `#11ff99` (at 18% opacity) +- Blue accent: `#3b9eff` +- Focus ring: `rgb(0, 0, 0) 0px 0px 0px 8px` + +### Example Component Prompts +- "Create a hero section on pure black (#000000) background. Headline at 96px Domaine Display weight 400, line-height 1.00, letter-spacing -0.96px, near-white (#f0f0f0) text, OpenType 'ss01 ss04 ss11'. Subtitle at 20px ABC Favorit weight 400, line-height 1.30. Two pill buttons: white solid (#ffffff, 9999px radius) and transparent with frost border (rgba(214,235,253,0.19))." +- "Design a navigation bar: dark background with frost border bottom (1px solid rgba(214,235,253,0.19)). Nav links at 14px ABC Favorit weight 500, letter-spacing +0.35px, OpenType 'ss01 ss03 ss04'. White pill CTA right-aligned." +- "Build a feature card: transparent background, frost border (rgba(214,235,253,0.19)), 16px radius. Title at 56px ABC Favorit weight 400, letter-spacing -2.8px. Body at 16px Inter weight 400, #a1a4a5 text." +- "Create a code block using Commit Mono 16px on dark background. Frost border container (24px radius). Syntax colors: orange (#ff801f), blue (#3b9eff), green (#11ff99), yellow (#ffc53d)." +- "Design an accent badge: background #ff5900 at 22% opacity, text #ffa057, 9999px radius, 12px Inter weight 500." + +### Iteration Guide +1. Start with pure black — everything floats in the void +2. Frost borders (`rgba(214, 235, 253, 0.19)`) are the universal structural element — not gray, not neutral +3. Three fonts, three roles: Domaine (hero), ABC Favorit (sections), Inter (body) — never cross +4. OpenType stylistic sets are mandatory on display fonts — they define the character +5. Multi-color accents at low opacity (12–42%) for backgrounds, full opacity for text +6. Pill shape (9999px) for CTAs and badges, standard radius (4px–16px) for containers +7. No shadows — use frost borders for depth against the void diff --git a/skills/creative/popular-web-designs/templates/revolut.md b/skills/creative/popular-web-designs/templates/revolut.md new file mode 100644 index 000000000000..685fe4016fb8 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/revolut.md @@ -0,0 +1,198 @@ +# Design System: Revolut + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Revolut's website is fintech confidence distilled into pixels — a design system that communicates "your money is in capable hands" through massive typography, generous whitespace, and a disciplined neutral palette. The visual language is built on Aeonik Pro, a geometric grotesque that creates billboard-scale headlines at 136px with weight 500 and aggressive negative tracking (-2.72px). This isn't subtle branding; it's fintech at stadium scale. + +The color system is built on a comprehensive `--rui-*` (Revolut UI) token architecture with semantic naming for every state: danger (`#e23b4a`), warning (`#ec7e00`), teal (`#00a87e`), blue (`#494fdf`), deep-pink (`#e61e49`), and more. But the marketing surface itself is remarkably restrained — near-black (`#191c1f`) and pure white (`#ffffff`) dominate, with the colorful semantic tokens reserved for the product interface, not the marketing page. + +What distinguishes Revolut is its pill-everything button system. Every button uses 9999px radius — primary dark (`#191c1f`), secondary light (`#f4f4f4`), outlined (`transparent + 2px solid`), and ghost on dark (`rgba(244,244,244,0.1) + 2px solid`). The padding is generous (14px 32px–34px), creating large, confident touch targets. Combined with Inter for body text at various weights and positive letter-spacing (0.16px–0.24px), the result is a design that feels both premium and accessible — banking for the modern era. + +**Key Characteristics:** +- Aeonik Pro display at 136px weight 500 — billboard-scale fintech headlines +- Near-black (`#191c1f`) + white binary with comprehensive `--rui-*` semantic tokens +- Universal pill buttons (9999px radius) with generous padding (14px 32px) +- Inter for body text with positive letter-spacing (0.16px–0.24px) +- Rich semantic color system: blue, teal, pink, yellow, green, brown, danger, warning +- Zero shadows detected — depth through color contrast only +- Tight display line-heights (1.00) with relaxed body (1.50–1.56) + +## 2. Color Palette & Roles + +### Primary +- **Revolut Dark** (`#191c1f`): Primary dark surface, button background, near-black text +- **Pure White** (`#ffffff`): `--rui-color-action-label`, primary light surface +- **Light Surface** (`#f4f4f4`): Secondary button background, subtle surface + +### Brand / Interactive +- **Revolut Blue** (`#494fdf`): `--rui-color-blue`, primary brand blue +- **Action Blue** (`#4f55f1`): `--rui-color-action-photo-header-text`, header accent +- **Blue Text** (`#376cd5`): `--website-color-blue-text`, link blue + +### Semantic +- **Danger Red** (`#e23b4a`): `--rui-color-danger`, error/destructive +- **Deep Pink** (`#e61e49`): `--rui-color-deep-pink`, critical accent +- **Warning Orange** (`#ec7e00`): `--rui-color-warning`, warning states +- **Yellow** (`#b09000`): `--rui-color-yellow`, attention +- **Teal** (`#00a87e`): `--rui-color-teal`, success/positive +- **Light Green** (`#428619`): `--rui-color-light-green`, secondary success +- **Green Text** (`#006400`): `--website-color-green-text`, green text +- **Light Blue** (`#007bc2`): `--rui-color-light-blue`, informational +- **Brown** (`#936d62`): `--rui-color-brown`, warm neutral accent +- **Red Text** (`#8b0000`): `--website-color-red-text`, dark red text + +### Neutral Scale +- **Mid Slate** (`#505a63`): Secondary text +- **Cool Gray** (`#8d969e`): Muted text, tertiary +- **Gray Tone** (`#c9c9cd`): `--rui-color-grey-tone-20`, borders/dividers + +## 3. Typography Rules + +### Font Families +- **Display**: `Aeonik Pro` — geometric grotesque, no detected fallbacks +- **Body / UI**: `Inter` — standard system sans +- **Fallback**: `Arial` for specific button contexts + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Mega | Aeonik Pro | 136px (8.50rem) | 500 | 1.00 (tight) | -2.72px | Stadium-scale hero | +| Display Hero | Aeonik Pro | 80px (5.00rem) | 500 | 1.00 (tight) | -0.8px | Primary hero | +| Section Heading | Aeonik Pro | 48px (3.00rem) | 500 | 1.21 (tight) | -0.48px | Feature sections | +| Sub-heading | Aeonik Pro | 40px (2.50rem) | 500 | 1.20 (tight) | -0.4px | Sub-sections | +| Card Title | Aeonik Pro | 32px (2.00rem) | 500 | 1.19 (tight) | -0.32px | Card headings | +| Feature Title | Aeonik Pro | 24px (1.50rem) | 400 | 1.33 | normal | Light headings | +| Nav / UI | Aeonik Pro | 20px (1.25rem) | 500 | 1.40 | normal | Navigation, buttons | +| Body Large | Inter | 18px (1.13rem) | 400 | 1.56 | -0.09px | Introductions | +| Body | Inter | 16px (1.00rem) | 400 | 1.50 | 0.24px | Standard reading | +| Body Semibold | Inter | 16px (1.00rem) | 600 | 1.50 | 0.16px | Emphasized body | +| Body Bold Link | Inter | 16px (1.00rem) | 700 | 1.50 | 0.24px | Bold links | + +### Principles +- **Weight 500 as display default**: Aeonik Pro uses medium (500) for ALL headings — no bold. This creates authority through size and tracking, not weight. +- **Billboard tracking**: -2.72px at 136px is extremely compressed — text designed to be read at a glance, like airport signage. +- **Positive tracking on body**: Inter uses +0.16px to +0.24px, creating airy, well-spaced reading text that contrasts with the compressed headings. + +## 4. Component Stylings + +### Buttons + +**Primary Dark Pill** +- Background: `#191c1f` +- Text: `#ffffff` +- Padding: 14px 32px +- Radius: 9999px (full pill) +- Hover: opacity 0.85 +- Focus: `0 0 0 0.125rem` ring + +**Secondary Light Pill** +- Background: `#f4f4f4` +- Text: `#000000` +- Padding: 14px 34px +- Radius: 9999px +- Hover: opacity 0.85 + +**Outlined Pill** +- Background: transparent +- Text: `#191c1f` +- Border: `2px solid #191c1f` +- Padding: 14px 32px +- Radius: 9999px + +**Ghost on Dark** +- Background: `rgba(244, 244, 244, 0.1)` +- Text: `#f4f4f4` +- Border: `2px solid #f4f4f4` +- Padding: 14px 32px +- Radius: 9999px + +### Cards & Containers +- Radius: 12px (small), 20px (cards) +- No shadows — flat surfaces with color contrast +- Dark and light section alternation + +### Navigation +- Aeonik Pro 20px weight 500 +- Clean header, hamburger toggle at 12px radius +- Pill CTAs right-aligned + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 4px, 6px, 8px, 14px, 16px, 20px, 24px, 32px, 40px, 48px, 80px, 88px, 120px +- Large section spacing: 80px–120px + +### Border Radius Scale +- Standard (12px): Navigation, small buttons +- Card (20px): Feature cards +- Pill (9999px): All buttons + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Everything — Revolut uses zero shadows | +| Focus | `0 0 0 0.125rem` ring | Accessibility focus | + +**Shadow Philosophy**: Revolut uses ZERO shadows. Depth comes entirely from the dark/light section contrast and the generous whitespace between elements. + +## 7. Do's and Don'ts + +### Do +- Use Aeonik Pro weight 500 for all display headings +- Apply 9999px radius to all buttons — pill shape is universal +- Use generous button padding (14px 32px) +- Keep the palette to near-black + white for marketing surfaces +- Apply positive letter-spacing on Inter body text + +### Don't +- Don't use shadows — Revolut is flat by design +- Don't use bold (700) for Aeonik Pro headings — 500 is the weight +- Don't use small buttons — the generous padding is intentional +- Don't apply semantic colors to marketing surfaces — they're for the product + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <400px | Compact, single column | +| Mobile | 400–720px | Standard mobile | +| Tablet | 720–1024px | 2-column layouts | +| Desktop | 1024–1280px | Standard desktop | +| Large | 1280–1920px | Full layout | + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Dark: Revolut Dark (`#191c1f`) +- Light: White (`#ffffff`) +- Surface: Light (`#f4f4f4`) +- Blue: Revolut Blue (`#494fdf`) +- Danger: Red (`#e23b4a`) +- Success: Teal (`#00a87e`) + +### Example Component Prompts +- "Create a hero: white background. Headline at 136px Aeonik Pro weight 500, line-height 1.00, letter-spacing -2.72px, #191c1f text. Dark pill CTA (#191c1f, 9999px, 14px 32px). Outlined pill secondary (transparent, 2px solid #191c1f)." +- "Build a pill button: #191c1f background, white text, 9999px radius, 14px 32px padding, 20px Aeonik Pro weight 500. Hover: opacity 0.85." + +### Iteration Guide +1. Aeonik Pro 500 for headings — never bold +2. All buttons are pills (9999px) with generous padding +3. Zero shadows — flat is the Revolut identity +4. Near-black + white for marketing, semantic colors for product diff --git a/skills/creative/popular-web-designs/templates/runwayml.md b/skills/creative/popular-web-designs/templates/runwayml.md new file mode 100644 index 000000000000..cbd2b1eac3ad --- /dev/null +++ b/skills/creative/popular-web-designs/templates/runwayml.md @@ -0,0 +1,257 @@ +# Design System: Runway + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Runway's interface is a cinematic reel brought to life as a website — a dark, editorial, film-production-grade design where full-bleed photography and video ARE the primary UI elements. This is not a typical tech product page; it's a visual manifesto for AI-powered creativity. Every section feels like a frame from a film: dramatic lighting, sweeping landscapes, and intimate human moments captured in high-quality imagery that dominates the viewport. + +The design language is built on a single typeface — abcNormal — a clean, geometric sans-serif that handles everything from 48px display headlines to 11px uppercase labels. This single-font commitment creates an extreme typographic uniformity that lets the visual content speak louder than the text. Headlines use tight line-heights (1.0) with negative letter-spacing (-0.9px to -1.2px), creating compressed text blocks that feel like film titles rather than marketing copy. + +What makes Runway distinctive is its complete commitment to visual content as design. Rather than illustrating features with icons or diagrams, Runway shows actual AI-generated and AI-enhanced imagery — cars driving through cinematic landscapes, artistic portraits, architectural renders. The interface itself retreats into near-invisibility: minimal borders, zero shadows, subtle cool-gray text, and a dark palette that puts maximum focus on the photography. + +**Key Characteristics:** +- Cinematic full-bleed photography and video as primary UI elements +- Single typeface system: abcNormal for everything from display to micro labels +- Dark-dominant palette with cool-toned neutrals (#767d88, #7d848e) +- Zero shadows, minimal borders — the interface is intentionally invisible +- Tight display typography (line-height 1.0) with negative tracking (-0.9px to -1.2px) +- Uppercase labels with positive letter-spacing for navigational structure +- Weight 450 (unusual intermediate) for small uppercase text — precision craft +- Editorial magazine layout with mixed-size image grids + +## 2. Color Palette & Roles + +### Primary +- **Runway Black** (`#000000`): The primary page background and maximum-emphasis text. +- **Deep Black** (`#030303`): A near-imperceptible variant for layered dark surfaces. +- **Dark Surface** (`#1a1a1a`): Card backgrounds and elevated dark containers. +- **Pure White** (`#ffffff`): Primary text on dark surfaces and light-section backgrounds. + +### Surface & Background +- **Near White** (`#fefefe`): The lightest surface — barely distinguishable from pure white. +- **Cool Cloud** (`#e9ecf2`): Light section backgrounds with a cool blue-gray tint. +- **Border Dark** (`#27272a`): The single dark-mode border color — barely visible containment. + +### Neutrals & Text +- **Charcoal** (`#404040`): Primary body text on light surfaces and secondary text. +- **Near Charcoal** (`#3f3f3f`): Slightly lighter variant for dark-section secondary text. +- **Cool Slate** (`#767d88`): Secondary body text — a distinctly blue-gray cool neutral. +- **Mid Slate** (`#7d848e`): Tertiary text, metadata descriptions. +- **Muted Gray** (`#a7a7a7`): De-emphasized content, timestamps. +- **Cool Silver** (`#c9ccd1`): Light borders and dividers. +- **Light Silver** (`#d0d4d4`): The lightest border/divider variant. +- **Tailwind Gray** (`#6b7280`): Standard Tailwind neutral for supplementary text. +- **Dark Link** (`#0c0c0c`): Darkest link text — nearly black. +- **Footer Gray** (`#999999`): Footer links and deeply muted content. + +### Gradient System +- **None in the interface.** Visual richness comes entirely from photographic content — AI-generated and enhanced imagery provides all the color and gradient the design needs. The interface itself is intentionally colorless. + +## 3. Typography Rules + +### Font Family +- **Universal**: `abcNormal`, with fallback: `abcNormal Fallback` + +*Note: abcNormal is a custom geometric sans-serif. For external implementations, Inter or DM Sans serve as close substitutes.* + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | abcNormal | 48px (3rem) | 400 | 1.00 (tight) | -1.2px | Maximum size, film-title presence | +| Section Heading | abcNormal | 40px (2.5rem) | 400 | 1.00–1.10 | -1px to 0px | Feature section titles | +| Sub-heading | abcNormal | 36px (2.25rem) | 400 | 1.00 (tight) | -0.9px | Secondary section markers | +| Card Title | abcNormal | 24px (1.5rem) | 400 | 1.00 (tight) | normal | Article and card headings | +| Feature Title | abcNormal | 20px (1.25rem) | 400 | 1.00 (tight) | normal | Small headings | +| Body / Button | abcNormal | 16px (1rem) | 400–600 | 1.30–1.50 | -0.16px to normal | Standard body, nav links | +| Caption / Label | abcNormal | 14px (0.88rem) | 500–600 | 1.25–1.43 | 0.35px (uppercase) | Metadata, section labels | +| Small | abcNormal | 13px (0.81rem) | 400 | 1.30 (tight) | -0.16px to -0.26px | Compact descriptions | +| Micro / Tag | abcNormal | 11px (0.69rem) | 450 | 1.30 (tight) | normal | Uppercase tags, tiny labels | + +### Principles +- **One typeface, complete expression**: abcNormal handles every text role. The design achieves variety through size, weight, case, and letter-spacing rather than font-family switching. +- **Tight everywhere**: Nearly every size uses line-height 1.0–1.30 — even body text is relatively compressed. This creates a dense, editorial feel. +- **Weight 450 — the precision detail**: Some small uppercase labels use weight 450, an uncommon intermediate between regular (400) and medium (500). This micro-craft signals typographic sophistication. +- **Negative tracking as default**: Even body text uses -0.16px to -0.26px letter-spacing, keeping everything slightly tighter than default. +- **Uppercase as structure**: Labels at 14px and 11px use `text-transform: uppercase` with positive letter-spacing (0.35px) to create navigational signposts that contrast with the tight lowercase text. + +## 4. Component Stylings + +### Buttons +- Text: weight 600 at 14px abcNormal +- Background: likely transparent or dark, with minimal border +- Radius: small (4px) for button-like links +- The button design is extremely restrained — no heavy fills or borders detected +- Interactive elements blend into the editorial flow + +### Cards & Containers +- Background: transparent or Dark Surface (`#1a1a1a`) +- Border: `1px solid #27272a` (dark mode) — barely visible containment +- Radius: small (4–8px) for functional elements; 16px for alert-style containers +- Shadow: zero — no shadows on any element +- Cards are primarily photographic — the image IS the card + +### Navigation +- Minimal horizontal nav — transparent over hero content +- Logo: Runway wordmark in white/black +- Links: abcNormal at 16px, weight 400–600 +- Hover: text shifts to white or higher opacity +- Extremely subtle — designed to not compete with visual content + +### Image Treatment +- Full-bleed cinematic photography and video dominate +- AI-generated content shown at large scale as primary visual elements +- Mixed-size image grids creating editorial magazine layouts +- Dark overlays on hero images for text readability +- Product screenshots with subtle rounded corners (8px) + +### Distinctive Components + +**Cinematic Hero** +- Full-viewport image or video with text overlay +- Headline in 48px abcNormal, white on dark imagery +- The image is always cinematic quality — film-grade composition + +**Research Article Cards** +- Photographic thumbnails with article titles +- Mixed-size grid layout (large feature + smaller supporting) +- Clean text overlay or below-image caption style + +**Trust Bar** +- Company logos (leading organizations across industries) +- Clean, monochrome treatment +- Horizontal layout with generous spacing + +**Mission Statement** +- "We are building AI to simulate the world through imagination, art and aesthetics" +- On a dark background with white text +- The emotional close — artistic and philosophical + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 4px, 6px, 8px, 12px, 16px, 20px, 24px, 28px, 32px, 48px, 64px, 78px +- Section vertical spacing: generous (48–78px) +- Component gaps: 16–24px + +### Grid & Container +- Max container width: up to 1600px (cinema-wide) +- Hero: full-viewport, edge-to-edge +- Content sections: centered with generous margins +- Image grids: asymmetric, magazine-style mixed sizes +- Footer: full-width dark section + +### Whitespace Philosophy +- **Cinema-grade breathing**: Large vertical gaps between sections create a scrolling experience that feels like watching scenes change. +- **Images replace whitespace**: Where other sites use empty space, Runway fills it with photography. The visual content IS the breathing room. +- **Editorial grid asymmetry**: The image grid uses intentionally varied sizes — large hero images paired with smaller supporting images, creating visual rhythm. + +### Border Radius Scale +- Sharp (4px): Buttons, small interactive elements +- Subtle (6px): Links, small containers +- Comfortable (8px): Standard containers, image cards +- Generous (16px): Alert-style containers, featured elements + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Everything — the dominant state | +| Bordered (Level 1) | `1px solid #27272a` | Alert containers only | +| Dark Section (Level 2) | Dark bg (#000000 / #1a1a1a) with light text | Hero, features, footer | +| Light Section (Level 3) | White/Cool Cloud bg with dark text | Content sections, research | + +**Shadow Philosophy**: Runway uses **zero shadows**. This is a film-production design decision — in cinema, depth comes from lighting, focus, and composition, not drop shadows. The interface mirrors this philosophy: depth is communicated through dark/light section alternation, photographic depth-of-field, and overlay transparency — never through CSS box-shadow. + +## 7. Do's and Don'ts + +### Do +- Use full-bleed cinematic photography as the primary visual element +- Use abcNormal for all text — maintain the single-typeface commitment +- Keep display line-heights at 1.0 with negative letter-spacing for film-title density +- Use the cool-gray neutral palette (#767d88, #7d848e) for secondary text +- Maintain zero shadows — depth comes from photography and section backgrounds +- Use uppercase with letter-spacing for navigational labels (14px, 0.35px spacing) +- Apply small border-radius (4–8px) — the design is NOT pill-shaped +- Let visual content (photos, videos) dominate — the UI should be invisible +- Use weight 450 for micro labels — the precision matters + +### Don't +- Don't add decorative colors to the interface — the only color comes from photography +- Don't use heavy borders or shadows — the interface must be nearly invisible +- Don't use pill-shaped radius — Runway's geometry is subtly rounded, not circular +- Don't use bold (700+) weight — 400–600 is the full range, with 450 as a precision tool +- Don't compete with the visual content — text overlays should be minimal and restrained +- Don't use gradient backgrounds in the interface — gradients exist only in photography +- Don't use more than one typeface — abcNormal handles everything +- Don't use body line-height above 1.50 — the tight, editorial feel is core +- Don't reduce image quality — cinematic photography IS the design + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, stacked images, reduced hero text | +| Tablet | 640–768px | 2-column image grids begin | +| Small Desktop | 768–1024px | Standard layout | +| Desktop | 1024–1280px | Full layout, expanded hero | +| Large Desktop | 1280–1600px | Maximum cinema-width container | + +### Touch Targets +- Navigation links at comfortable 16px +- Article cards serve as large touch targets +- Buttons at 14px weight 600 with adequate padding + +### Collapsing Strategy +- **Navigation**: Collapses to hamburger on mobile +- **Hero**: Full-bleed maintained, text scales down +- **Image grids**: Multi-column → 2-column → single column +- **Research articles**: Feature-size cards → stacked full-width +- **Trust logos**: Horizontal scroll or reduced grid + +### Image Behavior +- Cinematic images scale proportionally +- Full-bleed hero maintained across all sizes +- Image grids reflow to fewer columns +- Video content maintains aspect ratio + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background Dark: "Runway Black (#000000)" +- Background Light: "Pure White (#ffffff)" +- Primary Text Dark: "Charcoal (#404040)" +- Secondary Text: "Cool Slate (#767d88)" +- Muted Text: "Muted Gray (#a7a7a7)" +- Light Border: "Cool Silver (#c9ccd1)" +- Dark Border: "Border Dark (#27272a)" +- Card Surface: "Dark Surface (#1a1a1a)" + +### Example Component Prompts +- "Create a cinematic hero section: full-bleed dark background with a cinematic image overlay. Headline at 48px abcNormal weight 400, line-height 1.0, letter-spacing -1.2px in white. Minimal text below in Cool Slate (#767d88) at 16px." +- "Design a research article grid: one large card (50% width) with a cinematic image and 24px title, next to two smaller cards stacked. All images with 8px border-radius. Titles in white (dark bg) or Charcoal (#404040, light bg)." +- "Build a section label: 14px abcNormal weight 500, uppercase, letter-spacing 0.35px in Cool Slate (#767d88). No border, no background." +- "Create a trust bar: company logos in monochrome, horizontal layout with generous spacing. On dark background with white/gray logo treatments." +- "Design a mission statement section: Runway Black background, white text at 36px abcNormal, line-height 1.0, letter-spacing -0.9px. Centered, with generous vertical padding." + +### Iteration Guide +1. Visual content first — always include cinematic photography +2. Use abcNormal for everything — specify size and weight, never change the font +3. Keep the interface invisible — no heavy borders, no shadows, no bright colors +4. Use the cool slate grays (#767d88, #7d848e) for secondary text — not warm grays +5. Uppercase labels need letter-spacing (0.35px) — never tight uppercase +6. Dark sections should be truly dark (#000000 or #1a1a1a) — no medium grays as surfaces diff --git a/skills/creative/popular-web-designs/templates/sanity.md b/skills/creative/popular-web-designs/templates/sanity.md new file mode 100644 index 000000000000..31c67da93b53 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/sanity.md @@ -0,0 +1,370 @@ +# Design System: Sanity + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Space Grotesk` | **Mono:** `IBM Plex Mono` +> - **Font stack (CSS):** `font-family: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Sanity's website is a developer-content platform rendered as a nocturnal command center -- dark, precise, and deeply structured. The entire experience sits on a near-black canvas (`#0b0b0b`) that reads less like a "dark mode toggle" and more like the natural state of a tool built for people who live in terminals. Where most CMS marketing pages reach for friendly pastels and soft illustration, Sanity leans into the gravity of its own product: structured content deserves a structured stage. + +The signature typographic voice is waldenburgNormal -- a distinctive, slightly geometric sans-serif with tight negative letter-spacing (-0.32px to -4.48px at display sizes) that gives headlines a compressed, engineered quality. At 112px hero scale with -4.48px tracking, the type feels almost machined -- like precision-cut steel letterforms. This is paired with IBM Plex Mono for code and technical labels, creating a dual-register voice: editorial authority meets developer credibility. + +What makes Sanity distinctive is the interplay between its monochromatic dark palette and vivid, saturated accent punctuation. The neutral scale runs from pure black through a tightly controlled gray ramp (`#0b0b0b` -> `#212121` -> `#353535` -> `#797979` -> `#b9b9b9` -> `#ededed` -> `#ffffff`) with no warm or cool bias -- just pure, achromatic precision. Against this disciplined backdrop, a neon green accent (display-p3 green) and electric blue (`#0052ef`) land with the impact of signal lights in a dark control room. The orange-red CTA (`#f36458`) provides the only warm touch in an otherwise cool system. + +**Key Characteristics:** +- Near-black canvas (`#0b0b0b`) as the default, natural environment -- not a dark "mode" but the primary identity +- waldenburgNormal with extreme negative tracking at display sizes, creating a precision-engineered typographic voice +- Pure achromatic gray scale -- no warm or cool undertones, pure neutral discipline +- Vivid accent punctuation: neon green, electric blue (`#0052ef`), and coral-red (`#f36458`) against the dark field +- Pill-shaped primary buttons (99999px radius) contrasting with subtle rounded rectangles (3-6px) for secondary actions +- IBM Plex Mono as the technical counterweight to the editorial display face +- Full-bleed dark sections with content contained in measured max-width containers +- Hover states that shift to electric blue (`#0052ef`) across all interactive elements -- a consistent "activation" signal + +## 2. Color Palette & Roles + +### Primary Brand +- **Sanity Black** (`#0b0b0b`): The primary canvas and dominant surface color. Not pure black but close enough to feel absolute. The foundation of the entire visual identity. +- **Pure Black** (`#000000`): Used for maximum-contrast moments, deep overlays, and certain border accents. +- **Sanity Red** (`#f36458`): The primary CTA and brand accent -- a warm coral-red that serves as the main call-to-action color. Used for "Get Started" buttons and primary conversion points. + +### Accent & Interactive +- **Electric Blue** (`#0052ef`): The universal hover/active state color across the entire system. Buttons, links, and interactive elements all shift to this blue on hover. Also used as `--color-blue-700` for focus rings and active states. +- **Light Blue** (`#55beff` / `#afe3ff`): Secondary blue variants used for accent backgrounds, badges, and dimmed blue surfaces. +- **Neon Green** (`color(display-p3 .270588 1 0)`): A vivid, wide-gamut green used as `--color-fg-accent-green` for success states and premium feature highlights. Falls back to `#19d600` in sRGB. +- **Accent Magenta** (`color(display-p3 .960784 0 1)`): A vivid wide-gamut magenta for specialized accent moments. + +### Surface & Background +- **Near Black** (`#0b0b0b`): Default page background and primary surface. +- **Dark Gray** (`#212121`): Elevated surface color for cards, secondary containers, input backgrounds, and subtle layering above the base canvas. +- **Medium Dark** (`#353535`): Tertiary surface and border color for creating depth between dark layers. +- **Pure White** (`#ffffff`): Used for inverted sections, light-on-dark text, and specific button surfaces. +- **Light Gray** (`#ededed`): Light surface for inverted/light sections and subtle background tints. + +### Neutrals & Text +- **White** (`#ffffff`): Primary text color on dark surfaces, maximum legibility. +- **Silver** (`#b9b9b9`): Secondary text, body copy on dark surfaces, muted descriptions, and placeholder text. +- **Medium Gray** (`#797979`): Tertiary text, metadata, timestamps, and de-emphasized content. +- **Charcoal** (`#212121`): Text on light/inverted surfaces. +- **Near Black Text** (`#0b0b0b`): Primary text on white/light button surfaces. + +### Semantic +- **Error Red** (`#dd0000`): Destructive actions, validation errors, and critical warnings -- a pure, high-saturation red. +- **GPC Green** (`#37cd84`): Privacy/compliance indicator green. +- **Focus Ring Blue** (`#0052ef`): Focus ring color for accessibility, matching the interactive blue. + +### Border System +- **Dark Border** (`#0b0b0b`): Primary border on dark containers -- barely visible, maintaining minimal containment. +- **Subtle Border** (`#212121`): Standard border for inputs, textareas, and card edges on dark surfaces. +- **Medium Border** (`#353535`): More visible borders for emphasized containment and dividers. +- **Light Border** (`#ffffff`): Border on inverted/light elements or buttons needing contrast separation. +- **Orange Border** (`color(display-p3 1 0.3333 0)`): Special accent border for highlighted/featured elements. + +## 3. Typography Rules + +### Font Family +- **Display / Headline**: `waldenburgNormal`, fallback: `waldenburgNormal Fallback, ui-sans-serif, system-ui` +- **Body / UI**: `waldenburgNormal`, fallback: `waldenburgNormal Fallback, ui-sans-serif, system-ui` +- **Code / Technical**: `IBM Plex Mono`, fallback: `ibmPlexMono Fallback, ui-monospace` +- **Fallback / CJK**: `Helvetica`, fallback: `Arial, Hiragino Sans GB, STXihei, Microsoft YaHei, WenQuanYi Micro Hei` + +*Note: waldenburgNormal is a custom typeface. For external implementations, use Inter or Space Grotesk as the sans substitute (geometric, slightly condensed feel). IBM Plex Mono is available on Google Fonts.* + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | waldenburgNormal | 112px (7rem) | 400 | 1.00 (tight) | -4.48px | Maximum impact, compressed tracking | +| Hero Secondary | waldenburgNormal | 72px (4.5rem) | 400 | 1.05 (tight) | -2.88px | Large section headers | +| Section Heading | waldenburgNormal | 48px (3rem) | 400 | 1.08 (tight) | -1.68px | Primary section anchors | +| Heading Large | waldenburgNormal | 38px (2.38rem) | 400 | 1.10 (tight) | -1.14px | Feature section titles | +| Heading Medium | waldenburgNormal | 32px (2rem) | 425 | 1.24 (tight) | -0.32px | Card titles, subsection headers | +| Heading Small | waldenburgNormal | 24px (1.5rem) | 425 | 1.24 (tight) | -0.24px | Smaller feature headings | +| Subheading | waldenburgNormal | 20px (1.25rem) | 425 | 1.13 (tight) | -0.2px | Sub-section markers | +| Body Large | waldenburgNormal | 18px (1.13rem) | 400 | 1.50 | -0.18px | Intro paragraphs, descriptions | +| Body | waldenburgNormal | 16px (1rem) | 400 | 1.50 | normal | Standard body text | +| Body Small | waldenburgNormal | 15px (0.94rem) | 400 | 1.50 | -0.15px | Compact body text | +| Caption | waldenburgNormal | 13px (0.81rem) | 400-500 | 1.30-1.50 | -0.13px | Metadata, descriptions, tags | +| Small Caption | waldenburgNormal | 12px (0.75rem) | 400 | 1.50 | -0.12px | Footnotes, timestamps | +| Micro / Label | waldenburgNormal | 11px (0.69rem) | 500-600 | 1.00-1.50 | normal | Uppercase labels, tiny badges | +| Code Body | IBM Plex Mono | 15px (0.94rem) | 400 | 1.50 | normal | Code blocks, technical content | +| Code Caption | IBM Plex Mono | 13px (0.81rem) | 400-500 | 1.30-1.50 | normal | Inline code, small technical labels | +| Code Micro | IBM Plex Mono | 10-12px | 400 | 1.30-1.50 | normal | Tiny code labels, uppercase tags | + +### Principles +- **Extreme negative tracking at scale**: Display headings at 72px+ use aggressive negative letter-spacing (-2.88px to -4.48px), creating a tight, engineered quality that distinguishes Sanity from looser editorial typography. +- **Single font, multiple registers**: waldenburgNormal handles both editorial display and functional UI text. The weight range is narrow (400-425 for most, 500-600 only for tiny labels), keeping the voice consistent. +- **OpenType feature control**: Typography uses deliberate feature settings including `"cv01", "cv11", "cv12", "cv13", "ss07"` for display sizes and `"calt" 0` for body text, fine-tuning character alternates for different contexts. +- **Tight headings, relaxed body**: Headings use 1.00-1.24 line-height (extremely tight), while body text breathes at 1.50. This contrast creates clear visual hierarchy. +- **Uppercase for technical labels**: IBM Plex Mono captions and small labels frequently use `text-transform: uppercase` with tight line-heights, creating a "system readout" aesthetic for technical metadata. + +## 4. Component Stylings + +### Buttons + +**Primary CTA (Pill)** +- Background: Sanity Red (`#f36458`) +- Text: White (`#ffffff`) +- Padding: 8px 16px +- Border Radius: 99999px (full pill) +- Border: none +- Hover: Electric Blue (`#0052ef`) background, white text +- Font: 16px waldenburgNormal, weight 400 + +**Secondary (Dark Pill)** +- Background: Near Black (`#0b0b0b`) +- Text: Silver (`#b9b9b9`) +- Padding: 8px 12px +- Border Radius: 99999px (full pill) +- Border: none +- Hover: Electric Blue (`#0052ef`) background, white text + +**Outlined (Light Pill)** +- Background: White (`#ffffff`) +- Text: Near Black (`#0b0b0b`) +- Padding: 8px +- Border Radius: 99999px (full pill) +- Border: 1px solid `#0b0b0b` +- Hover: Electric Blue (`#0052ef`) background, white text + +**Ghost / Subtle** +- Background: Dark Gray (`#212121`) +- Text: Silver (`#b9b9b9`) +- Padding: 0px 12px +- Border Radius: 5px +- Border: 1px solid `#212121` +- Hover: Electric Blue (`#0052ef`) background, white text + +**Uppercase Label Button** +- Font: 11px waldenburgNormal, weight 600, uppercase +- Background: transparent or `#212121` +- Text: Silver (`#b9b9b9`) +- Letter-spacing: normal +- Used for tab-like navigation and filter controls + +### Cards + +**Dark Content Card** +- Background: `#212121` +- Border: 1px solid `#353535` or `#212121` +- Border Radius: 6px +- Padding: 24px +- Text: White (`#ffffff`) for titles, Silver (`#b9b9b9`) for body +- Hover: subtle border color shift or elevation change + +**Feature Card (Full-bleed)** +- Background: `#0b0b0b` or full-bleed image/gradient +- Border: none or 1px solid `#212121` +- Border Radius: 12px +- Padding: 32-48px +- Contains large imagery with overlaid text + +### Inputs + +**Text Input / Textarea** +- Background: Near Black (`#0b0b0b`) +- Text: Silver (`#b9b9b9`) +- Border: 1px solid `#212121` +- Padding: 8px 12px +- Border Radius: 3px +- Focus: outline with `var(--focus-ring-color)` (blue), 2px solid +- Focus background: shifts to deep cyan (`#072227`) + +**Search Input** +- Background: `#0b0b0b` +- Text: Silver (`#b9b9b9`) +- Padding: 0px 12px +- Border Radius: 3px +- Placeholder: Medium Gray (`#797979`) + +### Navigation + +**Top Navigation** +- Background: Near Black (`#0b0b0b`) with backdrop blur +- Height: auto, compact padding +- Logo: left-aligned, Sanity wordmark +- Links: waldenburgNormal 16px, Silver (`#b9b9b9`) +- Link Hover: Electric Blue via `--color-fg-accent-blue` +- CTA Button: Sanity Red pill button right-aligned +- Separator: 1px border-bottom `#212121` + +**Footer** +- Background: Near Black (`#0b0b0b`) +- Multi-column link layout +- Links: Silver (`#b9b9b9`), hover to blue +- Section headers: White (`#ffffff`), 13px uppercase IBM Plex Mono + +### Badges / Pills + +**Neutral Subtle** +- Background: White (`#ffffff`) +- Text: Near Black (`#0b0b0b`) +- Padding: 8px +- Font: 13px +- Border Radius: 99999px + +**Neutral Filled** +- Background: Near Black (`#0b0b0b`) +- Text: White (`#ffffff`) +- Padding: 8px +- Font: 13px +- Border Radius: 99999px + +## 5. Layout Principles + +### Spacing System +Base unit: **8px** + +| Token | Value | Usage | +|-------|-------|-------| +| space-1 | 1px | Hairline gaps, border-like spacing | +| space-2 | 2px | Minimal internal padding | +| space-3 | 4px | Tight component internal spacing | +| space-4 | 6px | Small element gaps | +| space-5 | 8px | Base unit -- button padding, input padding, badge padding | +| space-6 | 12px | Standard component gap, button horizontal padding | +| space-7 | 16px | Section internal padding, card spacing | +| space-8 | 24px | Large component padding, card internal spacing | +| space-9 | 32px | Section padding, container gutters | +| space-10 | 48px | Large section vertical spacing | +| space-11 | 64px | Major section breaks | +| space-12 | 96-120px | Hero vertical padding, maximum section spacing | + +### Grid & Container +- Max content width: ~1440px (inferred from breakpoints) +- Page gutter: 32px on desktop, 16px on mobile +- Content sections use full-bleed backgrounds with centered, max-width content +- Multi-column layouts: 2-3 columns on desktop, single column on mobile +- Card grids: CSS Grid with consistent gaps (16-24px) + +### Whitespace Philosophy +Sanity uses aggressive vertical spacing between sections (64-120px) to create breathing room on the dark canvas. Within sections, spacing is tighter (16-32px), creating dense information clusters separated by generous voids. This rhythm gives the page a "slides" quality -- each section feels like its own focused frame. + +### Border Radius Scale + +| Token | Value | Usage | +|-------|-------|-------| +| radius-xs | 3px | Inputs, textareas, subtle rounding | +| radius-sm | 4-5px | Secondary buttons, small cards, tags | +| radius-md | 6px | Standard cards, containers | +| radius-lg | 12px | Large cards, feature containers, forms | +| radius-pill | 99999px | Primary buttons, badges, nav pills | + +## 6. Depth & Elevation + +### Shadow System + +| Level | Value | Usage | +|-------|-------|-------| +| Level 0 (Flat) | none | Default state for most elements -- dark surfaces create depth through color alone | +| Level 1 (Subtle) | 0px 0px 0px 1px `#212121` | Border-like shadow for minimal containment without visible borders | +| Level 2 (Focus) | 0 0 0 2px `var(--color-blue-500)` | Focus ring for inputs and interactive elements | +| Level 3 (Overlay) | Backdrop blur + semi-transparent dark | Navigation overlay, modal backgrounds | + +### Depth Philosophy +Sanity's depth system is almost entirely **colorimetric** rather than shadow-based. Elevation is communicated through surface color shifts: `#0b0b0b` (ground) -> `#212121` (elevated) -> `#353535` (prominent) -> `#ffffff` (inverted/highest). This approach is native to dark interfaces where traditional drop shadows would be invisible. The few shadows that exist are ring-based (0px 0px 0px Npx) or blur-based (backdrop-filter) rather than offset shadows, maintaining the flat, precision-engineered aesthetic. + +Border-based containment (1px solid `#212121` or `#353535`) serves as the primary spatial separator, with the border darkness calibrated to be visible but not dominant. The system avoids "floating card" aesthetics -- everything feels mounted to the surface rather than hovering above it. + +## 7. Do's and Don'ts + +### Do +- Use the achromatic gray scale as the foundation -- maintain pure neutral discipline with no warm/cool tinting +- Apply Electric Blue (`#0052ef`) consistently as the universal hover/active state across all interactive elements +- Use extreme negative letter-spacing (-2px to -4.48px) on display headings 48px and above +- Keep primary CTAs as full-pill shapes (99999px radius) with the coral-red (`#f36458`) +- Use IBM Plex Mono uppercase for technical labels, tags, and system metadata +- Communicate depth through surface color (dark-to-light) rather than shadows +- Maintain generous vertical section spacing (64-120px) on the dark canvas +- Use `"cv01", "cv11", "cv12", "cv13", "ss07"` OpenType features for display typography + +### Don't +- Don't introduce warm or cool color tints to the neutral scale -- Sanity's grays are pure achromatic +- Don't use drop shadows for elevation -- dark interfaces demand colorimetric depth +- Don't apply border-radius between 13px and 99998px -- the system jumps from 12px (large card) directly to pill (99999px) +- Don't mix the coral-red CTA with the electric blue interactive color in the same element +- Don't use heavy font weights (700+) -- the system maxes out at 600 and only for 11px uppercase labels +- Don't place light text on light surfaces or dark text on dark surfaces without checking the gray-on-gray contrast ratio +- Don't use traditional offset box-shadows -- ring shadows (0 0 0 Npx) or border-based containment only +- Don't break the tight line-height on headings -- 1.00-1.24 is the range, never go to 1.5+ for display text + +## 8. Responsive Behavior + +### Breakpoints + +| Name | Width | Behavior | +|------|-------|----------| +| Desktop XL | >= 1640px | Full layout, maximum content width | +| Desktop | >= 1440px | Standard desktop layout | +| Desktop Compact | >= 1200px | Slightly condensed desktop | +| Laptop | >= 1100px | Reduced column widths | +| Tablet Landscape | >= 960px | 2-column layouts begin collapsing | +| Tablet | >= 768px | Transition zone, some elements stack | +| Mobile Large | >= 720px | Near-tablet layout | +| Mobile | >= 480px | Single-column, stacked layout | +| Mobile Small | >= 376px | Minimum supported width | + +### Collapsing Strategy +- **Navigation**: Horizontal links collapse to hamburger menu below 768px +- **Hero typography**: Scales from 112px -> 72px -> 48px -> 38px across breakpoints, maintaining tight letter-spacing ratios +- **Grid layouts**: 3-column -> 2-column at ~960px, single-column below 768px +- **Card grids**: Horizontal scrolling on mobile instead of wrapping (preserving card aspect ratios) +- **Section spacing**: Vertical padding reduces by ~40% on mobile (120px -> 64px -> 48px) +- **Button sizing**: CTA pills maintain padding but reduce font size; ghost buttons stay fixed +- **Code blocks**: Horizontal scroll with preserved monospace formatting + +### Mobile-Specific Adjustments +- Full-bleed sections extend edge-to-edge with 16px internal gutters +- Touch targets: minimum 44px for all interactive elements +- Heading letter-spacing relaxes slightly at mobile sizes (less aggressive negative tracking) +- Image containers switch from fixed aspect ratios to full-width with auto height + +## 9. Agent Prompt Guide + +### Quick Color Reference +``` +Background: #0b0b0b (near-black canvas) +Surface: #212121 (elevated cards/containers) +Border: #353535 (visible) / #212121 (subtle) +Text Primary: #ffffff (white on dark) +Text Secondary: #b9b9b9 (silver on dark) +Text Tertiary: #797979 (medium gray) +CTA: #f36458 (coral-red) +Interactive: #0052ef (electric blue, all hovers) +Success: #19d600 (green, sRGB fallback) +Error: #dd0000 (pure red) +Light Surface: #ededed / #ffffff (inverted sections) +``` + +### Example Prompts + +**Landing page section:** +"Create a feature section with a near-black (#0b0b0b) background. Use a 48px heading in Inter with -1.68px letter-spacing, white text. Below it, 16px body text in #b9b9b9 with 1.50 line-height. Include a coral-red (#f36458) pill button with white text and a secondary dark (#0b0b0b) pill button with #b9b9b9 text. Both buttons hover to #0052ef blue." + +**Card grid:** +"Build a 3-column card grid on a #0b0b0b background. Each card has a #212121 surface, 1px solid #353535 border, 6px border-radius, and 24px padding. Card titles are 24px white with -0.24px letter-spacing. Body text is 13px #b9b9b9. Add a 13px IBM Plex Mono uppercase tag in #797979 at the top of each card." + +**Form section:** +"Design a contact form on a #0b0b0b background. Inputs have #0b0b0b background, 1px solid #212121 border, 3px border-radius, 8px 12px padding, and #b9b9b9 placeholder text. Focus state shows a 2px blue (#0052ef) ring. Submit button is a full-width coral-red (#f36458) pill. Include a 13px #797979 helper text below each field." + +**Navigation bar:** +"Create a sticky top navigation on #0b0b0b with backdrop blur. Left: brand text in 15px white. Center/right: nav links in 16px #b9b9b9 that hover to blue. Far right: a coral-red (#f36458) pill CTA button. Bottom border: 1px solid #212121." + +### Iteration Guide +1. **Start dark**: Begin with `#0b0b0b` background, `#ffffff` primary text, `#b9b9b9` secondary text +2. **Add structure**: Use `#212121` surfaces and `#353535` borders for containment -- no shadows +3. **Apply typography**: Inter (or Space Grotesk) with tight letter-spacing on headings, 1.50 line-height on body +4. **Color punctuation**: Add `#f36458` for CTAs and `#0052ef` for all hover/interactive states +5. **Refine spacing**: 8px base unit, 24-32px within sections, 64-120px between sections +6. **Technical details**: Add IBM Plex Mono uppercase labels for tags and metadata +7. **Polish**: Ensure all interactive elements hover to `#0052ef`, all buttons are pills or subtle 5px radius, borders are hairline (1px) diff --git a/skills/creative/popular-web-designs/templates/sentry.md b/skills/creative/popular-web-designs/templates/sentry.md new file mode 100644 index 000000000000..113ff3f1d19c --- /dev/null +++ b/skills/creative/popular-web-designs/templates/sentry.md @@ -0,0 +1,275 @@ +# Design System: Sentry + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Rubik` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Rubik', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Sentry's website is a dark-mode-first developer tool interface that speaks the language of code editors and terminal windows. The entire aesthetic is rooted in deep purple-black backgrounds (`#1f1633`, `#150f23`) that evoke the late-night debugging sessions Sentry was built for. Against this inky canvas, a carefully curated set of purples, pinks, and a distinctive lime-green accent (`#c2ef4e`) create a visual system that feels simultaneously technical and vibrant. + +The typography pairing is deliberate: "Dammit Sans" appears at hero scale (88px, weight 700) as a display font with personality and attitude that matches Sentry's irreverent brand voice ("Code breaks. Fix it faster."), while Rubik serves as the workhorse UI font across all functional text — headings, body, buttons, captions, and navigation. Monaco provides the monospace layer for code snippets and technical content, completing the developer-tool trinity. + +What makes Sentry distinctive is its embrace of the "dark IDE" aesthetic without feeling cold or sterile. Warm purple tones replace the typical cool grays of developer tools, and bold illustrative elements (3D characters, colorful product screenshots) punctuate the dark canvas. The button system uses a signature muted purple (`#79628c`) with inset shadows that creates a tactile, almost physical quality — buttons feel like they could be pressed into the surface. + +**Key Characteristics:** +- Dark purple-black backgrounds (`#1f1633`, `#150f23`) — never pure black +- Warm purple accent spectrum: from deep (`#362d59`) through mid (`#79628c`, `#6a5fc1`) to vibrant (`#422082`) +- Lime-green accent (`#c2ef4e`) for high-visibility CTAs and highlights +- Pink/coral accents (`#ffb287`, `#fa7faa`) for focus states and secondary highlights +- "Dammit Sans" display font for brand personality at hero scale +- Rubik as primary UI font with uppercase letter-spaced labels +- Monaco monospace for code elements +- Inset shadows on buttons creating tactile depth +- Frosted glass effects with `blur(18px) saturate(180%)` + +## 2. Color Palette & Roles + +### Primary Brand +- **Deep Purple** (`#1f1633`): Primary background, the defining color of the brand +- **Darker Purple** (`#150f23`): Deeper sections, footer, secondary backgrounds +- **Border Purple** (`#362d59`): Borders, dividers, subtle structural lines + +### Accent Colors +- **Sentry Purple** (`#6a5fc1`): Primary interactive color — links, hover states, focus rings +- **Muted Purple** (`#79628c`): Button backgrounds, secondary interactive elements +- **Deep Violet** (`#422082`): Select dropdowns, active states, high-emphasis surfaces +- **Lime Green** (`#c2ef4e`): High-visibility accent, special links, badge highlights +- **Coral** (`#ffb287`): Focus state backgrounds, warm accent +- **Pink** (`#fa7faa`): Focus outlines, decorative accents + +### Text Colors +- **Pure White** (`#ffffff`): Primary text on dark backgrounds +- **Light Gray** (`#e5e7eb`): Secondary text, muted content +- **Code Yellow** (`#dcdcaa`): Syntax highlighting, code tokens + +### Surface & Overlay +- **Glass White** (`rgba(255, 255, 255, 0.18)`): Frosted glass button backgrounds +- **Glass Dark** (`rgba(54, 22, 107, 0.14)`): Hover overlay on glass elements +- **Input White** (`#ffffff`): Form input backgrounds (light context) +- **Input Border** (`#cfcfdb`): Form field borders + +### Shadows +- **Ambient Glow** (`rgba(22, 15, 36, 0.9) 0px 4px 4px 9px`): Deep purple ambient shadow +- **Button Hover** (`rgba(0, 0, 0, 0.18) 0px 0.5rem 1.5rem`): Elevated hover state +- **Card Shadow** (`rgba(0, 0, 0, 0.1) 0px 10px 15px -3px`): Standard card elevation +- **Inset Button** (`rgba(0, 0, 0, 0.1) 0px 1px 3px 0px inset`): Tactile pressed effect + +## 3. Typography Rules + +### Font Families +- **Display**: `Dammit Sans` — brand personality font for hero headings +- **Primary UI**: `Rubik`, with fallbacks: `-apple-system, system-ui, Segoe UI, Helvetica, Arial` +- **Monospace**: `Monaco`, with fallbacks: `Menlo, Ubuntu Mono` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Dammit Sans | 88px (5.50rem) | 700 | 1.20 (tight) | normal | Maximum impact, brand voice | +| Display Secondary | Dammit Sans | 60px (3.75rem) | 500 | 1.10 (tight) | normal | Secondary hero text | +| Section Heading | Rubik | 30px (1.88rem) | 400 | 1.20 (tight) | normal | Major section titles | +| Sub-heading | Rubik | 27px (1.69rem) | 500 | 1.25 (tight) | normal | Feature section headers | +| Card Title | Rubik | 24px (1.50rem) | 500 | 1.25 (tight) | normal | Card and block headings | +| Feature Title | Rubik | 20px (1.25rem) | 600 | 1.25 (tight) | normal | Emphasized feature names | +| Body | Rubik | 16px (1.00rem) | 400 | 1.50 | normal | Standard body text | +| Body Emphasis | Rubik | 16px (1.00rem) | 500–600 | 1.50 | normal | Bold body, nav items | +| Nav Label | Rubik | 15px (0.94rem) | 500 | 1.40 | normal | Navigation links | +| Uppercase Label | Rubik | 15px (0.94rem) | 500 | 1.25 (tight) | normal | `text-transform: uppercase` | +| Button Text | Rubik | 14px (0.88rem) | 500–700 | 1.14–1.29 (tight) | 0.2px | `text-transform: uppercase` | +| Caption | Rubik | 14px (0.88rem) | 500–700 | 1.00–1.43 | 0.2px | Often uppercase | +| Small Caption | Rubik | 12px (0.75rem) | 600 | 2.00 (relaxed) | normal | Subtle annotations | +| Micro Label | Rubik | 10px (0.63rem) | 600 | 1.80 (relaxed) | 0.25px | `text-transform: uppercase` | +| Code | Monaco | 16px (1.00rem) | 400–700 | 1.50 | normal | Code blocks, technical text | + +### Principles +- **Dual personality**: Dammit Sans brings irreverent brand character at display scale; Rubik provides clean professionalism for everything functional. +- **Uppercase as system**: Buttons, captions, labels, and micro-text all use `text-transform: uppercase` with subtle letter-spacing (0.2px–0.25px), creating a systematic "technical label" pattern throughout. +- **Weight stratification**: Rubik uses 400 (body), 500 (emphasis/nav), 600 (titles/strong), 700 (buttons/CTAs) — a clean four-tier weight system. +- **Tight headings, relaxed body**: All headings use 1.10–1.25 line-height; body uses 1.50; small captions expand to 2.00 for readability at tiny sizes. + +## 4. Component Stylings + +### Buttons + +**Primary Muted Purple** +- Background: `#79628c` (rgb(121, 98, 140)) +- Text: `#ffffff`, uppercase, 14px, weight 500–700, letter-spacing 0.2px +- Border: `1px solid #584674` +- Radius: 13px +- Shadow: `rgba(0, 0, 0, 0.1) 0px 1px 3px 0px inset` (tactile inset) +- Hover: elevated shadow `rgba(0, 0, 0, 0.18) 0px 0.5rem 1.5rem` + +**Glass White** +- Background: `rgba(255, 255, 255, 0.18)` (frosted glass) +- Text: `#ffffff` +- Padding: 8px +- Radius: 12px (left-aligned variant: `12px 0px 0px 12px`) +- Shadow: `rgba(0, 0, 0, 0.08) 0px 2px 8px` +- Hover background: `rgba(54, 22, 107, 0.14)` +- Use: Secondary actions on dark surfaces + +**White Solid** +- Background: `#ffffff` +- Text: `#1f1633` +- Padding: 12px 16px +- Radius: 8px +- Hover: background transitions to `#6a5fc1`, text to white +- Focus: background `#ffb287` (coral), outline `rgb(106, 95, 193) solid 0.125rem` +- Use: High-visibility CTA on dark backgrounds + +**Deep Violet (Select/Dropdown)** +- Background: `#422082` +- Text: `#ffffff` +- Padding: 8px 16px +- Radius: 8px + +### Inputs + +**Text Input** +- Background: `#ffffff` +- Text: `#1f1633` +- Border: `1px solid #cfcfdb` +- Padding: 8px 12px +- Radius: 6px +- Focus: border-color stays `#cfcfdb`, shadow `rgba(0, 0, 0, 0.15) 0px 2px 10px inset` + +### Links +- **Default on dark**: `#ffffff`, underline decoration +- **Hover**: color transitions to `#6a5fc1` (Sentry Purple) +- **Purple links**: `#6a5fc1` default, hover underline +- **Lime accent links**: `#c2ef4e` default, hover to `#6a5fc1` +- **Dark context links**: `#362d59`, hover to `#ffffff` + +### Cards & Containers +- Background: semi-transparent or dark purple surfaces +- Radius: 8px–12px +- Shadow: `rgba(0, 0, 0, 0.1) 0px 10px 15px -3px` +- Backdrop filter: `blur(18px) saturate(180%)` for glass effects + +### Navigation +- Dark transparent header over hero content +- Rubik 15px weight 500 for nav links +- White text, hover to Sentry Purple (`#6a5fc1`) +- Uppercase labels with 0.2px letter-spacing for categories +- Mobile: hamburger menu, full-width expanded + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 5px, 6px, 8px, 12px, 16px, 24px, 32px, 40px, 44px, 45px, 47px + +### Grid & Container +- Max content width: 1152px (XL breakpoint) +- Responsive padding: 2rem (mobile) → 4rem (tablet+) +- Content centered within container +- Full-width dark sections with contained inner content + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | < 576px | Single column, stacked layout | +| Small Tablet | 576–640px | Minor width adjustments | +| Tablet | 640–768px | 2-column begins | +| Small Desktop | 768–992px | Full nav visible | +| Desktop | 992–1152px | Standard layout | +| Large Desktop | 1152–1440px | Max-width content | + +### Whitespace Philosophy +- **Dark breathing room**: Generous vertical spacing between sections (64px–80px+) lets the dark background serve as a visual rest. +- **Content islands**: Feature sections are self-contained blocks floating in the dark purple sea, each with its own internal spacing rhythm. +- **Asymmetric padding**: Buttons use asymmetric padding patterns (12px 16px, 8px 12px) that feel organic rather than rigid. + +### Border Radius Scale +- Minimal (6px): Form inputs, small interactive elements +- Standard (8px): Buttons, cards, containers +- Comfortable (10px–12px): Larger containers, glass panels +- Rounded (13px): Primary muted buttons +- Pill (18px): Image containers, badges + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Sunken (Level -1) | Inset shadow `rgba(0, 0, 0, 0.1) 0px 1px 3px inset` | Primary buttons (tactile pressed feel) | +| Flat (Level 0) | No shadow | Default surfaces, dark backgrounds | +| Surface (Level 1) | `rgba(0, 0, 0, 0.08) 0px 2px 8px` | Glass buttons, subtle cards | +| Elevated (Level 2) | `rgba(0, 0, 0, 0.1) 0px 10px 15px -3px` | Cards, floating panels | +| Prominent (Level 3) | `rgba(0, 0, 0, 0.18) 0px 0.5rem 1.5rem` | Hover states, modals | +| Ambient (Level 4) | `rgba(22, 15, 36, 0.9) 0px 4px 4px 9px` | Deep purple ambient glow around hero | + +**Shadow Philosophy**: Sentry uses a unique combination of inset shadows (buttons feel pressed INTO the surface) and ambient glows (content radiates from the dark background). The deep purple ambient shadow (`rgba(22, 15, 36, 0.9)`) is the signature — it creates a bioluminescent quality where content seems to emit its own purple-tinted light. + +## 7. Do's and Don'ts + +### Do +- Use deep purple backgrounds (`#1f1633`, `#150f23`) — never pure black (`#000000`) +- Apply inset shadows on primary buttons for the tactile pressed effect +- Use Dammit Sans ONLY for hero/display headings — Rubik for everything else +- Apply `text-transform: uppercase` with `letter-spacing: 0.2px` on buttons and labels +- Use the lime-green accent (`#c2ef4e`) sparingly for maximum impact +- Employ frosted glass effects (`blur(18px) saturate(180%)`) for layered surfaces +- Maintain the warm purple shadow tones — shadows should feel purple-tinted, not neutral gray +- Use Rubik's 4-tier weight system: 400 (body), 500 (nav/emphasis), 600 (titles), 700 (CTAs) + +### Don't +- Don't use pure black (`#000000`) for backgrounds — always use the warm purple-blacks +- Don't apply Dammit Sans to body text or UI elements — it's display-only +- Don't use standard gray (`#666`, `#999`) for borders — use purple-tinted grays (`#362d59`, `#584674`) +- Don't drop the uppercase treatment on buttons — it's a system-wide pattern +- Don't use sharp corners (0px radius) — minimum 6px for all interactive elements +- Don't mix the lime-green accent with the coral/pink accents in the same component +- Don't use flat (non-inset) shadows on primary buttons — the tactile quality is signature +- Don't forget letter-spacing on uppercase text — 0.2px minimum + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <576px | Single column, hamburger nav, stacked CTAs | +| Tablet | 576–768px | 2-column feature grids begin | +| Small Desktop | 768–992px | Full navigation, side-by-side layouts | +| Desktop | 992–1152px | Max-width container, full layout | +| Large | >1152px | Content max-width maintained, generous margins | + +### Collapsing Strategy +- Hero text: 88px Dammit Sans → 60px → mobile scales +- Navigation: horizontal → hamburger with slide-out +- Feature sections: side-by-side → stacked cards +- Buttons: inline → full-width stacked on mobile +- Container padding: 4rem → 2rem + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: `#1f1633` (primary), `#150f23` (deeper) +- Text: `#ffffff` (primary), `#e5e7eb` (secondary) +- Interactive: `#6a5fc1` (links/hover), `#79628c` (buttons) +- Accent: `#c2ef4e` (lime highlight), `#ffb287` (coral focus) +- Border: `#362d59` (dark), `#cfcfdb` (light context) + +### Example Component Prompts +- "Create a hero section on deep purple background (#1f1633). Headline at 88px Dammit Sans weight 700, line-height 1.20, white text. Sub-text at 16px Rubik weight 400, line-height 1.50. White solid CTA button (8px radius, 12px 16px padding), hover transitions to #6a5fc1." +- "Design a navigation bar: transparent over dark background. Rubik 15px weight 500, white text. Uppercase category labels with 0.2px letter-spacing. Hover color #6a5fc1." +- "Build a primary button: background #79628c, border 1px solid #584674, inset shadow rgba(0,0,0,0.1) 0px 1px 3px, white uppercase text at 14px Rubik weight 700, letter-spacing 0.2px, radius 13px. Hover: shadow rgba(0,0,0,0.18) 0px 0.5rem 1.5rem." +- "Create a glass card panel: background rgba(255,255,255,0.18), backdrop-filter blur(18px) saturate(180%), radius 12px. White text content inside." +- "Design a feature section: #150f23 background, 24px Rubik weight 500 heading, 16px Rubik weight 400 body text. 14px uppercase lime-green (#c2ef4e) label above heading." + +### Iteration Guide +1. Always start with the dark purple background — the color palette is built FOR dark mode +2. Use inset shadows on buttons, ambient purple glows on hero sections +3. Uppercase + letter-spacing is the systematic pattern for labels, buttons, and captions +4. Lime green (#c2ef4e) is the "pop" color — use once per section maximum +5. Frosted glass for overlaid panels, solid purple for primary surfaces +6. Rubik handles 90% of typography — Dammit Sans is hero-only diff --git a/skills/creative/popular-web-designs/templates/spacex.md b/skills/creative/popular-web-designs/templates/spacex.md new file mode 100644 index 000000000000..4d62bf6a4fa6 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/spacex.md @@ -0,0 +1,207 @@ +# Design System: SpaceX + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +SpaceX's website is a full-screen cinematic experience that treats aerospace engineering like a film — every section is a scene, every photograph is a frame, and the interface disappears entirely behind the imagery. The design is pure black (`#000000`) with photography of rockets, space, and planets occupying 100% of the viewport. Text overlays sit directly on these photographs with no background panels, cards, or containers — just type on image, bold and unapologetic. + +The typography system uses D-DIN, an industrial geometric typeface with DIN heritage (the German industrial standard). The defining characteristic is that virtually ALL text is uppercase with positive letter-spacing (0.96px–1.17px), creating a military/aerospace labeling system where every word feels stenciled onto a spacecraft hull. D-DIN-Bold at 48px with uppercase and 0.96px tracking for the hero creates headlines that feel like mission briefing titles. Even body text at 16px maintains the uppercase/tracked treatment at smaller scales. + +What makes SpaceX distinctive is its radical minimalism: no shadows, no borders (except one ghost button border at `rgba(240,240,250,0.35)`), no color (only black and a spectral near-white `#f0f0fa`), no cards, no grids. The only visual element is photography + text. The ghost button with `rgba(240,240,250,0.1)` background and 32px radius is the sole interactive element — barely visible, floating over the imagery like a heads-up display. This isn't a design system in the traditional sense — it's a photographic exhibition with a type system and a single button. + +**Key Characteristics:** +- Pure black canvas with full-viewport cinematic photography — the interface is invisible +- D-DIN / D-DIN-Bold — industrial DIN-heritage typeface +- Universal uppercase + positive letter-spacing (0.96px–1.17px) — aerospace stencil aesthetic +- Near-white spectral text (`#f0f0fa`) — not pure white, a slight blue-violet tint +- Zero shadows, zero cards, zero containers — text on image only +- Single ghost button: `rgba(240,240,250,0.1)` background with spectral border +- Full-viewport sections — each section is a cinematic "scene" +- No decorative elements — every pixel serves the photography + +## 2. Color Palette & Roles + +### Primary +- **Space Black** (`#000000`): Page background, the void of space — at 50% opacity for overlay gradient +- **Spectral White** (`#f0f0fa`): Text color — not pure white, a slight blue-violet tint that mimics starlight + +### Interactive +- **Ghost Surface** (`rgba(240, 240, 250, 0.1)`): Button background — nearly invisible, 10% opacity +- **Ghost Border** (`rgba(240, 240, 250, 0.35)`): Button border — spectral, 35% opacity +- **Hover White** (`var(--white-100)`): Link hover state — full spectral white + +### Gradient +- **Dark Overlay** (`rgba(0, 0, 0, 0.5)`): Gradient overlay on photographs to ensure text legibility + +## 3. Typography Rules + +### Font Families +- **Display**: `D-DIN-Bold` — bold industrial geometric +- **Body / UI**: `D-DIN`, fallbacks: `Arial, Verdana` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | D-DIN-Bold | 48px (3.00rem) | 700 | 1.00 (tight) | 0.96px | `text-transform: uppercase` | +| Body | D-DIN | 16px (1.00rem) | 400 | 1.50–1.70 | normal | Standard reading text | +| Nav Link Bold | D-DIN | 13px (0.81rem) | 700 | 0.94 (tight) | 1.17px | `text-transform: uppercase` | +| Nav Link | D-DIN | 12px (0.75rem) | 400 | 2.00 (relaxed) | normal | `text-transform: uppercase` | +| Caption Bold | D-DIN | 13px (0.81rem) | 700 | 0.94 (tight) | 1.17px | `text-transform: uppercase` | +| Caption | D-DIN | 12px (0.75rem) | 400 | 1.00 (tight) | normal | `text-transform: uppercase` | +| Micro | D-DIN | 10px (0.63rem) | 400 | 0.94 (tight) | 1px | `text-transform: uppercase` | + +### Principles +- **Universal uppercase**: Nearly every text element uses `text-transform: uppercase`. This creates a systematic military/aerospace voice where all communication feels like official documentation. +- **Positive letter-spacing as identity**: 0.96px on display, 1.17px on nav — the wide tracking creates the stenciled, industrial feel that connects to DIN's heritage as a German engineering standard. +- **Two weights, strict hierarchy**: D-DIN-Bold (700) for headlines and nav emphasis, D-DIN (400) for body. No medium or semibold weights exist in the system. +- **Tight line-heights**: 0.94–1.00 across most text — compressed, efficient, mission-critical communication. + +## 4. Component Stylings + +### Buttons + +**Ghost Button** +- Background: `rgba(240, 240, 250, 0.1)` (barely visible) +- Text: Spectral White (`#f0f0fa`) +- Padding: 18px +- Radius: 32px +- Border: `1px solid rgba(240, 240, 250, 0.35)` +- Hover: background brightens, text to `var(--white-100)` +- Use: The only button variant — "LEARN MORE" CTAs on photography + +### Cards & Containers +- **None.** SpaceX does not use cards, panels, or containers. All content is text directly on full-viewport photographs. The absence of containers IS the design. + +### Inputs & Forms +- Not present on the homepage. The site is purely presentational. + +### Navigation +- Transparent overlay nav on photography +- D-DIN 13px weight 700, uppercase, 1.17px tracking +- Spectral white text on dark imagery +- Logo: SpaceX wordmark at 147x19px +- Mobile: hamburger collapse + +### Image Treatment +- Full-viewport (100vh) photography sections +- Professional aerospace photography: rockets, Mars, space +- Dark gradient overlays (`rgba(0,0,0,0.5)`) for text legibility +- Each section = one full-screen photograph with text overlay +- No border radius, no frames — edge-to-edge imagery + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 3px, 5px, 12px, 15px, 18px, 20px, 24px, 30px +- Minimal scale — spacing is not the organizing principle; photography is + +### Grid & Container +- No traditional grid — each section is a full-viewport cinematic frame +- Text is positioned absolutely or with generous padding over imagery +- Left-aligned text blocks on photography backgrounds +- No max-width container — content bleeds to viewport edges + +### Whitespace Philosophy +- **Photography IS the whitespace**: Empty space in the design is never empty — it's filled with the dark expanse of space, the curve of a planet, or the flame of a rocket engine. Traditional whitespace concepts don't apply. +- **Vertical pacing through viewport**: Each section is exactly one viewport tall, creating a rhythmic scroll where each "page" reveals a new scene. + +### Border Radius Scale +- Sharp (4px): Small dividers, utility elements +- Button (32px): Ghost buttons — the only rounded element + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Photography (Level 0) | Full-viewport imagery | Background layer — always present | +| Overlay (Level 1) | `rgba(0, 0, 0, 0.5)` gradient | Text legibility layer over photography | +| Text (Level 2) | Spectral white text, no shadow | Content layer — text floats directly on image | +| Ghost (Level 3) | `rgba(240, 240, 250, 0.1)` surface | Barely-visible interactive layer | + +**Shadow Philosophy**: SpaceX uses ZERO shadows. In a design built entirely on photography, shadows are meaningless — every surface is already a photograph with natural lighting. Depth comes from the photographic content itself: the receding curvature of Earth, the diminishing trail of a rocket, the atmospheric haze around Mars. + +## 7. Do's and Don'ts + +### Do +- Use full-viewport photography as the primary design element — every section is a scene +- Apply uppercase + positive letter-spacing to ALL text — the aerospace stencil voice +- Use D-DIN exclusively — no other fonts exist in the system +- Keep the color palette to black + spectral white (`#f0f0fa`) only +- Use ghost buttons (`rgba(240,240,250,0.1)`) as the sole interactive element +- Apply dark gradient overlays for text legibility on photographs +- Let photography carry the emotional weight — the type system is functional, not expressive + +### Don't +- Don't add cards, panels, or containers — text sits directly on photography +- Don't use shadows — they have no meaning in a photographic context +- Don't introduce colors — the palette is strictly achromatic with spectral tint +- Don't use sentence case — everything is uppercase +- Don't use negative letter-spacing — all tracking is positive (0.96px–1.17px) +- Don't reduce photography to thumbnails — every image is full-viewport +- Don't add decorative elements (icons, badges, dividers) — the design is photography + type + one button + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <600px | Stacked, reduced padding, smaller type | +| Tablet Small | 600–960px | Adjusted layout | +| Tablet | 960–1280px | Standard scaling | +| Desktop | 1280–1350px | Full layout | +| Large Desktop | 1350–1500px | Expanded | +| Ultra-wide | >1500px | Maximum viewport | + +### Touch Targets +- Ghost buttons: 18px padding provides adequate touch area +- Navigation links: uppercase with generous letter-spacing aids readability + +### Collapsing Strategy +- Photography: maintains full-viewport at all sizes, content reposition +- Hero text: 48px → scales down proportionally +- Navigation: horizontal → hamburger +- Text blocks: reposition but maintain overlay-on-photography pattern +- Full-viewport sections maintained on mobile + +### Image Behavior +- Edge-to-edge photography at all viewport sizes +- Background-size: cover with center focus +- Dark overlay gradients adapt to content position +- No art direction changes — same photographs, responsive positioning + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Space Black (`#000000`) +- Text: Spectral White (`#f0f0fa`) +- Button background: Ghost (`rgba(240, 240, 250, 0.1)`) +- Button border: Ghost Border (`rgba(240, 240, 250, 0.35)`) +- Overlay: `rgba(0, 0, 0, 0.5)` + +### Example Component Prompts +- "Create a full-viewport hero: background-image covering 100vh, dark gradient overlay rgba(0,0,0,0.5). Headline at 48px D-DIN-Bold, uppercase, letter-spacing 0.96px, spectral white (#f0f0fa) text. Ghost CTA button: rgba(240,240,250,0.1) bg, 1px solid rgba(240,240,250,0.35) border, 32px radius, 18px padding." +- "Design a navigation: transparent over photography. D-DIN 13px weight 700, uppercase, letter-spacing 1.17px, spectral white text. SpaceX wordmark left-aligned." +- "Build a content section: full-viewport height, background photography with dark overlay. Left-aligned text block with 48px D-DIN-Bold uppercase heading, 16px D-DIN body text, and ghost button below." +- "Create a micro label: D-DIN 10px, uppercase, letter-spacing 1px, spectral white, line-height 0.94." + +### Iteration Guide +1. Start with photography — the image IS the design +2. All text is uppercase with positive letter-spacing — no exceptions +3. Only two colors: black and spectral white (#f0f0fa) +4. Ghost buttons are the only interactive element — transparent, spectral-bordered +5. Zero shadows, zero cards, zero decorative elements +6. Every section is full-viewport (100vh) — cinematic pacing diff --git a/skills/creative/popular-web-designs/templates/spotify.md b/skills/creative/popular-web-designs/templates/spotify.md new file mode 100644 index 000000000000..7cfa4547b949 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/spotify.md @@ -0,0 +1,259 @@ +# Design System: Spotify + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Spotify's web interface is a dark, immersive music player that wraps listeners in a near-black cocoon (`#121212`, `#181818`, `#1f1f1f`) where album art and content become the primary source of color. The design philosophy is "content-first darkness" — the UI recedes into shadow so that music, podcasts, and playlists can glow. Every surface is a shade of charcoal, creating a theater-like environment where the only true color comes from the iconic Spotify Green (`#1ed760`) and the album artwork itself. + +The typography uses SpotifyMixUI and SpotifyMixUITitle — proprietary fonts from the CircularSp family (Circular by Lineto, customized for Spotify) with an extensive fallback stack that includes Arabic, Hebrew, Cyrillic, Greek, Devanagari, and CJK fonts, reflecting Spotify's global reach. The type system is compact and functional: 700 (bold) for emphasis and navigation, 600 (semibold) for secondary emphasis, and 400 (regular) for body. Buttons use uppercase with positive letter-spacing (1.4px–2px) for a systematic, label-like quality. + +What distinguishes Spotify is its pill-and-circle geometry. Primary buttons use 500px–9999px radius (full pill), circular play buttons use 50% radius, and search inputs are 500px pills. Combined with heavy shadows (`rgba(0,0,0,0.5) 0px 8px 24px`) on elevated elements and a unique inset border-shadow combo (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`), the result is an interface that feels like a premium audio device — tactile, rounded, and built for touch. + +**Key Characteristics:** +- Near-black immersive dark theme (`#121212`–`#1f1f1f`) — UI disappears behind content +- Spotify Green (`#1ed760`) as singular brand accent — never decorative, always functional +- SpotifyMixUI/CircularSp font family with global script support +- Pill buttons (500px–9999px) and circular controls (50%) — rounded, touch-optimized +- Uppercase button labels with wide letter-spacing (1.4px–2px) +- Heavy shadows on elevated elements (`rgba(0,0,0,0.5) 0px 8px 24px`) +- Semantic colors: negative red (`#f3727f`), warning orange (`#ffa42b`), announcement blue (`#539df5`) +- Album art as the primary color source — the UI is achromatic by design + +## 2. Color Palette & Roles + +### Primary Brand +- **Spotify Green** (`#1ed760`): Primary brand accent — play buttons, active states, CTAs +- **Near Black** (`#121212`): Deepest background surface +- **Dark Surface** (`#181818`): Cards, containers, elevated surfaces +- **Mid Dark** (`#1f1f1f`): Button backgrounds, interactive surfaces + +### Text +- **White** (`#ffffff`): `--text-base`, primary text +- **Silver** (`#b3b3b3`): Secondary text, muted labels, inactive nav +- **Near White** (`#cbcbcb`): Slightly brighter secondary text +- **Light** (`#fdfdfd`): Near-pure white for maximum emphasis + +### Semantic +- **Negative Red** (`#f3727f`): `--text-negative`, error states +- **Warning Orange** (`#ffa42b`): `--text-warning`, warning states +- **Announcement Blue** (`#539df5`): `--text-announcement`, info states + +### Surface & Border +- **Dark Card** (`#252525`): Elevated card surface +- **Mid Card** (`#272727`): Alternate card surface +- **Border Gray** (`#4d4d4d`): Button borders on dark +- **Light Border** (`#7c7c7c`): Outlined button borders, muted links +- **Separator** (`#b3b3b3`): Divider lines +- **Light Surface** (`#eeeeee`): Light-mode buttons (rare) +- **Spotify Green Border** (`#1db954`): Green accent border variant + +### Shadows +- **Heavy** (`rgba(0,0,0,0.5) 0px 8px 24px`): Dialogs, menus, elevated panels +- **Medium** (`rgba(0,0,0,0.3) 0px 8px 8px`): Cards, dropdowns +- **Inset Border** (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`): Input border-shadow combo + +## 3. Typography Rules + +### Font Families +- **Title**: `SpotifyMixUITitle`, fallbacks: `CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, Helvetica Neue, helvetica, arial, Hiragino Sans, Hiragino Kaku Gothic ProN, Meiryo, MS Gothic` +- **UI / Body**: `SpotifyMixUI`, same fallback stack + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Section Title | SpotifyMixUITitle | 24px (1.50rem) | 700 | normal | normal | Bold title weight | +| Feature Heading | SpotifyMixUI | 18px (1.13rem) | 600 | 1.30 (tight) | normal | Semibold section heads | +| Body Bold | SpotifyMixUI | 16px (1.00rem) | 700 | normal | normal | Emphasized text | +| Body | SpotifyMixUI | 16px (1.00rem) | 400 | normal | normal | Standard body | +| Button Uppercase | SpotifyMixUI | 14px (0.88rem) | 600–700 | 1.00 (tight) | 1.4px–2px | `text-transform: uppercase` | +| Button | SpotifyMixUI | 14px (0.88rem) | 700 | normal | 0.14px | Standard button | +| Nav Link Bold | SpotifyMixUI | 14px (0.88rem) | 700 | normal | normal | Navigation | +| Nav Link | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Inactive nav | +| Caption Bold | SpotifyMixUI | 14px (0.88rem) | 700 | 1.50–1.54 | normal | Bold metadata | +| Caption | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Metadata | +| Small Bold | SpotifyMixUI | 12px (0.75rem) | 700 | 1.50 | normal | Tags, counts | +| Small | SpotifyMixUI | 12px (0.75rem) | 400 | normal | normal | Fine print | +| Badge | SpotifyMixUI | 10.5px (0.66rem) | 600 | 1.33 | normal | `text-transform: capitalize` | +| Micro | SpotifyMixUI | 10px (0.63rem) | 400 | normal | normal | Smallest text | + +### Principles +- **Bold/regular binary**: Most text is either 700 (bold) or 400 (regular), with 600 used sparingly. This creates a clear visual hierarchy through weight contrast rather than size variation. +- **Uppercase buttons as system**: Button labels use uppercase + wide letter-spacing (1.4px–2px), creating a systematic "label" voice distinct from content text. +- **Compact sizing**: The range is 10px–24px — narrower than most systems. Spotify's type is compact and functional, designed for scanning playlists, not reading articles. +- **Global script support**: The extensive fallback stack (Arabic, Hebrew, Cyrillic, Greek, Devanagari, CJK) reflects Spotify's 180+ market reach. + +## 4. Component Stylings + +### Buttons + +**Dark Pill** +- Background: `#1f1f1f` +- Text: `#ffffff` or `#b3b3b3` +- Padding: 8px 16px +- Radius: 9999px (full pill) +- Use: Navigation pills, secondary actions + +**Dark Large Pill** +- Background: `#181818` +- Text: `#ffffff` +- Padding: 0px 43px +- Radius: 500px +- Use: Primary app navigation buttons + +**Light Pill** +- Background: `#eeeeee` +- Text: `#181818` +- Radius: 500px +- Use: Light-mode CTAs (cookie consent, marketing) + +**Outlined Pill** +- Background: transparent +- Text: `#ffffff` +- Border: `1px solid #7c7c7c` +- Padding: 4px 16px 4px 36px (asymmetric for icon) +- Radius: 9999px +- Use: Follow buttons, secondary actions + +**Circular Play** +- Background: `#1f1f1f` +- Text: `#ffffff` +- Padding: 12px +- Radius: 50% (circle) +- Use: Play/pause controls + +### Cards & Containers +- Background: `#181818` or `#1f1f1f` +- Radius: 6px–8px +- No visible borders on most cards +- Hover: slight background lightening +- Shadow: `rgba(0,0,0,0.3) 0px 8px 8px` on elevated + +### Inputs +- Search input: `#1f1f1f` background, `#ffffff` text +- Radius: 500px (pill) +- Padding: 12px 96px 12px 48px (icon-aware) +- Focus: border becomes `#000000`, outline `1px solid` + +### Navigation +- Dark sidebar with SpotifyMixUI 14px weight 700 for active, 400 for inactive +- `#b3b3b3` muted color for inactive items, `#ffffff` for active +- Circular icon buttons (50% radius) +- Spotify logo top-left in green + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 15px, 16px, 20px + +### Grid & Container +- Sidebar (fixed) + main content area +- Grid-based album/playlist cards +- Full-width now-playing bar at bottom +- Responsive content area fills remaining space + +### Whitespace Philosophy +- **Dark compression**: Spotify packs content densely — playlist grids, track lists, and navigation are all tightly spaced. The dark background provides visual rest between elements without needing large gaps. +- **Content density over breathing room**: This is an app, not a marketing site. Every pixel serves the listening experience. + +### Border Radius Scale +- Minimal (2px): Badges, explicit tags +- Subtle (4px): Inputs, small elements +- Standard (6px): Album art containers, cards +- Comfortable (8px): Sections, dialogs +- Medium (10px–20px): Panels, overlay elements +- Large (100px): Large pill buttons +- Pill (500px): Primary buttons, search input +- Full Pill (9999px): Navigation pills, search +- Circle (50%): Play buttons, avatars, icons + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Base (Level 0) | `#121212` background | Deepest layer, page background | +| Surface (Level 1) | `#181818` or `#1f1f1f` | Cards, sidebar, containers | +| Elevated (Level 2) | `rgba(0,0,0,0.3) 0px 8px 8px` | Dropdown menus, hover cards | +| Dialog (Level 3) | `rgba(0,0,0,0.5) 0px 8px 24px` | Modals, overlays, menus | +| Inset (Border) | `rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset` | Input borders | + +**Shadow Philosophy**: Spotify uses notably heavy shadows for a dark-themed app. The 0.5 opacity shadow at 24px blur creates a dramatic "floating in darkness" effect for dialogs and menus, while the 0.3 opacity at 8px blur provides a more subtle card lift. The unique inset border-shadow combination on inputs creates a recessed, tactile quality. + +## 7. Do's and Don'ts + +### Do +- Use near-black backgrounds (`#121212`–`#1f1f1f`) — depth through shade variation +- Apply Spotify Green (`#1ed760`) only for play controls, active states, and primary CTAs +- Use pill shape (500px–9999px) for all buttons — circular (50%) for play controls +- Apply uppercase + wide letter-spacing (1.4px–2px) on button labels +- Keep typography compact (10px–24px range) — this is an app, not a magazine +- Use heavy shadows (`0.3–0.5 opacity`) for elevated elements on dark backgrounds +- Let album art provide color — the UI itself is achromatic + +### Don't +- Don't use Spotify Green decoratively or on backgrounds — it's functional only +- Don't use light backgrounds for primary surfaces — the dark immersion is core +- Don't skip the pill/circle geometry on buttons — square buttons break the identity +- Don't use thin/subtle shadows — on dark backgrounds, shadows need to be heavy to be visible +- Don't add additional brand colors — green + achromatic grays is the complete palette +- Don't use relaxed line-heights — Spotify's typography is compact and dense +- Don't expose raw gray borders — use shadow-based or inset borders instead + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <425px | Compact mobile layout | +| Mobile | 425–576px | Standard mobile | +| Tablet | 576–768px | 2-column grid | +| Tablet Large | 768–896px | Expanded layout | +| Desktop Small | 896–1024px | Sidebar visible | +| Desktop | 1024–1280px | Full desktop layout | +| Large Desktop | >1280px | Expanded grid | + +### Collapsing Strategy +- Sidebar: full → collapsed → hidden +- Album grid: 5 columns → 3 → 2 → 1 +- Now-playing bar: maintained at all sizes +- Search: pill input maintained, width adjusts +- Navigation: sidebar → bottom bar on mobile + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Near Black (`#121212`) +- Surface: Dark Card (`#181818`) +- Text: White (`#ffffff`) +- Secondary text: Silver (`#b3b3b3`) +- Accent: Spotify Green (`#1ed760`) +- Border: `#4d4d4d` +- Error: Negative Red (`#f3727f`) + +### Example Component Prompts +- "Create a dark card: #181818 background, 8px radius. Title at 16px SpotifyMixUI weight 700, white text. Subtitle at 14px weight 400, #b3b3b3. Shadow rgba(0,0,0,0.3) 0px 8px 8px on hover." +- "Design a pill button: #1f1f1f background, white text, 9999px radius, 8px 16px padding. 14px SpotifyMixUI weight 700, uppercase, letter-spacing 1.4px." +- "Build a circular play button: Spotify Green (#1ed760) background, #000000 icon, 50% radius, 12px padding." +- "Create search input: #1f1f1f background, white text, 500px radius, 12px 48px padding. Inset border: rgb(124,124,124) 0px 0px 0px 1px inset." +- "Design navigation sidebar: #121212 background. Active items: 14px weight 700, white. Inactive: 14px weight 400, #b3b3b3." + +### Iteration Guide +1. Start with #121212 — everything lives in near-black darkness +2. Spotify Green for functional highlights only (play, active, CTA) +3. Pill everything — 500px for large, 9999px for small, 50% for circular +4. Uppercase + wide tracking on buttons — the systematic label voice +5. Heavy shadows (0.3–0.5 opacity) for elevation — light shadows are invisible on dark +6. Album art provides all the color — the UI stays achromatic diff --git a/skills/creative/popular-web-designs/templates/stripe.md b/skills/creative/popular-web-designs/templates/stripe.md new file mode 100644 index 000000000000..12296387091b --- /dev/null +++ b/skills/creative/popular-web-designs/templates/stripe.md @@ -0,0 +1,335 @@ +# Design System: Stripe + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Source Sans 3` | **Mono:** `Source Code Pro` +> - **Font stack (CSS):** `font-family: 'Source Sans 3', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Source Code Pro', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Stripe's website is the gold standard of fintech design -- a system that manages to feel simultaneously technical and luxurious, precise and warm. The page opens on a clean white canvas (`#ffffff`) with deep navy headings (`#061b31`) and a signature purple (`#533afd`) that functions as both brand anchor and interactive accent. This isn't the cold, clinical purple of enterprise software; it's a rich, saturated violet that reads as confident and premium. The overall impression is of a financial institution redesigned by a world-class type foundry. + +The custom `sohne-var` variable font is the defining element of Stripe's visual identity. Every text element enables the OpenType `"ss01"` stylistic set, which modifies character shapes for a distinctly geometric, modern feel. At display sizes (48px-56px), sohne-var runs at weight 300 -- an extraordinarily light weight for headlines that creates an ethereal, almost whispered authority. This is the opposite of the "bold hero headline" convention; Stripe's headlines feel like they don't need to shout. The negative letter-spacing (-1.4px at 56px, -0.96px at 48px) tightens the text into dense, engineered blocks. At smaller sizes, the system also uses weight 300 with proportionally reduced tracking, and tabular numerals via `"tnum"` for financial data display. + +What truly distinguishes Stripe is its shadow system. Rather than the flat or single-layer approach of most sites, Stripe uses multi-layer, blue-tinted shadows: the signature `rgba(50,50,93,0.25)` combined with `rgba(0,0,0,0.1)` creates shadows with a cool, almost atmospheric depth -- like elements are floating in a twilight sky. The blue-gray undertone of the primary shadow color (50,50,93) ties directly to the navy-purple brand palette, making even elevation feel on-brand. + +**Key Characteristics:** +- sohne-var with OpenType `"ss01"` on all text -- a custom stylistic set that defines the brand's letterforms +- Weight 300 as the signature headline weight -- light, confident, anti-convention +- Negative letter-spacing at display sizes (-1.4px at 56px, progressive relaxation downward) +- Blue-tinted multi-layer shadows using `rgba(50,50,93,0.25)` -- elevation that feels brand-colored +- Deep navy (`#061b31`) headings instead of black -- warm, premium, financial-grade +- Conservative border-radius (4px-8px) -- nothing pill-shaped, nothing harsh +- Ruby (`#ea2261`) and magenta (`#f96bee`) accents for gradient and decorative elements +- `SourceCodePro` as the monospace companion for code and technical labels + +## 2. Color Palette & Roles + +### Primary +- **Stripe Purple** (`#533afd`): Primary brand color, CTA backgrounds, link text, interactive highlights. A saturated blue-violet that anchors the entire system. +- **Deep Navy** (`#061b31`): `--hds-color-heading-solid`. Primary heading color. Not black, not gray -- a very dark blue that adds warmth and depth to text. +- **Pure White** (`#ffffff`): Page background, card surfaces, button text on dark backgrounds. + +### Brand & Dark +- **Brand Dark** (`#1c1e54`): `--hds-color-util-brand-900`. Deep indigo for dark sections, footer backgrounds, and immersive brand moments. +- **Dark Navy** (`#0d253d`): `--hds-color-core-neutral-975`. The darkest neutral -- almost-black with a blue undertone for maximum depth without harshness. + +### Accent Colors +- **Ruby** (`#ea2261`): `--hds-color-accentColorMode-ruby-icon-solid`. Warm red-pink for icons, alerts, and accent elements. +- **Magenta** (`#f96bee`): `--hds-color-accentColorMode-magenta-icon-gradientMiddle`. Vivid pink-purple for gradients and decorative highlights. +- **Magenta Light** (`#ffd7ef`): `--hds-color-util-accent-magenta-100`. Tinted surface for magenta-themed cards and badges. + +### Interactive +- **Primary Purple** (`#533afd`): Primary link color, active states, selected elements. +- **Purple Hover** (`#4434d4`): Darker purple for hover states on primary elements. +- **Purple Deep** (`#2e2b8c`): `--hds-color-button-ui-iconHover`. Dark purple for icon hover states. +- **Purple Light** (`#b9b9f9`): `--hds-color-action-bg-subduedHover`. Soft lavender for subdued hover backgrounds. +- **Purple Mid** (`#665efd`): `--hds-color-input-selector-text-range`. Range selector and input highlight color. + +### Neutral Scale +- **Heading** (`#061b31`): Primary headings, nav text, strong labels. +- **Label** (`#273951`): `--hds-color-input-text-label`. Form labels, secondary headings. +- **Body** (`#64748d`): Secondary text, descriptions, captions. +- **Success Green** (`#15be53`): Status badges, success indicators (with 0.2-0.4 alpha for backgrounds/borders). +- **Success Text** (`#108c3d`): Success badge text color. +- **Lemon** (`#9b6829`): `--hds-color-core-lemon-500`. Warning and highlight accent. + +### Surface & Borders +- **Border Default** (`#e5edf5`): Standard border color for cards, dividers, and containers. +- **Border Purple** (`#b9b9f9`): Active/selected state borders on buttons and inputs. +- **Border Soft Purple** (`#d6d9fc`): Subtle purple-tinted borders for secondary elements. +- **Border Magenta** (`#ffd7ef`): Pink-tinted borders for magenta-themed elements. +- **Border Dashed** (`#362baa`): Dashed borders for drop zones and placeholder elements. + +### Shadow Colors +- **Shadow Blue** (`rgba(50,50,93,0.25)`): The signature -- blue-tinted primary shadow color. +- **Shadow Dark Blue** (`rgba(3,3,39,0.25)`): Deeper blue shadow for elevated elements. +- **Shadow Black** (`rgba(0,0,0,0.1)`): Secondary shadow layer for depth reinforcement. +- **Shadow Ambient** (`rgba(23,23,23,0.08)`): Soft ambient shadow for subtle elevation. +- **Shadow Soft** (`rgba(23,23,23,0.06)`): Minimal ambient shadow for light lift. + +## 3. Typography Rules + +### Font Family +- **Primary**: `sohne-var`, with fallback: `SF Pro Display` +- **Monospace**: `SourceCodePro`, with fallback: `SFMono-Regular` +- **OpenType Features**: `"ss01"` enabled globally on all sohne-var text; `"tnum"` for tabular numbers on financial data and captions. + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Features | Notes | +|------|------|------|--------|-------------|----------------|----------|-------| +| Display Hero | sohne-var | 56px (3.50rem) | 300 | 1.03 (tight) | -1.4px | ss01 | Maximum size, whisper-weight authority | +| Display Large | sohne-var | 48px (3.00rem) | 300 | 1.15 (tight) | -0.96px | ss01 | Secondary hero headlines | +| Section Heading | sohne-var | 32px (2.00rem) | 300 | 1.10 (tight) | -0.64px | ss01 | Feature section titles | +| Sub-heading Large | sohne-var | 26px (1.63rem) | 300 | 1.12 (tight) | -0.26px | ss01 | Card headings, sub-sections | +| Sub-heading | sohne-var | 22px (1.38rem) | 300 | 1.10 (tight) | -0.22px | ss01 | Smaller section heads | +| Body Large | sohne-var | 18px (1.13rem) | 300 | 1.40 | normal | ss01 | Feature descriptions, intro text | +| Body | sohne-var | 16px (1.00rem) | 300-400 | 1.40 | normal | ss01 | Standard reading text | +| Button | sohne-var | 16px (1.00rem) | 400 | 1.00 (tight) | normal | ss01 | Primary button text | +| Button Small | sohne-var | 14px (0.88rem) | 400 | 1.00 (tight) | normal | ss01 | Secondary/compact buttons | +| Link | sohne-var | 14px (0.88rem) | 400 | 1.00 (tight) | normal | ss01 | Navigation links | +| Caption | sohne-var | 13px (0.81rem) | 400 | normal | normal | ss01 | Small labels, metadata | +| Caption Small | sohne-var | 12px (0.75rem) | 300-400 | 1.33-1.45 | normal | ss01 | Fine print, timestamps | +| Caption Tabular | sohne-var | 12px (0.75rem) | 300-400 | 1.33 | -0.36px | tnum | Financial data, numbers | +| Micro | sohne-var | 10px (0.63rem) | 300 | 1.15 (tight) | 0.1px | ss01 | Tiny labels, axis markers | +| Micro Tabular | sohne-var | 10px (0.63rem) | 300 | 1.15 (tight) | -0.3px | tnum | Chart data, small numbers | +| Nano | sohne-var | 8px (0.50rem) | 300 | 1.07 (tight) | normal | ss01 | Smallest labels | +| Code Body | SourceCodePro | 12px (0.75rem) | 500 | 2.00 (relaxed) | normal | -- | Code blocks, syntax | +| Code Bold | SourceCodePro | 12px (0.75rem) | 700 | 2.00 (relaxed) | normal | -- | Bold code, keywords | +| Code Label | SourceCodePro | 12px (0.75rem) | 500 | 2.00 (relaxed) | normal | uppercase | Technical labels | +| Code Micro | SourceCodePro | 9px (0.56rem) | 500 | 1.00 (tight) | normal | ss01 | Tiny code annotations | + +### Principles +- **Light weight as signature**: Weight 300 at display sizes is Stripe's most distinctive typographic choice. Where others use 600-700 to command attention, Stripe uses lightness as luxury -- the text is so confident it doesn't need weight to be authoritative. +- **ss01 everywhere**: The `"ss01"` stylistic set is non-negotiable. It modifies specific glyphs (likely alternate `a`, `g`, `l` forms) to create a more geometric, contemporary feel across all sohne-var text. +- **Two OpenType modes**: `"ss01"` for display/body text, `"tnum"` for tabular numerals in financial data. These never overlap -- a number in a paragraph uses ss01, a number in a data table uses tnum. +- **Progressive tracking**: Letter-spacing tightens proportionally with size: -1.4px at 56px, -0.96px at 48px, -0.64px at 32px, -0.26px at 26px, normal at 16px and below. +- **Two-weight simplicity**: Primarily 300 (body and headings) and 400 (UI/buttons). No bold (700) in the primary font -- SourceCodePro uses 500/700 for code contrast. + +## 4. Component Stylings + +### Buttons + +**Primary Purple** +- Background: `#533afd` +- Text: `#ffffff` +- Padding: 8px 16px +- Radius: 4px +- Font: 16px sohne-var weight 400, `"ss01"` +- Hover: `#4434d4` background +- Use: Primary CTA ("Start now", "Contact sales") + +**Ghost / Outlined** +- Background: transparent +- Text: `#533afd` +- Padding: 8px 16px +- Radius: 4px +- Border: `1px solid #b9b9f9` +- Font: 16px sohne-var weight 400, `"ss01"` +- Hover: background shifts to `rgba(83,58,253,0.05)` +- Use: Secondary actions + +**Transparent Info** +- Background: transparent +- Text: `#2874ad` +- Padding: 8px 16px +- Radius: 4px +- Border: `1px solid rgba(43,145,223,0.2)` +- Use: Tertiary/info-level actions + +**Neutral Ghost** +- Background: transparent (`rgba(255,255,255,0)`) +- Text: `rgba(16,16,16,0.3)` +- Padding: 8px 16px +- Radius: 4px +- Outline: `1px solid rgb(212,222,233)` +- Use: Disabled or muted actions + +### Cards & Containers +- Background: `#ffffff` +- Border: `1px solid #e5edf5` (standard) or `1px solid #061b31` (dark accent) +- Radius: 4px (tight), 5px (standard), 6px (comfortable), 8px (featured) +- Shadow (standard): `rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px` +- Shadow (ambient): `rgba(23,23,23,0.08) 0px 15px 35px 0px` +- Hover: shadow intensifies, often adding the blue-tinted layer + +### Badges / Tags / Pills +**Neutral Pill** +- Background: `#ffffff` +- Text: `#000000` +- Padding: 0px 6px +- Radius: 4px +- Border: `1px solid #f6f9fc` +- Font: 11px weight 400 + +**Success Badge** +- Background: `rgba(21,190,83,0.2)` +- Text: `#108c3d` +- Padding: 1px 6px +- Radius: 4px +- Border: `1px solid rgba(21,190,83,0.4)` +- Font: 10px weight 300 + +### Inputs & Forms +- Border: `1px solid #e5edf5` +- Radius: 4px +- Focus: `1px solid #533afd` or purple ring +- Label: `#273951`, 14px sohne-var +- Text: `#061b31` +- Placeholder: `#64748d` + +### Navigation +- Clean horizontal nav on white, sticky with blur backdrop +- Brand logotype left-aligned +- Links: sohne-var 14px weight 400, `#061b31` text with `"ss01"` +- Radius: 6px on nav container +- CTA: purple button right-aligned ("Sign in", "Start now") +- Mobile: hamburger toggle with 6px radius + +### Decorative Elements +**Dashed Borders** +- `1px dashed #362baa` (purple) for placeholder/drop zones +- `1px dashed #ffd7ef` (magenta) for magenta-themed decorative borders + +**Gradient Accents** +- Ruby-to-magenta gradients (`#ea2261` to `#f96bee`) for hero decorations +- Brand dark sections use `#1c1e54` backgrounds with white text + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 6px, 8px, 10px, 11px, 12px, 14px, 16px, 18px, 20px +- Notable: The scale is dense at the small end (every 2px from 4-12), reflecting Stripe's precision-oriented UI for financial data + +### Grid & Container +- Max content width: approximately 1080px +- Hero: centered single-column with generous padding, lightweight headlines +- Feature sections: 2-3 column grids for feature cards +- Full-width dark sections with `#1c1e54` background for brand immersion +- Code/dashboard previews as contained cards with blue-tinted shadows + +### Whitespace Philosophy +- **Precision spacing**: Unlike the vast emptiness of minimalist systems, Stripe uses measured, purposeful whitespace. Every gap is a deliberate typographic choice. +- **Dense data, generous chrome**: Financial data displays (tables, charts) are tightly packed, but the UI chrome around them is generously spaced. This creates a sense of controlled density -- like a well-organized spreadsheet in a beautiful frame. +- **Section rhythm**: White sections alternate with dark brand sections (`#1c1e54`), creating a dramatic light/dark cadence that prevents monotony without introducing arbitrary color. + +### Border Radius Scale +- Micro (1px): Fine-grained elements, subtle rounding +- Standard (4px): Buttons, inputs, badges, cards -- the workhorse +- Comfortable (5px): Standard card containers +- Relaxed (6px): Navigation, larger interactive elements +- Large (8px): Featured cards, hero elements +- Compound: `0px 0px 6px 6px` for bottom-rounded containers (tab panels, dropdown footers) + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page background, inline text | +| Ambient (Level 1) | `rgba(23,23,23,0.06) 0px 3px 6px` | Subtle card lift, hover hints | +| Standard (Level 2) | `rgba(23,23,23,0.08) 0px 15px 35px` | Standard cards, content panels | +| Elevated (Level 3) | `rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px` | Featured cards, dropdowns, popovers | +| Deep (Level 4) | `rgba(3,3,39,0.25) 0px 14px 21px -14px, rgba(0,0,0,0.1) 0px 8px 17px -8px` | Modals, floating panels | +| Ring (Accessibility) | `2px solid #533afd` outline | Keyboard focus ring | + +**Shadow Philosophy**: Stripe's shadow system is built on a principle of chromatic depth. Where most design systems use neutral gray or black shadows, Stripe's primary shadow color (`rgba(50,50,93,0.25)`) is a deep blue-gray that echoes the brand's navy palette. This creates shadows that don't just add depth -- they add brand atmosphere. The multi-layer approach pairs this blue-tinted shadow with a pure black secondary layer (`rgba(0,0,0,0.1)`) at a different offset, creating a parallax-like depth where the branded shadow sits farther from the element and the neutral shadow sits closer. The negative spread values (-30px, -18px) ensure shadows don't extend beyond the element's footprint horizontally, keeping elevation vertical and controlled. + +### Decorative Depth +- Dark brand sections (`#1c1e54`) create immersive depth through background color contrast +- Gradient overlays with ruby-to-magenta transitions for hero decorations +- Shadow color `rgba(0,55,112,0.08)` (`--hds-color-shadow-sm-top`) for top-edge shadows on sticky elements + +## 7. Do's and Don'ts + +### Do +- Use sohne-var with `"ss01"` on every text element -- the stylistic set IS the brand +- Use weight 300 for all headlines and body text -- lightness is the signature +- Apply blue-tinted shadows (`rgba(50,50,93,0.25)`) for all elevated elements +- Use `#061b31` (deep navy) for headings instead of `#000000` -- the warmth matters +- Keep border-radius between 4px-8px -- conservative rounding is intentional +- Use `"tnum"` for any tabular/financial number display +- Layer shadows: blue-tinted far + neutral close for depth parallax +- Use `#533afd` purple as the primary interactive/CTA color + +### Don't +- Don't use weight 600-700 for sohne-var headlines -- weight 300 is the brand voice +- Don't use large border-radius (12px+, pill shapes) on cards or buttons -- Stripe is conservative +- Don't use neutral gray shadows -- always tint with blue (`rgba(50,50,93,...)`) +- Don't skip `"ss01"` on any sohne-var text -- the alternate glyphs define the personality +- Don't use pure black (`#000000`) for headings -- always `#061b31` deep navy +- Don't use warm accent colors (orange, yellow) for interactive elements -- purple is primary +- Don't apply positive letter-spacing at display sizes -- Stripe tracks tight +- Don't use the magenta/ruby accents for buttons or links -- they're decorative/gradient only + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, reduced heading sizes, stacked cards | +| Tablet | 640-1024px | 2-column grids, moderate padding | +| Desktop | 1024-1280px | Full layout, 3-column feature grids | +| Large Desktop | >1280px | Centered content with generous margins | + +### Touch Targets +- Buttons use comfortable padding (8px-16px vertical) +- Navigation links at 14px with adequate spacing +- Badges have 6px horizontal padding minimum for tap targets +- Mobile nav toggle with 6px radius button + +### Collapsing Strategy +- Hero: 56px display -> 32px on mobile, weight 300 maintained +- Navigation: horizontal links + CTAs -> hamburger toggle +- Feature cards: 3-column -> 2-column -> single column stacked +- Dark brand sections: maintain full-width treatment, reduce internal padding +- Financial data tables: horizontal scroll on mobile +- Section spacing: 64px+ -> 40px on mobile +- Typography scale compresses: 56px -> 48px -> 32px hero sizes across breakpoints + +### Image Behavior +- Dashboard/product screenshots maintain blue-tinted shadow at all sizes +- Hero gradient decorations simplify on mobile +- Code blocks maintain `SourceCodePro` treatment, may horizontally scroll +- Card images maintain consistent 4px-6px border-radius + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Stripe Purple (`#533afd`) +- CTA Hover: Purple Dark (`#4434d4`) +- Background: Pure White (`#ffffff`) +- Heading text: Deep Navy (`#061b31`) +- Body text: Slate (`#64748d`) +- Label text: Dark Slate (`#273951`) +- Border: Soft Blue (`#e5edf5`) +- Link: Stripe Purple (`#533afd`) +- Dark section: Brand Dark (`#1c1e54`) +- Success: Green (`#15be53`) +- Accent decorative: Ruby (`#ea2261`), Magenta (`#f96bee`) + +### Example Component Prompts +- "Create a hero section on white background. Headline at 48px sohne-var weight 300, line-height 1.15, letter-spacing -0.96px, color #061b31, font-feature-settings 'ss01'. Subtitle at 18px weight 300, line-height 1.40, color #64748d. Purple CTA button (#533afd, 4px radius, 8px 16px padding, white text) and ghost button (transparent, 1px solid #b9b9f9, #533afd text, 4px radius)." +- "Design a card: white background, 1px solid #e5edf5 border, 6px radius. Shadow: rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px. Title at 22px sohne-var weight 300, letter-spacing -0.22px, color #061b31, 'ss01'. Body at 16px weight 300, #64748d." +- "Build a success badge: rgba(21,190,83,0.2) background, #108c3d text, 4px radius, 1px 6px padding, 10px sohne-var weight 300, border 1px solid rgba(21,190,83,0.4)." +- "Create navigation: white sticky header with backdrop-filter blur(12px). sohne-var 14px weight 400 for links, #061b31 text, 'ss01'. Purple CTA 'Start now' right-aligned (#533afd bg, white text, 4px radius). Nav container 6px radius." +- "Design a dark brand section: #1c1e54 background, white text. Headline 32px sohne-var weight 300, letter-spacing -0.64px, 'ss01'. Body 16px weight 300, rgba(255,255,255,0.7). Cards inside use rgba(255,255,255,0.1) border with 6px radius." + +### Iteration Guide +1. Always enable `font-feature-settings: "ss01"` on sohne-var text -- this is the brand's typographic DNA +2. Weight 300 is the default; use 400 only for buttons/links/navigation +3. Shadow formula: `rgba(50,50,93,0.25) 0px Y1 B1 -S1, rgba(0,0,0,0.1) 0px Y2 B2 -S2` where Y1/B1 are larger (far shadow) and Y2/B2 are smaller (near shadow) +4. Heading color is `#061b31` (deep navy), body is `#64748d` (slate), labels are `#273951` (dark slate) +5. Border-radius stays in the 4px-8px range -- never use pill shapes or large rounding +6. Use `"tnum"` for any numbers in tables, charts, or financial displays +7. Dark sections use `#1c1e54` -- not black, not gray, but a deep branded indigo +8. SourceCodePro for code at 12px/500 with 2.00 line-height (very generous for readability) diff --git a/skills/creative/popular-web-designs/templates/supabase.md b/skills/creative/popular-web-designs/templates/supabase.md new file mode 100644 index 000000000000..5e697b3647ba --- /dev/null +++ b/skills/creative/popular-web-designs/templates/supabase.md @@ -0,0 +1,268 @@ +# Design System: Supabase + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `Source Code Pro` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Source Code Pro', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Supabase's website is a dark-mode-native developer platform that channels the aesthetic of a premium code editor — deep black backgrounds (`#0f0f0f`, `#171717`) with emerald green accents (`#3ecf8e`, `#00c573`) that reference the brand's open-source, PostgreSQL-green identity. The design system feels like it was born in a terminal window and evolved into a sophisticated marketing surface without losing its developer soul. + +The typography is built on "Circular" — a geometric sans-serif with rounded terminals that softens the technical edge. At 72px with a 1.00 line-height, the hero text is compressed to its absolute minimum vertical space, creating dense, impactful statements that waste nothing. The monospace companion (Source Code Pro) appears sparingly for uppercase technical labels with 1.2px letter-spacing, creating the "developer console" markers that connect the marketing site to the product experience. + +What makes Supabase distinctive is its sophisticated HSL-based color token system. Rather than flat hex values, Supabase uses HSL with alpha channels for nearly every color (`--colors-crimson4`, `--colors-purple5`, `--colors-slateA12`), enabling a nuanced layering system where colors interact through transparency. This creates depth through translucency — borders at `rgba(46, 46, 46)`, surfaces at `rgba(41, 41, 41, 0.84)`, and accents at partial opacity all blend with the dark background to create a rich, dimensional palette from minimal color ingredients. + +The green accent (`#3ecf8e`) appears selectively — in the Supabase logo, in link colors (`#00c573`), and in border highlights (`rgba(62, 207, 142, 0.3)`) — always as a signal of "this is Supabase" rather than as a decorative element. Pill-shaped buttons (9999px radius) for primary CTAs contrast with standard 6px radius for secondary elements, creating a clear visual hierarchy of importance. + +**Key Characteristics:** +- Dark-mode-native: near-black backgrounds (`#0f0f0f`, `#171717`) — never pure black +- Emerald green brand accent (`#3ecf8e`, `#00c573`) used sparingly as identity marker +- Circular font — geometric sans-serif with rounded terminals +- Source Code Pro for uppercase technical labels (1.2px letter-spacing) +- HSL-based color token system with alpha channels for translucent layering +- Pill buttons (9999px) for primary CTAs, 6px radius for secondary +- Neutral gray scale from `#171717` through `#898989` to `#fafafa` +- Border system using dark grays (`#2e2e2e`, `#363636`, `#393939`) +- Minimal shadows — depth through border contrast and transparency +- Radix color primitives (crimson, purple, violet, indigo, yellow, tomato, orange, slate) + +## 2. Color Palette & Roles + +### Brand +- **Supabase Green** (`#3ecf8e`): Primary brand color, logo, accent borders +- **Green Link** (`#00c573`): Interactive green for links and actions +- **Green Border** (`rgba(62, 207, 142, 0.3)`): Subtle green border accent + +### Neutral Scale (Dark Mode) +- **Near Black** (`#0f0f0f`): Primary button background, deepest surface +- **Dark** (`#171717`): Page background, primary canvas +- **Dark Border** (`#242424`): Horizontal rule, section dividers +- **Border Dark** (`#2e2e2e`): Card borders, tab borders +- **Mid Border** (`#363636`): Button borders, dividers +- **Border Light** (`#393939`): Secondary borders +- **Charcoal** (`#434343`): Tertiary borders, dark accents +- **Dark Gray** (`#4d4d4d`): Heavy secondary text +- **Mid Gray** (`#898989`): Muted text, link color +- **Light Gray** (`#b4b4b4`): Secondary link text +- **Near White** (`#efefef`): Light border, subtle surface +- **Off White** (`#fafafa`): Primary text, button text + +### Radix Color Tokens (HSL-based) +- **Slate Scale**: `--colors-slate5` through `--colors-slateA12` — neutral progression +- **Purple**: `--colors-purple4`, `--colors-purple5`, `--colors-purpleA7` — accent spectrum +- **Violet**: `--colors-violet10` (`hsl(251, 63.2%, 63.2%)`) — vibrant accent +- **Crimson**: `--colors-crimson4`, `--colors-crimsonA9` — warm accent / alert +- **Indigo**: `--colors-indigoA2` — subtle blue wash +- **Yellow**: `--colors-yellowA7` — attention/warning +- **Tomato**: `--colors-tomatoA4` — error accent +- **Orange**: `--colors-orange6` — warm accent + +### Surface & Overlay +- **Glass Dark** (`rgba(41, 41, 41, 0.84)`): Translucent dark overlay +- **Slate Alpha** (`hsla(210, 87.8%, 16.1%, 0.031)`): Ultra-subtle blue wash +- **Fixed Scale Alpha** (`hsla(200, 90.3%, 93.4%, 0.109)`): Light frost overlay + +### Shadows +- Supabase uses **almost no shadows** in its dark theme. Depth is created through border contrast and surface color differences rather than box-shadows. Focus states use `rgba(0, 0, 0, 0.1) 0px 4px 12px` — minimal, functional. + +## 3. Typography Rules + +### Font Families +- **Primary**: `Circular`, with fallbacks: `custom-font, Helvetica Neue, Helvetica, Arial` +- **Monospace**: `Source Code Pro`, with fallbacks: `Office Code Pro, Menlo` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Circular | 72px (4.50rem) | 400 | 1.00 (tight) | normal | Maximum density, zero waste | +| Section Heading | Circular | 36px (2.25rem) | 400 | 1.25 (tight) | normal | Feature section titles | +| Card Title | Circular | 24px (1.50rem) | 400 | 1.33 | -0.16px | Slight negative tracking | +| Sub-heading | Circular | 18px (1.13rem) | 400 | 1.56 | normal | Secondary headings | +| Body | Circular | 16px (1.00rem) | 400 | 1.50 | normal | Standard body text | +| Nav Link | Circular | 14px (0.88rem) | 500 | 1.00–1.43 | normal | Navigation items | +| Button | Circular | 14px (0.88rem) | 500 | 1.14 (tight) | normal | Button labels | +| Caption | Circular | 14px (0.88rem) | 400–500 | 1.43 | normal | Metadata, tags | +| Small | Circular | 12px (0.75rem) | 400 | 1.33 | normal | Fine print, footer links | +| Code Label | Source Code Pro | 12px (0.75rem) | 400 | 1.33 | 1.2px | `text-transform: uppercase` | + +### Principles +- **Weight restraint**: Nearly all text uses weight 400 (regular/book). Weight 500 appears only for navigation links and button labels. There is no bold (700) in the detected system — hierarchy is created through size, not weight. +- **1.00 hero line-height**: The hero text is compressed to absolute zero leading. This is the defining typographic gesture — text that feels like a terminal command: dense, efficient, no wasted vertical space. +- **Negative tracking on cards**: Card titles use -0.16px letter-spacing, a subtle tightening that differentiates them from body text without being obvious. +- **Monospace as ritual**: Source Code Pro in uppercase with 1.2px letter-spacing is the "developer console" voice — used sparingly for technical labels that connect to the product experience. +- **Geometric personality**: Circular's rounded terminals create warmth in what could otherwise be a cold, technical interface. The font is the humanizing element. + +## 4. Component Stylings + +### Buttons + +**Primary Pill (Dark)** +- Background: `#0f0f0f` +- Text: `#fafafa` +- Padding: 8px 32px +- Radius: 9999px (full pill) +- Border: `1px solid #fafafa` (white border on dark) +- Focus shadow: `rgba(0, 0, 0, 0.1) 0px 4px 12px` +- Use: Primary CTA ("Start your project") + +**Secondary Pill (Dark, Muted)** +- Background: `#0f0f0f` +- Text: `#fafafa` +- Padding: 8px 32px +- Radius: 9999px +- Border: `1px solid #2e2e2e` (dark border) +- Opacity: 0.8 +- Use: Secondary CTA alongside primary + +**Ghost Button** +- Background: transparent +- Text: `#fafafa` +- Padding: 8px +- Radius: 6px +- Border: `1px solid transparent` +- Use: Tertiary actions, icon buttons + +### Cards & Containers +- Background: dark surfaces (`#171717` or slightly lighter) +- Border: `1px solid #2e2e2e` or `#363636` +- Radius: 8px–16px +- No visible shadows — borders define edges +- Internal padding: 16px–24px + +### Tabs +- Border: `1px solid #2e2e2e` +- Radius: 9999px (pill tabs) +- Active: green accent or lighter surface +- Inactive: dark, muted + +### Links +- **Green**: `#00c573` — Supabase-branded links +- **Primary Light**: `#fafafa` — standard links on dark +- **Secondary**: `#b4b4b4` — muted links +- **Muted**: `#898989` — tertiary links, footer + +### Navigation +- Dark background matching page (`#171717`) +- Supabase logo with green icon +- Circular 14px weight 500 for nav links +- Clean horizontal layout with product dropdown +- Green "Start your project" CTA pill button +- Sticky header behavior + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 4px, 6px, 8px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 90px, 96px, 128px +- Notable large jumps: 48px → 90px → 96px → 128px for major section spacing + +### Grid & Container +- Centered content with generous max-width +- Full-width dark sections with constrained inner content +- Feature grids: icon-based grids with consistent card sizes +- Logo grids for "Trusted by" sections +- Footer: multi-column on dark background + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <600px | Single column, stacked layout | +| Desktop | >600px | Multi-column grids, expanded layout | + +*Note: Supabase uses a notably minimal breakpoint system — primarily a single 600px breakpoint, suggesting a mobile-first approach with progressive enhancement.* + +### Whitespace Philosophy +- **Dramatic section spacing**: 90px–128px between major sections creates a cinematic pacing — each section is its own scene in the dark void. +- **Dense content blocks**: Within sections, spacing is tight (16px–24px), creating concentrated information clusters. +- **Border-defined space**: Instead of whitespace + shadows for separation, Supabase uses thin borders on dark backgrounds — separation through line, not gap. + +### Border Radius Scale +- Standard (6px): Ghost buttons, small elements +- Comfortable (8px): Cards, containers +- Medium (11px–12px): Mid-size panels +- Large (16px): Feature cards, major containers +- Pill (9999px): Primary buttons, tab indicators + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, border `#2e2e2e` | Default state, most surfaces | +| Subtle Border (Level 1) | Border `#363636` or `#393939` | Interactive elements, hover | +| Focus (Level 2) | `rgba(0, 0, 0, 0.1) 0px 4px 12px` | Focus states only | +| Green Accent (Level 3) | Border `rgba(62, 207, 142, 0.3)` | Brand-highlighted elements | + +**Shadow Philosophy**: Supabase deliberately avoids shadows. In a dark-mode-native design, shadows are nearly invisible and serve no purpose. Instead, depth is communicated through a sophisticated border hierarchy — from `#242424` (barely visible) through `#2e2e2e` (standard) to `#393939` (prominent). The green accent border (`rgba(62, 207, 142, 0.3)`) at 30% opacity is the "elevated" state — the brand color itself becomes the depth signal. + +## 7. Do's and Don'ts + +### Do +- Use near-black backgrounds (`#0f0f0f`, `#171717`) — depth comes from the gray border hierarchy +- Apply Supabase green (`#3ecf8e`, `#00c573`) sparingly — it's an identity marker, not a decoration +- Use Circular at weight 400 for nearly everything — 500 only for buttons and nav +- Set hero text to 1.00 line-height — the zero-leading is the typographic signature +- Create depth through border color differences (`#242424` → `#2e2e2e` → `#363636`) +- Use pill shape (9999px) exclusively for primary CTAs and tabs +- Employ HSL-based colors with alpha for translucent layering effects +- Use Source Code Pro uppercase labels for developer-context markers + +### Don't +- Don't add box-shadows — they're invisible on dark backgrounds and break the border-defined depth system +- Don't use bold (700) text weight — the system uses 400 and 500 only +- Don't apply green to backgrounds or large surfaces — it's for borders, links, and small accents +- Don't use warm colors (crimson, orange) as primary design elements — they exist as semantic tokens for states +- Don't increase hero line-height above 1.00 — the density is intentional +- Don't use large border radius (16px+) on buttons — pills (9999px) or standard (6px), nothing in between +- Don't lighten the background above `#171717` for primary surfaces — the darkness is structural +- Don't forget the translucent borders — `rgba` border colors are the layering mechanism + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <600px | Single column, stacked features, condensed nav | +| Desktop | >600px | Multi-column grids, full nav, expanded sections | + +### Collapsing Strategy +- Hero: 72px → scales down proportionally +- Feature grids: multi-column → single column stacked +- Logo row: horizontal → wrapped grid +- Navigation: full → hamburger +- Section spacing: 90–128px → 48–64px +- Buttons: inline → full-width stacked + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: `#0f0f0f` (button), `#171717` (page) +- Text: `#fafafa` (primary), `#b4b4b4` (secondary), `#898989` (muted) +- Brand green: `#3ecf8e` (brand), `#00c573` (links) +- Borders: `#242424` (subtle), `#2e2e2e` (standard), `#363636` (prominent) +- Green border: `rgba(62, 207, 142, 0.3)` (accent) + +### Example Component Prompts +- "Create a hero section on #171717 background. Headline at 72px Circular weight 400, line-height 1.00, #fafafa text. Sub-text at 16px Circular weight 400, line-height 1.50, #b4b4b4. Pill CTA button (#0f0f0f bg, #fafafa text, 9999px radius, 8px 32px padding, 1px solid #fafafa border)." +- "Design a feature card: #171717 background, 1px solid #2e2e2e border, 16px radius. Title at 24px Circular weight 400, letter-spacing -0.16px. Body at 14px weight 400, #898989 text." +- "Build navigation bar: #171717 background. Circular 14px weight 500 for links, #fafafa text. Supabase logo with green icon left-aligned. Green pill CTA 'Start your project' right-aligned." +- "Create a technical label: Source Code Pro 12px, uppercase, letter-spacing 1.2px, #898989 text." +- "Design a framework logo grid: 6-column layout on dark, grayscale logos at 60% opacity, 1px solid #2e2e2e border between sections." + +### Iteration Guide +1. Start with #171717 background — everything is dark-mode-native +2. Green is the brand identity marker — use it for links, logo, and accent borders only +3. Depth comes from borders (#242424 → #2e2e2e → #363636), not shadows +4. Weight 400 is the default for everything — 500 only for interactive elements +5. Hero line-height of 1.00 is the signature typographic move +6. Pill (9999px) for primary actions, 6px for secondary, 8-16px for cards +7. HSL with alpha channels creates the sophisticated translucent layering diff --git a/skills/creative/popular-web-designs/templates/superhuman.md b/skills/creative/popular-web-designs/templates/superhuman.md new file mode 100644 index 000000000000..b3c4c318ee95 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/superhuman.md @@ -0,0 +1,265 @@ +# Design System: Superhuman + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Superhuman's website feels like opening a luxury envelope — predominantly white, immaculately clean, with a single dramatic gesture of color that commands attention. The hero section is a cinematic purple gradient, a deep twilight wash of `#1b1938` that evokes the moment just before dawn, overlaid with confident white typography. Below this dramatic entrance, the rest of the site is almost entirely white canvas with dark charcoal text, creating a stark but refined reading experience. + +The typography is the true signature: Super Sans VF, a custom variable font with unconventional weight stops (460, 540, 600, 700) that sit between traditional font weight categories. Weight 460 — slightly heavier than regular but lighter than medium — is the workhorse, creating text that feels more confident than typical 400-weight but never aggressive. The tight line-heights (0.96 on display text) compress headlines into dense, powerful blocks, while generous 1.50 line-height on body text provides airy readability. This tension between compressed power and breathing room defines the Superhuman typographic voice. + +The design philosophy is maximum confidence through minimum decoration. Warm cream buttons (`#e9e5dd`) instead of bright CTAs, a near-absence of borders and shadows, and lavender purple (`#cbb7fb`) as the sole accent color. It's a productivity tool that markets itself like a luxury brand — every pixel earns its place, nothing is merely decorative. The brand naming convention extends to colors: the primary purple is called "Mysteria," straddling blue and purple with deliberate ambiguity. + +**Key Characteristics:** +- Deep purple gradient hero (`#1b1938`) contrasting against a predominantly white content body +- Super Sans VF variable font with non-standard weight stops (460, 540, 600, 700) — sits between conventional weight categories +- Ultra-tight display line-height (0.96) creating compressed, powerful headlines +- Warm Cream (`#e9e5dd`) buttons instead of bright/saturated CTAs — understated luxury +- Lavender Purple (`#cbb7fb`) as the singular accent color — a soft, approachable purple +- Minimal border-radius scale: only 8px and 16px — no micro-rounding, no pill shapes +- Product screenshots dominate the content — the UI sells itself with minimal surrounding decoration + +## 2. Color Palette & Roles + +### Primary +- **Mysteria Purple** (`#1b1938`): Hero gradient background, deep purple that straddles blue-purple — the darkest expression of the brand +- **Lavender Glow** (`#cbb7fb`): Primary accent and highlight color — soft purple used for emphasis, decorative elements, and interactive highlights +- **Charcoal Ink** (`#292827`): Primary text and heading color on light surfaces — warm near-black with faint brown undertone + +### Secondary & Accent +- **Amethyst Link** (`#714cb6`): Underlined link text — mid-range purple that connects to the brand palette while signaling interactivity +- **Translucent White** (`color(srgb 1 1 1 / 0.95)`): Hero overlay text — near-white at 95% opacity for depth layering on dark surfaces +- **Misted White** (`color(srgb 1 1 1 / 0.8)`): Secondary text on dark surfaces — 80% opacity white for hierarchy on the hero gradient + +### Surface & Background +- **Pure White** (`#ffffff`): Primary page background — the dominant canvas color for all content sections +- **Warm Cream** (`#e9e5dd`): Button background — a warm, neutral cream that avoids the coldness of pure gray +- **Parchment Border** (`#dcd7d3`): Card and divider borders — warm light gray with slight pink undertone + +### Neutrals & Text +- **Charcoal Ink** (`#292827`): Primary heading and body text on white surfaces +- **Amethyst Link** (`#714cb6`): In-content links with underline decoration +- **Translucent White 95%** (`color(srgb 1 1 1 / 0.95)`): Primary text on dark/purple surfaces +- **Translucent White 80%** (`color(srgb 1 1 1 / 0.8)`): Secondary text on dark/purple surfaces + +### Semantic & Accent +- Superhuman operates with extreme color restraint — Lavender Glow (`#cbb7fb`) is the only true accent +- Interactive states are communicated through opacity shifts and underline decorations rather than color changes +- The warm cream button palette avoids any saturated semantic colors (no red errors, green success visible on marketing) + +### Gradient System +- **Hero Gradient**: Deep purple gradient starting from `#1b1938`, transitioning through purple-to-twilight tones across the hero section — the most dramatic visual element on the entire site +- **Content Transition**: The gradient dissolves into the white content area, creating a cinematic curtain-lift effect as the user scrolls +- No other gradients on the marketing site — the hero gradient is a singular dramatic gesture + +## 3. Typography Rules + +### Font Family +- **Display & Body**: `Super Sans VF` — custom variable font with non-standard weight axis. Fallbacks: `system-ui, -apple-system, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue` +- **Product UI** (referenced in brand): `Messina Sans` / `Messina Serif` / `Messina Mono` from Luzi Type — used in the product itself for sans-serif-to-serif transitions + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Super Sans VF | 64px | 540 | 0.96 | 0px | Maximum compression, powerful block headlines | +| Section Display | Super Sans VF | 48px | 460 | 0.96 | -1.32px | Lighter weight for section introductions | +| Section Heading | Super Sans VF | 48px | 460 | 0.96 | 0px | Alternate section heading without tracking | +| Feature Title | Super Sans VF | 28px | 540 | 1.14 | -0.63px | Feature block headlines, tighter | +| Sub-heading Large | Super Sans VF | 26px | 460 | 1.30 | 0px | Content sub-sections | +| Card Heading | Super Sans VF | 22px | 460 | 0.76 | -0.315px | Card title with extreme compression | +| Body Heading | Super Sans VF | 20px | 460 | 1.20 | 0px | Bold content intros | +| Body Heading Alt | Super Sans VF | 20px | 460 | 1.10 | -0.55px | Tighter variant for emphasis | +| Body Heading Relaxed | Super Sans VF | 20px | 460 | 1.25 | -0.4px | More breathing room variant | +| Emphasis Body | Super Sans VF | 18px | 540 | 1.50 | -0.135px | Medium-weight body for callouts | +| Body | Super Sans VF | 16px | 460 | 1.50 | 0px | Standard reading text — generous line-height | +| Button / UI Bold | Super Sans VF | 16px | 700 | 1.00 | 0px | Bold UI elements | +| Button / UI Semi | Super Sans VF | 16px | 600 | 1.00 | 0px | Semi-bold navigation and labels | +| Nav Link | Super Sans VF | 16px | 460 | 1.20 | 0px | Navigation items | +| Caption | Super Sans VF | 14px | 500 | 1.20 | -0.315px | Small labels, metadata | +| Caption Semi | Super Sans VF | 14px | 600 | 1.29 | 0px | Emphasized small text | +| Caption Body | Super Sans VF | 14px | 460 | 1.50 | 0px | Small body text | +| Micro Label | Super Sans VF | 12px | 700 | 1.50 | 0px | Smallest text — badges, tags | + +### Principles +- **Non-standard weight axis**: Weights 460 and 540 are deliberately between conventional Regular (400) and Medium (500), creating a typographic texture that feels subtly "off" in a confident way — slightly heavier than expected, never quite bold +- **Extreme display compression**: Display headlines at 0.96 line-height collapse lines nearly on top of each other, creating dense typographic blocks that feel architectural +- **Body generosity**: In contrast, body text at 1.50 line-height is extremely spacious, ensuring comfortable reading after the dense headline impact +- **Selective negative tracking**: Letter-spacing is applied surgically — -1.32px on 48px headings, -0.63px on 28px features, but 0px on body text. The larger the text, the tighter the tracking +- **Variable font efficiency**: A single font file serves all weight variations (460–700), enabling smooth weight transitions and micro-adjustments + +## 4. Component Stylings + +### Buttons +- **Warm Cream Primary**: `#e9e5dd` background, Charcoal Ink (`#292827`) text, subtle rounded corners (8px radius), no visible border. The signature CTA — warm, muted, luxurious rather than aggressive +- **Dark Primary** (on light sections): `#292827` background with white text, 8px radius — inverse of the warm cream for contrast sections +- **Ghost / Text Link**: No background, underline decoration, Amethyst Link (`#714cb6`) or Charcoal Ink color depending on context +- **Hero CTA**: Warm Cream on the dark purple gradient — the cream color pops dramatically against `#1b1938` +- **Hover**: Subtle opacity or brightness shift — no dramatic color transformations + +### Cards & Containers +- **Content Card**: White background, Parchment Border (`#dcd7d3`) 1px border, 16px border-radius — clean and minimal +- **Dark Surface Card**: `#292827` border on dark sections, maintaining warm-neutral tone +- **Hero Surface**: Semi-transparent white border (`rgba(255, 255, 255, 0.2)`) on purple gradient — ghostly containment +- **Product Screenshot Cards**: Large product UI images with clean edges, minimal framing — the product itself is the visual +- **Hover**: Minimal state changes — consistency and calm over flashy interactions + +### Inputs & Forms +- Minimal form presence on the marketing site — Superhuman funnels users directly to signup +- Dark-bordered inputs with Charcoal Ink borders and warm-toned placeholder text +- Focus: Border emphasis increase, likely shifting from Parchment Border to Charcoal Ink + +### Navigation +- **Top nav**: Clean white background on content sections, transparent on hero gradient +- **Nav links**: Super Sans VF at 16px, weight 460/600 for hierarchy +- **CTA button**: Warm Cream (`#e9e5dd`) pill in the nav — subtle, not attention-grabbing +- **Sticky behavior**: Nav remains fixed on scroll with background transition +- **Mobile**: Collapses to hamburger menu with simplified layout + +### Image Treatment +- **Product screenshots**: Large, dominant product UI images showing the email interface — the product is the hero +- **Lifestyle photography**: A single dramatic image (silhouette against purple/red gradient) in the hero area — cinematic and editorial +- **Full-width presentation**: Screenshots span full container width with subtle shadow or no border +- **Aspect ratios**: Wide landscape ratios (roughly 16:9) for product screenshots +- **Color integration**: Screenshots are carefully color-graded to harmonize with the purple-to-white page flow + +### Testimonial / Social Proof +- "Your Superhuman suite" section with product feature grid +- Feature descriptions paired with product screenshots — proof through demonstration rather than quotes +- Clean grid layout with consistent card sizing + +## 5. Layout Principles + +### Spacing System +- **Base unit**: 8px +- **Scale**: 2px, 4px, 6px, 8px, 12px, 16px, 18px, 20px, 24px, 28px, 32px, 36px, 40px, 48px, 56px +- **Section padding**: 48px–80px vertical between major sections +- **Card padding**: 16px–32px internal spacing +- **Component gaps**: 8px–16px between related elements + +### Grid & Container +- **Max width**: ~1200px content container, centered +- **Column patterns**: Full-width hero, centered single-column for key messaging, 2-3 column grid for feature cards +- **Feature grid**: Even column distribution for "Your Superhuman suite" product showcase + +### Whitespace Philosophy +- **Confident emptiness**: Generous whitespace between sections signals premium positioning — every element has room to breathe +- **Product as content**: Large product screenshots fill space that lesser sites would fill with marketing copy +- **Progressive density**: The hero is spacious and cinematic, content sections become denser with feature grids, then opens up again for CTAs + +### Border Radius Scale +- **8px**: Buttons, inline elements (`span`, `button`, `div`) — the universal small radius +- **16px**: Cards, links, larger containers (`a`, card elements) — the universal large radius +- Only two radii in the entire system — radical simplicity. No micro-rounding (2px), no pill shapes (50px+) + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Level 0 (Flat) | No shadow, white background | Primary page canvas, most content surfaces | +| Level 1 (Border) | `1px solid #dcd7d3` (Parchment Border) | Card containment, section dividers | +| Level 2 (Dark Border) | `1px solid #292827` | Header elements, dark section separators | +| Level 3 (Glow) | Subtle shadow (from 6 shadow definitions detected) | Product screenshot containers, elevated cards | +| Level 4 (Hero Depth) | `rgba(255, 255, 255, 0.2)` transparent border | Elements on the dark purple gradient hero | + +### Shadow Philosophy +Superhuman's elevation system is remarkably restrained on the marketing site. Depth is primarily communicated through: +- **Border containment**: Warm-toned borders (`#dcd7d3`) at 1px create gentle separation +- **Color contrast**: The hero gradient creates massive depth through color shift rather than shadows +- **Product screenshots**: Screenshots themselves create depth by showing a layered UI within the flat page +- **Opacity layering**: Semi-transparent whites on the hero gradient create atmospheric depth layers + +### Decorative Depth +- **Hero gradient**: The `#1b1938` → white gradient transition is the primary depth device — a cinematic curtain effect +- **Lavender accents**: `#cbb7fb` Lavender Glow elements float above the dark gradient, creating a stellar/atmospheric effect +- **No glassmorphism**: Despite the translucent borders, there are no blur/frosted-glass effects +- **Photography depth**: The hero silhouette image creates natural atmospheric depth without artificial CSS + +## 7. Do's and Don'ts + +### Do +- Use Super Sans VF at weight 460 as the default — it's slightly heavier than regular, which is the brand's typographic signature +- Keep display headlines at 0.96 line-height — the compression is intentional and powerful +- Use Warm Cream (`#e9e5dd`) for primary buttons — not white, not gray, specifically warm cream +- Limit border-radius to 8px (small) and 16px (large) — the binary radius system is deliberate +- Apply negative letter-spacing on headlines only (-0.63px to -1.32px) — body text stays at 0px +- Use Lavender Glow (`#cbb7fb`) as the only accent color — it's the sole color departure from the neutral palette +- Let product screenshots be the primary visual content — the UI sells itself +- Maintain the dramatic hero gradient as a singular gesture — the rest of the page is white + +### Don't +- Use conventional font weights (400, 500, 600) — Superhuman's 460 and 540 are deliberately between standard stops +- Add bright or saturated CTA colors (blue, green, red) — buttons are intentionally muted in Warm Cream or Charcoal +- Introduce additional accent colors beyond Lavender Glow — the palette is deliberately restrained to one accent +- Apply shadows generously — depth comes from borders, color contrast, and photography, not box-shadows +- Use tight line-height on body text — display is compressed (0.96) but body is generous (1.50) +- Add decorative elements, icons, or illustrations — Superhuman relies on product UI and minimal typography +- Create pill-shaped buttons — the system uses 8px radius, not rounded pills +- Use pure black (`#000000`) for text — Charcoal Ink (`#292827`) is warmer and softer + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <768px | Single column, hero text reduces to ~36px, stacked feature cards, hamburger nav | +| Tablet | 768px–1024px | 2-column feature grid begins, hero text ~48px, nav partially visible | +| Desktop | 1024px–1440px | Full layout, 64px hero display, multi-column feature grid, full nav | +| Large Desktop | >1440px | Max-width container centered, generous side margins | + +### Touch Targets +- Buttons: 8px radius with comfortable padding — meets touch target guidelines +- Nav links: 16px text with adequate surrounding padding +- Mobile CTAs: Full-width Warm Cream buttons for easy thumb reach +- Links: Underline decoration provides clear tap affordance + +### Collapsing Strategy +- **Navigation**: Full horizontal nav → hamburger menu on mobile +- **Hero text**: 64px display → 48px → ~36px across breakpoints +- **Feature grid**: Multi-column product showcase → 2-column → single stacked column +- **Product screenshots**: Scale within containers, maintaining landscape ratios +- **Section spacing**: Reduces proportionally — generous desktop margins compress on mobile + +### Image Behavior +- Product screenshots scale responsively while maintaining aspect ratios +- Hero silhouette image crops or scales — maintains dramatic composition +- No art direction changes — same compositions across all breakpoints +- Lazy loading likely on below-fold product screenshots + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Hero Background: Mysteria Purple (`#1b1938`) +- Primary Text (light bg): Charcoal Ink (`#292827`) +- Primary Text (dark bg): Translucent White (`color(srgb 1 1 1 / 0.95)` — use `rgba(255,255,255,0.95)`) +- Accent: Lavender Glow (`#cbb7fb`) +- Button Background: Warm Cream (`#e9e5dd`) +- Border: Parchment Border (`#dcd7d3`) +- Link: Amethyst Link (`#714cb6`) +- Page Background: Pure White (`#ffffff`) + +### Example Component Prompts +- "Create a hero section with deep purple gradient background (#1b1938), 64px Super Sans heading at weight 540, line-height 0.96, white text at 95% opacity, and a warm cream button (#e9e5dd, 8px radius, #292827 text)" +- "Design a feature card with white background, 1px #dcd7d3 border, 16px radius, 20px Super Sans heading at weight 460, and 16px body text at weight 460 with 1.50 line-height in #292827" +- "Build a navigation bar with white background, Super Sans links at 16px weight 460, a warm cream CTA button (#e9e5dd, 8px radius), sticky positioning" +- "Create a product showcase section with centered 48px heading (weight 460, -1.32px letter-spacing, #292827), a large product screenshot below, on white background" +- "Design an accent badge using Lavender Glow (#cbb7fb) background, 8px radius, 12px bold text (weight 700), for category labels" + +### Iteration Guide +When refining existing screens generated with this design system: +1. Verify font weight is 460 (not 400 or 500) for body and 540 for display — the non-standard weights are essential +2. Check that display line-height is 0.96 — if headlines look too spaced, they're wrong +3. Ensure buttons use Warm Cream (#e9e5dd) not pure white or gray — the warmth is subtle but critical +4. Confirm the only accent color is Lavender Glow (#cbb7fb) — no other hues should appear +5. The overall tone should feel like a luxury product presentation — minimal, confident, with one dramatic color gesture in the hero diff --git a/skills/creative/popular-web-designs/templates/together.ai.md b/skills/creative/popular-web-designs/templates/together.ai.md new file mode 100644 index 000000000000..581f592e4f18 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/together.ai.md @@ -0,0 +1,276 @@ +# Design System: Together AI + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Together AI's interface is a pastel-gradient dreamscape built for enterprise AI infrastructure — a design that somehow makes GPU clusters and model inference feel light, airy, and optimistic. The hero section blooms with soft pink-blue-lavender gradients and abstract, painterly illustrations that evoke clouds and flight, establishing a visual metaphor for the "AI-Native Cloud" proposition. Against this softness, the typography cuts through with precision: "The Future" display font at 64px with aggressive negative tracking (-1.92px) creates dense, authoritative headline blocks. + +The design straddles two worlds: a bright, white-canvas light side where pastel gradients and stats cards create an approachable platform overview, and a dark navy universe (`#010120` — not gray-black but a deep midnight blue) where research papers and technical content live. This dual-world approach elegantly separates the "business" messaging (light, friendly, stat-driven) from the "research" messaging (dark, serious, academic). + +What makes Together AI distinctive is its type system. "The Future" handles all display and body text with a geometric modernist aesthetic, while "PP Neue Montreal Mono" provides uppercase labels with meticulous letter-spacing — creating a "technical infrastructure company with taste" personality. The brand accents — magenta (`#ef2cc1`) and orange (`#fc4c02`) — appear sparingly in the gradient and illustrations, never polluting the clean UI. + +**Key Characteristics:** +- Soft pastel gradients (pink, blue, lavender) against pure white canvas +- Deep midnight blue (`#010120`) for dark/research sections — not gray-black +- Custom "The Future" font with aggressive negative letter-spacing throughout +- PP Neue Montreal Mono for uppercase technical labels +- Sharp geometry (4px, 8px radius) — not rounded, not pill +- Magenta (#ef2cc1) + orange (#fc4c02) brand accents in illustrations only +- Lavender (#bdbbff) as a soft secondary accent +- Enterprise stats prominently displayed (2x, 60%, 90%) +- Dark-blue-tinted shadows (rgba(1, 1, 32, 0.1)) + +## 2. Color Palette & Roles + +### Primary +- **Brand Magenta** (`#ef2cc1`): The primary brand accent — a vivid pink-magenta used in gradient illustrations and the highest-signal brand moments. Never used as UI chrome. +- **Brand Orange** (`#fc4c02`): The secondary brand accent — a vivid orange for gradient endpoints and warm accent moments. +- **Dark Blue** (`#010120`): The primary dark surface — a deep midnight blue-black used for research sections, footer, and dark containers. Not gray, not black — distinctly blue. + +### Secondary & Accent +- **Soft Lavender** (`#bdbbff`): A gentle blue-violet used for subtle accents, secondary indicators, and soft UI highlights. +- **Black 40** (`#00000066`): Semi-transparent black for de-emphasized overlays and secondary text. + +### Surface & Background +- **Pure White** (`#ffffff`): The primary light-section page background. +- **Dark Blue** (`#010120`): Dark-section backgrounds — research, footer, technical content. +- **Glass Light** (`rgba(255, 255, 255, 0.12)`): Frosted glass button backgrounds on dark sections. +- **Glass Dark** (`rgba(0, 0, 0, 0.08)`): Subtle tinted surfaces on light sections. + +### Neutrals & Text +- **Pure Black** (`#000000`): Primary text on light surfaces. +- **Pure White** (`#ffffff`): Primary text on dark surfaces. +- **Black 8%** (`rgba(0, 0, 0, 0.08)`): Borders and subtle containment on light surfaces. +- **White 12%** (`rgba(255, 255, 255, 0.12)`): Borders and containment on dark surfaces. + +### Gradient System +- **Pastel Cloud Gradient**: Soft pink → lavender → soft blue gradients in hero illustrations. These appear in abstract, painterly forms — clouds, feathers, flowing shapes — that create visual warmth without literal meaning. +- **Hero Gradient**: The hero background uses soft pastel tints layered over white, creating a dawn-like atmospheric effect. + +## 3. Typography Rules + +### Font Family +- **Primary**: `The Future`, with fallback: `Arial` +- **Monospace / Labels**: `PP Neue Montreal Mono`, with fallback: `Georgia` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | The Future | 64px (4rem) | 400–500 | 1.00–1.10 (tight) | -1.92px | Maximum impact, dense blocks | +| Section Heading | The Future | 40px (2.5rem) | 500 | 1.20 (tight) | -0.8px | Feature section titles | +| Sub-heading | The Future | 28px (1.75rem) | 500 | 1.15 (tight) | -0.42px | Card headings | +| Feature Title | The Future | 22px (1.38rem) | 500 | 1.15 (tight) | -0.22px | Small feature headings | +| Body Large | The Future | 18px (1.13rem) | 400–500 | 1.30 (tight) | -0.18px | Descriptions, sections | +| Body / Button | The Future | 16px (1rem) | 400–500 | 1.25–1.30 | -0.16px | Standard body, nav, buttons | +| Caption | The Future | 14px (0.88rem) | 400–500 | 1.40 | normal | Metadata, descriptions | +| Mono Label | PP Neue Montreal Mono | 16px (1rem) | 500 | 1.00 (tight) | 0.08px | Uppercase section labels | +| Mono Small | PP Neue Montreal Mono | 11px (0.69rem) | 500 | 1.00–1.40 | 0.055–0.08px | Small uppercase tags | +| Mono Micro | PP Neue Montreal Mono | 10px (0.63rem) | 400 | 1.40 | 0.05px | Smallest uppercase labels | + +### Principles +- **Negative tracking everywhere**: Every size of "The Future" uses negative letter-spacing (-0.16px to -1.92px), creating consistently tight, modern text. +- **Mono for structure**: PP Neue Montreal Mono in uppercase with positive letter-spacing creates technical "label" moments that structure the page without competing with display text. +- **Weight 500 as emphasis**: The system uses 400 (regular) and 500 (medium) — no bold. Medium weight marks headings and emphasis. +- **Tight line-heights throughout**: Even body text uses 1.25–1.30 line-height — tighter than typical, creating a dense, information-rich feel. + +## 4. Component Stylings + +### Buttons + +**Glass on Dark** +- Background: `rgba(255, 255, 255, 0.12)` (frosted glass) +- Text: Pure White (`#ffffff`) +- Radius: sharp (4px) +- Opacity: 0.5 +- Hover: transparent dark overlay +- Used on dark sections — subtle, glass-like + +**Dark Solid** +- Background: Dark Blue (`#010120`) or Pure Black +- Text: Pure White +- Radius: sharp (4px) +- The primary CTA on light surfaces + +**Outlined Light** +- Border: `1px solid rgba(0, 0, 0, 0.08)` +- Background: transparent or subtle glass +- Text: Pure Black +- Radius: sharp (4px) +- Secondary actions on light surfaces + +### Cards & Containers +- Background: Pure White or subtle glass tint +- Border: `1px solid rgba(0, 0, 0, 0.08)` on light; `1px solid rgba(255, 255, 255, 0.12)` on dark +- Radius: sharp (4px) for badges and small elements; comfortable (8px) for larger containers +- Shadow: dark-blue-tinted (`rgba(1, 1, 32, 0.1) 0px 4px 10px`) — warm and subtle +- Stats cards with large numbers prominently displayed + +### Badges / Tags +- Background: `rgba(0, 0, 0, 0.04)` (light) or `rgba(255, 255, 255, 0.12)` (dark) +- Text: Black (light) or White (dark) +- Padding: 2px 8px (compact) +- Radius: sharp (4px) +- Border: `1px solid rgba(0, 0, 0, 0.08)` +- PP Neue Montreal Mono, uppercase, 16px + +### Navigation +- Clean horizontal nav on white/transparent +- Logo: Together AI wordmark +- Links: The Future at 16px, weight 400 +- CTA: Dark solid button +- Hover: no text-decoration + +### Image Treatment +- Abstract pastel gradient illustrations (cloud/feather forms) +- Product UI screenshots on dark/light surfaces +- Team photos in editorial style +- Research paper cards with dark backgrounds + +### Distinctive Components + +**Stats Bar** +- Large performance metrics (2x, 60%, 90%) +- Bold display numbers +- Short descriptive captions beneath +- Clean horizontal layout + +**Mono Section Labels** +- PP Neue Montreal Mono, uppercase, 11px, letter-spacing 0.055px +- Used as navigational signposts throughout the page +- Technical, structured feel + +**Research Section** +- Dark Blue (#010120) background +- White text, research paper thumbnails +- Creates a distinct "academic" zone + +**Large Footer Logo** +- "together" wordmark rendered at massive scale in the dark footer +- Creates a brand-statement closing moment + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 4px, 8px, 10px, 12px, 16px, 20px, 24px, 32px, 44px, 48px, 80px, 100px, 120px +- Button/badge padding: 2px 8px (compact) +- Card internal padding: approximately 24–32px +- Section vertical spacing: generous (80–120px) + +### Grid & Container +- Max container width: approximately 1200px, centered +- Hero: centered with pastel gradient background +- Feature sections: multi-column card grids +- Stats: horizontal row of metric cards +- Research: dark full-width section + +### Whitespace Philosophy +- **Optimistic breathing room**: Generous spacing between sections creates an open, inviting feel that makes enterprise AI infrastructure feel accessible. +- **Dual atmosphere**: Light sections breathe with whitespace; dark sections are denser with content. +- **Stats as visual anchors**: Large numbers with small captions create natural focal points. + +### Border Radius Scale +- Sharp (4px): Buttons, badges, tags, small interactive elements — the primary radius +- Comfortable (8px): Larger containers, feature cards + +*This is a deliberately restrained radius system — no pills, no generous rounding. The sharp geometry contrasts with the soft pastel gradients.* + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background, text blocks | +| Contained (Level 1) | `1px solid rgba(0,0,0,0.08)` (light) or `rgba(255,255,255,0.12)` (dark) | Cards, badges, containers | +| Elevated (Level 2) | `rgba(1, 1, 32, 0.1) 0px 4px 10px` | Feature cards, hover states | +| Dark Zone (Level 3) | Dark Blue (#010120) full-width background | Research, footer, technical sections | + +**Shadow Philosophy**: Together AI uses a single, distinctive shadow — tinted with Dark Blue (`rgba(1, 1, 32, 0.1)`) rather than generic black. This gives elevated elements a subtle blue-ish cast that ties them to the brand's midnight-blue dark mode. The shadow is soft (10px blur, 4px offset) and always downward — creating gentle paper-hover elevation. + +## 7. Do's and Don'ts + +### Do +- Use pastel gradients (pink/blue/lavender) for hero illustrations and decorative backgrounds +- Use Dark Blue (#010120) for dark sections — never generic gray-black +- Apply negative letter-spacing on all "The Future" text (scaled by size) +- Use PP Neue Montreal Mono in uppercase for section labels and technical markers +- Keep border-radius sharp (4px) for badges and interactive elements +- Use the dark-blue-tinted shadow for elevation +- Maintain the light/dark section duality — business (light) vs research (dark) +- Show enterprise stats prominently with large display numbers + +### Don't +- Don't use Brand Magenta (#ef2cc1) or Brand Orange (#fc4c02) as UI colors — they're for illustrations only +- Don't use pill-shaped or generously rounded corners — the geometry is sharp +- Don't use generic gray-black for dark sections — always Dark Blue (#010120) +- Don't use positive letter-spacing on "The Future" — it's always negative +- Don't use bold (700+) weight — 400–500 is the full range +- Don't use warm-toned shadows — always dark-blue-tinted +- Don't reduce section spacing below 48px — the open feeling is core +- Don't mix in additional typefaces — "The Future" + PP Neue Montreal Mono is the pair + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <479px | Compact layout, stacked everything | +| Large Mobile | 479–767px | Single column, hamburger nav | +| Tablet | 768–991px | 2-column grids begin | +| Desktop | 992px+ | Full multi-column layout | + +### Touch Targets +- Buttons with adequate padding +- Card surfaces as touch targets +- Navigation links at comfortable 16px + +### Collapsing Strategy +- **Navigation**: Collapses to hamburger on mobile +- **Hero text**: 64px → 40px → 28px progressive scaling +- **Stats bar**: Horizontal → stacked vertical +- **Feature grids**: Multi-column → single column +- **Research section**: Cards stack vertically + +### Image Behavior +- Pastel illustrations scale proportionally +- Product screenshots maintain aspect ratio +- Team photos scale within containers + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Text (light): "Pure Black (#000000)" +- Primary Text (dark): "Pure White (#ffffff)" +- Page Background: "Pure White (#ffffff)" +- Dark Surface: "Dark Blue (#010120)" +- Brand Accent 1: "Brand Magenta (#ef2cc1)" +- Brand Accent 2: "Brand Orange (#fc4c02)" +- Soft Accent: "Soft Lavender (#bdbbff)" +- Border (light): "rgba(0, 0, 0, 0.08)" + +### Example Component Prompts +- "Create a hero section on white with soft pastel gradients (pink → lavender → blue) as background. Headline at 64px 'The Future' weight 500, line-height 1.10, letter-spacing -1.92px. Pure Black text. Include a dark blue CTA button (#010120, 4px radius)." +- "Design a stats card: large display number (64px, weight 500) with a small caption below (14px). White background, 8px radius, dark-blue-tinted shadow (rgba(1, 1, 32, 0.1) 0px 4px 10px)." +- "Build a section label: PP Neue Montreal Mono, 11px, weight 500, uppercase, letter-spacing 0.055px. Black text on light, white on dark." +- "Create a dark research section: Dark Blue (#010120) background. White text, section heading at 40px 'The Future' weight 500, letter-spacing -0.8px. Cards with rgba(255, 255, 255, 0.12) border." +- "Design a badge: 4px radius, rgba(0, 0, 0, 0.04) background, 1px solid rgba(0, 0, 0, 0.08) border, 'The Future' 16px text. Padding: 2px 8px." + +### Iteration Guide +1. Always specify negative letter-spacing for "The Future" — it's scaled by size +2. Dark sections use #010120 (midnight blue), never generic black +3. Shadows are always dark-blue-tinted: rgba(1, 1, 32, 0.1) +4. Mono labels are always uppercase with positive letter-spacing +5. Keep radius sharp (4px or 8px) — no pills, no generous rounding +6. Pastel gradients are for decoration, not UI chrome diff --git a/skills/creative/popular-web-designs/templates/uber.md b/skills/creative/popular-web-designs/templates/uber.md new file mode 100644 index 000000000000..bdd4d3f898d5 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/uber.md @@ -0,0 +1,308 @@ +# Design System: Uber + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `DM Sans` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Uber's design language is a masterclass in confident minimalism -- a black-and-white universe where every pixel serves a purpose and nothing decorates without earning its place. The entire experience is built on a stark duality: jet black (`#000000`) and pure white (`#ffffff`), with virtually no mid-tone grays diluting the message. This isn't the sterile minimalism of a startup that hasn't finished designing -- it's the deliberate restraint of a brand so established it can afford to whisper. + +The signature typeface, UberMove, is a proprietary geometric sans-serif with a distinctly square, engineered quality. Headlines in UberMove Bold at 52px carry the weight of a billboard -- authoritative, direct, unapologetic. The companion face UberMoveText handles body copy and buttons with a slightly softer, more readable character at medium weight (500). Together, they create a typographic system that feels like a transit map: clear, efficient, built for scanning at speed. + +What makes Uber's design truly distinctive is its use of full-bleed photography and illustration paired with pill-shaped interactive elements (999px border-radius). Navigation chips, CTA buttons, and category selectors all share this capsule shape, creating a tactile, thumb-friendly interface language that's unmistakably Uber. The illustrations -- warm, slightly stylized scenes of drivers, riders, and cityscapes -- inject humanity into what could otherwise be a cold, monochrome system. The site alternates between white content sections and a full-black footer, with card-based layouts using the gentlest possible shadows (rgba(0,0,0,0.12-0.16)) to create subtle lift without breaking the flat aesthetic. + +**Key Characteristics:** +- Pure black-and-white foundation with virtually no mid-tone grays in the UI chrome +- UberMove (headlines) + UberMoveText (body/UI) -- proprietary geometric sans-serif family +- Pill-shaped everything: buttons, chips, nav items all use 999px border-radius +- Warm, human illustrations contrasting the stark monochrome interface +- Card-based layout with whisper-soft shadows (0.12-0.16 opacity) +- 8px spacing grid with compact, information-dense layouts +- Bold photography integrated as full-bleed hero backgrounds +- Black footer anchoring the page with a dark, high-contrast environment + +## 2. Color Palette & Roles + +### Primary +- **Uber Black** (`#000000`): The defining brand color -- used for primary buttons, headlines, navigation text, and the footer. Not "near-black" or "off-black," but true, uncompromising black. +- **Pure White** (`#ffffff`): The primary surface color and inverse text. Used for page backgrounds, card surfaces, and text on black elements. + +### Interactive & Button States +- **Hover Gray** (`#e2e2e2`): White button hover state -- a clean, cool light gray that provides clear feedback without warmth. +- **Hover Light** (`#f3f3f3`): Subtle hover for elevated white buttons -- barely-there gray for gentle interaction feedback. +- **Chip Gray** (`#efefef`): Background for secondary/filter buttons and navigation chips -- a neutral, ultra-light gray. + +### Text & Content +- **Body Gray** (`#4b4b4b`): Secondary text and footer links -- a true mid-gray with no warm or cool bias. +- **Muted Gray** (`#afafaf`): Tertiary text, de-emphasized footer links, and placeholder content. + +### Borders & Separation +- **Border Black** (`#000000`): Thin 1px borders for structural containment -- used sparingly on dividers and form containers. + +### Shadows & Depth +- **Shadow Light** (`rgba(0, 0, 0, 0.12)`): Standard card elevation -- a featherweight lift for content cards. +- **Shadow Medium** (`rgba(0, 0, 0, 0.16)`): Slightly stronger elevation for floating action buttons and overlays. +- **Button Press** (`rgba(0, 0, 0, 0.08)`): Inset shadow for active/pressed states on secondary buttons. + +### Link States +- **Default Link Blue** (`#0000ee`): Standard browser blue for text links with underline -- used in body content. +- **Link White** (`#ffffff`): Links on dark surfaces -- used in footer and dark sections. +- **Link Black** (`#000000`): Links on light surfaces with underline decoration. + +### Gradient System +- Uber's design is **entirely gradient-free**. The black/white duality and flat color blocks create all visual hierarchy. No gradients appear anywhere in the system -- every surface is a solid color, every transition is a hard edge or a shadow. + +## 3. Typography Rules + +### Font Family +- **Headline / Display**: `UberMove`, with fallbacks: `UberMoveText, system-ui, Helvetica Neue, Helvetica, Arial, sans-serif` +- **Body / UI**: `UberMoveText`, with fallbacks: `system-ui, Helvetica Neue, Helvetica, Arial, sans-serif` + +*Note: UberMove and UberMoveText are proprietary typefaces. For external implementations, use `system-ui` or Inter as the closest available substitute. The geometric, square-proportioned character of UberMove can be approximated with Inter or DM Sans.* + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Notes | +|------|------|------|--------|-------------|-------| +| Display / Hero | UberMove | 52px (3.25rem) | 700 | 1.23 (tight) | Maximum impact, billboard presence | +| Section Heading | UberMove | 36px (2.25rem) | 700 | 1.22 (tight) | Major section anchors | +| Card Title | UberMove | 32px (2rem) | 700 | 1.25 (tight) | Card and feature headings | +| Sub-heading | UberMove | 24px (1.5rem) | 700 | 1.33 | Secondary section headers | +| Small Heading | UberMove | 20px (1.25rem) | 700 | 1.40 | Compact headings, list titles | +| Nav / UI Large | UberMoveText | 18px (1.13rem) | 500 | 1.33 | Navigation links, prominent UI text | +| Body / Button | UberMoveText | 16px (1rem) | 400-500 | 1.25-1.50 | Standard body text, button labels | +| Caption | UberMoveText | 14px (0.88rem) | 400-500 | 1.14-1.43 | Metadata, descriptions, small links | +| Micro | UberMoveText | 12px (0.75rem) | 400 | 1.67 (relaxed) | Fine print, legal text | + +### Principles +- **Bold headlines, medium body**: UberMove headings are exclusively weight 700 (bold) -- every headline hits with billboard force. UberMoveText body and UI text uses 400-500, creating a clear visual hierarchy through weight contrast. +- **Tight heading line-heights**: All headlines use line-heights between 1.22-1.40 -- compact and punchy, designed for scanning rather than reading. +- **Functional typography**: There is no decorative type treatment anywhere. No letter-spacing, no text-transform, no ornamental sizing. Every text element serves a direct communication purpose. +- **Two fonts, strict roles**: UberMove is exclusively for headings. UberMoveText is exclusively for body, buttons, links, and UI. The boundary is never crossed. + +## 4. Component Stylings + +### Buttons + +**Primary Black (CTA)** +- Background: Uber Black (`#000000`) +- Text: Pure White (`#ffffff`) +- Padding: 10px 12px +- Radius: 999px (full pill) +- Outline: none +- Focus: inset ring `rgb(255,255,255) 0px 0px 0px 2px` +- The primary action button -- bold, high-contrast, unmissable + +**Secondary White** +- Background: Pure White (`#ffffff`) +- Text: Uber Black (`#000000`) +- Padding: 10px 12px +- Radius: 999px (full pill) +- Hover: background shifts to Hover Gray (`#e2e2e2`) +- Focus: background shifts to Hover Gray, inset ring appears +- Used on dark surfaces or as a secondary action alongside Primary Black + +**Chip / Filter** +- Background: Chip Gray (`#efefef`) +- Text: Uber Black (`#000000`) +- Padding: 14px 16px +- Radius: 999px (full pill) +- Active: inset shadow `rgba(0,0,0,0.08)` +- Navigation chips, category selectors, filter toggles + +**Floating Action** +- Background: Pure White (`#ffffff`) +- Text: Uber Black (`#000000`) +- Padding: 14px +- Radius: 999px (full pill) +- Shadow: `rgba(0,0,0,0.16) 0px 2px 8px 0px` +- Transform: `translateY(2px)` slight offset +- Hover: background shifts to `#f3f3f3` +- Map controls, scroll-to-top, floating CTAs + +### Cards & Containers +- Background: Pure White (`#ffffff`) on white pages; no distinct card background differentiation +- Border: none by default -- cards are defined by shadow, not stroke +- Radius: 8px for standard content cards; 12px for featured/promoted cards +- Shadow: `rgba(0,0,0,0.12) 0px 4px 16px 0px` for standard lift +- Cards are content-dense with minimal internal padding +- Image-led cards use full-bleed imagery with text overlay or below + +### Inputs & Forms +- Text: Uber Black (`#000000`) +- Background: Pure White (`#ffffff`) +- Border: 1px solid Black (`#000000`) -- the only place visible borders appear prominently +- Radius: 8px +- Padding: standard comfortable spacing +- Focus: no extracted custom focus state -- relies on standard browser focus ring + +### Navigation +- Sticky top navigation with white background +- Logo: Uber wordmark/icon at 24x24px in black +- Links: UberMoveText at 14-18px, weight 500, in Uber Black +- Pill-shaped nav chips with Chip Gray (`#efefef`) background for category navigation ("Ride", "Drive", "Business", "Uber Eats") +- Menu toggle: circular button with 50% border-radius +- Mobile: hamburger menu pattern + +### Image Treatment +- Warm, hand-illustrated scenes (not photographs for feature sections) +- Illustration style: slightly stylized people, warm color palette within illustrations, contemporary vibe +- Hero sections use bold photography or illustration as full-width backgrounds +- QR codes for app download CTAs +- All imagery uses standard 8px or 12px border-radius when contained in cards + +### Distinctive Components + +**Category Pill Navigation** +- Horizontal row of pill-shaped buttons for top-level navigation ("Ride", "Drive", "Business", "Uber Eats", "About") +- Each pill: Chip Gray background, black text, 999px radius +- Active state indicated by black background with white text (inversion) + +**Hero with Dual Action** +- Split hero: text/CTA on left, map/illustration on right +- Two input fields side by side for pickup/destination +- "See prices" CTA button in black pill + +**Plan-Ahead Cards** +- Cards promoting features like "Uber Reserve" and trip planning +- Illustration-heavy with warm, human-centric imagery +- Black CTA buttons with white text at bottom + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 4px, 6px, 8px, 10px, 12px, 14px, 16px, 18px, 20px, 24px, 32px +- Button padding: 10px 12px (compact) or 14px 16px (comfortable) +- Card internal padding: approximately 24-32px +- Section vertical spacing: generous but efficient -- approximately 64-96px between major sections + +### Grid & Container +- Max container width: approximately 1136px, centered +- Hero: split layout with text left, visual right +- Feature sections: 2-column card grids or full-width single-column +- Footer: multi-column link grid on black background +- Full-width sections extending to viewport edges + +### Whitespace Philosophy +- **Efficient, not airy**: Uber's whitespace is functional -- enough to separate, never enough to feel empty. This is transit-system spacing: compact, clear, purpose-driven. +- **Content-dense cards**: Cards pack information tightly with minimal internal spacing, relying on shadow and radius to define boundaries. +- **Section breathing room**: Major sections get generous vertical spacing, but within sections, elements are closely grouped. + +### Border Radius Scale +- Sharp (0px): No square corners used in interactive elements +- Standard (8px): Content cards, input fields, listboxes +- Comfortable (12px): Featured cards, larger containers, link cards +- Full Pill (999px): All buttons, chips, navigation items, pills +- Circle (50%): Avatar images, icon containers, circular controls + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, solid background | Page background, inline content, text sections | +| Subtle (Level 1) | `rgba(0,0,0,0.12) 0px 4px 16px` | Standard content cards, feature blocks | +| Medium (Level 2) | `rgba(0,0,0,0.16) 0px 4px 16px` | Elevated cards, overlay elements | +| Floating (Level 3) | `rgba(0,0,0,0.16) 0px 2px 8px` + translateY(2px) | Floating action buttons, map controls | +| Pressed (Level 4) | `rgba(0,0,0,0.08) inset` (999px spread) | Active/pressed button states | +| Focus Ring | `rgb(255,255,255) 0px 0px 0px 2px inset` | Keyboard focus indicators | + +**Shadow Philosophy**: Uber uses shadow purely as a structural tool, never decoratively. Shadows are always black at very low opacity (0.08-0.16), creating the bare minimum lift needed to separate content layers. The blur radii are moderate (8-16px) -- enough to feel natural but never dramatic. There are no colored shadows, no layered shadow stacks, and no ambient glow effects. Depth is communicated more through the black/white section contrast than through shadow elevation. + +## 7. Do's and Don'ts + +### Do +- Use true black (`#000000`) and pure white (`#ffffff`) as the primary palette -- the stark contrast IS Uber +- Use 999px border-radius for all buttons, chips, and pill-shaped navigation elements +- Keep all headings in UberMove Bold (700) for billboard-level impact +- Use whisper-soft shadows (0.12-0.16 opacity) for card elevation -- barely visible +- Maintain the compact, information-dense layout style -- Uber prioritizes efficiency over airiness +- Use warm, human-centric illustrations to soften the monochrome interface +- Apply 8px radius for content cards and 12px for featured containers +- Use UberMoveText at weight 500 for navigation and prominent UI text +- Pair black primary buttons with white secondary buttons for dual-action layouts + +### Don't +- Don't introduce color into the UI chrome -- Uber's interface is strictly black, white, and gray +- Don't use rounded corners less than 999px on buttons -- the full-pill shape is a core identity element +- Don't apply heavy shadows or drop shadows with high opacity -- depth is whisper-subtle +- Don't use serif fonts anywhere -- Uber's typography is exclusively geometric sans-serif +- Don't create airy, spacious layouts with excessive whitespace -- Uber's density is intentional +- Don't use gradients or color overlays -- every surface is a flat, solid color +- Don't mix UberMove into body text or UberMoveText into headlines -- the hierarchy is strict +- Don't use decorative borders -- borders are functional (inputs, dividers) or absent entirely +- Don't soften the black/white contrast with off-whites or near-blacks -- the duality is deliberate + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | 320px | Minimum layout, single column, stacked inputs, compact typography | +| Mobile | 600px | Standard mobile, stacked layout, hamburger nav | +| Tablet Small | 768px | Two-column grids begin, expanded card layouts | +| Tablet | 1119px | Full tablet layout, side-by-side hero content | +| Desktop Small | 1120px | Desktop grid activates, horizontal nav pills | +| Desktop | 1136px | Full desktop layout, maximum container width, split hero | + +### Touch Targets +- All pill buttons: minimum 44px height (10-14px vertical padding + line-height) +- Navigation chips: generous 14px 16px padding for comfortable thumb tapping +- Circular controls (menu, close): 50% radius ensures large, easy-to-hit targets +- Card surfaces serve as full-area touch targets on mobile + +### Collapsing Strategy +- **Navigation**: Horizontal pill nav collapses to hamburger menu with circular toggle +- **Hero**: Split layout (text + map/visual) stacks to single column -- text above, visual below +- **Input fields**: Side-by-side pickup/destination inputs stack vertically +- **Feature cards**: 2-column grid collapses to full-width stacked cards +- **Headings**: 52px display scales down through 36px, 32px, 24px, 20px +- **Footer**: Multi-column link grid collapses to accordion or stacked single column +- **Category pills**: Horizontal scroll with overflow on smaller screens + +### Image Behavior +- Illustrations scale proportionally within their containers +- Hero imagery maintains aspect ratio, may crop on smaller screens +- QR code sections hide on mobile (app download shifts to direct store links) +- Card imagery maintains 8-12px border radius at all sizes + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Button: "Uber Black (#000000)" +- Page Background: "Pure White (#ffffff)" +- Button Text (on black): "Pure White (#ffffff)" +- Button Text (on white): "Uber Black (#000000)" +- Secondary Text: "Body Gray (#4b4b4b)" +- Tertiary Text: "Muted Gray (#afafaf)" +- Chip Background: "Chip Gray (#efefef)" +- Hover State: "Hover Gray (#e2e2e2)" +- Card Shadow: "rgba(0,0,0,0.12) 0px 4px 16px" +- Footer Background: "Uber Black (#000000)" + +### Example Component Prompts +- "Create a hero section on Pure White (#ffffff) with a headline at 52px UberMove Bold (700), line-height 1.23. Use Uber Black (#000000) text. Add a subtitle in Body Gray (#4b4b4b) at 16px UberMoveText weight 400 with 1.50 line-height. Place an Uber Black (#000000) pill CTA button with Pure White text, 999px radius, padding 10px 12px." +- "Design a category navigation bar with horizontal pill buttons. Each pill: Chip Gray (#efefef) background, Uber Black (#000000) text, 14px 16px padding, 999px border-radius. Active pill inverts to Uber Black background with Pure White text. Use UberMoveText at 14px weight 500." +- "Build a feature card on Pure White (#ffffff) with 8px border-radius and shadow rgba(0,0,0,0.12) 0px 4px 16px. Title in UberMove at 24px weight 700, description in Body Gray (#4b4b4b) at 16px UberMoveText. Add a black pill CTA button at the bottom." +- "Create a dark footer on Uber Black (#000000) with Pure White (#ffffff) heading text in UberMove at 20px weight 700. Footer links in Muted Gray (#afafaf) at 14px UberMoveText. Links hover to Pure White. Multi-column grid layout." +- "Design a floating action button with Pure White (#ffffff) background, 999px radius, 14px padding, and shadow rgba(0,0,0,0.16) 0px 2px 8px. Hover shifts background to #f3f3f3. Use for scroll-to-top or map controls." + +### Iteration Guide +1. Focus on ONE component at a time +2. Reference the strict black/white palette -- "use Uber Black (#000000)" not "make it dark" +3. Always specify 999px radius for buttons and pills -- this is non-negotiable for the Uber identity +4. Describe the font family explicitly -- "UberMove Bold for the heading, UberMoveText Medium for the label" +5. For shadows, use "whisper shadow (rgba(0,0,0,0.12) 0px 4px 16px)" -- never heavy drop shadows +6. Keep layouts compact and information-dense -- Uber is efficient, not airy +7. Illustrations should be warm and human -- describe "stylized people in warm tones" not abstract shapes +8. Pair black CTAs with white secondaries for balanced dual-action layouts diff --git a/skills/creative/popular-web-designs/templates/vercel.md b/skills/creative/popular-web-designs/templates/vercel.md new file mode 100644 index 000000000000..7ecd1449d921 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/vercel.md @@ -0,0 +1,323 @@ +# Design System: Vercel + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Geist` | **Mono:** `Geist Mono` +> - **Font stack (CSS):** `font-family: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Vercel's website is the visual thesis of developer infrastructure made invisible — a design system so restrained it borders on philosophical. The page is overwhelmingly white (`#ffffff`) with near-black (`#171717`) text, creating a gallery-like emptiness where every element earns its pixel. This isn't minimalism as decoration; it's minimalism as engineering principle. The Geist design system treats the interface like a compiler treats code — every unnecessary token is stripped away until only structure remains. + +The custom Geist font family is the crown jewel. Geist Sans uses aggressive negative letter-spacing (-2.4px to -2.88px at display sizes), creating headlines that feel compressed, urgent, and engineered — like code that's been minified for production. At body sizes, the tracking relaxes but the geometric precision persists. Geist Mono completes the system as the monospace companion for code, terminal output, and technical labels. Both fonts enable OpenType `"liga"` (ligatures) globally, adding a layer of typographic sophistication that rewards close reading. + +What distinguishes Vercel from other monochrome design systems is its shadow-as-border philosophy. Instead of traditional CSS borders, Vercel uses `box-shadow: 0px 0px 0px 1px rgba(0,0,0,0.08)` — a zero-offset, zero-blur, 1px-spread shadow that creates a border-like line without the box model implications. This technique allows borders to exist in the shadow layer, enabling smoother transitions, rounded corners without clipping, and a subtler visual weight than traditional borders. The entire depth system is built on layered, multi-value shadow stacks where each layer serves a specific purpose: one for the border, one for soft elevation, one for ambient depth. + +**Key Characteristics:** +- Geist Sans with extreme negative letter-spacing (-2.4px to -2.88px at display) — text as compressed infrastructure +- Geist Mono for code and technical labels with OpenType `"liga"` globally +- Shadow-as-border technique: `box-shadow 0px 0px 0px 1px` replaces traditional borders throughout +- Multi-layer shadow stacks for nuanced depth (border + elevation + ambient in single declarations) +- Near-pure white canvas with `#171717` text — not quite black, creating micro-contrast softness +- Workflow-specific accent colors: Ship Red (`#ff5b4f`), Preview Pink (`#de1d8d`), Develop Blue (`#0a72ef`) +- Focus ring system using `hsla(212, 100%, 48%, 1)` — a saturated blue for accessibility +- Pill badges (9999px) with tinted backgrounds for status indicators + +## 2. Color Palette & Roles + +### Primary +- **Vercel Black** (`#171717`): Primary text, headings, dark surface backgrounds. Not pure black — the slight warmth prevents harshness. +- **Pure White** (`#ffffff`): Page background, card surfaces, button text on dark. +- **True Black** (`#000000`): Secondary use, `--geist-console-text-color-default`, used in specific console/code contexts. + +### Workflow Accent Colors +- **Ship Red** (`#ff5b4f`): `--ship-text`, the "ship to production" workflow step — warm, urgent coral-red. +- **Preview Pink** (`#de1d8d`): `--preview-text`, the preview deployment workflow — vivid magenta-pink. +- **Develop Blue** (`#0a72ef`): `--develop-text`, the development workflow — bright, focused blue. + +### Console / Code Colors +- **Console Blue** (`#0070f3`): `--geist-console-text-color-blue`, syntax highlighting blue. +- **Console Purple** (`#7928ca`): `--geist-console-text-color-purple`, syntax highlighting purple. +- **Console Pink** (`#eb367f`): `--geist-console-text-color-pink`, syntax highlighting pink. + +### Interactive +- **Link Blue** (`#0072f5`): Primary link color with underline decoration. +- **Focus Blue** (`hsla(212, 100%, 48%, 1)`): `--ds-focus-color`, focus ring on interactive elements. +- **Ring Blue** (`rgba(147, 197, 253, 0.5)`): `--tw-ring-color`, Tailwind ring utility. + +### Neutral Scale +- **Gray 900** (`#171717`): Primary text, headings, nav text. +- **Gray 600** (`#4d4d4d`): Secondary text, description copy. +- **Gray 500** (`#666666`): Tertiary text, muted links. +- **Gray 400** (`#808080`): Placeholder text, disabled states. +- **Gray 100** (`#ebebeb`): Borders, card outlines, dividers. +- **Gray 50** (`#fafafa`): Subtle surface tint, inner shadow highlight. + +### Surface & Overlay +- **Overlay Backdrop** (`hsla(0, 0%, 98%, 1)`): `--ds-overlay-backdrop-color`, modal/dialog backdrop. +- **Selection Text** (`hsla(0, 0%, 95%, 1)`): `--geist-selection-text-color`, text selection highlight. +- **Badge Blue Bg** (`#ebf5ff`): Pill badge background, tinted blue surface. +- **Badge Blue Text** (`#0068d6`): Pill badge text, darker blue for readability. + +### Shadows & Depth +- **Border Shadow** (`rgba(0, 0, 0, 0.08) 0px 0px 0px 1px`): The signature — replaces traditional borders. +- **Subtle Elevation** (`rgba(0, 0, 0, 0.04) 0px 2px 2px`): Minimal lift for cards. +- **Card Stack** (`rgba(0,0,0,0.08) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 2px, rgba(0,0,0,0.04) 0px 8px 8px -8px, #fafafa 0px 0px 0px 1px`): Full multi-layer card shadow. +- **Ring Border** (`rgb(235, 235, 235) 0px 0px 0px 1px`): Light gray ring-border for tabs and images. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Geist`, with fallbacks: `Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol` +- **Monospace**: `Geist Mono`, with fallbacks: `ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New` +- **OpenType Features**: `"liga"` enabled globally on all Geist text; `"tnum"` for tabular numbers on specific captions. + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Geist | 48px (3.00rem) | 600 | 1.00–1.17 (tight) | -2.4px to -2.88px | Maximum compression, billboard impact | +| Section Heading | Geist | 40px (2.50rem) | 600 | 1.20 (tight) | -2.4px | Feature section titles | +| Sub-heading Large | Geist | 32px (2.00rem) | 600 | 1.25 (tight) | -1.28px | Card headings, sub-sections | +| Sub-heading | Geist | 32px (2.00rem) | 400 | 1.50 | -1.28px | Lighter sub-headings | +| Card Title | Geist | 24px (1.50rem) | 600 | 1.33 | -0.96px | Feature cards | +| Card Title Light | Geist | 24px (1.50rem) | 500 | 1.33 | -0.96px | Secondary card headings | +| Body Large | Geist | 20px (1.25rem) | 400 | 1.80 (relaxed) | normal | Introductions, feature descriptions | +| Body | Geist | 18px (1.13rem) | 400 | 1.56 | normal | Standard reading text | +| Body Small | Geist | 16px (1.00rem) | 400 | 1.50 | normal | Standard UI text | +| Body Medium | Geist | 16px (1.00rem) | 500 | 1.50 | normal | Navigation, emphasized text | +| Body Semibold | Geist | 16px (1.00rem) | 600 | 1.50 | -0.32px | Strong labels, active states | +| Button / Link | Geist | 14px (0.88rem) | 500 | 1.43 | normal | Buttons, links, captions | +| Button Small | Geist | 14px (0.88rem) | 400 | 1.00 (tight) | normal | Compact buttons | +| Caption | Geist | 12px (0.75rem) | 400–500 | 1.33 | normal | Metadata, tags | +| Mono Body | Geist Mono | 16px (1.00rem) | 400 | 1.50 | normal | Code blocks | +| Mono Caption | Geist Mono | 13px (0.81rem) | 500 | 1.54 | normal | Code labels | +| Mono Small | Geist Mono | 12px (0.75rem) | 500 | 1.00 (tight) | normal | `text-transform: uppercase`, technical labels | +| Micro Badge | Geist | 7px (0.44rem) | 700 | 1.00 (tight) | normal | `text-transform: uppercase`, tiny badges | + +### Principles +- **Compression as identity**: Geist Sans at display sizes uses -2.4px to -2.88px letter-spacing — the most aggressive negative tracking of any major design system. This creates text that feels _minified_, like code optimized for production. The tracking progressively relaxes as size decreases: -1.28px at 32px, -0.96px at 24px, -0.32px at 16px, and normal at 14px. +- **Ligatures everywhere**: Every Geist text element enables OpenType `"liga"`. Ligatures aren't decorative — they're structural, creating tighter, more efficient glyph combinations. +- **Three weights, strict roles**: 400 (body/reading), 500 (UI/interactive), 600 (headings/emphasis). No bold (700) except for tiny micro-badges. This narrow weight range creates hierarchy through size and tracking, not weight. +- **Mono for identity**: Geist Mono in uppercase with `"tnum"` or `"liga"` serves as the "developer console" voice — compact technical labels that connect the marketing site to the product. + +## 4. Component Stylings + +### Buttons + +**Primary White (Shadow-bordered)** +- Background: `#ffffff` +- Text: `#171717` +- Padding: 0px 6px (minimal — content-driven width) +- Radius: 6px (subtly rounded) +- Shadow: `rgb(235, 235, 235) 0px 0px 0px 1px` (ring-border) +- Hover: background shifts to `var(--ds-gray-1000)` (dark) +- Focus: `2px solid var(--ds-focus-color)` outline + `var(--ds-focus-ring)` shadow +- Use: Standard secondary button + +**Primary Dark (Inferred from Geist system)** +- Background: `#171717` +- Text: `#ffffff` +- Padding: 8px 16px +- Radius: 6px +- Use: Primary CTA ("Start Deploying", "Get Started") + +**Pill Button / Badge** +- Background: `#ebf5ff` (tinted blue) +- Text: `#0068d6` +- Padding: 0px 10px +- Radius: 9999px (full pill) +- Font: 12px weight 500 +- Use: Status badges, tags, feature labels + +**Large Pill (Navigation)** +- Background: transparent or `#171717` +- Radius: 64px–100px +- Use: Tab navigation, section selectors + +### Cards & Containers +- Background: `#ffffff` +- Border: via shadow — `rgba(0, 0, 0, 0.08) 0px 0px 0px 1px` +- Radius: 8px (standard), 12px (featured/image cards) +- Shadow stack: `rgba(0,0,0,0.08) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 2px, #fafafa 0px 0px 0px 1px` +- Image cards: `1px solid #ebebeb` with 12px top radius +- Hover: subtle shadow intensification + +### Inputs & Forms +- Radio: standard styling with focus `var(--ds-gray-200)` background +- Focus shadow: `1px 0 0 0 var(--ds-gray-alpha-600)` +- Focus outline: `2px solid var(--ds-focus-color)` — consistent blue focus ring +- Border: via shadow technique, not traditional border + +### Navigation +- Clean horizontal nav on white, sticky +- Vercel logotype left-aligned, 262x52px +- Links: Geist 14px weight 500, `#171717` text +- Active: weight 600 or underline +- CTA: dark pill buttons ("Start Deploying", "Contact Sales") +- Mobile: hamburger menu collapse +- Product dropdowns with multi-level menus + +### Image Treatment +- Product screenshots with `1px solid #ebebeb` border +- Top-rounded images: `12px 12px 0px 0px` radius +- Dashboard/code preview screenshots dominate feature sections +- Soft gradient backgrounds behind hero images (pastel multi-color) + +### Distinctive Components + +**Workflow Pipeline** +- Three-step horizontal pipeline: Develop → Preview → Ship +- Each step has its own accent color: Blue → Pink → Red +- Connected with lines/arrows +- The visual metaphor for Vercel's core value proposition + +**Trust Bar / Logo Grid** +- Company logos (Perplexity, ChatGPT, Cursor, etc.) in grayscale +- Horizontal scroll or grid layout +- Subtle `#ebebeb` border separation + +**Metric Cards** +- Large number display (e.g., "10x faster") +- Geist 48px weight 600 for the metric +- Description below in gray body text +- Shadow-bordered card container + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 16px, 32px, 36px, 40px +- Notable gap: jumps from 16px to 32px — no 20px or 24px in primary scale + +### Grid & Container +- Max content width: approximately 1200px +- Hero: centered single-column with generous top padding +- Feature sections: 2–3 column grids for cards +- Full-width dividers using `border-bottom: 1px solid #171717` +- Code/dashboard screenshots as full-width or contained with border + +### Whitespace Philosophy +- **Gallery emptiness**: Massive vertical padding between sections (80px–120px+). The white space IS the design — it communicates that Vercel has nothing to prove and nothing to hide. +- **Compressed text, expanded space**: The aggressive negative letter-spacing on headlines is counterbalanced by generous surrounding whitespace. The text is dense; the space around it is vast. +- **Section rhythm**: White sections alternate with white sections — there's no color variation between sections. Separation comes from borders (shadow-borders) and spacing alone. + +### Border Radius Scale +- Micro (2px): Inline code snippets, small spans +- Subtle (4px): Small containers +- Standard (6px): Buttons, links, functional elements +- Comfortable (8px): Cards, list items +- Image (12px): Featured cards, image containers (top-rounded) +- Large (64px): Tab navigation pills +- XL (100px): Large navigation links +- Full Pill (9999px): Badges, status pills, tags +- Circle (50%): Menu toggle, avatar containers + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page background, text blocks | +| Ring (Level 1) | `rgba(0,0,0,0.08) 0px 0px 0px 1px` | Shadow-as-border for most elements | +| Light Ring (Level 1b) | `rgb(235,235,235) 0px 0px 0px 1px` | Lighter ring for tabs, images | +| Subtle Card (Level 2) | Ring + `rgba(0,0,0,0.04) 0px 2px 2px` | Standard cards with minimal lift | +| Full Card (Level 3) | Ring + Subtle + `rgba(0,0,0,0.04) 0px 8px 8px -8px` + inner `#fafafa` ring | Featured cards, highlighted panels | +| Focus (Accessibility) | `2px solid hsla(212, 100%, 48%, 1)` outline | Keyboard focus on all interactive elements | + +**Shadow Philosophy**: Vercel has arguably the most sophisticated shadow system in modern web design. Rather than using shadows for elevation in the traditional Material Design sense, Vercel uses multi-value shadow stacks where each layer has a distinct architectural purpose: one creates the "border" (0px spread, 1px), another adds ambient softness (2px blur), another handles depth at distance (8px blur with negative spread), and an inner ring (`#fafafa`) creates the subtle highlight that makes the card "glow" from within. This layered approach means cards feel built, not floating. + +### Decorative Depth +- Hero gradient: soft, pastel multi-color gradient wash behind hero content (barely visible, atmospheric) +- Section borders: `1px solid #171717` (full dark line) between major sections +- No background color variation — depth comes entirely from shadow layering and border contrast + +## 7. Do's and Don'ts + +### Do +- Use Geist Sans with aggressive negative letter-spacing at display sizes (-2.4px to -2.88px at 48px) +- Use shadow-as-border (`0px 0px 0px 1px rgba(0,0,0,0.08)`) instead of traditional CSS borders +- Enable `"liga"` on all Geist text — ligatures are structural, not optional +- Use the three-weight system: 400 (body), 500 (UI), 600 (headings) +- Apply workflow accent colors (Red/Pink/Blue) only in their workflow context +- Use multi-layer shadow stacks for cards (border + elevation + ambient + inner highlight) +- Keep the color palette achromatic — grays from `#171717` to `#ffffff` are the system +- Use `#171717` instead of `#000000` for primary text — the micro-warmth matters + +### Don't +- Don't use positive letter-spacing on Geist Sans — it's always negative or zero +- Don't use weight 700 (bold) on body text — 600 is the maximum, used only for headings +- Don't use traditional CSS `border` on cards — use the shadow-border technique +- Don't introduce warm colors (oranges, yellows, greens) into the UI chrome +- Don't apply the workflow accent colors (Ship Red, Preview Pink, Develop Blue) decoratively +- Don't use heavy shadows (> 0.1 opacity) — the shadow system is whisper-level +- Don't increase body text letter-spacing — Geist is designed to run tight +- Don't use pill radius (9999px) on primary action buttons — pills are for badges/tags only +- Don't skip the inner `#fafafa` ring in card shadows — it's the glow that makes the system work + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <400px | Tight single column, minimal padding | +| Mobile | 400–600px | Standard mobile, stacked layout | +| Tablet Small | 600–768px | 2-column grids begin | +| Tablet | 768–1024px | Full card grids, expanded padding | +| Desktop Small | 1024–1200px | Standard desktop layout | +| Desktop | 1200–1400px | Full layout, maximum content width | +| Large Desktop | >1400px | Centered, generous margins | + +### Touch Targets +- Buttons use comfortable padding (8px–16px vertical) +- Navigation links at 14px with adequate spacing +- Pill badges have 10px horizontal padding for tap targets +- Mobile menu toggle uses 50% radius circular button + +### Collapsing Strategy +- Hero: display 48px → scales down, maintains negative tracking proportionally +- Navigation: horizontal links + CTAs → hamburger menu +- Feature cards: 3-column → 2-column → single column stacked +- Code screenshots: maintain aspect ratio, may horizontally scroll +- Trust bar logos: grid → horizontal scroll +- Footer: multi-column → stacked single column +- Section spacing: 80px+ → 48px on mobile + +### Image Behavior +- Dashboard screenshots maintain border treatment at all sizes +- Hero gradient softens/simplifies on mobile +- Product screenshots use responsive images with consistent border radius +- Full-width sections maintain edge-to-edge treatment + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Vercel Black (`#171717`) +- Background: Pure White (`#ffffff`) +- Heading text: Vercel Black (`#171717`) +- Body text: Gray 600 (`#4d4d4d`) +- Border (shadow): `rgba(0, 0, 0, 0.08) 0px 0px 0px 1px` +- Link: Link Blue (`#0072f5`) +- Focus ring: Focus Blue (`hsla(212, 100%, 48%, 1)`) + +### Example Component Prompts +- "Create a hero section on white background. Headline at 48px Geist weight 600, line-height 1.00, letter-spacing -2.4px, color #171717. Subtitle at 20px Geist weight 400, line-height 1.80, color #4d4d4d. Dark CTA button (#171717, 6px radius, 8px 16px padding) and ghost button (white, shadow-border rgba(0,0,0,0.08) 0px 0px 0px 1px, 6px radius)." +- "Design a card: white background, no CSS border. Use shadow stack: rgba(0,0,0,0.08) 0px 0px 0px 1px, rgba(0,0,0,0.04) 0px 2px 2px, #fafafa 0px 0px 0px 1px. Radius 8px. Title at 24px Geist weight 600, letter-spacing -0.96px. Body at 16px weight 400, #4d4d4d." +- "Build a pill badge: #ebf5ff background, #0068d6 text, 9999px radius, 0px 10px padding, 12px Geist weight 500." +- "Create navigation: white sticky header. Geist 14px weight 500 for links, #171717 text. Dark pill CTA 'Start Deploying' right-aligned. Shadow-border on bottom: rgba(0,0,0,0.08) 0px 0px 0px 1px." +- "Design a workflow section showing three steps: Develop (text color #0a72ef), Preview (#de1d8d), Ship (#ff5b4f). Each step: 14px Geist Mono uppercase label + 24px Geist weight 600 title + 16px weight 400 description in #4d4d4d." + +### Iteration Guide +1. Always use shadow-as-border instead of CSS border — `0px 0px 0px 1px rgba(0,0,0,0.08)` is the foundation +2. Letter-spacing scales with font size: -2.4px at 48px, -1.28px at 32px, -0.96px at 24px, normal at 14px +3. Three weights only: 400 (read), 500 (interact), 600 (announce) +4. Color is functional, never decorative — workflow colors (Red/Pink/Blue) mark pipeline stages only +5. The inner `#fafafa` ring in card shadows is what gives Vercel cards their subtle inner glow +6. Geist Mono uppercase for technical labels, Geist Sans for everything else diff --git a/skills/creative/popular-web-designs/templates/voltagent.md b/skills/creative/popular-web-designs/templates/voltagent.md new file mode 100644 index 000000000000..d8623bd605d4 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/voltagent.md @@ -0,0 +1,336 @@ +# Design System: VoltAgent + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `system-ui` | **Mono:** `JetBrains Mono` +> - **Font stack (CSS):** `font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +VoltAgent's interface is a deep-space command terminal for the AI age — a developer-facing darkness built on near-pure-black surfaces (`#050507`) where the only interruption is the electric pulse of emerald green energy. The entire experience evokes the feeling of staring into a high-powered IDE at 2am: dark, focused, and alive with purpose. This is not a friendly SaaS landing page — it's an engineering platform that announces itself through code snippets, architectural diagrams, and raw technical confidence. + +The green accent (`#00d992`) is used with surgical precision — it glows from headlines, borders, and interactive elements like a circuit board carrying a signal. Against the carbon-black canvas, this green reads as "power on" — a deliberate visual metaphor for an AI agent engineering platform. The supporting palette is built entirely from warm-neutral grays (`#3d3a39`, `#8b949e`, `#b8b3b0`) that soften the darkness without introducing color noise, creating a cockpit-like warmth that pure blue-grays would lack. + +Typography leans on the system font stack for headings — achieving maximum rendering speed and native-feeling authority — while Inter carries the body and UI text with geometric precision. Code blocks use SFMono-Regular, the same font developers see in their terminals, reinforcing the tool's credibility at every scroll. + +**Key Characteristics:** +- Carbon-black canvas (`#050507`) with warm-gray border containment (`#3d3a39`) — not cold or sterile +- Single-accent identity: Emerald Signal Green (`#00d992`) as the sole chromatic energy source +- Dual-typography system: system-ui for authoritative headings, Inter for precise UI/body text, SFMono for code credibility +- Ultra-tight heading line-heights (1.0–1.11) creating dense, compressed power blocks +- Warm neutral palette (`#3d3a39`, `#8b949e`, `#b8b3b0`) that prevents the dark theme from feeling clinical +- Developer-terminal aesthetic where code snippets ARE the hero content +- Green glow effects (`drop-shadow`, border accents) that make UI elements feel electrically alive + +## 2. Color Palette & Roles + +### Primary +- **Emerald Signal Green** (`#00d992`): The core brand energy — used for accent borders, glow effects, and the highest-signal interactive moments. This is the "power-on" indicator of the entire interface. +- **VoltAgent Mint** (`#2fd6a1`): The button-text variant of the brand green — slightly warmer and more readable than pure Signal Green, used specifically for CTA text on dark surfaces. +- **Tailwind Emerald** (`#10b981`): The ecosystem-standard green used at low opacity (30%) for subtle background tints and link defaults. Bridges VoltAgent's custom palette with Tailwind's utility classes. + +### Secondary & Accent +- **Soft Purple** (`#818cf8`): A cool indigo-violet used sparingly for secondary categorization, code syntax highlights, and visual variety without competing with green. +- **Cobalt Primary** (`#306cce`): Docusaurus primary dark — used in documentation contexts for links and interactive focus states. +- **Deep Cobalt** (`#2554a0`): The darkest primary shade, reserved for pressed/active states in documentation UI. +- **Ring Blue** (`#3b82f6`): Tailwind's ring color at 50% opacity — visible only during keyboard focus for accessibility compliance. + +### Surface & Background +- **Abyss Black** (`#050507`): The landing page canvas — a near-pure black with the faintest warm undertone, darker than most "dark themes" for maximum contrast with green accents. +- **Carbon Surface** (`#101010`): The primary card and button background — one shade lighter than Abyss, creating a barely perceptible elevation layer. Used across all contained surfaces. +- **Warm Charcoal Border** (`#3d3a39`): The signature containment color — not a cold gray but a warm, almost brownish dark tone that prevents borders from feeling harsh against the black canvas. + +### Neutrals & Text +- **Snow White** (`#f2f2f2`): The primary text color on dark surfaces — not pure white (`#ffffff`) but a softened, eye-friendly off-white. The most-used color on the site (1008 instances). +- **Pure White** (`#ffffff`): Reserved for the highest-emphasis moments — ghost button text and maximum-contrast headings. Used at low opacity (5%) for subtle overlay effects. +- **Warm Parchment** (`#b8b3b0`): Secondary body text — a warm light gray with a slight pinkish undertone that reads as "paper" against the dark canvas. +- **Steel Slate** (`#8b949e`): Tertiary text, metadata, timestamps, and de-emphasized content. A cool blue-gray that provides clear hierarchy below Warm Parchment. +- **Fog Gray** (`#bdbdbd`): Footer links and supporting navigation text — brightens on hover to Pure White. +- **Mist Gray** (`#dcdcdc`): Slightly brighter than Fog, used for secondary link text that transitions to bright green on hover. +- **Near White** (`#eeeeee`): Highest-contrast secondary text, one step below Snow White. + +### Semantic & Accent +- **Success Emerald** (`#008b00`): Deep green for success states and positive confirmations in documentation contexts. +- **Success Light** (`#80d280`): Soft pastel green for success backgrounds and subtle positive indicators. +- **Warning Amber** (`#ffba00`): Bright amber for warning alerts and caution states. +- **Warning Pale** (`#ffdd80`): Softened amber for warning background fills. +- **Danger Coral** (`#fb565b`): Vivid red for error states and destructive action warnings. +- **Danger Rose** (`#fd9c9f`): Softened coral-pink for error backgrounds. +- **Info Teal** (`#4cb3d4`): Cool teal-blue for informational callouts and tip admonitions. +- **Dashed Border Slate** (`#4f5d75` at 40%): A muted blue-gray used exclusively for decorative dashed borders in workflow diagrams. + +### Gradient System +- **Green Signal Glow**: `drop-shadow(0 0 2px #00d992)` animating to `drop-shadow(0 0 8px #00d992)` — creates a pulsing "electric charge" effect on the VoltAgent bolt logo and interactive elements. The glow expands and contracts like a heartbeat. +- **Warm Ambient Haze**: `rgba(92, 88, 85, 0.2) 0px 0px 15px` — a warm-toned diffused shadow that creates a soft atmospheric glow around elevated cards, visible at the edges without sharp boundaries. +- **Deep Dramatic Elevation**: `rgba(0, 0, 0, 0.7) 0px 20px 60px` with `rgba(148, 163, 184, 0.1) 0px 0px 0px 1px inset` — a heavy, dramatic downward shadow paired with a faint inset slate ring for the most prominent floating elements. + +## 3. Typography Rules + +### Font Family +- **Primary (Headings)**: `system-ui`, with fallbacks: `-apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol` +- **Secondary (Body/UI)**: `Inter`, with fallbacks inheriting from system-ui stack. OpenType features: `"calt", "rlig"` (contextual alternates and required ligatures) +- **Monospace (Code)**: `SFMono-Regular`, with fallbacks: `Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display / Hero | system-ui | 60px (3.75rem) | 400 | 1.00 (tight) | -0.65px | Maximum impact, compressed blocks | +| Section Heading | system-ui | 36px (2.25rem) | 400 | 1.11 (tight) | -0.9px | Tightest letter-spacing in the system | +| Sub-heading | system-ui | 24px (1.50rem) | 700 | 1.33 | -0.6px | Bold weight for emphasis at this size | +| Sub-heading Light | system-ui / Inter | 24px (1.50rem) | 300–400 | 1.33 | -0.6px | Light weight variant for softer hierarchy | +| Overline | system-ui | 20px (1.25rem) | 600 | 1.40 | 0.5px | Uppercase transform, positive letter-spacing | +| Feature Title | Inter | 20px (1.25rem) | 500–600 | 1.40 | normal | Card headings, feature names | +| Overline Small | Inter | 18px (1.13rem) | 600 | 1.56 | 0.45px | Uppercase section labels | +| Body / Button | Inter | 16px (1.00rem) | 400–600 | 1.50–1.65 | normal | Standard text, nav links, buttons | +| Nav Link | Inter | 14.45px (0.90rem) | 500 | 1.65 | normal | Navigation-specific sizing | +| Caption / Label | Inter | 14px (0.88rem) | 400–600 | 1.43–1.65 | normal | Descriptions, metadata, badge text | +| Tag / Overline Tiny | system-ui | 14px (0.88rem) | 600 | 1.43 | 2.52px | Widest letter-spacing — reserved for uppercase tags | +| Micro | Inter | 12px (0.75rem) | 400–500 | 1.33 | normal | Smallest sans-serif text | +| Code Body | SFMono-Regular | 13–14px | 400–686 | 1.23–1.43 | normal | Inline code, terminal output, variable weight for syntax | +| Code Small | SFMono-Regular | 11–12px | 400 | 1.33–1.45 | normal | Tiny code references, line numbers | +| Code Button | monospace | 13px (0.81rem) | 700 | 1.65 | normal | Copy-to-clipboard button labels | + +### Principles +- **System-native authority**: Display headings use system-ui rather than a custom web font — this means the largest text renders instantly (no FOIT/FOUT) and inherits the operating system's native personality. On macOS it's SF Pro, on Windows it's Segoe UI. The design accepts this variability as a feature, not a bug. +- **Tight compression creates density**: Hero line-heights are extremely compressed (1.0) with negative letter-spacing (-0.65px to -0.9px), creating text blocks that feel like dense technical specifications rather than airy marketing copy. +- **Weight gradient, not weight contrast**: The system uses a gentle 300→400→500→600→700 weight progression. Bold (700) is reserved for sub-headings and code-button emphasis. Most body text lives at 400–500, creating subtle rather than dramatic hierarchy. +- **Uppercase is earned and wide**: When uppercase appears, it's always paired with generous letter-spacing (0.45px–2.52px), transforming dense words into spaced-out overline labels. This treatment is never applied to headings. +- **OpenType by default**: Both system-ui and Inter enable `"calt"` and `"rlig"` features, ensuring contextual character adjustments and ligature rendering throughout. + +## 4. Component Stylings + +### Buttons + +**Ghost / Outline (Standard)** +- Background: transparent +- Text: Pure White (`#ffffff`) +- Padding: comfortable (12px 16px) +- Border: thin solid Warm Charcoal (`1px solid #3d3a39`) +- Radius: comfortably rounded (6px) +- Hover: background darkens to `rgba(0, 0, 0, 0.2)`, opacity drops to 0.4 +- Outline: subtle green tint (`rgba(33, 196, 93, 0.5)`) +- The default interactive element — unassuming but clearly clickable + +**Primary Green CTA** +- Background: Carbon Surface (`#101010`) +- Text: VoltAgent Mint (`#2fd6a1`) +- Padding: comfortable (12px 16px) +- Border: none visible (outline-based focus indicator) +- Outline: VoltAgent Mint (`rgb(47, 214, 161)`) +- Hover: same darkening behavior as Ghost +- The "powered on" button — green text on dark surface reads as an active terminal command + +**Tertiary / Emphasized Container Button** +- Background: Carbon Surface (`#101010`) +- Text: Snow White (`#f2f2f2`) +- Padding: generous (20px all sides) +- Border: thick solid Warm Charcoal (`3px solid #3d3a39`) +- Radius: comfortably rounded (8px) +- A card-like button treatment for larger interactive surfaces (code copy blocks, feature CTAs) + +### Cards & Containers +- Background: Carbon Surface (`#101010`) — one shade lighter than the page canvas +- Border: `1px solid #3d3a39` (Warm Charcoal) for standard containment; `2px solid #00d992` for highlighted/active cards +- Radius: comfortably rounded (8px) for content cards; subtly rounded (4–6px) for smaller inline containers +- Shadow Level 1: Warm Ambient Haze (`rgba(92, 88, 85, 0.2) 0px 0px 15px`) for standard elevation +- Shadow Level 2: Deep Dramatic (`rgba(0, 0, 0, 0.7) 0px 20px 60px` + `rgba(148, 163, 184, 0.1) 0px 0px 0px 1px inset`) for hero/feature showcase cards +- Hover behavior: likely border color shift toward green accent or subtle opacity increase +- Dashed variant: `1px dashed rgba(79, 93, 117, 0.4)` for workflow/diagram containers — visually distinct from solid-border content cards + +### Inputs & Forms +- No explicit input token data extracted — the site is landing-page focused with minimal form UI +- The npm install command (`npm create voltagent-app@latest`) is presented as a code block rather than an input field +- Inferred style: Carbon Surface background, Warm Charcoal border, VoltAgent Mint focus ring, Snow White text + +### Navigation +- Sticky top nav bar on Abyss Black canvas +- Logo: VoltAgent bolt icon with animated green glow (`drop-shadow` cycling 2px–8px) +- Nav structure: Logo → Product dropdown → Use Cases dropdown → Resources dropdown → GitHub stars badge → Docs CTA +- Link text: Snow White (`#f2f2f2`) at 14–16px Inter, weight 500 +- Hover: links transition to green variants (`#00c182` or `#00ffaa`) +- GitHub badge: social proof element integrated directly into nav +- Mobile: collapses to hamburger menu, single-column vertical layout + +### Image Treatment +- Dark-themed product screenshots and architectural diagrams dominate +- Code blocks are treated as primary visual content — syntax-highlighted with SFMono-Regular +- Agent workflow visualizations appear as interactive node graphs with green connection lines +- Decorative dot-pattern backgrounds appear behind hero sections +- Full-bleed within card containers, respecting 8px radius rounding + +### Distinctive Components + +**npm Install Command Block** +- A prominent code snippet (`npm create voltagent-app@latest`) styled as a copyable command +- SFMono-Regular on Carbon Surface with a copy-to-clipboard button +- Functions as the primary CTA — "install first, read later" developer psychology + +**Company Logo Marquee** +- Horizontal scrolling strip of developer/company logos +- Infinite animation (`scrollLeft`/`scrollRight`, 25–80s durations) +- Pauses on hover and for users with reduced-motion preferences +- Demonstrates ecosystem adoption without cluttering the layout + +**Feature Section Cards** +- Large cards combining code examples with descriptive text +- Left: code snippet with syntax highlighting; Right: feature description +- Green accent border (`2px solid #00d992`) on highlighted/active features +- Internal padding: generous (24–32px estimated) + +**Agent Flow Diagrams** +- Interactive node-graph visualizations showing agent coordination +- Connection lines use VoltAgent green variants +- Nodes styled as mini-cards within the Warm Charcoal border system + +**Community / GitHub Section** +- Large GitHub icon as the visual anchor +- Star count and contributor metrics prominently displayed +- Warm social proof: Discord, X, Reddit, LinkedIn, YouTube links in footer + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 2px, 4px, 5px, 6px, 6.4px, 8px, 12px, 16px, 20px, 24px, 28px, 32px, 40px, 48px, 64px +- Button padding: 12px 16px (standard), 20px (container-button) +- Card internal padding: approximately 24–32px +- Section vertical spacing: generous (estimated 64–96px between major sections) +- Component gap: 16–24px between sibling cards/elements + +### Grid & Container +- Max container width: approximately 1280–1440px, centered +- Hero: centered single-column with maximum breathing room +- Feature sections: alternating asymmetric layouts (code left / text right, then reversed) +- Logo marquee: full-width horizontal scroll, breaking the container constraint +- Card grids: 2–3 column for feature showcases +- Integration grid: responsive multi-column for partner/integration icons + +### Whitespace Philosophy +- **Cinematic breathing room between sections**: Massive vertical gaps create a "scroll-through-chapters" experience — each section feels like a new scene. +- **Dense within components**: Cards and code blocks are internally compact, with tight line-heights and controlled padding. Information is concentrated, not spread thin. +- **Border-defined separation**: Rather than relying solely on whitespace, VoltAgent uses the Warm Charcoal border system (`#3d3a39`) to delineate content zones. The border IS the whitespace signal. +- **Hero-first hierarchy**: The top of the page commands the most space — the "AI Agent Engineering Platform" headline and npm command get maximum vertical runway before the first content section appears. + +### Border Radius Scale +- Nearly squared (4px): Small inline elements, SVG containers, code spans — the sharpest treatment, conveying technical precision +- Subtly rounded (6px): Buttons, links, clipboard actions — the workhorse radius for interactive elements +- Code-specific (6.4px): Code blocks, `pre` elements, clipboard copy targets — a deliberate micro-distinction from standard 6px +- Comfortably rounded (8px): Content cards, feature containers, emphasized buttons — the standard containment radius +- Pill-shaped (9999px): Tags, badges, status indicators, pill-shaped navigation elements — the roundest treatment for small categorical labels + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background (`#050507`), inline text | +| Contained (Level 1) | `1px solid #3d3a39`, no shadow | Standard cards, nav bar, code blocks | +| Emphasized (Level 2) | `3px solid #3d3a39`, no shadow | Large interactive buttons, emphasized containers | +| Accent (Level 3) | `2px solid #00d992`, no shadow | Active/highlighted feature cards, selected states | +| Ambient Glow (Level 4) | `rgba(92, 88, 85, 0.2) 0px 0px 15px` | Elevated cards, hover states, soft atmospheric lift | +| Dramatic Float (Level 5) | `rgba(0, 0, 0, 0.7) 0px 20px 60px` + `rgba(148, 163, 184, 0.1) 1px inset` | Hero feature showcase, modals, maximum-elevation content | + +**Shadow Philosophy**: VoltAgent communicates depth primarily through **border weight and color**, not shadows. The standard `1px solid #3d3a39` border IS the elevation — adding a `3px` border weight or switching to green (`#00d992`) communicates importance more than adding shadow does. When shadows do appear, they're either warm and diffused (Level 4) or cinematic and dramatic (Level 5) — never medium or generic. + +### Decorative Depth +- **Green Signal Glow**: The VoltAgent bolt logo pulses with a `drop-shadow` animation cycling between 2px and 8px blur radius in Emerald Signal Green. This is the most distinctive decorative element — it makes the logo feel "powered on." +- **Warm Charcoal Containment Lines**: The warm tone of `#3d3a39` borders creates a subtle visual warmth against the cool black, as if the cards are faintly heated from within. +- **Dashed Workflow Lines**: `1px dashed rgba(79, 93, 117, 0.4)` creates a blueprint-like aesthetic for architecture diagrams, visually distinct from solid content borders. + +## 7. Do's and Don'ts + +### Do +- Use Abyss Black (`#050507`) as the landing page background and Carbon Surface (`#101010`) for all contained elements — the two-shade dark system is essential +- Reserve Emerald Signal Green (`#00d992`) exclusively for high-signal moments: active borders, glow effects, and the most important interactive accents +- Use VoltAgent Mint (`#2fd6a1`) for button text on dark surfaces — it's more readable than pure Signal Green +- Keep heading line-heights compressed (1.0–1.11) with negative letter-spacing for dense, authoritative text blocks +- Use the warm gray palette (`#3d3a39`, `#8b949e`, `#b8b3b0`) for borders and secondary text — warmth prevents the dark theme from feeling sterile +- Present code snippets as primary content — they're hero elements, not supporting illustrations +- Use border weight (1px → 2px → 3px) and color shifts (`#3d3a39` → `#00d992`) to communicate depth and importance, rather than relying on shadows +- Pair system-ui for headings with Inter for body text — the speed/authority of native fonts combined with the precision of a geometric sans +- Use SFMono-Regular for all code content — it's the developer credibility signal +- Apply `"calt"` and `"rlig"` OpenType features across all text + +### Don't +- Don't use bright or light backgrounds as primary surfaces — the entire identity lives on near-black +- Don't introduce warm colors (orange, red, yellow) as decorative accents — the palette is strictly green + warm neutrals on black. Warm colors are reserved for semantic states (warning, error) only +- Don't use Emerald Signal Green (`#00d992`) on large surfaces or as background fills — it's an accent, never a surface +- Don't increase heading line-heights beyond 1.33 — the compressed density is core to the engineering-platform identity +- Don't use heavy shadows generously — depth comes from border treatment, not box-shadow. Shadows are reserved for Level 4–5 elevation only +- Don't use pure white (`#ffffff`) as default body text — Snow White (`#f2f2f2`) is the standard. Pure white is reserved for maximum-emphasis headings and button text +- Don't mix in serif or decorative fonts — the entire system is geometric sans + monospace +- Don't use border-radius larger than 8px on content cards — 9999px (pill) is only for small tags and badges +- Don't skip the warm-gray border system — cards without `#3d3a39` borders lose their containment and float ambiguously on the dark canvas +- Don't animate aggressively — animations are slow and subtle (25–100s durations for marquee, gentle glow pulses). Fast motion contradicts the "engineering precision" atmosphere + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Small Mobile | <420px | Minimum layout, stacked everything, reduced hero text to ~24px | +| Mobile | 420–767px | Single column, hamburger nav, full-width cards, hero text ~36px | +| Tablet | 768–1024px | 2-column grids begin, condensed nav, medium hero text | +| Desktop | 1025–1440px | Full multi-column layout, expanded nav with dropdowns, large hero (60px) | +| Large Desktop | >1440px | Max-width container centered (est. 1280–1440px), generous horizontal margins | + +*23 breakpoints detected in total, ranging from 360px to 1992px — indicating a fluid, heavily responsive grid system rather than fixed breakpoint snapping.* + +### Touch Targets +- Buttons use comfortable padding (12px 16px minimum) ensuring adequate touch area +- Navigation links spaced with sufficient gap for thumb navigation +- Interactive card surfaces are large enough to serve as full touch targets +- Minimum recommended touch target: 44x44px + +### Collapsing Strategy +- **Navigation**: Full horizontal nav with dropdowns collapses to hamburger menu on mobile +- **Feature grids**: 3-column → 2-column → single-column vertical stacking +- **Hero text**: 60px → 36px → 24px progressive scaling with maintained compression ratios +- **Logo marquee**: Adjusts scroll speed and item sizing; maintains infinite loop +- **Code blocks**: Horizontal scroll on smaller viewports rather than wrapping — preserving code readability +- **Section padding**: Reduces proportionally but maintains generous vertical rhythm between chapters +- **Cards**: Stack vertically on mobile with full-width treatment and maintained internal padding + +### Image Behavior +- Dark-themed screenshots and diagrams scale proportionally within containers +- Agent flow diagrams simplify or scroll horizontally on narrow viewports +- Dot-pattern decorative backgrounds scale with viewport +- No visible art direction changes between breakpoints — same crops, proportional scaling +- Lazy loading for below-fold images (Docusaurus default behavior) + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Brand Accent: "Emerald Signal Green (#00d992)" +- Button Text: "VoltAgent Mint (#2fd6a1)" +- Page Background: "Abyss Black (#050507)" +- Card Surface: "Carbon Surface (#101010)" +- Border / Containment: "Warm Charcoal (#3d3a39)" +- Primary Text: "Snow White (#f2f2f2)" +- Secondary Text: "Warm Parchment (#b8b3b0)" +- Tertiary Text: "Steel Slate (#8b949e)" + +### Example Component Prompts +- "Create a feature card on Carbon Surface (#101010) with a 1px solid Warm Charcoal (#3d3a39) border, comfortably rounded corners (8px). Use Snow White (#f2f2f2) for the title in system-ui at 24px weight 700, and Warm Parchment (#b8b3b0) for the description in Inter at 16px. Add a subtle Warm Ambient shadow (rgba(92, 88, 85, 0.2) 0px 0px 15px)." +- "Design a ghost button with transparent background, Snow White (#f2f2f2) text in Inter at 16px, a 1px solid Warm Charcoal (#3d3a39) border, and subtly rounded corners (6px). Padding: 12px vertical, 16px horizontal. On hover, background shifts to rgba(0, 0, 0, 0.2)." +- "Build a hero section on Abyss Black (#050507) with a massive heading at 60px system-ui, line-height 1.0, letter-spacing -0.65px. The word 'Platform' should be colored in Emerald Signal Green (#00d992). Below the heading, place a code block showing 'npm create voltagent-app@latest' in SFMono-Regular at 14px on Carbon Surface (#101010) with a copy button." +- "Create a highlighted feature card using a 2px solid Emerald Signal Green (#00d992) border instead of the standard Warm Charcoal. Keep Carbon Surface background, comfortably rounded corners (8px), and include a code snippet on the left with feature description text on the right." +- "Design a navigation bar on Abyss Black (#050507) with the VoltAgent logo (bolt icon with animated green glow) on the left, nav links in Inter at 14px weight 500 in Snow White, and a green CTA button (Carbon Surface bg, VoltAgent Mint text) on the right. Add a 1px solid Warm Charcoal bottom border." + +### Iteration Guide +When refining existing screens generated with this design system: +1. Focus on ONE component at a time +2. Reference specific color names and hex codes — "use Warm Parchment (#b8b3b0)" not "make it lighter" +3. Use border treatment to communicate elevation: "change the border to 2px solid Emerald Signal Green (#00d992)" for emphasis +4. Describe the desired "feel" alongside measurements — "compressed and authoritative heading at 36px with line-height 1.11 and -0.9px letter-spacing" +5. For glow effects, specify "Emerald Signal Green (#00d992) as a drop-shadow with 2–8px blur radius" +6. Always specify which font — system-ui for headings, Inter for body/UI, SFMono-Regular for code +7. Keep animations slow and subtle — marquee scrolls at 25–80s, glow pulses gently diff --git a/skills/creative/popular-web-designs/templates/warp.md b/skills/creative/popular-web-designs/templates/warp.md new file mode 100644 index 000000000000..08e8fa6a19cc --- /dev/null +++ b/skills/creative/popular-web-designs/templates/warp.md @@ -0,0 +1,266 @@ +# Design System: Warp + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Geist` | **Mono:** `Geist Mono` +> - **Font stack (CSS):** `font-family: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Warp's website feels like sitting at a campfire in a deep forest — warm, dark, and alive with quiet confidence. Unlike the cold, blue-tinted blacks favored by most developer tools, Warp wraps everything in a warm near-black that feels like charred wood or dark earth. The text isn't pure white either — it's Warm Parchment (`#faf9f6`), a barely-perceptible cream that softens every headline and makes the dark canvas feel inviting rather than austere. + +The typography is the secret weapon: Matter, a geometric sans-serif with distinctive character, deployed at Regular weight across virtually all text. The font choice is unusual for a developer tool — Matter has a softness and humanity that signals "this terminal is for everyone, not just greybeards." Combined with tight line-heights and controlled negative letter-spacing on headlines, the effect is refined and approachable simultaneously. Nature photography is woven between terminal screenshots, creating a visual language that says: this tool brings you closer to flow, to calm productivity. + +The overall design philosophy is restraint through warmth. Minimal color (almost monochromatic warm grays), minimal ornamentation, and a focus on product showcases set against cinematic dark landscapes. It's a terminal company that markets like a lifestyle brand. + +**Key Characteristics:** +- Warm dark background — not cold black, but earthy near-black with warm gray undertones +- Warm Parchment (`#faf9f6`) text instead of pure white — subtle cream warmth +- Matter font family (Regular weight) — geometric but approachable, not the typical developer-tool typeface +- Nature photography interleaved with product screenshots — lifestyle meets developer tool +- Almost monochromatic warm gray palette — no bold accent colors +- Uppercase labels with wide letter-spacing (2.4px) for categorization — editorial signaling +- Pill-shaped dark buttons (`#353534`, 50px radius) — restrained, muted CTAs + +## 2. Color Palette & Roles + +### Primary +- **Warm Parchment** (`#faf9f6`): Primary text color — a barely-cream off-white that softens every surface +- **Earth Gray** (`#353534`): Button backgrounds, dark interactive surfaces — warm, not cold +- **Deep Void** (near-black, page background): The warm dark canvas derived from the body background + +### Secondary & Accent +- **Stone Gray** (`#868584`): Secondary text, muted descriptions — warm mid-gray +- **Ash Gray** (`#afaeac`): Body text, button text — the workhorse reading color +- **Purple-Tint Gray** (`#666469`): Link text with subtle purple undertone — underlined links in content + +### Surface & Background +- **Frosted Veil** (`rgba(255, 255, 255, 0.04)`): Ultra-subtle white overlay for surface differentiation +- **Mist Border** (`rgba(226, 226, 226, 0.35)` / `rgba(227, 227, 227, 0.337)`): Semi-transparent borders for card containment +- **Translucent Parchment** (`rgba(250, 249, 246, 0.9)`): Slightly transparent primary surface, allowing depth + +### Neutrals & Text +- **Warm Parchment** (`#faf9f6`): Headlines, high-emphasis text +- **Ash Gray** (`#afaeac`): Body paragraphs, descriptions +- **Stone Gray** (`#868584`): Secondary labels, subdued information +- **Muted Purple** (`#666469`): Underlined links, tertiary content +- **Dark Charcoal** (`#454545` / `#353534`): Borders, button backgrounds + +### Semantic & Accent +- Warp operates as an almost monochromatic system — no bold accent colors +- Interactive states are communicated through opacity changes and underline decorations rather than color shifts +- Any accent color would break the warm, restrained palette + +### Gradient System +- No explicit gradients on the marketing site +- Depth is created through layered semi-transparent surfaces and photography rather than color gradients + +## 3. Typography Rules + +### Font Family +- **Display & Body**: `Matter Regular` — geometric sans-serif with soft character. Fallbacks: `Matter Regular Placeholder`, system sans-serif +- **Medium**: `Matter Medium` — weight 500 variant for emphasis. Fallbacks: `Matter Medium Placeholder` +- **Square**: `Matter SQ Regular` — squared variant for select display contexts. Fallbacks: `Matter SQ Regular Placeholder` +- **UI Supplement**: `Inter` — used for specific UI elements. Fallbacks: `Inter Placeholder` +- **Monospace Display**: `Geist Mono` — for code/terminal display headings +- **Monospace Body**: `Matter Mono Regular` — custom mono companion. Fallbacks: `Matter Mono Regular Placeholder` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero | Matter Regular | 80px | 400 | 1.00 | -2.4px | Maximum compression, hero impact | +| Section Display | Matter Regular | 56px | 400 | 1.20 | -0.56px | Feature section headings | +| Section Heading | Matter Regular | 48px | 400 | 1.20 | -0.48px to -0.96px | Alternate heading weight | +| Feature Heading | Matter Regular | 40px | 400 | 1.10 | -0.4px | Feature block titles | +| Sub-heading Large | Matter Regular | 36px | 400 | 1.15 | -0.72px | Sub-section headers | +| Card Display | Matter SQ Regular | 42px | 400 | 1.00 | 0px | Squared variant for special display | +| Sub-heading | Matter Regular | 32px | 400 | 1.19 | 0px | Content sub-headings | +| Body Heading | Matter Regular | 24px | 400 | 1.20 | -0.72px to 0px | Bold content intros | +| Card Title | Matter Medium | 22px | 500 | 1.14 | 0px | Emphasized card headers | +| Body Large | Matter Regular | 20px | 400 | 1.40 | -0.2px | Primary body text, relaxed | +| Body | Matter Regular | 18px | 400 | 1.30 | -0.18px | Standard body paragraphs | +| Nav/UI | Matter Regular | 16px | 400 | 1.20 | 0px | Navigation links, UI text | +| Button Text | Matter Medium | 16px | 500 | 1.20 | 0px | Button labels | +| Caption | Matter Regular | 14px | 400 | 1.00 | 1.4px | Uppercase labels (transform: uppercase) | +| Small Label | Matter Regular | 12px | 400 | 1.35 | 2.4px | Uppercase micro-labels (transform: uppercase) | +| Micro | Matter Regular | 11px | 400 | 1.20 | 0px | Smallest text elements | +| Code UI | Geist Mono | 16px | 400 | 1.00 | 0px | Terminal/code display | +| Code Body | Matter Mono Regular | 16px | 400 | 1.00 | -0.2px | Code content | +| UI Supplement | Inter | 16px | 500 | 1.00 | -0.2px | Specific UI elements | + +### Principles +- **Regular weight dominance**: Nearly all text uses weight 400 (Regular) — even headlines. Matter Medium (500) appears only for emphasis moments like card titles and buttons. This creates a remarkably even, calm typographic texture +- **Uppercase as editorial signal**: Small labels and categories use uppercase transform with wide letter-spacing (1.4px–2.4px), creating a magazine-editorial categorization system +- **Warm legibility**: The combination of Matter's geometric softness + warm text colors (#faf9f6) + controlled negative tracking creates text that reads as effortlessly human on dark surfaces +- **No bold display**: Zero use of bold (700+) weight anywhere — restraint is the philosophy + +## 4. Component Stylings + +### Buttons +- **Dark Pill**: `#353534` background, Ash Gray (`#afaeac`) text, pill shape (50px radius), `10px` padding. The primary CTA — warm, muted, understated +- **Frosted Tag**: `rgba(255, 255, 255, 0.16)` background, black text (`rgb(0, 0, 0)`), rectangular (6px radius), `1px 6px` padding. Small inline tag-like buttons +- **Ghost**: No visible background, text-only with underline decoration on hover +- **Hover**: Subtle opacity or brightness shift — no dramatic color changes + +### Cards & Containers +- **Photography Cards**: Full-bleed nature imagery with overlay text, 8px–12px border-radius +- **Terminal Screenshot Cards**: Product UI embedded in dark containers with rounded corners (8px–12px) +- **Bordered Cards**: Semi-transparent border (`rgba(226, 226, 226, 0.35)`) for containment, 12px–14px radius +- **Hover**: Minimal — content cards don't dramatically change on hover, maintaining the calm aesthetic + +### Inputs & Forms +- Minimal form presence on the marketing site +- Dark background inputs with warm gray text +- Focus: Border brightness increase, no colored rings (consistent with the monochromatic palette) + +### Navigation +- **Top nav**: Dark background, warm parchment brand text, Matter Regular at 16px for links +- **Link color**: Stone Gray (`#868584`) for muted nav, Warm Parchment for active/hover +- **CTA button**: Dark pill (#353534) at nav end — restrained, not attention-grabbing +- **Mobile**: Collapses to simplified navigation +- **Sticky**: Nav stays fixed on scroll + +### Image Treatment +- **Nature photography**: Landscapes, forests, golden-hour scenes — completely unique for a developer tool +- **Terminal screenshots**: Product UI shown in realistic terminal window frames +- **Mixed composition**: Nature images and terminal screenshots are interleaved, creating a lifestyle-meets-tool narrative +- **Full-bleed**: Images often span full container width with 8px radius +- **Video**: Video elements present with 10px border-radius + +### Testimonial Section +- Social proof area ("Don't take our word for it") with quotes +- Muted styling consistent with overall restraint + +## 5. Layout Principles + +### Spacing System +- **Base unit**: 8px +- **Scale**: 1px, 4px, 5px, 8px, 10px, 12px, 14px, 15px, 16px, 18px, 24px, 26px, 30px, 32px, 36px +- **Section padding**: 80px–120px vertical between major sections +- **Card padding**: 16px–32px internal spacing +- **Component gaps**: 8px–16px between related elements + +### Grid & Container +- **Max width**: ~1500px container (breakpoint at 1500px), centered +- **Column patterns**: Full-width hero, 2-column feature sections with photography, single-column testimonials +- **Cinematic layout**: Wide containers that let photography breathe + +### Whitespace Philosophy +- **Vast and warm**: Generous spacing between sections — the dark background creates a warm void that feels contemplative rather than empty +- **Photography as whitespace**: Nature images serve as visual breathing room between dense product information +- **Editorial pacing**: The layout reads like a magazine — each section is a deliberate page-turn moment + +### Border Radius Scale +- **4px**: Small interactive elements — buttons, tags +- **5px–6px**: Standard components — links, small containers +- **8px**: Images, video containers, standard cards +- **10px**: Video elements, medium containers +- **12px**: Feature cards, large images +- **14px**: Large containers, prominent cards +- **40px**: Large rounded sections +- **50px**: Pill buttons — primary CTAs +- **200px**: Progress bars — full pill shape + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Level 0 (Flat) | No shadow, dark background | Page canvas, most surfaces | +| Level 1 (Veil) | `rgba(255, 255, 255, 0.04)` overlay | Subtle surface differentiation | +| Level 2 (Border) | `rgba(226, 226, 226, 0.35) 1px` border | Card containment, section separation | +| Level 3 (Ambient) | `rgba(0, 0, 0, 0.2) 0px 5px 15px` (inferred from design) | Image containers, floating elements | + +### Shadow Philosophy +Warp's elevation system is remarkably flat — almost zero shadow usage on the marketing site. Depth is communicated through: +- **Semi-transparent borders** instead of shadows — borders at 35% opacity create a ghostly containment +- **Photography layering** — images create natural depth without artificial shadows +- **Surface opacity shifts** — `rgba(255, 255, 255, 0.04)` overlays create barely-perceptible layer differences +- The effect is calm and grounded — nothing floats, everything rests + +### Decorative Depth +- **Photography as depth**: Nature images create atmospheric depth that shadows cannot +- **No glass or blur effects**: The design avoids trendy glassmorphism entirely +- **Warm ambient**: Any glow comes from the photography's natural lighting, not artificial CSS + +## 7. Do's and Don'ts + +### Do +- Use warm off-white (`#faf9f6`) for text instead of pure white — the cream undertone is essential +- Keep buttons restrained and muted — dark fill (#353534) with muted text (#afaeac), no bright CTAs +- Apply Matter Regular (weight 400) for nearly everything — even headlines. Reserve Medium (500) for emphasis only +- Use uppercase labels with wide letter-spacing (1.4px–2.4px) for categorization +- Interleave nature photography with product screenshots — this is core to the brand identity +- Maintain the almost monochromatic warm gray palette — no bold accent colors +- Use semi-transparent borders (`rgba(226, 226, 226, 0.35)`) for card containment instead of shadows +- Keep negative letter-spacing on headlines (-0.4px to -2.4px) for Matter's compressed display treatment + +### Don't +- Use pure white (#ffffff) for text — it's always warm parchment (#faf9f6) +- Add bold accent colors (blue, red, green) — the system is deliberately monochromatic warm grays +- Apply bold weight (700+) to any text — Warp never goes above Medium (500) +- Use heavy drop shadows — depth comes from borders, photography, and opacity shifts +- Create cold or blue-tinted dark backgrounds — the warmth is essential +- Add decorative gradients or glow effects — the photography provides all visual interest +- Use tight, compressed layouts — the editorial spacing is generous and contemplative +- Mix in additional typefaces beyond the Matter family + Inter supplement + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <810px | Single column, stacked sections, hero text reduces to ~48px, hamburger nav | +| Tablet | 810px–1500px | 2-column features begin, photography scales, nav links partially visible | +| Desktop | >1500px | Full cinematic layout, 80px hero display, side-by-side photography + text | + +### Touch Targets +- Pill buttons: 50px radius with 10px padding — comfortable touch targets +- Nav links: 16px text with surrounding padding for accessibility +- Mobile CTAs: Full-width pills on mobile for easy thumb reach + +### Collapsing Strategy +- **Navigation**: Full horizontal nav → simplified mobile navigation +- **Hero text**: 80px display → 56px → 48px across breakpoints +- **Feature sections**: Side-by-side photography + text → stacked vertically +- **Photography**: Scales within containers, maintains cinematic aspect ratios +- **Section spacing**: Reduces proportionally — generous desktop → compact mobile + +### Image Behavior +- Nature photography scales responsively, maintaining wide cinematic ratios +- Terminal screenshots maintain aspect ratios within responsive containers +- Video elements scale with 10px radius maintained +- No art direction changes — same compositions across breakpoints + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary Text: Warm Parchment (`#faf9f6`) +- Secondary Text: Ash Gray (`#afaeac`) +- Tertiary Text: Stone Gray (`#868584`) +- Button Background: Earth Gray (`#353534`) +- Border: Mist Border (`rgba(226, 226, 226, 0.35)`) +- Background: Deep warm near-black (page background) + +### Example Component Prompts +- "Create a hero section on warm dark background with 80px Matter Regular heading in warm parchment (#faf9f6), line-height 1.0, letter-spacing -2.4px, and a dark pill button (#353534, 50px radius, #afaeac text)" +- "Design a feature card with semi-transparent border (rgba(226,226,226,0.35)), 12px radius, warm dark background, Matter Regular heading at 24px, and ash gray (#afaeac) body text at 18px" +- "Build a category label using Matter Regular at 12px, uppercase transform, letter-spacing 2.4px, stone gray (#868584) color — editorial magazine style" +- "Create a testimonial section with warm parchment quotes in Matter Regular 24px, attributed in stone gray (#868584), on dark background with minimal ornamentation" +- "Design a navigation bar with warm dark background, Matter Regular links at 16px in stone gray (#868584), hover to warm parchment (#faf9f6), and a dark pill CTA button (#353534) at the right" + +### Iteration Guide +When refining existing screens generated with this design system: +1. Verify text color is warm parchment (#faf9f6) not pure white — the warmth is subtle but essential +2. Ensure all buttons use the restrained dark palette (#353534) — no bright or colorful CTAs +3. Check that Matter Regular (400) is the default weight — Medium (500) only for emphasis +4. Confirm uppercase labels have wide letter-spacing (1.4px–2.4px) — tight uppercase feels wrong here +5. The overall tone should feel warm and calm, like a well-designed magazine — not aggressive or tech-flashy diff --git a/skills/creative/popular-web-designs/templates/webflow.md b/skills/creative/popular-web-designs/templates/webflow.md new file mode 100644 index 000000000000..db80ddc42f02 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/webflow.md @@ -0,0 +1,105 @@ +# Design System: Webflow + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Webflow's website is a visually rich, tool-forward platform that communicates "design without code" through clean white surfaces, the signature Webflow Blue (`#146ef5`), and a rich secondary color palette (purple, pink, green, orange, yellow, red). The custom WF Visual Sans Variable font creates a confident, precise typographic system with weight 600 for display and 500 for body. + +**Key Characteristics:** +- White canvas with near-black (`#080808`) text +- Webflow Blue (`#146ef5`) as primary brand + interactive color +- WF Visual Sans Variable — custom variable font with weight 500–600 +- Rich secondary palette: purple `#7a3dff`, pink `#ed52cb`, green `#00d722`, orange `#ff6b00`, yellow `#ffae13`, red `#ee1d36` +- Conservative 4px–8px border-radius — sharp, not rounded +- Multi-layer shadow stacks (5-layer cascading shadows) +- Uppercase labels: 10px–15px, weight 500–600, wide letter-spacing (0.6px–1.5px) +- translate(6px) hover animation on buttons + +## 2. Color Palette & Roles + +### Primary +- **Near Black** (`#080808`): Primary text +- **Webflow Blue** (`#146ef5`): `--_color---primary--webflow-blue`, primary CTA and links +- **Blue 400** (`#3b89ff`): `--_color---primary--blue-400`, lighter interactive blue +- **Blue 300** (`#006acc`): `--_color---blue-300`, darker blue variant +- **Button Hover Blue** (`#0055d4`): `--mkto-embed-color-button-hover` + +### Secondary Accents +- **Purple** (`#7a3dff`): `--_color---secondary--purple` +- **Pink** (`#ed52cb`): `--_color---secondary--pink` +- **Green** (`#00d722`): `--_color---secondary--green` +- **Orange** (`#ff6b00`): `--_color---secondary--orange` +- **Yellow** (`#ffae13`): `--_color---secondary--yellow` +- **Red** (`#ee1d36`): `--_color---secondary--red` + +### Neutral +- **Gray 800** (`#222222`): Dark secondary text +- **Gray 700** (`#363636`): Mid text +- **Gray 300** (`#ababab`): Muted text, placeholder +- **Mid Gray** (`#5a5a5a`): Link text +- **Border Gray** (`#d8d8d8`): Borders, dividers +- **Border Hover** (`#898989`): Hover border + +### Shadows +- **5-layer cascade**: `rgba(0,0,0,0) 0px 84px 24px, rgba(0,0,0,0.01) 0px 54px 22px, rgba(0,0,0,0.04) 0px 30px 18px, rgba(0,0,0,0.08) 0px 13px 13px, rgba(0,0,0,0.09) 0px 3px 7px` + +## 3. Typography Rules + +### Font: `WF Visual Sans Variable`, fallback: `Arial` + +| Role | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|--------|-------------|----------------|-------| +| Display Hero | 80px | 600 | 1.04 | -0.8px | | +| Section Heading | 56px | 600 | 1.04 | normal | | +| Sub-heading | 32px | 500 | 1.30 | normal | | +| Feature Title | 24px | 500–600 | 1.30 | normal | | +| Body | 20px | 400–500 | 1.40–1.50 | normal | | +| Body Standard | 16px | 400–500 | 1.60 | -0.16px | | +| Button | 16px | 500 | 1.60 | -0.16px | | +| Uppercase Label | 15px | 500 | 1.30 | 1.5px | uppercase | +| Caption | 14px | 400–500 | 1.40–1.60 | normal | | +| Badge Uppercase | 12.8px | 550 | 1.20 | normal | uppercase | +| Micro Uppercase | 10px | 500–600 | 1.30 | 1px | uppercase | +| Code: Inconsolata (companion monospace font) + +## 4. Component Stylings + +### Buttons +- Transparent: text `#080808`, translate(6px) on hover +- White circle: 50% radius, white bg +- Blue badge: `#146ef5` bg, 4px radius, weight 550 + +### Cards: `1px solid #d8d8d8`, 4px–8px radius +### Badges: Blue-tinted bg at 10% opacity, 4px radius + +## 5. Layout +- Spacing: fractional scale (1px, 2.4px, 3.2px, 4px, 5.6px, 6px, 7.2px, 8px, 9.6px, 12px, 16px, 24px) +- Radius: 2px, 4px, 8px, 50% — conservative, sharp +- Breakpoints: 479px, 768px, 992px + +## 6. Depth: 5-layer cascading shadow system + +## 7. Do's and Don'ts +- Do: Use WF Visual Sans Variable at 500–600. Blue (#146ef5) for CTAs. 4px radius. translate(6px) hover. +- Don't: Round beyond 8px for functional elements. Use secondary colors on primary CTAs. + +## 8. Responsive: 479px, 768px, 992px + +## 9. Agent Prompt Guide +- Text: Near Black (`#080808`) +- CTA: Webflow Blue (`#146ef5`) +- Background: White (`#ffffff`) +- Border: `#d8d8d8` +- Secondary: Purple `#7a3dff`, Pink `#ed52cb`, Green `#00d722` diff --git a/skills/creative/popular-web-designs/templates/wise.md b/skills/creative/popular-web-designs/templates/wise.md new file mode 100644 index 000000000000..1f0a9494b342 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/wise.md @@ -0,0 +1,186 @@ +# Design System: Wise + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Wise's website is a bold, confident fintech platform that communicates "money without borders" through massive typography and a distinctive lime-green accent. The design operates on a warm off-white canvas with near-black text (`#0e0f0c`) and a signature Wise Green (`#9fe870`) — a fresh, lime-bright color that feels alive and optimistic, unlike the corporate blues of traditional banking. + +The typography uses Wise Sans — a proprietary font used at extreme weight 900 (black) for display headings with a remarkably tight line-height of 0.85 and OpenType `"calt"` (contextual alternates). At 126px, the text is so dense it feels like a protest sign — bold, urgent, and impossible to ignore. Inter serves as the body font with weight 600 as the default for emphasis, creating a consistently confident voice. + +What distinguishes Wise is its green-on-white-on-black material palette. Lime Green (`#9fe870`) appears on buttons with dark green text (`#163300`), creating a nature-inspired CTA that feels fresh. Hover states use `scale(1.05)` expansion rather than color changes — buttons physically grow on interaction. The border-radius system uses 9999px for buttons (pill), 30px–40px for cards, and the shadow system is minimal — just `rgba(14,15,12,0.12) 0px 0px 0px 1px` ring shadows. + +**Key Characteristics:** +- Wise Sans at weight 900, 0.85 line-height — billboard-scale bold headlines +- Lime Green (`#9fe870`) accent with dark green text (`#163300`) — nature-inspired fintech +- Inter body at weight 600 as default — confident, not light +- Near-black (`#0e0f0c`) primary with warm green undertone +- Scale(1.05) hover animations — buttons physically grow +- OpenType `"calt"` on all text +- Pill buttons (9999px) and large rounded cards (30px–40px) +- Semantic color system with comprehensive state management + +## 2. Color Palette & Roles + +### Primary Brand +- **Near Black** (`#0e0f0c`): Primary text, background for dark sections +- **Wise Green** (`#9fe870`): Primary CTA button, brand accent +- **Dark Green** (`#163300`): Button text on green, deep green accent +- **Light Mint** (`#e2f6d5`): Soft green surface, badge backgrounds +- **Pastel Green** (`#cdffad`): `--color-interactive-contrast-hover`, hover accent + +### Semantic +- **Positive Green** (`#054d28`): `--color-sentiment-positive-primary`, success +- **Danger Red** (`#d03238`): `--color-interactive-negative-hover`, error/destructive +- **Warning Yellow** (`#ffd11a`): `--color-sentiment-warning-hover`, warnings +- **Background Cyan** (`rgba(56,200,255,0.10)`): `--color-background-accent`, info tint +- **Bright Orange** (`#ffc091`): `--color-bright-orange`, warm accent + +### Neutral +- **Warm Dark** (`#454745`): Secondary text, borders +- **Gray** (`#868685`): Muted text, tertiary +- **Light Surface** (`#e8ebe6`): Subtle green-tinted light surface + +## 3. Typography Rules + +### Font Families +- **Display**: `Wise Sans`, fallback: `Inter` — OpenType `"calt"` on all text +- **Body / UI**: `Inter`, fallbacks: `Helvetica, Arial` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Mega | Wise Sans | 126px (7.88rem) | 900 | 0.85 (ultra-tight) | normal | `"calt"` | +| Display Hero | Wise Sans | 96px (6.00rem) | 900 | 0.85 | normal | `"calt"` | +| Section Heading | Wise Sans | 64px (4.00rem) | 900 | 0.85 | normal | `"calt"` | +| Sub-heading | Wise Sans | 40px (2.50rem) | 900 | 0.85 | normal | `"calt"` | +| Alt Heading | Inter | 78px (4.88rem) | 600 | 1.10 (tight) | -2.34px | `"calt"` | +| Card Title | Inter | 26px (1.62rem) | 600 | 1.23 (tight) | -0.39px | `"calt"` | +| Feature Title | Inter | 22px (1.38rem) | 600 | 1.25 (tight) | -0.396px | `"calt"` | +| Body | Inter | 18px (1.13rem) | 400 | 1.44 | 0.18px | `"calt"` | +| Body Semibold | Inter | 18px (1.13rem) | 600 | 1.44 | -0.108px | `"calt"` | +| Button | Inter | 18px–22px | 600 | 1.00–1.44 | -0.108px | `"calt"` | +| Caption | Inter | 14px (0.88rem) | 400–600 | 1.50–1.86 | -0.084px to -0.108px | `"calt"` | +| Small | Inter | 12px (0.75rem) | 400–600 | 1.00–2.17 | -0.084px to -0.108px | `"calt"` | + +### Principles +- **Weight 900 as identity**: Wise Sans Black (900) is used exclusively for display — the heaviest weight in any analyzed system. It creates text that feels stamped, pressed, physical. +- **0.85 line-height**: The tightest display line-height analyzed. Letters overlap vertically, creating dense, billboard-like text blocks. +- **"calt" everywhere**: Contextual alternates enabled on ALL text — both Wise Sans and Inter. +- **Weight 600 as body default**: Inter Semibold is the standard reading weight — confident, not light. + +## 4. Component Stylings + +### Buttons + +**Primary Green Pill** +- Background: `#9fe870` (Wise Green) +- Text: `#163300` (Dark Green) +- Padding: 5px 16px +- Radius: 9999px +- Hover: scale(1.05) — button physically grows +- Active: scale(0.95) — button compresses +- Focus: inset ring + outline + +**Secondary Subtle Pill** +- Background: `rgba(22, 51, 0, 0.08)` (dark green at 8% opacity) +- Text: `#0e0f0c` +- Padding: 8px 12px 8px 16px +- Radius: 9999px +- Same scale hover/active behavior + +### Cards & Containers +- Radius: 16px (small), 30px (medium), 40px (large cards/tables) +- Border: `1px solid rgba(14,15,12,0.12)` or `1px solid #9fe870` (green accent) +- Shadow: `rgba(14,15,12,0.12) 0px 0px 0px 1px` (ring shadow) + +### Navigation +- Green-tinted navigation hover: `rgba(211,242,192,0.4)` +- Clean header with Wise wordmark +- Pill CTAs right-aligned + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 2px, 3px, 4px, 5px, 8px, 10px, 11px, 12px, 16px, 18px, 19px, 20px, 22px, 24px + +### Border Radius Scale +- Minimal (2px): Links, inputs +- Standard (10px): Comboboxes, inputs +- Card (16px): Small cards, buttons, radio +- Medium (20px): Links, medium cards +- Large (30px): Feature cards +- Section (40px): Tables, large cards +- Mega (1000px): Presentation elements +- Pill (9999px): All buttons, images +- Circle (50%): Icons, badges + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Default | +| Ring (Level 1) | `rgba(14,15,12,0.12) 0px 0px 0px 1px` | Card borders | +| Inset (Level 2) | `rgb(134,134,133) 0px 0px 0px 1px inset` | Input focus | + +**Shadow Philosophy**: Wise uses minimal shadows — ring shadows only. Depth comes from the bold green accent against the neutral canvas. + +## 7. Do's and Don'ts + +### Do +- Use Wise Sans weight 900 for display — the extreme boldness IS the brand +- Apply line-height 0.85 on Wise Sans display — ultra-tight is intentional +- Use Lime Green (#9fe870) for primary CTAs with Dark Green (#163300) text +- Apply scale(1.05) hover and scale(0.95) active on buttons +- Enable "calt" on all text +- Use Inter weight 600 as the body default + +### Don't +- Don't use light font weights for Wise Sans — only 900 +- Don't relax the 0.85 line-height on display — the density is the identity +- Don't use the Wise Green as background for large surfaces — it's for buttons and accents +- Don't skip the scale animation on buttons +- Don't use traditional shadows — ring shadows only + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <576px | Single column | +| Tablet | 576–992px | 2-column | +| Desktop | 992–1440px | Full layout | +| Large | >1440px | Expanded | + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Text: Near Black (`#0e0f0c`) +- Background: White (`#ffffff` / off-white) +- Accent: Wise Green (`#9fe870`) +- Button text: Dark Green (`#163300`) +- Secondary: Gray (`#868685`) + +### Example Component Prompts +- "Create hero: white background. Headline at 96px Wise Sans weight 900, line-height 0.85, 'calt' enabled, #0e0f0c text. Green pill CTA (#9fe870, 9999px radius, 5px 16px padding, #163300 text). Hover: scale(1.05)." +- "Build a card: 30px radius, 1px solid rgba(14,15,12,0.12). Title at 22px Inter weight 600, body at 18px weight 400." + +### Iteration Guide +1. Wise Sans 900 at 0.85 line-height — the extreme weight IS the brand +2. Lime Green for buttons only — dark green text on green background +3. Scale animations (1.05 hover, 0.95 active) on all interactive elements +4. "calt" on everything — contextual alternates are mandatory +5. Inter 600 for body — confident reading weight diff --git a/skills/creative/popular-web-designs/templates/x.ai.md b/skills/creative/popular-web-designs/templates/x.ai.md new file mode 100644 index 000000000000..c22ac1e2c0de --- /dev/null +++ b/skills/creative/popular-web-designs/templates/x.ai.md @@ -0,0 +1,270 @@ +# Design System: xAI + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Geist Mono` | **Mono:** `Geist Mono` +> - **Font stack (CSS):** `font-family: 'Geist Mono', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +xAI's website is a masterclass in dark-first, monospace-driven brutalist minimalism -- a design system that feels like it was built by engineers who understand that restraint is the ultimate form of sophistication. The entire experience is anchored to an almost-black background (`#1f2228`) with pure white text (`#ffffff`), creating a high-contrast, terminal-inspired aesthetic that signals deep technical credibility. There are no gradients, no decorative illustrations, no color accents competing for attention. This is a site that communicates through absence. + +The typographic system is split between two carefully chosen typefaces. `GeistMono` (Vercel's monospace font) handles display-level headlines at an extraordinary 320px with weight 300, and also serves as the button typeface in uppercase with tracked-out letter-spacing (1.4px). `universalSans` handles all body and secondary heading text with a clean, geometric sans-serif voice. The monospace-as-display-font choice is the defining aesthetic decision -- it positions xAI not as a consumer product but as infrastructure, as something built by people who live in terminals. + +The spacing system operates on an 8px base grid with values concentrated at the small end (4px, 8px, 24px, 48px), reflecting a dense, information-focused layout philosophy. Border radius is minimal -- the site barely rounds anything, maintaining sharp, architectural edges. There are no decorative shadows, no gradients, no layered elevation. Depth is communicated purely through contrast and whitespace. + +**Key Characteristics:** +- Pure dark theme: `#1f2228` background with `#ffffff` text -- no gray middle ground +- GeistMono at extreme display sizes (320px, weight 300) -- monospace as luxury +- Uppercase monospace buttons with 1.4px letter-spacing -- technical, commanding +- universalSans for body text at 16px/1.5 and headings at 30px/1.2 -- clean contrast +- Zero decorative elements: no shadows, no gradients, no colored accents +- 8px spacing grid with a sparse, deliberate scale +- Heroicons SVG icon system -- minimal, functional +- Tailwind CSS with arbitrary values -- utility-first engineering approach + +## 2. Color Palette & Roles + +### Primary +- **Pure White** (`#ffffff`): The singular text color, link color, and all foreground elements. In xAI's system, white is not a background -- it is the voice. +- **Dark Background** (`#1f2228`): The canvas. A warm near-black with a subtle blue undertone (not pure black, not neutral gray). This specific hue prevents the harsh eye strain of `#000000` while maintaining deep darkness. + +### Interactive +- **White Default** (`#ffffff`): Link and interactive element color in default state. +- **White Muted** (`rgba(255, 255, 255, 0.5)`): Hover state for links -- a deliberate dimming rather than brightening, which is unusual and distinctive. +- **White Subtle** (`rgba(255, 255, 255, 0.2)`): Borders, dividers, and subtle surface treatments. +- **Ring Blue** (`rgb(59, 130, 246) / 0.5`): Tailwind's default focus ring color (`--tw-ring-color`), used for keyboard accessibility focus states. + +### Surface & Borders +- **Surface Elevated** (`rgba(255, 255, 255, 0.05)`): Subtle card backgrounds and hover surfaces -- barely visible lift. +- **Surface Hover** (`rgba(255, 255, 255, 0.08)`): Slightly more visible hover state for interactive containers. +- **Border Default** (`rgba(255, 255, 255, 0.1)`): Standard border for cards, dividers, and containers. +- **Border Strong** (`rgba(255, 255, 255, 0.2)`): Emphasized borders for active states and button outlines. + +### Functional +- **Text Primary** (`#ffffff`): All headings, body text, labels. +- **Text Secondary** (`rgba(255, 255, 255, 0.7)`): Descriptions, captions, supporting text. +- **Text Tertiary** (`rgba(255, 255, 255, 0.5)`): Muted labels, placeholder text, timestamps. +- **Text Quaternary** (`rgba(255, 255, 255, 0.3)`): Disabled text, very subtle annotations. + +## 3. Typography Rules + +### Font Family +- **Display / Buttons**: `GeistMono`, with fallback: `ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New` +- **Body / Headings**: `universalSans`, with fallback: `universalSans Fallback` + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Transform | Notes | +|------|------|------|--------|-------------|----------------|-----------|-------| +| Display Hero | GeistMono | 320px (20rem) | 300 | 1.50 | normal | none | Extreme scale, monospace luxury | +| Section Heading | universalSans | 30px (1.88rem) | 400 | 1.20 (tight) | normal | none | Clean sans-serif contrast | +| Body | universalSans | 16px (1rem) | 400 | 1.50 | normal | none | Standard reading text | +| Button | GeistMono | 14px (0.88rem) | 400 | 1.43 | 1.4px | uppercase | Tracked monospace, commanding | +| Label / Caption | universalSans | 14px (0.88rem) | 400 | 1.50 | normal | none | Supporting text | +| Small / Meta | universalSans | 12px (0.75rem) | 400 | 1.50 | normal | none | Timestamps, footnotes | + +### Principles +- **Monospace as display**: GeistMono at 320px is not a gimmick -- it is the brand statement. The fixed-width characters at extreme scale create a rhythmic, architectural quality that no proportional font can achieve. +- **Light weight at scale**: Weight 300 for the 320px headline prevents the monospace from feeling heavy or brutish at extreme sizes. It reads as precise, not overwhelming. +- **Uppercase buttons**: All button text is uppercase GeistMono with 1.4px letter-spacing. This creates a distinctly technical, almost command-line aesthetic for interactive elements. +- **Sans-serif for reading**: universalSans at 16px/1.5 provides excellent readability for body content, creating a clean contrast against the monospace display elements. +- **Two-font clarity**: The system uses exactly two typefaces with clear roles -- monospace for impact and interaction, sans-serif for information and reading. No overlap, no ambiguity. + +## 4. Component Stylings + +### Buttons + +**Primary (White on Dark)** +- Background: `#ffffff` +- Text: `#1f2228` +- Padding: 12px 24px +- Radius: 0px (sharp corners) +- Font: GeistMono 14px weight 400, uppercase, letter-spacing 1.4px +- Hover: `rgba(255, 255, 255, 0.9)` background +- Use: Primary CTA ("TRY GROK", "GET STARTED") + +**Ghost / Outlined** +- Background: transparent +- Text: `#ffffff` +- Padding: 12px 24px +- Radius: 0px +- Border: `1px solid rgba(255, 255, 255, 0.2)` +- Font: GeistMono 14px weight 400, uppercase, letter-spacing 1.4px +- Hover: `rgba(255, 255, 255, 0.05)` background +- Use: Secondary actions ("LEARN MORE", "VIEW API") + +**Text Link** +- Background: none +- Text: `#ffffff` +- Font: universalSans 16px weight 400 +- Hover: `rgba(255, 255, 255, 0.5)` -- dims on hover +- Use: Inline links, navigation items + +### Cards & Containers +- Background: `rgba(255, 255, 255, 0.03)` or transparent +- Border: `1px solid rgba(255, 255, 255, 0.1)` +- Radius: 0px (sharp) or 4px (subtle) +- Shadow: none -- xAI does not use box shadows +- Hover: border shifts to `rgba(255, 255, 255, 0.2)` + +### Navigation +- Dark background matching page (`#1f2228`) +- Brand logotype: white text, left-aligned +- Links: universalSans 14px weight 400, `#ffffff` text +- Hover: `rgba(255, 255, 255, 0.5)` text color +- CTA: white primary button, right-aligned +- Mobile: hamburger toggle + +### Badges / Tags +**Monospace Tag** +- Background: transparent +- Text: `#ffffff` +- Padding: 4px 8px +- Border: `1px solid rgba(255, 255, 255, 0.2)` +- Radius: 0px +- Font: GeistMono 12px uppercase, letter-spacing 1px + +### Inputs & Forms +- Background: transparent or `rgba(255, 255, 255, 0.05)` +- Border: `1px solid rgba(255, 255, 255, 0.2)` +- Radius: 0px +- Focus: ring with `rgb(59, 130, 246) / 0.5` +- Text: `#ffffff` +- Placeholder: `rgba(255, 255, 255, 0.3)` +- Label: `rgba(255, 255, 255, 0.7)`, universalSans 14px + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 4px, 8px, 24px, 48px +- The scale is deliberately sparse -- xAI avoids granular spacing distinctions, preferring large jumps that create clear visual hierarchy through whitespace alone + +### Grid & Container +- Max content width: approximately 1200px +- Hero: full-viewport height with massive centered monospace headline +- Feature sections: simple vertical stacking with generous section padding (48px-96px) +- Two-column layouts for feature descriptions at desktop +- Full-width dark sections maintain the single dark background throughout + +### Whitespace Philosophy +- **Extreme generosity**: xAI uses vast amounts of whitespace. The 320px headline with 48px+ surrounding padding creates a sense of emptiness that is itself a design statement -- the content is so important it needs room to breathe. +- **Vertical rhythm over horizontal density**: Content stacks vertically with large gaps between sections rather than packing horizontally. This creates a scroll-driven experience that feels deliberate and cinematic. +- **No visual noise**: The absence of decorative elements, borders between sections, and color variety means whitespace is the primary structural tool. + +### Breakpoints +- 2000px, 1536px, 1280px, 1024px, 1000px, 768px, 640px +- Tailwind responsive modifiers drive breakpoint behavior + +### Border Radius Scale +- Sharp (0px): Primary treatment for buttons, cards, inputs -- the default +- Subtle (4px): Occasional softening on secondary containers +- The near-zero radius philosophy is core to the brand's brutalist identity + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow, no border | Page background, body content | +| Surface (Level 1) | `rgba(255,255,255,0.03)` background | Subtle card surfaces | +| Bordered (Level 2) | `1px solid rgba(255,255,255,0.1)` border | Cards, containers, dividers | +| Active (Level 3) | `1px solid rgba(255,255,255,0.2)` border | Hover states, active elements | +| Focus (Accessibility) | `ring` with `rgb(59,130,246)/0.5` | Keyboard focus indicator | + +**Elevation Philosophy**: xAI rejects the conventional shadow-based elevation system entirely. There are no box-shadows anywhere on the site. Instead, depth is communicated through three mechanisms: (1) opacity-based borders that brighten on interaction, creating a sense of elements "activating" rather than lifting; (2) extremely subtle background opacity shifts (`0.03` to `0.08`) that create barely-perceptible surface differentiation; and (3) the massive scale contrast between the 320px display type and 16px body text, which creates typographic depth. This is elevation through contrast and opacity, not through simulated light and shadow. + +## 7. Do's and Don'ts + +### Do +- Use `#1f2228` as the universal background -- never pure black `#000000` +- Use GeistMono for all display headlines and button text -- monospace IS the brand +- Apply uppercase + 1.4px letter-spacing to all button labels +- Use weight 300 for the massive display headline (320px) +- Keep borders at `rgba(255, 255, 255, 0.1)` -- barely visible, not absent +- Dim interactive elements on hover to `rgba(255, 255, 255, 0.5)` -- the reverse of convention +- Maintain sharp corners (0px radius) as the default -- brutalist precision +- Use universalSans for all body and reading text at 16px/1.5 + +### Don't +- Don't use box-shadows -- xAI has zero shadow elevation +- Don't introduce color accents beyond white and the dark background -- the monochromatic palette is sacred +- Don't use large border-radius (8px+, pill shapes) -- the sharp edge is intentional +- Don't use bold weights (600-700) for headlines -- weight 300-400 only +- Don't brighten elements on hover -- xAI dims to `0.5` opacity instead +- Don't add decorative gradients, illustrations, or color blocks +- Don't use proportional fonts for buttons -- GeistMono uppercase is mandatory +- Don't use colored status indicators unless absolutely necessary -- keep everything in the white/dark spectrum + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile | <640px | Single column, hero headline scales dramatically down | +| Small Tablet | 640-768px | Slight increase in padding | +| Tablet | 768-1024px | Two-column layouts begin, heading sizes increase | +| Desktop | 1024-1280px | Full layout, generous whitespace | +| Large | 1280-1536px | Wider containers, more breathing room | +| Extra Large | 1536-2000px | Maximum content width, centered | +| Ultra | >2000px | Content stays centered, extreme margins | + +### Touch Targets +- Buttons use 12px 24px padding for comfortable touch +- Navigation links spaced with 24px gaps +- Minimum tap target: 44px height +- Mobile: full-width buttons for easy thumb reach + +### Collapsing Strategy +- Hero: 320px monospace headline scales down dramatically (to ~48px-64px on mobile) +- Navigation: horizontal links collapse to hamburger menu +- Feature sections: two-column to single-column stacking +- Section padding: 96px -> 48px -> 24px across breakpoints +- Massive display type is the first thing to resize -- it must remain impactful but not overflow + +### Image Behavior +- Minimal imagery -- the site relies on typography and whitespace +- Any product screenshots maintain sharp corners +- Full-width media scales proportionally with viewport + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Background: Dark (`#1f2228`) +- Text Primary: White (`#ffffff`) +- Text Secondary: White 70% (`rgba(255, 255, 255, 0.7)`) +- Text Muted: White 50% (`rgba(255, 255, 255, 0.5)`) +- Text Disabled: White 30% (`rgba(255, 255, 255, 0.3)`) +- Border Default: White 10% (`rgba(255, 255, 255, 0.1)`) +- Border Strong: White 20% (`rgba(255, 255, 255, 0.2)`) +- Surface Subtle: White 3% (`rgba(255, 255, 255, 0.03)`) +- Surface Hover: White 8% (`rgba(255, 255, 255, 0.08)`) +- Focus Ring: Blue (`rgb(59, 130, 246)` at 50% opacity) +- Button Primary BG: White (`#ffffff`), text Dark (`#1f2228`) + +### Example Component Prompts +- "Create a hero section on #1f2228 background. Headline in GeistMono at 72px weight 300, color #ffffff, centered. Subtitle in universalSans 18px weight 400, rgba(255,255,255,0.7), max-width 600px centered. Two buttons: primary (white bg, #1f2228 text, 0px radius, GeistMono 14px uppercase, 1.4px letter-spacing, 12px 24px padding) and ghost (transparent bg, 1px solid rgba(255,255,255,0.2), white text, same font treatment)." +- "Design a card: transparent or rgba(255,255,255,0.03) background, 1px solid rgba(255,255,255,0.1) border, 0px radius, 24px padding. No shadow. Title in universalSans 22px weight 400, #ffffff. Body in universalSans 16px weight 400, rgba(255,255,255,0.7), line-height 1.5. Hover: border changes to rgba(255,255,255,0.2)." +- "Build navigation: #1f2228 background, full-width. Brand text left (GeistMono 14px uppercase). Links in universalSans 14px #ffffff with hover to rgba(255,255,255,0.5). White primary button right-aligned (GeistMono 14px uppercase, 1.4px letter-spacing)." +- "Create a form: dark background #1f2228. Label in universalSans 14px rgba(255,255,255,0.7). Input with transparent bg, 1px solid rgba(255,255,255,0.2) border, 0px radius, white text 16px universalSans. Focus: blue ring rgb(59,130,246)/0.5. Placeholder: rgba(255,255,255,0.3)." +- "Design a monospace tag/badge: transparent bg, 1px solid rgba(255,255,255,0.2), 0px radius, GeistMono 12px uppercase, 1px letter-spacing, white text, 4px 8px padding." + +### Iteration Guide +1. Always start with `#1f2228` background -- never use pure black or gray backgrounds +2. GeistMono for display and buttons, universalSans for everything else -- never mix these roles +3. All buttons must be GeistMono uppercase with 1.4px letter-spacing -- this is non-negotiable +4. No shadows, ever -- depth comes from border opacity and background opacity only +5. Borders are always white with low opacity (0.1 default, 0.2 for emphasis) +6. Hover behavior dims to 0.5 opacity rather than brightening -- the reverse of most systems +7. Sharp corners (0px) by default -- only use 4px for specific secondary containers +8. Body text at 16px universalSans with 1.5 line-height for comfortable reading +9. Generous section padding (48px-96px) -- let content breathe in the darkness +10. The monochromatic white-on-dark palette is absolute -- resist adding color unless critical for function diff --git a/skills/creative/popular-web-designs/templates/zapier.md b/skills/creative/popular-web-designs/templates/zapier.md new file mode 100644 index 000000000000..f728c78a9e51 --- /dev/null +++ b/skills/creative/popular-web-designs/templates/zapier.md @@ -0,0 +1,341 @@ +# Design System: Zapier + + +> **Hermes Agent — Implementation Notes** +> +> The original site uses proprietary fonts. For self-contained HTML output, use these CDN substitutes: +> - **Primary:** `Inter` | **Mono:** `system monospace stack` +> - **Font stack (CSS):** `font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;` +> - **Mono stack (CSS):** `font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;` +> ```html +> +> ``` +> Use `write_file` to create HTML, serve via `generative-widgets` skill (cloudflared tunnel). +> Verify visual accuracy with `browser_vision` after generating. + +## 1. Visual Theme & Atmosphere + +Zapier's website radiates warm, approachable professionalism. It rejects the cold monochrome minimalism of developer tools in favor of a cream-tinted canvas (`#fffefb`) that feels like unbleached paper -- the digital equivalent of a well-organized notebook. The near-black (`#201515`) text has a faint reddish-brown warmth, creating an atmosphere more human than mechanical. This is automation designed to feel effortless, not technical. + +The typographic system is a deliberate interplay of two distinct personalities. **Degular Display** -- a geometric, wide-set display face -- handles hero-scale headlines at 56-80px with medium weight (500) and extraordinarily tight line-heights (0.90), creating headlines that compress vertically like stacked blocks. **Inter** serves as the workhorse for everything else, from section headings to body text and navigation, with fallbacks to Helvetica and Arial. **GT Alpina**, an elegant thin-weight serif with aggressive negative letter-spacing (-1.6px to -1.92px), makes occasional appearances for softer editorial moments. This three-font system gives Zapier the ability to shift register -- from bold and punchy (Degular) to clean and functional (Inter) to refined and literary (GT Alpina). + +The brand's signature orange (`#ff4f00`) is unmistakable -- a vivid, saturated red-orange that sits precisely between traffic-cone urgency and sunset warmth. It's used sparingly but decisively: primary CTA buttons, active state underlines, and accent borders. Against the warm cream background, this orange creates a color relationship that feels energetic without being aggressive. + +**Key Characteristics:** +- Warm cream canvas (`#fffefb`) instead of pure white -- organic, paper-like warmth +- Near-black with reddish undertone (`#201515`) -- text that breathes rather than dominates +- Degular Display for hero headlines at 0.90 line-height -- compressed, impactful, modern +- Inter as the universal UI font across all functional typography +- GT Alpina for editorial accents -- thin-weight serif with extreme negative tracking +- Zapier Orange (`#ff4f00`) as the single accent -- vivid, warm, sparingly applied +- Warm neutral palette: borders (`#c5c0b1`), muted text (`#939084`), surface tints (`#eceae3`) +- 8px base spacing system with generous padding on CTAs (20px 24px) +- Border-forward design: `1px solid` borders in warm grays define structure over shadows + +## 2. Color Palette & Roles + +### Primary +- **Zapier Black** (`#201515`): Primary text, headings, dark button backgrounds. A warm near-black with reddish undertones -- never cold. +- **Cream White** (`#fffefb`): Page background, card surfaces, light button fills. Not pure white; the yellowish warmth is intentional. +- **Off-White** (`#fffdf9`): Secondary background surface, subtle alternate tint. Nearly indistinguishable from cream white but creates depth. + +### Brand Accent +- **Zapier Orange** (`#ff4f00`): Primary CTA buttons, active underline indicators, accent borders. The signature color -- vivid and warm. + +### Neutral Scale +- **Dark Charcoal** (`#36342e`): Secondary text, footer text, border color for strong dividers. A warm dark gray-brown with 70% opacity variant. +- **Warm Gray** (`#939084`): Tertiary text, muted labels, timestamp-style content. Mid-range with greenish-warm undertone. +- **Sand** (`#c5c0b1`): Primary border color, hover state backgrounds, divider lines. The backbone of Zapier's structural elements. +- **Light Sand** (`#eceae3`): Secondary button backgrounds, light borders, subtle card surfaces. +- **Mid Warm** (`#b5b2aa`): Alternate border tone, used on specific span elements. + +### Interactive +- **Orange CTA** (`#ff4f00`): Primary action buttons and active tab underlines. +- **Dark CTA** (`#201515`): Secondary dark buttons with sand hover state. +- **Light CTA** (`#eceae3`): Tertiary/ghost buttons with sand hover. +- **Link Default** (`#201515`): Standard link color, matching body text. +- **Hover Underline**: Links remove `text-decoration: underline` on hover (inverse pattern). + +### Overlay & Surface +- **Semi-transparent Dark** (`rgba(45, 45, 46, 0.5)`): Overlay button variant, backdrop-like elements. +- **Pill Surface** (`#fffefb`): White pill buttons with sand borders. + +### Shadows & Depth +- **Inset Underline** (`rgb(255, 79, 0) 0px -4px 0px 0px inset`): Active tab indicator -- orange underline using inset box-shadow. +- **Hover Underline** (`rgb(197, 192, 177) 0px -4px 0px 0px inset`): Inactive tab hover -- sand-colored underline. + +## 3. Typography Rules + +### Font Families +- **Display**: `Degular Display` -- wide geometric display face for hero headlines +- **Primary**: `Inter`, with fallbacks: `Helvetica, Arial` +- **Editorial**: `GT Alpina` -- thin-weight serif for editorial moments +- **System**: `Arial` -- fallback for form elements and system UI + +### Hierarchy + +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|------|--------|-------------|----------------|-------| +| Display Hero XL | Degular Display | 80px (5.00rem) | 500 | 0.90 (tight) | normal | Maximum impact, compressed block | +| Display Hero | Degular Display | 56px (3.50rem) | 500 | 0.90-1.10 (tight) | 0-1.12px | Primary hero headlines | +| Display Hero SM | Degular Display | 40px (2.50rem) | 500 | 0.90 (tight) | normal | Smaller hero variant | +| Display Button | Degular Display | 24px (1.50rem) | 600 | 1.00 (tight) | 1px | Large CTA button text | +| Section Heading | Inter | 48px (3.00rem) | 500 | 1.04 (tight) | normal | Major section titles | +| Editorial Heading | GT Alpina | 48px (3.00rem) | 250 | normal | -1.92px | Thin editorial headlines | +| Editorial Sub | GT Alpina | 40px (2.50rem) | 300 | 1.08 (tight) | -1.6px | Editorial subheadings | +| Sub-heading LG | Inter | 36px (2.25rem) | 500 | normal | -1px | Large sub-sections | +| Sub-heading | Inter | 32px (2.00rem) | 400 | 1.25 (tight) | normal | Standard sub-sections | +| Sub-heading MD | Inter | 28px (1.75rem) | 500 | normal | normal | Medium sub-headings | +| Card Title | Inter | 24px (1.50rem) | 600 | normal | -0.48px | Card headings | +| Body Large | Inter | 20px (1.25rem) | 400-500 | 1.00-1.20 (tight) | -0.2px | Feature descriptions | +| Body Emphasis | Inter | 18px (1.13rem) | 600 | 1.00 (tight) | normal | Emphasized body text | +| Body | Inter | 16px (1.00rem) | 400-500 | 1.20-1.25 | -0.16px | Standard reading text | +| Body Semibold | Inter | 16px (1.00rem) | 600 | 1.16 (tight) | normal | Strong labels | +| Button | Inter | 16px (1.00rem) | 600 | normal | normal | Standard buttons | +| Button SM | Inter | 14px (0.88rem) | 600 | normal | normal | Small buttons | +| Caption | Inter | 14px (0.88rem) | 500 | 1.25-1.43 | normal | Labels, metadata | +| Caption Upper | Inter | 14px (0.88rem) | 600 | normal | 0.5px | Uppercase section labels | +| Micro | Inter | 12px (0.75rem) | 600 | 0.90-1.33 | 0.5px | Tiny labels, often uppercase | +| Micro SM | Inter | 13px (0.81rem) | 500 | 1.00-1.54 | normal | Small metadata text | + +### Principles +- **Three-font system, clear roles**: Degular Display commands attention at hero scale only. Inter handles everything functional. GT Alpina adds editorial warmth sparingly. +- **Compressed display**: Degular at 0.90 line-height creates vertically compressed headline blocks that feel modern and architectural. +- **Weight as hierarchy signal**: Inter uses 400 (reading), 500 (navigation/emphasis), 600 (headings/CTAs). Degular uses 500 (display) and 600 (buttons). +- **Uppercase for labels**: Section labels (like "01 / Colors") and small categorization use `text-transform: uppercase` with 0.5px letter-spacing. +- **Negative tracking for elegance**: GT Alpina uses -1.6px to -1.92px letter-spacing for its thin-weight editorial headlines. + +## 4. Component Stylings + +### Buttons + +**Primary Orange** +- Background: `#ff4f00` +- Text: `#fffefb` +- Padding: 8px 16px +- Radius: 4px +- Border: `1px solid #ff4f00` +- Use: Primary CTA ("Start free with email", "Sign up free") + +**Primary Dark** +- Background: `#201515` +- Text: `#fffefb` +- Padding: 20px 24px +- Radius: 8px +- Border: `1px solid #201515` +- Hover: background shifts to `#c5c0b1`, text to `#201515` +- Use: Large secondary CTA buttons + +**Light / Ghost** +- Background: `#eceae3` +- Text: `#36342e` +- Padding: 20px 24px +- Radius: 8px +- Border: `1px solid #c5c0b1` +- Hover: background shifts to `#c5c0b1`, text to `#201515` +- Use: Tertiary actions, filter buttons + +**Pill Button** +- Background: `#fffefb` +- Text: `#36342e` +- Padding: 0px 16px +- Radius: 20px +- Border: `1px solid #c5c0b1` +- Use: Tag-like selections, filter pills + +**Overlay Semi-transparent** +- Background: `rgba(45, 45, 46, 0.5)` +- Text: `#fffefb` +- Radius: 20px +- Hover: background becomes fully opaque `#2d2d2e` +- Use: Video play buttons, floating actions + +**Tab / Navigation (Inset Shadow)** +- Background: transparent +- Text: `#201515` +- Padding: 12px 16px +- Shadow: `rgb(255, 79, 0) 0px -4px 0px 0px inset` (active orange underline) +- Hover shadow: `rgb(197, 192, 177) 0px -4px 0px 0px inset` (sand underline) +- Use: Horizontal tab navigation + +### Cards & Containers +- Background: `#fffefb` +- Border: `1px solid #c5c0b1` (warm sand border) +- Radius: 5px (standard), 8px (featured) +- No shadow elevation by default -- borders define containment +- Hover: subtle border color intensification + +### Inputs & Forms +- Background: `#fffefb` +- Text: `#201515` +- Border: `1px solid #c5c0b1` +- Radius: 5px +- Focus: border color shifts to `#ff4f00` (orange) +- Placeholder: `#939084` + +### Navigation +- Clean horizontal nav on cream background +- Zapier logotype left-aligned, 104x28px +- Links: Inter 16px weight 500, `#201515` text +- CTA: Orange button ("Start free with email") +- Tab navigation uses inset box-shadow underline technique +- Mobile: hamburger collapse + +### Image Treatment +- Product screenshots with `1px solid #c5c0b1` border +- Rounded corners: 5-8px +- Dashboard/workflow screenshots prominent in feature sections +- Light gradient backgrounds behind hero content + +### Distinctive Components + +**Workflow Integration Cards** +- Display connected app icons in pairs +- Arrow or connection indicator between apps +- Sand border containment +- Inter weight 500 for app names + +**Stat Counter** +- Large display number using Inter 48px weight 500 +- Muted description below in `#36342e` +- Used for social proof metrics + +**Social Proof Icons** +- Circular icon buttons: 14px radius +- Sand border: `1px solid #c5c0b1` +- Used for social media follow links in footer + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Scale: 1px, 4px, 6px, 8px, 10px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 56px, 64px, 72px +- CTA buttons use generous padding: 20px 24px for large, 8px 16px for standard +- Section padding: 64px-80px vertical + +### Grid & Container +- Max content width: approximately 1200px +- Hero: centered single-column with large top padding +- Feature sections: 2-3 column grids for integration cards +- Full-width sand-bordered dividers between sections +- Footer: multi-column dark background (`#201515`) + +### Whitespace Philosophy +- **Warm breathing room**: Generous vertical spacing between sections (64px-80px), but content areas are relatively dense -- Zapier packs information efficiently within its cream canvas. +- **Architectural compression**: Degular Display headlines at 0.90 line-height compress vertically, contrasting with the open spacing around them. +- **Section rhythm**: Cream background throughout, with sections separated by sand-colored borders rather than background color changes. + +### Border Radius Scale +- Tight (3px): Small inline spans +- Standard (4px): Buttons (orange CTA), tags, small elements +- Content (5px): Cards, links, general containers +- Comfortable (8px): Featured cards, large buttons, tabs +- Social (14px): Social icon buttons, pill-like elements +- Pill (20px): Play buttons, large pill buttons, floating actions + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Flat (Level 0) | No shadow | Page background, text blocks | +| Bordered (Level 1) | `1px solid #c5c0b1` | Standard cards, containers, inputs | +| Strong Border (Level 1b) | `1px solid #36342e` | Dark dividers, emphasized sections | +| Active Tab (Level 2) | `rgb(255, 79, 0) 0px -4px 0px 0px inset` | Active tab underline (orange) | +| Hover Tab (Level 2b) | `rgb(197, 192, 177) 0px -4px 0px 0px inset` | Hover tab underline (sand) | +| Focus (Accessibility) | `1px solid #ff4f00` outline | Focus ring on interactive elements | + +**Shadow Philosophy**: Zapier deliberately avoids traditional shadow-based elevation. Structure is defined almost entirely through borders -- warm sand (`#c5c0b1`) borders for standard containment, dark charcoal (`#36342e`) borders for emphasis. The only shadow-like technique is the inset box-shadow used for tab underlines, where a `0px -4px 0px 0px inset` shadow creates a bottom-bar indicator. This border-first approach keeps the design grounded and tangible rather than floating. + +### Decorative Depth +- Orange inset underline on active tabs creates visual "weight" at the bottom of elements +- Sand hover underlines provide preview states without layout shifts +- No background gradients in main content -- the cream canvas is consistent +- Footer uses full dark background (`#201515`) for contrast reversal + +## 7. Do's and Don'ts + +### Do +- Use Degular Display exclusively for hero-scale headlines (40px+) with 0.90 line-height for compressed impact +- Use Inter for all functional UI -- navigation, body text, buttons, labels +- Apply warm cream (`#fffefb`) as the background, never pure white +- Use `#201515` for text, never pure black -- the reddish warmth matters +- Keep Zapier Orange (`#ff4f00`) reserved for primary CTAs and active state indicators +- Use sand (`#c5c0b1`) borders as the primary structural element instead of shadows +- Apply generous button padding (20px 24px) for large CTAs to match Zapier's spacious button style +- Use inset box-shadow underlines for tab navigation rather than border-bottom +- Apply uppercase with 0.5px letter-spacing for section labels and micro-categorization + +### Don't +- Don't use Degular Display for body text or UI elements -- it's display-only +- Don't use pure white (`#ffffff`) or pure black (`#000000`) -- Zapier's palette is warm-shifted +- Don't apply box-shadow elevation to cards -- use borders instead +- Don't scatter Zapier Orange across the UI -- it's reserved for CTAs and active states +- Don't use tight padding on large CTA buttons -- Zapier's buttons are deliberately spacious +- Don't ignore the warm neutral system -- borders should be `#c5c0b1`, not gray +- Don't use GT Alpina for functional UI -- it's an editorial accent at thin weights only +- Don't apply positive letter-spacing to GT Alpina -- it uses aggressive negative tracking (-1.6px to -1.92px) +- Don't use rounded pill shapes (9999px) for primary buttons -- pills are for tags and social icons + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <450px | Tight single column, reduced hero text | +| Mobile | 450-600px | Standard mobile, stacked layout | +| Mobile Large | 600-640px | Slight horizontal breathing room | +| Tablet Small | 640-680px | 2-column grids begin | +| Tablet | 680-768px | Card grids expand | +| Tablet Large | 768-991px | Full card grids, expanded padding | +| Desktop Small | 991-1024px | Desktop layout initiates | +| Desktop | 1024-1280px | Full layout, maximum content width | +| Large Desktop | >1280px | Centered with generous margins | + +### Touch Targets +- Large CTA buttons: 20px 24px padding (comfortable 60px+ height) +- Standard buttons: 8px 16px padding +- Navigation links: 16px weight 500 with adequate spacing +- Social icons: 14px radius circular buttons +- Tab items: 12px 16px padding + +### Collapsing Strategy +- Hero: Degular 80px display scales to 40-56px on smaller screens +- Navigation: horizontal links + CTA collapse to hamburger menu +- Feature cards: 3-column grid to 2-column to single-column stacked +- Integration workflow illustrations: maintain aspect ratio, may simplify +- Footer: multi-column dark section collapses to stacked +- Section spacing: 64-80px reduces to 40-48px on mobile + +### Image Behavior +- Product screenshots maintain sand border treatment at all sizes +- Integration app icons maintain fixed sizes within responsive containers +- Hero illustrations scale proportionally +- Full-width sections maintain edge-to-edge treatment + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: Zapier Orange (`#ff4f00`) +- Background: Cream White (`#fffefb`) +- Heading text: Zapier Black (`#201515`) +- Body text: Dark Charcoal (`#36342e`) +- Border: Sand (`#c5c0b1`) +- Secondary surface: Light Sand (`#eceae3`) +- Muted text: Warm Gray (`#939084`) + +### Example Component Prompts +- "Create a hero section on cream background (`#fffefb`). Headline at 56px Degular Display weight 500, line-height 0.90, color `#201515`. Subtitle at 20px Inter weight 400, line-height 1.20, color `#36342e`. Orange CTA button (`#ff4f00`, 4px radius, 8px 16px padding, white text) and dark button (`#201515`, 8px radius, 20px 24px padding, white text)." +- "Design a card: cream background (`#fffefb`), `1px solid #c5c0b1` border, 5px radius. Title at 24px Inter weight 600, letter-spacing -0.48px, `#201515`. Body at 16px weight 400, `#36342e`. No box-shadow." +- "Build a tab navigation: transparent background. Inter 16px weight 500, `#201515` text. Active tab: `box-shadow: rgb(255, 79, 0) 0px -4px 0px 0px inset`. Hover: `box-shadow: rgb(197, 192, 177) 0px -4px 0px 0px inset`. Padding 12px 16px." +- "Create navigation: cream sticky header (`#fffefb`). Inter 16px weight 500 for links, `#201515` text. Orange pill CTA 'Start free with email' right-aligned (`#ff4f00`, 4px radius, 8px 16px padding)." +- "Design a footer with dark background (`#201515`). Text `#fffefb`. Links in `#c5c0b1` with hover to `#fffefb`. Multi-column layout. Social icons as 14px-radius circles with sand borders." + +### Iteration Guide +1. Always use warm cream (`#fffefb`) background, never pure white -- the warmth defines Zapier +2. Borders (`1px solid #c5c0b1`) are the structural backbone -- avoid shadow elevation +3. Zapier Orange (`#ff4f00`) is the only accent color; everything else is warm neutrals +4. Three fonts, strict roles: Degular Display (hero), Inter (UI), GT Alpina (editorial) +5. Large CTA buttons need generous padding (20px 24px) -- Zapier buttons feel spacious +6. Tab navigation uses inset box-shadow underlines, not border-bottom +7. Text is always warm: `#201515` for dark, `#36342e` for body, `#939084` for muted +8. Uppercase labels at 12-14px with 0.5px letter-spacing for section categorization From 4976a8b0668f43734e0187428610ddcf2d6c864b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:04:44 -0700 Subject: [PATCH 030/213] =?UTF-8?q?feat:=20/model=20command=20=E2=80=94=20?= =?UTF-8?q?models.dev=20primary=20database=20+=20--provider=20flag=20(#518?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full overhaul of the model/provider system. ## What changed - models.dev (109 providers, 4000+ models) as primary database for provider identity AND model metadata - --provider flag replaces colon syntax for explicit provider switching - Full ModelInfo/ProviderInfo dataclasses with context, cost, capabilities, modalities - HermesOverlay system merges models.dev + Hermes-specific transport/auth/aggregator flags - User-defined endpoints via config.yaml providers: section - /model (no args) lists authenticated providers with curated model catalog - Rich metadata display: context window, max output, cost/M tokens, capabilities - Config migration: custom_providers list → providers dict (v11→v12) - AIAgent.switch_model() for in-place model swap preserving conversation ## Files agent/models_dev.py, hermes_cli/providers.py, hermes_cli/model_switch.py, hermes_cli/model_normalize.py, cli.py, gateway/run.py, run_agent.py, hermes_cli/config.py, hermes_cli/commands.py --- agent/models_dev.py | 591 +++++++++++++++++++++++++++++- cli.py | 163 +++++++++ gateway/run.py | 200 ++++++++++ hermes_cli/commands.py | 1 + hermes_cli/config.py | 66 +++- hermes_cli/model_normalize.py | 359 ++++++++++++++++++ hermes_cli/model_switch.py | 664 ++++++++++++++++++++++++++++++---- hermes_cli/providers.py | 519 ++++++++++++++++++++++++++ run_agent.py | 123 +++++++ 9 files changed, 2609 insertions(+), 77 deletions(-) create mode 100644 hermes_cli/model_normalize.py create mode 100644 hermes_cli/providers.py diff --git a/agent/models_dev.py b/agent/models_dev.py index b4b699558442..61483b6a1091 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -1,19 +1,31 @@ -"""Models.dev registry integration for provider-aware context length detection. +"""Models.dev registry integration — primary database for providers and models. -Fetches model metadata from https://models.dev/api.json — a community-maintained -database of 3800+ models across 100+ providers, including per-provider context -windows, pricing, and capabilities. +Fetches from https://models.dev/api.json — a community-maintained database +of 4000+ models across 109+ providers. Provides: -Data is cached in memory (1hr TTL) and on disk (~/.hermes/models_dev_cache.json) -to avoid cold-start network latency. +- **Provider metadata**: name, base URL, env vars, documentation link +- **Model metadata**: context window, max output, cost/M tokens, capabilities + (reasoning, tools, vision, PDF, audio), modalities, knowledge cutoff, + open-weights flag, family grouping, deprecation status + +Data resolution order (like TypeScript OpenCode): + 1. Bundled snapshot (ships with the package — offline-first) + 2. Disk cache (~/.hermes/models_dev_cache.json) + 3. Network fetch (https://models.dev/api.json) + 4. Background refresh every 60 minutes + +Other modules should import the dataclasses and query functions from here +rather than parsing the raw JSON themselves. """ +import difflib import json import logging import os import time +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Tuple, Union from utils import atomic_json_write @@ -28,7 +40,110 @@ _models_dev_cache: Dict[str, Any] = {} _models_dev_cache_time: float = 0 -# Provider ID mapping: Hermes provider names → models.dev provider IDs + +# --------------------------------------------------------------------------- +# Dataclasses — rich metadata for providers and models +# --------------------------------------------------------------------------- + +@dataclass +class ModelInfo: + """Full metadata for a single model from models.dev.""" + + id: str + name: str + family: str + provider_id: str # models.dev provider ID (e.g. "anthropic") + + # Capabilities + reasoning: bool = False + tool_call: bool = False + attachment: bool = False # supports image/file attachments (vision) + temperature: bool = False + structured_output: bool = False + open_weights: bool = False + + # Modalities + input_modalities: Tuple[str, ...] = () # ("text", "image", "pdf", ...) + output_modalities: Tuple[str, ...] = () + + # Limits + context_window: int = 0 + max_output: int = 0 + max_input: Optional[int] = None + + # Cost (per million tokens, USD) + cost_input: float = 0.0 + cost_output: float = 0.0 + cost_cache_read: Optional[float] = None + cost_cache_write: Optional[float] = None + + # Metadata + knowledge_cutoff: str = "" + release_date: str = "" + status: str = "" # "alpha", "beta", "deprecated", or "" + interleaved: Any = False # True or {"field": "reasoning_content"} + + def has_cost_data(self) -> bool: + return self.cost_input > 0 or self.cost_output > 0 + + def supports_vision(self) -> bool: + return self.attachment or "image" in self.input_modalities + + def supports_pdf(self) -> bool: + return "pdf" in self.input_modalities + + def supports_audio_input(self) -> bool: + return "audio" in self.input_modalities + + def format_cost(self) -> str: + """Human-readable cost string, e.g. '$3.00/M in, $15.00/M out'.""" + if not self.has_cost_data(): + return "unknown" + parts = [f"${self.cost_input:.2f}/M in", f"${self.cost_output:.2f}/M out"] + if self.cost_cache_read is not None: + parts.append(f"cache read ${self.cost_cache_read:.2f}/M") + return ", ".join(parts) + + def format_capabilities(self) -> str: + """Human-readable capabilities, e.g. 'reasoning, tools, vision, PDF'.""" + caps = [] + if self.reasoning: + caps.append("reasoning") + if self.tool_call: + caps.append("tools") + if self.supports_vision(): + caps.append("vision") + if self.supports_pdf(): + caps.append("PDF") + if self.supports_audio_input(): + caps.append("audio") + if self.structured_output: + caps.append("structured output") + if self.open_weights: + caps.append("open weights") + return ", ".join(caps) if caps else "basic" + + +@dataclass +class ProviderInfo: + """Full metadata for a provider from models.dev.""" + + id: str # models.dev provider ID + name: str # display name + env: Tuple[str, ...] # env var names for API key + api: str # base URL + doc: str = "" # documentation URL + model_count: int = 0 + + def has_api_url(self) -> bool: + return bool(self.api) + + +# --------------------------------------------------------------------------- +# Provider ID mapping: Hermes ↔ models.dev +# --------------------------------------------------------------------------- + +# Hermes provider names → models.dev provider IDs PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openrouter": "openrouter", "anthropic": "anthropic", @@ -44,8 +159,28 @@ "opencode-go": "opencode-go", "kilocode": "kilo", "fireworks": "fireworks-ai", + "huggingface": "huggingface", + "google": "google", + "xai": "xai", + "nvidia": "nvidia", + "groq": "groq", + "mistral": "mistral", + "togetherai": "togetherai", + "perplexity": "perplexity", + "cohere": "cohere", } +# Reverse mapping: models.dev → Hermes (built lazily) +_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, str]] = None + + +def _get_reverse_mapping() -> Dict[str, str]: + """Return models.dev ID → Hermes provider ID mapping.""" + global _MODELS_DEV_TO_PROVIDER + if _MODELS_DEV_TO_PROVIDER is None: + _MODELS_DEV_TO_PROVIDER = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()} + return _MODELS_DEV_TO_PROVIDER + def _get_cache_path() -> Path: """Return path to disk cache file.""" @@ -170,3 +305,443 @@ def _extract_context(entry: Dict[str, Any]) -> Optional[int]: if isinstance(ctx, (int, float)) and ctx > 0: return int(ctx) return None + + +# --------------------------------------------------------------------------- +# Model capability metadata +# --------------------------------------------------------------------------- + + +@dataclass +class ModelCapabilities: + """Structured capability metadata for a model from models.dev.""" + + supports_tools: bool = True + supports_vision: bool = False + supports_reasoning: bool = False + context_window: int = 200000 + max_output_tokens: int = 8192 + model_family: str = "" + + +def _get_provider_models(provider: str) -> Optional[Dict[str, Any]]: + """Resolve a Hermes provider ID to its models dict from models.dev. + + Returns the models dict or None if the provider is unknown or has no data. + """ + mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider) + if not mdev_provider_id: + return None + + data = fetch_models_dev() + provider_data = data.get(mdev_provider_id) + if not isinstance(provider_data, dict): + return None + + models = provider_data.get("models", {}) + if not isinstance(models, dict): + return None + + return models + + +def _find_model_entry(models: Dict[str, Any], model: str) -> Optional[Dict[str, Any]]: + """Find a model entry by exact match, then case-insensitive fallback.""" + # Exact match + entry = models.get(model) + if isinstance(entry, dict): + return entry + + # Case-insensitive match + model_lower = model.lower() + for mid, mdata in models.items(): + if mid.lower() == model_lower and isinstance(mdata, dict): + return mdata + + return None + + +def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilities]: + """Look up full capability metadata from models.dev cache. + + Uses the existing fetch_models_dev() and PROVIDER_TO_MODELS_DEV mapping. + Returns None if model not found. + + Extracts from model entry fields: + - reasoning (bool) → supports_reasoning + - tool_call (bool) → supports_tools + - attachment (bool) → supports_vision + - limit.context (int) → context_window + - limit.output (int) → max_output_tokens + - family (str) → model_family + """ + models = _get_provider_models(provider) + if models is None: + return None + + entry = _find_model_entry(models, model) + if entry is None: + return None + + # Extract capability flags (default to False if missing) + supports_tools = bool(entry.get("tool_call", False)) + supports_vision = bool(entry.get("attachment", False)) + supports_reasoning = bool(entry.get("reasoning", False)) + + # Extract limits + limit = entry.get("limit", {}) + if not isinstance(limit, dict): + limit = {} + + ctx = limit.get("context") + context_window = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 200000 + + out = limit.get("output") + max_output_tokens = int(out) if isinstance(out, (int, float)) and out > 0 else 8192 + + model_family = entry.get("family", "") or "" + + return ModelCapabilities( + supports_tools=supports_tools, + supports_vision=supports_vision, + supports_reasoning=supports_reasoning, + context_window=context_window, + max_output_tokens=max_output_tokens, + model_family=model_family, + ) + + +def list_provider_models(provider: str) -> List[str]: + """Return all model IDs for a provider from models.dev. + + Returns an empty list if the provider is unknown or has no data. + """ + models = _get_provider_models(provider) + if models is None: + return [] + return list(models.keys()) + + +def search_models_dev( + query: str, provider: str = None, limit: int = 5 +) -> List[Dict[str, Any]]: + """Fuzzy search across models.dev catalog. Returns matching model entries. + + Args: + query: Search string to match against model IDs. + provider: Optional Hermes provider ID to restrict search scope. + If None, searches across all providers in PROVIDER_TO_MODELS_DEV. + limit: Maximum number of results to return. + + Returns: + List of dicts, each containing 'provider', 'model_id', and the full + model 'entry' from models.dev. + """ + data = fetch_models_dev() + if not data: + return [] + + # Build list of (provider_id, model_id, entry) candidates + candidates: List[tuple] = [] + + if provider is not None: + # Search only the specified provider + mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider) + if not mdev_provider_id: + return [] + provider_data = data.get(mdev_provider_id, {}) + if isinstance(provider_data, dict): + models = provider_data.get("models", {}) + if isinstance(models, dict): + for mid, mdata in models.items(): + candidates.append((provider, mid, mdata)) + else: + # Search across all mapped providers + for hermes_prov, mdev_prov in PROVIDER_TO_MODELS_DEV.items(): + provider_data = data.get(mdev_prov, {}) + if isinstance(provider_data, dict): + models = provider_data.get("models", {}) + if isinstance(models, dict): + for mid, mdata in models.items(): + candidates.append((hermes_prov, mid, mdata)) + + if not candidates: + return [] + + # Use difflib for fuzzy matching — case-insensitive comparison + model_ids_lower = [c[1].lower() for c in candidates] + query_lower = query.lower() + + # First try exact substring matches (more intuitive than pure edit-distance) + substring_matches = [] + for prov, mid, mdata in candidates: + if query_lower in mid.lower(): + substring_matches.append({"provider": prov, "model_id": mid, "entry": mdata}) + + # Then add difflib fuzzy matches for any remaining slots + fuzzy_ids = difflib.get_close_matches( + query_lower, model_ids_lower, n=limit * 2, cutoff=0.4 + ) + + seen_ids: set = set() + results: List[Dict[str, Any]] = [] + + # Prioritize substring matches + for match in substring_matches: + key = (match["provider"], match["model_id"]) + if key not in seen_ids: + seen_ids.add(key) + results.append(match) + if len(results) >= limit: + return results + + # Add fuzzy matches + for fid in fuzzy_ids: + # Find original-case candidates matching this lowered ID + for prov, mid, mdata in candidates: + if mid.lower() == fid: + key = (prov, mid) + if key not in seen_ids: + seen_ids.add(key) + results.append({"provider": prov, "model_id": mid, "entry": mdata}) + if len(results) >= limit: + return results + + return results + + +# --------------------------------------------------------------------------- +# Rich dataclass constructors — parse raw models.dev JSON into dataclasses +# --------------------------------------------------------------------------- + +def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> ModelInfo: + """Convert a raw models.dev model entry dict into a ModelInfo dataclass.""" + limit = raw.get("limit") or {} + if not isinstance(limit, dict): + limit = {} + + cost = raw.get("cost") or {} + if not isinstance(cost, dict): + cost = {} + + modalities = raw.get("modalities") or {} + if not isinstance(modalities, dict): + modalities = {} + + input_mods = modalities.get("input") or [] + output_mods = modalities.get("output") or [] + + ctx = limit.get("context") + ctx_int = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 0 + out = limit.get("output") + out_int = int(out) if isinstance(out, (int, float)) and out > 0 else 0 + inp = limit.get("input") + inp_int = int(inp) if isinstance(inp, (int, float)) and inp > 0 else None + + return ModelInfo( + id=model_id, + name=raw.get("name", "") or model_id, + family=raw.get("family", "") or "", + provider_id=provider_id, + reasoning=bool(raw.get("reasoning", False)), + tool_call=bool(raw.get("tool_call", False)), + attachment=bool(raw.get("attachment", False)), + temperature=bool(raw.get("temperature", False)), + structured_output=bool(raw.get("structured_output", False)), + open_weights=bool(raw.get("open_weights", False)), + input_modalities=tuple(input_mods) if isinstance(input_mods, list) else (), + output_modalities=tuple(output_mods) if isinstance(output_mods, list) else (), + context_window=ctx_int, + max_output=out_int, + max_input=inp_int, + cost_input=float(cost.get("input", 0) or 0), + cost_output=float(cost.get("output", 0) or 0), + cost_cache_read=float(cost["cache_read"]) if "cache_read" in cost and cost["cache_read"] is not None else None, + cost_cache_write=float(cost["cache_write"]) if "cache_write" in cost and cost["cache_write"] is not None else None, + knowledge_cutoff=raw.get("knowledge", "") or "", + release_date=raw.get("release_date", "") or "", + status=raw.get("status", "") or "", + interleaved=raw.get("interleaved", False), + ) + + +def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo: + """Convert a raw models.dev provider entry dict into a ProviderInfo.""" + env = raw.get("env") or [] + models = raw.get("models") or {} + return ProviderInfo( + id=provider_id, + name=raw.get("name", "") or provider_id, + env=tuple(env) if isinstance(env, list) else (), + api=raw.get("api", "") or "", + doc=raw.get("doc", "") or "", + model_count=len(models) if isinstance(models, dict) else 0, + ) + + +# --------------------------------------------------------------------------- +# Provider-level queries +# --------------------------------------------------------------------------- + +def get_provider_info(provider_id: str) -> Optional[ProviderInfo]: + """Get full provider metadata from models.dev. + + Accepts either a Hermes provider ID (e.g. "kilocode") or a models.dev + ID (e.g. "kilo"). Returns None if the provider is not in the catalog. + """ + # Resolve Hermes ID → models.dev ID + mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) + + data = fetch_models_dev() + raw = data.get(mdev_id) + if not isinstance(raw, dict): + return None + + return _parse_provider_info(mdev_id, raw) + + +def list_all_providers() -> Dict[str, ProviderInfo]: + """Return all providers from models.dev as {provider_id: ProviderInfo}. + + Returns the full catalog — 109+ providers. For providers that have + a Hermes alias, both the models.dev ID and the Hermes ID are included. + """ + data = fetch_models_dev() + result: Dict[str, ProviderInfo] = {} + + for pid, pdata in data.items(): + if isinstance(pdata, dict): + info = _parse_provider_info(pid, pdata) + result[pid] = info + + return result + + +def get_providers_for_env_var(env_var: str) -> List[str]: + """Reverse lookup: find all providers that use a given env var. + + Useful for auto-detection: "user has ANTHROPIC_API_KEY set, which + providers does that enable?" + + Returns list of models.dev provider IDs. + """ + data = fetch_models_dev() + matches: List[str] = [] + + for pid, pdata in data.items(): + if isinstance(pdata, dict): + env = pdata.get("env", []) + if isinstance(env, list) and env_var in env: + matches.append(pid) + + return matches + + +# --------------------------------------------------------------------------- +# Model-level queries (rich ModelInfo) +# --------------------------------------------------------------------------- + +def get_model_info( + provider_id: str, model_id: str +) -> Optional[ModelInfo]: + """Get full model metadata from models.dev. + + Accepts Hermes or models.dev provider ID. Tries exact match then + case-insensitive fallback. Returns None if not found. + """ + mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) + + data = fetch_models_dev() + pdata = data.get(mdev_id) + if not isinstance(pdata, dict): + return None + + models = pdata.get("models", {}) + if not isinstance(models, dict): + return None + + # Exact match + raw = models.get(model_id) + if isinstance(raw, dict): + return _parse_model_info(model_id, raw, mdev_id) + + # Case-insensitive fallback + model_lower = model_id.lower() + for mid, mdata in models.items(): + if mid.lower() == model_lower and isinstance(mdata, dict): + return _parse_model_info(mid, mdata, mdev_id) + + return None + + +def get_model_info_any_provider(model_id: str) -> Optional[ModelInfo]: + """Search all providers for a model by ID. + + Useful when you have a full slug like "anthropic/claude-sonnet-4.6" or + a bare name and want to find it anywhere. Checks Hermes-mapped providers + first, then falls back to all models.dev providers. + """ + data = fetch_models_dev() + + # Try Hermes-mapped providers first (more likely what the user wants) + for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items(): + pdata = data.get(mdev_id) + if not isinstance(pdata, dict): + continue + models = pdata.get("models", {}) + if not isinstance(models, dict): + continue + + raw = models.get(model_id) + if isinstance(raw, dict): + return _parse_model_info(model_id, raw, mdev_id) + + # Case-insensitive + model_lower = model_id.lower() + for mid, mdata in models.items(): + if mid.lower() == model_lower and isinstance(mdata, dict): + return _parse_model_info(mid, mdata, mdev_id) + + # Fall back to ALL providers + for pid, pdata in data.items(): + if pid in _get_reverse_mapping(): + continue # already checked + if not isinstance(pdata, dict): + continue + models = pdata.get("models", {}) + if not isinstance(models, dict): + continue + + raw = models.get(model_id) + if isinstance(raw, dict): + return _parse_model_info(model_id, raw, pid) + + return None + + +def list_provider_model_infos(provider_id: str) -> List[ModelInfo]: + """Return all models for a provider as ModelInfo objects. + + Filters out deprecated models by default. + """ + mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) + + data = fetch_models_dev() + pdata = data.get(mdev_id) + if not isinstance(pdata, dict): + return [] + + models = pdata.get("models", {}) + if not isinstance(models, dict): + return [] + + result: List[ModelInfo] = [] + for mid, mdata in models.items(): + if not isinstance(mdata, dict): + continue + status = mdata.get("status", "") + if status == "deprecated": + continue + result.append(_parse_model_info(mid, mdata, mdev_id)) + + return result diff --git a/cli.py b/cli.py index de21d81e502f..5802a31e2f37 100644 --- a/cli.py +++ b/cli.py @@ -3519,6 +3519,167 @@ def undo_last(self): remaining = len(self.conversation_history) print(f" {remaining} message(s) remaining in history.") + def _handle_model_switch(self, cmd_original: str): + """Handle /model command — switch model for this session. + + Supports: + /model — show current model + usage hints + /model — switch for this session only + /model --global — switch and persist to config.yaml + /model --provider — switch provider + model + /model --provider — switch to provider, auto-detect model + """ + from hermes_cli.model_switch import switch_model, parse_model_flags, list_authenticated_providers + from hermes_cli.providers import get_label + + # Parse args from the original command + parts = cmd_original.split(None, 1) # split off '/model' + raw_args = parts[1].strip() if len(parts) > 1 else "" + + # Parse --provider and --global flags + model_input, explicit_provider, persist_global = parse_model_flags(raw_args) + + # No args at all: show available providers + models + if not model_input and not explicit_provider: + model_display = self.model or "unknown" + provider_display = get_label(self.provider) if self.provider else "unknown" + _cprint(f" Current: {model_display} on {provider_display}") + _cprint("") + + # Show authenticated providers with top models + try: + # Load user providers from config + user_provs = None + try: + from hermes_cli.config import load_config + cfg = load_config() + user_provs = cfg.get("providers") + except Exception: + pass + + providers = list_authenticated_providers( + current_provider=self.provider or "", + user_providers=user_provs, + max_models=6, + ) + if providers: + for p in providers: + tag = " (current)" if p["is_current"] else "" + _cprint(f" {p['name']} [--provider {p['slug']}]{tag}:") + if p["models"]: + model_strs = ", ".join(p["models"]) + extra = f" (+{p['total_models'] - len(p['models'])} more)" if p["total_models"] > len(p["models"]) else "" + _cprint(f" {model_strs}{extra}") + elif p.get("api_url"): + _cprint(f" {p['api_url']} (use /model --provider {p['slug']})") + else: + _cprint(f" (no models listed)") + _cprint("") + else: + _cprint(" No authenticated providers found.") + _cprint("") + except Exception: + pass + + # Aliases + from hermes_cli.model_switch import MODEL_ALIASES + alias_list = ", ".join(sorted(MODEL_ALIASES.keys())) + _cprint(f" Aliases: {alias_list}") + _cprint("") + _cprint(" /model switch model") + _cprint(" /model --provider switch provider") + _cprint(" /model --global persist to config") + return + + # Perform the switch + result = switch_model( + raw_input=model_input, + current_provider=self.provider or "", + current_model=self.model or "", + current_base_url=self.base_url or "", + current_api_key=self.api_key or "", + is_global=persist_global, + explicit_provider=explicit_provider, + ) + + if not result.success: + _cprint(f" ✗ {result.error_message}") + return + + # Apply to CLI state + old_model = self.model + self.model = result.new_model + self.provider = result.target_provider + if result.api_key: + self.api_key = result.api_key + if result.base_url: + self.base_url = result.base_url + if result.api_mode: + self.api_mode = result.api_mode + + # Apply to running agent (in-place swap) + if self.agent is not None: + try: + self.agent.switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + except Exception as exc: + _cprint(f" ⚠ Agent swap failed ({exc}); change applied to next session.") + + # Display confirmation with full metadata + provider_label = result.provider_label or result.target_provider + _cprint(f" ✓ Model switched: {result.new_model}") + _cprint(f" Provider: {provider_label}") + + # Rich metadata from models.dev + mi = result.model_info + if mi: + if mi.context_window: + _cprint(f" Context: {mi.context_window:,} tokens") + if mi.max_output: + _cprint(f" Max output: {mi.max_output:,} tokens") + if mi.has_cost_data(): + _cprint(f" Cost: {mi.format_cost()}") + _cprint(f" Capabilities: {mi.format_capabilities()}") + else: + # Fallback to old context length lookup + try: + from agent.model_metadata import get_model_context_length + ctx = get_model_context_length( + result.new_model, + base_url=result.base_url or self.base_url, + api_key=result.api_key or self.api_key, + provider=result.target_provider, + ) + _cprint(f" Context: {ctx:,} tokens") + except Exception: + pass + + # Cache notice + cache_enabled = ( + ("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower()) + or result.api_mode == "anthropic_messages" + ) + if cache_enabled: + _cprint(" Prompt caching: enabled") + + # Warning from validation + if result.warning_message: + _cprint(f" ⚠ {result.warning_message}") + + # Persistence + if persist_global: + save_config_value("model.name", result.new_model) + if result.provider_changed: + save_config_value("model.provider", result.target_provider) + _cprint(" Saved to config.yaml (--global)") + else: + _cprint(" (session only — add --global to persist)") + def _show_model_and_providers(self): """Show current model + provider and list all authenticated providers. @@ -4134,6 +4295,8 @@ def process_command(self, command: str) -> bool: self.new_session() elif canonical == "resume": self._handle_resume_command(cmd_original) + elif canonical == "model": + self._handle_model_switch(cmd_original) elif canonical == "provider": self._show_model_and_providers() elif canonical == "prompt": diff --git a/gateway/run.py b/gateway/run.py index 3c1c230163de..0db0514ea19a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -509,6 +509,9 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._effective_model: Optional[str] = None self._effective_provider: Optional[str] = None + # Per-session model overrides from /model command. + # Key: session_key, Value: dict with model/provider/api_key/base_url/api_mode + self._session_model_overrides: Dict[str, Dict[str, str]] = {} # Track pending exec approvals per session # Key: session_key, Value: {"command": str, "pattern_key": str, ...} self._pending_approvals: Dict[str, Dict[str, Any]] = {} @@ -1859,6 +1862,10 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: adapter._pending_messages[_quick_key] = queued_event return "Queued for the next turn." + # /model must not be used while the agent is running. + if _cmd_def_inner and _cmd_def_inner.name == "model": + return "Agent is running — wait or /stop first, then switch models." + # /approve and /deny must bypass the running-agent interrupt path. # The agent thread is blocked on a threading.Event inside # tools/approval.py — sending an interrupt won't unblock it. @@ -1958,6 +1965,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "yolo": return await self._handle_yolo_command(event) + if canonical == "model": + return await self._handle_model_command(event) + if canonical == "provider": return await self._handle_provider_command(event) @@ -3268,6 +3278,196 @@ async def _handle_commands_command(self, event: MessageEvent) -> str: lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") return "\n".join(lines) + async def _handle_model_command(self, event: MessageEvent) -> str: + """Handle /model command — switch model for this session. + + Supports: + /model — show current model info + /model — switch for this session only + /model --global — switch and persist to config.yaml + /model --provider — switch provider + model + /model --provider — switch to provider, auto-detect model + """ + import yaml + from hermes_cli.model_switch import ( + switch_model as _switch_model, parse_model_flags, + list_authenticated_providers, + ) + from hermes_cli.providers import get_label + + raw_args = event.get_command_args().strip() + + # Parse --provider and --global flags + model_input, explicit_provider, persist_global = parse_model_flags(raw_args) + + # Read current model/provider from config + current_model = "" + current_provider = "openrouter" + current_base_url = "" + current_api_key = "" + user_provs = None + config_path = _hermes_home / "config.yaml" + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + current_model = model_cfg.get("name", "") + current_provider = model_cfg.get("provider", current_provider) + current_base_url = model_cfg.get("base_url", "") + user_provs = cfg.get("providers") + except Exception: + pass + + # Check for session override + source = event.source + session_key = self._session_key_for_source(source) + override = getattr(self, "_session_model_overrides", {}).get(session_key, {}) + if override: + current_model = override.get("model", current_model) + current_provider = override.get("provider", current_provider) + current_base_url = override.get("base_url", current_base_url) + current_api_key = override.get("api_key", current_api_key) + + # No args: show authenticated providers with models + if not model_input and not explicit_provider: + provider_label = get_label(current_provider) + lines = [f"Current: `{current_model or 'unknown'}` on {provider_label}", ""] + + try: + providers = list_authenticated_providers( + current_provider=current_provider, + user_providers=user_provs, + max_models=5, + ) + for p in providers: + tag = " (current)" if p["is_current"] else "" + lines.append(f"**{p['name']}** `--provider {p['slug']}`{tag}:") + if p["models"]: + model_strs = ", ".join(f"`{m}`" for m in p["models"]) + extra = f" (+{p['total_models'] - len(p['models'])} more)" if p["total_models"] > len(p["models"]) else "" + lines.append(f" {model_strs}{extra}") + elif p.get("api_url"): + lines.append(f" `{p['api_url']}`") + lines.append("") + except Exception: + pass + + lines.append("`/model ` — switch model") + lines.append("`/model --provider ` — switch provider") + lines.append("`/model --global` — persist") + return "\n".join(lines) + + # Perform the switch + result = _switch_model( + raw_input=model_input, + current_provider=current_provider, + current_model=current_model, + current_base_url=current_base_url, + current_api_key=current_api_key, + is_global=persist_global, + explicit_provider=explicit_provider, + ) + + if not result.success: + return f"Error: {result.error_message}" + + # If there's a cached agent, update it in-place + cached_entry = None + _cache_lock = getattr(self, "_agent_cache_lock", None) + _cache = getattr(self, "_agent_cache", None) + if _cache_lock and _cache is not None: + with _cache_lock: + cached_entry = _cache.get(session_key) + + if cached_entry and cached_entry[0] is not None: + try: + cached_entry[0].switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + except Exception as exc: + logger.warning("In-place model switch failed for cached agent: %s", exc) + + # Store session override so next agent creation uses the new model + if not hasattr(self, "_session_model_overrides"): + self._session_model_overrides = {} + self._session_model_overrides[session_key] = { + "model": result.new_model, + "provider": result.target_provider, + "api_key": result.api_key, + "base_url": result.base_url, + "api_mode": result.api_mode, + } + + # Persist to config if --global + if persist_global: + try: + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + else: + cfg = {} + model_cfg = cfg.setdefault("model", {}) + model_cfg["name"] = result.new_model + model_cfg["provider"] = result.target_provider + if result.base_url: + model_cfg["base_url"] = result.base_url + from hermes_cli.config import save_config + save_config(cfg) + except Exception as e: + logger.warning("Failed to persist model switch: %s", e) + + # Build confirmation message with full metadata + provider_label = result.provider_label or result.target_provider + lines = [f"Model switched to `{result.new_model}`"] + lines.append(f"Provider: {provider_label}") + + # Rich metadata from models.dev + mi = result.model_info + if mi: + if mi.context_window: + lines.append(f"Context: {mi.context_window:,} tokens") + if mi.max_output: + lines.append(f"Max output: {mi.max_output:,} tokens") + if mi.has_cost_data(): + lines.append(f"Cost: {mi.format_cost()}") + lines.append(f"Capabilities: {mi.format_capabilities()}") + else: + try: + from agent.model_metadata import get_model_context_length + ctx = get_model_context_length( + result.new_model, + base_url=result.base_url or current_base_url, + api_key=result.api_key or current_api_key, + provider=result.target_provider, + ) + lines.append(f"Context: {ctx:,} tokens") + except Exception: + pass + + # Cache notice + cache_enabled = ( + ("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower()) + or result.api_mode == "anthropic_messages" + ) + if cache_enabled: + lines.append("Prompt caching: enabled") + + if result.warning_message: + lines.append(f"Warning: {result.warning_message}") + + if persist_global: + lines.append("Saved to config.yaml (`--global`)") + else: + lines.append("_(session only -- add `--global` to persist)_") + + return "\n".join(lines) + async def _handle_provider_command(self, event: MessageEvent) -> str: """Handle /provider command - show available providers.""" import yaml diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 07a8f5e1ebfd..782d52250f8e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -84,6 +84,7 @@ class CommandDef: # Configuration CommandDef("config", "Show current configuration", "Configuration", cli_only=True), + CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"), CommandDef("provider", "Show available providers and current provider", "Configuration"), CommandDef("prompt", "View/set custom system prompt", "Configuration", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 00d0923d2f83..1871bc916692 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -199,6 +199,7 @@ def ensure_hermes_home(): DEFAULT_CONFIG = { "model": "", + "providers": {}, "fallback_providers": [], "credential_pool_strategies": {}, "toolsets": ["hermes-cli"], @@ -531,7 +532,7 @@ def ensure_hermes_home(): }, # Config schema version - bump this when adding new required fields - "_config_version": 11, + "_config_version": 12, } # ============================================================================= @@ -1312,6 +1313,69 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A except Exception: pass + # ── Version 11 → 12: migrate custom_providers list → providers dict ── + if current_ver < 12: + config = load_config() + custom_list = config.get("custom_providers") + if isinstance(custom_list, list) and custom_list: + providers_dict = config.get("providers", {}) + if not isinstance(providers_dict, dict): + providers_dict = {} + migrated_count = 0 + for entry in custom_list: + if not isinstance(entry, dict): + continue + old_name = entry.get("name", "") + old_url = entry.get("base_url", "") or entry.get("url", "") or "" + old_key = entry.get("api_key", "") + if not old_url: + continue # skip entries with no URL + + # Generate a kebab-case key from the display name + key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "") + # Remove consecutive hyphens and trailing hyphens + while "--" in key: + key = key.replace("--", "-") + key = key.strip("-") + if not key: + # Fallback: derive from URL hostname + try: + from urllib.parse import urlparse + parsed = urlparse(old_url) + key = (parsed.hostname or "endpoint").replace(".", "-") + except Exception: + key = f"endpoint-{migrated_count}" + + # Don't overwrite existing entries + if key in providers_dict: + key = f"{key}-{migrated_count}" + + new_entry = {"api": old_url} + if old_name: + new_entry["name"] = old_name + if old_key and old_key not in ("no-key", "no-key-required", ""): + new_entry["api_key"] = old_key + + # Carry over model and api_mode if present + if entry.get("model"): + new_entry["default_model"] = entry["model"] + if entry.get("api_mode"): + new_entry["transport"] = entry["api_mode"] + + providers_dict[key] = new_entry + migrated_count += 1 + + if migrated_count > 0: + config["providers"] = providers_dict + # Remove the old list + del config["custom_providers"] + save_config(config) + if not quiet: + print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section") + for key in list(providers_dict.keys())[-migrated_count:]: + ep = providers_dict[key] + print(f" → {key}: {ep.get('api', '')}") + if current_ver < latest_ver and not quiet: print(f"Config version: {current_ver} → {latest_ver}") diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py new file mode 100644 index 000000000000..e362d44e2150 --- /dev/null +++ b/hermes_cli/model_normalize.py @@ -0,0 +1,359 @@ +"""Per-provider model name normalization. + +Different LLM providers expect model identifiers in different formats: + +- **Aggregators** (OpenRouter, Nous, AI Gateway, Kilo Code) need + ``vendor/model`` slugs like ``anthropic/claude-sonnet-4.6``. +- **Anthropic** native API expects bare names with dots replaced by + hyphens: ``claude-sonnet-4-6``. +- **Copilot** expects bare names *with* dots preserved: + ``claude-sonnet-4.6``. +- **OpenCode** (Zen & Go) follows the same dot-to-hyphen convention as + Anthropic: ``claude-sonnet-4-6``. +- **DeepSeek** only accepts two model identifiers: + ``deepseek-chat`` and ``deepseek-reasoner``. +- **Custom** and remaining providers pass the name through as-is. + +This module centralises that translation so callers can simply write:: + + api_model = normalize_model_for_provider(user_input, provider) + +Inspired by Clawdbot's ``normalizeAnthropicModelId`` pattern. +""" + +from __future__ import annotations + +from typing import Optional + +# --------------------------------------------------------------------------- +# Vendor prefix mapping +# --------------------------------------------------------------------------- +# Maps the first hyphen-delimited token of a bare model name to the vendor +# slug used by aggregator APIs (OpenRouter, Nous, etc.). +# +# Example: "claude-sonnet-4.6" -> first token "claude" -> vendor "anthropic" +# -> aggregator slug: "anthropic/claude-sonnet-4.6" + +_VENDOR_PREFIXES: dict[str, str] = { + "claude": "anthropic", + "gpt": "openai", + "o1": "openai", + "o3": "openai", + "o4": "openai", + "gemini": "google", + "deepseek": "deepseek", + "glm": "z-ai", + "kimi": "moonshotai", + "minimax": "minimax", + "grok": "x-ai", + "qwen": "qwen", + "mimo": "xiaomi", + "nemotron": "nvidia", + "llama": "meta-llama", + "step": "stepfun", + "trinity": "arcee-ai", +} + +# Providers whose APIs consume vendor/model slugs. +_AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({ + "openrouter", + "nous", + "ai-gateway", + "kilocode", +}) + +# Providers that want bare names with dots replaced by hyphens. +_DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({ + "anthropic", + "opencode-zen", + "opencode-go", +}) + +# Providers that want bare names with dots preserved. +_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({ + "copilot", + "copilot-acp", +}) + +# Providers whose own naming is authoritative -- pass through unchanged. +_PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({ + "zai", + "kimi-coding", + "minimax", + "minimax-cn", + "alibaba", + "huggingface", + "openai-codex", + "custom", +}) + +# --------------------------------------------------------------------------- +# DeepSeek special handling +# --------------------------------------------------------------------------- +# DeepSeek's API only recognises exactly two model identifiers. We map +# common aliases and patterns to the canonical names. + +_DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({ + "reasoner", + "r1", + "think", + "reasoning", + "cot", +}) + +_DEEPSEEK_CANONICAL_MODELS: frozenset[str] = frozenset({ + "deepseek-chat", + "deepseek-reasoner", +}) + + +def _normalize_for_deepseek(model_name: str) -> str: + """Map any model input to one of DeepSeek's two accepted identifiers. + + Rules: + - Already ``deepseek-chat`` or ``deepseek-reasoner`` -> pass through. + - Contains any reasoner keyword (r1, think, reasoning, cot, reasoner) + -> ``deepseek-reasoner``. + - Everything else -> ``deepseek-chat``. + + Args: + model_name: The bare model name (vendor prefix already stripped). + + Returns: + One of ``"deepseek-chat"`` or ``"deepseek-reasoner"``. + """ + bare = _strip_vendor_prefix(model_name).lower() + + if bare in _DEEPSEEK_CANONICAL_MODELS: + return bare + + # Check for reasoner-like keywords anywhere in the name + for keyword in _DEEPSEEK_REASONER_KEYWORDS: + if keyword in bare: + return "deepseek-reasoner" + + return "deepseek-chat" + + +# --------------------------------------------------------------------------- +# Helper utilities +# --------------------------------------------------------------------------- + +def _strip_vendor_prefix(model_name: str) -> str: + """Remove a ``vendor/`` prefix if present. + + Examples:: + + >>> _strip_vendor_prefix("anthropic/claude-sonnet-4.6") + 'claude-sonnet-4.6' + >>> _strip_vendor_prefix("claude-sonnet-4.6") + 'claude-sonnet-4.6' + >>> _strip_vendor_prefix("meta-llama/llama-4-scout") + 'llama-4-scout' + """ + if "/" in model_name: + return model_name.split("/", 1)[1] + return model_name + + +def _dots_to_hyphens(model_name: str) -> str: + """Replace dots with hyphens in a model name. + + Anthropic's native API uses hyphens where marketing names use dots: + ``claude-sonnet-4.6`` -> ``claude-sonnet-4-6``. + """ + return model_name.replace(".", "-") + + +def detect_vendor(model_name: str) -> Optional[str]: + """Detect the vendor slug from a bare model name. + + Uses the first hyphen-delimited token of the model name to look up + the corresponding vendor in ``_VENDOR_PREFIXES``. Also handles + case-insensitive matching and special patterns. + + Args: + model_name: A model name, optionally already including a + ``vendor/`` prefix. If a prefix is present it is used + directly. + + Returns: + The vendor slug (e.g. ``"anthropic"``, ``"openai"``) or ``None`` + if no vendor can be confidently detected. + + Examples:: + + >>> detect_vendor("claude-sonnet-4.6") + 'anthropic' + >>> detect_vendor("gpt-5.4-mini") + 'openai' + >>> detect_vendor("anthropic/claude-sonnet-4.6") + 'anthropic' + >>> detect_vendor("my-custom-model") + """ + name = model_name.strip() + if not name: + return None + + # If there's already a vendor/ prefix, extract it + if "/" in name: + return name.split("/", 1)[0].lower() or None + + name_lower = name.lower() + + # Try first hyphen-delimited token (exact match) + first_token = name_lower.split("-")[0] + if first_token in _VENDOR_PREFIXES: + return _VENDOR_PREFIXES[first_token] + + # Handle patterns where the first token includes version digits, + # e.g. "qwen3.5-plus" -> first token "qwen3.5", but prefix is "qwen" + for prefix, vendor in _VENDOR_PREFIXES.items(): + if name_lower.startswith(prefix): + return vendor + + return None + + +def _prepend_vendor(model_name: str) -> str: + """Prepend the detected ``vendor/`` prefix if missing. + + Used for aggregator providers that require ``vendor/model`` format. + If the name already contains a ``/``, it is returned as-is. + If no vendor can be detected, the name is returned unchanged + (aggregators may still accept it or return an error). + + Examples:: + + >>> _prepend_vendor("claude-sonnet-4.6") + 'anthropic/claude-sonnet-4.6' + >>> _prepend_vendor("anthropic/claude-sonnet-4.6") + 'anthropic/claude-sonnet-4.6' + >>> _prepend_vendor("my-custom-thing") + 'my-custom-thing' + """ + if "/" in model_name: + return model_name + + vendor = detect_vendor(model_name) + if vendor: + return f"{vendor}/{model_name}" + return model_name + + +# --------------------------------------------------------------------------- +# Main normalisation entry point +# --------------------------------------------------------------------------- + +def normalize_model_for_provider(model_input: str, target_provider: str) -> str: + """Translate a model name into the format the target provider's API expects. + + This is the primary entry point for model name normalisation. It + accepts any user-facing model identifier and transforms it for the + specific provider that will receive the API call. + + Args: + model_input: The model name as provided by the user or config. + Can be bare (``"claude-sonnet-4.6"``), vendor-prefixed + (``"anthropic/claude-sonnet-4.6"``), or already in native + format (``"claude-sonnet-4-6"``). + target_provider: The canonical Hermes provider id, e.g. + ``"openrouter"``, ``"anthropic"``, ``"copilot"``, + ``"deepseek"``, ``"custom"``. Should already be normalised + via ``hermes_cli.models.normalize_provider()``. + + Returns: + The model identifier string that the target provider's API + expects. + + Raises: + No exceptions -- always returns a best-effort string. + + Examples:: + + >>> normalize_model_for_provider("claude-sonnet-4.6", "openrouter") + 'anthropic/claude-sonnet-4.6' + + >>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "anthropic") + 'claude-sonnet-4-6' + + >>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "copilot") + 'claude-sonnet-4.6' + + >>> normalize_model_for_provider("openai/gpt-5.4", "copilot") + 'gpt-5.4' + + >>> normalize_model_for_provider("claude-sonnet-4.6", "opencode-zen") + 'claude-sonnet-4-6' + + >>> normalize_model_for_provider("deepseek-v3", "deepseek") + 'deepseek-chat' + + >>> normalize_model_for_provider("deepseek-r1", "deepseek") + 'deepseek-reasoner' + + >>> normalize_model_for_provider("my-model", "custom") + 'my-model' + + >>> normalize_model_for_provider("claude-sonnet-4.6", "zai") + 'claude-sonnet-4.6' + """ + name = (model_input or "").strip() + if not name: + return name + + provider = (target_provider or "").strip().lower() + + # --- Aggregators: need vendor/model format --- + if provider in _AGGREGATOR_PROVIDERS: + return _prepend_vendor(name) + + # --- Anthropic / OpenCode: strip vendor, dots -> hyphens --- + if provider in _DOT_TO_HYPHEN_PROVIDERS: + bare = _strip_vendor_prefix(name) + return _dots_to_hyphens(bare) + + # --- Copilot: strip vendor, keep dots --- + if provider in _STRIP_VENDOR_ONLY_PROVIDERS: + return _strip_vendor_prefix(name) + + # --- DeepSeek: map to one of two canonical names --- + if provider == "deepseek": + return _normalize_for_deepseek(name) + + # --- Custom & all others: pass through as-is --- + return name + + +# --------------------------------------------------------------------------- +# Batch / convenience helpers +# --------------------------------------------------------------------------- + +def model_display_name(model_id: str) -> str: + """Return a short, human-readable display name for a model id. + + Strips the vendor prefix (if any) for a cleaner display in menus + and status bars, while preserving dots for readability. + + Examples:: + + >>> model_display_name("anthropic/claude-sonnet-4.6") + 'claude-sonnet-4.6' + >>> model_display_name("claude-sonnet-4-6") + 'claude-sonnet-4-6' + """ + return _strip_vendor_prefix((model_id or "").strip()) + + +def is_aggregator_provider(provider: str) -> bool: + """Check if a provider is an aggregator that needs vendor/model format.""" + return (provider or "").strip().lower() in _AGGREGATOR_PROVIDERS + + +def vendor_for_model(model_name: str) -> str: + """Return the vendor slug for a model, or ``""`` if unknown. + + Convenience wrapper around :func:`detect_vendor` that never returns + ``None``. + """ + return detect_vendor(model_name) or "" diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index ae4de86a5b1e..9534f3765685 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -3,18 +3,120 @@ Both the CLI (cli.py) and gateway (gateway/run.py) /model handlers share the same core pipeline: - parse_model_input → is_custom detection → auto-detect provider - → credential resolution → validate model → return result + parse flags -> alias resolution -> provider resolution -> + credential resolution -> normalize model name -> + metadata lookup -> build result -This module extracts that shared pipeline into pure functions that -return result objects. The callers handle all platform-specific -concerns: state mutation, config persistence, output formatting. +This module ties together the foundation layers: + +- ``agent.models_dev`` -- models.dev catalog, ModelInfo, ProviderInfo +- ``hermes_cli.providers`` -- canonical provider identity + overlays +- ``hermes_cli.model_normalize`` -- per-provider name formatting + +Provider switching uses the ``--provider`` flag exclusively. +No colon-based ``provider:model`` syntax — colons are reserved for +OpenRouter variant suffixes (``:free``, ``:extended``, ``:fast``). """ from __future__ import annotations -from dataclasses import dataclass +import logging +from dataclasses import dataclass, field +from typing import List, NamedTuple, Optional + +from hermes_cli.providers import ( + ALIASES, + LABELS, + TRANSPORT_TO_API_MODE, + determine_api_mode, + get_label, + get_provider, + is_aggregator, + normalize_provider, + resolve_provider_full, +) +from hermes_cli.model_normalize import ( + detect_vendor, + normalize_model_for_provider, +) +from agent.models_dev import ( + ModelCapabilities, + ModelInfo, + get_model_capabilities, + get_model_info, + list_provider_models, + search_models_dev, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Model aliases -- short names -> (vendor, family) with NO version numbers. +# Resolved dynamically against the live models.dev catalog. +# --------------------------------------------------------------------------- + +class ModelIdentity(NamedTuple): + """Vendor slug and family prefix used for catalog resolution.""" + vendor: str + family: str + + +MODEL_ALIASES: dict[str, ModelIdentity] = { + # Anthropic + "sonnet": ModelIdentity("anthropic", "claude-sonnet"), + "opus": ModelIdentity("anthropic", "claude-opus"), + "haiku": ModelIdentity("anthropic", "claude-haiku"), + "claude": ModelIdentity("anthropic", "claude"), + + # OpenAI + "gpt5": ModelIdentity("openai", "gpt-5"), + "gpt": ModelIdentity("openai", "gpt"), + "codex": ModelIdentity("openai", "codex"), + "o3": ModelIdentity("openai", "o3"), + "o4": ModelIdentity("openai", "o4"), + + # Google + "gemini": ModelIdentity("google", "gemini"), + + # DeepSeek + "deepseek": ModelIdentity("deepseek", "deepseek-chat"), + + # X.AI + "grok": ModelIdentity("x-ai", "grok"), + + # Meta + "llama": ModelIdentity("meta-llama", "llama"), + + # Qwen / Alibaba + "qwen": ModelIdentity("qwen", "qwen"), + + # MiniMax + "minimax": ModelIdentity("minimax", "minimax"), + + # Nvidia + "nemotron": ModelIdentity("nvidia", "nemotron"), + + # Moonshot / Kimi + "kimi": ModelIdentity("moonshotai", "kimi"), + + # Z.AI / GLM + "glm": ModelIdentity("z-ai", "glm"), + + # StepFun + "step": ModelIdentity("stepfun", "step"), + # Xiaomi + "mimo": ModelIdentity("xiaomi", "mimo"), + + # Arcee + "trinity": ModelIdentity("arcee-ai", "trinity"), +} + + +# --------------------------------------------------------------------------- +# Result dataclasses +# --------------------------------------------------------------------------- @dataclass class ModelSwitchResult: @@ -27,11 +129,13 @@ class ModelSwitchResult: api_key: str = "" base_url: str = "" api_mode: str = "" - persist: bool = False error_message: str = "" warning_message: str = "" - is_custom_target: bool = False provider_label: str = "" + resolved_via_alias: str = "" + capabilities: Optional[ModelCapabilities] = None + model_info: Optional[ModelInfo] = None + is_global: bool = False @dataclass @@ -45,91 +149,336 @@ class CustomAutoResult: error_message: str = "" +# --------------------------------------------------------------------------- +# Flag parsing +# --------------------------------------------------------------------------- + +def parse_model_flags(raw_args: str) -> tuple[str, str, bool]: + """Parse --provider and --global flags from /model command args. + + Returns (model_input, explicit_provider, is_global). + + Examples:: + + "sonnet" -> ("sonnet", "", False) + "sonnet --global" -> ("sonnet", "", True) + "sonnet --provider anthropic" -> ("sonnet", "anthropic", False) + "--provider my-ollama" -> ("", "my-ollama", False) + "sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True) + """ + is_global = False + explicit_provider = "" + + # Extract --global + if "--global" in raw_args: + is_global = True + raw_args = raw_args.replace("--global", "").strip() + + # Extract --provider + parts = raw_args.split() + i = 0 + filtered: list[str] = [] + while i < len(parts): + if parts[i] == "--provider" and i + 1 < len(parts): + explicit_provider = parts[i + 1] + i += 2 + else: + filtered.append(parts[i]) + i += 1 + + model_input = " ".join(filtered).strip() + return (model_input, explicit_provider, is_global) + + +# --------------------------------------------------------------------------- +# Alias resolution +# --------------------------------------------------------------------------- + +def resolve_alias( + raw_input: str, + current_provider: str, +) -> Optional[tuple[str, str, str]]: + """Resolve a short alias against the current provider's catalog. + + Looks up *raw_input* in :data:`MODEL_ALIASES`, then searches the + current provider's models.dev catalog for the first model whose ID + starts with ``vendor/family`` (or just ``family`` for non-aggregator + providers). + + Returns: + ``(provider, resolved_model_id, alias_name)`` if a match is + found on the current provider, or ``None`` if the alias doesn't + exist or no matching model is available. + """ + key = raw_input.strip().lower() + identity = MODEL_ALIASES.get(key) + if identity is None: + return None + + vendor, family = identity + + # Search the provider's catalog from models.dev + catalog = list_provider_models(current_provider) + if not catalog: + return None + + # For aggregators, models are vendor/model-name format + aggregator = is_aggregator(current_provider) + + for model_id in catalog: + mid_lower = model_id.lower() + if aggregator: + # Match vendor/family prefix -- e.g. "anthropic/claude-sonnet" + prefix = f"{vendor}/{family}".lower() + if mid_lower.startswith(prefix): + return (current_provider, model_id, key) + else: + # Non-aggregator: bare names -- e.g. "claude-sonnet-4-6" + family_lower = family.lower() + if mid_lower.startswith(family_lower): + return (current_provider, model_id, key) + + return None + + +def _resolve_alias_fallback( + raw_input: str, + fallback_providers: tuple[str, ...] = ("openrouter", "nous"), +) -> Optional[tuple[str, str, str]]: + """Try to resolve an alias on fallback providers.""" + for provider in fallback_providers: + result = resolve_alias(raw_input, provider) + if result is not None: + return result + return None + + +# --------------------------------------------------------------------------- +# Core model-switching pipeline +# --------------------------------------------------------------------------- + def switch_model( raw_input: str, current_provider: str, + current_model: str, current_base_url: str = "", current_api_key: str = "", + is_global: bool = False, + explicit_provider: str = "", + user_providers: dict = None, ) -> ModelSwitchResult: """Core model-switching pipeline shared between CLI and gateway. - Handles parsing, provider detection, credential resolution, and - model validation. Does NOT handle config persistence, state - mutation, or output formatting — those are caller responsibilities. + Resolution chain: + + If --provider given: + a. Resolve provider via resolve_provider_full() + b. Resolve credentials + c. If model given, resolve alias on target provider or use as-is + d. If no model, auto-detect from endpoint + + If no --provider: + a. Try alias resolution on current provider + b. If alias exists but not on current provider -> fallback + c. On aggregator, try vendor/model slug conversion + d. Aggregator catalog search + e. detect_provider_for_model() as last resort + f. Resolve credentials + g. Normalize model name for target provider + + Finally: + h. Get full model metadata from models.dev + i. Build result Args: - raw_input: The user's model input (e.g. "claude-sonnet-4", - "zai:glm-5", "custom:local:qwen"). + raw_input: The model name (after flag parsing). current_provider: The currently active provider. - current_base_url: The currently active base URL (used for - is_custom detection). + current_model: The currently active model name. + current_base_url: The currently active base URL. current_api_key: The currently active API key. + is_global: Whether to persist the switch. + explicit_provider: From --provider flag (empty = no explicit provider). + user_providers: The ``providers:`` dict from config.yaml (for user endpoints). Returns: - ModelSwitchResult with all information the caller needs to - apply the switch and format output. + ModelSwitchResult with all information the caller needs. """ from hermes_cli.models import ( - parse_model_input, detect_provider_for_model, validate_requested_model, - _PROVIDER_LABELS, opencode_model_api_mode, ) from hermes_cli.runtime_provider import resolve_runtime_provider - # Step 1: Parse provider:model syntax - target_provider, new_model = parse_model_input(raw_input, current_provider) + resolved_alias = "" + new_model = raw_input.strip() + target_provider = current_provider + + # ================================================================= + # PATH A: Explicit --provider given + # ================================================================= + if explicit_provider: + # Resolve the provider + pdef = resolve_provider_full(explicit_provider, user_providers) + if pdef is None: + return ModelSwitchResult( + success=False, + is_global=is_global, + error_message=( + f"Unknown provider '{explicit_provider}'. " + f"Check 'hermes model' for available providers, or define it " + f"in config.yaml under 'providers:'." + ), + ) - # Step 2: Detect if we're currently on a custom endpoint - _base = current_base_url or "" - is_custom = current_provider == "custom" or ( - "localhost" in _base or "127.0.0.1" in _base - ) + target_provider = pdef.id + + # If no model specified, try auto-detect from endpoint + if not new_model: + if pdef.base_url: + from hermes_cli.runtime_provider import _auto_detect_local_model + detected = _auto_detect_local_model(pdef.base_url) + if detected: + new_model = detected + else: + return ModelSwitchResult( + success=False, + target_provider=target_provider, + provider_label=pdef.name, + is_global=is_global, + error_message=( + f"No model detected on {pdef.name} ({pdef.base_url}). " + f"Specify the model explicitly: /model --provider {explicit_provider}" + ), + ) + else: + return ModelSwitchResult( + success=False, + target_provider=target_provider, + provider_label=pdef.name, + is_global=is_global, + error_message=( + f"Provider '{pdef.name}' has no base URL configured. " + f"Specify a model: /model --provider {explicit_provider}" + ), + ) + + # Resolve alias on the TARGET provider + alias_result = resolve_alias(new_model, target_provider) + if alias_result is not None: + _, new_model, resolved_alias = alias_result + + # ================================================================= + # PATH B: No explicit provider — resolve from model input + # ================================================================= + else: + # --- Step a: Try alias resolution on current provider --- + alias_result = resolve_alias(raw_input, current_provider) + + if alias_result is not None: + target_provider, new_model, resolved_alias = alias_result + logger.debug( + "Alias '%s' resolved to %s on %s", + resolved_alias, new_model, target_provider, + ) + else: + # --- Step b: Alias exists but not on current provider -> fallback --- + key = raw_input.strip().lower() + if key in MODEL_ALIASES: + fallback_result = _resolve_alias_fallback(raw_input) + if fallback_result is not None: + target_provider, new_model, resolved_alias = fallback_result + logger.debug( + "Alias '%s' resolved via fallback to %s on %s", + resolved_alias, new_model, target_provider, + ) + else: + identity = MODEL_ALIASES[key] + return ModelSwitchResult( + success=False, + is_global=is_global, + error_message=( + f"Alias '{key}' maps to {identity.vendor}/{identity.family} " + f"but no matching model was found in any provider catalog. " + f"Try specifying the full model name." + ), + ) + else: + # --- Step c: On aggregator, convert vendor:model to vendor/model --- + colon_pos = raw_input.find(":") + if colon_pos > 0 and is_aggregator(current_provider): + left = raw_input[:colon_pos].strip().lower() + right = raw_input[colon_pos + 1:].strip() + if left and right: + # Colons become slashes for aggregator slugs + new_model = f"{left}/{right}" + logger.debug( + "Converted vendor:model '%s' to aggregator slug '%s'", + raw_input, new_model, + ) + + # --- Step d: Aggregator catalog search --- + if is_aggregator(target_provider) and not resolved_alias: + catalog = list_provider_models(target_provider) + if catalog: + new_model_lower = new_model.lower() + for mid in catalog: + if mid.lower() == new_model_lower: + new_model = mid + break + else: + for mid in catalog: + if "/" in mid: + _, bare = mid.split("/", 1) + if bare.lower() == new_model_lower: + new_model = mid + break + + # --- Step e: detect_provider_for_model() as last resort --- + _base = current_base_url or "" + is_custom = current_provider in ("custom", "local") or ( + "localhost" in _base or "127.0.0.1" in _base + ) - # Step 3: Auto-detect provider when no explicit provider:model syntax - # was used. Skip for custom providers — the model name might - # coincidentally match a known provider's catalog. - if target_provider == current_provider and not is_custom: - detected = detect_provider_for_model(new_model, current_provider) - if detected: - target_provider, new_model = detected + if ( + target_provider == current_provider + and not is_custom + and not resolved_alias + ): + detected = detect_provider_for_model(new_model, current_provider) + if detected: + target_provider, new_model = detected + + # ================================================================= + # COMMON PATH: Resolve credentials, normalize, get metadata + # ================================================================= provider_changed = target_provider != current_provider + provider_label = get_label(target_provider) - # Step 4: Resolve credentials for target provider + # --- Resolve credentials --- api_key = current_api_key base_url = current_base_url api_mode = "" - if provider_changed: + + if provider_changed or explicit_provider: try: runtime = resolve_runtime_provider(requested=target_provider) api_key = runtime.get("api_key", "") base_url = runtime.get("base_url", "") api_mode = runtime.get("api_mode", "") except Exception as e: - provider_label = _PROVIDER_LABELS.get(target_provider, target_provider) - if target_provider == "custom": - return ModelSwitchResult( - success=False, - target_provider=target_provider, - error_message=( - "No custom endpoint configured. Set model.base_url " - "in config.yaml, or set OPENAI_BASE_URL in .env, " - "or run: hermes setup → Custom OpenAI-compatible endpoint" - ), - ) return ModelSwitchResult( success=False, target_provider=target_provider, + provider_label=provider_label, + is_global=is_global, error_message=( f"Could not resolve credentials for provider " f"'{provider_label}': {e}" ), ) else: - # Gateway also resolves for unchanged provider to get accurate - # base_url for validation probing. try: runtime = resolve_runtime_provider(requested=current_provider) api_key = runtime.get("api_key", "") @@ -138,7 +487,10 @@ def switch_model( except Exception: pass - # Step 5: Validate the model + # --- Normalize model name for target provider --- + new_model = normalize_model_for_provider(new_model, target_provider) + + # --- Validate --- try: validation = validate_requested_model( new_model, @@ -160,23 +512,26 @@ def switch_model( success=False, new_model=new_model, target_provider=target_provider, + provider_label=provider_label, + is_global=is_global, error_message=msg, ) - # Step 6: Build result - provider_label = _PROVIDER_LABELS.get(target_provider, target_provider) - is_custom_target = target_provider == "custom" or ( - base_url - and "openrouter.ai" not in (base_url or "") - and ("localhost" in (base_url or "") or "127.0.0.1" in (base_url or "")) - ) - - if target_provider in {"opencode-zen", "opencode-go"}: - # Recompute against the requested new model, not the currently-configured - # model used during runtime resolution. OpenCode mixes API surfaces by - # model family, so a same-provider model switch can change api_mode. + # --- OpenCode api_mode override --- + if target_provider in {"opencode-zen", "opencode-go", "opencode", "opencode-go"}: api_mode = opencode_model_api_mode(target_provider, new_model) + # --- Determine api_mode if not already set --- + if not api_mode: + api_mode = determine_api_mode(target_provider, base_url) + + # --- Get capabilities (legacy) --- + capabilities = get_model_capabilities(target_provider, new_model) + + # --- Get full model info from models.dev --- + model_info = get_model_info(target_provider, new_model) + + # --- Build result --- return ModelSwitchResult( success=True, new_model=new_model, @@ -185,18 +540,191 @@ def switch_model( api_key=api_key, base_url=base_url, api_mode=api_mode, - persist=bool(validation.get("persist")), warning_message=validation.get("message") or "", - is_custom_target=is_custom_target, provider_label=provider_label, + resolved_via_alias=resolved_alias, + capabilities=capabilities, + model_info=model_info, + is_global=is_global, ) -def switch_to_custom_provider() -> CustomAutoResult: - """Handle bare '/model custom' — resolve endpoint and auto-detect model. +# --------------------------------------------------------------------------- +# Authenticated providers listing (for /model no-args display) +# --------------------------------------------------------------------------- + +def list_authenticated_providers( + current_provider: str = "", + user_providers: dict = None, + max_models: int = 8, +) -> List[dict]: + """Detect which providers have credentials and list their curated models. + + Uses the curated model lists from hermes_cli/models.py (OPENROUTER_MODELS, + _PROVIDER_MODELS) — NOT the full models.dev catalog. These are hand-picked + agentic models that work well as agent backends. - Returns a result object; the caller handles persistence and output. + Returns a list of dicts, each with: + - slug: str — the --provider value to use + - name: str — display name + - is_current: bool + - is_user_defined: bool + - models: list[str] — curated model IDs (up to max_models) + - total_models: int — total curated count + - source: str — "built-in", "models.dev", "user-config" + + Only includes providers that have API keys set or are user-defined endpoints. """ + import os + from agent.models_dev import ( + PROVIDER_TO_MODELS_DEV, + fetch_models_dev, + get_provider_info as _mdev_pinfo, + ) + from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS + + results: List[dict] = [] + seen_slugs: set = set() + + data = fetch_models_dev() + + # Build curated model lists keyed by hermes provider ID + curated: dict[str, list[str]] = dict(_PROVIDER_MODELS) + curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS] + # "nous" shares OpenRouter's curated list if not separately defined + if "nous" not in curated: + curated["nous"] = curated["openrouter"] + + # --- 1. Check Hermes-mapped providers --- + for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items(): + pdata = data.get(mdev_id) + if not isinstance(pdata, dict): + continue + + env_vars = pdata.get("env", []) + if not isinstance(env_vars, list): + continue + + # Check if any env var is set + has_creds = any(os.environ.get(ev) for ev in env_vars) + if not has_creds: + continue + + # Use curated list, falling back to models.dev if no curated list + model_ids = curated.get(hermes_id, []) + total = len(model_ids) + top = model_ids[:max_models] + + slug = hermes_id + pinfo = _mdev_pinfo(mdev_id) + display_name = pinfo.name if pinfo else mdev_id + + results.append({ + "slug": slug, + "name": display_name, + "is_current": slug == current_provider or mdev_id == current_provider, + "is_user_defined": False, + "models": top, + "total_models": total, + "source": "built-in", + }) + seen_slugs.add(slug) + + # --- 2. Check Hermes-only providers (nous, openai-codex, copilot) --- + from hermes_cli.providers import HERMES_OVERLAYS + for pid, overlay in HERMES_OVERLAYS.items(): + if pid in seen_slugs: + continue + # Check if credentials exist + has_creds = False + if overlay.extra_env_vars: + has_creds = any(os.environ.get(ev) for ev in overlay.extra_env_vars) + if overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"): + # These use auth stores, not env vars — check for auth.json entries + try: + from hermes_cli.auth import _read_auth_store + store = _read_auth_store() + if store and pid in store: + has_creds = True + except Exception: + pass + if not has_creds: + continue + + # Use curated list + model_ids = curated.get(pid, []) + total = len(model_ids) + top = model_ids[:max_models] + + results.append({ + "slug": pid, + "name": get_label(pid), + "is_current": pid == current_provider, + "is_user_defined": False, + "models": top, + "total_models": total, + "source": "hermes", + }) + seen_slugs.add(pid) + + # --- 3. User-defined endpoints from config --- + if user_providers and isinstance(user_providers, dict): + for ep_name, ep_cfg in user_providers.items(): + if not isinstance(ep_cfg, dict): + continue + display_name = ep_cfg.get("name", "") or ep_name + api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or "" + default_model = ep_cfg.get("default_model", "") + + models_list = [] + if default_model: + models_list.append(default_model) + + # Try to probe /v1/models if URL is set (but don't block on it) + # For now just show what we know from config + results.append({ + "slug": ep_name, + "name": display_name, + "is_current": ep_name == current_provider, + "is_user_defined": True, + "models": models_list, + "total_models": len(models_list) if models_list else 0, + "source": "user-config", + "api_url": api_url, + }) + + # Sort: current provider first, then by model count descending + results.sort(key=lambda r: (not r["is_current"], -r["total_models"])) + + return results + + +# --------------------------------------------------------------------------- +# Fuzzy suggestions +# --------------------------------------------------------------------------- + +def suggest_models(raw_input: str, limit: int = 3) -> List[str]: + """Return fuzzy model suggestions for a (possibly misspelled) input.""" + query = raw_input.strip() + if not query: + return [] + + results = search_models_dev(query, limit=limit) + suggestions: list[str] = [] + for r in results: + mid = r.get("model_id", "") + if mid: + suggestions.append(mid) + + return suggestions[:limit] + + +# --------------------------------------------------------------------------- +# Custom provider switch +# --------------------------------------------------------------------------- + +def switch_to_custom_provider() -> CustomAutoResult: + """Handle bare '/model --provider custom' — resolve endpoint and auto-detect model.""" from hermes_cli.runtime_provider import ( resolve_runtime_provider, _auto_detect_local_model, @@ -219,7 +747,7 @@ def switch_to_custom_provider() -> CustomAutoResult: error_message=( "No custom endpoint configured. " "Set model.base_url in config.yaml, or set OPENAI_BASE_URL " - "in .env, or run: hermes setup → Custom OpenAI-compatible endpoint" + "in .env, or run: hermes setup -> Custom OpenAI-compatible endpoint" ), ) @@ -232,7 +760,7 @@ def switch_to_custom_provider() -> CustomAutoResult: error_message=( f"Custom endpoint at {cust_base} is reachable but no single " f"model was auto-detected. Specify the model explicitly: " - f"/model custom:" + f"/model --provider custom" ), ) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py new file mode 100644 index 000000000000..890927884ccf --- /dev/null +++ b/hermes_cli/providers.py @@ -0,0 +1,519 @@ +""" +Single source of truth for provider identity in Hermes Agent. + +Two data sources, merged at runtime: + +1. **models.dev catalog** — 109+ providers with base URLs, env vars, display + names, and full model metadata (context, cost, capabilities). This is + the primary database. + +2. **Hermes overlays** — transport type, auth patterns, aggregator flags, + and additional env vars that models.dev doesn't track. Small dict, + maintained here. + +3. **User config** (``providers:`` section in config.yaml) — user-defined + endpoints and overrides. Merged on top of everything else. + +Other modules import from this file. No parallel registries. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# -- Hermes overlay ---------------------------------------------------------- +# Hermes-specific metadata that models.dev doesn't provide. + +@dataclass(frozen=True) +class HermesOverlay: + """Hermes-specific provider metadata layered on top of models.dev.""" + + transport: str = "openai_chat" # openai_chat | anthropic_messages | codex_responses + is_aggregator: bool = False + auth_type: str = "api_key" # api_key | oauth_device_code | oauth_external | external_process + extra_env_vars: Tuple[str, ...] = () # env vars models.dev doesn't list + base_url_override: str = "" # override if models.dev URL is wrong/missing + base_url_env_var: str = "" # env var for user-custom base URL + + +HERMES_OVERLAYS: Dict[str, HermesOverlay] = { + "openrouter": HermesOverlay( + transport="openai_chat", + is_aggregator=True, + extra_env_vars=("OPENAI_API_KEY",), + base_url_env_var="OPENROUTER_BASE_URL", + ), + "nous": HermesOverlay( + transport="openai_chat", + auth_type="oauth_device_code", + base_url_override="https://inference-api.nousresearch.com/v1", + ), + "openai-codex": HermesOverlay( + transport="codex_responses", + auth_type="oauth_external", + base_url_override="https://chatgpt.com/backend-api/codex", + ), + "copilot-acp": HermesOverlay( + transport="codex_responses", + auth_type="external_process", + base_url_override="acp://copilot", + base_url_env_var="COPILOT_ACP_BASE_URL", + ), + "github-copilot": HermesOverlay( + transport="openai_chat", + extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"), + ), + "anthropic": HermesOverlay( + transport="anthropic_messages", + extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), + ), + "zai": HermesOverlay( + transport="openai_chat", + extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), + base_url_env_var="GLM_BASE_URL", + ), + "kimi-for-coding": HermesOverlay( + transport="openai_chat", + base_url_env_var="KIMI_BASE_URL", + ), + "minimax": HermesOverlay( + transport="openai_chat", + base_url_env_var="MINIMAX_BASE_URL", + ), + "minimax-cn": HermesOverlay( + transport="openai_chat", + base_url_env_var="MINIMAX_CN_BASE_URL", + ), + "deepseek": HermesOverlay( + transport="openai_chat", + base_url_env_var="DEEPSEEK_BASE_URL", + ), + "alibaba": HermesOverlay( + transport="openai_chat", + base_url_env_var="DASHSCOPE_BASE_URL", + ), + "vercel": HermesOverlay( + transport="openai_chat", + is_aggregator=True, + ), + "opencode": HermesOverlay( + transport="openai_chat", + is_aggregator=True, + base_url_env_var="OPENCODE_ZEN_BASE_URL", + ), + "opencode-go": HermesOverlay( + transport="openai_chat", + is_aggregator=True, + base_url_env_var="OPENCODE_GO_BASE_URL", + ), + "kilo": HermesOverlay( + transport="openai_chat", + is_aggregator=True, + base_url_env_var="KILOCODE_BASE_URL", + ), + "huggingface": HermesOverlay( + transport="openai_chat", + is_aggregator=True, + base_url_env_var="HF_BASE_URL", + ), +} + + +# -- Resolved provider ------------------------------------------------------- +# The merged result of models.dev + overlay + user config. + +@dataclass +class ProviderDef: + """Complete provider definition — merged from all sources.""" + + id: str + name: str + transport: str # openai_chat | anthropic_messages | codex_responses + api_key_env_vars: Tuple[str, ...] # all env vars to check for API key + base_url: str = "" + base_url_env_var: str = "" + is_aggregator: bool = False + auth_type: str = "api_key" + doc: str = "" + source: str = "" # "models.dev", "hermes", "user-config" + + @property + def is_user_defined(self) -> bool: + return self.source == "user-config" + + +# -- Aliases ------------------------------------------------------------------ +# Maps human-friendly / legacy names to canonical provider IDs. +# Uses models.dev IDs where possible. + +ALIASES: Dict[str, str] = { + # openrouter + "openai": "openrouter", # bare "openai" → route through aggregator + + # zai + "glm": "zai", + "z-ai": "zai", + "z.ai": "zai", + "zhipu": "zai", + + # kimi-for-coding (models.dev ID) + "kimi": "kimi-for-coding", + "kimi-coding": "kimi-for-coding", + "moonshot": "kimi-for-coding", + + # minimax-cn + "minimax-china": "minimax-cn", + "minimax_cn": "minimax-cn", + + # anthropic + "claude": "anthropic", + "claude-code": "anthropic", + + # github-copilot (models.dev ID) + "copilot": "github-copilot", + "github": "github-copilot", + "github-copilot-acp": "copilot-acp", + + # vercel (models.dev ID for AI Gateway) + "ai-gateway": "vercel", + "aigateway": "vercel", + "vercel-ai-gateway": "vercel", + + # opencode (models.dev ID for OpenCode Zen) + "opencode-zen": "opencode", + "zen": "opencode", + + # opencode-go + "go": "opencode-go", + "opencode-go-sub": "opencode-go", + + # kilo (models.dev ID for KiloCode) + "kilocode": "kilo", + "kilo-code": "kilo", + "kilo-gateway": "kilo", + + # deepseek + "deep-seek": "deepseek", + + # alibaba + "dashscope": "alibaba", + "aliyun": "alibaba", + "qwen": "alibaba", + "alibaba-cloud": "alibaba", + + # huggingface + "hf": "huggingface", + "hugging-face": "huggingface", + "huggingface-hub": "huggingface", + + # Local server aliases → virtual "local" concept (resolved via user config) + "lmstudio": "lmstudio", + "lm-studio": "lmstudio", + "lm_studio": "lmstudio", + "ollama": "ollama-cloud", + "vllm": "local", + "llamacpp": "local", + "llama.cpp": "local", + "llama-cpp": "local", +} + + +# -- Display labels ----------------------------------------------------------- +# Built dynamically from models.dev + overlays. Fallback for providers +# not in the catalog. + +_LABEL_OVERRIDES: Dict[str, str] = { + "nous": "Nous Portal", + "openai-codex": "OpenAI Codex", + "copilot-acp": "GitHub Copilot ACP", + "local": "Local endpoint", +} + + +# -- Transport → API mode mapping --------------------------------------------- + +TRANSPORT_TO_API_MODE: Dict[str, str] = { + "openai_chat": "chat_completions", + "anthropic_messages": "anthropic_messages", + "codex_responses": "codex_responses", +} + + +# -- Helper functions --------------------------------------------------------- + +def normalize_provider(name: str) -> str: + """Resolve aliases and normalise casing to a canonical provider id. + + Returns the canonical id string. Does *not* validate that the id + corresponds to a known provider. + """ + key = name.strip().lower() + return ALIASES.get(key, key) + + +def get_overlay(provider_id: str) -> Optional[HermesOverlay]: + """Get Hermes overlay for a provider, if one exists.""" + canonical = normalize_provider(provider_id) + return HERMES_OVERLAYS.get(canonical) + + +def get_provider(name: str) -> Optional[ProviderDef]: + """Look up a provider by id or alias, merging all data sources. + + Resolution order: + 1. Hermes overlays (for providers not in models.dev: nous, openai-codex, etc.) + 2. models.dev catalog + Hermes overlay + 3. User-defined providers from config (TODO: Phase 4) + + Returns a fully-resolved ProviderDef or None. + """ + canonical = normalize_provider(name) + + # Try to get models.dev data + try: + from agent.models_dev import get_provider_info as _mdev_provider + mdev_info = _mdev_provider(canonical) + except Exception: + mdev_info = None + + overlay = HERMES_OVERLAYS.get(canonical) + + if mdev_info is not None: + # Merge models.dev + overlay + transport = overlay.transport if overlay else "openai_chat" + is_agg = overlay.is_aggregator if overlay else False + auth = overlay.auth_type if overlay else "api_key" + base_url_env = overlay.base_url_env_var if overlay else "" + base_url_override = overlay.base_url_override if overlay else "" + + # Combine env vars: models.dev env + hermes extra + env_vars = list(mdev_info.env) + if overlay and overlay.extra_env_vars: + for ev in overlay.extra_env_vars: + if ev not in env_vars: + env_vars.append(ev) + + return ProviderDef( + id=canonical, + name=mdev_info.name, + transport=transport, + api_key_env_vars=tuple(env_vars), + base_url=base_url_override or mdev_info.api, + base_url_env_var=base_url_env, + is_aggregator=is_agg, + auth_type=auth, + doc=mdev_info.doc, + source="models.dev", + ) + + if overlay is not None: + # Hermes-only provider (not in models.dev) + return ProviderDef( + id=canonical, + name=_LABEL_OVERRIDES.get(canonical, canonical), + transport=overlay.transport, + api_key_env_vars=overlay.extra_env_vars, + base_url=overlay.base_url_override, + base_url_env_var=overlay.base_url_env_var, + is_aggregator=overlay.is_aggregator, + auth_type=overlay.auth_type, + source="hermes", + ) + + return None + + +def get_label(provider_id: str) -> str: + """Get a human-readable display name for a provider.""" + canonical = normalize_provider(provider_id) + + # Check label overrides first + if canonical in _LABEL_OVERRIDES: + return _LABEL_OVERRIDES[canonical] + + # Try models.dev + pdef = get_provider(canonical) + if pdef: + return pdef.name + + return canonical + + +# Build LABELS dict for backward compat +def _build_labels() -> Dict[str, str]: + """Build labels dict from overlays + overrides. Lazy, cached.""" + labels: Dict[str, str] = {} + for pid in HERMES_OVERLAYS: + labels[pid] = get_label(pid) + labels.update(_LABEL_OVERRIDES) + return labels + +# Lazy-built on first access +_labels_cache: Optional[Dict[str, str]] = None + +@property +def LABELS() -> Dict[str, str]: + """Backward-compatible labels dict.""" + global _labels_cache + if _labels_cache is None: + _labels_cache = _build_labels() + return _labels_cache + +# For direct import compat, expose as module-level dict +# Built on demand by get_label() calls +LABELS: Dict[str, str] = { + # Static entries for backward compat — get_label() is the proper API + "openrouter": "OpenRouter", + "nous": "Nous Portal", + "openai-codex": "OpenAI Codex", + "copilot-acp": "GitHub Copilot ACP", + "github-copilot": "GitHub Copilot", + "anthropic": "Anthropic", + "zai": "Z.AI / GLM", + "kimi-for-coding": "Kimi / Moonshot", + "minimax": "MiniMax", + "minimax-cn": "MiniMax (China)", + "deepseek": "DeepSeek", + "alibaba": "Alibaba Cloud (DashScope)", + "vercel": "Vercel AI Gateway", + "opencode": "OpenCode Zen", + "opencode-go": "OpenCode Go", + "kilo": "Kilo Gateway", + "huggingface": "Hugging Face", + "local": "Local endpoint", + "custom": "Custom endpoint", + # Legacy Hermes IDs (point to same providers) + "ai-gateway": "Vercel AI Gateway", + "kilocode": "Kilo Gateway", + "copilot": "GitHub Copilot", + "kimi-coding": "Kimi / Moonshot", + "opencode-zen": "OpenCode Zen", +} + + +def is_aggregator(provider: str) -> bool: + """Return True when the provider is a multi-model aggregator.""" + pdef = get_provider(provider) + return pdef.is_aggregator if pdef else False + + +def determine_api_mode(provider: str, base_url: str = "") -> str: + """Determine the API mode (wire protocol) for a provider/endpoint. + + Resolution order: + 1. Known provider → transport → TRANSPORT_TO_API_MODE. + 2. URL heuristics for unknown / custom providers. + 3. Default: 'chat_completions'. + """ + pdef = get_provider(provider) + if pdef is not None: + return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions") + + # URL-based heuristics for custom / unknown providers + if base_url: + url_lower = base_url.rstrip("/").lower() + if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower: + return "anthropic_messages" + if "api.openai.com" in url_lower: + return "codex_responses" + + return "chat_completions" + + +# -- Provider from user config ------------------------------------------------ + +def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[ProviderDef]: + """Resolve a provider from the user's config.yaml ``providers:`` section. + + Args: + name: Provider name as given by the user. + user_config: The ``providers:`` dict from config.yaml. + + Returns: + ProviderDef if found, else None. + """ + if not user_config or not isinstance(user_config, dict): + return None + + entry = user_config.get(name) + if not isinstance(entry, dict): + return None + + # Extract fields + display_name = entry.get("name", "") or name + api_url = entry.get("api", "") or entry.get("url", "") or entry.get("base_url", "") or "" + key_env = entry.get("key_env", "") or "" + transport = entry.get("transport", "openai_chat") or "openai_chat" + + env_vars: List[str] = [] + if key_env: + env_vars.append(key_env) + + return ProviderDef( + id=name, + name=display_name, + transport=transport, + api_key_env_vars=tuple(env_vars), + base_url=api_url, + is_aggregator=False, + auth_type="api_key", + source="user-config", + ) + + +def resolve_provider_full( + name: str, + user_providers: Optional[Dict[str, Any]] = None, +) -> Optional[ProviderDef]: + """Full resolution chain: built-in → models.dev → user config. + + This is the main entry point for --provider flag resolution. + + Args: + name: Provider name or alias. + user_providers: The ``providers:`` dict from config.yaml (optional). + + Returns: + ProviderDef if found, else None. + """ + canonical = normalize_provider(name) + + # 1. Built-in (models.dev + overlays) + pdef = get_provider(canonical) + if pdef is not None: + return pdef + + # 2. User-defined providers from config + if user_providers: + # Try canonical name + user_pdef = resolve_user_provider(canonical, user_providers) + if user_pdef is not None: + return user_pdef + # Try original name (in case alias didn't match) + user_pdef = resolve_user_provider(name.strip().lower(), user_providers) + if user_pdef is not None: + return user_pdef + + # 3. Try models.dev directly (for providers not in our ALIASES) + try: + from agent.models_dev import get_provider_info as _mdev_provider + mdev_info = _mdev_provider(canonical) + if mdev_info is not None: + return ProviderDef( + id=canonical, + name=mdev_info.name, + transport="openai_chat", + api_key_env_vars=mdev_info.env, + base_url=mdev_info.api, + source="models.dev", + ) + except Exception: + pass + + return None diff --git a/run_agent.py b/run_agent.py index b66f5f9669f6..48daa3113c90 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1268,6 +1268,129 @@ def reset_session_state(self): # Iterative summary from previous session must not bleed into new one (#2635) self.context_compressor._previous_summary = None + def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mode=''): + """Switch the model/provider in-place for a live agent. + + Called by the /model command handlers (CLI and gateway) after + ``model_switch.switch_model()`` has resolved credentials and + validated the model. This method performs the actual runtime + swap: rebuilding clients, updating caching flags, and refreshing + the context compressor. + + The implementation mirrors ``_try_activate_fallback()`` for the + client-swap logic but also updates ``_primary_runtime`` so the + change persists across turns (unlike fallback which is + turn-scoped). + """ + import logging + from hermes_cli.providers import determine_api_mode + + # ── Determine api_mode if not provided ── + if not api_mode: + api_mode = determine_api_mode(new_provider, base_url) + + old_model = self.model + old_provider = self.provider + + # ── Swap core runtime fields ── + self.model = new_model + self.provider = new_provider + self.base_url = base_url or self.base_url + self.api_mode = api_mode + if api_key: + self.api_key = api_key + + # ── Build new client ── + if api_mode == "anthropic_messages": + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_anthropic_token, + _is_oauth_token, + ) + effective_key = api_key or self.api_key or resolve_anthropic_token() or "" + self.api_key = effective_key + self._anthropic_api_key = effective_key + self._anthropic_base_url = base_url or getattr(self, "_anthropic_base_url", None) + self._anthropic_client = build_anthropic_client( + effective_key, self._anthropic_base_url, + ) + self._is_anthropic_oauth = _is_oauth_token(effective_key) + self.client = None + self._client_kwargs = {} + else: + effective_key = api_key or self.api_key + effective_base = base_url or self.base_url + self._client_kwargs = { + "api_key": effective_key, + "base_url": effective_base, + } + self.client = self._create_openai_client( + dict(self._client_kwargs), + reason="switch_model", + shared=True, + ) + + # ── Re-evaluate prompt caching ── + is_native_anthropic = api_mode == "anthropic_messages" + self._use_prompt_caching = ( + ("openrouter" in (self.base_url or "").lower() and "claude" in new_model.lower()) + or is_native_anthropic + ) + + # ── Update context compressor ── + if hasattr(self, "context_compressor") and self.context_compressor: + from agent.model_metadata import get_model_context_length + new_context_length = get_model_context_length( + self.model, + base_url=self.base_url, + api_key=self.api_key, + provider=self.provider, + ) + self.context_compressor.model = self.model + self.context_compressor.base_url = self.base_url + self.context_compressor.api_key = self.api_key + self.context_compressor.provider = self.provider + self.context_compressor.context_length = new_context_length + self.context_compressor.threshold_tokens = int( + new_context_length * self.context_compressor.threshold_percent + ) + + # ── Invalidate cached system prompt so it rebuilds next turn ── + self._cached_system_prompt = None + + # ── Update _primary_runtime so the change persists across turns ── + _cc = self.context_compressor if hasattr(self, "context_compressor") and self.context_compressor else None + self._primary_runtime = { + "model": self.model, + "provider": self.provider, + "base_url": self.base_url, + "api_mode": self.api_mode, + "api_key": getattr(self, "api_key", ""), + "client_kwargs": dict(self._client_kwargs), + "use_prompt_caching": self._use_prompt_caching, + "compressor_model": _cc.model if _cc else self.model, + "compressor_base_url": _cc.base_url if _cc else self.base_url, + "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", + "compressor_provider": _cc.provider if _cc else self.provider, + "compressor_context_length": _cc.context_length if _cc else 0, + "compressor_threshold_tokens": _cc.threshold_tokens if _cc else 0, + } + if api_mode == "anthropic_messages": + self._primary_runtime.update({ + "anthropic_api_key": self._anthropic_api_key, + "anthropic_base_url": self._anthropic_base_url, + "is_anthropic_oauth": self._is_anthropic_oauth, + }) + + # ── Reset fallback state ── + self._fallback_activated = False + self._fallback_index = 0 + + logging.info( + "Model switched in-place: %s (%s) -> %s (%s)", + old_model, old_provider, new_model, new_provider, + ) + def _safe_print(self, *args, **kwargs): """Print that silently handles broken pipes / closed stdout. From d932980c1a7d9b83b7dac7552824192d73fdd635 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:00:19 -0700 Subject: [PATCH 031/213] Add gitnexus-explorer optional skill (#5208) Index codebases with GitNexus and serve an interactive knowledge graph web UI via Cloudflare tunnel. No sudo required. Includes: - Full setup/build/serve/tunnel pipeline - Zero-dependency Node.js reverse proxy script - Pitfalls section covering cloudflared config conflicts, Vite allowedHosts, Claude Code artifact cleanup, and browser memory limits for large repos --- .../research/gitnexus-explorer/SKILL.md | 213 ++++++++++++++++++ .../gitnexus-explorer/scripts/proxy.mjs | 92 ++++++++ 2 files changed, 305 insertions(+) create mode 100644 optional-skills/research/gitnexus-explorer/SKILL.md create mode 100644 optional-skills/research/gitnexus-explorer/scripts/proxy.mjs diff --git a/optional-skills/research/gitnexus-explorer/SKILL.md b/optional-skills/research/gitnexus-explorer/SKILL.md new file mode 100644 index 000000000000..d57c896ed5e5 --- /dev/null +++ b/optional-skills/research/gitnexus-explorer/SKILL.md @@ -0,0 +1,213 @@ +--- +name: gitnexus-explorer +description: Index a codebase with GitNexus and serve an interactive knowledge graph via web UI + Cloudflare tunnel. +version: 1.0.0 +author: Hermes Agent + Teknium +license: MIT +metadata: + hermes: + tags: [gitnexus, code-intelligence, knowledge-graph, visualization] + related_skills: [native-mcp, codebase-inspection] +--- + +# GitNexus Explorer + +Index any codebase into a knowledge graph and serve an interactive web UI for exploring +symbols, call chains, clusters, and execution flows. Tunneled via Cloudflare for remote access. + +## When to Use + +- User wants to visually explore a codebase's architecture +- User asks for a knowledge graph / dependency graph of a repo +- User wants to share an interactive codebase explorer with someone + +## Prerequisites + +- **Node.js** (v18+) — required for GitNexus and the proxy +- **git** — repo must have a `.git` directory +- **cloudflared** — for tunneling (auto-installed to ~/.local/bin if missing) + +## Size Warning + +The web UI renders all nodes in the browser. Repos under ~5,000 files work well. Large +repos (30k+ nodes) will be sluggish or crash the browser tab. The CLI/MCP tools work +at any scale — only the web visualization has this limit. + +## Steps + +### 1. Clone and Build GitNexus (one-time setup) + +```bash +GITNEXUS_DIR="${GITNEXUS_DIR:-$HOME/.local/share/gitnexus}" + +if [ ! -d "$GITNEXUS_DIR/gitnexus-web/dist" ]; then + git clone https://github.com/abhigyanpatwari/GitNexus.git "$GITNEXUS_DIR" + cd "$GITNEXUS_DIR/gitnexus-shared" && npm install && npm run build + cd "$GITNEXUS_DIR/gitnexus-web" && npm install +fi +``` + +### 2. Patch the Web UI for Remote Access + +The web UI defaults to `localhost:4747` for API calls. Patch it to use same-origin +so it works through a tunnel/proxy: + +**File: `$GITNEXUS_DIR/gitnexus-web/src/config/ui-constants.ts`** +Change: +```typescript +export const DEFAULT_BACKEND_URL = 'http://localhost:4747'; +``` +To: +```typescript +export const DEFAULT_BACKEND_URL = typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? window.location.origin : 'http://localhost:4747'; +``` + +**File: `$GITNEXUS_DIR/gitnexus-web/vite.config.ts`** +Add `allowedHosts: true` inside the `server: { }` block (only needed if running dev +mode instead of production build): +```typescript +server: { + allowedHosts: true, + // ... existing config +}, +``` + +Then build the production bundle: +```bash +cd "$GITNEXUS_DIR/gitnexus-web" && npx vite build +``` + +### 3. Index the Target Repo + +```bash +cd /path/to/target-repo +npx gitnexus analyze --skip-agents-md +rm -rf .claude/ # remove Claude Code-specific artifacts +``` + +Add `--embeddings` for semantic search (slower — minutes instead of seconds). + +The index lives in `.gitnexus/` inside the repo (auto-gitignored). + +### 4. Create the Proxy Script + +Write this to a file (e.g., `$GITNEXUS_DIR/proxy.mjs`). It serves the production +web UI and proxies `/api/*` to the GitNexus backend — same origin, no CORS issues, +no sudo, no nginx. + +```javascript +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; + +const API_PORT = parseInt(process.env.API_PORT || '4747'); +const DIST_DIR = process.argv[2] || './dist'; +const PORT = parseInt(process.argv[3] || '8888'); + +const MIME = { + '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', + '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', + '.wasm': 'application/wasm', +}; + +function proxyToApi(req, res) { + const opts = { + hostname: '127.0.0.1', port: API_PORT, + path: req.url, method: req.method, headers: req.headers, + }; + const proxy = http.request(opts, (upstream) => { + res.writeHead(upstream.statusCode, upstream.headers); + upstream.pipe(res, { end: true }); + }); + proxy.on('error', () => { res.writeHead(502); res.end('Backend unavailable'); }); + req.pipe(proxy, { end: true }); +} + +function serveStatic(req, res) { + let filePath = path.join(DIST_DIR, req.url === '/' ? 'index.html' : req.url.split('?')[0]); + if (!fs.existsSync(filePath)) filePath = path.join(DIST_DIR, 'index.html'); + const ext = path.extname(filePath); + const mime = MIME[ext] || 'application/octet-stream'; + try { + const data = fs.readFileSync(filePath); + res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=3600' }); + res.end(data); + } catch { res.writeHead(404); res.end('Not found'); } +} + +http.createServer((req, res) => { + if (req.url.startsWith('/api')) proxyToApi(req, res); + else serveStatic(req, res); +}).listen(PORT, () => console.log(`GitNexus proxy on http://localhost:${PORT}`)); +``` + +### 5. Start the Services + +```bash +# Terminal 1: GitNexus backend API +npx gitnexus serve & + +# Terminal 2: Proxy (web UI + API on one port) +node "$GITNEXUS_DIR/proxy.mjs" "$GITNEXUS_DIR/gitnexus-web/dist" 8888 & +``` + +Verify: `curl -s http://localhost:8888/api/repos` should return the indexed repo(s). + +### 6. Tunnel with Cloudflare (optional — for remote access) + +```bash +# Install cloudflared if needed (no sudo) +if ! command -v cloudflared &>/dev/null; then + mkdir -p ~/.local/bin + curl -sL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \ + -o ~/.local/bin/cloudflared + chmod +x ~/.local/bin/cloudflared + export PATH="$HOME/.local/bin:$PATH" +fi + +# Start tunnel (--config /dev/null avoids conflicts with existing named tunnels) +cloudflared tunnel --config /dev/null --url http://localhost:8888 --no-autoupdate --protocol http2 +``` + +The tunnel URL (e.g., `https://random-words.trycloudflare.com`) is printed to stderr. +Share it — anyone with the link can explore the graph. + +### 7. Cleanup + +```bash +# Stop services +pkill -f "gitnexus serve" +pkill -f "proxy.mjs" +pkill -f cloudflared + +# Remove index from the target repo +cd /path/to/target-repo +npx gitnexus clean +rm -rf .claude/ +``` + +## Pitfalls + +- **`--config /dev/null` is required for cloudflared** if the user has an existing + named tunnel config at `~/.cloudflared/config.yml`. Without it, the catch-all + ingress rule in the config returns 404 for all quick tunnel requests. + +- **Production build is mandatory for tunneling.** The Vite dev server blocks + non-localhost hosts by default (`allowedHosts`). The production build + Node + proxy avoids this entirely. + +- **The web UI does NOT create `.claude/` or `CLAUDE.md`.** Those are created by + `npx gitnexus analyze`. Use `--skip-agents-md` to suppress the markdown files, + then `rm -rf .claude/` for the rest. These are Claude Code integrations that + hermes-agent users don't need. + +- **Browser memory limit.** The web UI loads the entire graph into browser memory. + Repos with 5k+ files may be sluggish. 30k+ files will likely crash the tab. + +- **Embeddings are optional.** `--embeddings` enables semantic search but takes + minutes on large repos. Skip it for quick exploration; add it if you want + natural language queries via the AI chat panel. + +- **Multiple repos.** `gitnexus serve` serves ALL indexed repos. Index several + repos, start serve once, and the web UI lets you switch between them. diff --git a/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs b/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs new file mode 100644 index 000000000000..65b34e745b56 --- /dev/null +++ b/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs @@ -0,0 +1,92 @@ +/** + * GitNexus reverse proxy — serves production web UI + proxies /api/* to backend. + * Zero dependencies, Node.js built-ins only. + * + * Usage: node proxy.mjs [port] + * dist-dir: path to gitnexus-web/dist (production build) + * port: listen port (default: 8888) + * + * Environment: + * API_PORT: GitNexus serve backend port (default: 4747) + */ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; + +const API_PORT = parseInt(process.env.API_PORT || '4747'); +const DIST_DIR = process.argv[2] || './dist'; +const PORT = parseInt(process.argv[3] || '8888'); + +const MIME = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.wasm': 'application/wasm', + '.ttf': 'font/ttf', + '.map': 'application/json', +}; + +function proxyToApi(req, res) { + const opts = { + hostname: '127.0.0.1', + port: API_PORT, + path: req.url, + method: req.method, + headers: { ...req.headers, host: `127.0.0.1:${API_PORT}` }, + }; + const proxy = http.request(opts, (upstream) => { + res.writeHead(upstream.statusCode, upstream.headers); + upstream.pipe(res, { end: true }); + }); + proxy.on('error', () => { + res.writeHead(502, { 'Content-Type': 'text/plain' }); + res.end('GitNexus backend unavailable — is `npx gitnexus serve` running?'); + }); + req.pipe(proxy, { end: true }); +} + +function serveStatic(req, res) { + const urlPath = req.url.split('?')[0]; + let filePath = path.join(DIST_DIR, urlPath === '/' ? 'index.html' : urlPath); + + // SPA fallback: if file doesn't exist and isn't a static asset, serve index.html + if (!fs.existsSync(filePath) && !path.extname(filePath)) { + filePath = path.join(DIST_DIR, 'index.html'); + } + + const ext = path.extname(filePath); + const mime = MIME[ext] || 'application/octet-stream'; + + try { + const data = fs.readFileSync(filePath); + res.writeHead(200, { + 'Content-Type': mime, + 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=86400', + }); + res.end(data); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + } +} + +const server = http.createServer((req, res) => { + if (req.url.startsWith('/api')) { + proxyToApi(req, res); + } else { + serveStatic(req, res); + } +}); + +server.listen(PORT, () => { + console.log(`GitNexus proxy listening on http://localhost:${PORT}`); + console.log(` Web UI: http://localhost:${PORT}/`); + console.log(` API: http://localhost:${PORT}/api/repos`); + console.log(` Backend: http://127.0.0.1:${API_PORT}`); +}); From 51ed7dc2f399295b6692be78aab0fe975a263cc8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:29:57 -0700 Subject: [PATCH 032/213] feat: save oversized tool results to file instead of destructive truncation (#5210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, tool results exceeding 100K characters were silently chopped with only a '[Truncated]' notice — the rest of the content was lost permanently. The model had no way to access the truncated portion. Now, oversized results are written to HERMES_HOME/cache/tool_responses/ and the model receives: - A 1,500-char head preview for immediate context - The file path so it can use read_file/search_files on the full output This preserves the context window protection (inline content stays small) while making the full data recoverable. Falls back to the old destructive truncation if the file write fails. Inspired by Block/goose's large response handler pattern. --- run_agent.py | 87 ++++++++++++----- tests/test_large_tool_result.py | 162 ++++++++++++++++++++++++++++++++ tests/test_run_agent.py | 18 ++-- 3 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 tests/test_large_tool_result.py diff --git a/run_agent.py b/run_agent.py index 48daa3113c90..7235f9f6c1c0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -405,6 +405,68 @@ def _strip_budget_warnings_from_history(messages: list) -> None: msg["content"] = cleaned +# ========================================================================= +# Large tool result handler — save oversized output to temp file +# ========================================================================= + +# Threshold at which tool results are saved to a file instead of kept inline. +# 100K chars ≈ 25K tokens — generous for any reasonable output but prevents +# catastrophic context explosions. +_LARGE_RESULT_CHARS = 100_000 + +# How many characters of the original result to include as an inline preview +# so the model has immediate context about what the tool returned. +_LARGE_RESULT_PREVIEW_CHARS = 1_500 + + +def _save_oversized_tool_result(function_name: str, function_result: str) -> str: + """Replace oversized tool results with a file reference + preview. + + When a tool returns more than ``_LARGE_RESULT_CHARS`` characters, the full + content is written to a temporary file under ``HERMES_HOME/cache/tool_responses/`` + and the result sent to the model is replaced with: + • a brief head preview (first ``_LARGE_RESULT_PREVIEW_CHARS`` chars) + • the file path so the model can use ``read_file`` / ``search_files`` + + Falls back to destructive truncation if the file write fails. + """ + original_len = len(function_result) + if original_len <= _LARGE_RESULT_CHARS: + return function_result + + # Build the target directory + try: + response_dir = os.path.join(get_hermes_home(), "cache", "tool_responses") + os.makedirs(response_dir, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + # Sanitize tool name for use in filename + safe_name = re.sub(r"[^\w\-]", "_", function_name)[:40] + filename = f"{safe_name}_{timestamp}.txt" + filepath = os.path.join(response_dir, filename) + + with open(filepath, "w", encoding="utf-8") as f: + f.write(function_result) + + preview = function_result[:_LARGE_RESULT_PREVIEW_CHARS] + return ( + f"{preview}\n\n" + f"[Large tool response: {original_len:,} characters total — " + f"only the first {_LARGE_RESULT_PREVIEW_CHARS:,} shown above. " + f"Full output saved to: {filepath}\n" + f"Use read_file or search_files on that path to access the rest.]" + ) + except Exception as exc: + # Fall back to destructive truncation if file write fails + logger.warning("Failed to save large tool result to file: %s", exc) + return ( + function_result[:_LARGE_RESULT_CHARS] + + f"\n\n[Truncated: tool response was {original_len:,} chars, " + f"exceeding the {_LARGE_RESULT_CHARS:,} char limit. " + f"File save failed: {exc}]" + ) + + class AIAgent: """ AI Agent with tool calling capabilities. @@ -6051,15 +6113,8 @@ def _run_tool(index, tool_call, function_name, function_args): except Exception as cb_err: logging.debug(f"Tool complete callback error: {cb_err}") - # Truncate oversized results - MAX_TOOL_RESULT_CHARS = 100_000 - if len(function_result) > MAX_TOOL_RESULT_CHARS: - original_len = len(function_result) - function_result = ( - function_result[:MAX_TOOL_RESULT_CHARS] - + f"\n\n[Truncated: tool response was {original_len:,} chars, " - f"exceeding the {MAX_TOOL_RESULT_CHARS:,} char limit]" - ) + # Save oversized results to file instead of destructive truncation + function_result = _save_oversized_tool_result(name, function_result) # Append tool result message in order tool_msg = { @@ -6332,18 +6387,8 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe except Exception as cb_err: logging.debug(f"Tool complete callback error: {cb_err}") - # Guard against tools returning absurdly large content that would - # blow up the context window. 100K chars ≈ 25K tokens — generous - # enough for any reasonable tool output but prevents catastrophic - # context explosions (e.g. accidental base64 image dumps). - MAX_TOOL_RESULT_CHARS = 100_000 - if len(function_result) > MAX_TOOL_RESULT_CHARS: - original_len = len(function_result) - function_result = ( - function_result[:MAX_TOOL_RESULT_CHARS] - + f"\n\n[Truncated: tool response was {original_len:,} chars, " - f"exceeding the {MAX_TOOL_RESULT_CHARS:,} char limit]" - ) + # Save oversized results to file instead of destructive truncation + function_result = _save_oversized_tool_result(function_name, function_result) tool_msg = { "role": "tool", diff --git a/tests/test_large_tool_result.py b/tests/test_large_tool_result.py new file mode 100644 index 000000000000..ef51f2fe5005 --- /dev/null +++ b/tests/test_large_tool_result.py @@ -0,0 +1,162 @@ +"""Tests for _save_oversized_tool_result() — the large tool response handler. + +When a tool returns more than _LARGE_RESULT_CHARS characters, the full content +is saved to a file and the model receives a preview + file path instead. +""" + +import os +import re + +import pytest + +from run_agent import ( + _save_oversized_tool_result, + _LARGE_RESULT_CHARS, + _LARGE_RESULT_PREVIEW_CHARS, +) + + +class TestSaveOversizedToolResult: + """Unit tests for the large tool result handler.""" + + def test_small_result_returned_unchanged(self): + """Results under the threshold pass through untouched.""" + small = "x" * 1000 + assert _save_oversized_tool_result("terminal", small) is small + + def test_exactly_at_threshold_returned_unchanged(self): + """Results exactly at the threshold pass through.""" + exact = "y" * _LARGE_RESULT_CHARS + assert _save_oversized_tool_result("terminal", exact) is exact + + def test_oversized_result_saved_to_file(self, tmp_path, monkeypatch): + """Results over the threshold are written to a file.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + os.makedirs(tmp_path / ".hermes", exist_ok=True) + + big = "A" * (_LARGE_RESULT_CHARS + 500) + result = _save_oversized_tool_result("terminal", big) + + # Should contain the preview + assert result.startswith("A" * _LARGE_RESULT_PREVIEW_CHARS) + # Should mention the file path + assert "Full output saved to:" in result + # Should mention original size + assert f"{len(big):,}" in result + + # Extract the file path and verify the file exists with full content + match = re.search(r"Full output saved to: (.+?)\n", result) + assert match, f"No file path found in result: {result[:300]}" + filepath = match.group(1) + assert os.path.isfile(filepath) + with open(filepath, "r", encoding="utf-8") as f: + saved = f.read() + assert saved == big + assert len(saved) == _LARGE_RESULT_CHARS + 500 + + def test_file_placed_in_cache_tool_responses(self, tmp_path, monkeypatch): + """Saved file lives under HERMES_HOME/cache/tool_responses/.""" + hermes_home = str(tmp_path / ".hermes") + monkeypatch.setenv("HERMES_HOME", hermes_home) + os.makedirs(hermes_home, exist_ok=True) + + big = "B" * (_LARGE_RESULT_CHARS + 1) + result = _save_oversized_tool_result("web_search", big) + + match = re.search(r"Full output saved to: (.+?)\n", result) + filepath = match.group(1) + expected_dir = os.path.join(hermes_home, "cache", "tool_responses") + assert filepath.startswith(expected_dir) + + def test_filename_contains_tool_name(self, tmp_path, monkeypatch): + """The saved filename includes a sanitized version of the tool name.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + os.makedirs(tmp_path / ".hermes", exist_ok=True) + + big = "C" * (_LARGE_RESULT_CHARS + 1) + result = _save_oversized_tool_result("browser_navigate", big) + + match = re.search(r"Full output saved to: (.+?)\n", result) + filename = os.path.basename(match.group(1)) + assert filename.startswith("browser_navigate_") + assert filename.endswith(".txt") + + def test_tool_name_sanitized(self, tmp_path, monkeypatch): + """Special characters in tool names are replaced in the filename.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + os.makedirs(tmp_path / ".hermes", exist_ok=True) + + big = "D" * (_LARGE_RESULT_CHARS + 1) + result = _save_oversized_tool_result("mcp:some/weird tool", big) + + match = re.search(r"Full output saved to: (.+?)\n", result) + filename = os.path.basename(match.group(1)) + # No slashes or colons in filename + assert "/" not in filename + assert ":" not in filename + + def test_fallback_on_write_failure(self, tmp_path, monkeypatch): + """When file write fails, falls back to destructive truncation.""" + # Point HERMES_HOME to a path that will fail (file, not directory) + bad_path = str(tmp_path / "not_a_dir.txt") + with open(bad_path, "w") as f: + f.write("I'm a file, not a directory") + monkeypatch.setenv("HERMES_HOME", bad_path) + + big = "E" * (_LARGE_RESULT_CHARS + 50_000) + result = _save_oversized_tool_result("terminal", big) + + # Should still contain data (fallback truncation) + assert len(result) > 0 + assert result.startswith("E" * 1000) + # Should mention the failure + assert "File save failed" in result + # Should be truncated to approximately _LARGE_RESULT_CHARS + error msg + assert len(result) < len(big) + + def test_preview_length_capped(self, tmp_path, monkeypatch): + """The inline preview is capped at _LARGE_RESULT_PREVIEW_CHARS.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + os.makedirs(tmp_path / ".hermes", exist_ok=True) + + # Use distinct chars so we can measure the preview + big = "Z" * (_LARGE_RESULT_CHARS + 5000) + result = _save_oversized_tool_result("terminal", big) + + # The preview section is the content before the "[Large tool response:" marker + marker_pos = result.index("[Large tool response:") + preview_section = result[:marker_pos].rstrip() + assert len(preview_section) == _LARGE_RESULT_PREVIEW_CHARS + + def test_guidance_message_mentions_tools(self, tmp_path, monkeypatch): + """The replacement message tells the model how to access the file.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + os.makedirs(tmp_path / ".hermes", exist_ok=True) + + big = "F" * (_LARGE_RESULT_CHARS + 1) + result = _save_oversized_tool_result("terminal", big) + + assert "read_file" in result + assert "search_files" in result + + def test_empty_result_passes_through(self): + """Empty strings are not oversized.""" + assert _save_oversized_tool_result("terminal", "") == "" + + def test_unicode_content_preserved(self, tmp_path, monkeypatch): + """Unicode content is fully preserved in the saved file.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + os.makedirs(tmp_path / ".hermes", exist_ok=True) + + # Mix of ASCII and multi-byte unicode to exceed threshold + unit = "Hello 世界! 🎉 " * 100 # ~1400 chars per repeat + big = unit * ((_LARGE_RESULT_CHARS // len(unit)) + 1) + assert len(big) > _LARGE_RESULT_CHARS + + result = _save_oversized_tool_result("terminal", big) + match = re.search(r"Full output saved to: (.+?)\n", result) + filepath = match.group(1) + + with open(filepath, "r", encoding="utf-8") as f: + saved = f.read() + assert saved == big diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 963ee56f313f..66ba411a01ea 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1002,16 +1002,19 @@ def test_invalid_json_args_defaults_empty(self, agent): assert messages[0]["role"] == "tool" assert messages[0]["tool_call_id"] == "c1" - def test_result_truncation_over_100k(self, agent): + def test_result_truncation_over_100k(self, agent, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") mock_msg = _mock_assistant_msg(content="", tool_calls=[tc]) messages = [] big_result = "x" * 150_000 with patch("run_agent.handle_function_call", return_value=big_result): agent._execute_tool_calls(mock_msg, messages, "task-1") - # Content should be truncated + # Content should be replaced with preview + file path assert len(messages[0]["content"]) < 150_000 - assert "Truncated" in messages[0]["content"] + assert "Large tool response" in messages[0]["content"] + assert "Full output saved to:" in messages[0]["content"] class TestConcurrentToolExecution: @@ -1230,8 +1233,10 @@ def test_concurrent_interrupt_before_start(self, agent): assert "cancelled" in messages[0]["content"].lower() or "skipped" in messages[0]["content"].lower() assert "cancelled" in messages[1]["content"].lower() or "skipped" in messages[1]["content"].lower() - def test_concurrent_truncates_large_results(self, agent): - """Concurrent path should truncate results over 100k chars.""" + def test_concurrent_truncates_large_results(self, agent, tmp_path, monkeypatch): + """Concurrent path should save oversized results to file.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1") tc2 = _mock_tool_call(name="web_search", arguments='{}', call_id="c2") mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2]) @@ -1244,7 +1249,8 @@ def test_concurrent_truncates_large_results(self, agent): assert len(messages) == 2 for m in messages: assert len(m["content"]) < 150_000 - assert "Truncated" in m["content"] + assert "Large tool response" in m["content"] + assert "Full output saved to:" in m["content"] def test_invoke_tool_dispatches_to_handle_function_call(self, agent): """_invoke_tool should route regular tools through handle_function_call.""" From e899d6a05d59ab6edf74ed4558b5a9eb4e7beed0 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 5 Apr 2026 10:32:48 -0700 Subject: [PATCH 033/213] fix: increase default HERMES_AGENT_TIMEOUT from 10min to 30min Users hitting the 10-minute default during complex tool chains. Bumps both the execution cap and stale-lock eviction timeout. Still overridable via HERMES_AGENT_TIMEOUT env var (0 = unlimited). --- gateway/run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 0db0514ea19a..4411b5c68d91 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1784,7 +1784,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Staleness eviction: if an entry has been in _running_agents for # longer than the agent timeout, it's a leaked lock from a hung or # crashed handler. Evict it so the session isn't permanently stuck. - _raw_stale_timeout = float(os.getenv("HERMES_AGENT_TIMEOUT", 600)) + _raw_stale_timeout = float(os.getenv("HERMES_AGENT_TIMEOUT", 1800)) _STALE_TTL = (_raw_stale_timeout + 60) if _raw_stale_timeout > 0 else float("inf") _stale_ts = self._running_agents_ts.get(_quick_key, 0) if _quick_key in self._running_agents and _stale_ts and (time.time() - _stale_ts) > _STALE_TTL: @@ -6651,9 +6651,9 @@ async def _notify_long_running(): try: # Run in thread pool to not block. Cap total execution time # so a hung API call or runaway tool doesn't permanently lock - # the session. Default 10 minutes; override with env var. + # the session. Default 30 minutes; override with env var. # Set to 0 for no limit (infinite). - _agent_timeout_raw = float(os.getenv("HERMES_AGENT_TIMEOUT", 600)) + _agent_timeout_raw = float(os.getenv("HERMES_AGENT_TIMEOUT", 1800)) _agent_timeout = _agent_timeout_raw if _agent_timeout_raw > 0 else None loop = asyncio.get_event_loop() try: From 35d280d0bdc157adfb858141e9eda915efa034eb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:57:34 -0700 Subject: [PATCH 034/213] feat: coerce tool call arguments to match JSON Schema types (#5265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLMs frequently return numbers as strings ("42" instead of 42) and booleans as strings ("true" instead of true). This causes silent failures with MCP tools and any tool with strictly-typed parameters. Added coerce_tool_args() in model_tools.py that runs before every tool dispatch. For each argument, it checks the tool registry schema and attempts safe coercion: - "42" → 42 when schema says "type": "integer" - "3.14" → 3.14 when schema says "type": "number" - "true"/"false" → True/False when schema says "type": "boolean" - Union types tried in order - Original values preserved when coercion fails or is not applicable Inspired by Block/goose tool argument coercion system. --- model_tools.py | 94 ++++++++++++ tests/test_tool_arg_coercion.py | 262 ++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 tests/test_tool_arg_coercion.py diff --git a/model_tools.py b/model_tools.py index ec472ff99ea9..edea2315da77 100644 --- a/model_tools.py +++ b/model_tools.py @@ -365,6 +365,97 @@ def get_tool_definitions( _READ_SEARCH_TOOLS = {"read_file", "search_files"} +# ========================================================================= +# Tool argument type coercion +# ========================================================================= + +def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Coerce tool call arguments to match their JSON Schema types. + + LLMs frequently return numbers as strings (``"42"`` instead of ``42``) + and booleans as strings (``"true"`` instead of ``true``). This compares + each argument value against the tool's registered JSON Schema and attempts + safe coercion when the value is a string but the schema expects a different + type. Original values are preserved when coercion fails. + + Handles ``"type": "integer"``, ``"type": "number"``, ``"type": "boolean"``, + and union types (``"type": ["integer", "string"]``). + """ + if not args or not isinstance(args, dict): + return args + + schema = registry.get_schema(tool_name) + if not schema: + return args + + properties = (schema.get("parameters") or {}).get("properties") + if not properties: + return args + + for key, value in args.items(): + if not isinstance(value, str): + continue + prop_schema = properties.get(key) + if not prop_schema: + continue + expected = prop_schema.get("type") + if not expected: + continue + coerced = _coerce_value(value, expected) + if coerced is not value: + args[key] = coerced + + return args + + +def _coerce_value(value: str, expected_type): + """Attempt to coerce a string *value* to *expected_type*. + + Returns the original string when coercion is not applicable or fails. + """ + if isinstance(expected_type, list): + # Union type — try each in order, return first successful coercion + for t in expected_type: + result = _coerce_value(value, t) + if result is not value: + return result + return value + + if expected_type in ("integer", "number"): + return _coerce_number(value, integer_only=(expected_type == "integer")) + if expected_type == "boolean": + return _coerce_boolean(value) + return value + + +def _coerce_number(value: str, integer_only: bool = False): + """Try to parse *value* as a number. Returns original string on failure.""" + try: + f = float(value) + except (ValueError, OverflowError): + return value + # Guard against inf/nan before int() conversion + if f != f or f == float("inf") or f == float("-inf"): + return f + # If it looks like an integer (no fractional part), return int + if f == int(f): + return int(f) + if integer_only: + # Schema wants an integer but value has decimals — keep as string + return value + return f + + +def _coerce_boolean(value: str): + """Try to parse *value* as a boolean. Returns original string on failure.""" + low = value.strip().lower() + if low == "true": + return True + if low == "false": + return False + return value + + def handle_function_call( function_name: str, function_args: Dict[str, Any], @@ -388,6 +479,9 @@ def handle_function_call( Returns: Function result as a JSON string. """ + # Coerce string arguments to their schema-declared types (e.g. "42"→42) + function_args = coerce_tool_args(function_name, function_args) + # Notify the read-loop tracker when a non-read/search tool runs, # so the *consecutive* counter resets (reads after other work are fine). if function_name not in _READ_SEARCH_TOOLS: diff --git a/tests/test_tool_arg_coercion.py b/tests/test_tool_arg_coercion.py new file mode 100644 index 000000000000..cf1876d4e431 --- /dev/null +++ b/tests/test_tool_arg_coercion.py @@ -0,0 +1,262 @@ +"""Tests for tool argument type coercion. + +When LLMs return tool call arguments, they frequently put numbers as strings +("42" instead of 42) and booleans as strings ("true" instead of true). +coerce_tool_args() fixes these type mismatches by comparing argument values +against the tool's JSON Schema before dispatch. +""" + +import pytest +from unittest.mock import patch + +from model_tools import ( + coerce_tool_args, + _coerce_value, + _coerce_number, + _coerce_boolean, +) + + +# ── Low-level coercion helpers ──────────────────────────────────────────── + + +class TestCoerceNumber: + """Unit tests for _coerce_number.""" + + def test_integer_string(self): + assert _coerce_number("42") == 42 + assert isinstance(_coerce_number("42"), int) + + def test_negative_integer(self): + assert _coerce_number("-7") == -7 + + def test_zero(self): + assert _coerce_number("0") == 0 + assert isinstance(_coerce_number("0"), int) + + def test_float_string(self): + assert _coerce_number("3.14") == 3.14 + assert isinstance(_coerce_number("3.14"), float) + + def test_float_with_zero_fractional(self): + """3.0 should become int(3) since there's no fractional part.""" + assert _coerce_number("3.0") == 3 + assert isinstance(_coerce_number("3.0"), int) + + def test_integer_only_rejects_float(self): + """When integer_only=True, "3.14" should stay as string.""" + result = _coerce_number("3.14", integer_only=True) + assert result == "3.14" + assert isinstance(result, str) + + def test_integer_only_accepts_whole(self): + assert _coerce_number("42", integer_only=True) == 42 + + def test_not_a_number(self): + assert _coerce_number("hello") == "hello" + + def test_empty_string(self): + assert _coerce_number("") == "" + + def test_large_number(self): + assert _coerce_number("1000000") == 1000000 + + def test_scientific_notation(self): + assert _coerce_number("1e5") == 100000 + + def test_inf_stays_string_for_integer_only(self): + """Infinity should not be converted to int.""" + result = _coerce_number("inf") + assert result == float("inf") + + def test_negative_float(self): + assert _coerce_number("-2.5") == -2.5 + + +class TestCoerceBoolean: + """Unit tests for _coerce_boolean.""" + + def test_true_lowercase(self): + assert _coerce_boolean("true") is True + + def test_false_lowercase(self): + assert _coerce_boolean("false") is False + + def test_true_mixed_case(self): + assert _coerce_boolean("True") is True + + def test_false_mixed_case(self): + assert _coerce_boolean("False") is False + + def test_true_with_whitespace(self): + assert _coerce_boolean(" true ") is True + + def test_not_a_boolean(self): + assert _coerce_boolean("yes") == "yes" + + def test_one_zero_not_coerced(self): + """'1' and '0' are not boolean values.""" + assert _coerce_boolean("1") == "1" + assert _coerce_boolean("0") == "0" + + def test_empty_string(self): + assert _coerce_boolean("") == "" + + +class TestCoerceValue: + """Unit tests for _coerce_value.""" + + def test_integer_type(self): + assert _coerce_value("5", "integer") == 5 + + def test_number_type(self): + assert _coerce_value("3.14", "number") == 3.14 + + def test_boolean_type(self): + assert _coerce_value("true", "boolean") is True + + def test_string_type_passthrough(self): + """Strings expected as strings should not be coerced.""" + assert _coerce_value("hello", "string") == "hello" + + def test_unknown_type_passthrough(self): + assert _coerce_value("stuff", "object") == "stuff" + + def test_union_type_prefers_first_match(self): + """Union types try each in order.""" + assert _coerce_value("42", ["integer", "string"]) == 42 + + def test_union_type_falls_through(self): + """If no type matches, return original string.""" + assert _coerce_value("hello", ["integer", "boolean"]) == "hello" + + def test_union_with_string_preserves_original(self): + """A non-numeric string in [number, string] should stay a string.""" + assert _coerce_value("hello", ["number", "string"]) == "hello" + + +# ── Full coerce_tool_args with registry ─────────────────────────────────── + + +class TestCoerceToolArgs: + """Integration tests for coerce_tool_args using the tool registry.""" + + def _mock_schema(self, properties): + """Build a minimal tool schema with the given properties.""" + return { + "name": "test_tool", + "description": "test", + "parameters": { + "type": "object", + "properties": properties, + }, + } + + def test_coerces_integer_arg(self): + schema = self._mock_schema({"limit": {"type": "integer"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"limit": "10"} + result = coerce_tool_args("test_tool", args) + assert result["limit"] == 10 + assert isinstance(result["limit"], int) + + def test_coerces_boolean_arg(self): + schema = self._mock_schema({"merge": {"type": "boolean"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"merge": "true"} + result = coerce_tool_args("test_tool", args) + assert result["merge"] is True + + def test_coerces_number_arg(self): + schema = self._mock_schema({"temperature": {"type": "number"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"temperature": "0.7"} + result = coerce_tool_args("test_tool", args) + assert result["temperature"] == 0.7 + + def test_leaves_string_args_alone(self): + schema = self._mock_schema({"path": {"type": "string"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"path": "/tmp/file.txt"} + result = coerce_tool_args("test_tool", args) + assert result["path"] == "/tmp/file.txt" + + def test_leaves_already_correct_types(self): + schema = self._mock_schema({"limit": {"type": "integer"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"limit": 10} + result = coerce_tool_args("test_tool", args) + assert result["limit"] == 10 + + def test_unknown_tool_returns_args_unchanged(self): + with patch("model_tools.registry.get_schema", return_value=None): + args = {"limit": "10"} + result = coerce_tool_args("unknown_tool", args) + assert result["limit"] == "10" + + def test_empty_args(self): + assert coerce_tool_args("test_tool", {}) == {} + + def test_none_args(self): + assert coerce_tool_args("test_tool", None) is None + + def test_preserves_non_string_values(self): + """Lists, dicts, and other non-string values are never touched.""" + schema = self._mock_schema({ + "items": {"type": "array"}, + "config": {"type": "object"}, + }) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"items": [1, 2, 3], "config": {"key": "val"}} + result = coerce_tool_args("test_tool", args) + assert result["items"] == [1, 2, 3] + assert result["config"] == {"key": "val"} + + def test_extra_args_without_schema_left_alone(self): + """Args not in the schema properties are not touched.""" + schema = self._mock_schema({"limit": {"type": "integer"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"limit": "10", "extra": "42"} + result = coerce_tool_args("test_tool", args) + assert result["limit"] == 10 + assert result["extra"] == "42" # no schema for extra, stays string + + def test_mixed_coercion(self): + """Multiple args coerced in the same call.""" + schema = self._mock_schema({ + "offset": {"type": "integer"}, + "limit": {"type": "integer"}, + "full": {"type": "boolean"}, + "path": {"type": "string"}, + }) + with patch("model_tools.registry.get_schema", return_value=schema): + args = { + "offset": "1", + "limit": "500", + "full": "false", + "path": "readme.md", + } + result = coerce_tool_args("test_tool", args) + assert result["offset"] == 1 + assert result["limit"] == 500 + assert result["full"] is False + assert result["path"] == "readme.md" + + def test_failed_coercion_preserves_original(self): + """A non-parseable string stays as string even if schema says integer.""" + schema = self._mock_schema({"limit": {"type": "integer"}}) + with patch("model_tools.registry.get_schema", return_value=schema): + args = {"limit": "not_a_number"} + result = coerce_tool_args("test_tool", args) + assert result["limit"] == "not_a_number" + + def test_real_read_file_schema(self): + """Test against the actual read_file schema from the registry.""" + # This uses the real registry — read_file should be registered + args = {"path": "foo.py", "offset": "10", "limit": "100"} + result = coerce_tool_args("read_file", args) + assert result["path"] == "foo.py" + assert result["offset"] == 10 + assert isinstance(result["offset"], int) + assert result["limit"] == 100 + assert isinstance(result["limit"], int) From 70f798043b65b003345803f95ffaa958d43465ac Mon Sep 17 00:00:00 2001 From: LucidPaths Date: Sun, 5 Apr 2026 10:58:44 -0700 Subject: [PATCH 035/213] fix: Ollama Cloud auth, /model switch persistence, and alias tab completion - Add OLLAMA_API_KEY to credential resolution chain for ollama.com endpoints - Update requested_provider/_explicit_api_key/_explicit_base_url after /model switch so _ensure_runtime_credentials() doesn't revert the switch - Pass base_url/api_key from fallback config to resolve_provider_client() - Add DirectAlias system: user-configurable model_aliases in config.yaml checked before catalog resolution, with reverse lookup by model ID - Add /model tab completion showing aliases with provider metadata Co-authored-by: LucidPaths --- cli-config.yaml.example | 27 ++ cli.py | 21 +- gateway/run.py | 16 + hermes_cli/commands.py | 38 ++ hermes_cli/model_switch.py | 88 +++++ hermes_cli/runtime_provider.py | 4 + run_agent.py | 13 +- tests/test_ollama_cloud_auth.py | 657 ++++++++++++++++++++++++++++++++ 8 files changed, 862 insertions(+), 2 deletions(-) create mode 100644 tests/test_ollama_cloud_auth.py diff --git a/cli-config.yaml.example b/cli-config.yaml.example index f43b90838d23..6b1809273f33 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -34,6 +34,12 @@ model: # base_url: "http://localhost:1234/v1" # No API key needed — local servers typically ignore auth. # + # For Ollama Cloud (https://ollama.com/pricing): + # provider: "custom" + # base_url: "https://ollama.com/v1" + # Set OLLAMA_API_KEY in .env — automatically picked up when base_url + # points to ollama.com. + # # Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var. provider: "auto" @@ -789,6 +795,27 @@ display: # skin: default +# ============================================================================= +# Model Aliases — short names for /model command +# ============================================================================= +# Map short aliases to exact (model, provider, base_url) tuples. +# Used by /model tab completion and resolve_alias(). +# Aliases are checked BEFORE the models.dev catalog, so they can route +# to endpoints not in the catalog (e.g. Ollama Cloud, local servers). +# +# model_aliases: +# opus: +# model: claude-opus-4-6 +# provider: anthropic +# qwen: +# model: "qwen3.5:397b" +# provider: custom +# base_url: "https://ollama.com/v1" +# glm: +# model: glm-4.7 +# provider: custom +# base_url: "https://ollama.com/v1" + # ============================================================================= # Privacy # ============================================================================= diff --git a/cli.py b/cli.py index 5802a31e2f37..096ab9ec44fe 100644 --- a/cli.py +++ b/cli.py @@ -3606,14 +3606,19 @@ def _handle_model_switch(self, cmd_original: str): _cprint(f" ✗ {result.error_message}") return - # Apply to CLI state + # Apply to CLI state. + # Update requested_provider so _ensure_runtime_credentials() doesn't + # overwrite the switch on the next turn (it re-resolves from this). old_model = self.model self.model = result.new_model self.provider = result.target_provider + self.requested_provider = result.target_provider if result.api_key: self.api_key = result.api_key + self._explicit_api_key = result.api_key if result.base_url: self.base_url = result.base_url + self._explicit_base_url = result.base_url if result.api_mode: self.api_mode = result.api_mode @@ -3630,6 +3635,15 @@ def _handle_model_switch(self, cmd_original: str): except Exception as exc: _cprint(f" ⚠ Agent swap failed ({exc}); change applied to next session.") + # Store a note to prepend to the next user message so the model + # knows a switch occurred (avoids injecting system messages mid-history + # which breaks providers and prompt caching). + self._pending_model_switch_note = ( + f"[Note: model was just switched from {old_model} to {result.new_model} " + f"via {result.provider_label or result.target_provider}. " + f"Adjust your self-identification accordingly.]" + ) + # Display confirmation with full metadata provider_label = result.provider_label or result.target_provider _cprint(f" ✓ Model switched: {result.new_model}") @@ -6347,6 +6361,11 @@ def stream_callback(delta: str): def run_agent(): nonlocal result agent_message = _voice_prefix + message if _voice_prefix else message + # Prepend pending model switch note so the model knows about the switch + _msn = getattr(self, '_pending_model_switch_note', None) + if _msn: + agent_message = _msn + "\n\n" + agent_message + self._pending_model_switch_note = None try: result = self.agent.run_conversation( user_message=agent_message, diff --git a/gateway/run.py b/gateway/run.py index 4411b5c68d91..070b77e189d6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3393,6 +3393,16 @@ async def _handle_model_command(self, event: MessageEvent) -> str: except Exception as exc: logger.warning("In-place model switch failed for cached agent: %s", exc) + # Store a note to prepend to the next user message so the model + # knows about the switch (avoids system messages mid-history). + if not hasattr(self, "_pending_model_notes"): + self._pending_model_notes = {} + self._pending_model_notes[session_key] = ( + f"[Note: model was just switched from {current_model} to {result.new_model} " + f"via {result.provider_label or result.target_provider}. " + f"Adjust your self-identification accordingly.]" + ) + # Store session override so next agent creation uses the new model if not hasattr(self, "_session_model_overrides"): self._session_model_overrides = {} @@ -6440,6 +6450,12 @@ def _approval_notify_sync(approval_data: dict) -> None: except Exception as _e: logger.error("Failed to send approval request: %s", _e) + # Prepend pending model switch note so the model knows about the switch + _pending_notes = getattr(self, '_pending_model_notes', {}) + _msn = _pending_notes.pop(session_key, None) if session_key else None + if _msn: + message = _msn + "\n\n" + message + _approval_session_key = session_key or "" _approval_session_token = set_current_session_key(_approval_session_key) register_gateway_notify(_approval_session_key, _approval_notify_sync) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 782d52250f8e..e0c769d1984f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -745,6 +745,39 @@ def _context_completions(word: str, limit: int = 30): ) count += 1 + def _model_completions(self, sub_text: str, sub_lower: str): + """Yield completions for /model from config aliases + built-in aliases.""" + seen = set() + # Config-based direct aliases (preferred — include provider info) + try: + from hermes_cli.model_switch import ( + _ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES, + ) + _ensure_direct_aliases() + for name, da in DIRECT_ALIASES.items(): + if name.startswith(sub_lower) and name != sub_lower: + seen.add(name) + yield Completion( + name, + start_position=-len(sub_text), + display=name, + display_meta=f"{da.model} ({da.provider})", + ) + # Built-in catalog aliases not already covered + for name in sorted(MODEL_ALIASES.keys()): + if name in seen: + continue + if name.startswith(sub_lower) and name != sub_lower: + identity = MODEL_ALIASES[name] + yield Completion( + name, + start_position=-len(sub_text), + display=name, + display_meta=f"{identity.vendor}/{identity.family}", + ) + except Exception: + pass + def get_completions(self, document, complete_event): text = document.text_before_cursor if not text.startswith("/"): @@ -766,6 +799,11 @@ def get_completions(self, document, complete_event): sub_text = parts[1] if len(parts) > 1 else "" sub_lower = sub_text.lower() + # Dynamic model alias completions for /model + if " " not in sub_text and base_cmd == "/model": + yield from self._model_completions(sub_text, sub_lower) + return + # Static subcommand completions if " " not in sub_text and base_cmd in SUBCOMMANDS: for sub in SUBCOMMANDS[base_cmd]: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 9534f3765685..dc9ca2eecba5 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -114,6 +114,71 @@ class ModelIdentity(NamedTuple): } +# --------------------------------------------------------------------------- +# Direct aliases — exact model+provider+base_url for endpoints that aren't +# in the models.dev catalog (e.g. Ollama Cloud, local servers). +# Checked BEFORE catalog resolution. Format: +# alias -> (model_id, provider, base_url) +# These can also be loaded from config.yaml ``model_aliases:`` section. +# --------------------------------------------------------------------------- + +class DirectAlias(NamedTuple): + """Exact model mapping that bypasses catalog resolution.""" + model: str + provider: str + base_url: str + + +# Built-in direct aliases (can be extended via config.yaml model_aliases:) +_BUILTIN_DIRECT_ALIASES: dict[str, DirectAlias] = {} + +# Merged dict (builtins + user config); populated by _load_direct_aliases() +DIRECT_ALIASES: dict[str, DirectAlias] = {} + + +def _load_direct_aliases() -> dict[str, DirectAlias]: + """Load direct aliases from config.yaml ``model_aliases:`` section. + + Config format:: + + model_aliases: + qwen: + model: "qwen3.5:397b" + provider: custom + base_url: "https://ollama.com/v1" + minimax: + model: "minimax-m2.7" + provider: custom + base_url: "https://ollama.com/v1" + """ + merged = dict(_BUILTIN_DIRECT_ALIASES) + try: + from hermes_cli.config import load_config + cfg = load_config() + user_aliases = cfg.get("model_aliases") + if isinstance(user_aliases, dict): + for name, entry in user_aliases.items(): + if not isinstance(entry, dict): + continue + model = entry.get("model", "") + provider = entry.get("provider", "custom") + base_url = entry.get("base_url", "") + if model: + merged[name.strip().lower()] = DirectAlias( + model=model, provider=provider, base_url=base_url, + ) + except Exception: + pass + return merged + + +def _ensure_direct_aliases() -> None: + """Lazy-load direct aliases on first use.""" + global DIRECT_ALIASES + if not DIRECT_ALIASES: + DIRECT_ALIASES = _load_direct_aliases() + + # --------------------------------------------------------------------------- # Result dataclasses # --------------------------------------------------------------------------- @@ -211,6 +276,20 @@ def resolve_alias( exist or no matching model is available. """ key = raw_input.strip().lower() + + # Check direct aliases first (exact model+provider+base_url mappings) + _ensure_direct_aliases() + direct = DIRECT_ALIASES.get(key) + if direct is not None: + return (direct.provider, direct.model, key) + + # Reverse lookup: match by model ID so full names (e.g. "kimi-k2.5", + # "glm-4.7") route through direct aliases instead of falling through + # to the catalog/OpenRouter. + for alias_name, da in DIRECT_ALIASES.items(): + if da.model.lower() == key: + return (da.provider, da.model, alias_name) + identity = MODEL_ALIASES.get(key) if identity is None: return None @@ -487,6 +566,15 @@ def switch_model( except Exception: pass + # --- Direct alias override: use exact base_url from the alias if set --- + if resolved_alias: + _ensure_direct_aliases() + _da = DIRECT_ALIASES.get(resolved_alias) + if _da is not None and _da.base_url: + base_url = _da.base_url + if not api_key: + api_key = "no-key-required" + # --- Normalize model name for target provider --- new_model = normalize_model_for_provider(new_model, target_provider) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 0ed4c826c864..b14807231555 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -377,9 +377,13 @@ def _resolve_openrouter_runtime( ] else: # Custom endpoint: use api_key from config when using config base_url (#1760). + # When the endpoint is Ollama Cloud, check OLLAMA_API_KEY — it's + # the canonical env var for ollama.com authentication. + _is_ollama_url = "ollama.com" in base_url.lower() api_key_candidates = [ explicit_api_key, (cfg_api_key if use_config_base_url else ""), + (os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""), os.getenv("OPENAI_API_KEY"), os.getenv("OPENROUTER_API_KEY"), ] diff --git a/run_agent.py b/run_agent.py index 7235f9f6c1c0..7aa4a33f46c7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4794,8 +4794,19 @@ def _try_activate_fallback(self) -> bool: # access for Codex providers. try: from agent.auxiliary_client import resolve_provider_client + # Pass base_url and api_key from fallback config so custom + # endpoints (e.g. Ollama Cloud) resolve correctly instead of + # falling through to OpenRouter defaults. + fb_base_url_hint = (fb.get("base_url") or "").strip() or None + fb_api_key_hint = (fb.get("api_key") or "").strip() or None + # For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env + # when no explicit key is in the fallback config. + if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint: + fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None fb_client, _ = resolve_provider_client( - fb_provider, model=fb_model, raw_codex=True) + fb_provider, model=fb_model, raw_codex=True, + explicit_base_url=fb_base_url_hint, + explicit_api_key=fb_api_key_hint) if fb_client is None: logging.warning( "Fallback to %s failed: provider not configured", diff --git a/tests/test_ollama_cloud_auth.py b/tests/test_ollama_cloud_auth.py new file mode 100644 index 000000000000..7a5dbf6aeba2 --- /dev/null +++ b/tests/test_ollama_cloud_auth.py @@ -0,0 +1,657 @@ +"""Tests for Ollama Cloud authentication and /model switch fixes. + +Covers: +- OLLAMA_API_KEY resolution for custom endpoints pointing to ollama.com +- Fallback provider passing base_url/api_key to resolve_provider_client +- /model command updating requested_provider for session persistence +- Direct alias resolution from config.yaml model_aliases +- Reverse lookup: full model names match direct aliases +- /model tab completion for model aliases +""" + +import os +import pytest +from unittest.mock import patch, MagicMock + + +# --------------------------------------------------------------------------- +# OLLAMA_API_KEY credential resolution +# --------------------------------------------------------------------------- + +class TestOllamaCloudCredentials: + """runtime_provider should use OLLAMA_API_KEY for ollama.com endpoints.""" + + def test_ollama_api_key_used_for_ollama_endpoint(self, monkeypatch, tmp_path): + """When base_url contains ollama.com, OLLAMA_API_KEY is in the candidate chain.""" + monkeypatch.setenv("OLLAMA_API_KEY", "test-ollama-key-12345") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + # Mock config to return custom provider with ollama base_url + mock_config = { + "model": { + "default": "qwen3.5:397b", + "provider": "custom", + "base_url": "https://ollama.com/v1", + } + } + monkeypatch.setattr( + "hermes_cli.runtime_provider._get_model_config", + lambda: mock_config.get("model", {}), + ) + + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider(requested="custom") + + assert runtime["base_url"] == "https://ollama.com/v1" + assert runtime["api_key"] == "test-ollama-key-12345" + assert runtime["provider"] == "custom" + + def test_ollama_key_not_used_for_non_ollama_endpoint(self, monkeypatch): + """OLLAMA_API_KEY should NOT be used for non-ollama endpoints.""" + monkeypatch.setenv("OLLAMA_API_KEY", "test-ollama-key") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + mock_config = { + "model": { + "provider": "custom", + "base_url": "http://localhost:11434/v1", + } + } + monkeypatch.setattr( + "hermes_cli.runtime_provider._get_model_config", + lambda: mock_config.get("model", {}), + ) + + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider(requested="custom") + + # Should fall through to no-key-required for local endpoints + assert runtime["api_key"] != "test-ollama-key" + + +# --------------------------------------------------------------------------- +# Direct alias resolution +# --------------------------------------------------------------------------- + +class TestDirectAliases: + """model_switch direct aliases from config.yaml model_aliases.""" + + def test_direct_alias_loaded_from_config(self, monkeypatch): + """Direct aliases load from config.yaml model_aliases section.""" + mock_config = { + "model_aliases": { + "mymodel": { + "model": "custom-model:latest", + "provider": "custom", + "base_url": "https://example.com/v1", + } + } + } + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + + assert "mymodel" in aliases + assert aliases["mymodel"].model == "custom-model:latest" + assert aliases["mymodel"].provider == "custom" + assert aliases["mymodel"].base_url == "https://example.com/v1" + + def test_direct_alias_resolved_before_catalog(self, monkeypatch): + """Direct aliases take priority over models.dev catalog lookup.""" + from hermes_cli.model_switch import DirectAlias, resolve_alias + import hermes_cli.model_switch as ms + + test_aliases = { + "glm": DirectAlias("glm-4.7", "custom", "https://ollama.com/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + result = resolve_alias("glm", "openrouter") + assert result is not None + provider, model, alias = result + assert model == "glm-4.7" + assert provider == "custom" + assert alias == "glm" + + def test_reverse_lookup_by_model_id(self, monkeypatch): + """Full model names (e.g. 'kimi-k2.5') match via reverse lookup.""" + from hermes_cli.model_switch import DirectAlias, resolve_alias + import hermes_cli.model_switch as ms + + test_aliases = { + "kimi": DirectAlias("kimi-k2.5", "custom", "https://ollama.com/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + # Typing full model name should resolve through the alias + result = resolve_alias("kimi-k2.5", "openrouter") + assert result is not None + provider, model, alias = result + assert model == "kimi-k2.5" + assert provider == "custom" + assert alias == "kimi" + + def test_reverse_lookup_case_insensitive(self, monkeypatch): + """Reverse lookup is case-insensitive.""" + from hermes_cli.model_switch import DirectAlias, resolve_alias + import hermes_cli.model_switch as ms + + test_aliases = { + "glm": DirectAlias("GLM-4.7", "custom", "https://ollama.com/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + result = resolve_alias("glm-4.7", "openrouter") + assert result is not None + assert result[1] == "GLM-4.7" + + +# --------------------------------------------------------------------------- +# /model command persistence +# --------------------------------------------------------------------------- + +class TestModelSwitchPersistence: + """CLI /model command should update requested_provider for session persistence.""" + + def test_model_switch_result_fields(self): + """ModelSwitchResult has all required fields for CLI state update.""" + from hermes_cli.model_switch import ModelSwitchResult + + result = ModelSwitchResult( + success=True, + new_model="claude-opus-4-6", + target_provider="anthropic", + provider_changed=True, + api_key="test-key", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + ) + + assert result.success + assert result.new_model == "claude-opus-4-6" + assert result.target_provider == "anthropic" + assert result.api_key == "test-key" + assert result.base_url == "https://api.anthropic.com" + + +# --------------------------------------------------------------------------- +# /model tab completion +# --------------------------------------------------------------------------- + +class TestModelTabCompletion: + """SlashCommandCompleter provides model alias completions for /model.""" + + def test_model_completions_yields_direct_aliases(self, monkeypatch): + """_model_completions yields direct aliases with model and provider info.""" + from hermes_cli.commands import SlashCommandCompleter + from hermes_cli.model_switch import DirectAlias + import hermes_cli.model_switch as ms + + test_aliases = { + "opus": DirectAlias("claude-opus-4-6", "anthropic", ""), + "qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + completer = SlashCommandCompleter() + completions = list(completer._model_completions("", "")) + + names = [c.text for c in completions] + assert "opus" in names + assert "qwen" in names + + def test_model_completions_filters_by_prefix(self, monkeypatch): + """Completions filter by typed prefix.""" + from hermes_cli.commands import SlashCommandCompleter + from hermes_cli.model_switch import DirectAlias + import hermes_cli.model_switch as ms + + test_aliases = { + "opus": DirectAlias("claude-opus-4-6", "anthropic", ""), + "qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + completer = SlashCommandCompleter() + completions = list(completer._model_completions("o", "o")) + + names = [c.text for c in completions] + assert "opus" in names + assert "qwen" not in names + + def test_model_completions_shows_metadata(self, monkeypatch): + """Completions include model name and provider in display_meta.""" + from hermes_cli.commands import SlashCommandCompleter + from hermes_cli.model_switch import DirectAlias + import hermes_cli.model_switch as ms + + test_aliases = { + "glm": DirectAlias("glm-4.7", "custom", "https://ollama.com/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + completer = SlashCommandCompleter() + completions = list(completer._model_completions("g", "g")) + + assert len(completions) >= 1 + glm_comp = [c for c in completions if c.text == "glm"][0] + meta_str = str(glm_comp.display_meta) + assert "glm-4.7" in meta_str + assert "custom" in meta_str + + +# --------------------------------------------------------------------------- +# Fallback base_url passthrough +# --------------------------------------------------------------------------- + +class TestFallbackBaseUrlPassthrough: + """_try_activate_fallback should pass base_url from fallback config.""" + + def test_fallback_config_has_base_url(self): + """Verify fallback_providers config structure supports base_url.""" + # This tests the contract: fallback dicts can have base_url + fb = { + "provider": "custom", + "model": "qwen3.5:397b", + "base_url": "https://ollama.com/v1", + } + assert fb.get("base_url") == "https://ollama.com/v1" + + def test_ollama_key_lookup_for_fallback(self, monkeypatch): + """When fallback base_url is ollama.com and no api_key, OLLAMA_API_KEY is used.""" + monkeypatch.setenv("OLLAMA_API_KEY", "fb-ollama-key") + + fb = { + "provider": "custom", + "model": "qwen3.5:397b", + "base_url": "https://ollama.com/v1", + } + + fb_base_url_hint = (fb.get("base_url") or "").strip() or None + fb_api_key_hint = (fb.get("api_key") or "").strip() or None + + if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint: + fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None + + assert fb_api_key_hint == "fb-ollama-key" + assert fb_base_url_hint == "https://ollama.com/v1" + + +# --------------------------------------------------------------------------- +# Edge cases: _load_direct_aliases +# --------------------------------------------------------------------------- + +class TestLoadDirectAliasesEdgeCases: + """Edge cases for _load_direct_aliases parsing.""" + + def test_empty_model_aliases_config(self, monkeypatch): + """Empty model_aliases dict returns only builtins (if any).""" + mock_config = {"model_aliases": {}} + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert isinstance(aliases, dict) + + def test_model_aliases_not_a_dict(self, monkeypatch): + """Non-dict model_aliases value is gracefully ignored.""" + mock_config = {"model_aliases": "bad-string-value"} + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert isinstance(aliases, dict) + + def test_model_aliases_none_value(self, monkeypatch): + """model_aliases: null in config is handled gracefully.""" + mock_config = {"model_aliases": None} + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert isinstance(aliases, dict) + + def test_malformed_entry_without_model_key(self, monkeypatch): + """Entries missing 'model' key are skipped.""" + mock_config = { + "model_aliases": { + "bad_entry": { + "provider": "custom", + "base_url": "https://example.com/v1", + }, + "good_entry": { + "model": "valid-model", + "provider": "custom", + }, + } + } + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert "bad_entry" not in aliases + assert "good_entry" in aliases + + def test_malformed_entry_non_dict_value(self, monkeypatch): + """Non-dict entry values are skipped.""" + mock_config = { + "model_aliases": { + "string_entry": "just-a-string", + "none_entry": None, + "list_entry": ["a", "b"], + "good": {"model": "real-model", "provider": "custom"}, + } + } + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert "string_entry" not in aliases + assert "none_entry" not in aliases + assert "list_entry" not in aliases + assert "good" in aliases + + def test_load_config_exception_returns_builtins(self, monkeypatch): + """If load_config raises, _load_direct_aliases returns builtins only.""" + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("config broken")), + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert isinstance(aliases, dict) + + def test_alias_name_normalized_lowercase(self, monkeypatch): + """Alias names are lowercased and stripped.""" + mock_config = { + "model_aliases": { + " MyModel ": { + "model": "my-model:latest", + "provider": "custom", + } + } + } + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert "mymodel" in aliases + assert " MyModel " not in aliases + + def test_empty_model_string_skipped(self, monkeypatch): + """Entries with empty model string are skipped.""" + mock_config = { + "model_aliases": { + "empty": {"model": "", "provider": "custom"}, + "good": {"model": "real", "provider": "custom"}, + } + } + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + + from hermes_cli.model_switch import _load_direct_aliases + aliases = _load_direct_aliases() + assert "empty" not in aliases + assert "good" in aliases + + +# --------------------------------------------------------------------------- +# _ensure_direct_aliases idempotency +# --------------------------------------------------------------------------- + +class TestEnsureDirectAliases: + """_ensure_direct_aliases lazy-loading behavior.""" + + def test_ensure_populates_on_first_call(self, monkeypatch): + """DIRECT_ALIASES is populated after _ensure_direct_aliases.""" + import hermes_cli.model_switch as ms + + mock_config = { + "model_aliases": { + "test": {"model": "test-model", "provider": "custom"}, + } + } + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: mock_config, + ) + monkeypatch.setattr(ms, "DIRECT_ALIASES", {}) + ms._ensure_direct_aliases() + assert "test" in ms.DIRECT_ALIASES + + def test_ensure_no_reload_when_populated(self, monkeypatch): + """_ensure_direct_aliases does not reload if already populated.""" + import hermes_cli.model_switch as ms + from hermes_cli.model_switch import DirectAlias + + existing = {"pre": DirectAlias("pre-model", "custom", "")} + monkeypatch.setattr(ms, "DIRECT_ALIASES", existing) + + call_count = [0] + original_load = ms._load_direct_aliases + def counting_load(): + call_count[0] += 1 + return original_load() + monkeypatch.setattr(ms, "_load_direct_aliases", counting_load) + + ms._ensure_direct_aliases() + assert call_count[0] == 0 + assert "pre" in ms.DIRECT_ALIASES + + +# --------------------------------------------------------------------------- +# resolve_alias: fallthrough and edge cases +# --------------------------------------------------------------------------- + +class TestResolveAliasEdgeCases: + """Edge cases for resolve_alias.""" + + def test_unknown_alias_returns_none(self, monkeypatch): + """Unknown alias not in direct or catalog returns None.""" + import hermes_cli.model_switch as ms + monkeypatch.setattr(ms, "DIRECT_ALIASES", {}) + + result = ms.resolve_alias("nonexistent_model_xyz", "openrouter") + assert result is None + + def test_whitespace_input_handled(self, monkeypatch): + """Input with whitespace is stripped before lookup.""" + from hermes_cli.model_switch import DirectAlias + import hermes_cli.model_switch as ms + + test_aliases = { + "myalias": DirectAlias("my-model", "custom", "https://example.com"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + result = ms.resolve_alias(" myalias ", "openrouter") + assert result is not None + assert result[1] == "my-model" + + +# --------------------------------------------------------------------------- +# switch_model: direct alias base_url override +# --------------------------------------------------------------------------- + +class TestSwitchModelDirectAliasOverride: + """switch_model should use base_url from direct alias.""" + + def test_switch_model_uses_alias_base_url(self, monkeypatch): + """When resolved alias has base_url, switch_model should use it.""" + from hermes_cli.model_switch import DirectAlias + import hermes_cli.model_switch as ms + + test_aliases = { + "qwen": DirectAlias("qwen3.5:397b", "custom", "https://ollama.com/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + + monkeypatch.setattr(ms, "resolve_alias", + lambda raw, prov: ("custom", "qwen3.5:397b", "qwen")) + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda requested: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"}, + ) + + monkeypatch.setattr("hermes_cli.models.validate_requested_model", + lambda *a, **kw: {"accepted": True, "persist": True, "recognized": True, "message": None}) + monkeypatch.setattr("hermes_cli.models.opencode_model_api_mode", + lambda *a, **kw: "openai_compat") + + result = ms.switch_model("qwen", "openrouter", "old-model") + assert result.success + assert result.base_url == "https://ollama.com/v1" + assert result.new_model == "qwen3.5:397b" + + def test_switch_model_alias_no_api_key_gets_default(self, monkeypatch): + """When alias has base_url but no api_key, 'no-key-required' is set.""" + from hermes_cli.model_switch import DirectAlias + import hermes_cli.model_switch as ms + + test_aliases = { + "local": DirectAlias("local-model", "custom", "http://localhost:11434/v1"), + } + monkeypatch.setattr(ms, "DIRECT_ALIASES", test_aliases) + monkeypatch.setattr(ms, "resolve_alias", + lambda raw, prov: ("custom", "local-model", "local")) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda requested: {"api_key": "", "base_url": "", "api_mode": "openai_compat", "provider": "custom"}, + ) + monkeypatch.setattr("hermes_cli.models.validate_requested_model", + lambda *a, **kw: {"accepted": True, "persist": True, "recognized": True, "message": None}) + monkeypatch.setattr("hermes_cli.models.opencode_model_api_mode", + lambda *a, **kw: "openai_compat") + + result = ms.switch_model("local", "openrouter", "old-model") + assert result.success + assert result.api_key == "no-key-required" + assert result.base_url == "http://localhost:11434/v1" + + +# --------------------------------------------------------------------------- +# CLI state update: requested_provider persistence +# --------------------------------------------------------------------------- + +class TestCLIStateUpdate: + """CLI /model handler should update requested_provider and explicit fields.""" + + def test_model_switch_result_has_provider_label(self): + """ModelSwitchResult supports provider_label for display.""" + from hermes_cli.model_switch import ModelSwitchResult + + result = ModelSwitchResult( + success=True, + new_model="qwen3.5:397b", + target_provider="custom", + provider_changed=True, + api_key="key", + base_url="https://ollama.com/v1", + api_mode="openai_compat", + provider_label="Ollama Cloud", + ) + assert result.provider_label == "Ollama Cloud" + + def test_model_switch_result_defaults(self): + """ModelSwitchResult has sensible defaults.""" + from hermes_cli.model_switch import ModelSwitchResult + + result = ModelSwitchResult( + success=False, + new_model="", + target_provider="", + provider_changed=False, + error_message="Something failed", + ) + assert not result.success + assert result.error_message == "Something failed" + assert result.api_key is None or result.api_key == "" + assert result.base_url is None or result.base_url == "" + + +# --------------------------------------------------------------------------- +# Fallback: OLLAMA_API_KEY edge cases +# --------------------------------------------------------------------------- + +class TestFallbackEdgeCases: + """Edge cases for fallback OLLAMA_API_KEY logic.""" + + def test_ollama_key_not_injected_for_localhost(self, monkeypatch): + """OLLAMA_API_KEY should not be injected for localhost URLs.""" + monkeypatch.setenv("OLLAMA_API_KEY", "should-not-use") + + fb = { + "provider": "custom", + "model": "local-model", + "base_url": "http://localhost:11434/v1", + } + + fb_base_url_hint = (fb.get("base_url") or "").strip() or None + fb_api_key_hint = (fb.get("api_key") or "").strip() or None + + if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint: + fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None + + assert fb_api_key_hint is None + + def test_explicit_api_key_not_overridden_by_ollama_key(self, monkeypatch): + """Explicit api_key in fallback config is not overridden by OLLAMA_API_KEY.""" + monkeypatch.setenv("OLLAMA_API_KEY", "env-key") + + fb = { + "provider": "custom", + "model": "qwen3.5:397b", + "base_url": "https://ollama.com/v1", + "api_key": "explicit-key", + } + + fb_base_url_hint = (fb.get("base_url") or "").strip() or None + fb_api_key_hint = (fb.get("api_key") or "").strip() or None + + if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint: + fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None + + assert fb_api_key_hint == "explicit-key" + + def test_no_base_url_in_fallback(self, monkeypatch): + """Fallback with no base_url doesn't crash.""" + monkeypatch.setenv("OLLAMA_API_KEY", "some-key") + + fb = {"provider": "openrouter", "model": "some-model"} + + fb_base_url_hint = (fb.get("base_url") or "").strip() or None + fb_api_key_hint = (fb.get("api_key") or "").strip() or None + + if fb_base_url_hint and "ollama.com" in fb_base_url_hint.lower() and not fb_api_key_hint: + fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None + + assert fb_base_url_hint is None + assert fb_api_key_hint is None From 914f7461dc1b0ff3a3b30df4a879e3f81a2cb4c9 Mon Sep 17 00:00:00 2001 From: thakoreh Date: Sun, 5 Apr 2026 10:43:52 -0700 Subject: [PATCH 036/213] fix: add missing shutil import for Matrix E2EE setup Cherry-picked from PR #5136 by thakoreh. setup_gateway() uses shutil.which('uv') at line 2126 but shutil was never imported at module level, causing NameError during Matrix E2EE auto-install. Adds top-level import and regression test. --- hermes_cli/setup.py | 1 + tests/hermes_cli/test_setup_matrix_e2ee.py | 31 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/hermes_cli/test_setup_matrix_e2ee.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 98b7541523c7..201c7b54abb7 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -14,6 +14,7 @@ import importlib.util import logging import os +import shutil import sys from pathlib import Path from typing import Optional, Dict, Any diff --git a/tests/hermes_cli/test_setup_matrix_e2ee.py b/tests/hermes_cli/test_setup_matrix_e2ee.py new file mode 100644 index 000000000000..ebdb5a44c7b3 --- /dev/null +++ b/tests/hermes_cli/test_setup_matrix_e2ee.py @@ -0,0 +1,31 @@ +"""Test that setup.py has shutil available for Matrix E2EE auto-install.""" +import ast + +import pytest + + +def _parse_setup_imports(): + """Parse setup.py and return top-level import names.""" + with open("hermes_cli/setup.py") as f: + tree = ast.parse(f.read()) + names = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + names.add(alias.name) + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + names.add(alias.name) + return names + + +class TestSetupShutilImport: + def test_shutil_imported_at_module_level(self): + """shutil must be imported at module level so setup_gateway can use it + for the matrix-nio auto-install path (line ~2126).""" + names = _parse_setup_imports() + assert "shutil" in names, ( + "shutil is not imported at the top of hermes_cli/setup.py. " + "This causes a NameError when the Matrix E2EE auto-install " + "tries to call shutil.which('uv')." + ) From 9d7c288d8699ed7a953a6a1445d9617b995c1935 Mon Sep 17 00:00:00 2001 From: pjay-io Date: Sun, 5 Apr 2026 10:44:13 -0700 Subject: [PATCH 037/213] fix(matrix): add filesize to nio.upload() for Synapse compatibility Cherry-picked from PR #4343 by pjay-io. Synapse rejects chunked uploads without Content-Length. Adding filesize=len(data) ensures the upload includes proper sizing. --- gateway/platforms/matrix.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 4f77e920a87f..8a33530336ad 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -604,6 +604,7 @@ async def _upload_and_send( io.BytesIO(data), content_type=content_type, filename=filename, + filesize=len(data), ) if not isinstance(resp, nio.UploadResponse): err = getattr(resp, "message", str(resp)) From b65e67545a49e163f7f3df55ebf18850d2ba3db5 Mon Sep 17 00:00:00 2001 From: binhnt92 Date: Sun, 5 Apr 2026 10:44:39 -0700 Subject: [PATCH 038/213] fix(gateway): stop Matrix/Mattermost reconnect on permanent auth failures Cherry-picked from PR #3695 by binhnt92. Matrix _sync_loop() and Mattermost _ws_loop() were retrying all errors forever, including permanent auth failures (expired tokens, revoked access). Now detects M_UNKNOWN_TOKEN, M_FORBIDDEN, 401/403 and stops instead of spinning. Includes 216 lines of tests. --- gateway/platforms/matrix.py | 13 ++ gateway/platforms/mattermost.py | 10 ++ tests/gateway/test_ws_auth_retry.py | 216 ++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 tests/gateway/test_ws_auth_retry.py diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 8a33530336ad..89203d48a27b 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -684,6 +684,13 @@ async def _sync_loop(self) -> None: if isinstance(resp, nio.SyncError): if self._closing: return + err_msg = str(getattr(resp, "message", resp)).lower() + if "m_unknown_token" in err_msg or "m_forbidden" in err_msg or "401" in err_msg: + logger.error( + "Matrix: permanent auth error from sync: %s — stopping sync", + getattr(resp, "message", resp), + ) + return logger.warning( "Matrix: sync returned %s: %s — retrying in 5s", type(resp).__name__, @@ -698,6 +705,12 @@ async def _sync_loop(self) -> None: except Exception as exc: if self._closing: return + # Detect permanent auth/permission failures that will never + # succeed on retry — stop syncing instead of looping forever. + err_str = str(exc).lower() + if "401" in err_str or "403" in err_str or "unauthorized" in err_str or "forbidden" in err_str: + logger.error("Matrix: permanent auth error: %s — stopping sync", exc) + return logger.warning("Matrix: sync error: %s — retrying in 5s", exc) await asyncio.sleep(5) diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index c134bb35da56..7c9c2d29baca 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -513,6 +513,16 @@ async def _ws_loop(self) -> None: except Exception as exc: if self._closing: return + # Detect permanent auth/permission failures that will never + # succeed on retry — stop reconnecting instead of looping forever. + import aiohttp + err_str = str(exc).lower() + if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in (401, 403): + logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status) + return + if "401" in err_str or "403" in err_str or "unauthorized" in err_str: + logger.error("Mattermost WS permanent error: %s — stopping reconnect", exc) + return logger.warning("Mattermost WS error: %s — reconnecting in %.0fs", exc, delay) if self._closing: diff --git a/tests/gateway/test_ws_auth_retry.py b/tests/gateway/test_ws_auth_retry.py new file mode 100644 index 000000000000..beef6722e571 --- /dev/null +++ b/tests/gateway/test_ws_auth_retry.py @@ -0,0 +1,216 @@ +"""Tests for auth-aware retry in Mattermost WS and Matrix sync loops. + +Both Mattermost's _ws_loop and Matrix's _sync_loop previously caught all +exceptions with a broad ``except Exception`` and retried forever. Permanent +auth failures (401, 403, M_UNKNOWN_TOKEN) would loop indefinitely instead +of stopping. These tests verify that auth errors now stop the reconnect. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Mattermost: _ws_loop auth-aware retry +# --------------------------------------------------------------------------- + +class TestMattermostWSAuthRetry: + """gateway/platforms/mattermost.py — _ws_loop()""" + + def test_401_handshake_stops_reconnect(self): + """A WSServerHandshakeError with status 401 should stop the loop.""" + import aiohttp + + exc = aiohttp.WSServerHandshakeError( + request_info=MagicMock(), + history=(), + status=401, + message="Unauthorized", + headers=MagicMock(), + ) + + from gateway.platforms.mattermost import MattermostAdapter + adapter = MattermostAdapter.__new__(MattermostAdapter) + adapter._closing = False + + call_count = 0 + + async def fake_connect(): + nonlocal call_count + call_count += 1 + raise exc + + adapter._ws_connect_and_listen = fake_connect + + asyncio.run(adapter._ws_loop()) + + # Should have attempted once and stopped, not retried + assert call_count == 1 + + def test_403_handshake_stops_reconnect(self): + """A WSServerHandshakeError with status 403 should stop the loop.""" + import aiohttp + + exc = aiohttp.WSServerHandshakeError( + request_info=MagicMock(), + history=(), + status=403, + message="Forbidden", + headers=MagicMock(), + ) + + from gateway.platforms.mattermost import MattermostAdapter + adapter = MattermostAdapter.__new__(MattermostAdapter) + adapter._closing = False + + call_count = 0 + + async def fake_connect(): + nonlocal call_count + call_count += 1 + raise exc + + adapter._ws_connect_and_listen = fake_connect + + asyncio.run(adapter._ws_loop()) + assert call_count == 1 + + def test_transient_error_retries(self): + """A transient ConnectionError should retry (not stop immediately).""" + from gateway.platforms.mattermost import MattermostAdapter + adapter = MattermostAdapter.__new__(MattermostAdapter) + adapter._closing = False + + call_count = 0 + + async def fake_connect(): + nonlocal call_count + call_count += 1 + if call_count >= 2: + # Stop the loop after 2 attempts + adapter._closing = True + return + raise ConnectionError("connection reset") + + adapter._ws_connect_and_listen = fake_connect + + async def run(): + with patch("asyncio.sleep", new_callable=AsyncMock): + await adapter._ws_loop() + + asyncio.run(run()) + + # Should have retried at least once + assert call_count >= 2 + + +# --------------------------------------------------------------------------- +# Matrix: _sync_loop auth-aware retry +# --------------------------------------------------------------------------- + +class TestMatrixSyncAuthRetry: + """gateway/platforms/matrix.py — _sync_loop()""" + + def test_unknown_token_sync_error_stops_loop(self): + """A SyncError with M_UNKNOWN_TOKEN should stop syncing.""" + import types + nio_mock = types.ModuleType("nio") + + class SyncError: + def __init__(self, message): + self.message = message + + nio_mock.SyncError = SyncError + + from gateway.platforms.matrix import MatrixAdapter + adapter = MatrixAdapter.__new__(MatrixAdapter) + adapter._closing = False + + sync_count = 0 + + async def fake_sync(timeout=30000): + nonlocal sync_count + sync_count += 1 + return SyncError("M_UNKNOWN_TOKEN: Invalid access token") + + adapter._client = MagicMock() + adapter._client.sync = fake_sync + + async def run(): + import sys + sys.modules["nio"] = nio_mock + try: + await adapter._sync_loop() + finally: + del sys.modules["nio"] + + asyncio.run(run()) + assert sync_count == 1 + + def test_exception_with_401_stops_loop(self): + """An exception containing '401' should stop syncing.""" + from gateway.platforms.matrix import MatrixAdapter + adapter = MatrixAdapter.__new__(MatrixAdapter) + adapter._closing = False + + call_count = 0 + + async def fake_sync(timeout=30000): + nonlocal call_count + call_count += 1 + raise RuntimeError("HTTP 401 Unauthorized") + + adapter._client = MagicMock() + adapter._client.sync = fake_sync + + async def run(): + import types + nio_mock = types.ModuleType("nio") + nio_mock.SyncError = type("SyncError", (), {}) + + import sys + sys.modules["nio"] = nio_mock + try: + await adapter._sync_loop() + finally: + del sys.modules["nio"] + + asyncio.run(run()) + assert call_count == 1 + + def test_transient_error_retries(self): + """A transient error should retry (not stop immediately).""" + from gateway.platforms.matrix import MatrixAdapter + adapter = MatrixAdapter.__new__(MatrixAdapter) + adapter._closing = False + + call_count = 0 + + async def fake_sync(timeout=30000): + nonlocal call_count + call_count += 1 + if call_count >= 2: + adapter._closing = True + return MagicMock() # Normal response + raise ConnectionError("network timeout") + + adapter._client = MagicMock() + adapter._client.sync = fake_sync + + async def run(): + import types + nio_mock = types.ModuleType("nio") + nio_mock.SyncError = type("SyncError", (), {}) + + import sys + sys.modules["nio"] = nio_mock + try: + with patch("asyncio.sleep", new_callable=AsyncMock): + await adapter._sync_loop() + finally: + del sys.modules["nio"] + + asyncio.run(run()) + assert call_count >= 2 From bec02f3731f47d9ec741f2bc5de4cea913c21643 Mon Sep 17 00:00:00 2001 From: chalkers Date: Sun, 5 Apr 2026 10:47:42 -0700 Subject: [PATCH 039/213] fix(matrix): handle encrypted media events and cache decrypted attachments Cherry-picked from PR #3140 by chalkers, resolved against current main. Registers RoomEncryptedImage/Audio/Video/File callbacks, decrypts attachments via nio.crypto, caches all media types (images, audio, documents), prevents ciphertext URL fallback for encrypted media. Unifies the separate voice-message download into the main cache block. Preserves main's MATRIX_REQUIRE_MENTION, auto-thread, and mention stripping features. Includes 355 lines of encrypted media tests. --- gateway/platforms/matrix.py | 158 +++++++++++----- tests/gateway/test_matrix.py | 355 +++++++++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+), 51 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 89203d48a27b..055c6e65f2e6 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -273,6 +273,14 @@ async def connect(self) -> bool: client.add_event_callback(self._on_room_message_media, nio.RoomMessageAudio) client.add_event_callback(self._on_room_message_media, nio.RoomMessageVideo) client.add_event_callback(self._on_room_message_media, nio.RoomMessageFile) + for encrypted_media_cls in ( + getattr(nio, "RoomEncryptedImage", None), + getattr(nio, "RoomEncryptedAudio", None), + getattr(nio, "RoomEncryptedVideo", None), + getattr(nio, "RoomEncryptedFile", None), + ): + if encrypted_media_cls is not None: + client.add_event_callback(self._on_room_message_media, encrypted_media_cls) client.add_event_callback(self._on_invite, nio.InviteMemberEvent) # If E2EE: handle encrypted events. @@ -1025,47 +1033,122 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: # Use the MIME type from the event's content info when available, # falling back to category-level MIME types for downstream matching # (gateway/run.py checks startswith("image/"), startswith("audio/"), etc.) - content_info = getattr(event, "content", {}) if isinstance(getattr(event, "content", None), dict) else {} - event_mimetype = (content_info.get("info") or {}).get("mimetype", "") + source_content = getattr(event, "source", {}).get("content", {}) + if not isinstance(source_content, dict): + source_content = {} + event_content = getattr(event, "content", {}) + if not isinstance(event_content, dict): + event_content = {} + content_info = event_content.get("info") if isinstance(event_content, dict) else {} + if not isinstance(content_info, dict) or not content_info: + content_info = source_content.get("info", {}) if isinstance(source_content, dict) else {} + event_mimetype = ( + (content_info.get("mimetype") if isinstance(content_info, dict) else None) + or getattr(event, "mimetype", "") + or "" + ) + # For encrypted media, the URL may be in file.url instead of event.url. + file_content = source_content.get("file", {}) if isinstance(source_content, dict) else {} + if not url and isinstance(file_content, dict): + url = file_content.get("url", "") or "" + if url and url.startswith("mxc://"): + http_url = self._mxc_to_http(url) + media_type = "application/octet-stream" msg_type = MessageType.DOCUMENT + is_encrypted_image = isinstance(event, getattr(nio, "RoomEncryptedImage", ())) + is_encrypted_audio = isinstance(event, getattr(nio, "RoomEncryptedAudio", ())) + is_encrypted_video = isinstance(event, getattr(nio, "RoomEncryptedVideo", ())) + is_encrypted_file = isinstance(event, getattr(nio, "RoomEncryptedFile", ())) + is_encrypted_media = any((is_encrypted_image, is_encrypted_audio, is_encrypted_video, is_encrypted_file)) is_voice_message = False - - if isinstance(event, nio.RoomMessageImage): + + if isinstance(event, nio.RoomMessageImage) or is_encrypted_image: msg_type = MessageType.PHOTO media_type = event_mimetype or "image/png" - elif isinstance(event, nio.RoomMessageAudio): - # Check for MSC3245 voice flag: org.matrix.msc3245.voice: {} - source_content = getattr(event, "source", {}).get("content", {}) + elif isinstance(event, nio.RoomMessageAudio) or is_encrypted_audio: if source_content.get("org.matrix.msc3245.voice") is not None: is_voice_message = True msg_type = MessageType.VOICE else: msg_type = MessageType.AUDIO media_type = event_mimetype or "audio/ogg" - elif isinstance(event, nio.RoomMessageVideo): + elif isinstance(event, nio.RoomMessageVideo) or is_encrypted_video: msg_type = MessageType.VIDEO media_type = event_mimetype or "video/mp4" elif event_mimetype: media_type = event_mimetype - # For images, download and cache locally so vision tools can access them. - # Matrix MXC URLs require authentication, so direct URL access fails. + # Cache media locally when downstream tools need a real file path: + # - photos (vision tools can't access MXC URLs) + # - voice messages (transcription tools need local files) + # - any encrypted media (HTTP fallback would point at ciphertext) cached_path = None - if msg_type == MessageType.PHOTO and url: + should_cache_locally = ( + msg_type == MessageType.PHOTO or is_voice_message or is_encrypted_media + ) + if should_cache_locally and url: try: - ext_map = { - "image/jpeg": ".jpg", "image/png": ".png", - "image/gif": ".gif", "image/webp": ".webp", - } - ext = ext_map.get(event_mimetype, ".jpg") - download_resp = await self._client.download(url) - if isinstance(download_resp, nio.DownloadResponse): - from gateway.platforms.base import cache_image_from_bytes - cached_path = cache_image_from_bytes(download_resp.body, ext=ext) - logger.info("[Matrix] Cached user image at %s", cached_path) + if is_voice_message: + download_resp = await self._client.download(mxc=url) + else: + download_resp = await self._client.download(url) + file_bytes = getattr(download_resp, "body", None) + if file_bytes is not None: + if is_encrypted_media: + from nio.crypto.attachments import decrypt_attachment + + hashes_value = getattr(event, "hashes", None) + if hashes_value is None and isinstance(file_content, dict): + hashes_value = file_content.get("hashes") + hash_value = hashes_value.get("sha256") if isinstance(hashes_value, dict) else None + + key_value = getattr(event, "key", None) + if key_value is None and isinstance(file_content, dict): + key_value = file_content.get("key") + if isinstance(key_value, dict): + key_value = key_value.get("k") + + iv_value = getattr(event, "iv", None) + if iv_value is None and isinstance(file_content, dict): + iv_value = file_content.get("iv") + + if key_value and hash_value and iv_value: + file_bytes = decrypt_attachment(file_bytes, key_value, hash_value, iv_value) + else: + logger.warning( + "[Matrix] Encrypted media event missing decryption metadata for %s", + event.event_id, + ) + file_bytes = None + + if file_bytes is not None: + from gateway.platforms.base import ( + cache_audio_from_bytes, + cache_document_from_bytes, + cache_image_from_bytes, + ) + + if msg_type == MessageType.PHOTO: + ext_map = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + } + ext = ext_map.get(media_type, ".jpg") + cached_path = cache_image_from_bytes(file_bytes, ext=ext) + logger.info("[Matrix] Cached user image at %s", cached_path) + elif msg_type in (MessageType.AUDIO, MessageType.VOICE): + ext = Path(body or ("voice.ogg" if is_voice_message else "audio.ogg")).suffix or ".ogg" + cached_path = cache_audio_from_bytes(file_bytes, ext=ext) + else: + filename = body or ( + "video.mp4" if msg_type == MessageType.VIDEO else "document" + ) + cached_path = cache_document_from_bytes(file_bytes, filename) except Exception as e: - logger.warning("[Matrix] Failed to cache image: %s", e) + logger.warning("[Matrix] Failed to cache media: %s", e) is_dm = self._dm_rooms.get(room.room_id, False) if not is_dm and room.member_count == 2: @@ -1073,7 +1156,6 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: chat_type = "dm" if is_dm else "group" # Thread/reply detection. - source_content = getattr(event, "source", {}).get("content", {}) relates_to = source_content.get("m.relates_to", {}) thread_id = None if relates_to.get("rel_type") == "m.thread": @@ -1103,31 +1185,6 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: thread_id = event.event_id self._track_thread(thread_id) - # For voice messages, cache audio locally for transcription tools. - # Use the authenticated nio client to download (Matrix requires auth for media). - media_urls = [http_url] if http_url else None - media_types = [media_type] if http_url else None - - if is_voice_message and url and url.startswith("mxc://"): - try: - import nio - from gateway.platforms.base import cache_audio_from_bytes - - resp = await self._client.download(mxc=url) - if isinstance(resp, nio.MemoryDownloadResponse): - # Extract extension from mimetype or default to .ogg - ext = ".ogg" - if media_type and "/" in media_type: - subtype = media_type.split("/")[1] - ext = f".{subtype}" if subtype else ".ogg" - local_path = cache_audio_from_bytes(resp.body, ext) - media_urls = [local_path] - logger.debug("Matrix: cached voice message to %s", local_path) - else: - logger.warning("Matrix: failed to download voice: %s", getattr(resp, "message", resp)) - except Exception as e: - logger.warning("Matrix: failed to cache voice message, using HTTP URL: %s", e) - source = self.build_source( chat_id=room.room_id, chat_type=chat_type, @@ -1136,9 +1193,8 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: thread_id=thread_id, ) - # Use cached local path for images (voice messages already handled above). - if cached_path: - media_urls = [cached_path] + allow_http_fallback = bool(http_url) and not is_encrypted_media + media_urls = [cached_path] if cached_path else ([http_url] if allow_http_fallback else None) media_types = [media_type] if media_urls else None msg_event = MessageEvent( diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 9912eef00b93..5e2c7c3579bb 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -993,3 +993,358 @@ async def test_disconnect_skips_export_when_no_encryption(self): # Should not have tried to export assert not hasattr(fake_client, "export_keys") or \ not fake_client.export_keys.called + + +# --------------------------------------------------------------------------- +# E2EE: Encrypted media +# --------------------------------------------------------------------------- + +class TestMatrixEncryptedMedia: + @pytest.mark.asyncio + async def test_connect_registers_callbacks_for_encrypted_media_events(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_te...oken", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + }, + ) + adapter = MatrixAdapter(config) + + class FakeWhoamiResponse: + def __init__(self, user_id, device_id): + self.user_id = user_id + self.device_id = device_id + + class FakeSyncResponse: + def __init__(self): + self.rooms = MagicMock(join={}) + + class FakeRoomMessageText: ... + class FakeRoomMessageImage: ... + class FakeRoomMessageAudio: ... + class FakeRoomMessageVideo: ... + class FakeRoomMessageFile: ... + class FakeRoomEncryptedImage: ... + class FakeRoomEncryptedAudio: ... + class FakeRoomEncryptedVideo: ... + class FakeRoomEncryptedFile: ... + class FakeInviteMemberEvent: ... + class FakeMegolmEvent: ... + + fake_client = MagicMock() + fake_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "DEV123")) + fake_client.sync = AsyncMock(return_value=FakeSyncResponse()) + fake_client.keys_upload = AsyncMock() + fake_client.keys_query = AsyncMock() + fake_client.keys_claim = AsyncMock() + fake_client.send_to_device_messages = AsyncMock(return_value=[]) + fake_client.get_users_for_key_claiming = MagicMock(return_value={}) + fake_client.close = AsyncMock() + fake_client.add_event_callback = MagicMock() + fake_client.rooms = {} + fake_client.account_data = {} + fake_client.olm = object() + fake_client.should_upload_keys = False + fake_client.should_query_keys = False + fake_client.should_claim_keys = False + fake_client.restore_login = MagicMock(side_effect=lambda u, d, t: None) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(return_value=fake_client) + fake_nio.WhoamiResponse = FakeWhoamiResponse + fake_nio.SyncResponse = FakeSyncResponse + fake_nio.LoginResponse = type("LoginResponse", (), {}) + fake_nio.RoomMessageText = FakeRoomMessageText + fake_nio.RoomMessageImage = FakeRoomMessageImage + fake_nio.RoomMessageAudio = FakeRoomMessageAudio + fake_nio.RoomMessageVideo = FakeRoomMessageVideo + fake_nio.RoomMessageFile = FakeRoomMessageFile + fake_nio.RoomEncryptedImage = FakeRoomEncryptedImage + fake_nio.RoomEncryptedAudio = FakeRoomEncryptedAudio + fake_nio.RoomEncryptedVideo = FakeRoomEncryptedVideo + fake_nio.RoomEncryptedFile = FakeRoomEncryptedFile + fake_nio.InviteMemberEvent = FakeInviteMemberEvent + fake_nio.MegolmEvent = FakeMegolmEvent + + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True + + callback_classes = [call.args[1] for call in fake_client.add_event_callback.call_args_list] + assert FakeRoomEncryptedImage in callback_classes + assert FakeRoomEncryptedAudio in callback_classes + assert FakeRoomEncryptedVideo in callback_classes + assert FakeRoomEncryptedFile in callback_classes + + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_on_room_message_media_decrypts_encrypted_image_and_passes_local_path(self): + from nio.crypto.attachments import encrypt_attachment + + adapter = _make_adapter() + adapter._user_id = "@bot:example.org" + adapter._startup_ts = 0.0 + adapter._dm_rooms = {} + adapter.handle_message = AsyncMock() + + plaintext = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 + ciphertext, keys = encrypt_attachment(plaintext) + + class FakeRoomEncryptedImage: + def __init__(self): + self.sender = "@alice:example.org" + self.event_id = "$img1" + self.server_timestamp = 0 + self.body = "screenshot.png" + self.url = "mxc://example.org/media123" + self.key = keys["key"]["k"] + self.hashes = keys["hashes"] + self.iv = keys["iv"] + self.mimetype = "image/png" + self.source = { + "content": { + "body": "screenshot.png", + "info": {"mimetype": "image/png"}, + "file": { + "url": self.url, + "key": keys["key"], + "hashes": keys["hashes"], + "iv": keys["iv"], + }, + } + } + + class FakeDownloadResponse: + def __init__(self, body): + self.body = body + + fake_client = MagicMock() + fake_client.download = AsyncMock(return_value=FakeDownloadResponse(ciphertext)) + adapter._client = fake_client + + fake_nio = MagicMock() + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.RoomEncryptedImage = FakeRoomEncryptedImage + fake_nio.RoomEncryptedAudio = type("RoomEncryptedAudio", (), {}) + fake_nio.RoomEncryptedVideo = type("RoomEncryptedVideo", (), {}) + fake_nio.RoomEncryptedFile = type("RoomEncryptedFile", (), {}) + + room = MagicMock(room_id="!room:example.org", member_count=2, users={}) + event = FakeRoomEncryptedImage() + + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch("gateway.platforms.base.cache_image_from_bytes", return_value="/tmp/cached-image.png") as cache_mock: + await adapter._on_room_message_media(room, event) + + cache_mock.assert_called_once_with(plaintext, ext=".png") + msg_event = adapter.handle_message.await_args.args[0] + assert msg_event.message_type.name == "PHOTO" + assert msg_event.media_urls == ["/tmp/cached-image.png"] + assert msg_event.media_types == ["image/png"] + + @pytest.mark.asyncio + async def test_on_room_message_media_decrypts_encrypted_voice_and_caches_audio(self): + from nio.crypto.attachments import encrypt_attachment + + adapter = _make_adapter() + adapter._user_id = "@bot:example.org" + adapter._startup_ts = 0.0 + adapter._dm_rooms = {} + adapter.handle_message = AsyncMock() + + plaintext = b"OggS" + b"\x00" * 32 + ciphertext, keys = encrypt_attachment(plaintext) + + class FakeRoomEncryptedAudio: + def __init__(self): + self.sender = "@alice:example.org" + self.event_id = "$voice1" + self.server_timestamp = 0 + self.body = "voice.ogg" + self.url = "mxc://example.org/voice123" + self.key = keys["key"]["k"] + self.hashes = keys["hashes"] + self.iv = keys["iv"] + self.mimetype = "audio/ogg" + self.source = { + "content": { + "body": "voice.ogg", + "info": {"mimetype": "audio/ogg"}, + "org.matrix.msc3245.voice": {}, + "file": { + "url": self.url, + "key": keys["key"], + "hashes": keys["hashes"], + "iv": keys["iv"], + }, + } + } + + class FakeDownloadResponse: + def __init__(self, body): + self.body = body + + fake_client = MagicMock() + fake_client.download = AsyncMock(return_value=FakeDownloadResponse(ciphertext)) + adapter._client = fake_client + + fake_nio = MagicMock() + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.RoomEncryptedImage = type("RoomEncryptedImage", (), {}) + fake_nio.RoomEncryptedAudio = FakeRoomEncryptedAudio + fake_nio.RoomEncryptedVideo = type("RoomEncryptedVideo", (), {}) + fake_nio.RoomEncryptedFile = type("RoomEncryptedFile", (), {}) + + room = MagicMock(room_id="!room:example.org", member_count=2, users={}) + event = FakeRoomEncryptedAudio() + + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch("gateway.platforms.base.cache_audio_from_bytes", return_value="/tmp/cached-voice.ogg") as cache_mock: + await adapter._on_room_message_media(room, event) + + cache_mock.assert_called_once_with(plaintext, ext=".ogg") + msg_event = adapter.handle_message.await_args.args[0] + assert msg_event.message_type.name == "VOICE" + assert msg_event.media_urls == ["/tmp/cached-voice.ogg"] + assert msg_event.media_types == ["audio/ogg"] + + @pytest.mark.asyncio + async def test_on_room_message_media_decrypts_encrypted_file_and_caches_document(self): + from nio.crypto.attachments import encrypt_attachment + + adapter = _make_adapter() + adapter._user_id = "@bot:example.org" + adapter._startup_ts = 0.0 + adapter._dm_rooms = {} + adapter.handle_message = AsyncMock() + + plaintext = b"hello from encrypted document" + ciphertext, keys = encrypt_attachment(plaintext) + + class FakeRoomEncryptedFile: + def __init__(self): + self.sender = "@alice:example.org" + self.event_id = "$file1" + self.server_timestamp = 0 + self.body = "notes.txt" + self.url = "mxc://example.org/file123" + self.key = keys["key"] + self.hashes = keys["hashes"] + self.iv = keys["iv"] + self.mimetype = "text/plain" + self.source = { + "content": { + "body": "notes.txt", + "info": {"mimetype": "text/plain"}, + "file": { + "url": self.url, + "key": keys["key"], + "hashes": keys["hashes"], + "iv": keys["iv"], + }, + } + } + + class FakeDownloadResponse: + def __init__(self, body): + self.body = body + + fake_client = MagicMock() + fake_client.download = AsyncMock(return_value=FakeDownloadResponse(ciphertext)) + adapter._client = fake_client + + fake_nio = MagicMock() + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.RoomEncryptedImage = type("RoomEncryptedImage", (), {}) + fake_nio.RoomEncryptedAudio = type("RoomEncryptedAudio", (), {}) + fake_nio.RoomEncryptedVideo = type("RoomEncryptedVideo", (), {}) + fake_nio.RoomEncryptedFile = FakeRoomEncryptedFile + + room = MagicMock(room_id="!room:example.org", member_count=2, users={}) + event = FakeRoomEncryptedFile() + + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch("gateway.platforms.base.cache_document_from_bytes", return_value="/tmp/cached-notes.txt") as cache_mock: + await adapter._on_room_message_media(room, event) + + cache_mock.assert_called_once_with(plaintext, "notes.txt") + msg_event = adapter.handle_message.await_args.args[0] + assert msg_event.message_type.name == "DOCUMENT" + assert msg_event.media_urls == ["/tmp/cached-notes.txt"] + assert msg_event.media_types == ["text/plain"] + + @pytest.mark.asyncio + async def test_on_room_message_media_does_not_emit_ciphertext_url_when_encrypted_media_decryption_fails(self): + adapter = _make_adapter() + adapter._user_id = "@bot:example.org" + adapter._startup_ts = 0.0 + adapter._dm_rooms = {} + adapter.handle_message = AsyncMock() + + class FakeRoomEncryptedImage: + def __init__(self): + self.sender = "@alice:example.org" + self.event_id = "$img2" + self.server_timestamp = 0 + self.body = "broken.png" + self.url = "mxc://example.org/media999" + self.key = {"k": "broken"} + self.hashes = {"sha256": "broken"} + self.iv = "broken" + self.mimetype = "image/png" + self.source = { + "content": { + "body": "broken.png", + "info": {"mimetype": "image/png"}, + "file": { + "url": self.url, + "key": self.key, + "hashes": self.hashes, + "iv": self.iv, + }, + } + } + + class FakeDownloadResponse: + def __init__(self, body): + self.body = body + + fake_client = MagicMock() + fake_client.download = AsyncMock(return_value=FakeDownloadResponse(b"ciphertext")) + adapter._client = fake_client + + fake_nio = MagicMock() + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.RoomEncryptedImage = FakeRoomEncryptedImage + fake_nio.RoomEncryptedAudio = type("RoomEncryptedAudio", (), {}) + fake_nio.RoomEncryptedVideo = type("RoomEncryptedVideo", (), {}) + fake_nio.RoomEncryptedFile = type("RoomEncryptedFile", (), {}) + + room = MagicMock(room_id="!room:example.org", member_count=2, users={}) + event = FakeRoomEncryptedImage() + + with patch.dict("sys.modules", {"nio": fake_nio}): + await adapter._on_room_message_media(room, event) + + msg_event = adapter.handle_message.await_args.args[0] + assert not msg_event.media_urls + assert not msg_event.media_types From 36e046e843c7474c25b222b823409e62b2884ca8 Mon Sep 17 00:00:00 2001 From: dlkakbs Date: Sun, 5 Apr 2026 10:48:20 -0700 Subject: [PATCH 040/213] fix(gateway): MIME type fallback for Matrix document uploads Cherry-picked run.py portion from PR #3495 by dlkakbs. When Matrix sends non-image files (text, YAML, JSON, etc.), the MIME type may be empty or application/octet-stream. Falls back to extension-based detection so text files are properly injected into agent context. --- gateway/run.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 070b77e189d6..f14713249c78 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2650,8 +2650,20 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): # Enrich document messages with context notes for the agent # ----------------------------------------------------------------- if event.media_urls and event.message_type == MessageType.DOCUMENT: + import mimetypes as _mimetypes + _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} for i, path in enumerate(event.media_urls): mtype = event.media_types[i] if i < len(event.media_types) else "" + # Fall back to extension-based detection when MIME type is unreliable. + if mtype in ("", "application/octet-stream"): + import os as _os2 + _ext = _os2.path.splitext(path)[1].lower() + if _ext in _TEXT_EXTENSIONS: + mtype = "text/plain" + else: + guessed, _ = _mimetypes.guess_type(path) + if guessed: + mtype = guessed if not (mtype.startswith("application/") or mtype.startswith("text/")): continue # Extract display filename by stripping the doc_{uuid12}_ prefix From c100ad874c34bc0fe8357bee1b6f890d95da166c Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 5 Apr 2026 10:52:29 -0700 Subject: [PATCH 041/213] fix(matrix): E2EE cron delivery via live adapter + HTML formatting + origin fallback Salvaged from PRs #3767 (chalkers), #5236 (ygd58), #2641 (buntingszn). Three improvements to Matrix cron delivery: 1. Live adapter path: when the gateway is running, cron delivery now uses the connected MatrixAdapter via run_coroutine_threadsafe instead of the standalone HTTP PUT. This enables delivery to E2EE rooms where the raw HTTP path cannot encrypt. Falls back to standalone on failure. Threads adapters + event loop from gateway -> cron ticker -> tick() -> _deliver_result(). (from #3767) 2. HTML formatted_body: _send_matrix() now converts markdown to HTML using the optional markdown library, with h1-h6 to bold conversion for Element X compatibility. Falls back to plain text if markdown is not installed. Also adds random bytes to txn_id to prevent collisions. (from #5236) 3. Origin fallback: when deliver="origin" but origin is null (jobs created via API/scripts), falls back to HOME_CHANNEL env vars in order: matrix -> telegram -> discord -> slack. (from #2641) --- cron/scheduler.py | 71 ++++++++++++++++++++++++++++++------- gateway/platforms/matrix.py | 18 +++++++--- gateway/run.py | 12 ++++--- tools/send_message_tool.py | 23 ++++++++++-- 4 files changed, 100 insertions(+), 24 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 8b977f422c98..5d561066a7a3 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -74,13 +74,28 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]: return None if deliver == "origin": - if not origin: - return None - return { - "platform": origin["platform"], - "chat_id": str(origin["chat_id"]), - "thread_id": origin.get("thread_id"), - } + if origin: + return { + "platform": origin["platform"], + "chat_id": str(origin["chat_id"]), + "thread_id": origin.get("thread_id"), + } + # Origin missing (e.g. job created via API/script) — try each + # platform's home channel as a fallback instead of silently dropping. + for platform_name in ("matrix", "telegram", "discord", "slack"): + chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "") + if chat_id: + logger.info( + "Job '%s' has deliver=origin but no origin; falling back to %s home channel", + job.get("name", job.get("id", "?")), + platform_name, + ) + return { + "platform": platform_name, + "chat_id": chat_id, + "thread_id": None, + } + return None if ":" in deliver: platform_name, rest = deliver.split(":", 1) @@ -130,12 +145,14 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]: } -def _deliver_result(job: dict, content: str) -> None: +def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None: """ Deliver job output to the configured target (origin chat, specific platform, etc.). - Uses the standalone platform send functions from send_message_tool so delivery - works whether or not the gateway is running. + When ``adapters`` and ``loop`` are provided (gateway is running), tries to + use the live adapter first — this supports E2EE rooms (e.g. Matrix) where + the standalone HTTP path cannot encrypt. Falls back to standalone send if + the adapter path fails or is unavailable. """ target = _resolve_delivery_target(job) if not target: @@ -206,7 +223,33 @@ def _deliver_result(job: dict, content: str) -> None: else: delivery_content = content - # Run the async send in a fresh event loop (safe from any thread) + # Prefer the live adapter when the gateway is running — this supports E2EE + # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt. + runtime_adapter = (adapters or {}).get(platform) + if runtime_adapter is not None and loop is not None and getattr(loop, "is_running", lambda: False)(): + send_metadata = {"thread_id": thread_id} if thread_id else None + try: + future = asyncio.run_coroutine_threadsafe( + runtime_adapter.send(chat_id, delivery_content, metadata=send_metadata), + loop, + ) + send_result = future.result(timeout=60) + if send_result and not getattr(send_result, "success", True): + err = getattr(send_result, "error", "unknown") + logger.warning( + "Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone", + job["id"], platform_name, chat_id, err, + ) + else: + logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id) + return + except Exception as e: + logger.warning( + "Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone", + job["id"], platform_name, chat_id, e, + ) + + # Standalone path: run the async send in a fresh event loop (safe from any thread) coro = _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id) try: result = asyncio.run(coro) @@ -629,7 +672,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e) -def tick(verbose: bool = True) -> int: +def tick(verbose: bool = True, adapters=None, loop=None) -> int: """ Check and run all due jobs. @@ -638,6 +681,8 @@ def tick(verbose: bool = True) -> int: Args: verbose: Whether to print status messages + adapters: Optional dict mapping Platform → live adapter (from gateway) + loop: Optional asyncio event loop (from gateway) for live adapter sends Returns: Number of jobs executed (0 if another tick is already running) @@ -694,7 +739,7 @@ def tick(verbose: bool = True) -> int: if should_deliver: try: - _deliver_result(job, deliver_content) + _deliver_result(job, deliver_content, adapters=adapters, loop=loop) except Exception as de: logger.error("Delivery failed for job %s: %s", job["id"], de) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 055c6e65f2e6..25f701d95da5 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -1056,10 +1056,20 @@ async def _on_room_message_media(self, room: Any, event: Any) -> None: media_type = "application/octet-stream" msg_type = MessageType.DOCUMENT - is_encrypted_image = isinstance(event, getattr(nio, "RoomEncryptedImage", ())) - is_encrypted_audio = isinstance(event, getattr(nio, "RoomEncryptedAudio", ())) - is_encrypted_video = isinstance(event, getattr(nio, "RoomEncryptedVideo", ())) - is_encrypted_file = isinstance(event, getattr(nio, "RoomEncryptedFile", ())) + + # Safely resolve encrypted media classes — they may not exist on older + # nio versions, and in test environments nio may be mocked (MagicMock + # auto-attributes are not valid types for isinstance). + def _safe_isinstance(obj, cls_name): + cls = getattr(nio, cls_name, None) + if cls is None or not isinstance(cls, type): + return False + return isinstance(obj, cls) + + is_encrypted_image = _safe_isinstance(event, "RoomEncryptedImage") + is_encrypted_audio = _safe_isinstance(event, "RoomEncryptedAudio") + is_encrypted_video = _safe_isinstance(event, "RoomEncryptedVideo") + is_encrypted_file = _safe_isinstance(event, "RoomEncryptedFile") is_encrypted_media = any((is_encrypted_image, is_encrypted_audio, is_encrypted_video, is_encrypted_file)) is_voice_message = False diff --git a/gateway/run.py b/gateway/run.py index f14713249c78..52bc9f7a0d80 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6843,13 +6843,16 @@ async def _notify_long_running(): return response -def _start_cron_ticker(stop_event: threading.Event, adapters=None, interval: int = 60): +def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60): """ Background thread that ticks the cron scheduler at a regular interval. Runs inside the gateway process so cronjobs fire automatically without needing a separate `hermes cron daemon` or system cron entry. + When ``adapters`` and ``loop`` are provided, passes them through to the + cron delivery path so live adapters can be used for E2EE rooms. + Also refreshes the channel directory every 5 minutes and prunes the image/audio/document cache once per hour. """ @@ -6863,7 +6866,7 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, interval: int tick_count = 0 while not stop_event.is_set(): try: - cron_tick(verbose=False) + cron_tick(verbose=False, adapters=adapters, loop=loop) except Exception as e: logger.debug("Cron tick error: %s", e) @@ -7049,12 +7052,13 @@ def signal_handler(): write_pid_file() atexit.register(remove_pid_file) - # Start background cron ticker so scheduled jobs fire automatically + # Start background cron ticker so scheduled jobs fire automatically. + # Pass the event loop so cron delivery can use live adapters (E2EE support). cron_stop = threading.Event() cron_thread = threading.Thread( target=_start_cron_ticker, args=(cron_stop,), - kwargs={"adapters": runner.adapters}, + kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()}, daemon=True, name="cron-ticker", ) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index d12eed5099cb..32741f08ac73 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -719,7 +719,11 @@ async def _send_mattermost(token, extra, chat_id, message): async def _send_matrix(token, extra, chat_id, message): - """Send via Matrix Client-Server API.""" + """Send via Matrix Client-Server API. + + Converts markdown to HTML for rich rendering in Matrix clients. + Falls back to plain text if the ``markdown`` library is not installed. + """ try: import aiohttp except ImportError: @@ -729,11 +733,24 @@ async def _send_matrix(token, extra, chat_id, message): token = token or os.getenv("MATRIX_ACCESS_TOKEN", "") if not homeserver or not token: return {"error": "Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)"} - txn_id = f"hermes_{int(time.time() * 1000)}" + txn_id = f"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}" url = f"{homeserver}/_matrix/client/v3/rooms/{chat_id}/send/m.room.message/{txn_id}" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + # Build message payload with optional HTML formatted_body. + payload = {"msgtype": "m.text", "body": message} + try: + import markdown as _md + html = _md.markdown(message, extensions=["fenced_code", "tables"]) + # Convert h1-h6 to bold for Element X compatibility. + html = re.sub(r"(.*?)", r"\1", html) + payload["format"] = "org.matrix.custom.html" + payload["formatted_body"] = html + except ImportError: + pass + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: - async with session.put(url, headers=headers, json={"msgtype": "m.text", "body": message}) as resp: + async with session.put(url, headers=headers, json=payload) as resp: if resp.status not in (200, 201): body = await resp.text() return {"error": f"Matrix API error ({resp.status}): {body}"} From 20b4060dbfac0b3942d240a313b36df216ea4a6c Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 5 Apr 2026 11:16:33 -0700 Subject: [PATCH 042/213] fix: web_extract fast-fail on scrape timeout + summarizer resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Firecrawl scrape: 60s timeout via asyncio.wait_for + to_thread (previously could hang indefinitely) - Summarizer retries: 6 → 2 (one retry), reads timeout from auxiliary.web_extract.timeout config (default 360s / 6min) - Summarizer failure: falls back to truncated raw content (~5000 chars) instead of useless error message, with guidance about config/model - Config default: auxiliary.web_extract.timeout bumped 30 → 360s for local model compatibility Addresses Discord reports of agent hanging during web_extract. --- hermes_cli/config.py | 2 +- tools/web_tools.py | 50 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 1871bc916692..1be21ff0fa81 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -315,7 +315,7 @@ def ensure_hermes_home(): "model": "", "base_url": "", "api_key": "", - "timeout": 30, # seconds — increase for slow local models + "timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models }, "compression": { "provider": "auto", diff --git a/tools/web_tools.py b/tools/web_tools.py index 69ab16e863fa..8571c2a26f68 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -554,8 +554,24 @@ async def process_content_with_llm( return processed_content except Exception as e: - logger.debug("Error processing content with LLM: %s", e) - return f"[Failed to process content: {str(e)[:100]}. Content size: {len(content):,} chars]" + logger.warning( + "web_extract LLM summarization failed (%s). " + "Tip: increase auxiliary.web_extract.timeout in config.yaml " + "or switch to a faster auxiliary model.", + str(e)[:120], + ) + # Fall back to truncated raw content instead of returning a useless + # error message. The first ~5000 chars are almost always more useful + # to the model than "[Failed to process content: ...]". + truncated = content[:MAX_OUTPUT_SIZE] + if len(content) > MAX_OUTPUT_SIZE: + truncated += ( + f"\n\n[Content truncated — showing first {MAX_OUTPUT_SIZE:,} of " + f"{len(content):,} chars. LLM summarization timed out. " + f"To fix: increase auxiliary.web_extract.timeout in config.yaml, " + f"or use a faster auxiliary model. Use browser_navigate for the full page.]" + ) + return truncated async def _call_summarizer_llm( @@ -620,8 +636,9 @@ async def _call_summarizer_llm( Create a markdown summary that captures all key information in a well-organized, scannable format. Include important quotes and code snippets in their original formatting. Focus on actionable information, specific details, and unique insights.""" - # Call the LLM with retry logic - max_retries = 6 + # Call the LLM with retry logic — keep retries low since summarization + # is a nice-to-have; the caller falls back to truncated content on failure. + max_retries = 2 retry_delay = 2 last_error = None @@ -640,6 +657,9 @@ async def _call_summarizer_llm( ], "temperature": 0.1, "max_tokens": max_tokens, + # No explicit timeout — async_call_llm reads auxiliary.web_extract.timeout + # from config (default 360s / 6min). Users with slow local models can + # increase it in config.yaml. } if extra_body: call_kwargs["extra_body"] = extra_body @@ -1264,10 +1284,24 @@ async def web_extract_tool( try: logger.info("Scraping: %s", url) - scrape_result = _get_firecrawl_client().scrape( - url=url, - formats=formats - ) + # Run synchronous Firecrawl scrape in a thread with a + # 60s timeout so a hung fetch doesn't block the session. + try: + scrape_result = await asyncio.wait_for( + asyncio.to_thread( + _get_firecrawl_client().scrape, + url=url, + formats=formats, + ), + timeout=60, + ) + except asyncio.TimeoutError: + logger.warning("Firecrawl scrape timed out for %s", url) + results.append({ + "url": url, "title": "", "content": "", + "error": "Scrape timed out after 60s — page may be too large or unresponsive. Try browser_navigate instead.", + }) + continue scrape_payload = _extract_scrape_payload(scrape_result) metadata = scrape_payload.get("metadata", {}) From 534511bebbb13475b9f3dfb638444b5104b316a4 Mon Sep 17 00:00:00 2001 From: nepenth Date: Sun, 5 Apr 2026 11:19:27 -0700 Subject: [PATCH 043/213] =?UTF-8?q?feat(matrix):=20Tier=201=20enhancement?= =?UTF-8?q?=20=E2=80=94=20reactions,=20read=20receipts,=20rich=20formattin?= =?UTF-8?q?g,=20room=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from PR #4338 by nepenth, resolved against current main. Adds: - Processing lifecycle reactions (eyes/checkmark/cross) via MATRIX_REACTIONS env - Reaction send/receive with ReactionEvent + UnknownEvent fallback for older nio - Fire-and-forget read receipts on text and media messages - Message redaction, room history fetch, room creation, user invite - Presence status control (online/offline/unavailable) - Emote (/me) and notice message types with HTML rendering - XSS-hardened markdown-to-HTML converter (strips raw HTML preprocessor, sanitizes link URLs against javascript:/data:/vbscript: schemes) - Comprehensive regex fallback with full block/inline markdown support - Markdown>=3.6 added to [matrix] extras in pyproject.toml - 46 new tests covering all features and security hardening --- gateway/platforms/matrix.py | 588 +++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- tests/gateway/test_matrix.py | 418 +++++++++++++++++++++++++ 3 files changed, 989 insertions(+), 19 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 25f701d95da5..35cf72ad414a 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -10,8 +10,10 @@ MATRIX_USER_ID Full user ID (@bot:server) — required for password login MATRIX_PASSWORD Password (alternative to access token) MATRIX_ENCRYPTION Set "true" to enable E2EE - MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) - MATRIX_HOME_ROOM Room ID for cron/notification delivery + MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) + MATRIX_HOME_ROOM Room ID for cron/notification delivery + MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions + (eyes/checkmark/cross). Default: true MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) @@ -30,6 +32,8 @@ from pathlib import Path from typing import Any, Dict, Optional, Set +from html import escape as _html_escape + from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, @@ -130,6 +134,11 @@ def __init__(self, config: PlatformConfig): self._bot_participated_threads: set = self._load_participated_threads() self._MAX_TRACKED_THREADS = 500 + # Reactions: configurable via MATRIX_REACTIONS (default: true). + self._reactions_enabled: bool = os.getenv( + "MATRIX_REACTIONS", "true" + ).lower() not in ("false", "0", "no") + def _is_duplicate_event(self, event_id) -> bool: """Return True if this event was already processed. Tracks the ID otherwise.""" if not event_id: @@ -283,6 +292,13 @@ async def connect(self) -> bool: client.add_event_callback(self._on_room_message_media, encrypted_media_cls) client.add_event_callback(self._on_invite, nio.InviteMemberEvent) + # Reaction events (m.reaction). + if hasattr(nio, "ReactionEvent"): + client.add_event_callback(self._on_reaction, nio.ReactionEvent) + else: + # Older matrix-nio versions: use UnknownEvent fallback. + client.add_event_callback(self._on_unknown_event, nio.UnknownEvent) + # If E2EE: handle encrypted events. if self._encryption and hasattr(client, "olm"): client.add_event_callback( @@ -1002,6 +1018,9 @@ async def _on_room_message(self, room: Any, event: Any) -> None: if thread_id: self._track_thread(thread_id) + # Acknowledge receipt so the room shows as read (fire-and-forget). + self._background_read_receipt(room.room_id, event.event_id) + await self.handle_message(msg_event) async def _on_room_message_media(self, room: Any, event: Any) -> None: @@ -1220,6 +1239,9 @@ def _safe_isinstance(obj, cls_name): if thread_id: self._track_thread(thread_id) + # Acknowledge receipt so the room shows as read (fire-and-forget). + self._background_read_receipt(room.room_id, event.event_id) + await self.handle_message(msg_event) async def _on_invite(self, room: Any, event: Any) -> None: @@ -1255,6 +1277,369 @@ async def _on_invite(self, room: Any, event: Any) -> None: except Exception as exc: logger.warning("Matrix: error joining %s: %s", room.room_id, exc) + # ------------------------------------------------------------------ + # Reactions (send, receive, processing lifecycle) + # ------------------------------------------------------------------ + + async def _send_reaction( + self, room_id: str, event_id: str, emoji: str, + ) -> bool: + """Send an emoji reaction to a message in a room.""" + import nio + + if not self._client: + return False + content = { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": event_id, + "key": emoji, + } + } + try: + resp = await self._client.room_send( + room_id, "m.reaction", content, + ignore_unverified_devices=True, + ) + if isinstance(resp, nio.RoomSendResponse): + logger.debug("Matrix: sent reaction %s to %s", emoji, event_id) + return True + logger.debug("Matrix: reaction send failed: %s", resp) + return False + except Exception as exc: + logger.debug("Matrix: reaction send error: %s", exc) + return False + + async def _redact_reaction( + self, room_id: str, reaction_event_id: str, reason: str = "", + ) -> bool: + """Remove a reaction by redacting its event.""" + return await self.redact_message(room_id, reaction_event_id, reason) + + async def on_processing_start(self, event: MessageEvent) -> None: + """Add eyes reaction when the agent starts processing a message.""" + if not self._reactions_enabled: + return + msg_id = event.message_id + room_id = event.source.chat_id + if msg_id and room_id: + await self._send_reaction(room_id, msg_id, "\U0001f440") + + async def on_processing_complete( + self, event: MessageEvent, success: bool, + ) -> None: + """Replace eyes with checkmark (success) or cross (failure).""" + if not self._reactions_enabled: + return + msg_id = event.message_id + room_id = event.source.chat_id + if not msg_id or not room_id: + return + # Note: Matrix doesn't support removing a specific reaction easily + # without tracking the reaction event_id. We send the new reaction; + # the eyes stays (acceptable UX — both are visible). + await self._send_reaction( + room_id, msg_id, "\u2705" if success else "\u274c", + ) + + async def _on_reaction(self, room: Any, event: Any) -> None: + """Handle incoming reaction events.""" + if event.sender == self._user_id: + return + if self._is_duplicate_event(getattr(event, "event_id", None)): + return + # Log for now; future: trigger agent actions based on emoji. + reacts_to = getattr(event, "reacts_to", "") + key = getattr(event, "key", "") + logger.info( + "Matrix: reaction %s from %s on %s in %s", + key, event.sender, reacts_to, room.room_id, + ) + + async def _on_unknown_event(self, room: Any, event: Any) -> None: + """Fallback handler for events not natively parsed by matrix-nio. + + Catches m.reaction on older nio versions that lack ReactionEvent. + """ + source = getattr(event, "source", {}) + if source.get("type") != "m.reaction": + return + content = source.get("content", {}) + relates_to = content.get("m.relates_to", {}) + if relates_to.get("rel_type") != "m.annotation": + return + if source.get("sender") == self._user_id: + return + logger.info( + "Matrix: reaction %s from %s on %s in %s", + relates_to.get("key", "?"), + source.get("sender", "?"), + relates_to.get("event_id", "?"), + room.room_id, + ) + + # ------------------------------------------------------------------ + # Read receipts + # ------------------------------------------------------------------ + + def _background_read_receipt(self, room_id: str, event_id: str) -> None: + """Fire-and-forget read receipt with error logging.""" + async def _send() -> None: + try: + await self.send_read_receipt(room_id, event_id) + except Exception as exc: # pragma: no cover — defensive + logger.debug("Matrix: background read receipt failed: %s", exc) + asyncio.ensure_future(_send()) + + async def send_read_receipt(self, room_id: str, event_id: str) -> bool: + """Send a read receipt (m.read) for an event. + + Also sets the fully-read marker so the room is marked as read + in all clients. + """ + if not self._client: + return False + try: + if hasattr(self._client, "room_read_markers"): + await self._client.room_read_markers( + room_id, + fully_read_event=event_id, + read_event=event_id, + ) + else: + # Fallback for older matrix-nio. + await self._client.room_send( + room_id, "m.receipt", {"event_id": event_id}, + ) + logger.debug("Matrix: sent read receipt for %s in %s", event_id, room_id) + return True + except Exception as exc: + logger.debug("Matrix: read receipt failed: %s", exc) + return False + + # ------------------------------------------------------------------ + # Message redaction + # ------------------------------------------------------------------ + + async def redact_message( + self, room_id: str, event_id: str, reason: str = "", + ) -> bool: + """Redact (delete) a message or event from a room.""" + import nio + + if not self._client: + return False + try: + resp = await self._client.room_redact( + room_id, event_id, reason=reason, + ) + if isinstance(resp, nio.RoomRedactResponse): + logger.info("Matrix: redacted %s in %s", event_id, room_id) + return True + logger.warning("Matrix: redact failed: %s", resp) + return False + except Exception as exc: + logger.warning("Matrix: redact error: %s", exc) + return False + + # ------------------------------------------------------------------ + # Room history + # ------------------------------------------------------------------ + + async def fetch_room_history( + self, + room_id: str, + limit: int = 50, + start: str = "", + ) -> list: + """Fetch recent messages from a room. + + Returns a list of dicts with keys: event_id, sender, body, + timestamp, type. Uses the ``room_messages()`` API. + """ + import nio + + if not self._client: + return [] + try: + resp = await self._client.room_messages( + room_id, + start=start or "", + limit=limit, + direction=nio.Api.MessageDirection.back + if hasattr(nio.Api, "MessageDirection") + else "b", + ) + except Exception as exc: + logger.warning("Matrix: room_messages failed for %s: %s", room_id, exc) + return [] + + if not isinstance(resp, nio.RoomMessagesResponse): + logger.warning("Matrix: room_messages returned %s", type(resp).__name__) + return [] + + messages = [] + for event in reversed(resp.chunk): + body = getattr(event, "body", "") or "" + messages.append({ + "event_id": getattr(event, "event_id", ""), + "sender": getattr(event, "sender", ""), + "body": body, + "timestamp": getattr(event, "server_timestamp", 0), + "type": type(event).__name__, + }) + return messages + + # ------------------------------------------------------------------ + # Room creation & management + # ------------------------------------------------------------------ + + async def create_room( + self, + name: str = "", + topic: str = "", + invite: Optional[list] = None, + is_direct: bool = False, + preset: str = "private_chat", + ) -> Optional[str]: + """Create a new Matrix room. + + Args: + name: Human-readable room name. + topic: Room topic. + invite: List of user IDs to invite. + is_direct: Mark as a DM room. + preset: One of private_chat, public_chat, trusted_private_chat. + + Returns the room_id on success, None on failure. + """ + import nio + + if not self._client: + return None + try: + resp = await self._client.room_create( + name=name or None, + topic=topic or None, + invite=invite or [], + is_direct=is_direct, + preset=getattr( + nio.Api.RoomPreset if hasattr(nio.Api, "RoomPreset") else type("", (), {}), + preset, None, + ) or preset, + ) + if isinstance(resp, nio.RoomCreateResponse): + room_id = resp.room_id + self._joined_rooms.add(room_id) + logger.info("Matrix: created room %s (%s)", room_id, name or "unnamed") + return room_id + logger.warning("Matrix: room_create failed: %s", resp) + return None + except Exception as exc: + logger.warning("Matrix: room_create error: %s", exc) + return None + + async def invite_user(self, room_id: str, user_id: str) -> bool: + """Invite a user to a room.""" + import nio + + if not self._client: + return False + try: + resp = await self._client.room_invite(room_id, user_id) + if isinstance(resp, nio.RoomInviteResponse): + logger.info("Matrix: invited %s to %s", user_id, room_id) + return True + logger.warning("Matrix: invite failed: %s", resp) + return False + except Exception as exc: + logger.warning("Matrix: invite error: %s", exc) + return False + + # ------------------------------------------------------------------ + # Presence + # ------------------------------------------------------------------ + + _VALID_PRESENCE_STATES = frozenset(("online", "offline", "unavailable")) + + async def set_presence(self, state: str = "online", status_msg: str = "") -> bool: + """Set the bot's presence status.""" + if not self._client: + return False + if state not in self._VALID_PRESENCE_STATES: + logger.warning("Matrix: invalid presence state %r", state) + return False + try: + if hasattr(self._client, "set_presence"): + await self._client.set_presence(state, status_msg=status_msg or None) + logger.debug("Matrix: presence set to %s", state) + return True + except Exception as exc: + logger.debug("Matrix: set_presence failed: %s", exc) + return False + + # ------------------------------------------------------------------ + # Emote & notice message types + # ------------------------------------------------------------------ + + async def send_emote( + self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an emote message (/me style action).""" + import nio + + if not self._client or not text: + return SendResult(success=False, error="No client or empty text") + + msg_content: Dict[str, Any] = { + "msgtype": "m.emote", + "body": text, + } + html = self._markdown_to_html(text) + if html and html != text: + msg_content["format"] = "org.matrix.custom.html" + msg_content["formatted_body"] = html + + try: + resp = await self._client.room_send( + chat_id, "m.room.message", msg_content, + ignore_unverified_devices=True, + ) + if isinstance(resp, nio.RoomSendResponse): + return SendResult(success=True, message_id=resp.event_id) + return SendResult(success=False, error=str(resp)) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + async def send_notice( + self, chat_id: str, text: str, metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a notice message (bot-appropriate, non-alerting).""" + import nio + + if not self._client or not text: + return SendResult(success=False, error="No client or empty text") + + msg_content: Dict[str, Any] = { + "msgtype": "m.notice", + "body": text, + } + html = self._markdown_to_html(text) + if html and html != text: + msg_content["format"] = "org.matrix.custom.html" + msg_content["formatted_body"] = html + + try: + resp = await self._client.room_send( + chat_id, "m.room.message", msg_content, + ignore_unverified_devices=True, + ) + if isinstance(resp, nio.RoomSendResponse): + return SendResult(success=True, message_id=resp.event_id) + return SendResult(success=False, error=str(resp)) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @@ -1406,29 +1791,196 @@ def _mxc_to_http(self, mxc_url: str) -> str: return f"{self._homeserver}/_matrix/client/v1/media/download/{parts}" def _markdown_to_html(self, text: str) -> str: - """Convert Markdown to Matrix-compatible HTML. + """Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html). - Uses a simple conversion for common patterns. For full fidelity - a markdown-it style library could be used, but this covers the - common cases without an extra dependency. + Uses the ``markdown`` library when available (installed with the + ``matrix`` extra). Falls back to a comprehensive regex converter + that handles fenced code blocks, inline code, headers, bold, + italic, strikethrough, links, blockquotes, lists, and horizontal + rules — everything the Matrix HTML spec allows. """ try: - import markdown - html = markdown.markdown( - text, - extensions=["fenced_code", "tables", "nl2br"], + import markdown as _md + + md = _md.Markdown( + extensions=["fenced_code", "tables", "nl2br", "sane_lists"], ) - # Strip wrapping

tags for single-paragraph messages. + # Remove the raw HTML preprocessor so ") + assert "") + assert "") + assert "") + assert "*") + assert "\n```') + assert "<script>" in result + assert " + + + + + + + + + + +``` + +Key implementation patterns: +- **Seeded randomness**: Always `randomSeed()` + `noiseSeed()` for reproducibility +- **Color mode**: Use `colorMode(HSB, 360, 100, 100, 100)` for intuitive color control +- **State separation**: CONFIG for parameters, PALETTE for colors, globals for mutable state +- **Class-based entities**: Particles, agents, shapes as classes with `update()` + `display()` methods +- **Offscreen buffers**: `createGraphics()` for layered composition, trails, masks + +### Step 4: Preview & Iterate + +- Open HTML file directly in browser — no server needed for basic sketches +- For `loadImage()`/`loadFont()` from local files: use `scripts/serve.sh` or `python3 -m http.server` +- Chrome DevTools Performance tab to verify 60fps +- Test at target export resolution, not just the window size +- Adjust parameters until the visual matches the concept from Step 1 + +### Step 5: Export + +| Format | Method | Command | +|--------|--------|---------| +| **PNG** | `saveCanvas('output', 'png')` in `keyPressed()` | Press 's' to save | +| **High-res PNG** | Puppeteer headless capture | `node scripts/export-frames.js sketch.html --width 3840 --height 2160 --frames 1` | +| **GIF** | `saveGif('output', 5)` — captures N seconds | Press 'g' to save | +| **Frame sequence** | `saveFrames('frame', 'png', 10, 30)` — 10s at 30fps | Then `ffmpeg -i frame-%04d.png -c:v libx264 output.mp4` | +| **MP4** | Puppeteer frame capture + ffmpeg | `bash scripts/render.sh sketch.html output.mp4 --duration 30 --fps 30` | +| **SVG** | `createCanvas(w, h, SVG)` with p5.js-svg | `save('output.svg')` | + +### Step 6: Quality Verification + +- **Does it match the vision?** Compare output to the creative concept. If it looks generic, go back to Step 1 +- **Resolution check**: Is it sharp at the target display size? No aliasing artifacts? +- **Performance check**: Does it hold 60fps in browser? (30fps minimum for animations) +- **Color check**: Do the colors work together? Test on both light and dark monitors +- **Edge cases**: What happens at canvas edges? On resize? After running for 10 minutes? + +## Critical Implementation Notes + +### Performance — Disable FES First + +The Friendly Error System (FES) adds up to 10x overhead. Disable it in every production sketch: + +```javascript +p5.disableFriendlyErrors = true; // BEFORE setup() + +function setup() { + pixelDensity(1); // prevent 2x-4x overdraw on retina + createCanvas(1920, 1080); +} +``` + +In hot loops (particles, pixel ops), use `Math.*` instead of p5 wrappers — measurably faster: + +```javascript +// In draw() or update() hot paths: +let a = Math.sin(t); // not sin(t) +let r = Math.sqrt(dx*dx+dy*dy); // not dist() — or better: skip sqrt, compare magSq +let v = Math.random(); // not random() — when seed not needed +let m = Math.min(a, b); // not min(a, b) +``` + +Never `console.log()` inside `draw()`. Never manipulate DOM in `draw()`. See `references/troubleshooting.md` § Performance. + +### Seeded Randomness — Always + +Every generative sketch must be reproducible. Same seed, same output. + +```javascript +function setup() { + randomSeed(CONFIG.seed); + noiseSeed(CONFIG.seed); + // All random() and noise() calls now deterministic +} +``` + +Never use `Math.random()` for generative content — only for performance-critical non-visual code. Always `random()` for visual elements. If you need a random seed: `CONFIG.seed = floor(random(99999))`. + +### Generative Art Platform Support (fxhash / Art Blocks) + +For generative art platforms, replace p5's PRNG with the platform's deterministic random: + +```javascript +// fxhash convention +const SEED = $fx.hash; // unique per mint +const rng = $fx.rand; // deterministic PRNG +$fx.features({ palette: 'warm', complexity: 'high' }); + +// In setup(): +randomSeed(SEED); // for p5's noise() +noiseSeed(SEED); + +// Replace random() with rng() for platform determinism +let x = rng() * width; // instead of random(width) +``` + +See `references/export-pipeline.md` § Platform Export. + +### Color Mode — Use HSB + +HSB (Hue, Saturation, Brightness) is dramatically easier to work with than RGB for generative art: + +```javascript +colorMode(HSB, 360, 100, 100, 100); +// Now: fill(hue, sat, bri, alpha) +// Rotate hue: fill((baseHue + offset) % 360, 80, 90) +// Desaturate: fill(hue, sat * 0.3, bri) +// Darken: fill(hue, sat, bri * 0.5) +``` + +Never hardcode raw RGB values. Define a palette object, derive variations procedurally. See `references/color-systems.md`. + +### Noise — Multi-Octave, Not Raw + +Raw `noise(x, y)` looks like smooth blobs. Layer octaves for natural texture: + +```javascript +function fbm(x, y, octaves = 4) { + let val = 0, amp = 1, freq = 1, sum = 0; + for (let i = 0; i < octaves; i++) { + val += noise(x * freq, y * freq) * amp; + sum += amp; + amp *= 0.5; + freq *= 2; + } + return val / sum; +} +``` + +For flowing organic forms, use **domain warping**: feed noise output back as noise input coordinates. See `references/visual-effects.md`. + +### createGraphics() for Layers — Not Optional + +Flat single-pass rendering looks flat. Use offscreen buffers for composition: + +```javascript +let bgLayer, fgLayer, trailLayer; +function setup() { + createCanvas(1920, 1080); + bgLayer = createGraphics(width, height); + fgLayer = createGraphics(width, height); + trailLayer = createGraphics(width, height); +} +function draw() { + renderBackground(bgLayer); + renderTrails(trailLayer); // persistent, fading + renderForeground(fgLayer); // cleared each frame + image(bgLayer, 0, 0); + image(trailLayer, 0, 0); + image(fgLayer, 0, 0); +} +``` + +### Performance — Vectorize Where Possible + +p5.js draw calls are expensive. For thousands of particles: + +```javascript +// SLOW: individual shapes +for (let p of particles) { + ellipse(p.x, p.y, p.size); +} + +// FAST: single shape with beginShape() +beginShape(POINTS); +for (let p of particles) { + vertex(p.x, p.y); +} +endShape(); + +// FASTEST: pixel buffer for massive counts +loadPixels(); +for (let p of particles) { + let idx = 4 * (floor(p.y) * width + floor(p.x)); + pixels[idx] = r; pixels[idx+1] = g; pixels[idx+2] = b; pixels[idx+3] = 255; +} +updatePixels(); +``` + +See `references/troubleshooting.md` § Performance. + +### Instance Mode for Multiple Sketches + +Global mode pollutes `window`. For production, use instance mode: + +```javascript +const sketch = (p) => { + p.setup = function() { + p.createCanvas(800, 800); + }; + p.draw = function() { + p.background(0); + p.ellipse(p.mouseX, p.mouseY, 50); + }; +}; +new p5(sketch, 'canvas-container'); +``` + +Required when embedding multiple sketches on one page or integrating with frameworks. + +### WebGL Mode Gotchas + +- `createCanvas(w, h, WEBGL)` — origin is center, not top-left +- Y-axis is inverted (positive Y goes up in WEBGL, down in P2D) +- `translate(-width/2, -height/2)` to get P2D-like coordinates +- `push()`/`pop()` around every transform — matrix stack overflows silently +- `texture()` before `rect()`/`plane()` — not after +- Custom shaders: `createShader(vert, frag)` — test on multiple browsers + +### Export — Key Bindings Convention + +Every sketch should include these in `keyPressed()`: + +```javascript +function keyPressed() { + if (key === 's' || key === 'S') saveCanvas('output', 'png'); + if (key === 'g' || key === 'G') saveGif('output', 5); + if (key === 'r' || key === 'R') { randomSeed(millis()); noiseSeed(millis()); } + if (key === ' ') CONFIG.paused = !CONFIG.paused; +} +``` + +### Headless Video Export — Use noLoop() + +For headless rendering via Puppeteer, the sketch **must** use `noLoop()` in setup. Without it, p5's draw loop runs freely while screenshots are slow — the sketch races ahead and you get skipped/duplicate frames. + +```javascript +function setup() { + createCanvas(1920, 1080); + pixelDensity(1); + noLoop(); // capture script controls frame advance + window._p5Ready = true; // signal readiness to capture script +} +``` + +The bundled `scripts/export-frames.js` detects `_p5Ready` and calls `redraw()` once per capture for exact 1:1 frame correspondence. See `references/export-pipeline.md` § Deterministic Capture. + +For multi-scene videos, use the per-clip architecture: one HTML per scene, render independently, stitch with `ffmpeg -f concat`. See `references/export-pipeline.md` § Per-Clip Architecture. + +### Agent Workflow + +When building p5.js sketches: + +1. **Write the HTML file** — single self-contained file, all code inline +2. **Open in browser** — `open sketch.html` (macOS) or `xdg-open sketch.html` (Linux) +3. **Local assets** (fonts, images) require a server: `python3 -m http.server 8080` in the project directory, then open `http://localhost:8080/sketch.html` +4. **Export PNG/GIF** — add `keyPressed()` shortcuts as shown above, tell the user which key to press +5. **Headless export** — `node scripts/export-frames.js sketch.html --frames 300` for automated frame capture (sketch must use `noLoop()` + `_p5Ready`) +6. **MP4 rendering** — `bash scripts/render.sh sketch.html output.mp4 --duration 30` +7. **Iterative refinement** — edit the HTML file, user refreshes browser to see changes +8. **Load references on demand** — use `skill_view(name="p5js", file_path="references/...")` to load specific reference files as needed during implementation + +## Performance Targets + +| Metric | Target | +|--------|--------| +| Frame rate (interactive) | 60fps sustained | +| Frame rate (animated export) | 30fps minimum | +| Particle count (P2D shapes) | 5,000-10,000 at 60fps | +| Particle count (pixel buffer) | 50,000-100,000 at 60fps | +| Canvas resolution | Up to 3840x2160 (export), 1920x1080 (interactive) | +| File size (HTML) | < 100KB (excluding CDN libraries) | +| Load time | < 2s to first frame | + +## References + +| File | Contents | +|------|----------| +| `references/core-api.md` | Canvas setup, coordinate system, draw loop, `push()`/`pop()`, offscreen buffers, composition patterns, `pixelDensity()`, responsive design | +| `references/shapes-and-geometry.md` | 2D primitives, `beginShape()`/`endShape()`, Bezier/Catmull-Rom curves, `vertex()` systems, custom shapes, `p5.Vector`, signed distance fields, SVG path conversion | +| `references/visual-effects.md` | Noise (Perlin, fractal, domain warp, curl), flow fields, particle systems (physics, flocking, trails), pixel manipulation, texture generation (stipple, hatch, halftone), feedback loops, reaction-diffusion | +| `references/animation.md` | Frame-based animation, easing functions, `lerp()`/`map()`, spring physics, state machines, timeline sequencing, `millis()`-based timing, transition patterns | +| `references/typography.md` | `text()`, `loadFont()`, `textToPoints()`, kinetic typography, text masks, font metrics, responsive text sizing | +| `references/color-systems.md` | `colorMode()`, HSB/HSL/RGB, `lerpColor()`, `paletteLerp()`, procedural palettes, color harmony, `blendMode()`, gradient rendering, curated palette library | +| `references/webgl-and-3d.md` | WEBGL renderer, 3D primitives, camera, lighting, materials, custom geometry, GLSL shaders (`createShader()`, `createFilterShader()`), framebuffers, post-processing | +| `references/interaction.md` | Mouse events, keyboard state, touch input, DOM elements, `createSlider()`/`createButton()`, audio input (p5.sound FFT/amplitude), scroll-driven animation, responsive events | +| `references/export-pipeline.md` | `saveCanvas()`, `saveGif()`, `saveFrames()`, deterministic headless capture, ffmpeg frame-to-video, CCapture.js, SVG export, per-clip architecture, platform export (fxhash), video gotchas | +| `references/troubleshooting.md` | Performance profiling, per-pixel budgets, common mistakes, browser compatibility, WebGL debugging, font loading issues, pixel density traps, memory leaks, CORS | +| `templates/viewer.html` | Interactive viewer template: seed navigation (prev/next/random/jump), parameter sliders, download PNG, responsive canvas. Start from this for explorable generative art | diff --git a/skills/creative/p5js/references/animation.md b/skills/creative/p5js/references/animation.md new file mode 100644 index 000000000000..ab3d69c6e506 --- /dev/null +++ b/skills/creative/p5js/references/animation.md @@ -0,0 +1,439 @@ +# Animation + +## Frame-Based Animation + +### The Draw Loop + +```javascript +function draw() { + // Called ~60 times/sec by default + // frameCount — integer, starts at 1 + // deltaTime — ms since last frame (use for framerate-independent motion) + // millis() — ms since sketch start +} +``` + +### Time-Based vs Frame-Based + +```javascript +// Frame-based (speed varies with framerate) +x += speed; + +// Time-based (consistent speed regardless of framerate) +x += speed * (deltaTime / 16.67); // normalized to 60fps +``` + +### Normalized Time + +```javascript +// Progress from 0 to 1 over N seconds +let duration = 5000; // 5 seconds in ms +let t = constrain(millis() / duration, 0, 1); + +// Looping progress (0 → 1 → 0 → 1...) +let period = 3000; // 3 second loop +let t = (millis() % period) / period; + +// Ping-pong (0 → 1 → 0 → 1...) +let raw = (millis() % (period * 2)) / period; +let t = raw <= 1 ? raw : 2 - raw; +``` + +## Easing Functions + +### Built-in Lerp + +```javascript +// Linear interpolation — smooth but mechanical +let x = lerp(startX, endX, t); + +// Map for non-0-1 ranges +let y = map(t, 0, 1, startY, endY); +``` + +### Common Easing Curves + +```javascript +// Ease in (slow start) +function easeInQuad(t) { return t * t; } +function easeInCubic(t) { return t * t * t; } +function easeInExpo(t) { return t === 0 ? 0 : pow(2, 10 * (t - 1)); } + +// Ease out (slow end) +function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); } +function easeOutCubic(t) { return 1 - pow(1 - t, 3); } +function easeOutExpo(t) { return t === 1 ? 1 : 1 - pow(2, -10 * t); } + +// Ease in-out (slow both ends) +function easeInOutCubic(t) { + return t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3) / 2; +} +function easeInOutQuint(t) { + return t < 0.5 ? 16 * t * t * t * t * t : 1 - pow(-2 * t + 2, 5) / 2; +} + +// Elastic (spring overshoot) +function easeOutElastic(t) { + if (t === 0 || t === 1) return t; + return pow(2, -10 * t) * sin((t * 10 - 0.75) * (2 * PI / 3)) + 1; +} + +// Bounce +function easeOutBounce(t) { + if (t < 1/2.75) return 7.5625 * t * t; + else if (t < 2/2.75) { t -= 1.5/2.75; return 7.5625 * t * t + 0.75; } + else if (t < 2.5/2.75) { t -= 2.25/2.75; return 7.5625 * t * t + 0.9375; } + else { t -= 2.625/2.75; return 7.5625 * t * t + 0.984375; } +} + +// Smooth step (Hermite interpolation — great default) +function smoothstep(t) { return t * t * (3 - 2 * t); } + +// Smoother step (Ken Perlin) +function smootherstep(t) { return t * t * t * (t * (t * 6 - 15) + 10); } +``` + +### Applying Easing + +```javascript +// Animate from startVal to endVal over duration ms +function easedValue(startVal, endVal, startTime, duration, easeFn) { + let t = constrain((millis() - startTime) / duration, 0, 1); + return lerp(startVal, endVal, easeFn(t)); +} + +// Usage +let x = easedValue(100, 700, animStartTime, 2000, easeOutCubic); +``` + +## Spring Physics + +More natural than easing — responds to force, overshoots, settles. + +```javascript +class Spring { + constructor(value, target, stiffness = 0.1, damping = 0.7) { + this.value = value; + this.target = target; + this.velocity = 0; + this.stiffness = stiffness; + this.damping = damping; + } + + update() { + let force = (this.target - this.value) * this.stiffness; + this.velocity += force; + this.velocity *= this.damping; + this.value += this.velocity; + return this.value; + } + + setTarget(t) { this.target = t; } + isSettled(threshold = 0.01) { + return abs(this.velocity) < threshold && abs(this.value - this.target) < threshold; + } +} + +// Usage +let springX = new Spring(0, 0, 0.08, 0.85); +function draw() { + springX.setTarget(mouseX); + let x = springX.update(); + ellipse(x, height/2, 50); +} +``` + +### 2D Spring + +```javascript +class Spring2D { + constructor(x, y) { + this.pos = createVector(x, y); + this.target = createVector(x, y); + this.vel = createVector(0, 0); + this.stiffness = 0.08; + this.damping = 0.85; + } + + update() { + let force = p5.Vector.sub(this.target, this.pos).mult(this.stiffness); + this.vel.add(force).mult(this.damping); + this.pos.add(this.vel); + return this.pos; + } +} +``` + +## State Machines + +For complex multi-phase animations. + +```javascript +const STATES = { IDLE: 0, ENTER: 1, ACTIVE: 2, EXIT: 3 }; +let state = STATES.IDLE; +let stateStart = 0; + +function setState(newState) { + state = newState; + stateStart = millis(); +} + +function stateTime() { + return millis() - stateStart; +} + +function draw() { + switch (state) { + case STATES.IDLE: + // waiting... + break; + case STATES.ENTER: + let t = constrain(stateTime() / 1000, 0, 1); + let alpha = easeOutCubic(t) * 255; + // fade in... + if (t >= 1) setState(STATES.ACTIVE); + break; + case STATES.ACTIVE: + // main animation... + break; + case STATES.EXIT: + let t2 = constrain(stateTime() / 500, 0, 1); + // fade out... + if (t2 >= 1) setState(STATES.IDLE); + break; + } +} +``` + +## Timeline Sequencing + +For timed multi-scene animations (motion graphics, title sequences). + +```javascript +class Timeline { + constructor() { + this.events = []; + } + + at(timeMs, duration, fn) { + this.events.push({ start: timeMs, end: timeMs + duration, fn }); + return this; + } + + update() { + let now = millis(); + for (let e of this.events) { + if (now >= e.start && now < e.end) { + let t = (now - e.start) / (e.end - e.start); + e.fn(t); + } + } + } +} + +// Usage +let timeline = new Timeline(); +timeline + .at(0, 2000, (t) => { + // Scene 1: title fade in (0-2s) + let alpha = easeOutCubic(t) * 255; + fill(255, alpha); + textSize(48); + text("Hello", width/2, height/2); + }) + .at(2000, 1000, (t) => { + // Scene 2: title fade out (2-3s) + let alpha = (1 - easeInCubic(t)) * 255; + fill(255, alpha); + textSize(48); + text("Hello", width/2, height/2); + }) + .at(3000, 5000, (t) => { + // Scene 3: main content (3-8s) + renderMainContent(t); + }); + +function draw() { + background(0); + timeline.update(); +} +``` + +## Noise-Driven Motion + +More organic than deterministic animation. + +```javascript +// Smooth wandering position +let x = map(noise(frameCount * 0.005, 0), 0, 1, 0, width); +let y = map(noise(0, frameCount * 0.005), 0, 1, 0, height); + +// Noise-driven rotation +let angle = noise(frameCount * 0.01) * TWO_PI; + +// Noise-driven scale (breathing effect) +let s = map(noise(frameCount * 0.02), 0, 1, 0.8, 1.2); + +// Noise-driven color shift +let hue = map(noise(frameCount * 0.003), 0, 1, 0, 360); +``` + +## Transition Patterns + +### Fade In/Out + +```javascript +function fadeIn(t) { return constrain(t, 0, 1); } +function fadeOut(t) { return constrain(1 - t, 0, 1); } +``` + +### Slide + +```javascript +function slideIn(t, direction = 'left') { + let et = easeOutCubic(t); + switch (direction) { + case 'left': return lerp(-width, 0, et); + case 'right': return lerp(width, 0, et); + case 'up': return lerp(-height, 0, et); + case 'down': return lerp(height, 0, et); + } +} +``` + +### Scale Reveal + +```javascript +function scaleReveal(t) { + let et = easeOutElastic(constrain(t, 0, 1)); + push(); + translate(width/2, height/2); + scale(et); + translate(-width/2, -height/2); + // draw content... + pop(); +} +``` + +### Staggered Entry + +```javascript +// N elements appear one after another +let staggerDelay = 100; // ms between each +for (let i = 0; i < elements.length; i++) { + let itemStart = baseTime + i * staggerDelay; + let t = constrain((millis() - itemStart) / 500, 0, 1); + let alpha = easeOutCubic(t) * 255; + let yOffset = lerp(30, 0, easeOutCubic(t)); + // draw element with alpha and yOffset +} +``` + +## Recording Deterministic Animations + +For frame-perfect export, use frame count instead of millis(): + +```javascript +const TOTAL_FRAMES = 300; // 10 seconds at 30fps +const FPS = 30; + +function draw() { + let t = frameCount / TOTAL_FRAMES; // 0 to 1 over full duration + if (t > 1) { noLoop(); return; } + + // Use t for all animation timing — deterministic + renderFrame(t); + + // Export + if (CONFIG.recording) { + saveCanvas('frame-' + nf(frameCount, 4), 'png'); + } +} +``` + +## Scene Fade Envelopes (Video) + +Every scene in a multi-scene video needs fade-in and fade-out. Hard cuts between visually different generative scenes are jarring. + +```javascript +const SCENE_FRAMES = 150; // 5 seconds at 30fps +const FADE = 15; // half-second fade + +function draw() { + let lf = frameCount - 1; // 0-indexed local frame + let t = lf / SCENE_FRAMES; // 0..1 normalized progress + + // Fade envelope: ramp up at start, ramp down at end + let fade = 1; + if (lf < FADE) fade = lf / FADE; + if (lf > SCENE_FRAMES - FADE) fade = (SCENE_FRAMES - lf) / FADE; + fade = fade * fade * (3 - 2 * fade); // smoothstep for organic feel + + // Apply fade to all visual output + // Option 1: multiply alpha values by fade + fill(r, g, b, alpha * fade); + + // Option 2: tint entire composited image + tint(255, fade * 255); + image(sceneBuffer, 0, 0); + noTint(); + + // Option 3: multiply pixel brightness (for pixel-level scenes) + pixels[i] = r * fade; +} +``` + +## Animating Static Algorithms + +Some generative algorithms produce a single static result (attractors, circle packing, Voronoi). In video, static content reads as frozen/broken. Techniques to add motion: + +### Progressive Reveal + +Expand a mask from center outward to reveal the precomputed result: + +```javascript +let revealRadius = easeOutCubic(min(t * 1.5, 1)) * (width * 0.8); +// In the render loop, skip pixels beyond revealRadius from center +let dx = x - width/2, dy = y - height/2; +if (sqrt(dx*dx + dy*dy) > revealRadius) continue; +// Soft edge: +let edgeFade = constrain((revealRadius - dist) / 40, 0, 1); +``` + +### Parameter Sweep + +Slowly change a parameter to show the algorithm evolving: + +```javascript +// Attractor with drifting parameters +let a = -1.7 + sin(t * 0.5) * 0.2; // oscillate around base value +let b = 1.3 + cos(t * 0.3) * 0.15; +``` + +### Slow Camera Motion + +Apply subtle zoom or rotation to the final image: + +```javascript +push(); +translate(width/2, height/2); +scale(1 + t * 0.05); // slow 5% zoom over scene duration +rotate(t * 0.1); // gentle rotation +translate(-width/2, -height/2); +image(precomputedResult, 0, 0); +pop(); +``` + +### Overlay Dynamic Elements + +Add particles, grain, or subtle noise on top of static content: + +```javascript +// Static background +image(staticResult, 0, 0); +// Dynamic overlay +for (let p of ambientParticles) { + p.update(); + p.display(); // slow-moving specks add life +} +``` diff --git a/skills/creative/p5js/references/color-systems.md b/skills/creative/p5js/references/color-systems.md new file mode 100644 index 000000000000..23980026451c --- /dev/null +++ b/skills/creative/p5js/references/color-systems.md @@ -0,0 +1,352 @@ +# Color Systems + +## Color Modes + +### HSB (Recommended for Generative Art) + +```javascript +colorMode(HSB, 360, 100, 100, 100); +// Hue: 0-360 (color wheel position) +// Saturation: 0-100 (gray to vivid) +// Brightness: 0-100 (black to full) +// Alpha: 0-100 + +fill(200, 80, 90); // blue, vivid, bright +fill(200, 80, 90, 50); // 50% transparent +``` + +HSB advantages: +- Rotate hue: `(baseHue + offset) % 360` +- Desaturate: reduce S +- Darken: reduce B +- Monochrome variations: fix H, vary S and B +- Complementary: `(hue + 180) % 360` +- Analogous: `hue +/- 30` + +### HSL + +```javascript +colorMode(HSL, 360, 100, 100, 100); +// Lightness 50 = pure color, 0 = black, 100 = white +// More intuitive for tints (L > 50) and shades (L < 50) +``` + +### RGB + +```javascript +colorMode(RGB, 255, 255, 255, 255); // default +// Direct channel control, less intuitive for procedural palettes +``` + +## Color Objects + +```javascript +let c = color(200, 80, 90); // create color object +fill(c); + +// Extract components +let h = hue(c); +let s = saturation(c); +let b = brightness(c); +let r = red(c); +let g = green(c); +let bl = blue(c); +let a = alpha(c); + +// Hex colors work everywhere +fill('#e8d5b7'); +fill('#e8d5b7cc'); // with alpha + +// Modify via setters +c.setAlpha(128); +c.setRed(200); +``` + +## Color Interpolation + +### lerpColor + +```javascript +let c1 = color(0, 80, 100); // red +let c2 = color(200, 80, 100); // blue +let mixed = lerpColor(c1, c2, 0.5); // midpoint blend +// Works in current colorMode +``` + +### paletteLerp (p5.js 1.11+) + +Interpolate through multiple colors at once. + +```javascript +let colors = [ + color('#2E0854'), + color('#850E35'), + color('#EE6C4D'), + color('#F5E663') +]; +let c = paletteLerp(colors, t); // t = 0..1, interpolates through all +``` + +### Manual Multi-Stop Gradient + +```javascript +function multiLerp(colors, t) { + t = constrain(t, 0, 1); + let segment = t * (colors.length - 1); + let idx = floor(segment); + let frac = segment - idx; + idx = min(idx, colors.length - 2); + return lerpColor(colors[idx], colors[idx + 1], frac); +} +``` + +## Gradient Rendering + +### Linear Gradient + +```javascript +function linearGradient(x1, y1, x2, y2, c1, c2) { + let steps = dist(x1, y1, x2, y2); + for (let i = 0; i <= steps; i++) { + let t = i / steps; + let c = lerpColor(c1, c2, t); + stroke(c); + let x = lerp(x1, x2, t); + let y = lerp(y1, y2, t); + // Draw perpendicular line at each point + let dx = -(y2 - y1) / steps * 1000; + let dy = (x2 - x1) / steps * 1000; + line(x - dx, y - dy, x + dx, y + dy); + } +} +``` + +### Radial Gradient + +```javascript +function radialGradient(cx, cy, r, innerColor, outerColor) { + noStroke(); + for (let i = r; i > 0; i--) { + let t = 1 - i / r; + fill(lerpColor(innerColor, outerColor, t)); + ellipse(cx, cy, i * 2); + } +} +``` + +### Noise-Based Gradient + +```javascript +function noiseGradient(colors, noiseScale, time) { + loadPixels(); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let n = noise(x * noiseScale, y * noiseScale, time); + let c = multiLerp(colors, n); + let idx = 4 * (y * width + x); + pixels[idx] = red(c); + pixels[idx+1] = green(c); + pixels[idx+2] = blue(c); + pixels[idx+3] = 255; + } + } + updatePixels(); +} +``` + +## Procedural Palette Generation + +### Complementary + +```javascript +function complementary(baseHue) { + return [baseHue, (baseHue + 180) % 360]; +} +``` + +### Analogous + +```javascript +function analogous(baseHue, spread = 30) { + return [ + (baseHue - spread + 360) % 360, + baseHue, + (baseHue + spread) % 360 + ]; +} +``` + +### Triadic + +```javascript +function triadic(baseHue) { + return [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360]; +} +``` + +### Split Complementary + +```javascript +function splitComplementary(baseHue) { + return [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360]; +} +``` + +### Tetradic (Rectangle) + +```javascript +function tetradic(baseHue) { + return [baseHue, (baseHue + 60) % 360, (baseHue + 180) % 360, (baseHue + 240) % 360]; +} +``` + +### Monochromatic Variations + +```javascript +function monoVariations(hue, count = 5) { + let colors = []; + for (let i = 0; i < count; i++) { + let s = map(i, 0, count - 1, 20, 90); + let b = map(i, 0, count - 1, 95, 40); + colors.push(color(hue, s, b)); + } + return colors; +} +``` + +## Curated Palette Library + +### Warm Palettes + +```javascript +const SUNSET = ['#2E0854', '#850E35', '#EE6C4D', '#F5E663']; +const EMBER = ['#1a0000', '#4a0000', '#8b2500', '#cd5c00', '#ffd700']; +const PEACH = ['#fff5eb', '#ffdab9', '#ff9a76', '#ff6b6b', '#c94c4c']; +const COPPER = ['#1c1108', '#3d2b1f', '#7b4b2a', '#b87333', '#daa06d']; +``` + +### Cool Palettes + +```javascript +const OCEAN = ['#0a0e27', '#1a1b4b', '#2a4a7f', '#3d7cb8', '#87ceeb']; +const ARCTIC = ['#0d1b2a', '#1b263b', '#415a77', '#778da9', '#e0e1dd']; +const FOREST = ['#0b1a0b', '#1a3a1a', '#2d5a2d', '#4a8c4a', '#90c990']; +const DEEP_SEA = ['#000814', '#001d3d', '#003566', '#006d77', '#83c5be']; +``` + +### Neutral Palettes + +```javascript +const GRAPHITE = ['#1a1a1a', '#333333', '#555555', '#888888', '#cccccc']; +const CREAM = ['#f4f0e8', '#e8dcc8', '#c9b99a', '#a89070', '#7a6450']; +const SLATE = ['#1e293b', '#334155', '#475569', '#64748b', '#94a3b8']; +``` + +### Vivid Palettes + +```javascript +const NEON = ['#ff00ff', '#00ffff', '#ff0080', '#80ff00', '#0080ff']; +const RAINBOW = ['#ff0000', '#ff8000', '#ffff00', '#00ff00', '#0000ff', '#8000ff']; +const VAPOR = ['#ff71ce', '#01cdfe', '#05ffa1', '#b967ff', '#fffb96']; +const CYBER = ['#0f0f0f', '#00ff41', '#ff0090', '#00d4ff', '#ffd000']; +``` + +### Earth Tones + +```javascript +const TERRA = ['#2c1810', '#5c3a2a', '#8b6b4a', '#c4a672', '#e8d5b7']; +const MOSS = ['#1a1f16', '#3d4a2e', '#6b7c4f', '#9aab7a', '#c8d4a9']; +const CLAY = ['#3b2f2f', '#6b4c4c', '#9e7676', '#c9a0a0', '#e8caca']; +``` + +## Blend Modes + +```javascript +blendMode(BLEND); // default — alpha compositing +blendMode(ADD); // additive — bright glow effects +blendMode(MULTIPLY); // darkening — shadows, texture overlay +blendMode(SCREEN); // lightening — soft glow +blendMode(OVERLAY); // contrast boost — high/low emphasis +blendMode(DIFFERENCE); // color subtraction — psychedelic +blendMode(EXCLUSION); // softer difference +blendMode(REPLACE); // overwrite (no alpha blending) +blendMode(REMOVE); // subtract alpha +blendMode(LIGHTEST); // keep brighter pixel +blendMode(DARKEST); // keep darker pixel +blendMode(BURN); // darken + saturate +blendMode(DODGE); // lighten + saturate +blendMode(SOFT_LIGHT); // subtle overlay +blendMode(HARD_LIGHT); // strong overlay + +// ALWAYS reset after use +blendMode(BLEND); +``` + +### Blend Mode Recipes + +| Effect | Mode | Use case | +|--------|------|----------| +| Additive glow | `ADD` | Light beams, fire, particles | +| Shadow overlay | `MULTIPLY` | Texture, vignette | +| Soft light mix | `SCREEN` | Fog, mist, backlight | +| High contrast | `OVERLAY` | Dramatic compositing | +| Color negative | `DIFFERENCE` | Glitch, psychedelic | +| Layer compositing | `BLEND` | Standard alpha layering | + +## Background Techniques + +### Textured Background + +```javascript +function texturedBackground(baseColor, noiseScale, noiseAmount) { + loadPixels(); + let r = red(baseColor), g = green(baseColor), b = blue(baseColor); + for (let i = 0; i < pixels.length; i += 4) { + let x = (i / 4) % width; + let y = floor((i / 4) / width); + let n = (noise(x * noiseScale, y * noiseScale) - 0.5) * noiseAmount; + pixels[i] = constrain(r + n, 0, 255); + pixels[i+1] = constrain(g + n, 0, 255); + pixels[i+2] = constrain(b + n, 0, 255); + pixels[i+3] = 255; + } + updatePixels(); +} +``` + +### Vignette + +```javascript +function vignette(strength = 0.5, radius = 0.7) { + loadPixels(); + let cx = width / 2, cy = height / 2; + let maxDist = dist(0, 0, cx, cy); + for (let i = 0; i < pixels.length; i += 4) { + let x = (i / 4) % width; + let y = floor((i / 4) / width); + let d = dist(x, y, cx, cy) / maxDist; + let factor = 1.0 - smoothstep(constrain((d - radius) / (1 - radius), 0, 1)) * strength; + pixels[i] *= factor; + pixels[i+1] *= factor; + pixels[i+2] *= factor; + } + updatePixels(); +} + +function smoothstep(t) { return t * t * (3 - 2 * t); } +``` + +### Film Grain + +```javascript +function filmGrain(amount = 30) { + loadPixels(); + for (let i = 0; i < pixels.length; i += 4) { + let grain = random(-amount, amount); + pixels[i] = constrain(pixels[i] + grain, 0, 255); + pixels[i+1] = constrain(pixels[i+1] + grain, 0, 255); + pixels[i+2] = constrain(pixels[i+2] + grain, 0, 255); + } + updatePixels(); +} +``` diff --git a/skills/creative/p5js/references/core-api.md b/skills/creative/p5js/references/core-api.md new file mode 100644 index 000000000000..e76d60274ad1 --- /dev/null +++ b/skills/creative/p5js/references/core-api.md @@ -0,0 +1,410 @@ +# Core API Reference + +## Canvas Setup + +### createCanvas() + +```javascript +// 2D (default renderer) +createCanvas(1920, 1080); + +// WebGL (3D, shaders) +createCanvas(1920, 1080, WEBGL); + +// Responsive +createCanvas(windowWidth, windowHeight); +``` + +### Pixel Density + +High-DPI displays render at 2x by default. This doubles memory usage and halves performance. + +```javascript +// Force 1x for consistent export and performance +pixelDensity(1); + +// Match display (default) — sharp on retina but expensive +pixelDensity(displayDensity()); + +// ALWAYS call before createCanvas() +function setup() { + pixelDensity(1); // first + createCanvas(1920, 1080); // second +} +``` + +For export, always `pixelDensity(1)` and use the exact target resolution. Never rely on device scaling for final output. + +### Responsive Resize + +```javascript +function windowResized() { + resizeCanvas(windowWidth, windowHeight); + // Recreate offscreen buffers at new size + bgLayer = createGraphics(width, height); + // Reinitialize any size-dependent state +} +``` + +## Coordinate System + +### P2D (Default) +- Origin: top-left (0, 0) +- X increases rightward +- Y increases downward +- Angles: radians by default, `angleMode(DEGREES)` to switch + +### WEBGL +- Origin: center of canvas +- X increases rightward, Y increases **upward**, Z increases toward viewer +- To get P2D-like coordinates in WEBGL: `translate(-width/2, -height/2)` + +## Draw Loop + +```javascript +function preload() { + // Load assets before setup — fonts, images, JSON, CSV + // Blocks execution until all loads complete + font = loadFont('font.otf'); + img = loadImage('texture.png'); + data = loadJSON('data.json'); +} + +function setup() { + // Runs once. Create canvas, initialize state. + createCanvas(1920, 1080); + colorMode(HSB, 360, 100, 100, 100); + randomSeed(CONFIG.seed); + noiseSeed(CONFIG.seed); +} + +function draw() { + // Runs every frame (default 60fps). + // Set frameRate(30) in setup() to change. + // Call noLoop() for static sketches (render once). +} +``` + +### Frame Control + +```javascript +frameRate(30); // set target FPS +noLoop(); // stop draw loop (static pieces) +loop(); // restart draw loop +redraw(); // call draw() once (manual refresh) +frameCount // frames since start (integer) +deltaTime // milliseconds since last frame (float) +millis() // milliseconds since sketch started +``` + +## Transform Stack + +Every transform is cumulative. Use `push()`/`pop()` to isolate. + +```javascript +push(); + translate(width / 2, height / 2); + rotate(angle); + scale(1.5); + // draw something at transformed position + ellipse(0, 0, 100, 100); +pop(); +// back to original coordinate system +``` + +### Transform Functions + +| Function | Effect | +|----------|--------| +| `translate(x, y)` | Move origin | +| `rotate(angle)` | Rotate around origin (radians) | +| `scale(s)` / `scale(sx, sy)` | Scale from origin | +| `shearX(angle)` | Skew X axis | +| `shearY(angle)` | Skew Y axis | +| `applyMatrix(a, b, c, d, e, f)` | Arbitrary 2D affine transform | +| `resetMatrix()` | Clear all transforms | + +### Composition Pattern: Rotate Around Center + +```javascript +push(); + translate(cx, cy); // move origin to center + rotate(angle); // rotate around that center + translate(-cx, -cy); // move origin back + // draw at original coordinates, but rotated around (cx, cy) + rect(cx - 50, cy - 50, 100, 100); +pop(); +``` + +## Offscreen Buffers (createGraphics) + +Offscreen buffers are separate canvases you can draw to and composite. Essential for: +- **Layered composition** — background, midground, foreground +- **Persistent trails** — draw to buffer, fade with semi-transparent rect, never clear +- **Masking** — draw mask to buffer, apply with `image()` or pixel operations +- **Post-processing** — render scene to buffer, apply effects, draw to main canvas + +```javascript +let layer; + +function setup() { + createCanvas(1920, 1080); + layer = createGraphics(width, height); +} + +function draw() { + // Draw to offscreen buffer + layer.background(0, 10); // semi-transparent clear = trails + layer.fill(255); + layer.ellipse(mouseX, mouseY, 20); + + // Composite to main canvas + image(layer, 0, 0); +} +``` + +### Trail Effect Pattern + +```javascript +let trailBuffer; + +function setup() { + createCanvas(1920, 1080); + trailBuffer = createGraphics(width, height); + trailBuffer.background(0); +} + +function draw() { + // Fade previous frame (lower alpha = longer trails) + trailBuffer.noStroke(); + trailBuffer.fill(0, 0, 0, 15); // RGBA — 15/255 alpha + trailBuffer.rect(0, 0, width, height); + + // Draw new content + trailBuffer.fill(255); + trailBuffer.ellipse(mouseX, mouseY, 10); + + // Show + image(trailBuffer, 0, 0); +} +``` + +### Multi-Layer Composition + +```javascript +let bgLayer, contentLayer, fxLayer; + +function setup() { + createCanvas(1920, 1080); + bgLayer = createGraphics(width, height); + contentLayer = createGraphics(width, height); + fxLayer = createGraphics(width, height); +} + +function draw() { + // Background — drawn once or slowly evolving + renderBackground(bgLayer); + + // Content — main visual elements + contentLayer.clear(); + renderContent(contentLayer); + + // FX — overlays, vignettes, grain + fxLayer.clear(); + renderEffects(fxLayer); + + // Composite with blend modes + image(bgLayer, 0, 0); + blendMode(ADD); + image(contentLayer, 0, 0); + blendMode(MULTIPLY); + image(fxLayer, 0, 0); + blendMode(BLEND); // reset +} +``` + +## Composition Patterns + +### Grid Layout + +```javascript +let cols = 10, rows = 10; +let cellW = width / cols; +let cellH = height / rows; +for (let i = 0; i < cols; i++) { + for (let j = 0; j < rows; j++) { + let cx = cellW * (i + 0.5); + let cy = cellH * (j + 0.5); + // draw element at (cx, cy) within cell size (cellW, cellH) + } +} +``` + +### Radial Layout + +```javascript +let n = 12; +for (let i = 0; i < n; i++) { + let angle = TWO_PI * i / n; + let r = 300; + let x = width/2 + cos(angle) * r; + let y = height/2 + sin(angle) * r; + // draw element at (x, y) +} +``` + +### Golden Ratio Spiral + +```javascript +let phi = (1 + sqrt(5)) / 2; +let n = 500; +for (let i = 0; i < n; i++) { + let angle = i * TWO_PI / (phi * phi); + let r = sqrt(i) * 10; + let x = width/2 + cos(angle) * r; + let y = height/2 + sin(angle) * r; + let size = map(i, 0, n, 8, 2); + ellipse(x, y, size); +} +``` + +### Margin-Aware Composition + +```javascript +const MARGIN = 80; // pixels from edge +const drawW = width - 2 * MARGIN; +const drawH = height - 2 * MARGIN; + +// Map normalized [0,1] coordinates to drawable area +function mapX(t) { return MARGIN + t * drawW; } +function mapY(t) { return MARGIN + t * drawH; } +``` + +## Random and Noise + +### Seeded Random + +```javascript +randomSeed(42); +let x = random(100); // always same value for seed 42 +let y = random(-1, 1); // range +let item = random(myArray); // random element +``` + +### Gaussian Random + +```javascript +let x = randomGaussian(0, 1); // mean=0, stddev=1 +// Useful for natural-looking distributions +``` + +### Perlin Noise + +```javascript +noiseSeed(42); +noiseDetail(4, 0.5); // 4 octaves, 0.5 falloff + +let v = noise(x * 0.01, y * 0.01); // returns 0.0 to 1.0 +// Scale factor (0.01) controls feature size — smaller = smoother +``` + +## Math Utilities + +| Function | Description | +|----------|-------------| +| `map(v, lo1, hi1, lo2, hi2)` | Remap value between ranges | +| `constrain(v, lo, hi)` | Clamp to range | +| `lerp(a, b, t)` | Linear interpolation | +| `norm(v, lo, hi)` | Normalize to 0-1 | +| `dist(x1, y1, x2, y2)` | Euclidean distance | +| `mag(x, y)` | Vector magnitude | +| `abs()`, `ceil()`, `floor()`, `round()` | Standard math | +| `sq(n)`, `sqrt(n)`, `pow(b, e)` | Powers | +| `sin()`, `cos()`, `tan()`, `atan2()` | Trig (radians) | +| `degrees(r)`, `radians(d)` | Angle conversion | +| `fract(n)` | Fractional part | + +## p5.js 2.0 Changes + +p5.js 2.0 (released Apr 2025, current: 2.2) introduces breaking changes. The p5.js editor defaults to 1.x until Aug 2026. Use 2.x only when you need its features. + +### async setup() replaces preload() + +```javascript +// p5.js 1.x +let img; +function preload() { img = loadImage('cat.jpg'); } +function setup() { createCanvas(800, 800); } + +// p5.js 2.x +let img; +async function setup() { + createCanvas(800, 800); + img = await loadImage('cat.jpg'); +} +``` + +### New Color Modes + +```javascript +colorMode(OKLCH); // perceptually uniform — better gradients +// L: 0-1 (lightness), C: 0-0.4 (chroma), H: 0-360 (hue) +fill(0.7, 0.15, 200); // medium-bright saturated blue + +colorMode(OKLAB); // perceptually uniform, no hue angle +colorMode(HWB); // Hue-Whiteness-Blackness +``` + +### splineVertex() replaces curveVertex() + +No more doubling first/last control points: + +```javascript +// p5.js 1.x — must repeat first and last +beginShape(); +curveVertex(pts[0].x, pts[0].y); // doubled +for (let p of pts) curveVertex(p.x, p.y); +curveVertex(pts[pts.length-1].x, pts[pts.length-1].y); // doubled +endShape(); + +// p5.js 2.x — clean +beginShape(); +for (let p of pts) splineVertex(p.x, p.y); +endShape(); +``` + +### Shader .modify() API + +Modify built-in shaders without writing full GLSL: + +```javascript +let myShader = baseMaterialShader().modify({ + vertexDeclarations: 'uniform float uTime;', + 'vec4 getWorldPosition': `(vec4 pos) { + pos.y += sin(pos.x * 0.1 + uTime) * 20.0; + return pos; + }` +}); +``` + +### Variable Fonts + +```javascript +textWeight(700); // dynamic weight without loading multiple files +``` + +### textToContours() and textToModel() + +```javascript +let contours = font.textToContours('HELLO', 0, 0, 200); +// Returns array of contour arrays (closed paths) + +let geo = font.textToModel('HELLO', 0, 0, 200); +// Returns p5.Geometry for 3D extruded text +``` + +### CDN for p5.js 2.x + +```html + +``` diff --git a/skills/creative/p5js/references/export-pipeline.md b/skills/creative/p5js/references/export-pipeline.md new file mode 100644 index 000000000000..0c111117da6a --- /dev/null +++ b/skills/creative/p5js/references/export-pipeline.md @@ -0,0 +1,566 @@ +# Export Pipeline + +## PNG Export + +### In-Sketch (Keyboard Shortcut) + +```javascript +function keyPressed() { + if (key === 's' || key === 'S') { + saveCanvas('output', 'png'); + // Downloads output.png immediately + } +} +``` + +### Timed Export (Static Generative) + +```javascript +function setup() { + createCanvas(3840, 2160); + pixelDensity(1); + randomSeed(CONFIG.seed); + noiseSeed(CONFIG.seed); + noLoop(); +} + +function draw() { + // ... render everything ... + saveCanvas('output-seed-' + CONFIG.seed, 'png'); +} +``` + +### High-Resolution Export + +For resolutions beyond screen size, use `pixelDensity()` or a large offscreen buffer: + +```javascript +function exportHighRes(scale) { + let buffer = createGraphics(width * scale, height * scale); + buffer.scale(scale); + // Re-render everything to buffer at higher resolution + renderScene(buffer); + buffer.save('highres-output.png'); +} +``` + +### Batch Seed Export + +```javascript +function exportBatch(startSeed, count) { + for (let i = 0; i < count; i++) { + CONFIG.seed = startSeed + i; + randomSeed(CONFIG.seed); + noiseSeed(CONFIG.seed); + // Render + background(0); + renderScene(); + saveCanvas('seed-' + nf(CONFIG.seed, 5), 'png'); + } +} +``` + +## GIF Export + +### saveGif() + +```javascript +function keyPressed() { + if (key === 'g' || key === 'G') { + saveGif('output', 5); + // Captures 5 seconds of animation + // Options: saveGif(filename, duration, options) + } +} + +// With options +saveGif('output', 5, { + delay: 0, // delay before starting capture (seconds) + units: 'seconds' // or 'frames' +}); +``` + +Limitations: +- GIF is 256 colors max — dithering artifacts on gradients +- Large canvases produce huge files +- Use a smaller canvas (640x360) for GIF, higher for PNG/MP4 +- Frame rate is approximate + +### Optimal GIF Settings + +```javascript +// For GIF output, use smaller canvas and lower framerate +function setup() { + createCanvas(640, 360); + frameRate(15); // GIF standard + pixelDensity(1); +} +``` + +## Frame Sequence Export + +### saveFrames() + +```javascript +function keyPressed() { + if (key === 'f') { + saveFrames('frame', 'png', 10, 30); + // 10 seconds, 30 fps → 300 PNG files + // Downloads as individual files (browser may block bulk downloads) + } +} +``` + +### Manual Frame Export (More Control) + +```javascript +let recording = false; +let frameNum = 0; +const TOTAL_FRAMES = 300; + +function keyPressed() { + if (key === 'r') recording = !recording; +} + +function draw() { + // ... render frame ... + + if (recording) { + saveCanvas('frame-' + nf(frameNum, 4), 'png'); + frameNum++; + if (frameNum >= TOTAL_FRAMES) { + recording = false; + noLoop(); + console.log('Recording complete: ' + frameNum + ' frames'); + } + } +} +``` + +### Deterministic Capture (Critical for Video) + +The `noLoop()` + `redraw()` pattern is **required** for frame-perfect headless capture. Without it, p5's draw loop runs freely in Chrome while Puppeteer screenshots are slow — the sketch runs ahead and you get duplicate/missing frames. + +```javascript +function setup() { + createCanvas(1920, 1080); + pixelDensity(1); + noLoop(); // STOP the automatic draw loop + window._p5Ready = true; // Signal to capture script +} + +function draw() { + // This only runs when redraw() is called by the capture script + // frameCount increments exactly once per redraw() +} +``` + +The bundled `scripts/export-frames.js` detects `window._p5Ready` and switches to deterministic mode automatically. Without it, falls back to timed capture (less precise). + +### ffmpeg: Frames to MP4 + +```bash +# Basic encoding +ffmpeg -framerate 30 -i frame-%04d.png -c:v libx264 -pix_fmt yuv420p output.mp4 + +# High quality +ffmpeg -framerate 30 -i frame-%04d.png \ + -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p \ + output.mp4 + +# With audio +ffmpeg -framerate 30 -i frame-%04d.png -i audio.mp3 \ + -c:v libx264 -c:a aac -shortest \ + output.mp4 + +# Loop for social media (3 loops) +ffmpeg -stream_loop 2 -i output.mp4 -c copy output-looped.mp4 +``` + +### Video Export Gotchas + +**YUV420 clips dark values.** H.264 encodes in YUV420 color space, which rounds dark RGB values. Content below RGB(8,8,8) may become pure black. Subtle dark details (dim particle trails, faint noise textures) disappear in the encoded video even though they're visible in the PNG frames. + +**Fix:** Ensure minimum brightness of ~10 for any visible content. Test by encoding a few frames and comparing the MP4 frame vs the source PNG. + +```bash +# Extract a frame from MP4 for comparison +ffmpeg -i output.mp4 -vf "select=eq(n\,100)" -vframes 1 check.png +``` + +**Static frames look broken in video.** If an algorithm produces a single static image (like a pre-computed attractor heatmap), it reads as a freeze/glitch in video. Always add animation even to static content: +- Progressive reveal (expand from center, sweep across) +- Slow parameter drift (rotate color mapping, shift noise offset) +- Camera-like motion (slow zoom, slight pan) +- Overlay animated particles or grain + +**Scene transitions are mandatory.** Hard cuts between visually different scenes are jarring. Use fade envelopes: + +```javascript +const FADE_FRAMES = 15; // half-second at 30fps +let fade = 1; +if (localFrame < FADE_FRAMES) fade = localFrame / FADE_FRAMES; +if (localFrame > SCENE_FRAMES - FADE_FRAMES) fade = (SCENE_FRAMES - localFrame) / FADE_FRAMES; +fade = fade * fade * (3 - 2 * fade); // smoothstep +// Apply: multiply all alpha/brightness by fade +``` + +### Per-Clip Architecture (Multi-Scene Videos) + +For videos with multiple scenes, render each as a separate HTML file + MP4 clip, then stitch with ffmpeg. This enables re-rendering individual scenes without touching the rest. + +**Directory structure:** +``` +project/ +├── capture-scene.js # Shared: node capture-scene.js +├── render-all.sh # Renders all + stitches +├── scenes/ +│ ├── 00-intro.html # Each scene is self-contained +│ ├── 01-particles.html +│ ├── 02-noise.html +│ └── 03-outro.html +└── clips/ + ├── 00-intro.mp4 # Each clip rendered independently + ├── 01-particles.mp4 + ├── 02-noise.mp4 + ├── 03-outro.mp4 + └── concat.txt +``` + +**Stitch clips with ffmpeg concat:** +```bash +# concat.txt (order determines final sequence) +file '00-intro.mp4' +file '01-particles.mp4' +file '02-noise.mp4' +file '03-outro.mp4' + +# Lossless stitch (all clips must have same codec/resolution/fps) +ffmpeg -f concat -safe 0 -i concat.txt -c copy final.mp4 +``` + +**Re-render a single scene:** +```bash +node capture-scene.js scenes/01-particles.html clips/01-particles 150 +ffmpeg -y -framerate 30 -i clips/01-particles/frame-%04d.png \ + -c:v libx264 -preset slow -crf 16 -pix_fmt yuv420p clips/01-particles.mp4 +# Then re-stitch +ffmpeg -y -f concat -safe 0 -i clips/concat.txt -c copy final.mp4 +``` + +**Re-order without re-rendering:** Just change the order in concat.txt and re-stitch. No frames need re-rendering. + +**Each scene HTML must:** +- Call `noLoop()` in setup and set `window._p5Ready = true` +- Use `frameCount`-based timing (not `millis()`) for deterministic output +- Handle its own fade-in/fade-out envelope +- Be fully self-contained (no shared state between scenes) + +### ffmpeg: Frames to GIF (Better Quality) + +```bash +# Generate palette first for optimal colors +ffmpeg -i frame-%04d.png -vf "fps=15,palettegen=max_colors=256" palette.png + +# Render GIF using palette +ffmpeg -i frame-%04d.png -i palette.png \ + -lavfi "fps=15 [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=3" \ + output.gif +``` + +## Headless Export (Puppeteer) + +For automated, server-side, or CI rendering. Uses a headless Chrome browser to run the sketch. + +### export-frames.js (Node.js Script) + +See `scripts/export-frames.js` for the full implementation. Basic pattern: + +```javascript +const puppeteer = require('puppeteer'); + +async function captureFrames(htmlPath, outputDir, options) { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + const page = await browser.newPage(); + + await page.setViewport({ + width: options.width || 1920, + height: options.height || 1080, + deviceScaleFactor: 1 + }); + + await page.goto(`file://${path.resolve(htmlPath)}`, { + waitUntil: 'networkidle0' + }); + + // Wait for sketch to initialize + await page.waitForSelector('canvas'); + await page.waitForTimeout(1000); + + for (let i = 0; i < options.frames; i++) { + const canvas = await page.$('canvas'); + await canvas.screenshot({ + path: path.join(outputDir, `frame-${String(i).padStart(4, '0')}.png`) + }); + + // Advance one frame + await page.evaluate(() => { redraw(); }); + await page.waitForTimeout(1000 / options.fps); + } + + await browser.close(); +} +``` + +### render.sh (Full Pipeline) + +See `scripts/render.sh` for the complete render script. Pipeline: + +``` +1. Launch Puppeteer → open sketch HTML +2. Capture N frames as PNG sequence +3. Pipe to ffmpeg → encode H.264 MP4 +4. Optional: add audio track +5. Clean up temp frames +``` + +## SVG Export + +### Using p5.js-svg Library + +```html + +``` + +```javascript +function setup() { + createCanvas(1920, 1080, SVG); // SVG renderer + noLoop(); +} + +function draw() { + // Only vector operations (no pixels, no blend modes) + stroke(0); + noFill(); + for (let i = 0; i < 100; i++) { + let x = random(width); + let y = random(height); + ellipse(x, y, random(10, 50)); + } + save('output.svg'); +} +``` + +Limitations: +- No `loadPixels()`, `updatePixels()`, `filter()`, `blendMode()` +- No WebGL +- No pixel-level effects +- Great for: line art, geometric patterns, plots + +### Hybrid: Raster Background + SVG Overlay + +Render background effects to PNG, then SVG for crisp vector elements on top. + +## Export Format Decision Guide + +| Need | Format | Method | +|------|--------|--------| +| Single still image | PNG | `saveCanvas()` or `keyPressed()` | +| Print-quality still | PNG (high-res) | `pixelDensity(1)` + large canvas | +| Short animated loop | GIF | `saveGif()` | +| Long animation | MP4 | Frame sequence + ffmpeg | +| Social media video | MP4 | `scripts/render.sh` | +| Vector/print | SVG | p5.js-svg renderer | +| Batch variations | PNG sequence | Seed loop + `saveCanvas()` | +| Interactive deployment | HTML | Single self-contained file | +| Headless rendering | PNG/MP4 | Puppeteer + ffmpeg | + +## Tiling for Ultra-High-Resolution + +For resolutions too large for a single canvas (e.g., 10000x10000 for print): + +```javascript +function renderTiled(totalW, totalH, tileSize) { + let cols = ceil(totalW / tileSize); + let rows = ceil(totalH / tileSize); + + for (let ty = 0; ty < rows; ty++) { + for (let tx = 0; tx < cols; tx++) { + let buffer = createGraphics(tileSize, tileSize); + buffer.push(); + buffer.translate(-tx * tileSize, -ty * tileSize); + renderScene(buffer, totalW, totalH); + buffer.pop(); + buffer.save(`tile-${tx}-${ty}.png`); + buffer.remove(); // free memory + } + } + // Stitch with ImageMagick: + // montage tile-*.png -tile 4x4 -geometry +0+0 final.png +} +``` + +## CCapture.js — Deterministic Video Capture + +The built-in `saveFrames()` has limitations: small frame counts, memory issues, browser download blocking. CCapture.js solves all of these by hooking into the browser's timing functions to simulate constant time steps regardless of actual render speed. + +```html + +``` + +### Basic Setup + +```javascript +let capturer; +let recording = false; + +function setup() { + createCanvas(1920, 1080); + pixelDensity(1); + + capturer = new CCapture({ + format: 'webm', // 'webm', 'gif', 'png', 'jpg' + framerate: 30, + quality: 99, // 0-100 for webm/jpg + // timeLimit: 10, // auto-stop after N seconds + // motionBlurFrames: 4 // supersampled motion blur + }); +} + +function draw() { + // ... render frame ... + + if (recording) { + capturer.capture(document.querySelector('canvas')); + } +} + +function keyPressed() { + if (key === 'c') { + if (!recording) { + capturer.start(); + recording = true; + console.log('Recording started'); + } else { + capturer.stop(); + capturer.save(); // triggers download + recording = false; + console.log('Recording saved'); + } + } +} +``` + +### Format Comparison + +| Format | Quality | Size | Browser Support | +|--------|---------|------|-----------------| +| **WebM** | High | Medium | Chrome only | +| **GIF** | 256 colors | Large | All (via gif.js worker) | +| **PNG sequence** | Lossless | Very large (TAR) | All | +| **JPEG sequence** | Lossy | Large (TAR) | All | + +### Important: Timing Hook + +CCapture.js overrides `Date.now()`, `setTimeout`, `requestAnimationFrame`, and `performance.now()`. This means: +- `millis()` returns simulated time (perfect for recording) +- `deltaTime` is constant (1000/framerate) +- Complex sketches that take 500ms per frame still record at smooth 30fps +- **Caveat**: Audio sync breaks (audio plays in real-time, not simulated time) + +## Programmatic Export (canvas API) + +For custom export workflows beyond `saveCanvas()`: + +```javascript +// Canvas to Blob (for upload, processing) +document.querySelector('canvas').toBlob((blob) => { + // Upload to server, process, etc. + let url = URL.createObjectURL(blob); + console.log('Blob URL:', url); +}, 'image/png'); + +// Canvas to Data URL (for inline embedding) +let dataUrl = document.querySelector('canvas').toDataURL('image/png'); +// Use in or send as base64 +``` + +## SVG Export (p5.js-svg) + +```html + +``` + +```javascript +function setup() { + createCanvas(1920, 1080, SVG); // SVG renderer + noLoop(); +} + +function draw() { + // Only vector operations work (no pixel ops, no blendMode) + stroke(0); + noFill(); + for (let i = 0; i < 100; i++) { + ellipse(random(width), random(height), random(10, 50)); + } + save('output.svg'); +} +``` + +**Critical SVG caveats:** +- **Must call `clear()` in `draw()`** for animated sketches — SVG DOM accumulates child elements, causing memory bloat +- `blendMode()` is **not implemented** in SVG renderer +- `filter()`, `loadPixels()`, `updatePixels()` don't work +- Requires **p5.js 1.11.x** — not compatible with p5.js 2.x +- Perfect for: line art, geometric patterns, pen plotter output + +## Platform Export + +### fxhash Conventions + +```javascript +// Replace p5's random with fxhash's deterministic PRNG +const rng = $fx.rand; + +// Declare features for rarity/filtering +$fx.features({ + 'Palette': paletteName, + 'Complexity': complexity > 0.7 ? 'High' : 'Low', + 'Has Particles': particleCount > 0 +}); + +// Declare on-chain parameters +$fx.params([ + { id: 'density', name: 'Density', type: 'number', + options: { min: 1, max: 100, step: 1 } }, + { id: 'palette', name: 'Palette', type: 'select', + options: { options: ['Warm', 'Cool', 'Mono'] } }, + { id: 'accent', name: 'Accent Color', type: 'color' } +]); + +// Read params +let density = $fx.getParam('density'); + +// Build: npx fxhash build → upload.zip +// Dev: npx fxhash dev → localhost:3300 +``` + +### Art Blocks / Generic Platform + +```javascript +// Platform provides a hash string +const hash = tokenData.hash; // Art Blocks convention + +// Build deterministic PRNG from hash +function prngFromHash(hash) { + let seed = parseInt(hash.slice(0, 16), 16); + // xoshiro128** or similar + return function() { /* ... */ }; +} + +const rng = prngFromHash(hash); +``` diff --git a/skills/creative/p5js/references/interaction.md b/skills/creative/p5js/references/interaction.md new file mode 100644 index 000000000000..5daef7b5009f --- /dev/null +++ b/skills/creative/p5js/references/interaction.md @@ -0,0 +1,398 @@ +# Interaction + +## Mouse Events + +### Continuous State + +```javascript +mouseX, mouseY // current position (relative to canvas) +pmouseX, pmouseY // previous frame position +mouseIsPressed // boolean +mouseButton // LEFT, RIGHT, CENTER (during press) +movedX, movedY // delta since last frame +winMouseX, winMouseY // relative to window (not canvas) +``` + +### Event Callbacks + +```javascript +function mousePressed() { + // fires once on press + // mouseButton tells you which button +} + +function mouseReleased() { + // fires once on release +} + +function mouseClicked() { + // fires after press+release (same element) +} + +function doubleClicked() { + // fires on double-click +} + +function mouseMoved() { + // fires when mouse moves (no button pressed) +} + +function mouseDragged() { + // fires when mouse moves WITH button pressed +} + +function mouseWheel(event) { + // event.delta: positive = scroll down, negative = scroll up + zoom += event.delta * -0.01; + return false; // prevent page scroll +} +``` + +### Mouse Interaction Patterns + +**Spawn on click:** +```javascript +function mousePressed() { + particles.push(new Particle(mouseX, mouseY)); +} +``` + +**Mouse follow with spring:** +```javascript +let springX, springY; +function setup() { + springX = new Spring(width/2, width/2); + springY = new Spring(height/2, height/2); +} +function draw() { + springX.setTarget(mouseX); + springY.setTarget(mouseY); + let x = springX.update(); + let y = springY.update(); + ellipse(x, y, 50); +} +``` + +**Drag interaction:** +```javascript +let dragging = false; +let dragObj = null; +let offsetX, offsetY; + +function mousePressed() { + for (let obj of objects) { + if (dist(mouseX, mouseY, obj.x, obj.y) < obj.radius) { + dragging = true; + dragObj = obj; + offsetX = mouseX - obj.x; + offsetY = mouseY - obj.y; + break; + } + } +} + +function mouseDragged() { + if (dragging && dragObj) { + dragObj.x = mouseX - offsetX; + dragObj.y = mouseY - offsetY; + } +} + +function mouseReleased() { + dragging = false; + dragObj = null; +} +``` + +**Mouse repulsion (particles flee cursor):** +```javascript +function draw() { + let mousePos = createVector(mouseX, mouseY); + for (let p of particles) { + let d = p.pos.dist(mousePos); + if (d < 150) { + let repel = p5.Vector.sub(p.pos, mousePos); + repel.normalize(); + repel.mult(map(d, 0, 150, 5, 0)); + p.applyForce(repel); + } + } +} +``` + +## Keyboard Events + +### State + +```javascript +keyIsPressed // boolean +key // last key as string ('a', 'A', ' ') +keyCode // numeric code (LEFT_ARROW, UP_ARROW, etc.) +``` + +### Event Callbacks + +```javascript +function keyPressed() { + // fires once on press + if (keyCode === LEFT_ARROW) { /* ... */ } + if (key === 's') saveCanvas('output', 'png'); + if (key === ' ') CONFIG.paused = !CONFIG.paused; + return false; // prevent default browser behavior +} + +function keyReleased() { + // fires once on release +} + +function keyTyped() { + // fires for printable characters only (not arrows, shift, etc.) +} +``` + +### Continuous Key State (Multiple Keys) + +```javascript +let keys = {}; + +function keyPressed() { keys[keyCode] = true; } +function keyReleased() { keys[keyCode] = false; } + +function draw() { + if (keys[LEFT_ARROW]) player.x -= 5; + if (keys[RIGHT_ARROW]) player.x += 5; + if (keys[UP_ARROW]) player.y -= 5; + if (keys[DOWN_ARROW]) player.y += 5; +} +``` + +### Key Constants + +``` +LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW +BACKSPACE, DELETE, ENTER, RETURN, TAB, ESCAPE +SHIFT, CONTROL, OPTION, ALT +``` + +## Touch Events + +```javascript +touches // array of { x, y, id } — all current touches + +function touchStarted() { + // fires on first touch + return false; // prevent default (stops scroll on mobile) +} + +function touchMoved() { + // fires on touch drag + return false; +} + +function touchEnded() { + // fires on touch release +} +``` + +### Pinch Zoom + +```javascript +let prevDist = 0; +let zoomLevel = 1; + +function touchMoved() { + if (touches.length === 2) { + let d = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y); + if (prevDist > 0) { + zoomLevel *= d / prevDist; + } + prevDist = d; + } + return false; +} + +function touchEnded() { + prevDist = 0; +} +``` + +## DOM Elements + +### Creating Controls + +```javascript +function setup() { + createCanvas(800, 800); + + // Slider + let slider = createSlider(0, 255, 100, 1); // min, max, default, step + slider.position(10, height + 10); + slider.input(() => { CONFIG.value = slider.value(); }); + + // Button + let btn = createButton('Reset'); + btn.position(10, height + 40); + btn.mousePressed(() => { resetSketch(); }); + + // Checkbox + let check = createCheckbox('Show grid', false); + check.position(10, height + 70); + check.changed(() => { CONFIG.showGrid = check.checked(); }); + + // Select / dropdown + let sel = createSelect(); + sel.position(10, height + 100); + sel.option('Mode A'); + sel.option('Mode B'); + sel.changed(() => { CONFIG.mode = sel.value(); }); + + // Color picker + let picker = createColorPicker('#ff0000'); + picker.position(10, height + 130); + picker.input(() => { CONFIG.color = picker.value(); }); + + // Text input + let inp = createInput('Hello'); + inp.position(10, height + 160); + inp.input(() => { CONFIG.text = inp.value(); }); +} +``` + +### Styling DOM Elements + +```javascript +let slider = createSlider(0, 100, 50); +slider.position(10, 10); +slider.style('width', '200px'); +slider.class('my-slider'); +slider.parent('controls-div'); // attach to specific DOM element +``` + +## Audio Input (p5.sound) + +Requires `p5.sound.min.js` addon. + +```html + +``` + +### Microphone Input + +```javascript +let mic, fft, amplitude; + +function setup() { + createCanvas(800, 800); + userStartAudio(); // required — user gesture to enable audio + + mic = new p5.AudioIn(); + mic.start(); + + fft = new p5.FFT(0.8, 256); // smoothing, bins + fft.setInput(mic); + + amplitude = new p5.Amplitude(); + amplitude.setInput(mic); +} + +function draw() { + let level = amplitude.getLevel(); // 0.0 to 1.0 (overall volume) + let spectrum = fft.analyze(); // array of 256 frequency values (0-255) + let waveform = fft.waveform(); // array of 256 time-domain samples (-1 to 1) + + // Get energy in frequency bands + let bass = fft.getEnergy('bass'); // 20-140 Hz + let lowMid = fft.getEnergy('lowMid'); // 140-400 Hz + let mid = fft.getEnergy('mid'); // 400-2600 Hz + let highMid = fft.getEnergy('highMid'); // 2600-5200 Hz + let treble = fft.getEnergy('treble'); // 5200-14000 Hz + // Each returns 0-255 +} +``` + +### Audio File Playback + +```javascript +let song, fft; + +function preload() { + song = loadSound('track.mp3'); +} + +function setup() { + createCanvas(800, 800); + fft = new p5.FFT(0.8, 512); + fft.setInput(song); +} + +function mousePressed() { + if (song.isPlaying()) { + song.pause(); + } else { + song.play(); + } +} +``` + +### Beat Detection (Simple) + +```javascript +let prevBass = 0; +let beatThreshold = 30; +let beatCooldown = 0; + +function detectBeat() { + let bass = fft.getEnergy('bass'); + let isBeat = bass - prevBass > beatThreshold && beatCooldown <= 0; + prevBass = bass; + if (isBeat) beatCooldown = 10; // frames + beatCooldown--; + return isBeat; +} +``` + +## Scroll-Driven Animation + +```javascript +let scrollProgress = 0; + +function setup() { + let canvas = createCanvas(windowWidth, windowHeight); + canvas.style('position', 'fixed'); + // Make page scrollable + document.body.style.height = '500vh'; +} + +window.addEventListener('scroll', () => { + let maxScroll = document.body.scrollHeight - window.innerHeight; + scrollProgress = window.scrollY / maxScroll; +}); + +function draw() { + background(0); + // Use scrollProgress (0 to 1) to drive animation + let x = lerp(0, width, scrollProgress); + ellipse(x, height/2, 50); +} +``` + +## Responsive Events + +```javascript +function windowResized() { + resizeCanvas(windowWidth, windowHeight); + // Recreate buffers + bgLayer = createGraphics(width, height); + // Recalculate layout + recalculateLayout(); +} + +// Visibility change (tab switching) +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + noLoop(); // pause when tab not visible + } else { + loop(); + } +}); +``` diff --git a/skills/creative/p5js/references/shapes-and-geometry.md b/skills/creative/p5js/references/shapes-and-geometry.md new file mode 100644 index 000000000000..1c177964cb01 --- /dev/null +++ b/skills/creative/p5js/references/shapes-and-geometry.md @@ -0,0 +1,300 @@ +# Shapes and Geometry + +## 2D Primitives + +```javascript +point(x, y); +line(x1, y1, x2, y2); +rect(x, y, w, h); // default: corner mode +rect(x, y, w, h, r); // rounded corners +rect(x, y, w, h, tl, tr, br, bl); // per-corner radius +square(x, y, size); +ellipse(x, y, w, h); +circle(x, y, d); // diameter, not radius +triangle(x1, y1, x2, y2, x3, y3); +quad(x1, y1, x2, y2, x3, y3, x4, y4); +arc(x, y, w, h, start, stop, mode); // mode: OPEN, CHORD, PIE +``` + +### Drawing Modes + +```javascript +rectMode(CENTER); // x,y is center (default: CORNER) +rectMode(CORNERS); // x1,y1 to x2,y2 +ellipseMode(CORNER); // x,y is top-left corner +ellipseMode(CENTER); // default — x,y is center +``` + +## Stroke and Fill + +```javascript +fill(r, g, b, a); // or fill(gray), fill('#hex'), fill(h, s, b) in HSB mode +noFill(); +stroke(r, g, b, a); +noStroke(); +strokeWeight(2); +strokeCap(ROUND); // ROUND, SQUARE, PROJECT +strokeJoin(ROUND); // ROUND, MITER, BEVEL +``` + +## Custom Shapes with Vertices + +### Basic vertex shape + +```javascript +beginShape(); + vertex(100, 100); + vertex(200, 50); + vertex(300, 100); + vertex(250, 200); + vertex(150, 200); +endShape(CLOSE); // CLOSE connects last vertex to first +``` + +### Shape modes + +```javascript +beginShape(); // default: polygon connecting all vertices +beginShape(POINTS); // individual points +beginShape(LINES); // pairs of vertices as lines +beginShape(TRIANGLES); // triplets as triangles +beginShape(TRIANGLE_FAN); +beginShape(TRIANGLE_STRIP); +beginShape(QUADS); // groups of 4 +beginShape(QUAD_STRIP); +``` + +### Contours (holes in shapes) + +```javascript +beginShape(); + // outer shape + vertex(100, 100); + vertex(300, 100); + vertex(300, 300); + vertex(100, 300); + // inner hole + beginContour(); + vertex(150, 150); + vertex(150, 250); + vertex(250, 250); + vertex(250, 150); + endContour(); +endShape(CLOSE); +``` + +## Bezier Curves + +### Cubic Bezier + +```javascript +bezier(x1, y1, cx1, cy1, cx2, cy2, x2, y2); +// x1,y1 = start point +// cx1,cy1 = first control point +// cx2,cy2 = second control point +// x2,y2 = end point +``` + +### Bezier in custom shapes + +```javascript +beginShape(); + vertex(100, 200); + bezierVertex(150, 50, 250, 50, 300, 200); + // control1, control2, endpoint +endShape(); +``` + +### Quadratic Bezier + +```javascript +beginShape(); + vertex(100, 200); + quadraticVertex(200, 50, 300, 200); + // single control point + endpoint +endShape(); +``` + +### Interpolation along Bezier + +```javascript +let x = bezierPoint(x1, cx1, cx2, x2, t); // t = 0..1 +let y = bezierPoint(y1, cy1, cy2, y2, t); +let tx = bezierTangent(x1, cx1, cx2, x2, t); // tangent +``` + +## Catmull-Rom Splines + +```javascript +curve(cpx1, cpy1, x1, y1, x2, y2, cpx2, cpy2); +// cpx1,cpy1 = control point before start +// x1,y1 = start point (visible) +// x2,y2 = end point (visible) +// cpx2,cpy2 = control point after end + +curveVertex(x, y); // in beginShape() — smooth curve through all points +curveTightness(0); // 0 = Catmull-Rom, 1 = straight lines, -1 = loose +``` + +### Smooth curve through points + +```javascript +let points = [/* array of {x, y} */]; +beginShape(); + curveVertex(points[0].x, points[0].y); // repeat first for tangent + for (let p of points) { + curveVertex(p.x, p.y); + } + curveVertex(points[points.length-1].x, points[points.length-1].y); // repeat last +endShape(); +``` + +## p5.Vector + +Essential for physics, particle systems, and geometric computation. + +```javascript +let v = createVector(x, y); + +// Arithmetic (modifies in place) +v.add(other); // vector addition +v.sub(other); // subtraction +v.mult(scalar); // scale +v.div(scalar); // inverse scale +v.normalize(); // unit vector (length 1) +v.limit(max); // cap magnitude +v.setMag(len); // set exact magnitude + +// Queries (non-destructive) +v.mag(); // magnitude (length) +v.magSq(); // squared magnitude (faster, no sqrt) +v.heading(); // angle in radians +v.dist(other); // distance to other vector +v.dot(other); // dot product +v.cross(other); // cross product (3D) +v.angleBetween(other); // angle between vectors + +// Static methods (return new vector) +p5.Vector.add(a, b); // a + b → new vector +p5.Vector.sub(a, b); // a - b → new vector +p5.Vector.fromAngle(a); // unit vector at angle +p5.Vector.random2D(); // random unit vector +p5.Vector.lerp(a, b, t); // interpolate + +// Copy +let copy = v.copy(); +``` + +## Signed Distance Fields (2D) + +SDFs return the distance from a point to the nearest edge of a shape. Negative inside, positive outside. Useful for smooth shapes, glow effects, boolean operations. + +```javascript +// Circle SDF +function sdCircle(px, py, cx, cy, r) { + return dist(px, py, cx, cy) - r; +} + +// Box SDF +function sdBox(px, py, cx, cy, hw, hh) { + let dx = abs(px - cx) - hw; + let dy = abs(py - cy) - hh; + return sqrt(max(dx, 0) ** 2 + max(dy, 0) ** 2) + min(max(dx, dy), 0); +} + +// Line segment SDF +function sdSegment(px, py, ax, ay, bx, by) { + let pa = createVector(px - ax, py - ay); + let ba = createVector(bx - ax, by - ay); + let t = constrain(pa.dot(ba) / ba.dot(ba), 0, 1); + let closest = p5.Vector.add(createVector(ax, ay), p5.Vector.mult(ba, t)); + return dist(px, py, closest.x, closest.y); +} + +// Smooth boolean union +function opSmoothUnion(d1, d2, k) { + let h = constrain(0.5 + 0.5 * (d2 - d1) / k, 0, 1); + return lerp(d2, d1, h) - k * h * (1 - h); +} + +// Rendering SDF as glow +let d = sdCircle(x, y, width/2, height/2, 200); +let glow = exp(-abs(d) * 0.02); // exponential falloff +fill(glow * 255); +``` + +## Useful Geometry Patterns + +### Regular Polygon + +```javascript +function regularPolygon(cx, cy, r, sides) { + beginShape(); + for (let i = 0; i < sides; i++) { + let a = TWO_PI * i / sides - HALF_PI; + vertex(cx + cos(a) * r, cy + sin(a) * r); + } + endShape(CLOSE); +} +``` + +### Star Shape + +```javascript +function star(cx, cy, r1, r2, npoints) { + beginShape(); + let angle = TWO_PI / npoints; + let halfAngle = angle / 2; + for (let a = -HALF_PI; a < TWO_PI - HALF_PI; a += angle) { + vertex(cx + cos(a) * r2, cy + sin(a) * r2); + vertex(cx + cos(a + halfAngle) * r1, cy + sin(a + halfAngle) * r1); + } + endShape(CLOSE); +} +``` + +### Rounded Line (Capsule) + +```javascript +function capsule(x1, y1, x2, y2, weight) { + strokeWeight(weight); + strokeCap(ROUND); + line(x1, y1, x2, y2); +} +``` + +### Soft Body / Blob + +```javascript +function blob(cx, cy, baseR, noiseScale, noiseOffset, detail = 64) { + beginShape(); + for (let i = 0; i < detail; i++) { + let a = TWO_PI * i / detail; + let r = baseR + noise(cos(a) * noiseScale + noiseOffset, + sin(a) * noiseScale + noiseOffset) * baseR * 0.4; + vertex(cx + cos(a) * r, cy + sin(a) * r); + } + endShape(CLOSE); +} +``` + +## Clipping and Masking + +```javascript +// Clip shape — everything drawn after is masked by the clip shape +beginClip(); + circle(width/2, height/2, 400); +endClip(); +// Only content inside the circle is visible +image(myImage, 0, 0); + +// Or functional form +clip(() => { + circle(width/2, height/2, 400); +}); + +// Erase mode — cut holes +erase(); + circle(mouseX, mouseY, 100); // this area becomes transparent +noErase(); +``` diff --git a/skills/creative/p5js/references/troubleshooting.md b/skills/creative/p5js/references/troubleshooting.md new file mode 100644 index 000000000000..d27b6c486a87 --- /dev/null +++ b/skills/creative/p5js/references/troubleshooting.md @@ -0,0 +1,532 @@ +# Troubleshooting + +## Performance + +### Step Zero — Disable FES + +The Friendly Error System (FES) adds massive overhead — up to 10x slowdown. Disable it in every production sketch: + +```javascript +// BEFORE any p5 code +p5.disableFriendlyErrors = true; + +// Or use p5.min.js instead of p5.js — FES is stripped from minified build +``` + +### Step One — pixelDensity(1) + +Retina/HiDPI displays default to 2x or 3x density, multiplying pixel count by 4-9x: + +```javascript +function setup() { + pixelDensity(1); // force 1:1 — always do this first + createCanvas(1920, 1080); +} +``` + +### Use Math.* in Hot Loops + +p5's `sin()`, `cos()`, `random()`, `min()`, `max()`, `abs()` are wrapper functions with overhead. In hot loops (thousands of iterations per frame), use native `Math.*`: + +```javascript +// SLOW — p5 wrappers +for (let p of particles) { + let a = sin(p.angle); + let d = dist(p.x, p.y, mx, my); +} + +// FAST — native Math +for (let p of particles) { + let a = Math.sin(p.angle); + let dx = p.x - mx, dy = p.y - my; + let dSq = dx * dx + dy * dy; // skip sqrt entirely +} +``` + +Use `magSq()` instead of `mag()` for distance comparisons — avoids expensive `sqrt()`. + +### Diagnosis + +Open Chrome DevTools > Performance tab > Record while sketch runs. + +Common bottlenecks: +1. **FES enabled** — 10x overhead on every p5 function call +2. **pixelDensity > 1** — 4x pixel count, 4x slower +3. **Too many draw calls** — thousands of `ellipse()`, `rect()` per frame +4. **Large canvas + pixel operations** — `loadPixels()`/`updatePixels()` on 4K canvas +5. **Unoptimized particle systems** — checking all-vs-all distances (O(n^2)) +6. **Memory leaks** — creating objects every frame without cleanup +7. **Shader compilation** — calling `createShader()` in `draw()` instead of `setup()` +8. **console.log() in draw()** — DOM write per frame, destroys performance +9. **DOM manipulation in draw()** — layout thrashing (400-500x slower than canvas ops) + +### Solutions + +**Reduce draw calls:** +```javascript +// BAD: 10000 individual circles +for (let p of particles) { + ellipse(p.x, p.y, p.size); +} + +// GOOD: single shape with vertices +beginShape(POINTS); +for (let p of particles) { + vertex(p.x, p.y); +} +endShape(); + +// BEST: direct pixel manipulation +loadPixels(); +for (let p of particles) { + let idx = 4 * (floor(p.y) * width + floor(p.x)); + pixels[idx] = p.r; + pixels[idx+1] = p.g; + pixels[idx+2] = p.b; + pixels[idx+3] = 255; +} +updatePixels(); +``` + +**Spatial hashing for neighbor queries:** +```javascript +class SpatialHash { + constructor(cellSize) { + this.cellSize = cellSize; + this.cells = new Map(); + } + + clear() { this.cells.clear(); } + + _key(x, y) { + return `${floor(x / this.cellSize)},${floor(y / this.cellSize)}`; + } + + insert(obj) { + let key = this._key(obj.pos.x, obj.pos.y); + if (!this.cells.has(key)) this.cells.set(key, []); + this.cells.get(key).push(obj); + } + + query(x, y, radius) { + let results = []; + let minCX = floor((x - radius) / this.cellSize); + let maxCX = floor((x + radius) / this.cellSize); + let minCY = floor((y - radius) / this.cellSize); + let maxCY = floor((y + radius) / this.cellSize); + + for (let cx = minCX; cx <= maxCX; cx++) { + for (let cy = minCY; cy <= maxCY; cy++) { + let key = `${cx},${cy}`; + let cell = this.cells.get(key); + if (cell) { + for (let obj of cell) { + if (dist(x, y, obj.pos.x, obj.pos.y) <= radius) { + results.push(obj); + } + } + } + } + } + return results; + } +} +``` + +**Object pooling:** +```javascript +class ParticlePool { + constructor(maxSize) { + this.pool = []; + this.active = []; + for (let i = 0; i < maxSize; i++) { + this.pool.push(new Particle(0, 0)); + } + } + + spawn(x, y) { + let p = this.pool.pop(); + if (p) { + p.reset(x, y); + this.active.push(p); + } + } + + update() { + for (let i = this.active.length - 1; i >= 0; i--) { + this.active[i].update(); + if (this.active[i].isDead()) { + this.pool.push(this.active.splice(i, 1)[0]); + } + } + } +} +``` + +**Throttle heavy operations:** +```javascript +// Only update flow field every N frames +if (frameCount % 5 === 0) { + flowField.update(frameCount * 0.001); +} +``` + +### Frame Rate Targets + +| Context | Target | Acceptable | +|---------|--------|------------| +| Interactive sketch | 60fps | 30fps | +| Ambient animation | 30fps | 20fps | +| Export/recording | 30fps render | Any (offline) | +| Mobile | 30fps | 20fps | + +### Per-Pixel Rendering Budgets + +Pixel-level operations (`loadPixels()` loops) are the most expensive common pattern. Budget depends on canvas size and computation per pixel. + +| Canvas | Pixels | Simple noise (1 call) | fBM (4 octave) | Domain warp (3-layer fBM) | +|--------|--------|----------------------|----------------|--------------------------| +| 540x540 | 291K | ~5ms | ~20ms | ~80ms | +| 1080x1080 | 1.17M | ~20ms | ~80ms | ~300ms+ | +| 1920x1080 | 2.07M | ~35ms | ~140ms | ~500ms+ | +| 3840x2160 | 8.3M | ~140ms | ~560ms | WILL CRASH | + +**Rules of thumb:** +- 1 `noise()` call per pixel at 1080x1080 = ~20ms/frame (OK at 30fps) +- 4-octave fBM per pixel at 1080x1080 = ~80ms/frame (borderline) +- Multi-layer domain warp at 1080x1080 = 300ms+ (too slow for real-time, fine for `noLoop()` export) +- **Headless Chrome is 2-5x slower** than desktop Chrome for pixel ops + +**Solution: render at lower resolution, fill blocks:** +```javascript +let step = 3; // render 1/9 of pixels, fill 3x3 blocks +loadPixels(); +for (let y = 0; y < H; y += step) { + for (let x = 0; x < W; x += step) { + let v = expensiveNoise(x, y); + for (let dy = 0; dy < step && y+dy < H; dy++) + for (let dx = 0; dx < step && x+dx < W; dx++) { + let i = 4 * ((y+dy) * W + (x+dx)); + pixels[i] = v; pixels[i+1] = v; pixels[i+2] = v; pixels[i+3] = 255; + } + } +} +updatePixels(); +``` + +Step=2 gives 4x speedup. Step=3 gives 9x. Visible at 1080p but acceptable for video (motion hides it). + +## Common Mistakes + +### 1. Forgetting to reset blend mode + +```javascript +blendMode(ADD); +image(glowLayer, 0, 0); +// WRONG: everything after this is ADD blended +blendMode(BLEND); // ALWAYS reset +``` + +### 2. Creating objects in draw() + +```javascript +// BAD: creates new font object every frame +function draw() { + let f = loadFont('font.otf'); // NEVER load in draw() +} + +// GOOD: load in preload, use in draw +let f; +function preload() { f = loadFont('font.otf'); } +``` + +### 3. Not using push()/pop() with transforms + +```javascript +// BAD: transforms accumulate +translate(100, 0); +rotate(0.1); +ellipse(0, 0, 50); +// Everything after this is also translated and rotated + +// GOOD: isolated transforms +push(); +translate(100, 0); +rotate(0.1); +ellipse(0, 0, 50); +pop(); +``` + +### 4. Integer coordinates for crisp lines + +```javascript +// BLURRY: sub-pixel rendering +line(10.5, 20.3, 100.7, 80.2); + +// CRISP: integer + 0.5 for 1px lines +line(10.5, 20.5, 100.5, 80.5); // on pixel boundary +``` + +### 5. Pixel density confusion + +```javascript +// WRONG: assuming pixel array matches canvas dimensions +loadPixels(); +let idx = 4 * (y * width + x); // wrong if pixelDensity > 1 + +// RIGHT: account for pixel density +let d = pixelDensity(); +loadPixels(); +let idx = 4 * ((y * d) * (width * d) + (x * d)); + +// SIMPLEST: set pixelDensity(1) at the start +``` + +### 6. Color mode confusion + +```javascript +// In HSB mode, fill(255) is NOT white +colorMode(HSB, 360, 100, 100); +fill(255); // This is hue=255, sat=100, bri=100 = vivid purple + +// White in HSB: +fill(0, 0, 100); // any hue, 0 saturation, 100 brightness + +// Black in HSB: +fill(0, 0, 0); +``` + +### 7. WebGL origin is center + +```javascript +// In WEBGL mode, (0,0) is CENTER, not top-left +function draw() { + // This draws at the center, not the corner + rect(0, 0, 100, 100); + + // For top-left behavior: + translate(-width/2, -height/2); + rect(0, 0, 100, 100); // now at top-left +} +``` + +### 8. createGraphics cleanup + +```javascript +// BAD: memory leak — buffer never freed +function draw() { + let temp = createGraphics(width, height); // new buffer every frame! + // ... +} + +// GOOD: create once, reuse +let temp; +function setup() { + temp = createGraphics(width, height); +} +function draw() { + temp.clear(); + // ... reuse temp +} + +// If you must create/destroy: +temp.remove(); // explicitly free +``` + +### 9. noise() returns 0-1, not -1 to 1 + +```javascript +let n = noise(x); // 0.0 to 1.0 (biased toward 0.5) + +// For -1 to 1 range: +let n = noise(x) * 2 - 1; + +// For a specific range: +let n = map(noise(x), 0, 1, -100, 100); +``` + +### 10. saveCanvas() in draw() saves every frame + +```javascript +// BAD: saves a PNG every single frame +function draw() { + // ... render ... + saveCanvas('output', 'png'); // DON'T DO THIS +} + +// GOOD: save once via keyboard +function keyPressed() { + if (key === 's') saveCanvas('output', 'png'); +} + +// GOOD: save once after rendering static piece +function draw() { + // ... render ... + saveCanvas('output', 'png'); + noLoop(); // stop after saving +} +``` + +### 11. console.log() in draw() + +```javascript +// BAD: writes to DOM console every frame — massive overhead +function draw() { + console.log(particles.length); // 60 DOM writes/second +} + +// GOOD: log periodically or conditionally +function draw() { + if (frameCount % 60 === 0) console.log('FPS:', frameRate().toFixed(1)); +} +``` + +### 12. DOM manipulation in draw() + +```javascript +// BAD: layout thrashing — 400-500x slower than canvas ops +function draw() { + document.getElementById('counter').innerText = frameCount; + let el = document.querySelector('.info'); // DOM query per frame +} + +// GOOD: cache DOM refs, update infrequently +let counterEl; +function setup() { counterEl = document.getElementById('counter'); } +function draw() { + if (frameCount % 30 === 0) counterEl.innerText = frameCount; +} +``` + +### 13. Not disabling FES in production + +```javascript +// BAD: every p5 function call has error-checking overhead (up to 10x slower) +function setup() { createCanvas(800, 800); } + +// GOOD: disable before any p5 code +p5.disableFriendlyErrors = true; +function setup() { createCanvas(800, 800); } + +// ALSO GOOD: use p5.min.js (FES stripped from minified build) +``` + +## Browser Compatibility + +### Safari Issues +- WebGL shader precision: always declare `precision mediump float;` +- `AudioContext` requires user gesture (`userStartAudio()`) +- Some `blendMode()` options behave differently + +### Firefox Issues +- `textToPoints()` may return slightly different point counts +- WebGL extensions may differ from Chrome +- Color profile handling can shift colors + +### Mobile Issues +- Touch events need `return false` to prevent scroll +- `devicePixelRatio` can be 2x or 3x — use `pixelDensity(1)` for performance +- Smaller canvas recommended (720p or less) +- Audio requires explicit user gesture to start + +## CORS Issues + +```javascript +// Loading images/fonts from external URLs requires CORS headers +// Local files need a server: +// python3 -m http.server 8080 + +// Or use a CORS proxy for external resources (not recommended for production) +``` + +## Memory Leaks + +### Symptoms +- Framerate degrading over time +- Browser tab memory growing unbounded +- Page becomes unresponsive after minutes + +### Common Causes + +```javascript +// 1. Growing arrays +let history = []; +function draw() { + history.push(someData); // grows forever +} +// FIX: cap the array +if (history.length > 1000) history.shift(); + +// 2. Creating p5 objects in draw() +function draw() { + let v = createVector(0, 0); // allocation every frame +} +// FIX: reuse pre-allocated objects + +// 3. Unreleased graphics buffers +let layers = []; +function reset() { + for (let l of layers) l.remove(); // free old buffers + layers = []; +} + +// 4. Event listener accumulation +function setup() { + // BAD: adds new listener every time setup runs + window.addEventListener('resize', handler); +} +// FIX: use p5's built-in windowResized() +``` + +## Debugging Tips + +### Console Logging + +```javascript +// Log once (not every frame) +if (frameCount === 1) { + console.log('Canvas:', width, 'x', height); + console.log('Pixel density:', pixelDensity()); + console.log('Renderer:', drawingContext.constructor.name); +} + +// Log periodically +if (frameCount % 60 === 0) { + console.log('FPS:', frameRate().toFixed(1)); + console.log('Particles:', particles.length); +} +``` + +### Visual Debugging + +```javascript +// Show frame rate +function draw() { + // ... your sketch ... + if (CONFIG.debug) { + fill(255, 0, 0); + noStroke(); + textSize(14); + textAlign(LEFT, TOP); + text('FPS: ' + frameRate().toFixed(1), 10, 10); + text('Particles: ' + particles.length, 10, 28); + text('Frame: ' + frameCount, 10, 46); + } +} + +// Toggle debug with 'd' key +function keyPressed() { + if (key === 'd') CONFIG.debug = !CONFIG.debug; +} +``` + +### Isolating Issues + +```javascript +// Comment out layers to find the slow one +function draw() { + renderBackground(); // comment out to test + // renderParticles(); // this might be slow + // renderPostEffects(); // or this +} +``` diff --git a/skills/creative/p5js/references/typography.md b/skills/creative/p5js/references/typography.md new file mode 100644 index 000000000000..15782dea4008 --- /dev/null +++ b/skills/creative/p5js/references/typography.md @@ -0,0 +1,302 @@ +# Typography + +## Loading Fonts + +### System Fonts + +```javascript +textFont('Helvetica'); +textFont('Georgia'); +textFont('monospace'); +``` + +### Custom Fonts (OTF/TTF/WOFF2) + +```javascript +let myFont; + +function preload() { + myFont = loadFont('path/to/font.otf'); + // Requires local server or CORS-enabled URL +} + +function setup() { + textFont(myFont); +} +``` + +### Google Fonts via CSS + +```html + + +``` + +Google Fonts work without `loadFont()` but only for `text()` — not for `textToPoints()`. For particle text, you need `loadFont()` with an OTF/TTF file. + +## Text Rendering + +### Basic Text + +```javascript +textSize(32); +textAlign(CENTER, CENTER); +text('Hello World', width/2, height/2); +``` + +### Text Properties + +```javascript +textSize(48); // pixel size +textAlign(LEFT, TOP); // horizontal: LEFT, CENTER, RIGHT + // vertical: TOP, CENTER, BOTTOM, BASELINE +textLeading(40); // line spacing (for multi-line text) +textStyle(BOLD); // NORMAL, BOLD, ITALIC, BOLDITALIC +textWrap(WORD); // WORD or CHAR (for text() with max width) +``` + +### Text Metrics + +```javascript +let w = textWidth('Hello'); // pixel width of string +let a = textAscent(); // height above baseline +let d = textDescent(); // height below baseline +let totalH = a + d; // full line height +``` + +### Text Bounding Box + +```javascript +let bounds = myFont.textBounds('Hello', x, y, size); +// bounds = { x, y, w, h } +// Useful for positioning, collision, background rectangles +``` + +### Multi-Line Text + +```javascript +// With max width — auto wraps +textWrap(WORD); +text('Long text that wraps within the given width', x, y, maxWidth); + +// With max width AND height — clips +text('Very long text', x, y, maxWidth, maxHeight); +``` + +## textToPoints() — Text as Particles + +Convert text outline to array of points. Requires a loaded font (OTF/TTF via `loadFont()`). + +```javascript +let font; +let points; + +function preload() { + font = loadFont('font.otf'); // MUST be loadFont, not CSS +} + +function setup() { + createCanvas(1200, 600); + points = font.textToPoints('HELLO', 100, 400, 200, { + sampleFactor: 0.1, // lower = more points (0.1-0.5 typical) + simplifyThreshold: 0 + }); +} + +function draw() { + background(0); + for (let pt of points) { + let n = noise(pt.x * 0.01, pt.y * 0.01, frameCount * 0.01); + fill(255, n * 255); + noStroke(); + ellipse(pt.x + random(-2, 2), pt.y + random(-2, 2), 3); + } +} +``` + +### Particle Text Class + +```javascript +class TextParticle { + constructor(target) { + this.target = createVector(target.x, target.y); + this.pos = createVector(random(width), random(height)); + this.vel = createVector(0, 0); + this.acc = createVector(0, 0); + this.maxSpeed = 10; + this.maxForce = 0.5; + } + + arrive() { + let desired = p5.Vector.sub(this.target, this.pos); + let d = desired.mag(); + let speed = d < 100 ? map(d, 0, 100, 0, this.maxSpeed) : this.maxSpeed; + desired.setMag(speed); + let steer = p5.Vector.sub(desired, this.vel); + steer.limit(this.maxForce); + this.acc.add(steer); + } + + flee(target, radius) { + let d = this.pos.dist(target); + if (d < radius) { + let desired = p5.Vector.sub(this.pos, target); + desired.setMag(this.maxSpeed); + let steer = p5.Vector.sub(desired, this.vel); + steer.limit(this.maxForce * 2); + this.acc.add(steer); + } + } + + update() { + this.vel.add(this.acc); + this.vel.limit(this.maxSpeed); + this.pos.add(this.vel); + this.acc.mult(0); + } + + display() { + fill(255); + noStroke(); + ellipse(this.pos.x, this.pos.y, 3); + } +} + +// Usage: particles form text, scatter from mouse +let textParticles = []; +for (let pt of points) { + textParticles.push(new TextParticle(pt)); +} + +function draw() { + background(0); + for (let p of textParticles) { + p.arrive(); + p.flee(createVector(mouseX, mouseY), 80); + p.update(); + p.display(); + } +} +``` + +## Kinetic Typography + +### Wave Text + +```javascript +function waveText(str, x, y, size, amplitude, frequency) { + textSize(size); + textAlign(LEFT, BASELINE); + let xOff = 0; + for (let i = 0; i < str.length; i++) { + let yOff = sin(frameCount * 0.05 + i * frequency) * amplitude; + text(str[i], x + xOff, y + yOff); + xOff += textWidth(str[i]); + } +} +``` + +### Typewriter Effect + +```javascript +class Typewriter { + constructor(str, x, y, speed = 50) { + this.str = str; + this.x = x; + this.y = y; + this.speed = speed; // ms per character + this.startTime = millis(); + this.cursor = true; + } + + display() { + let elapsed = millis() - this.startTime; + let chars = min(floor(elapsed / this.speed), this.str.length); + let visible = this.str.substring(0, chars); + + textAlign(LEFT, TOP); + text(visible, this.x, this.y); + + // Blinking cursor + if (chars < this.str.length && floor(millis() / 500) % 2 === 0) { + let cursorX = this.x + textWidth(visible); + line(cursorX, this.y, cursorX, this.y + textAscent() + textDescent()); + } + } + + isDone() { return millis() - this.startTime >= this.str.length * this.speed; } +} +``` + +### Character-by-Character Animation + +```javascript +function animatedText(str, x, y, size, delay = 50) { + textSize(size); + textAlign(LEFT, BASELINE); + let xOff = 0; + + for (let i = 0; i < str.length; i++) { + let charStart = i * delay; + let t = constrain((millis() - charStart) / 500, 0, 1); + let et = easeOutElastic(t); + + push(); + translate(x + xOff, y); + scale(et); + let alpha = t * 255; + fill(255, alpha); + text(str[i], 0, 0); + pop(); + + xOff += textWidth(str[i]); + } +} +``` + +## Text as Mask + +```javascript +let textBuffer; + +function setup() { + createCanvas(800, 800); + textBuffer = createGraphics(width, height); + textBuffer.background(0); + textBuffer.fill(255); + textBuffer.textSize(200); + textBuffer.textAlign(CENTER, CENTER); + textBuffer.text('MASK', width/2, height/2); +} + +function draw() { + // Draw content + background(0); + // ... render something colorful + + // Apply text mask (show content only where text is white) + loadPixels(); + textBuffer.loadPixels(); + for (let i = 0; i < pixels.length; i += 4) { + let maskVal = textBuffer.pixels[i]; // white = show, black = hide + pixels[i + 3] = maskVal; // set alpha from mask + } + updatePixels(); +} +``` + +## Responsive Text Sizing + +```javascript +function responsiveTextSize(baseSize, baseWidth = 1920) { + return baseSize * (width / baseWidth); +} + +// Usage +textSize(responsiveTextSize(48)); +text('Scales with canvas', width/2, height/2); +``` diff --git a/skills/creative/p5js/references/visual-effects.md b/skills/creative/p5js/references/visual-effects.md new file mode 100644 index 000000000000..1e8a95ffd9ea --- /dev/null +++ b/skills/creative/p5js/references/visual-effects.md @@ -0,0 +1,895 @@ +# Visual Effects + +## Noise + +### Perlin Noise Basics + +```javascript +noiseSeed(42); +noiseDetail(4, 0.5); // octaves, falloff + +// 1D noise — smooth undulation +let y = noise(x * 0.01); // returns 0.0 to 1.0 + +// 2D noise — terrain/texture +let v = noise(x * 0.005, y * 0.005); + +// 3D noise — animated 2D field (z = time) +let v = noise(x * 0.005, y * 0.005, frameCount * 0.005); +``` + +The scale factor (0.005 etc.) is critical: +- `0.001` — very smooth, large features +- `0.005` — smooth, medium features +- `0.01` — standard generative art scale +- `0.05` — detailed, small features +- `0.1` — near-random, grainy + +### Fractal Brownian Motion (fBM) + +Layered noise octaves for natural-looking texture. Each octave adds detail at smaller scale. + +```javascript +function fbm(x, y, octaves = 6, lacunarity = 2.0, gain = 0.5) { + let value = 0; + let amplitude = 1.0; + let frequency = 1.0; + let maxValue = 0; + for (let i = 0; i < octaves; i++) { + value += noise(x * frequency, y * frequency) * amplitude; + maxValue += amplitude; + amplitude *= gain; + frequency *= lacunarity; + } + return value / maxValue; +} +``` + +### Domain Warping + +Feed noise output back as input coordinates for flowing organic distortion. + +```javascript +function domainWarp(x, y, scale, strength, time) { + // First warp pass + let qx = fbm(x + 0.0, y + 0.0); + let qy = fbm(x + 5.2, y + 1.3); + + // Second warp pass (feed back) + let rx = fbm(x + strength * qx + 1.7, y + strength * qy + 9.2, 4, 2, 0.5); + let ry = fbm(x + strength * qx + 8.3, y + strength * qy + 2.8, 4, 2, 0.5); + + return fbm(x + strength * rx + time, y + strength * ry + time); +} +``` + +### Curl Noise + +Divergence-free noise field. Particles following curl noise never converge or diverge — they flow in smooth, swirling patterns. + +```javascript +function curlNoise(x, y, scale, time) { + let eps = 0.001; + // Partial derivatives via finite differences + let dndx = (noise(x * scale + eps, y * scale, time) - + noise(x * scale - eps, y * scale, time)) / (2 * eps); + let dndy = (noise(x * scale, y * scale + eps, time) - + noise(x * scale, y * scale - eps, time)) / (2 * eps); + // Curl = perpendicular to gradient + return createVector(dndy, -dndx); +} +``` + +## Flow Fields + +A grid of vectors that steer particles. The foundational generative art technique. + +```javascript +class FlowField { + constructor(resolution, noiseScale) { + this.resolution = resolution; + this.cols = ceil(width / resolution); + this.rows = ceil(height / resolution); + this.field = new Array(this.cols * this.rows); + this.noiseScale = noiseScale; + } + + update(time) { + for (let i = 0; i < this.cols; i++) { + for (let j = 0; j < this.rows; j++) { + let angle = noise(i * this.noiseScale, j * this.noiseScale, time) * TWO_PI * 2; + this.field[i + j * this.cols] = p5.Vector.fromAngle(angle); + } + } + } + + lookup(x, y) { + let col = constrain(floor(x / this.resolution), 0, this.cols - 1); + let row = constrain(floor(y / this.resolution), 0, this.rows - 1); + return this.field[col + row * this.cols].copy(); + } +} +``` + +### Flow Field Particle + +```javascript +class FlowParticle { + constructor(x, y) { + this.pos = createVector(x, y); + this.vel = createVector(0, 0); + this.acc = createVector(0, 0); + this.prev = this.pos.copy(); + this.maxSpeed = 2; + this.life = 1.0; + } + + follow(field) { + let force = field.lookup(this.pos.x, this.pos.y); + force.mult(0.5); // force magnitude + this.acc.add(force); + } + + update() { + this.prev = this.pos.copy(); + this.vel.add(this.acc); + this.vel.limit(this.maxSpeed); + this.pos.add(this.vel); + this.acc.mult(0); + this.life -= 0.001; + } + + edges() { + if (this.pos.x > width) this.pos.x = 0; + if (this.pos.x < 0) this.pos.x = width; + if (this.pos.y > height) this.pos.y = 0; + if (this.pos.y < 0) this.pos.y = height; + this.prev = this.pos.copy(); // prevent wrap line + } + + display(buffer) { + buffer.stroke(255, this.life * 30); + buffer.strokeWeight(0.5); + buffer.line(this.prev.x, this.prev.y, this.pos.x, this.pos.y); + } +} +``` + +## Particle Systems + +### Basic Physics Particle + +```javascript +class Particle { + constructor(x, y) { + this.pos = createVector(x, y); + this.vel = p5.Vector.random2D().mult(random(1, 3)); + this.acc = createVector(0, 0); + this.life = 255; + this.decay = random(1, 5); + this.size = random(3, 8); + } + + applyForce(f) { this.acc.add(f); } + + update() { + this.vel.add(this.acc); + this.pos.add(this.vel); + this.acc.mult(0); + this.life -= this.decay; + } + + display() { + noStroke(); + fill(255, this.life); + ellipse(this.pos.x, this.pos.y, this.size); + } + + isDead() { return this.life <= 0; } +} +``` + +### Attractor-Driven Particles + +```javascript +class Attractor { + constructor(x, y, strength) { + this.pos = createVector(x, y); + this.strength = strength; + } + + attract(particle) { + let force = p5.Vector.sub(this.pos, particle.pos); + let d = constrain(force.mag(), 5, 200); + force.normalize(); + force.mult(this.strength / (d * d)); + particle.applyForce(force); + } +} +``` + +### Boid Flocking + +```javascript +class Boid { + constructor(x, y) { + this.pos = createVector(x, y); + this.vel = p5.Vector.random2D().mult(random(2, 4)); + this.acc = createVector(0, 0); + this.maxForce = 0.2; + this.maxSpeed = 4; + this.perceptionRadius = 50; + } + + flock(boids) { + let alignment = createVector(0, 0); + let cohesion = createVector(0, 0); + let separation = createVector(0, 0); + let total = 0; + + for (let other of boids) { + let d = this.pos.dist(other.pos); + if (other !== this && d < this.perceptionRadius) { + alignment.add(other.vel); + cohesion.add(other.pos); + let diff = p5.Vector.sub(this.pos, other.pos); + diff.div(d * d); + separation.add(diff); + total++; + } + } + if (total > 0) { + alignment.div(total).setMag(this.maxSpeed).sub(this.vel).limit(this.maxForce); + cohesion.div(total).sub(this.pos).setMag(this.maxSpeed).sub(this.vel).limit(this.maxForce); + separation.div(total).setMag(this.maxSpeed).sub(this.vel).limit(this.maxForce); + } + + this.acc.add(alignment.mult(1.0)); + this.acc.add(cohesion.mult(1.0)); + this.acc.add(separation.mult(1.5)); + } + + update() { + this.vel.add(this.acc); + this.vel.limit(this.maxSpeed); + this.pos.add(this.vel); + this.acc.mult(0); + } +} +``` + +## Pixel Manipulation + +### Reading and Writing Pixels + +```javascript +loadPixels(); +for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let idx = 4 * (y * width + x); + let r = pixels[idx]; + let g = pixels[idx + 1]; + let b = pixels[idx + 2]; + let a = pixels[idx + 3]; + + // Modify + pixels[idx] = 255 - r; // invert red + pixels[idx + 1] = 255 - g; // invert green + pixels[idx + 2] = 255 - b; // invert blue + } +} +updatePixels(); +``` + +### Pixel-Level Noise Texture + +```javascript +loadPixels(); +for (let i = 0; i < pixels.length; i += 4) { + let x = (i / 4) % width; + let y = floor((i / 4) / width); + let n = noise(x * 0.01, y * 0.01, frameCount * 0.02); + let c = n * 255; + pixels[i] = c; + pixels[i + 1] = c; + pixels[i + 2] = c; + pixels[i + 3] = 255; +} +updatePixels(); +``` + +### Built-in Filters + +```javascript +filter(BLUR, 3); // Gaussian blur (radius) +filter(THRESHOLD, 0.5); // Black/white threshold +filter(INVERT); // Color inversion +filter(POSTERIZE, 4); // Reduce color levels +filter(GRAY); // Desaturate +filter(ERODE); // Thin bright areas +filter(DILATE); // Expand bright areas +filter(OPAQUE); // Remove transparency +``` + +## Texture Generation + +### Stippling / Pointillism + +```javascript +function stipple(buffer, density, minSize, maxSize) { + buffer.loadPixels(); + for (let i = 0; i < density; i++) { + let x = floor(random(width)); + let y = floor(random(height)); + let idx = 4 * (y * width + x); + let brightness = (buffer.pixels[idx] + buffer.pixels[idx+1] + buffer.pixels[idx+2]) / 3; + let size = map(brightness, 0, 255, maxSize, minSize); + if (random() < map(brightness, 0, 255, 0.8, 0.1)) { + noStroke(); + fill(buffer.pixels[idx], buffer.pixels[idx+1], buffer.pixels[idx+2]); + ellipse(x, y, size); + } + } +} +``` + +### Halftone + +```javascript +function halftone(sourceBuffer, dotSpacing, maxDotSize) { + sourceBuffer.loadPixels(); + background(255); + fill(0); + noStroke(); + for (let y = 0; y < height; y += dotSpacing) { + for (let x = 0; x < width; x += dotSpacing) { + let idx = 4 * (y * width + x); + let brightness = (sourceBuffer.pixels[idx] + sourceBuffer.pixels[idx+1] + sourceBuffer.pixels[idx+2]) / 3; + let dotSize = map(brightness, 0, 255, maxDotSize, 0); + ellipse(x + dotSpacing/2, y + dotSpacing/2, dotSize); + } + } +} +``` + +### Cross-Hatching + +```javascript +function crossHatch(x, y, w, h, value, spacing) { + // value: 0 (dark) to 1 (light) + let numLayers = floor(map(value, 0, 1, 4, 0)); + let angles = [PI/4, -PI/4, 0, PI/2]; + + for (let layer = 0; layer < numLayers; layer++) { + push(); + translate(x + w/2, y + h/2); + rotate(angles[layer]); + let s = spacing + layer * 2; + for (let i = -max(w, h); i < max(w, h); i += s) { + line(i, -max(w, h), i, max(w, h)); + } + pop(); + } +} +``` + +## Feedback Loops + +### Frame Feedback (Echo/Trail) + +```javascript +let feedback; + +function setup() { + createCanvas(800, 800); + feedback = createGraphics(width, height); +} + +function draw() { + // Copy current feedback, slightly zoomed and rotated + let temp = feedback.get(); + + feedback.push(); + feedback.translate(width/2, height/2); + feedback.scale(1.005); // slow zoom + feedback.rotate(0.002); // slow rotation + feedback.translate(-width/2, -height/2); + feedback.tint(255, 245); // slight fade + feedback.image(temp, 0, 0); + feedback.pop(); + + // Draw new content to feedback + feedback.noStroke(); + feedback.fill(255); + feedback.ellipse(mouseX, mouseY, 20); + + // Show + image(feedback, 0, 0); +} +``` + +### Bloom / Glow (Post-Processing) + +Downsample the scene to a small buffer, blur it, overlay additively. Creates soft glow around bright areas. This is the standard generative art bloom technique. + +```javascript +let scene, bloomBuf; + +function setup() { + createCanvas(1080, 1080); + scene = createGraphics(width, height); + bloomBuf = createGraphics(width, height); +} + +function draw() { + // 1. Render scene to offscreen buffer + scene.background(0); + scene.fill(255, 200, 100); + scene.noStroke(); + // ... draw bright elements to scene ... + + // 2. Build bloom: downsample → blur → upscale + bloomBuf.clear(); + bloomBuf.image(scene, 0, 0, width / 4, height / 4); // 4x downsample + bloomBuf.filter(BLUR, 6); // blur the small version + + // 3. Composite: scene + additive bloom + background(0); + image(scene, 0, 0); // base layer + blendMode(ADD); // additive = glow + tint(255, 80); // control bloom intensity (0-255) + image(bloomBuf, 0, 0, width, height); // upscale back to full size + noTint(); + blendMode(BLEND); // ALWAYS reset blend mode +} +``` + +**Tuning:** +- Downsample ratio (1/4 is standard, 1/8 for softer, 1/2 for tighter) +- Blur radius (4-8 typical, higher = wider glow) +- Tint alpha (40-120, controls glow intensity) +- Update bloom every N frames to save perf: `if (frameCount % 2 === 0) { ... }` + +**Common mistake:** Forgetting `blendMode(BLEND)` after the ADD pass — everything drawn after will be additive. + +### Trail Buffer Brightness + +Trail accumulation via `createGraphics()` + semi-transparent fade rect is the standard technique for particle trails, but **trails are always dimmer than you expect**. The fade rect's alpha compounds multiplicatively every frame. + +```javascript +// The fade rect alpha controls trail length AND brightness: +trailBuf.fill(0, 0, 0, alpha); +trailBuf.rect(0, 0, width, height); + +// alpha=5 → very long trails, very dim (content fades to 50% in ~35 frames) +// alpha=10 → long trails, dim +// alpha=20 → medium trails, visible +// alpha=40 → short trails, bright +// alpha=80 → very short trails, crisp +``` + +**The trap:** You set alpha=5 for long trails, but particle strokes at alpha=30 are invisible because they fade before accumulating enough density. Either: +- **Boost stroke alpha** to 80-150 (not the intuitive 20-40) +- **Reduce fade alpha** but accept shorter trails +- **Use additive blending** for the strokes: bright particles accumulate, dim ones stay dark + +```javascript +// WRONG: low fade + low stroke = invisible +trailBuf.fill(0, 0, 0, 5); // long trails +trailBuf.rect(0, 0, W, H); +trailBuf.stroke(255, 30); // too dim to ever accumulate +trailBuf.line(px, py, x, y); + +// RIGHT: low fade + high stroke = visible long trails +trailBuf.fill(0, 0, 0, 5); +trailBuf.rect(0, 0, W, H); +trailBuf.stroke(255, 100); // bright enough to persist through fade +trailBuf.line(px, py, x, y); +``` + +### Reaction-Diffusion (Gray-Scott) + +```javascript +class ReactionDiffusion { + constructor(w, h) { + this.w = w; + this.h = h; + this.a = new Float32Array(w * h).fill(1); + this.b = new Float32Array(w * h).fill(0); + this.nextA = new Float32Array(w * h); + this.nextB = new Float32Array(w * h); + this.dA = 1.0; + this.dB = 0.5; + this.feed = 0.055; + this.kill = 0.062; + } + + seed(cx, cy, r) { + for (let y = cy - r; y < cy + r; y++) { + for (let x = cx - r; x < cx + r; x++) { + if (dist(x, y, cx, cy) < r) { + let idx = y * this.w + x; + this.b[idx] = 1; + } + } + } + } + + step() { + for (let y = 1; y < this.h - 1; y++) { + for (let x = 1; x < this.w - 1; x++) { + let idx = y * this.w + x; + let a = this.a[idx], b = this.b[idx]; + let lapA = this.laplacian(this.a, x, y); + let lapB = this.laplacian(this.b, x, y); + let abb = a * b * b; + this.nextA[idx] = constrain(a + this.dA * lapA - abb + this.feed * (1 - a), 0, 1); + this.nextB[idx] = constrain(b + this.dB * lapB + abb - (this.kill + this.feed) * b, 0, 1); + } + } + [this.a, this.nextA] = [this.nextA, this.a]; + [this.b, this.nextB] = [this.nextB, this.b]; + } + + laplacian(arr, x, y) { + let w = this.w; + return arr[(y-1)*w+x] + arr[(y+1)*w+x] + arr[y*w+(x-1)] + arr[y*w+(x+1)] + - 4 * arr[y*w+x]; + } +} +``` + +## Pixel Sorting + +```javascript +function pixelSort(buffer, threshold, direction = 'horizontal') { + buffer.loadPixels(); + let px = buffer.pixels; + + if (direction === 'horizontal') { + for (let y = 0; y < height; y++) { + let spans = findSpans(px, y, width, threshold, true); + for (let span of spans) { + sortSpan(px, span.start, span.end, y, true); + } + } + } + buffer.updatePixels(); +} + +function findSpans(px, row, w, threshold, horizontal) { + let spans = []; + let start = -1; + for (let i = 0; i < w; i++) { + let idx = horizontal ? 4 * (row * w + i) : 4 * (i * w + row); + let brightness = (px[idx] + px[idx+1] + px[idx+2]) / 3; + if (brightness > threshold && start === -1) { + start = i; + } else if (brightness <= threshold && start !== -1) { + spans.push({ start, end: i }); + start = -1; + } + } + if (start !== -1) spans.push({ start, end: w }); + return spans; +} +``` + +## Advanced Generative Techniques + +### L-Systems (Lindenmayer Systems) + +Grammar-based recursive growth for trees, plants, fractals. + +```javascript +class LSystem { + constructor(axiom, rules) { + this.axiom = axiom; + this.rules = rules; // { 'F': 'F[+F]F[-F]F' } + this.sentence = axiom; + } + + generate(iterations) { + for (let i = 0; i < iterations; i++) { + let next = ''; + for (let ch of this.sentence) { + next += this.rules[ch] || ch; + } + this.sentence = next; + } + } + + draw(len, angle) { + for (let ch of this.sentence) { + switch (ch) { + case 'F': line(0, 0, 0, -len); translate(0, -len); break; + case '+': rotate(angle); break; + case '-': rotate(-angle); break; + case '[': push(); break; + case ']': pop(); break; + } + } + } +} + +// Usage: fractal plant +let lsys = new LSystem('X', { + 'X': 'F+[[X]-X]-F[-FX]+X', + 'F': 'FF' +}); +lsys.generate(5); +translate(width/2, height); +lsys.draw(4, radians(25)); +``` + +### Circle Packing + +Fill a space with non-overlapping circles of varying size. + +```javascript +class PackedCircle { + constructor(x, y, r) { + this.x = x; this.y = y; this.r = r; + this.growing = true; + } + + grow() { if (this.growing) this.r += 0.5; } + + overlaps(other) { + let d = dist(this.x, this.y, other.x, other.y); + return d < this.r + other.r + 2; // +2 gap + } + + atEdge() { + return this.x - this.r < 0 || this.x + this.r > width || + this.y - this.r < 0 || this.y + this.r > height; + } +} + +let circles = []; + +function packStep() { + // Try to place new circle + for (let attempts = 0; attempts < 100; attempts++) { + let x = random(width), y = random(height); + let valid = true; + for (let c of circles) { + if (dist(x, y, c.x, c.y) < c.r + 2) { valid = false; break; } + } + if (valid) { circles.push(new PackedCircle(x, y, 1)); break; } + } + + // Grow existing circles + for (let c of circles) { + if (!c.growing) continue; + c.grow(); + if (c.atEdge()) { c.growing = false; continue; } + for (let other of circles) { + if (c !== other && c.overlaps(other)) { c.growing = false; break; } + } + } +} +``` + +### Voronoi Diagram (Fortune's Algorithm Approximation) + +```javascript +// Simple brute-force Voronoi (for small point counts) +function drawVoronoi(points, colors) { + loadPixels(); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let minDist = Infinity; + let closest = 0; + for (let i = 0; i < points.length; i++) { + let d = (x - points[i].x) ** 2 + (y - points[i].y) ** 2; // magSq + if (d < minDist) { minDist = d; closest = i; } + } + let idx = 4 * (y * width + x); + let c = colors[closest % colors.length]; + pixels[idx] = red(c); + pixels[idx+1] = green(c); + pixels[idx+2] = blue(c); + pixels[idx+3] = 255; + } + } + updatePixels(); +} +``` + +### Fractal Trees + +```javascript +function fractalTree(x, y, len, angle, depth, branchAngle) { + if (depth <= 0 || len < 2) return; + + let x2 = x + Math.cos(angle) * len; + let y2 = y + Math.sin(angle) * len; + + strokeWeight(map(depth, 0, 10, 0.5, 4)); + line(x, y, x2, y2); + + let shrink = 0.67 + noise(x * 0.01, y * 0.01) * 0.15; + fractalTree(x2, y2, len * shrink, angle - branchAngle, depth - 1, branchAngle); + fractalTree(x2, y2, len * shrink, angle + branchAngle, depth - 1, branchAngle); +} + +// Usage +fractalTree(width/2, height, 120, -HALF_PI, 10, PI/6); +``` + +### Strange Attractors + +```javascript +// Clifford Attractor +function cliffordAttractor(a, b, c, d, iterations) { + let x = 0, y = 0; + beginShape(POINTS); + for (let i = 0; i < iterations; i++) { + let nx = Math.sin(a * y) + c * Math.cos(a * x); + let ny = Math.sin(b * x) + d * Math.cos(b * y); + x = nx; y = ny; + let px = map(x, -3, 3, 0, width); + let py = map(y, -3, 3, 0, height); + vertex(px, py); + } + endShape(); +} + +// De Jong Attractor +function deJongAttractor(a, b, c, d, iterations) { + let x = 0, y = 0; + beginShape(POINTS); + for (let i = 0; i < iterations; i++) { + let nx = Math.sin(a * y) - Math.cos(b * x); + let ny = Math.sin(c * x) - Math.cos(d * y); + x = nx; y = ny; + let px = map(x, -2.5, 2.5, 0, width); + let py = map(y, -2.5, 2.5, 0, height); + vertex(px, py); + } + endShape(); +} +``` + +### Poisson Disk Sampling + +Even distribution that looks natural — better than pure random for placing elements. + +```javascript +function poissonDiskSampling(r, k = 30) { + let cellSize = r / Math.sqrt(2); + let cols = Math.ceil(width / cellSize); + let rows = Math.ceil(height / cellSize); + let grid = new Array(cols * rows).fill(-1); + let points = []; + let active = []; + + function gridIndex(x, y) { + return Math.floor(x / cellSize) + Math.floor(y / cellSize) * cols; + } + + // Seed + let p0 = createVector(random(width), random(height)); + points.push(p0); + active.push(p0); + grid[gridIndex(p0.x, p0.y)] = 0; + + while (active.length > 0) { + let idx = Math.floor(Math.random() * active.length); + let pos = active[idx]; + let found = false; + + for (let n = 0; n < k; n++) { + let angle = Math.random() * TWO_PI; + let mag = r + Math.random() * r; + let sample = createVector(pos.x + Math.cos(angle) * mag, pos.y + Math.sin(angle) * mag); + + if (sample.x < 0 || sample.x >= width || sample.y < 0 || sample.y >= height) continue; + + let col = Math.floor(sample.x / cellSize); + let row = Math.floor(sample.y / cellSize); + let ok = true; + + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + let nc = col + dx, nr = row + dy; + if (nc >= 0 && nc < cols && nr >= 0 && nr < rows) { + let gi = nc + nr * cols; + if (grid[gi] !== -1 && points[grid[gi]].dist(sample) < r) { ok = false; } + } + } + } + + if (ok) { + points.push(sample); + active.push(sample); + grid[gridIndex(sample.x, sample.y)] = points.length - 1; + found = true; + break; + } + } + if (!found) active.splice(idx, 1); + } + return points; +} +``` + +## Addon Libraries + +### p5.brush — Natural Media + +Hand-drawn, organic aesthetics. Watercolor, charcoal, pen, marker. Requires **p5.js 2.x + WEBGL**. + +```html + +``` + +```javascript +function setup() { + createCanvas(1200, 1200, WEBGL); + brush.scaleBrushes(3); // essential for proper sizing + translate(-width/2, -height/2); // WEBGL origin is center + brush.pick('2B'); // pencil brush + brush.stroke(50, 50, 50); + brush.strokeWeight(2); + brush.line(100, 100, 500, 500); + brush.pick('watercolor'); + brush.fill('#4a90d9', 150); + brush.circle(400, 400, 200); +} +``` + +Built-in brushes: `2B`, `HB`, `2H`, `cpencil`, `pen`, `rotring`, `spray`, `marker`, `charcoal`, `hatch_brush`. +Built-in vector fields: `hand`, `curved`, `zigzag`, `waves`, `seabed`, `spiral`, `columns`. + +### p5.grain — Film Grain & Texture + +```html + +``` + +```javascript +function draw() { + // ... render scene ... + applyMonochromaticGrain(42); // uniform grain + // or: applyChromaticGrain(42); // per-channel randomization +} +``` + +### CCapture.js — Deterministic Video Capture + +Records canvas at fixed framerate regardless of actual render speed. Essential for complex generative art. + +```html + +``` + +```javascript +let capturer; + +function setup() { + createCanvas(1920, 1080); + capturer = new CCapture({ + format: 'webm', + framerate: 60, + quality: 99, + // timeLimit: 10, // auto-stop after N seconds + // motionBlurFrames: 4 // supersampled motion blur + }); +} + +function startRecording() { + capturer.start(); +} + +function draw() { + // ... render frame ... + if (capturer) capturer.capture(document.querySelector('canvas')); +} + +function stopRecording() { + capturer.stop(); + capturer.save(); // triggers download +} +``` diff --git a/skills/creative/p5js/references/webgl-and-3d.md b/skills/creative/p5js/references/webgl-and-3d.md new file mode 100644 index 000000000000..848091e4931d --- /dev/null +++ b/skills/creative/p5js/references/webgl-and-3d.md @@ -0,0 +1,423 @@ +# WebGL and 3D + +## WebGL Mode Setup + +```javascript +function setup() { + createCanvas(1920, 1080, WEBGL); + // Origin is CENTER, not top-left + // Y-axis points UP (opposite of 2D mode) + // Z-axis points toward viewer +} +``` + +### Coordinate Conversion (WEBGL to P2D-like) + +```javascript +function draw() { + translate(-width/2, -height/2); // shift origin to top-left + // Now coordinates work like P2D +} +``` + +## 3D Primitives + +```javascript +box(w, h, d); // rectangular prism +sphere(radius, detailX, detailY); +cylinder(radius, height, detailX, detailY); +cone(radius, height, detailX, detailY); +torus(radius, tubeRadius, detailX, detailY); +plane(width, height); // flat rectangle +ellipsoid(rx, ry, rz); // stretched sphere +``` + +### 3D Transforms + +```javascript +push(); + translate(x, y, z); + rotateX(angleX); + rotateY(angleY); + rotateZ(angleZ); + scale(s); + box(100); +pop(); +``` + +## Camera + +### Default Camera + +```javascript +camera( + eyeX, eyeY, eyeZ, // camera position + centerX, centerY, centerZ, // look-at target + upX, upY, upZ // up direction +); + +// Default: camera(0, 0, (height/2)/tan(PI/6), 0, 0, 0, 0, 1, 0) +``` + +### Orbit Control + +```javascript +function draw() { + orbitControl(); // mouse drag to rotate, scroll to zoom + box(200); +} +``` + +### createCamera + +```javascript +let cam; + +function setup() { + createCanvas(800, 800, WEBGL); + cam = createCamera(); + cam.setPosition(300, -200, 500); + cam.lookAt(0, 0, 0); +} + +// Camera methods +cam.setPosition(x, y, z); +cam.lookAt(x, y, z); +cam.move(dx, dy, dz); // relative to camera orientation +cam.pan(angle); // horizontal rotation +cam.tilt(angle); // vertical rotation +cam.roll(angle); // z-axis rotation +cam.slerp(otherCam, t); // smooth interpolation between cameras +``` + +### Perspective and Orthographic + +```javascript +// Perspective (default) +perspective(fov, aspect, near, far); +// fov: field of view in radians (PI/3 default) +// aspect: width/height +// near/far: clipping planes + +// Orthographic (no depth foreshortening) +ortho(-width/2, width/2, -height/2, height/2, 0, 2000); +``` + +## Lighting + +```javascript +// Ambient (uniform, no direction) +ambientLight(50, 50, 50); // dim fill light + +// Directional (parallel rays, like sun) +directionalLight(255, 255, 255, 0, -1, 0); // color + direction + +// Point (radiates from position) +pointLight(255, 200, 150, 200, -300, 400); // color + position + +// Spot (cone from position toward target) +spotLight(255, 255, 255, // color + 0, -300, 300, // position + 0, 1, -1, // direction + PI / 4, 5); // angle, concentration + +// Image-based lighting +imageLight(myHDRI); + +// No lights (flat shading) +noLights(); + +// Quick default lighting +lights(); +``` + +### Three-Point Lighting Setup + +```javascript +function setupLighting() { + ambientLight(30, 30, 40); // dim blue fill + + // Key light (main, warm) + directionalLight(255, 240, 220, -1, -1, -1); + + // Fill light (softer, cooler, opposite side) + directionalLight(80, 100, 140, 1, -0.5, -1); + + // Rim light (behind subject, for edge definition) + pointLight(200, 200, 255, 0, -200, -400); +} +``` + +## Materials + +```javascript +// Normal material (debug — colors from surface normals) +normalMaterial(); + +// Ambient (responds only to ambientLight) +ambientMaterial(200, 100, 100); + +// Emissive (self-lit, no shadows) +emissiveMaterial(255, 0, 100); + +// Specular (shiny reflections) +specularMaterial(255); +shininess(50); // 1-200 (higher = tighter highlight) +metalness(100); // 0-200 (metallic reflection) + +// Fill works too (no lighting response) +fill(255, 0, 0); +``` + +### Texture + +```javascript +let img; +function preload() { img = loadImage('texture.jpg'); } + +function draw() { + texture(img); + textureMode(NORMAL); // UV coords 0-1 + // textureMode(IMAGE); // UV coords in pixels + textureWrap(REPEAT); // or CLAMP, MIRROR + box(200); +} +``` + +## Custom Geometry + +### buildGeometry + +```javascript +let myShape; + +function setup() { + createCanvas(800, 800, WEBGL); + myShape = buildGeometry(() => { + for (let i = 0; i < 50; i++) { + push(); + translate(random(-200, 200), random(-200, 200), random(-200, 200)); + sphere(10); + pop(); + } + }); +} + +function draw() { + model(myShape); // renders once-built geometry efficiently +} +``` + +### beginGeometry / endGeometry + +```javascript +beginGeometry(); + // draw shapes here + box(50); + translate(100, 0, 0); + sphere(30); +let geo = endGeometry(); + +model(geo); // reuse +``` + +### Manual Geometry (p5.Geometry) + +```javascript +let geo = new p5.Geometry(detailX, detailY, function() { + for (let i = 0; i <= detailX; i++) { + for (let j = 0; j <= detailY; j++) { + let u = i / detailX; + let v = j / detailY; + let x = cos(u * TWO_PI) * (100 + 30 * cos(v * TWO_PI)); + let y = sin(u * TWO_PI) * (100 + 30 * cos(v * TWO_PI)); + let z = 30 * sin(v * TWO_PI); + this.vertices.push(createVector(x, y, z)); + this.uvs.push(u, v); + } + } + this.computeFaces(); + this.computeNormals(); +}); +``` + +## GLSL Shaders + +### createShader (Vertex + Fragment) + +```javascript +let myShader; + +function setup() { + createCanvas(800, 800, WEBGL); + + let vert = ` + precision mediump float; + attribute vec3 aPosition; + attribute vec2 aTexCoord; + varying vec2 vTexCoord; + uniform mat4 uModelViewMatrix; + uniform mat4 uProjectionMatrix; + void main() { + vTexCoord = aTexCoord; + vec4 pos = uProjectionMatrix * uModelViewMatrix * vec4(aPosition, 1.0); + gl_Position = pos; + } + `; + + let frag = ` + precision mediump float; + varying vec2 vTexCoord; + uniform float uTime; + uniform vec2 uResolution; + + void main() { + vec2 uv = vTexCoord; + vec3 col = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0, 2, 4)); + gl_FragColor = vec4(col, 1.0); + } + `; + + myShader = createShader(vert, frag); +} + +function draw() { + shader(myShader); + myShader.setUniform('uTime', millis() / 1000.0); + myShader.setUniform('uResolution', [width, height]); + rect(0, 0, width, height); + resetShader(); +} +``` + +### createFilterShader (Post-Processing) + +Simpler — only needs a fragment shader. Automatically gets the canvas as a texture. + +```javascript +let blurShader; + +function setup() { + createCanvas(800, 800, WEBGL); + + blurShader = createFilterShader(` + precision mediump float; + varying vec2 vTexCoord; + uniform sampler2D tex0; + uniform vec2 texelSize; + + void main() { + vec4 sum = vec4(0.0); + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + sum += texture2D(tex0, vTexCoord + vec2(float(x), float(y)) * texelSize); + } + } + gl_FragColor = sum / 25.0; + } + `); +} + +function draw() { + // Draw scene normally + background(0); + fill(255, 0, 0); + sphere(100); + + // Apply post-processing filter + filter(blurShader); +} +``` + +### Common Shader Uniforms + +```javascript +myShader.setUniform('uTime', millis() / 1000.0); +myShader.setUniform('uResolution', [width, height]); +myShader.setUniform('uMouse', [mouseX / width, mouseY / height]); +myShader.setUniform('uTexture', myGraphics); // pass p5.Graphics as texture +myShader.setUniform('uValue', 0.5); // float +myShader.setUniform('uColor', [1.0, 0.0, 0.5, 1.0]); // vec4 +``` + +### Shader Recipes + +**Chromatic Aberration:** +```glsl +vec4 r = texture2D(tex0, vTexCoord + vec2(0.005, 0.0)); +vec4 g = texture2D(tex0, vTexCoord); +vec4 b = texture2D(tex0, vTexCoord - vec2(0.005, 0.0)); +gl_FragColor = vec4(r.r, g.g, b.b, 1.0); +``` + +**Vignette:** +```glsl +float d = distance(vTexCoord, vec2(0.5)); +float v = smoothstep(0.7, 0.4, d); +gl_FragColor = texture2D(tex0, vTexCoord) * v; +``` + +**Scanlines:** +```glsl +float scanline = sin(vTexCoord.y * uResolution.y * 3.14159) * 0.04; +vec4 col = texture2D(tex0, vTexCoord); +gl_FragColor = col - scanline; +``` + +## Framebuffers + +```javascript +let fbo; + +function setup() { + createCanvas(800, 800, WEBGL); + fbo = createFramebuffer(); +} + +function draw() { + // Render to framebuffer + fbo.begin(); + clear(); + rotateY(frameCount * 0.01); + box(200); + fbo.end(); + + // Use framebuffer as texture + texture(fbo.color); + plane(width, height); +} +``` + +### Multi-Pass Rendering + +```javascript +let sceneBuffer, blurBuffer; + +function setup() { + createCanvas(800, 800, WEBGL); + sceneBuffer = createFramebuffer(); + blurBuffer = createFramebuffer(); +} + +function draw() { + // Pass 1: render scene + sceneBuffer.begin(); + clear(); + lights(); + rotateY(frameCount * 0.01); + box(200); + sceneBuffer.end(); + + // Pass 2: blur + blurBuffer.begin(); + shader(blurShader); + blurShader.setUniform('uTexture', sceneBuffer.color); + rect(0, 0, width, height); + resetShader(); + blurBuffer.end(); + + // Final: composite + texture(blurBuffer.color); + plane(width, height); +} +``` diff --git a/skills/creative/p5js/scripts/export-frames.js b/skills/creative/p5js/scripts/export-frames.js new file mode 100755 index 000000000000..0e4078dac14e --- /dev/null +++ b/skills/creative/p5js/scripts/export-frames.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * p5.js Skill — Headless Frame Export + * + * Captures frames from a p5.js sketch using Puppeteer (headless Chrome). + * Uses noLoop() + redraw() for DETERMINISTIC frame-by-frame control. + * + * IMPORTANT: Your sketch must call noLoop() in setup() and set + * window._p5Ready = true when initialized. This script calls redraw() + * for each frame capture, ensuring exact 1:1 correspondence between + * frameCount and captured frames. + * + * If the sketch does NOT set window._p5Ready, the script falls back to + * a timed capture mode (less precise, may drop/duplicate frames). + * + * Usage: + * node export-frames.js sketch.html [options] + * + * Options: + * --output

Output directory (default: ./frames) + * --width Canvas width (default: 1920) + * --height Canvas height (default: 1080) + * --frames Number of frames to capture (default: 1) + * --fps Target FPS for timed fallback mode (default: 30) + * --wait Wait before first capture (default: 2000) + * --selector Canvas CSS selector (default: canvas) + * + * Examples: + * node export-frames.js sketch.html --frames 1 # single PNG + * node export-frames.js sketch.html --frames 300 --fps 30 # 10s at 30fps + * node export-frames.js sketch.html --width 3840 --height 2160 # 4K still + * + * Sketch template for deterministic capture: + * function setup() { + * createCanvas(1920, 1080); + * pixelDensity(1); + * noLoop(); // REQUIRED for deterministic capture + * window._p5Ready = true; // REQUIRED to signal readiness + * } + * function draw() { ... } + */ + +const puppeteer = require('puppeteer'); +const path = require('path'); +const fs = require('fs'); + +// Parse CLI arguments +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + input: null, + output: './frames', + width: 1920, + height: 1080, + frames: 1, + fps: 30, + wait: 2000, + selector: 'canvas', + }; + + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) { + const key = args[i].slice(2); + const val = args[i + 1]; + if (key in opts && val !== undefined) { + opts[key] = isNaN(Number(val)) ? val : Number(val); + i++; + } + } else if (!opts.input) { + opts.input = args[i]; + } + } + + if (!opts.input) { + console.error('Usage: node export-frames.js [options]'); + process.exit(1); + } + + return opts; +} + +async function main() { + const opts = parseArgs(); + const inputPath = path.resolve(opts.input); + + if (!fs.existsSync(inputPath)) { + console.error(`File not found: ${inputPath}`); + process.exit(1); + } + + // Create output directory + fs.mkdirSync(opts.output, { recursive: true }); + + console.log(`Capturing ${opts.frames} frame(s) from ${opts.input}`); + console.log(`Resolution: ${opts.width}x${opts.height}`); + console.log(`Output: ${opts.output}/`); + + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-web-security', + '--allow-file-access-from-files', + ], + }); + + const page = await browser.newPage(); + + await page.setViewport({ + width: opts.width, + height: opts.height, + deviceScaleFactor: 1, + }); + + // Navigate to sketch + const fileUrl = `file://${inputPath}`; + await page.goto(fileUrl, { waitUntil: 'networkidle0', timeout: 30000 }); + + // Wait for canvas to appear + await page.waitForSelector(opts.selector, { timeout: 10000 }); + + // Detect capture mode: deterministic (noLoop+redraw) vs timed (fallback) + let deterministic = false; + try { + await page.waitForFunction('window._p5Ready === true', { timeout: 5000 }); + deterministic = true; + console.log(`Mode: deterministic (noLoop + redraw)`); + } catch { + console.log(`Mode: timed fallback (sketch does not set window._p5Ready)`); + console.log(` For frame-perfect capture, add noLoop() and window._p5Ready=true to setup()`); + await new Promise(r => setTimeout(r, opts.wait)); + } + + const startTime = Date.now(); + + for (let i = 0; i < opts.frames; i++) { + if (deterministic) { + // Advance exactly one frame + await page.evaluate(() => { redraw(); }); + // Brief settle time for render to complete + await new Promise(r => setTimeout(r, 20)); + } + + const frameName = `frame-${String(i).padStart(4, '0')}.png`; + const framePath = path.join(opts.output, frameName); + + // Capture the canvas element + const canvas = await page.$(opts.selector); + if (!canvas) { + console.error('Canvas element not found'); + break; + } + + await canvas.screenshot({ path: framePath, type: 'png' }); + + // Progress + if (i % 30 === 0 || i === opts.frames - 1) { + const pct = ((i + 1) / opts.frames * 100).toFixed(1); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + process.stdout.write(`\r Frame ${i + 1}/${opts.frames} (${pct}%) — ${elapsed}s`); + } + + // In timed mode, wait between frames + if (!deterministic && i < opts.frames - 1) { + await new Promise(r => setTimeout(r, 1000 / opts.fps)); + } + } + + console.log('\n Done.'); + await browser.close(); +} + +main().catch(err => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/skills/creative/p5js/scripts/render.sh b/skills/creative/p5js/scripts/render.sh new file mode 100755 index 000000000000..81e65cf2f337 --- /dev/null +++ b/skills/creative/p5js/scripts/render.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# p5.js Skill — Headless Render Pipeline +# Renders a p5.js sketch to MP4 video via Puppeteer + ffmpeg +# +# Usage: +# bash scripts/render.sh sketch.html output.mp4 [options] +# +# Options: +# --width Canvas width (default: 1920) +# --height Canvas height (default: 1080) +# --fps Frames per second (default: 30) +# --duration Duration in seconds (default: 10) +# --quality CRF value 0-51 (default: 18, lower = better) +# --frames-only Only export frames, skip MP4 encoding +# +# Examples: +# bash scripts/render.sh sketch.html output.mp4 +# bash scripts/render.sh sketch.html output.mp4 --duration 30 --fps 60 +# bash scripts/render.sh sketch.html output.mp4 --width 3840 --height 2160 + +set -euo pipefail + +# Defaults +WIDTH=1920 +HEIGHT=1080 +FPS=30 +DURATION=10 +CRF=18 +FRAMES_ONLY=false + +# Parse arguments +INPUT="${1:?Usage: render.sh [options]}" +OUTPUT="${2:?Usage: render.sh [options]}" +shift 2 + +while [[ $# -gt 0 ]]; do + case $1 in + --width) WIDTH="$2"; shift 2 ;; + --height) HEIGHT="$2"; shift 2 ;; + --fps) FPS="$2"; shift 2 ;; + --duration) DURATION="$2"; shift 2 ;; + --quality) CRF="$2"; shift 2 ;; + --frames-only) FRAMES_ONLY=true; shift ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +TOTAL_FRAMES=$((FPS * DURATION)) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FRAME_DIR=$(mktemp -d) + +echo "=== p5.js Render Pipeline ===" +echo "Input: $INPUT" +echo "Output: $OUTPUT" +echo "Resolution: ${WIDTH}x${HEIGHT}" +echo "FPS: $FPS" +echo "Duration: ${DURATION}s (${TOTAL_FRAMES} frames)" +echo "Quality: CRF $CRF" +echo "Frame dir: $FRAME_DIR" +echo "" + +# Check dependencies +command -v node >/dev/null 2>&1 || { echo "Error: Node.js required"; exit 1; } +if [ "$FRAMES_ONLY" = false ]; then + command -v ffmpeg >/dev/null 2>&1 || { echo "Error: ffmpeg required for MP4"; exit 1; } +fi + +# Step 1: Capture frames via Puppeteer +echo "Step 1/2: Capturing ${TOTAL_FRAMES} frames..." +node "$SCRIPT_DIR/export-frames.js" \ + "$INPUT" \ + --output "$FRAME_DIR" \ + --width "$WIDTH" \ + --height "$HEIGHT" \ + --frames "$TOTAL_FRAMES" \ + --fps "$FPS" + +echo "Frames captured to $FRAME_DIR" + +if [ "$FRAMES_ONLY" = true ]; then + echo "Frames saved to: $FRAME_DIR" + echo "To encode manually:" + echo " ffmpeg -framerate $FPS -i $FRAME_DIR/frame-%04d.png -c:v libx264 -crf $CRF -pix_fmt yuv420p $OUTPUT" + exit 0 +fi + +# Step 2: Encode to MP4 +echo "Step 2/2: Encoding MP4..." +ffmpeg -y \ + -framerate "$FPS" \ + -i "$FRAME_DIR/frame-%04d.png" \ + -c:v libx264 \ + -preset slow \ + -crf "$CRF" \ + -pix_fmt yuv420p \ + -movflags +faststart \ + "$OUTPUT" \ + 2>"$FRAME_DIR/ffmpeg.log" + +# Cleanup +rm -rf "$FRAME_DIR" + +# Report +FILE_SIZE=$(ls -lh "$OUTPUT" | awk '{print $5}') +echo "" +echo "=== Done ===" +echo "Output: $OUTPUT ($FILE_SIZE)" +echo "Duration: ${DURATION}s at ${FPS}fps, ${WIDTH}x${HEIGHT}" diff --git a/skills/creative/p5js/scripts/serve.sh b/skills/creative/p5js/scripts/serve.sh new file mode 100755 index 000000000000..34055d596737 --- /dev/null +++ b/skills/creative/p5js/scripts/serve.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# p5.js Skill — Local Development Server +# Serves the current directory over HTTP for loading local assets (fonts, images) +# +# Usage: +# bash scripts/serve.sh [port] [directory] +# +# Examples: +# bash scripts/serve.sh # serve CWD on port 8080 +# bash scripts/serve.sh 3000 # serve CWD on port 3000 +# bash scripts/serve.sh 8080 ./my-project # serve specific directory + +PORT="${1:-8080}" +DIR="${2:-.}" + +echo "=== p5.js Dev Server ===" +echo "Serving: $(cd "$DIR" && pwd)" +echo "URL: http://localhost:$PORT" +echo "Press Ctrl+C to stop" +echo "" + +cd "$DIR" && python3 -m http.server "$PORT" 2>/dev/null || { + echo "Python3 not found. Trying Node.js..." + npx serve -l "$PORT" "$DIR" 2>/dev/null || { + echo "Error: Need python3 or npx (Node.js) for local server" + exit 1 + } +} diff --git a/skills/creative/p5js/scripts/setup.sh b/skills/creative/p5js/scripts/setup.sh new file mode 100755 index 000000000000..33f9e0e172f0 --- /dev/null +++ b/skills/creative/p5js/scripts/setup.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# p5.js Skill — Dependency Verification +# Run: bash skills/creative/p5js/scripts/setup.sh + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +ok() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +fail() { echo -e "${RED}[FAIL]${NC} $1"; } + +echo "=== p5.js Skill — Setup Check ===" +echo "" + +# Required: Node.js (for Puppeteer headless export) +if command -v node &>/dev/null; then + NODE_VER=$(node -v) + ok "Node.js $NODE_VER" +else + warn "Node.js not found — optional, needed for headless export" + echo " Install: https://nodejs.org/ or 'brew install node'" +fi + +# Required: npm (for Puppeteer install) +if command -v npm &>/dev/null; then + NPM_VER=$(npm -v) + ok "npm $NPM_VER" +else + warn "npm not found — optional, needed for headless export" +fi + +# Optional: Puppeteer +if node -e "require('puppeteer')" 2>/dev/null; then + ok "Puppeteer installed" +else + warn "Puppeteer not installed — needed for headless export" + echo " Install: npm install puppeteer" +fi + +# Optional: ffmpeg (for MP4 encoding from frame sequences) +if command -v ffmpeg &>/dev/null; then + FFMPEG_VER=$(ffmpeg -version 2>&1 | head -1 | awk '{print $3}') + ok "ffmpeg $FFMPEG_VER" +else + warn "ffmpeg not found — needed for MP4 export" + echo " Install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)" +fi + +# Optional: Python3 (for local server) +if command -v python3 &>/dev/null; then + PY_VER=$(python3 --version 2>&1 | awk '{print $2}') + ok "Python $PY_VER (for local server: python3 -m http.server)" +else + warn "Python3 not found — needed for local file serving" +fi + +# Browser check (macOS) +if [[ "$(uname)" == "Darwin" ]]; then + if open -Ra "Google Chrome" 2>/dev/null; then + ok "Google Chrome found" + elif open -Ra "Safari" 2>/dev/null; then + ok "Safari found" + else + warn "No browser detected" + fi +fi + +echo "" +echo "=== Core Requirements ===" +echo " A modern browser (Chrome/Firefox/Safari/Edge)" +echo " p5.js loaded via CDN — no local install needed" +echo "" +echo "=== Optional (for export) ===" +echo " Node.js + Puppeteer — headless frame capture" +echo " ffmpeg — frame sequence to MP4" +echo " Python3 — local development server" +echo "" +echo "=== Quick Start ===" +echo " 1. Create an HTML file with inline p5.js sketch" +echo " 2. Open in browser: open sketch.html" +echo " 3. Press 's' to save PNG, 'g' to save GIF" +echo "" +echo "Setup check complete." diff --git a/skills/creative/p5js/templates/viewer.html b/skills/creative/p5js/templates/viewer.html new file mode 100644 index 000000000000..1a7d27a55562 --- /dev/null +++ b/skills/creative/p5js/templates/viewer.html @@ -0,0 +1,395 @@ + + + + + + +Generative Art Viewer + + + + + + + + + +
+ + + + \ No newline at end of file From 5e88eb2ba0bd9c6087d7d741e20e7bfebf705ceb Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:41:47 +0530 Subject: [PATCH 127/213] fix(signal): implement send_image_file, send_voice, and send_video for MEDIA: tag delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Signal adapter inherited base class defaults for send_image_file(), send_voice(), and send_video() which only sent the file path as text (e.g. '🖼️ Image: /tmp/chart.png') instead of actually delivering the file as a Signal attachment. When agent responses contain MEDIA:/path/to/file tags, the gateway media pipeline extracts them and routes through these methods by file type. Without proper overrides, image/audio/video files were never actually delivered to Signal users. Extract a shared _send_attachment() helper that handles all file validation, size checking, group/DM routing, and RPC dispatch. The four public methods (send_document, send_image_file, send_voice, send_video) now delegate to this helper, following the same pattern used by WhatsApp (_send_media_to_bridge) and Discord (_send_file_attachment). The helper also uses a single stat() call with try/except FileNotFoundError instead of the previous exists() + stat() two-syscall pattern, eliminating a TOCTOU race. As a bonus, send_document() now gains the 100MB size check that was previously missing (inconsistency with send_image). Add 25 tests covering all methods plus MEDIA: tag extraction integration, method-override guards, and send_document's new size check. Fixes #5105 --- gateway/platforms/signal.py | 74 +++++++- tests/gateway/test_signal.py | 339 +++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+), 7 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 1629e08631e5..66d455ccafd2 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -717,19 +717,27 @@ async def send_image( return SendResult(success=True) return SendResult(success=False, error="RPC send with attachment failed") - async def send_document( + async def _send_attachment( self, chat_id: str, file_path: str, + media_label: str, caption: Optional[str] = None, - filename: Optional[str] = None, - **kwargs, ) -> SendResult: - """Send a document/file attachment.""" + """Send any file as a Signal attachment via RPC. + + Shared implementation for send_document, send_image_file, send_voice, + and send_video — avoids duplicating the validation/routing/RPC logic. + """ await self._stop_typing_indicator(chat_id) - if not Path(file_path).exists(): - return SendResult(success=False, error="File not found") + try: + file_size = Path(file_path).stat().st_size + except FileNotFoundError: + return SendResult(success=False, error=f"{media_label} file not found: {file_path}") + + if file_size > SIGNAL_MAX_ATTACHMENT_SIZE: + return SendResult(success=False, error=f"{media_label} too large ({file_size} bytes)") params: Dict[str, Any] = { "account": self.account, @@ -746,7 +754,59 @@ async def send_document( if result is not None: self._track_sent_timestamp(result) return SendResult(success=True) - return SendResult(success=False, error="RPC send document failed") + return SendResult(success=False, error=f"RPC send {media_label.lower()} failed") + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + filename: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a document/file attachment.""" + return await self._send_attachment(chat_id, file_path, "File", caption) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a local image file as a native Signal attachment. + + Called by the gateway media delivery flow when MEDIA: tags containing + image paths are extracted from agent responses. + """ + return await self._send_attachment(chat_id, image_path, "Image", caption) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send an audio file as a Signal attachment. + + Signal does not distinguish voice messages from file attachments at + the API level, so this routes through the same RPC send path. + """ + return await self._send_attachment(chat_id, audio_path, "Audio", caption) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send a video file as a Signal attachment.""" + return await self._send_attachment(chat_id, video_path, "Video", caption) # ------------------------------------------------------------------ # Typing Indicators diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index acd6513e5ba6..b2830e1fcd38 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -2,6 +2,7 @@ import base64 import json import pytest +from pathlib import Path from unittest.mock import MagicMock, patch, AsyncMock from urllib.parse import quote @@ -368,3 +369,341 @@ def test_signal_in_platform_map(self): # Just verify the import works and Signal is a valid platform from gateway.config import Platform assert Platform.SIGNAL.value == "signal" + + +# --------------------------------------------------------------------------- +# send_image_file method (#5105) +# --------------------------------------------------------------------------- + +class TestSignalSendImageFile: + @pytest.mark.asyncio + async def test_send_image_file_sends_via_rpc(self, monkeypatch, tmp_path): + """send_image_file should send image as attachment via signal-cli RPC.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + img_path = tmp_path / "chart.png" + img_path.write_bytes(b"\x89PNG" + b"\x00" * 100) + + result = await adapter.send_image_file(chat_id="+155****4567", image_path=str(img_path)) + + assert result.success is True + assert len(captured) == 1 + assert captured[0]["method"] == "send" + assert captured[0]["params"]["account"] == adapter.account + assert captured[0]["params"]["recipient"] == ["+155****4567"] + assert captured[0]["params"]["attachments"] == [str(img_path)] + assert captured[0]["params"]["message"] == "" # caption=None → "" + # Typing indicator must be stopped before sending + adapter._stop_typing_indicator.assert_awaited_once_with("+155****4567") + # Timestamp must be tracked for echo-back prevention + assert 1234567890 in adapter._recent_sent_timestamps + + @pytest.mark.asyncio + async def test_send_image_file_to_group(self, monkeypatch, tmp_path): + """send_image_file should route group chats via groupId.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + img_path = tmp_path / "photo.jpg" + img_path.write_bytes(b"\xff\xd8" + b"\x00" * 100) + + result = await adapter.send_image_file( + chat_id="group:abc123==", image_path=str(img_path), caption="Here's the chart" + ) + + assert result.success is True + assert captured[0]["params"]["groupId"] == "abc123==" + assert captured[0]["params"]["message"] == "Here's the chart" + + @pytest.mark.asyncio + async def test_send_image_file_missing(self, monkeypatch): + """send_image_file should fail gracefully for nonexistent files.""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.send_image_file(chat_id="+155****4567", image_path="/nonexistent.png") + + assert result.success is False + assert "not found" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_image_file_too_large(self, monkeypatch, tmp_path): + """send_image_file should reject files over 100MB.""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + img_path = tmp_path / "huge.png" + img_path.write_bytes(b"x") + + def mock_stat(self, **kwargs): + class FakeStat: + st_size = 200 * 1024 * 1024 # 200 MB + return FakeStat() + + with patch.object(Path, "stat", mock_stat): + result = await adapter.send_image_file(chat_id="+155****4567", image_path=str(img_path)) + + assert result.success is False + assert "too large" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_image_file_rpc_failure(self, monkeypatch, tmp_path): + """send_image_file should return error when RPC returns None.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, _ = _stub_rpc(None) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + img_path = tmp_path / "test.png" + img_path.write_bytes(b"\x89PNG" + b"\x00" * 100) + + result = await adapter.send_image_file(chat_id="+155****4567", image_path=str(img_path)) + + assert result.success is False + assert "failed" in result.error.lower() + + +# --------------------------------------------------------------------------- +# send_voice method (#5105) +# --------------------------------------------------------------------------- + +class TestSignalSendVoice: + @pytest.mark.asyncio + async def test_send_voice_sends_via_rpc(self, monkeypatch, tmp_path): + """send_voice should send audio as attachment via signal-cli RPC.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + audio_path = tmp_path / "reply.ogg" + audio_path.write_bytes(b"OggS" + b"\x00" * 100) + + result = await adapter.send_voice(chat_id="+155****4567", audio_path=str(audio_path)) + + assert result.success is True + assert captured[0]["method"] == "send" + assert captured[0]["params"]["attachments"] == [str(audio_path)] + assert captured[0]["params"]["message"] == "" # caption=None → "" + adapter._stop_typing_indicator.assert_awaited_once_with("+155****4567") + assert 1234567890 in adapter._recent_sent_timestamps + + @pytest.mark.asyncio + async def test_send_voice_missing_file(self, monkeypatch): + """send_voice should fail for nonexistent audio.""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.send_voice(chat_id="+155****4567", audio_path="/missing.ogg") + + assert result.success is False + assert "not found" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_voice_to_group(self, monkeypatch, tmp_path): + """send_voice should route group chats correctly.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, captured = _stub_rpc({"timestamp": 9999}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + audio_path = tmp_path / "note.mp3" + audio_path.write_bytes(b"\xff\xe0" + b"\x00" * 100) + + result = await adapter.send_voice(chat_id="group:grp1==", audio_path=str(audio_path)) + + assert result.success is True + assert captured[0]["params"]["groupId"] == "grp1==" + + @pytest.mark.asyncio + async def test_send_voice_too_large(self, monkeypatch, tmp_path): + """send_voice should reject files over 100MB.""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + audio_path = tmp_path / "huge.ogg" + audio_path.write_bytes(b"x") + + def mock_stat(self, **kwargs): + class FakeStat: + st_size = 200 * 1024 * 1024 + return FakeStat() + + with patch.object(Path, "stat", mock_stat): + result = await adapter.send_voice(chat_id="+155****4567", audio_path=str(audio_path)) + + assert result.success is False + assert "too large" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_voice_rpc_failure(self, monkeypatch, tmp_path): + """send_voice should return error when RPC returns None.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, _ = _stub_rpc(None) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + audio_path = tmp_path / "reply.ogg" + audio_path.write_bytes(b"OggS" + b"\x00" * 100) + + result = await adapter.send_voice(chat_id="+155****4567", audio_path=str(audio_path)) + + assert result.success is False + assert "failed" in result.error.lower() + + +# --------------------------------------------------------------------------- +# send_video method (#5105) +# --------------------------------------------------------------------------- + +class TestSignalSendVideo: + @pytest.mark.asyncio + async def test_send_video_sends_via_rpc(self, monkeypatch, tmp_path): + """send_video should send video as attachment via signal-cli RPC.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, captured = _stub_rpc({"timestamp": 1234567890}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + vid_path = tmp_path / "demo.mp4" + vid_path.write_bytes(b"\x00\x00\x00\x18ftyp" + b"\x00" * 100) + + result = await adapter.send_video(chat_id="+155****4567", video_path=str(vid_path)) + + assert result.success is True + assert captured[0]["method"] == "send" + assert captured[0]["params"]["attachments"] == [str(vid_path)] + assert captured[0]["params"]["message"] == "" # caption=None → "" + adapter._stop_typing_indicator.assert_awaited_once_with("+155****4567") + assert 1234567890 in adapter._recent_sent_timestamps + + @pytest.mark.asyncio + async def test_send_video_missing_file(self, monkeypatch): + """send_video should fail for nonexistent video.""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.send_video(chat_id="+155****4567", video_path="/missing.mp4") + + assert result.success is False + assert "not found" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_video_too_large(self, monkeypatch, tmp_path): + """send_video should reject files over 100MB.""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + vid_path = tmp_path / "huge.mp4" + vid_path.write_bytes(b"x") + + def mock_stat(self, **kwargs): + class FakeStat: + st_size = 200 * 1024 * 1024 + return FakeStat() + + with patch.object(Path, "stat", mock_stat): + result = await adapter.send_video(chat_id="+155****4567", video_path=str(vid_path)) + + assert result.success is False + assert "too large" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_video_rpc_failure(self, monkeypatch, tmp_path): + """send_video should return error when RPC returns None.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, _ = _stub_rpc(None) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + vid_path = tmp_path / "demo.mp4" + vid_path.write_bytes(b"\x00\x00\x00\x18ftyp" + b"\x00" * 100) + + result = await adapter.send_video(chat_id="+155****4567", video_path=str(vid_path)) + + assert result.success is False + assert "failed" in result.error.lower() + + +# --------------------------------------------------------------------------- +# MEDIA: tag extraction integration +# --------------------------------------------------------------------------- + +class TestSignalMediaExtraction: + """Verify the full pipeline: MEDIA: tag → extract → send_image_file/send_voice.""" + + def test_extract_media_finds_image_tag(self): + """BasePlatformAdapter.extract_media should find MEDIA: image paths.""" + from gateway.platforms.base import BasePlatformAdapter + media, cleaned = BasePlatformAdapter.extract_media( + "Here's the chart.\nMEDIA:/tmp/price_graph.png" + ) + assert len(media) == 1 + assert media[0][0] == "/tmp/price_graph.png" + assert "MEDIA:" not in cleaned + + def test_extract_media_finds_audio_tag(self): + """BasePlatformAdapter.extract_media should find MEDIA: audio paths.""" + from gateway.platforms.base import BasePlatformAdapter + media, cleaned = BasePlatformAdapter.extract_media( + "[[audio_as_voice]]\nMEDIA:/tmp/reply.ogg" + ) + assert len(media) == 1 + assert media[0][0] == "/tmp/reply.ogg" + assert media[0][1] is True # is_voice flag + + def test_signal_has_all_media_methods(self, monkeypatch): + """SignalAdapter must override all media send methods used by gateway.""" + adapter = _make_signal_adapter(monkeypatch) + from gateway.platforms.base import BasePlatformAdapter + + # These methods must NOT be the base class defaults (which just send text) + assert type(adapter).send_image_file is not BasePlatformAdapter.send_image_file + assert type(adapter).send_voice is not BasePlatformAdapter.send_voice + assert type(adapter).send_video is not BasePlatformAdapter.send_video + assert type(adapter).send_document is not BasePlatformAdapter.send_document + assert type(adapter).send_image is not BasePlatformAdapter.send_image + + +# --------------------------------------------------------------------------- +# send_document now routes through _send_attachment (#5105 bonus) +# --------------------------------------------------------------------------- + +class TestSignalSendDocumentViaHelper: + """Verify send_document gained size check and path-in-error via _send_attachment.""" + + @pytest.mark.asyncio + async def test_send_document_too_large(self, monkeypatch, tmp_path): + """send_document should now reject files over 100MB (was previously missing).""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + doc_path = tmp_path / "huge.pdf" + doc_path.write_bytes(b"x") + + def mock_stat(self, **kwargs): + class FakeStat: + st_size = 200 * 1024 * 1024 + return FakeStat() + + with patch.object(Path, "stat", mock_stat): + result = await adapter.send_document(chat_id="+155****4567", file_path=str(doc_path)) + + assert result.success is False + assert "too large" in result.error.lower() + + @pytest.mark.asyncio + async def test_send_document_error_includes_path(self, monkeypatch): + """send_document error message should include the file path.""" + adapter = _make_signal_adapter(monkeypatch) + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.send_document(chat_id="+155****4567", file_path="/nonexistent.pdf") + + assert result.success is False + assert "/nonexistent.pdf" in result.error From 3d08a2fa1bf344da866d43cdfb8271290eec29c2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:42:44 -0700 Subject: [PATCH 128/213] fix: extract MEDIA: tags from cron delivery before sending (#5598) The cron scheduler delivery path passed raw text including MEDIA: tags to _send_to_platform(), so media attachments were delivered as literal text instead of actual files. The send function already supports media_files= but the cron path never used it. Now calls BasePlatformAdapter.extract_media() to split media paths from text before sending, matching the gateway's normal message flow. Salvaged from PR #4877 by robert-hoffmann. --- cron/scheduler.py | 8 ++++++-- tests/cron/test_scheduler.py | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index c2f52be0e3b1..606a9ba7befb 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -237,6 +237,10 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None: else: delivery_content = content + # Extract MEDIA: tags so attachments are forwarded as files, not raw text + from gateway.platforms.base import BasePlatformAdapter + media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content) + # Prefer the live adapter when the gateway is running — this supports E2EE # rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt. runtime_adapter = (adapters or {}).get(platform) @@ -264,7 +268,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None: ) # Standalone path: run the async send in a fresh event loop (safe from any thread) - coro = _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id) + coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files) try: result = asyncio.run(coro) except RuntimeError: @@ -275,7 +279,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None: coro.close() import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id)) + future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)) result = future.result(timeout=30) except Exception as e: logger.error("Job '%s': delivery to %s:%s failed: %s", job["id"], platform_name, chat_id, e) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 00531d3c17d5..33f265de3380 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -250,6 +250,33 @@ def test_delivery_skips_wrapping_when_config_disabled(self): assert "Cronjob Response" not in sent_content assert "The agent cannot see" not in sent_content + def test_delivery_extracts_media_tags_before_send(self): + """Cron delivery should pass MEDIA attachments separately to the send helper.""" + from gateway.config import Platform + + pconfig = MagicMock() + pconfig.enabled = True + mock_cfg = MagicMock() + mock_cfg.platforms = {Platform.TELEGRAM: pconfig} + + with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}): + job = { + "id": "voice-job", + "deliver": "origin", + "origin": {"platform": "telegram", "chat_id": "123"}, + } + _deliver_result(job, "Title\nMEDIA:/tmp/test-voice.ogg") + + send_mock.assert_called_once() + args, kwargs = send_mock.call_args + # Text content should have MEDIA: tag stripped + assert "MEDIA:" not in args[3] + assert "Title" in args[3] + # Media files should be forwarded separately + assert kwargs["media_files"] == [("/tmp/test-voice.ogg", False)] + def test_no_mirror_to_session_call(self): """Cron deliveries should NOT mirror into the gateway session.""" from gateway.config import Platform From 92c19924a93fd83098fe34e58a3cfc2a488e1e49 Mon Sep 17 00:00:00 2001 From: Julien Talbot Date: Mon, 6 Apr 2026 19:06:36 +0400 Subject: [PATCH 129/213] feat: add xAI prompt caching via x-grok-conv-id header When using xAI's API directly (base_url contains x.ai), send the x-grok-conv-id header set to the Hermes session_id. This routes consecutive requests to the same server, maximizing automatic prompt cache hits. Ref: https://docs.x.ai/developers/advanced-api-usage/prompt-caching --- run_agent.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/run_agent.py b/run_agent.py index 5d45532d8f04..d85682a16f2e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5438,6 +5438,12 @@ def _build_api_kwargs(self, api_messages: list) -> dict: if extra_body: api_kwargs["extra_body"] = extra_body + # xAI prompt caching: send x-grok-conv-id header to route requests + # to the same server, maximizing automatic cache hits. + # https://docs.x.ai/developers/advanced-api-usage/prompt-caching + if "x.ai" in self._base_url_lower and hasattr(self, "session_id") and self.session_id: + api_kwargs["extra_headers"] = {"x-grok-conv-id": self.session_id} + return api_kwargs def _supports_reasoning_extra_body(self) -> bool: From 8ffd44a6f9306d4103426bff53e43c39096a9b8d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:09:36 -0700 Subject: [PATCH 130/213] feat(discord): register skills as native slash commands via shared gateway logic (#5603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize the skill → slash command registration that Telegram already had in commands.py so Discord uses the exact same priority system, filtering, and cap enforcement: 1. Core/built-in commands (never trimmed) 2. Plugin commands (never trimmed) 3. Skill commands (fill remaining slots, alphabetical, only tier trimmed) Changes: hermes_cli/commands.py: - Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms) - Rename _clamp_telegram_names → _clamp_command_names (generic) - Extract _collect_gateway_skill_entries() — shared plugin + skill collection with platform filtering, name sanitization, description truncation, and cap enforcement - Refactor telegram_menu_commands() to use the shared helper - Add discord_skill_commands() that returns (name, desc, cmd_key) triples - Preserve _sanitize_telegram_name() for Telegram-specific name cleaning gateway/platforms/discord.py: - Call discord_skill_commands() from _register_slash_commands() - Create app_commands.Command per skill entry with cmd_key callback - Respect 100-command global Discord limit - Log warning when skills are skipped due to cap Backward-compat aliases preserved for _TG_NAME_LIMIT and _clamp_telegram_names. Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass. Inspired by PR #5498 (sprmn24). Closes #5480. --- gateway/platforms/discord.py | 41 +++++ hermes_cli/commands.py | 202 ++++++++++++++++++------- tests/hermes_cli/test_commands.py | 238 ++++++++++++++++++++++++++++++ 3 files changed, 428 insertions(+), 53 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 0ccac36b6159..75ba3d1153db 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1695,6 +1695,47 @@ async def slash_background(interaction: discord.Interaction, prompt: str): async def slash_btw(interaction: discord.Interaction, question: str): await self._run_simple_slash(interaction, f"/btw {question}") + # Register installed skills as native slash commands (parity with + # Telegram, which uses telegram_menu_commands() in commands.py). + # Discord allows up to 100 application commands globally. + _DISCORD_CMD_LIMIT = 100 + try: + from hermes_cli.commands import discord_skill_commands + + existing_names = {cmd.name for cmd in tree.get_commands()} + remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names)) + + skill_entries, skipped = discord_skill_commands( + max_slots=remaining_slots, + reserved_names=existing_names, + ) + + for discord_name, description, cmd_key in skill_entries: + # Closure factory to capture cmd_key per iteration + def _make_skill_handler(_key: str): + async def _skill_slash(interaction: discord.Interaction, args: str = ""): + await self._run_simple_slash(interaction, f"{_key} {args}".strip()) + return _skill_slash + + handler = _make_skill_handler(cmd_key) + handler.__name__ = f"skill_{discord_name.replace('-', '_')}" + + cmd = discord.app_commands.Command( + name=discord_name, + description=description, + callback=handler, + ) + discord.app_commands.describe(args="Optional arguments for the skill")(cmd) + tree.add_command(cmd) + + if skipped: + logger.warning( + "[%s] Discord slash command limit reached (%d): %d skill(s) not registered", + self.name, _DISCORD_CMD_LIMIT, skipped, + ) + except Exception as exc: + logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc) + 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) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 07732b50f019..9bce834d04ab 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -372,7 +372,11 @@ def telegram_bot_commands() -> list[tuple[str, str]]: return result -_TG_NAME_LIMIT = 32 +_CMD_NAME_LIMIT = 32 +"""Max command name length shared by Telegram and Discord.""" + +# Backward-compat alias — tests and external code may reference the old name. +_TG_NAME_LIMIT = _CMD_NAME_LIMIT # Telegram Bot API allows only lowercase a-z, 0-9, and underscores in # command names. This regex strips everything else after initial conversion. @@ -394,13 +398,14 @@ def _sanitize_telegram_name(raw: str) -> str: return name.strip("_") -def _clamp_telegram_names( +def _clamp_command_names( entries: list[tuple[str, str]], reserved: set[str], ) -> list[tuple[str, str]]: - """Enforce Telegram's 32-char command name limit with collision avoidance. + """Enforce 32-char command name limit with collision avoidance. - Names exceeding 32 chars are truncated. If truncation creates a duplicate + Both Telegram and Discord cap slash command names at 32 characters. + Names exceeding the limit are truncated. If truncation creates a duplicate (against *reserved* names or earlier entries in the same batch), the name is shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate. If all 10 digit slots are taken the entry is silently dropped. @@ -408,10 +413,10 @@ def _clamp_telegram_names( used: set[str] = set(reserved) result: list[tuple[str, str]] = [] for name, desc in entries: - if len(name) > _TG_NAME_LIMIT: - candidate = name[:_TG_NAME_LIMIT] + if len(name) > _CMD_NAME_LIMIT: + candidate = name[:_CMD_NAME_LIMIT] if candidate in used: - prefix = name[:_TG_NAME_LIMIT - 1] + prefix = name[:_CMD_NAME_LIMIT - 1] for digit in range(10): candidate = f"{prefix}{digit}" if candidate not in used: @@ -427,63 +432,83 @@ def _clamp_telegram_names( return result -def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]: - """Return Telegram menu commands capped to the Bot API limit. +# Backward-compat alias. +_clamp_telegram_names = _clamp_command_names - Priority order (higher priority = never bumped by overflow): - 1. Core CommandDef commands (always included) - 2. Plugin slash commands (take precedence over skills) - 3. Built-in skill commands (fill remaining slots, alphabetical) - Skills are the only tier that gets trimmed when the cap is hit. - User-installed hub skills are excluded — accessible via /skills. - Skills disabled for the ``"telegram"`` platform (via ``hermes skills - config``) are excluded from the menu entirely. +# --------------------------------------------------------------------------- +# Shared skill/plugin collection for gateway platforms +# --------------------------------------------------------------------------- + +def _collect_gateway_skill_entries( + platform: str, + max_slots: int, + reserved_names: set[str], + desc_limit: int = 100, + sanitize_name: "Callable[[str], str] | None" = None, +) -> tuple[list[tuple[str, str, str]], int]: + """Collect plugin + skill entries for a gateway platform. + + Priority order: + 1. Plugin slash commands (take precedence over skills) + 2. Built-in skill commands (fill remaining slots, alphabetical) + + Only skills are trimmed when the cap is reached. + Hub-installed skills are excluded. Per-platform disabled skills are + excluded. + + Args: + platform: Platform identifier for per-platform skill filtering + (``"telegram"``, ``"discord"``, etc.). + max_slots: Maximum number of entries to return (remaining slots after + built-in/core commands). + reserved_names: Names already taken by built-in commands. Mutated + in-place as new names are added. + desc_limit: Max description length (40 for Telegram, 100 for Discord). + sanitize_name: Optional name transform applied before clamping, e.g. + :func:`_sanitize_telegram_name` for Telegram. May return an + empty string to signal "skip this entry". Returns: - (menu_commands, hidden_count) where hidden_count is the number of - skill commands omitted due to the cap. + ``(entries, hidden_count)`` where *entries* is a list of + ``(name, description, cmd_key)`` triples and *hidden_count* is the + number of skill entries dropped due to the cap. ``cmd_key`` is the + original ``/skill-name`` key from :func:`get_skill_commands`. """ - core_commands = list(telegram_bot_commands()) - # Reserve core names so plugin/skill truncation can't collide with them - reserved_names = {n for n, _ in core_commands} - all_commands = list(core_commands) + all_entries: list[tuple[str, str, str]] = [] - # Plugin slash commands get priority over skills - plugin_entries: list[tuple[str, str]] = [] + # --- Tier 1: Plugin slash commands (never trimmed) --------------------- + plugin_pairs: list[tuple[str, str]] = [] try: from hermes_cli.plugins import get_plugin_manager pm = get_plugin_manager() plugin_cmds = getattr(pm, "_plugin_commands", {}) for cmd_name in sorted(plugin_cmds): - tg_name = _sanitize_telegram_name(cmd_name) - if not tg_name: + name = sanitize_name(cmd_name) if sanitize_name else cmd_name + if not name: continue desc = "Plugin command" - if len(desc) > 40: - desc = desc[:37] + "..." - plugin_entries.append((tg_name, desc)) + if len(desc) > desc_limit: + desc = desc[:desc_limit - 3] + "..." + plugin_pairs.append((name, desc)) except Exception: pass - # Clamp plugin names to 32 chars with collision avoidance - plugin_entries = _clamp_telegram_names(plugin_entries, reserved_names) - reserved_names.update(n for n, _ in plugin_entries) - all_commands.extend(plugin_entries) + plugin_pairs = _clamp_command_names(plugin_pairs, reserved_names) + reserved_names.update(n for n, _ in plugin_pairs) + # Plugins have no cmd_key — use empty string as placeholder + for n, d in plugin_pairs: + all_entries.append((n, d, "")) - # Load per-platform disabled skills so they don't consume menu slots. - # get_skill_commands() already filters the *global* disabled list, but - # per-platform overrides (skills.platform_disabled.telegram) were never - # applied here — that's what this block fixes. + # --- Tier 2: Built-in skill commands (trimmed at cap) ----------------- _platform_disabled: set[str] = set() try: from agent.skill_utils import get_disabled_skill_names - _platform_disabled = get_disabled_skill_names(platform="telegram") + _platform_disabled = get_disabled_skill_names(platform=platform) except Exception: pass - # Remaining slots go to built-in skill commands (not hub-installed). - skill_entries: list[tuple[str, str]] = [] + skill_triples: list[tuple[str, str, str]] = [] try: from agent.skill_commands import get_skill_commands from tools.skills_tool import SKILLS_DIR @@ -497,32 +522,103 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str continue if skill_path.startswith(_hub_dir): continue - # Skip skills disabled for telegram skill_name = info.get("name", "") if skill_name in _platform_disabled: continue - name = _sanitize_telegram_name(cmd_key.lstrip("/")) + raw_name = cmd_key.lstrip("/") + name = sanitize_name(raw_name) if sanitize_name else raw_name if not name: continue desc = info.get("description", "") - # Keep descriptions short — setMyCommands has an undocumented - # total payload limit. 40 chars fits 100 commands safely. - if len(desc) > 40: - desc = desc[:37] + "..." - skill_entries.append((name, desc)) + if len(desc) > desc_limit: + desc = desc[:desc_limit - 3] + "..." + skill_triples.append((name, desc, cmd_key)) except Exception: pass - # Clamp skill names to 32 chars with collision avoidance - skill_entries = _clamp_telegram_names(skill_entries, reserved_names) + # Clamp names; _clamp_command_names works on (name, desc) pairs so we + # need to zip/unzip. + skill_pairs = [(n, d) for n, d, _ in skill_triples] + key_by_pair = {(n, d): k for n, d, k in skill_triples} + skill_pairs = _clamp_command_names(skill_pairs, reserved_names) + + # Skills fill remaining slots — only tier that gets trimmed + remaining = max(0, max_slots - len(all_entries)) + hidden_count = max(0, len(skill_pairs) - remaining) + for n, d in skill_pairs[:remaining]: + all_entries.append((n, d, key_by_pair.get((n, d), ""))) + + return all_entries[:max_slots], hidden_count + + +# --------------------------------------------------------------------------- +# Platform-specific wrappers +# --------------------------------------------------------------------------- + +def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]: + """Return Telegram menu commands capped to the Bot API limit. + + Priority order (higher priority = never bumped by overflow): + 1. Core CommandDef commands (always included) + 2. Plugin slash commands (take precedence over skills) + 3. Built-in skill commands (fill remaining slots, alphabetical) + + Skills are the only tier that gets trimmed when the cap is hit. + User-installed hub skills are excluded — accessible via /skills. + Skills disabled for the ``"telegram"`` platform (via ``hermes skills + config``) are excluded from the menu entirely. + + Returns: + (menu_commands, hidden_count) where hidden_count is the number of + skill commands omitted due to the cap. + """ + core_commands = list(telegram_bot_commands()) + reserved_names = {n for n, _ in core_commands} + all_commands = list(core_commands) - # Skills fill remaining slots — they're the only tier that gets trimmed remaining_slots = max(0, max_commands - len(all_commands)) - hidden_count = max(0, len(skill_entries) - remaining_slots) - all_commands.extend(skill_entries[:remaining_slots]) + entries, hidden_count = _collect_gateway_skill_entries( + platform="telegram", + max_slots=remaining_slots, + reserved_names=reserved_names, + desc_limit=40, + sanitize_name=_sanitize_telegram_name, + ) + # Drop the cmd_key — Telegram only needs (name, desc) pairs. + all_commands.extend((n, d) for n, d, _k in entries) return all_commands[:max_commands], hidden_count +def discord_skill_commands( + max_slots: int, + reserved_names: set[str], +) -> tuple[list[tuple[str, str, str]], int]: + """Return skill entries for Discord slash command registration. + + Same priority and filtering logic as :func:`telegram_menu_commands` + (plugins > skills, hub excluded, per-platform disabled excluded), but + adapted for Discord's constraints: + + - Hyphens are allowed in names (no ``-`` → ``_`` sanitization) + - Descriptions capped at 100 chars (Discord's per-field max) + + Args: + max_slots: Available command slots (100 minus existing built-in count). + reserved_names: Names of already-registered built-in commands. + + Returns: + ``(entries, hidden_count)`` where *entries* is a list of + ``(discord_name, description, cmd_key)`` triples. ``cmd_key`` is + the original ``/skill-name`` key needed for the slash handler callback. + """ + return _collect_gateway_skill_entries( + platform="discord", + max_slots=max_slots, + reserved_names=set(reserved_names), # copy — don't mutate caller's set + desc_limit=100, + ) + + def slack_subcommand_map() -> dict[str, str]: """Return subcommand -> /command mapping for Slack /hermes handler. diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 1ff1a18aa372..81c262a84082 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -12,9 +12,12 @@ SUBCOMMANDS, SlashCommandAutoSuggest, SlashCommandCompleter, + _CMD_NAME_LIMIT, _TG_NAME_LIMIT, + _clamp_command_names, _clamp_telegram_names, _sanitize_telegram_name, + discord_skill_commands, gateway_help_lines, resolve_command, slack_subcommand_map, @@ -751,3 +754,238 @@ def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch): assert "valid_skill" in menu_names # No empty string in menu names assert "" not in menu_names + + +# --------------------------------------------------------------------------- +# Backward-compat aliases +# --------------------------------------------------------------------------- + +class TestBackwardCompatAliases: + """The renamed constants/functions still exist under the old names.""" + + def test_tg_name_limit_alias(self): + assert _TG_NAME_LIMIT == _CMD_NAME_LIMIT == 32 + + def test_clamp_telegram_names_is_clamp_command_names(self): + assert _clamp_telegram_names is _clamp_command_names + + +# --------------------------------------------------------------------------- +# Discord skill command registration +# --------------------------------------------------------------------------- + +class TestDiscordSkillCommands: + """Tests for discord_skill_commands() — centralized skill registration.""" + + def test_returns_skill_entries(self, tmp_path, monkeypatch): + """Skills under SKILLS_DIR (not .hub) should be returned.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + "/gif-search": { + "name": "gif-search", + "description": "Search for GIFs", + "skill_md_path": f"{fake_skills_dir}/gif-search/SKILL.md", + "skill_dir": f"{fake_skills_dir}/gif-search", + }, + "/code-review": { + "name": "code-review", + "description": "Review code changes", + "skill_md_path": f"{fake_skills_dir}/code-review/SKILL.md", + "skill_dir": f"{fake_skills_dir}/code-review", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "skills").mkdir(exist_ok=True) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + entries, hidden = discord_skill_commands( + max_slots=50, reserved_names=set(), + ) + + names = {n for n, _d, _k in entries} + assert "gif-search" in names + assert "code-review" in names + assert hidden == 0 + # Verify cmd_key is preserved for handler callbacks + keys = {k for _n, _d, k in entries} + assert "/gif-search" in keys + assert "/code-review" in keys + + def test_names_allow_hyphens(self, tmp_path, monkeypatch): + """Discord names should keep hyphens (unlike Telegram's _ sanitization).""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + "/my-cool-skill": { + "name": "my-cool-skill", + "description": "A cool skill", + "skill_md_path": f"{fake_skills_dir}/my-cool-skill/SKILL.md", + "skill_dir": f"{fake_skills_dir}/my-cool-skill", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "skills").mkdir(exist_ok=True) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + entries, _ = discord_skill_commands( + max_slots=50, reserved_names=set(), + ) + + assert entries[0][0] == "my-cool-skill" # hyphens preserved + + def test_cap_enforcement(self, tmp_path, monkeypatch): + """Entries beyond max_slots should be hidden.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + f"/skill-{i:03d}": { + "name": f"skill-{i:03d}", + "description": f"Skill {i}", + "skill_md_path": f"{fake_skills_dir}/skill-{i:03d}/SKILL.md", + "skill_dir": f"{fake_skills_dir}/skill-{i:03d}", + } + for i in range(20) + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "skills").mkdir(exist_ok=True) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + entries, hidden = discord_skill_commands( + max_slots=5, reserved_names=set(), + ) + + assert len(entries) == 5 + assert hidden == 15 + + def test_excludes_discord_disabled_skills(self, tmp_path, monkeypatch): + """Skills disabled for discord should not appear.""" + from unittest.mock import patch + + config_file = tmp_path / "config.yaml" + config_file.write_text( + "skills:\n" + " platform_disabled:\n" + " discord:\n" + " - secret-skill\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + "/secret-skill": { + "name": "secret-skill", + "description": "Should not appear", + "skill_md_path": f"{fake_skills_dir}/secret-skill/SKILL.md", + "skill_dir": f"{fake_skills_dir}/secret-skill", + }, + "/public-skill": { + "name": "public-skill", + "description": "Should appear", + "skill_md_path": f"{fake_skills_dir}/public-skill/SKILL.md", + "skill_dir": f"{fake_skills_dir}/public-skill", + }, + } + (tmp_path / "skills").mkdir(exist_ok=True) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + entries, _ = discord_skill_commands( + max_slots=50, reserved_names=set(), + ) + + names = {n for n, _d, _k in entries} + assert "secret-skill" not in names + assert "public-skill" in names + + def test_reserved_names_not_overwritten(self, tmp_path, monkeypatch): + """Skills whose names collide with built-in commands should be skipped.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + "/status": { + "name": "status", + "description": "Skill that collides with built-in", + "skill_md_path": f"{fake_skills_dir}/status/SKILL.md", + "skill_dir": f"{fake_skills_dir}/status", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "skills").mkdir(exist_ok=True) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + entries, _ = discord_skill_commands( + max_slots=50, reserved_names={"status"}, + ) + + names = {n for n, _d, _k in entries} + assert "status" not in names + + def test_description_truncated_at_100_chars(self, tmp_path, monkeypatch): + """Descriptions exceeding 100 chars should be truncated.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + long_desc = "x" * 150 + fake_cmds = { + "/verbose-skill": { + "name": "verbose-skill", + "description": long_desc, + "skill_md_path": f"{fake_skills_dir}/verbose-skill/SKILL.md", + "skill_dir": f"{fake_skills_dir}/verbose-skill", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "skills").mkdir(exist_ok=True) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + entries, _ = discord_skill_commands( + max_slots=50, reserved_names=set(), + ) + + assert len(entries[0][1]) == 100 + assert entries[0][1].endswith("...") + + def test_all_names_within_32_chars(self, tmp_path, monkeypatch): + """All returned names must respect the 32-char Discord limit.""" + from unittest.mock import patch + + fake_skills_dir = str(tmp_path / "skills") + long_name = "a" * 50 + fake_cmds = { + f"/{long_name}": { + "name": long_name, + "description": "Long name skill", + "skill_md_path": f"{fake_skills_dir}/{long_name}/SKILL.md", + "skill_dir": f"{fake_skills_dir}/{long_name}", + }, + } + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "skills").mkdir(exist_ok=True) + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + entries, _ = discord_skill_commands( + max_slots=50, reserved_names=set(), + ) + + for name, _d, _k in entries: + assert len(name) <= _CMD_NAME_LIMIT, ( + f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})" + ) From da02a4e283e4fc7aca249d19d0652925cbe5177b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:41:40 -0700 Subject: [PATCH 131/213] =?UTF-8?q?fix:=20auxiliary=20client=20payment=20f?= =?UTF-8?q?allback=20=E2=80=94=20retry=20with=20next=20provider=20on=20402?= =?UTF-8?q?=20(#5599)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user runs out of OpenRouter credits and switches to Codex (or any other provider), auxiliary tasks (compression, vision, web_extract) would still try OpenRouter first and fail with 402. Two fixes: 1. Payment fallback in call_llm(): When a resolved provider returns HTTP 402 or a credit-related error, automatically retry with the next available provider in the auto-detection chain. Skips the depleted provider and tries Nous → Custom → Codex → API-key providers. 2. Remove hardcoded OpenRouter fallback: The old code fell back specifically to OpenRouter when auto/custom resolution returned no client. Now falls back to the full auto-detection chain, which handles any available provider — not just OpenRouter. Also extracts _get_provider_chain() as a shared function (replaces inline tuple in _resolve_auto and the new fallback), built at call time so test patches on _try_* functions remain visible. Adds 16 tests covering _is_payment_error(), _get_provider_chain(), _try_payment_fallback(), and call_llm() integration with 402 retry. --- agent/auxiliary_client.py | 131 +++++++++++++++++-- tests/agent/test_auxiliary_client.py | 184 +++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 11 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 5cceeb9e3057..95d5def0ac07 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -34,6 +34,12 @@ Per-task direct endpoint overrides (e.g. AUXILIARY_VISION_BASE_URL, AUXILIARY_VISION_API_KEY) let callers route a specific auxiliary task to a custom OpenAI-compatible endpoint without touching the main model settings. + +Payment / credit exhaustion fallback: + When a resolved provider returns HTTP 402 or a credit-related error, + call_llm() automatically retries with the next available provider in the + auto-detection chain. This handles the common case where a user depletes + their OpenRouter balance but has Codex OAuth or another provider available. """ import json @@ -874,10 +880,90 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st "_resolve_api_key_provider": "api-key", } - _AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"}) +def _get_provider_chain() -> List[tuple]: + """Return the ordered provider detection chain. + + Built at call time (not module level) so that test patches + on the ``_try_*`` functions are picked up correctly. + """ + return [ + ("openrouter", _try_openrouter), + ("nous", _try_nous), + ("local/custom", _try_custom_endpoint), + ("openai-codex", _try_codex), + ("api-key", _resolve_api_key_provider), + ] + + +def _is_payment_error(exc: Exception) -> bool: + """Detect payment/credit/quota exhaustion errors. + + Returns True for HTTP 402 (Payment Required) and for 429/other errors + whose message indicates billing exhaustion rather than rate limiting. + """ + status = getattr(exc, "status_code", None) + if status == 402: + return True + err_lower = str(exc).lower() + # OpenRouter and other providers include "credits" or "afford" in 402 bodies, + # but sometimes wrap them in 429 or other codes. + if status in (402, 429, None): + if any(kw in err_lower for kw in ("credits", "insufficient funds", + "can only afford", "billing", + "payment required")): + return True + return False + + +def _try_payment_fallback( + failed_provider: str, + task: str = None, +) -> Tuple[Optional[Any], Optional[str], str]: + """Try alternative providers after a payment/credit error. + + Iterates the standard auto-detection chain, skipping the provider that + returned a payment error. + + Returns: + (client, model, provider_label) or (None, None, "") if no fallback. + """ + # Normalise the failed provider label for matching. + skip = failed_provider.lower().strip() + # Also skip Step-1 main-provider path if it maps to the same backend. + # (e.g. main_provider="openrouter" → skip "openrouter" in chain) + main_provider = _read_main_provider() + skip_labels = {skip} + if main_provider and main_provider.lower() in skip: + skip_labels.add(main_provider.lower()) + # Map common resolved_provider values back to chain labels. + _alias_to_label = {"openrouter": "openrouter", "nous": "nous", + "openai-codex": "openai-codex", "codex": "openai-codex", + "custom": "local/custom", "local/custom": "local/custom"} + skip_chain_labels = {_alias_to_label.get(s, s) for s in skip_labels} + + tried = [] + for label, try_fn in _get_provider_chain(): + if label in skip_chain_labels: + continue + client, model = try_fn() + if client is not None: + logger.info( + "Auxiliary %s: payment error on %s — falling back to %s (%s)", + task or "call", failed_provider, label, model or "default", + ) + return client, model, label + tried.append(label) + + logger.warning( + "Auxiliary %s: payment error on %s and no fallback available (tried: %s)", + task or "call", failed_provider, ", ".join(tried), + ) + return None, None, "" + + def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: """Full auto-detection chain. @@ -905,10 +991,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: # ── Step 2: aggregator / fallback chain ────────────────────────────── tried = [] - for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint, - _try_codex, _resolve_api_key_provider): - fn_name = getattr(try_fn, "__name__", "unknown") - label = _AUTO_PROVIDER_LABELS.get(fn_name, fn_name) + for label, try_fn in _get_provider_chain(): client, model = try_fn() if client is not None: if tried: @@ -1786,12 +1869,15 @@ def call_llm( f"was found. Set the {_explicit.upper()}_API_KEY environment " f"variable, or switch to a different provider with `hermes model`." ) - # For auto/custom, fall back to OpenRouter + # For auto/custom with no credentials, try the full auto chain + # rather than hardcoding OpenRouter (which may be depleted). + # Pass model=None so each provider uses its own default — + # resolved_model may be an OpenRouter-format slug that doesn't + # work on other providers. if not resolved_base_url: - logger.info("Auxiliary %s: provider %s unavailable, falling back to openrouter", + logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", task or "call", resolved_provider) - client, final_model = _get_cached_client( - "openrouter", resolved_model or _OPENROUTER_MODEL) + client, final_model = _get_cached_client("auto") if client is None: raise RuntimeError( f"No LLM provider configured for task={task} provider={resolved_provider}. " @@ -1812,7 +1898,7 @@ def call_llm( tools=tools, timeout=effective_timeout, extra_body=extra_body, base_url=resolved_base_url) - # Handle max_tokens vs max_completion_tokens retry + # Handle max_tokens vs max_completion_tokens retry, then payment fallback. try: return client.chat.completions.create(**kwargs) except Exception as first_err: @@ -1820,7 +1906,30 @@ def call_llm( if "max_tokens" in err_str or "unsupported_parameter" in err_str: kwargs.pop("max_tokens", None) kwargs["max_completion_tokens"] = max_tokens - return client.chat.completions.create(**kwargs) + try: + return client.chat.completions.create(**kwargs) + except Exception as retry_err: + # If the max_tokens retry also hits a payment error, + # fall through to the payment fallback below. + if not _is_payment_error(retry_err): + raise + first_err = retry_err + + # ── Payment / credit exhaustion fallback ────────────────────── + # When the resolved provider returns 402 or a credit-related error, + # try alternative providers instead of giving up. This handles the + # common case where a user runs out of OpenRouter credits but has + # Codex OAuth or another provider available. + if _is_payment_error(first_err): + fb_client, fb_model, fb_label = _try_payment_fallback( + resolved_provider, task) + if fb_client is not None: + fb_kwargs = _build_call_kwargs( + fb_label, fb_model, messages, + temperature=temperature, max_tokens=max_tokens, + tools=tools, timeout=effective_timeout, + extra_body=extra_body) + return fb_client.chat.completions.create(**fb_kwargs) raise diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index eb03a64c9f9b..32f481988e63 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -14,8 +14,12 @@ resolve_vision_provider_client, resolve_provider_client, auxiliary_max_tokens_param, + call_llm, _read_codex_access_token, _get_auxiliary_provider, + _get_provider_chain, + _is_payment_error, + _try_payment_fallback, _resolve_forced_provider, _resolve_auto, ) @@ -1106,3 +1110,183 @@ def test_no_provider_uses_max_tokens(self): patch("agent.auxiliary_client._read_codex_access_token", return_value=None): result = auxiliary_max_tokens_param(1024) assert result == {"max_tokens": 1024} + + +# ── Payment / credit exhaustion fallback ───────────────────────────────── + + +class TestIsPaymentError: + """_is_payment_error detects 402 and credit-related errors.""" + + def test_402_status_code(self): + exc = Exception("Payment Required") + exc.status_code = 402 + assert _is_payment_error(exc) is True + + def test_402_with_credits_message(self): + exc = Exception("You requested up to 65535 tokens, but can only afford 8029") + exc.status_code = 402 + assert _is_payment_error(exc) is True + + def test_429_with_credits_message(self): + exc = Exception("insufficient credits remaining") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_without_credits_message_is_not_payment(self): + """Normal rate limits should NOT be treated as payment errors.""" + exc = Exception("Rate limit exceeded, try again in 2 seconds") + exc.status_code = 429 + assert _is_payment_error(exc) is False + + def test_generic_500_is_not_payment(self): + exc = Exception("Internal server error") + exc.status_code = 500 + assert _is_payment_error(exc) is False + + def test_no_status_code_with_billing_message(self): + exc = Exception("billing: payment required for this request") + assert _is_payment_error(exc) is True + + def test_no_status_code_no_message(self): + exc = Exception("connection reset") + assert _is_payment_error(exc) is False + + +class TestGetProviderChain: + """_get_provider_chain() resolves functions at call time (testable).""" + + def test_returns_five_entries(self): + chain = _get_provider_chain() + assert len(chain) == 5 + labels = [label for label, _ in chain] + assert labels == ["openrouter", "nous", "local/custom", "openai-codex", "api-key"] + + def test_picks_up_patched_functions(self): + """Patches on _try_* functions must be visible in the chain.""" + sentinel = lambda: ("patched", "model") + with patch("agent.auxiliary_client._try_openrouter", sentinel): + chain = _get_provider_chain() + assert chain[0] == ("openrouter", sentinel) + + +class TestTryPaymentFallback: + """_try_payment_fallback skips the failed provider and tries alternatives.""" + + def test_skips_failed_provider(self): + mock_client = MagicMock() + with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_nous", return_value=(mock_client, "nous-model")), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"): + client, model, label = _try_payment_fallback("openrouter", task="compression") + assert client is mock_client + assert model == "nous-model" + assert label == "nous" + + def test_returns_none_when_no_fallback(self): + with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_codex", return_value=(None, None)), \ + patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"): + client, model, label = _try_payment_fallback("openrouter") + assert client is None + assert label == "" + + def test_codex_alias_maps_to_chain_label(self): + """'codex' should map to 'openai-codex' in the skip set.""" + mock_client = MagicMock() + with patch("agent.auxiliary_client._try_openrouter", return_value=(mock_client, "or-model")), \ + patch("agent.auxiliary_client._try_codex", return_value=(None, None)), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openai-codex"): + client, model, label = _try_payment_fallback("openai-codex", task="vision") + assert client is mock_client + assert label == "openrouter" + + def test_skips_to_codex_when_or_and_nous_fail(self): + mock_codex = MagicMock() + with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ + patch("agent.auxiliary_client._try_codex", return_value=(mock_codex, "gpt-5.2-codex")), \ + patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"): + client, model, label = _try_payment_fallback("openrouter") + assert client is mock_codex + assert model == "gpt-5.2-codex" + assert label == "openai-codex" + + +class TestCallLlmPaymentFallback: + """call_llm() retries with a different provider on 402 / payment errors.""" + + def _make_402_error(self, msg="Payment Required: insufficient credits"): + exc = Exception(msg) + exc.status_code = 402 + return exc + + def test_402_triggers_fallback(self, monkeypatch): + """When the primary provider returns 402, call_llm tries the next one.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_402_error() + + fallback_client = MagicMock() + fallback_response = MagicMock() + fallback_client.chat.completions.create.return_value = fallback_response + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "google/gemini-3-flash-preview")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \ + patch("agent.auxiliary_client._try_payment_fallback", + return_value=(fallback_client, "gpt-5.2-codex", "openai-codex")) as mock_fb: + result = call_llm( + task="compression", + messages=[{"role": "user", "content": "hello"}], + ) + + assert result is fallback_response + mock_fb.assert_called_once_with("openrouter", "compression") + # Fallback call should use the fallback model + fb_kwargs = fallback_client.chat.completions.create.call_args.kwargs + assert fb_kwargs["model"] == "gpt-5.2-codex" + + def test_non_payment_error_not_caught(self, monkeypatch): + """Non-payment errors (500, connection, etc.) should NOT trigger fallback.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + server_err = Exception("Internal Server Error") + server_err.status_code = 500 + primary_client.chat.completions.create.side_effect = server_err + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "google/gemini-3-flash-preview")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("openrouter", "google/gemini-3-flash-preview", None, None)): + with pytest.raises(Exception, match="Internal Server Error"): + call_llm( + task="compression", + messages=[{"role": "user", "content": "hello"}], + ) + + def test_402_with_no_fallback_reraises(self, monkeypatch): + """When 402 hits and no fallback is available, the original error propagates.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_402_error() + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "google/gemini-3-flash-preview")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \ + patch("agent.auxiliary_client._try_payment_fallback", + return_value=(None, None, "")): + with pytest.raises(Exception, match="insufficient credits"): + call_llm( + task="compression", + messages=[{"role": "user", "content": "hello"}], + ) From 7d0953d6ff3903134bc45e1c5f24ef9e8d62ecdb Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Mon, 6 Apr 2026 16:05:15 +0300 Subject: [PATCH 132/213] security(gateway): isolate env/credential registries using ContextVars --- gateway/run.py | 12 ++++++++++++ tools/credential_files.py | 22 +++++++++++++++++----- tools/env_passthrough.py | 24 ++++++++++++++++++------ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index f909a2c738c5..c50c67462222 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3252,6 +3252,18 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: logger.debug("Gateway memory flush on reset failed: %s", e) self._evict_cached_agent(session_key) + try: + from tools.env_passthrough import clear_env_passthrough + clear_env_passthrough() + except Exception: + pass + + try: + from tools.credential_files import clear_credential_files + clear_credential_files() + except Exception: + pass + # Reset the session new_entry = self.session_store.reset_session(session_key) diff --git a/tools/credential_files.py b/tools/credential_files.py index 9a30f9bff36a..49768bff4831 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -22,14 +22,26 @@ import logging import os +from contextvars import ContextVar from pathlib import Path from typing import Dict, List logger = logging.getLogger(__name__) # Session-scoped list of credential files to mount. -# Key: container_path (deduplicated), Value: host_path -_registered_files: Dict[str, str] = {} +# Backed by ContextVar to prevent cross-session data bleed in the gateway pipeline. +_registered_files_var: ContextVar[Dict[str, str]] = ContextVar("_registered_files") + + +def _get_registered() -> Dict[str, str]: + """Get or create the registered credential files dict for the current context/session.""" + try: + return _registered_files_var.get() + except LookupError: + val: Dict[str, str] = {} + _registered_files_var.set(val) + return val + # Cache for config-based file list (loaded once per process). _config_files: List[Dict[str, str]] | None = None @@ -86,7 +98,7 @@ def register_credential_file( return False container_path = f"{container_base.rstrip('/')}/{relative_path}" - _registered_files[container_path] = str(resolved) + _get_registered()[container_path] = str(resolved) logger.debug("credential_files: registered %s -> %s", resolved, container_path) return True @@ -174,7 +186,7 @@ def get_credential_file_mounts() -> List[Dict[str, str]]: mounts: Dict[str, str] = {} # Skill-registered files - for container_path, host_path in _registered_files.items(): + for container_path, host_path in _get_registered().items(): # Re-check existence (file may have been deleted since registration) if Path(host_path).is_file(): mounts[container_path] = host_path @@ -395,7 +407,7 @@ def iter_cache_files( def clear_credential_files() -> None: """Reset the skill-scoped registry (e.g. on session reset).""" - _registered_files.clear() + _get_registered().clear() def reset_config_cache() -> None: diff --git a/tools/env_passthrough.py b/tools/env_passthrough.py index 29e94e7c3560..e8dc5127265d 100644 --- a/tools/env_passthrough.py +++ b/tools/env_passthrough.py @@ -21,13 +21,25 @@ import logging import os -from pathlib import Path +from contextvars import ContextVar from typing import Iterable logger = logging.getLogger(__name__) # Session-scoped set of env var names that should pass through to sandboxes. -_allowed_env_vars: set[str] = set() +# Backed by ContextVar to prevent cross-session data bleed in the gateway pipeline. +_allowed_env_vars_var: ContextVar[set[str]] = ContextVar("_allowed_env_vars") + + +def _get_allowed() -> set[str]: + """Get or create the allowed env vars set for the current context/session.""" + try: + return _allowed_env_vars_var.get() + except LookupError: + val: set[str] = set() + _allowed_env_vars_var.set(val) + return val + # Cache for the config-based allowlist (loaded once per process). _config_passthrough: frozenset[str] | None = None @@ -41,7 +53,7 @@ def register_env_passthrough(var_names: Iterable[str]) -> None: for name in var_names: name = name.strip() if name: - _allowed_env_vars.add(name) + _get_allowed().add(name) logger.debug("env passthrough: registered %s", name) @@ -78,19 +90,19 @@ def is_env_passthrough(var_name: str) -> bool: Returns ``True`` if the variable was registered by a skill or listed in the user's ``tools.env_passthrough`` config. """ - if var_name in _allowed_env_vars: + if var_name in _get_allowed(): return True return var_name in _load_config_passthrough() def get_all_passthrough() -> frozenset[str]: """Return the union of skill-registered and config-based passthrough vars.""" - return frozenset(_allowed_env_vars) | _load_config_passthrough() + return frozenset(_get_allowed()) | _load_config_passthrough() def clear_env_passthrough() -> None: """Reset the skill-scoped allowlist (e.g. on session reset).""" - _allowed_env_vars.clear() + _get_allowed().clear() def reset_config_cache() -> None: From 878b1d3d33490ae53e4e30f92de78de2315044f9 Mon Sep 17 00:00:00 2001 From: Awsh1 Date: Mon, 6 Apr 2026 12:12:45 -0700 Subject: [PATCH 133/213] fix(cron): harden scheduler against path traversal and env leaks Cherry-picked from PR #5503 by Awsh1. - Validate ALL script paths (absolute, relative, tilde) against scripts_dir boundary - Add API-boundary validation in cronjob_tools.py - Move os.environ injections inside try block so finally cleanup always runs - Comprehensive regression tests for path containment bypass --- cron/scheduler.py | 52 ++++--- tests/cron/test_cron_script.py | 275 +++++++++++++++++++++++++++++++-- tools/cronjob_tools.py | 49 ++++++ 3 files changed, 349 insertions(+), 27 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 606a9ba7befb..63018d6fff9b 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -297,8 +297,15 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None: def _run_job_script(script_path: str) -> tuple[bool, str]: """Execute a cron job's data-collection script and capture its output. + Scripts must reside within HERMES_HOME/scripts/. Both relative and + absolute paths are resolved and validated against this directory to + prevent arbitrary script execution via path traversal or absolute + path injection. + Args: - script_path: Path to a Python script (resolved via HERMES_HOME/scripts/ or absolute). + script_path: Path to a Python script. Relative paths are resolved + against HERMES_HOME/scripts/. Absolute and ~-prefixed paths + are also validated to ensure they stay within the scripts dir. Returns: (success, output) — on failure *output* contains the error message so the @@ -306,16 +313,25 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: """ from hermes_constants import get_hermes_home - path = Path(script_path).expanduser() - if not path.is_absolute(): - # Resolve relative paths against HERMES_HOME/scripts/ - scripts_dir = get_hermes_home() / "scripts" - path = (scripts_dir / path).resolve() - # Guard against path traversal (e.g. "../../etc/passwd") - try: - path.relative_to(scripts_dir.resolve()) - except ValueError: - return False, f"Script path escapes the scripts directory: {script_path!r}" + scripts_dir = get_hermes_home() / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + scripts_dir_resolved = scripts_dir.resolve() + + raw = Path(script_path).expanduser() + if raw.is_absolute(): + path = raw.resolve() + else: + path = (scripts_dir / raw).resolve() + + # Guard against path traversal, absolute path injection, and symlink + # escape — scripts MUST reside within HERMES_HOME/scripts/. + try: + path.relative_to(scripts_dir_resolved) + except ValueError: + return False, ( + f"Blocked: script path resolves outside the scripts directory " + f"({scripts_dir_resolved}): {script_path!r}" + ) if not path.exists(): return False, f"Script not found: {path}" @@ -473,14 +489,14 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: logger.info("Running job '%s' (ID: %s)", job_name, job_id) logger.info("Prompt: %s", prompt[:100]) - # Inject origin context so the agent's send_message tool knows the chat - if origin: - os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"] - os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"]) - if origin.get("chat_name"): - os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"] - try: + # Inject origin context so the agent's send_message tool knows the chat. + # Must be INSIDE the try block so the finally cleanup always runs. + if origin: + os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"] + os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"]) + if origin.get("chat_name"): + os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"] # Re-read .env and config.yaml fresh every run so provider/key # changes take effect without a gateway restart. from dotenv import load_dotenv diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index e833963547cc..d7f278aa9640 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -114,7 +114,7 @@ def test_script_relative_path(self, cron_env): def test_script_not_found(self, cron_env): from cron.scheduler import _run_job_script - success, output = _run_job_script("/nonexistent/script.py") + success, output = _run_job_script("nonexistent_script.py") assert success is False assert "not found" in output.lower() @@ -198,7 +198,7 @@ def test_script_error_injected(self, cron_env): job = { "prompt": "Report status.", - "script": "/nonexistent/script.py", + "script": "nonexistent_monitor.py", } prompt = _build_job_prompt(job) assert "## Script Error" in prompt @@ -239,10 +239,10 @@ def test_create_with_script(self, cron_env, monkeypatch): action="create", schedule="every 1h", prompt="Monitor things", - script="/home/user/monitor.py", + script="monitor.py", )) assert result["success"] is True - assert result["job"]["script"] == "/home/user/monitor.py" + assert result["job"]["script"] == "monitor.py" def test_update_script(self, cron_env, monkeypatch): monkeypatch.setenv("HERMES_INTERACTIVE", "1") @@ -258,10 +258,10 @@ def test_update_script(self, cron_env, monkeypatch): update_result = json.loads(cronjob( action="update", job_id=job_id, - script="/new/script.py", + script="new_script.py", )) assert update_result["success"] is True - assert update_result["job"]["script"] == "/new/script.py" + assert update_result["job"]["script"] == "new_script.py" def test_clear_script(self, cron_env, monkeypatch): monkeypatch.setenv("HERMES_INTERACTIVE", "1") @@ -271,7 +271,7 @@ def test_clear_script(self, cron_env, monkeypatch): action="create", schedule="every 1h", prompt="Monitor things", - script="/some/script.py", + script="some_script.py", )) job_id = create_result["job_id"] @@ -291,10 +291,267 @@ def test_list_shows_script(self, cron_env, monkeypatch): action="create", schedule="every 1h", prompt="Monitor things", - script="/path/to/script.py", + script="data_collector.py", ) list_result = json.loads(cronjob(action="list")) assert list_result["success"] is True assert len(list_result["jobs"]) == 1 - assert list_result["jobs"][0]["script"] == "/path/to/script.py" + assert list_result["jobs"][0]["script"] == "data_collector.py" + + +class TestScriptPathContainment: + """Regression tests for path containment bypass in _run_job_script(). + + Prior to the fix, absolute paths and ~-prefixed paths bypassed the + scripts_dir containment check entirely, allowing arbitrary script + execution through the cron system. + """ + + def test_absolute_path_outside_scripts_dir_blocked(self, cron_env): + """Absolute paths outside ~/.hermes/scripts/ must be rejected.""" + from cron.scheduler import _run_job_script + + # Create a script outside the scripts dir + outside_script = cron_env / "outside.py" + outside_script.write_text('print("should not run")\n') + + success, output = _run_job_script(str(outside_script)) + assert success is False + assert "blocked" in output.lower() or "outside" in output.lower() + + def test_absolute_path_tmp_blocked(self, cron_env): + """Absolute paths to /tmp must be rejected.""" + from cron.scheduler import _run_job_script + + success, output = _run_job_script("/tmp/evil.py") + assert success is False + assert "blocked" in output.lower() or "outside" in output.lower() + + def test_tilde_path_blocked(self, cron_env): + """~ prefixed paths must be rejected (expanduser bypasses check).""" + from cron.scheduler import _run_job_script + + success, output = _run_job_script("~/evil.py") + assert success is False + assert "blocked" in output.lower() or "outside" in output.lower() + + def test_tilde_traversal_blocked(self, cron_env): + """~/../../../tmp/evil.py must be rejected.""" + from cron.scheduler import _run_job_script + + success, output = _run_job_script("~/../../../tmp/evil.py") + assert success is False + assert "blocked" in output.lower() or "outside" in output.lower() + + def test_relative_traversal_still_blocked(self, cron_env): + """../../etc/passwd style traversal must still be blocked.""" + from cron.scheduler import _run_job_script + + success, output = _run_job_script("../../etc/passwd") + assert success is False + assert "blocked" in output.lower() or "outside" in output.lower() + + def test_relative_path_inside_scripts_dir_allowed(self, cron_env): + """Relative paths within the scripts dir should still work.""" + from cron.scheduler import _run_job_script + + script = cron_env / "scripts" / "good.py" + script.write_text('print("ok")\n') + + success, output = _run_job_script("good.py") + assert success is True + assert output == "ok" + + def test_subdirectory_inside_scripts_dir_allowed(self, cron_env): + """Relative paths to subdirectories within scripts/ should work.""" + from cron.scheduler import _run_job_script + + subdir = cron_env / "scripts" / "monitors" + subdir.mkdir() + script = subdir / "check.py" + script.write_text('print("sub ok")\n') + + success, output = _run_job_script("monitors/check.py") + assert success is True + assert output == "sub ok" + + def test_absolute_path_inside_scripts_dir_allowed(self, cron_env): + """Absolute paths that resolve WITHIN scripts/ should work.""" + from cron.scheduler import _run_job_script + + script = cron_env / "scripts" / "abs_ok.py" + script.write_text('print("abs ok")\n') + + success, output = _run_job_script(str(script)) + assert success is True + assert output == "abs ok" + + @pytest.mark.skipif( + sys.platform == "win32", + reason="Symlinks require elevated privileges on Windows", + ) + def test_symlink_escape_blocked(self, cron_env, tmp_path): + """Symlinks pointing outside scripts/ must be rejected.""" + from cron.scheduler import _run_job_script + + # Create a script outside the scripts dir + outside = tmp_path / "outside_evil.py" + outside.write_text('print("escaped")\n') + + # Create a symlink inside scripts/ pointing outside + link = cron_env / "scripts" / "sneaky.py" + link.symlink_to(outside) + + success, output = _run_job_script("sneaky.py") + assert success is False + assert "blocked" in output.lower() or "outside" in output.lower() + + +class TestCronjobToolScriptValidation: + """Test API-boundary validation of cron script paths in cronjob_tools.""" + + def test_create_with_absolute_script_rejected(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="/home/user/evil.py", + )) + assert result["success"] is False + assert "relative" in result["error"].lower() or "absolute" in result["error"].lower() + + def test_create_with_tilde_script_rejected(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="~/monitor.py", + )) + assert result["success"] is False + assert "relative" in result["error"].lower() or "absolute" in result["error"].lower() + + def test_create_with_traversal_script_rejected(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="../../etc/passwd", + )) + assert result["success"] is False + assert "escapes" in result["error"].lower() or "traversal" in result["error"].lower() + + def test_create_with_relative_script_allowed(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="monitor.py", + )) + assert result["success"] is True + assert result["job"]["script"] == "monitor.py" + + def test_update_with_absolute_script_rejected(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + create_result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + )) + job_id = create_result["job_id"] + + update_result = json.loads(cronjob( + action="update", + job_id=job_id, + script="/tmp/evil.py", + )) + assert update_result["success"] is False + assert "relative" in update_result["error"].lower() or "absolute" in update_result["error"].lower() + + def test_update_clear_script_allowed(self, cron_env, monkeypatch): + """Clearing a script (empty string) should always be permitted.""" + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + create_result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="monitor.py", + )) + job_id = create_result["job_id"] + + update_result = json.loads(cronjob( + action="update", + job_id=job_id, + script="", + )) + assert update_result["success"] is True + assert "script" not in update_result["job"] + + def test_windows_absolute_path_rejected(self, cron_env, monkeypatch): + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + from tools.cronjob_tools import cronjob + + result = json.loads(cronjob( + action="create", + schedule="every 1h", + prompt="Monitor things", + script="C:\\Users\\evil\\script.py", + )) + assert result["success"] is False + + +class TestRunJobEnvVarCleanup: + """Test that run_job() env vars are cleaned up even on early failure.""" + + def test_env_vars_cleaned_on_early_error(self, cron_env, monkeypatch): + """Origin env vars must be cleaned up even if run_job fails early.""" + # Ensure env vars are clean before test + for key in ( + "HERMES_SESSION_PLATFORM", + "HERMES_SESSION_CHAT_ID", + "HERMES_SESSION_CHAT_NAME", + ): + monkeypatch.delenv(key, raising=False) + + # Build a job with origin info that will fail during execution + # (no valid model, no API key — will raise inside try block) + job = { + "id": "test-envleak", + "name": "env-leak-test", + "prompt": "test", + "schedule_display": "every 1h", + "origin": { + "platform": "telegram", + "chat_id": "12345", + "chat_name": "Test Chat", + }, + } + + from cron.scheduler import run_job + + # Expect it to fail (no model/API key), but env vars must be cleaned + try: + run_job(job) + except Exception: + pass + + # Verify env vars were cleaned up by the finally block + assert os.environ.get("HERMES_SESSION_PLATFORM") is None + assert os.environ.get("HERMES_SESSION_CHAT_ID") is None + assert os.environ.get("HERMES_SESSION_CHAT_NAME") is None diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 965cfe130376..eb13240b15f8 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -112,6 +112,45 @@ def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: return text or None +def _validate_cron_script_path(script: Optional[str]) -> Optional[str]: + """Validate a cron job script path at the API boundary. + + Scripts must be relative paths that resolve within HERMES_HOME/scripts/. + Absolute paths and ~ expansion are rejected to prevent arbitrary script + execution via prompt injection. + + Returns an error string if blocked, else None (valid). + """ + if not script or not script.strip(): + return None # empty/None = clearing the field, always OK + + from pathlib import Path + from hermes_constants import get_hermes_home + + raw = script.strip() + + # Reject absolute paths and ~ expansion at the API boundary. + # Only relative paths within ~/.hermes/scripts/ are allowed. + if raw.startswith(("/", "~")) or (len(raw) >= 2 and raw[1] == ":"): + return ( + f"Script path must be relative to ~/.hermes/scripts/. " + f"Got absolute or home-relative path: {raw!r}. " + f"Place scripts in ~/.hermes/scripts/ and use just the filename." + ) + + # Validate containment after resolution + scripts_dir = get_hermes_home() / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + resolved = (scripts_dir / raw).resolve() + try: + resolved.relative_to(scripts_dir.resolve()) + except ValueError: + return ( + f"Script path escapes the scripts directory via traversal: {raw!r}" + ) + + return None + def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: prompt = job.get("prompt", "") @@ -176,6 +215,12 @@ def cronjob( if scan_error: return json.dumps({"success": False, "error": scan_error}, indent=2) + # Validate script path before storing + if script: + script_error = _validate_cron_script_path(script) + if script_error: + return json.dumps({"success": False, "error": script_error}, indent=2) + job = create_job( prompt=prompt or "", schedule=schedule, @@ -272,6 +317,10 @@ def cronjob( updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True) if script is not None: # Pass empty string to clear an existing script + if script: + script_error = _validate_cron_script_path(script) + if script_error: + return json.dumps({"success": False, "error": script_error}, indent=2) updates["script"] = _normalize_optional_job_value(script) if script else None if repeat is not None: # Normalize: treat 0 or negative as None (infinite) From 261e2ee8621af0dcae489ad801a3d49f4029eb09 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 12:12:50 -0700 Subject: [PATCH 134/213] fix: restore Path import in env_passthrough.py (removed by #5526) The ContextVar migration removed 'from pathlib import Path' but Path is still used in _load_config_passthrough(). Without this import, config-based env passthrough would raise NameError. --- tools/env_passthrough.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/env_passthrough.py b/tools/env_passthrough.py index e8dc5127265d..1c70d518ff49 100644 --- a/tools/env_passthrough.py +++ b/tools/env_passthrough.py @@ -22,6 +22,7 @@ import logging import os from contextvars import ContextVar +from pathlib import Path from typing import Iterable logger = logging.getLogger(__name__) From 537a2b8bb81348987d455aae5d9afadcb9ecde76 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:01:18 -0700 Subject: [PATCH 135/213] docs: add WSL2 networking guide for local model servers (#5616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows users running Hermes in WSL2 with model servers on the Windows host hit 'connection refused' because WSL2's NAT networking means localhost points to the VM, not Windows. Covers: - Mirrored networking mode (Win 11 22H2+) — makes localhost work - NAT mode fallback using the host IP via ip route - Per-server bind address table (Ollama, LM Studio, llama-server, vLLM, SGLang) - Detailed Ollama Windows service config for OLLAMA_HOST - Windows Firewall rules for WSL2 connections - Quick verification steps - Cross-reference from Troubleshooting section --- website/docs/integrations/providers.md | 115 +++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 2bc996cd4f52..643cdbf5bcec 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -478,10 +478,125 @@ To set persistent per-model defaults: My Models tab → gear icon on the model --- +### WSL2 Networking (Windows Users) + +Since Hermes Agent requires a Unix environment, Windows users run it inside WSL2. If your model server (Ollama, LM Studio, etc.) runs on the **Windows host**, you need to bridge the network gap — WSL2 uses a virtual network adapter with its own subnet, so `localhost` inside WSL2 refers to the Linux VM, **not** the Windows host. + +:::tip Both in WSL2? No problem. +If your model server also runs inside WSL2 (common for vLLM, SGLang, and llama-server), `localhost` works as expected — they share the same network namespace. Skip this section. +::: + +#### Option 1: Mirrored Networking Mode (Recommended) + +Available on **Windows 11 22H2+**, mirrored mode makes `localhost` work bidirectionally between Windows and WSL2 — the simplest fix. + +1. Create or edit `%USERPROFILE%\.wslconfig` (e.g., `C:\Users\YourName\.wslconfig`): + ```ini + [wsl2] + networkingMode=mirrored + ``` + +2. Restart WSL from PowerShell: + ```powershell + wsl --shutdown + ``` + +3. Reopen your WSL2 terminal. `localhost` now reaches Windows services: + ```bash + curl http://localhost:11434/v1/models # Ollama on Windows — works + ``` + +:::note Hyper-V Firewall +On some Windows 11 builds, the Hyper-V firewall blocks mirrored connections by default. If `localhost` still doesn't work after enabling mirrored mode, run this in an **Admin PowerShell**: +```powershell +Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow +``` +::: + +#### Option 2: Use the Windows Host IP (Windows 10 / older builds) + +If you can't use mirrored mode, find the Windows host IP from inside WSL2 and use that instead of `localhost`: + +```bash +# Get the Windows host IP (the default gateway of WSL2's virtual network) +ip route show | grep -i default | awk '{ print $3 }' +# Example output: 172.29.192.1 +``` + +Use that IP in your Hermes config: + +```yaml +model: + default: qwen2.5-coder:32b + provider: custom + base_url: http://172.29.192.1:11434/v1 # Windows host IP, not localhost +``` + +:::tip Dynamic helper +The host IP can change on WSL2 restart. You can grab it dynamically in your shell: +```bash +export WSL_HOST=$(ip route show | grep -i default | awk '{ print $3 }') +echo "Windows host at: $WSL_HOST" +curl http://$WSL_HOST:11434/v1/models # Test Ollama +``` + +Or use your machine's mDNS name (requires `libnss-mdns` in WSL2): +```bash +sudo apt install libnss-mdns +curl http://$(hostname).local:11434/v1/models +``` +::: + +#### Server Bind Address (Required for NAT Mode) + +If you're using **Option 2** (NAT mode with the host IP), the model server on Windows must accept connections from outside `127.0.0.1`. By default, most servers only listen on localhost — WSL2 connections in NAT mode come from a different virtual subnet and will be refused. In mirrored mode, `localhost` maps directly so the default `127.0.0.1` binding works fine. + +| Server | Default bind | How to fix | +|--------|-------------|------------| +| **Ollama** | `127.0.0.1` | Set `OLLAMA_HOST=0.0.0.0` environment variable before starting Ollama (System Settings → Environment Variables on Windows, or edit the Ollama service) | +| **LM Studio** | `127.0.0.1` | Enable **"Serve on Network"** in the Developer tab → Server settings | +| **llama-server** | `127.0.0.1` | Add `--host 0.0.0.0` to the startup command | +| **vLLM** | `0.0.0.0` | Already binds to all interfaces by default | +| **SGLang** | `127.0.0.1` | Add `--host 0.0.0.0` to the startup command | + +**Ollama on Windows (detailed):** Ollama runs as a Windows service. To set `OLLAMA_HOST`: +1. Open **System Properties** → **Environment Variables** +2. Add a new **System variable**: `OLLAMA_HOST` = `0.0.0.0` +3. Restart the Ollama service (or reboot) + +#### Windows Firewall + +Windows Firewall treats WSL2 as a separate network (in both NAT and mirrored mode). If connections still fail after the steps above, add a firewall rule for your model server's port: + +```powershell +# Run in Admin PowerShell — replace PORT with your server's port +New-NetFirewallRule -DisplayName "Allow WSL2 to Model Server" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 11434 +``` + +Common ports: Ollama `11434`, vLLM `8000`, SGLang `30000`, llama-server `8080`, LM Studio `1234`. + +#### Quick Verification + +From inside WSL2, test that you can reach your model server: + +```bash +# Replace URL with your server's address and port +curl http://localhost:11434/v1/models # Mirrored mode +curl http://172.29.192.1:11434/v1/models # NAT mode (use your actual host IP) +``` + +If you get a JSON response listing your models, you're good. Use that same URL as the `base_url` in your Hermes config. + +--- + ### Troubleshooting Local Models These issues affect **all** local inference servers when used with Hermes. +#### "Connection refused" from WSL2 to a Windows-hosted model server + +If you're running Hermes inside WSL2 and your model server on the Windows host, `http://localhost:` won't work in WSL2's default NAT networking mode. See [WSL2 Networking](#wsl2-networking-windows-users) above for the fix. + #### Tool calls appear as text instead of executing The model outputs something like `{"name": "web_search", "arguments": {...}}` as a message instead of actually calling the tool. From af9a9f773ce78c0083e896ed7582915430718bbe Mon Sep 17 00:00:00 2001 From: "Mariano A. Nicolini" Date: Mon, 6 Apr 2026 13:16:25 -0700 Subject: [PATCH 136/213] fix(security): sanitize workdir parameter in terminal tool backends Shell injection via unquoted workdir interpolation in docker, singularity, and SSH backends. When workdir contained shell metacharacters (e.g. ~/;id), arbitrary commands could execute. Changes: - Add shlex.quote() at each interpolation point in docker.py, singularity.py, and ssh.py with tilde-aware quoting (keep ~ unquoted for shell expansion, quote only the subpath) - Add _validate_workdir() allowlist in terminal_tool.py as defense-in-depth before workdir reaches any backend Original work by Mariano A. Nicolini (PR #5620). Salvaged with fixes for tilde expansion (shlex.quote breaks cd ~/path) and replaced incomplete deny-list with strict character allowlist. Co-authored-by: Mariano A. Nicolini --- tools/environments/docker.py | 11 ++++++--- tools/environments/singularity.py | 11 ++++++--- tools/environments/ssh.py | 9 ++++++- tools/terminal_tool.py | 41 +++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/tools/environments/docker.py b/tools/environments/docker.py index ea553a7b60da..1d2d325cbae8 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -8,6 +8,7 @@ import logging import os import re +import shlex import shutil import subprocess import sys @@ -484,9 +485,13 @@ def execute(self, command: str, cwd: str = "", *, else: effective_stdin = stdin_data - # docker exec -w doesn't expand ~, so prepend a cd into the command - if work_dir == "~" or work_dir.startswith("~/"): - exec_command = f"cd {work_dir} && {exec_command}" + # docker exec -w doesn't expand ~, so prepend a cd into the command. + # Keep ~ unquoted (for shell expansion) and quote only the subpath. + if work_dir == "~": + exec_command = f"cd ~ && {exec_command}" + work_dir = "/" + elif work_dir.startswith("~/"): + exec_command = f"cd ~/{shlex.quote(work_dir[2:])} && {exec_command}" work_dir = "/" assert self._container_id, "Container not started" diff --git a/tools/environments/singularity.py b/tools/environments/singularity.py index 89d9ffb04b47..6643ea1b3f6f 100644 --- a/tools/environments/singularity.py +++ b/tools/environments/singularity.py @@ -8,6 +8,7 @@ import json import logging import os +import shlex import shutil import subprocess import tempfile @@ -311,9 +312,13 @@ def execute(self, command: str, cwd: str = "", *, else: effective_stdin = stdin_data - # apptainer exec --pwd doesn't expand ~, so prepend a cd into the command - if work_dir == "~" or work_dir.startswith("~/"): - exec_command = f"cd {work_dir} && {exec_command}" + # apptainer exec --pwd doesn't expand ~, so prepend a cd into the command. + # Keep ~ unquoted (for shell expansion) and quote only the subpath. + if work_dir == "~": + exec_command = f"cd ~ && {exec_command}" + work_dir = "/tmp" + elif work_dir.startswith("~/"): + exec_command = f"cd ~/{shlex.quote(work_dir[2:])} && {exec_command}" work_dir = "/tmp" cmd = [self.executable, "exec", "--pwd", work_dir, diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index 387dea34e0f6..afd28c4affad 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -1,6 +1,7 @@ """SSH remote execution environment with ControlMaster connection persistence.""" import logging +import shlex import shutil import subprocess import tempfile @@ -228,7 +229,13 @@ def _execute_oneshot(self, command: str, cwd: str = "", *, stdin_data: str | None = None) -> dict: work_dir = cwd or self.cwd exec_command, sudo_stdin = self._prepare_command(command) - wrapped = f'cd {work_dir} && {exec_command}' + # Keep ~ unquoted (for shell expansion) and quote only the subpath. + if work_dir == "~": + wrapped = f'cd ~ && {exec_command}' + elif work_dir.startswith("~/"): + wrapped = f'cd ~/{shlex.quote(work_dir[2:])} && {exec_command}' + else: + wrapped = f'cd {shlex.quote(work_dir)} && {exec_command}' effective_timeout = timeout or self.timeout if sudo_stdin is not None and stdin_data is not None: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 26591ceedb25..be565f19666e 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -154,6 +154,34 @@ def _check_all_guards(command: str, env_type: str) -> dict: approval_callback=_approval_callback) +# Allowlist: characters that can legitimately appear in directory paths. +# Covers alphanumeric, path separators, tilde, dot, hyphen, underscore, space, +# plus, at, equals, and comma. Everything else is rejected. +_WORKDIR_SAFE_RE = re.compile(r'^[A-Za-z0-9/_\-.~ +@=,]+$') + + +def _validate_workdir(workdir: str) -> str | None: + """Reject workdir values that don't look like a filesystem path. + + Uses an allowlist of safe characters rather than a deny-list, so novel + shell metacharacters can't slip through. + + Returns None if safe, or an error message string if dangerous. + """ + if not workdir: + return None + if not _WORKDIR_SAFE_RE.match(workdir): + # Find the first offending character for a helpful message. + for ch in workdir: + if not _WORKDIR_SAFE_RE.match(ch): + return ( + f"Blocked: workdir contains disallowed character {repr(ch)}. " + "Use a simple filesystem path without shell metacharacters." + ) + return "Blocked: workdir contains disallowed characters." + return None + + def _handle_sudo_failure(output: str, env_type: str) -> str: """ Check for sudo failure and add helpful message for messaging contexts. @@ -1166,6 +1194,19 @@ def terminal_tool( desc = approval.get("description", "flagged as dangerous") approval_note = f"Command was flagged ({desc}) and auto-approved by smart approval." + # Validate workdir against shell injection + if workdir: + workdir_error = _validate_workdir(workdir) + if workdir_error: + logger.warning("Blocked dangerous workdir: %s (command: %s)", + workdir[:200], command[:200]) + return json.dumps({ + "output": "", + "exit_code": -1, + "error": workdir_error, + "status": "blocked" + }, ensure_ascii=False) + # Prepare command for execution if background: # Spawn a tracked background process via the process registry. From 1a2f109d8e9fe8e0f97a38a5f3477926c515766f Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Mon, 6 Apr 2026 21:16:34 +0300 Subject: [PATCH 137/213] Ensure atomic writes for gateway channel directory cache to prevent truncation --- gateway/channel_directory.py | 5 ++--- tests/gateway/test_channel_directory.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index cdd2ff9a2417..ecc54e64464e 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional from hermes_cli.config import get_hermes_home +from utils import atomic_json_write logger = logging.getLogger(__name__) @@ -86,9 +87,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: } try: - DIRECTORY_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(DIRECTORY_PATH, "w", encoding="utf-8") as f: - json.dump(directory, f, indent=2, ensure_ascii=False) + atomic_json_write(DIRECTORY_PATH, directory) except Exception as e: logger.warning("Channel directory: failed to write: %s", e) diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 8981be6befb4..50d5b04b747c 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -6,6 +6,7 @@ from unittest.mock import patch from gateway.channel_directory import ( + build_channel_directory, resolve_channel_name, format_directory_for_display, load_directory, @@ -45,6 +46,27 @@ def test_corrupt_file(self, tmp_path): assert result["updated_at"] is None +class TestBuildChannelDirectoryWrites: + def test_failed_write_preserves_previous_cache(self, tmp_path, monkeypatch): + cache_file = _write_directory(tmp_path, { + "telegram": [{"id": "123", "name": "Alice", "type": "dm"}] + }) + previous = json.loads(cache_file.read_text()) + + def broken_dump(data, fp, *args, **kwargs): + fp.write('{"updated_at":') + fp.flush() + raise OSError("disk full") + + monkeypatch.setattr(json, "dump", broken_dump) + + with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file): + build_channel_directory({}) + result = load_directory() + + assert result == previous + + class TestResolveChannelName: def _setup(self, tmp_path, platforms): cache_file = _write_directory(tmp_path, platforms) From 96f85b03cda934f519358278ab566542903e1a13 Mon Sep 17 00:00:00 2001 From: Ayman Kamal Date: Mon, 6 Apr 2026 10:00:24 -0400 Subject: [PATCH 138/213] fix: handle launchctl kickstart exit code 113 in launchd_start() launchctl kickstart returns exit code 113 ("Could not find service") when the plist exists but the job hasn't been bootstrapped into the runtime domain. The existing recovery path only caught exit code 3 ("unloaded"), causing an unhandled CalledProcessError. Exit code 113 means the same thing practically -- the service definition needs bootstrapping before it can be kicked. Add it to the same recovery path that already handles exit 3, matching the existing pattern in launchd_stop(). Follow-up: add a unit test covering the 113 recovery path. --- hermes_cli/gateway.py | 4 ++-- tests/hermes_cli/test_gateway_service.py | 27 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1348e3155807..c99761d5c6a5 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1121,7 +1121,7 @@ def launchd_start(): try: subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) except subprocess.CalledProcessError as e: - if e.returncode != 3: + if e.returncode not in (3, 113): raise print("↻ launchd job was unloaded; reloading service definition") subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) @@ -1183,7 +1183,7 @@ def launchd_restart(): subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) print("✓ Service restarted") except subprocess.CalledProcessError as e: - if e.returncode != 3: + if e.returncode not in (3, 113): raise # Job not loaded — bootstrap and start fresh print("↻ launchd job was unloaded; reloading") diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index b08fb46c3d27..03c9c56ec254 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -205,6 +205,33 @@ def fake_run(cmd, check=False, **kwargs): ["launchctl", "kickstart", target], ] + def test_launchd_start_reloads_on_kickstart_exit_code_113(self, tmp_path, monkeypatch): + """Exit code 113 (\"Could not find service\") should also trigger bootstrap recovery.""" + plist_path = tmp_path / "ai.hermes.gateway.plist" + plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") + label = gateway_cli.get_launchd_label() + + calls = [] + domain = gateway_cli._launchd_domain() + target = f"{domain}/{label}" + + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + if cmd == ["launchctl", "kickstart", target] and calls.count(cmd) == 1: + raise gateway_cli.subprocess.CalledProcessError(113, cmd, stderr="Could not find service") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_start() + + assert calls == [ + ["launchctl", "kickstart", target], + ["launchctl", "bootstrap", domain, str(plist_path)], + ["launchctl", "kickstart", target], + ] + def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys): plist_path = tmp_path / "ai.hermes.gateway.plist" plist_path.write_text("old content", encoding="utf-8") From ff655de4813a4a52862f3e2a3e6158c5ecf22a40 Mon Sep 17 00:00:00 2001 From: donrhmexe Date: Mon, 6 Apr 2026 15:20:06 +0200 Subject: [PATCH 139/213] fix: model alias fallback uses authenticated providers instead of hardcoded openrouter/nous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an alias like 'claude' can't be resolved on the current provider, _resolve_alias_fallback() tries other providers. Previously it hardcoded ('openrouter', 'nous') — so '/model claude' on z.ai would resolve to openrouter even if the user doesn't have openrouter credentials but does have anthropic. Now the fallback uses the user's actual authenticated providers (detected via list_authenticated_providers which is backed by the models.dev in-memory cache). If no authenticated providers are found, falls back to the old ('openrouter', 'nous') for backwards compatibility. New helper: get_authenticated_provider_slugs() returns just the slug strings from list_authenticated_providers(). --- hermes_cli/model_switch.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index bff54eaef6fc..b2f763c61bf3 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -339,12 +339,37 @@ def resolve_alias( return None +def get_authenticated_provider_slugs( + current_provider: str = "", + user_providers: dict = None, +) -> list[str]: + """Return slugs of providers that have credentials. + + Uses ``list_authenticated_providers()`` which is backed by the models.dev + in-memory cache (1 hr TTL) — no extra network cost. + """ + try: + providers = list_authenticated_providers( + current_provider=current_provider, + user_providers=user_providers, + max_models=0, + ) + return [p["slug"] for p in providers] + except Exception: + return [] + + def _resolve_alias_fallback( raw_input: str, - fallback_providers: tuple[str, ...] = ("openrouter", "nous"), + authenticated_providers: list[str] = (), ) -> Optional[tuple[str, str, str]]: - """Try to resolve an alias on fallback providers.""" - for provider in fallback_providers: + """Try to resolve an alias on the user's authenticated providers. + + Falls back to ``("openrouter", "nous")`` only when no authenticated + providers are supplied (backwards compat for non-interactive callers). + """ + providers = authenticated_providers or ("openrouter", "nous") + for provider in providers: result = resolve_alias(raw_input, provider) if result is not None: return result @@ -494,7 +519,11 @@ def switch_model( # --- Step b: Alias exists but not on current provider -> fallback --- key = raw_input.strip().lower() if key in MODEL_ALIASES: - fallback_result = _resolve_alias_fallback(raw_input) + authed = get_authenticated_provider_slugs( + current_provider=current_provider, + user_providers=user_providers, + ) + fallback_result = _resolve_alias_fallback(raw_input, authed) if fallback_result is not None: target_provider, new_model, resolved_alias = fallback_result logger.debug( From ad567c9a8fca3a6a4d65f0a036e1803efe0d1c84 Mon Sep 17 00:00:00 2001 From: BongSuCHOI Date: Mon, 6 Apr 2026 18:16:02 +0000 Subject: [PATCH 140/213] fix: subagent toolset inheritance when parent enabled_toolsets is None When parent_agent.enabled_toolsets is None (the default, meaning all tools are enabled), subagents incorrectly fell back to DEFAULT_TOOLSETS (['terminal', 'file', 'web']) instead of inheriting the parent's full toolset. Root cause: - Line 188 used 'or' fallback: None or DEFAULT_TOOLSETS evaluates to DEFAULT_TOOLSETS - Line 192 checked truthiness: None is falsy, falling through to else Fix: - Use 'is not None' checks instead of truthiness - When enabled_toolsets is None, derive effective toolsets from parent_agent.valid_tool_names via the tool registry Fixes the bug introduced in f75b1d21b and repeated in e5d14445e (PR #3269). --- tools/delegate_tool.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 2a990d8f93c4..71a78ea664da 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -185,12 +185,28 @@ def _build_child_agent( # When no explicit toolsets given, inherit from parent's enabled toolsets # so disabled tools (e.g. web) don't leak to subagents. - parent_toolsets = set(getattr(parent_agent, "enabled_toolsets", None) or DEFAULT_TOOLSETS) + # Note: enabled_toolsets=None means "all tools enabled" (the default), + # so we must derive effective toolsets from the parent's loaded tools. + parent_enabled = getattr(parent_agent, "enabled_toolsets", None) + if parent_enabled is not None: + parent_toolsets = set(parent_enabled) + elif parent_agent and hasattr(parent_agent, "valid_tool_names"): + # enabled_toolsets is None (all tools) — derive from loaded tool names + import model_tools + parent_toolsets = { + ts for name in parent_agent.valid_tool_names + if (ts := model_tools.get_toolset_for_tool(name)) is not None + } + else: + parent_toolsets = set(DEFAULT_TOOLSETS) + if toolsets: # Intersect with parent — subagent must not gain tools the parent lacks child_toolsets = _strip_blocked_tools([t for t in toolsets if t in parent_toolsets]) - elif parent_agent and getattr(parent_agent, "enabled_toolsets", None): - child_toolsets = _strip_blocked_tools(parent_agent.enabled_toolsets) + elif parent_agent and parent_enabled is not None: + child_toolsets = _strip_blocked_tools(parent_enabled) + elif parent_toolsets: + child_toolsets = _strip_blocked_tools(sorted(parent_toolsets)) else: child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS) From 2c814d7b5d76b91e5884a56341d6d6a248480648 Mon Sep 17 00:00:00 2001 From: donrhmexe Date: Mon, 6 Apr 2026 15:19:12 +0200 Subject: [PATCH 141/213] fix: /model --global writes model.name instead of model.default The canonical config key for model name is model.default (used by setup, auth, runtime_provider, profile list, and CLI startup). But /model --global wrote to model.name in both gateway and CLI paths. This caused: - hermes profile list showing the old model (reads model.default) - Gateway restart reverting to the old model (_resolve_gateway_model reads model.default) - CLI startup using the old model (main.py reads model.default) The only reason it appeared to work in Telegram was the cached agent staying alive with the in-place switch. Fix: change all 3 write/read sites to use model.default. --- cli.py | 2 +- gateway/run.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index c5278d3c24fb..ff097532c23d 100644 --- a/cli.py +++ b/cli.py @@ -3721,7 +3721,7 @@ def _handle_model_switch(self, cmd_original: str): # Persistence if persist_global: - save_config_value("model.name", result.new_model) + save_config_value("model.default", result.new_model) if result.provider_changed: save_config_value("model.provider", result.target_provider) _cprint(" Saved to config.yaml (--global)") diff --git a/gateway/run.py b/gateway/run.py index c50c67462222..ca1e489463d6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3493,7 +3493,7 @@ async def _handle_model_command(self, event: MessageEvent) -> str: cfg = yaml.safe_load(f) or {} model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): - current_model = model_cfg.get("name", "") + current_model = model_cfg.get("default", "") current_provider = model_cfg.get("provider", current_provider) current_base_url = model_cfg.get("base_url", "") user_provs = cfg.get("providers") @@ -3603,7 +3603,7 @@ async def _handle_model_command(self, event: MessageEvent) -> str: else: cfg = {} model_cfg = cfg.setdefault("model", {}) - model_cfg["name"] = result.new_model + model_cfg["default"] = result.new_model model_cfg["provider"] = result.target_provider if result.base_url: model_cfg["base_url"] = result.base_url From 9afb9a6cb23199688993df52040ddffed3f0ec98 Mon Sep 17 00:00:00 2001 From: Mikita Lisavets Date: Mon, 6 Apr 2026 15:22:30 +0200 Subject: [PATCH 142/213] fix: clear session-scoped model overrides during session reset --- gateway/run.py | 4 + tests/gateway/test_session_model_reset.py | 126 ++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/gateway/test_session_model_reset.py diff --git a/gateway/run.py b/gateway/run.py index ca1e489463d6..4838ce2120c1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3264,6 +3264,10 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: except Exception: pass + # Clear any session-scoped model override so the next agent picks up + # the configured default instead of the previously switched model. + self._session_model_overrides.pop(session_key, None) + # Reset the session new_entry = self.session_store.reset_session(session_key) diff --git a/tests/gateway/test_session_model_reset.py b/tests/gateway/test_session_model_reset.py new file mode 100644 index 000000000000..6529f3a11dd2 --- /dev/null +++ b/tests/gateway/test_session_model_reset.py @@ -0,0 +1,126 @@ +"""Tests that /new (and its /reset alias) clears the session-scoped model override.""" +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source() -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + ) + + +def _make_event(text: str) -> MessageEvent: + return MessageEvent(text=text, source=_make_source(), message_id="m1") + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + adapter = MagicMock() + adapter.send = AsyncMock() + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner._session_model_overrides = {} + runner._pending_model_notes = {} + runner._background_tasks = set() + + session_key = build_session_key(_make_source()) + session_entry = SessionEntry( + session_key=session_key, + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.reset_session.return_value = session_entry + runner.session_store._entries = {session_key: session_entry} + runner.session_store._generate_session_key.return_value = session_key + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_db = None + runner._agent_cache_lock = None # disables _evict_cached_agent lock path + runner._is_user_authorized = lambda _source: True + runner._format_session_info = lambda: "" + + return runner + + +@pytest.mark.asyncio +async def test_new_command_clears_session_model_override(): + """/new must remove the session-scoped model override for that session.""" + runner = _make_runner() + session_key = build_session_key(_make_source()) + + # Simulate a prior /model switch stored as a session override + runner._session_model_overrides[session_key] = { + "model": "gpt-4o", + "provider": "openai", + "api_key": "sk-test", + "base_url": "", + "api_mode": "openai", + } + + await runner._handle_reset_command(_make_event("/new")) + + assert session_key not in runner._session_model_overrides + + +@pytest.mark.asyncio +async def test_new_command_no_override_is_noop(): + """/new with no prior model override must not raise.""" + runner = _make_runner() + session_key = build_session_key(_make_source()) + + assert session_key not in runner._session_model_overrides + + await runner._handle_reset_command(_make_event("/new")) + + assert session_key not in runner._session_model_overrides + + +@pytest.mark.asyncio +async def test_new_command_only_clears_own_session(): + """/new must only clear the override for the session that triggered it.""" + runner = _make_runner() + session_key = build_session_key(_make_source()) + other_key = "other_session_key" + + runner._session_model_overrides[session_key] = { + "model": "gpt-4o", + "provider": "openai", + "api_key": "sk-test", + "base_url": "", + "api_mode": "openai", + } + runner._session_model_overrides[other_key] = { + "model": "claude-sonnet-4-6", + "provider": "anthropic", + "api_key": "sk-ant-test", + "base_url": "", + "api_mode": "anthropic", + } + + await runner._handle_reset_command(_make_event("/new")) + + assert session_key not in runner._session_model_overrides + assert other_key in runner._session_model_overrides From 29b5ec25556f622187f5dc35db79ca1f145f1a04 Mon Sep 17 00:00:00 2001 From: Mikita Lisavets Date: Mon, 6 Apr 2026 16:52:27 +0200 Subject: [PATCH 143/213] fix: clear session-scoped model after session reset --- gateway/run.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 4838ce2120c1..731bc8c0345a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3264,13 +3264,13 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: except Exception: pass + # Reset the session + new_entry = self.session_store.reset_session(session_key) + # Clear any session-scoped model override so the next agent picks up # the configured default instead of the previously switched model. self._session_model_overrides.pop(session_key, None) - # Reset the session - new_entry = self.session_store.reset_session(session_key) - # Emit session:end hook (session is ending) await self.hooks.emit("session:end", { "platform": source.platform.value if source.platform else "", From 150f70f821af33556821c34d17dfc8caca4cb8cb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:49:13 -0700 Subject: [PATCH 144/213] feat(skills): add skill config interface + llm-wiki skill (#5635) Skills can now declare config.yaml settings via metadata.hermes.config in their SKILL.md frontmatter. Values are stored under skills.config.* namespace, prompted during hermes config migrate, shown in hermes config show, and injected into the skill context at load time. Also adds the llm-wiki skill (Karpathy's LLM Wiki pattern) as the first skill to use the new config interface, declaring wiki.path. Skill config interface (new): - agent/skill_utils.py: extract_skill_config_vars(), discover_all_skill_config_vars(), resolve_skill_config_values(), SKILL_CONFIG_PREFIX - agent/skill_commands.py: _inject_skill_config() injects resolved values into skill messages as [Skill config: ...] block - hermes_cli/config.py: get_missing_skill_config_vars(), skill config prompting in migrate_config(), Skill Settings in show_config() LLM Wiki skill (skills/research/llm-wiki/SKILL.md): - Three-layer architecture (raw sources, wiki pages, schema) - Three operations (ingest, query, lint) - Session orientation, page thresholds, tag taxonomy, update policy, scaling guidance, log rotation, archiving workflow Docs: creating-skills.md, configuration.md, skills.md, skills-catalog.md Closes #5100 --- agent/skill_commands.py | 42 ++ agent/skill_utils.py | 157 +++++++ hermes_cli/config.py | 99 ++++- skills/research/llm-wiki/SKILL.md | 404 ++++++++++++++++++ .../docs/developer-guide/creating-skills.md | 58 +++ website/docs/reference/skills-catalog.md | 1 + website/docs/user-guide/configuration.md | 25 ++ website/docs/user-guide/features/skills.md | 23 + 8 files changed, 808 insertions(+), 1 deletion(-) create mode 100644 skills/research/llm-wiki/SKILL.md diff --git a/agent/skill_commands.py b/agent/skill_commands.py index e12945a9c5a1..18414199dceb 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -79,6 +79,45 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu return loaded_skill, skill_dir, skill_name +def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None: + """Resolve and inject skill-declared config values into the message parts. + + If the loaded skill's frontmatter declares ``metadata.hermes.config`` + entries, their current values (from config.yaml or defaults) are appended + as a ``[Skill config: ...]`` block so the agent knows the configured values + without needing to read config.yaml itself. + """ + try: + from agent.skill_utils import ( + extract_skill_config_vars, + parse_frontmatter, + resolve_skill_config_values, + ) + + # The loaded_skill dict contains the raw content which includes frontmatter + raw_content = str(loaded_skill.get("raw_content") or loaded_skill.get("content") or "") + if not raw_content: + return + + frontmatter, _ = parse_frontmatter(raw_content) + config_vars = extract_skill_config_vars(frontmatter) + if not config_vars: + return + + resolved = resolve_skill_config_values(config_vars) + if not resolved: + return + + lines = ["", "[Skill config (from ~/.hermes/config.yaml):"] + for key, value in resolved.items(): + display_val = str(value) if value else "(not set)" + lines.append(f" {key} = {display_val}") + lines.append("]") + parts.extend(lines) + except Exception: + pass # Non-critical — skill still loads without config injection + + def _build_skill_message( loaded_skill: dict[str, Any], skill_dir: Path | None, @@ -93,6 +132,9 @@ def _build_skill_message( parts = [activation_note, "", content.strip()] + # ── Inject resolved skill config values ── + _inject_skill_config(loaded_skill, parts) + if loaded_skill.get("setup_skipped"): parts.extend( [ diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 2f4b966912df..f241636091d0 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -254,6 +254,163 @@ def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]: } +# ── Skill config extraction ─────────────────────────────────────────────── + + +def extract_skill_config_vars(frontmatter: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract config variable declarations from parsed frontmatter. + + Skills declare config.yaml settings they need via:: + + metadata: + hermes: + config: + - key: wiki.path + description: Path to the LLM Wiki knowledge base directory + default: "~/wiki" + prompt: Wiki directory path + + Returns a list of dicts with keys: ``key``, ``description``, ``default``, + ``prompt``. Invalid or incomplete entries are silently skipped. + """ + metadata = frontmatter.get("metadata") + if not isinstance(metadata, dict): + return [] + hermes = metadata.get("hermes") + if not isinstance(hermes, dict): + return [] + raw = hermes.get("config") + if not raw: + return [] + if isinstance(raw, dict): + raw = [raw] + if not isinstance(raw, list): + return [] + + result: List[Dict[str, Any]] = [] + seen: set = set() + for item in raw: + if not isinstance(item, dict): + continue + key = str(item.get("key", "")).strip() + if not key or key in seen: + continue + # Must have at least key and description + desc = str(item.get("description", "")).strip() + if not desc: + continue + entry: Dict[str, Any] = { + "key": key, + "description": desc, + } + default = item.get("default") + if default is not None: + entry["default"] = default + prompt_text = item.get("prompt") + if isinstance(prompt_text, str) and prompt_text.strip(): + entry["prompt"] = prompt_text.strip() + else: + entry["prompt"] = desc + seen.add(key) + result.append(entry) + return result + + +def discover_all_skill_config_vars() -> List[Dict[str, Any]]: + """Scan all enabled skills and collect their config variable declarations. + + Walks every skills directory, parses each SKILL.md frontmatter, and returns + a deduplicated list of config var dicts. Each dict also includes a + ``skill`` key with the skill name for attribution. + + Disabled and platform-incompatible skills are excluded. + """ + all_vars: List[Dict[str, Any]] = [] + seen_keys: set = set() + + disabled = get_disabled_skill_names() + for skills_dir in get_all_skills_dirs(): + if not skills_dir.is_dir(): + continue + for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"): + try: + raw = skill_file.read_text(encoding="utf-8") + frontmatter, _ = parse_frontmatter(raw) + except Exception: + continue + + skill_name = frontmatter.get("name") or skill_file.parent.name + if str(skill_name) in disabled: + continue + if not skill_matches_platform(frontmatter): + continue + + config_vars = extract_skill_config_vars(frontmatter) + for var in config_vars: + if var["key"] not in seen_keys: + var["skill"] = str(skill_name) + all_vars.append(var) + seen_keys.add(var["key"]) + + return all_vars + + +# Storage prefix: all skill config vars are stored under skills.config.* +# in config.yaml. Skill authors declare logical keys (e.g. "wiki.path"); +# the system adds this prefix for storage and strips it for display. +SKILL_CONFIG_PREFIX = "skills.config" + + +def _resolve_dotpath(config: Dict[str, Any], dotted_key: str): + """Walk a nested dict following a dotted key. Returns None if any part is missing.""" + parts = dotted_key.split(".") + current = config + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +def resolve_skill_config_values( + config_vars: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Resolve current values for skill config vars from config.yaml. + + Skill config is stored under ``skills.config.`` in config.yaml. + Returns a dict mapping **logical** keys (as declared by skills) to their + current values (or the declared default if the key isn't set). + Path values are expanded via ``os.path.expanduser``. + """ + config_path = get_hermes_home() / "config.yaml" + config: Dict[str, Any] = {} + if config_path.exists(): + try: + parsed = yaml_load(config_path.read_text(encoding="utf-8")) + if isinstance(parsed, dict): + config = parsed + except Exception: + pass + + resolved: Dict[str, Any] = {} + for var in config_vars: + logical_key = var["key"] + storage_key = f"{SKILL_CONFIG_PREFIX}.{logical_key}" + value = _resolve_dotpath(config, storage_key) + + if value is None or (isinstance(value, str) and not value.strip()): + value = var.get("default", "") + + # Expand ~ in path-like values + if isinstance(value, str) and ("~" in value or "${" in value): + value = os.path.expanduser(os.path.expandvars(value)) + + resolved[logical_key] = value + + return resolved + + # ── Description extraction ──────────────────────────────────────────────── diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4f7811ca7c2a..94f087e9866c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1264,6 +1264,43 @@ def _check(defaults: dict, current: dict, prefix: str = ""): return missing +def get_missing_skill_config_vars() -> List[Dict[str, Any]]: + """Return skill-declared config vars that are missing or empty in config.yaml. + + Scans all enabled skills for ``metadata.hermes.config`` entries, then checks + which ones are absent or empty under ``skills.config.`` in the user's + config.yaml. Returns a list of dicts suitable for prompting. + """ + try: + from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX + except Exception: + return [] + + all_vars = discover_all_skill_config_vars() + if not all_vars: + return [] + + config = load_config() + missing: List[Dict[str, Any]] = [] + for var in all_vars: + # Skill config is stored under skills.config. + storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}" + parts = storage_key.split(".") + current = config + value = None + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + value = current + else: + value = None + break + # Missing = key doesn't exist or is empty string + if value is None or (isinstance(value, str) and not value.strip()): + missing.append(var) + return missing + + def check_config_version() -> Tuple[int, int]: """ Check config version. @@ -1695,7 +1732,50 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A config = load_config() config["_config_version"] = latest_ver save_config(config) - + + # ── Skill-declared config vars ────────────────────────────────────── + # Skills can declare config.yaml settings they need via + # metadata.hermes.config in their SKILL.md frontmatter. + # Prompt for any that are missing/empty. + missing_skill_config = get_missing_skill_config_vars() + if missing_skill_config and interactive and not quiet: + print(f"\n {len(missing_skill_config)} skill setting(s) not configured:") + for var in missing_skill_config: + skill_name = var.get("skill", "unknown") + print(f" • {var['key']} — {var['description']} (from skill: {skill_name})") + print() + try: + answer = input(" Configure skill settings? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "n" + + if answer in ("y", "yes"): + print() + config = load_config() + try: + from agent.skill_utils import SKILL_CONFIG_PREFIX + except Exception: + SKILL_CONFIG_PREFIX = "skills.config" + for var in missing_skill_config: + default = var.get("default", "") + default_hint = f" (default: {default})" if default else "" + value = input(f" {var['prompt']}{default_hint}: ").strip() + if not value and default: + value = str(default) + if value: + storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}" + _set_nested(config, storage_key, value) + results["config_added"].append(var["key"]) + print(f" ✓ Saved {var['key']} = {value}") + else: + results["warnings"].append( + f"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later" + ) + print() + save_config(config) + else: + print(" Set later with: hermes config set ") + return results @@ -2349,6 +2429,23 @@ def show_config(): print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}") print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}") + # Skill config + try: + from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values + skill_vars = discover_all_skill_config_vars() + if skill_vars: + resolved = resolve_skill_config_values(skill_vars) + print() + print(color("◆ Skill Settings", Colors.CYAN, Colors.BOLD)) + for var in skill_vars: + key = var["key"] + value = resolved.get(key, "") + skill_name = var.get("skill", "") + display_val = str(value) if value else color("(not set)", Colors.DIM) + print(f" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}") + except Exception: + pass + print() print(color("─" * 60, Colors.DIM)) print(color(" hermes config edit # Edit config file", Colors.DIM)) diff --git a/skills/research/llm-wiki/SKILL.md b/skills/research/llm-wiki/SKILL.md new file mode 100644 index 000000000000..db172438f317 --- /dev/null +++ b/skills/research/llm-wiki/SKILL.md @@ -0,0 +1,404 @@ +--- +name: llm-wiki +description: "Karpathy's LLM Wiki — build and maintain a persistent, interlinked markdown knowledge base. Ingest sources, query compiled knowledge, and lint for consistency." +version: 2.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [wiki, knowledge-base, research, notes, markdown, rag-alternative] + category: research + related_skills: [obsidian, arxiv, agentic-research-ideas] + config: + - key: wiki.path + description: Path to the LLM Wiki knowledge base directory + default: "~/wiki" + prompt: Wiki directory path +--- + +# Karpathy's LLM Wiki + +Build and maintain a persistent, compounding knowledge base as interlinked markdown files. +Based on [Andrej Karpathy's LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f). + +Unlike traditional RAG (which rediscovers knowledge from scratch per query), the wiki +compiles knowledge once and keeps it current. Cross-references are already there. +Contradictions have already been flagged. Synthesis reflects everything ingested. + +**Division of labor:** The human curates sources and directs analysis. The agent +summarizes, cross-references, files, and maintains consistency. + +## When This Skill Activates + +Use this skill when the user: +- Asks to create, build, or start a wiki or knowledge base +- Asks to ingest, add, or process a source into their wiki +- Asks a question and an existing wiki is present at the configured path +- Asks to lint, audit, or health-check their wiki +- References their wiki, knowledge base, or "notes" in a research context + +## Wiki Location + +Configured via `skills.config.wiki.path` in `~/.hermes/config.yaml` (prompted +during `hermes config migrate` or `hermes setup`): + +```yaml +skills: + config: + wiki: + path: ~/wiki +``` + +Falls back to `~/wiki` default. The resolved path is injected when this +skill loads — check the `[Skill config: ...]` block above for the active value. + +The wiki is just a directory of markdown files — open it in Obsidian, VS Code, or +any editor. No database, no special tooling required. + +## Architecture: Three Layers + +``` +wiki/ +├── SCHEMA.md # Conventions, structure rules, domain config +├── index.md # Sectioned content catalog with one-line summaries +├── log.md # Chronological action log (append-only, rotated yearly) +├── raw/ # Layer 1: Immutable source material +│ ├── articles/ # Web articles, clippings +│ ├── papers/ # PDFs, arxiv papers +│ ├── transcripts/ # Meeting notes, interviews +│ └── assets/ # Images, diagrams referenced by sources +├── entities/ # Layer 2: Entity pages (people, orgs, products, models) +├── concepts/ # Layer 2: Concept/topic pages +├── comparisons/ # Layer 2: Side-by-side analyses +└── queries/ # Layer 2: Filed query results worth keeping +``` + +**Layer 1 — Raw Sources:** Immutable. The agent reads but never modifies these. +**Layer 2 — The Wiki:** Agent-owned markdown files. Created, updated, and +cross-referenced by the agent. +**Layer 3 — The Schema:** `SCHEMA.md` defines structure, conventions, and tag taxonomy. + +## Resuming an Existing Wiki (CRITICAL — do this every session) + +When the user has an existing wiki, **always orient yourself before doing anything**: + +① **Read `SCHEMA.md`** — understand the domain, conventions, and tag taxonomy. +② **Read `index.md`** — learn what pages exist and their summaries. +③ **Scan recent `log.md`** — read the last 20-30 entries to understand recent activity. + +```bash +WIKI="${wiki_path:-$HOME/wiki}" +# Orientation reads at session start +read_file "$WIKI/SCHEMA.md" +read_file "$WIKI/index.md" +read_file "$WIKI/log.md" offset= +``` + +Only after orientation should you ingest, query, or lint. This prevents: +- Creating duplicate pages for entities that already exist +- Missing cross-references to existing content +- Contradicting the schema's conventions +- Repeating work already logged + +For large wikis (100+ pages), also run a quick `search_files` for the topic +at hand before creating anything new. + +## Initializing a New Wiki + +When the user asks to create or start a wiki: + +1. Determine the wiki path (from config, env var, or ask the user; default `~/wiki`) +2. Create the directory structure above +3. Ask the user what domain the wiki covers — be specific +4. Write `SCHEMA.md` customized to the domain (see template below) +5. Write initial `index.md` with sectioned header +6. Write initial `log.md` with creation entry +7. Confirm the wiki is ready and suggest first sources to ingest + +### SCHEMA.md Template + +Adapt to the user's domain. The schema constrains agent behavior and ensures consistency: + +```markdown +# Wiki Schema + +## Domain +[What this wiki covers — e.g., "AI/ML research", "personal health", "startup intelligence"] + +## Conventions +- File names: lowercase, hyphens, no spaces (e.g., `transformer-architecture.md`) +- Every wiki page starts with YAML frontmatter (see below) +- Use `[[wikilinks]]` to link between pages (minimum 2 outbound links per page) +- When updating a page, always bump the `updated` date +- Every new page must be added to `index.md` under the correct section +- Every action must be appended to `log.md` + +## Frontmatter + ```yaml + --- + title: Page Title + created: YYYY-MM-DD + updated: YYYY-MM-DD + type: entity | concept | comparison | query | summary + tags: [from taxonomy below] + sources: [raw/articles/source-name.md] + --- + ``` + +## Tag Taxonomy +[Define 10-20 top-level tags for the domain. Add new tags here BEFORE using them.] + +Example for AI/ML: +- Models: model, architecture, benchmark, training +- People/Orgs: person, company, lab, open-source +- Techniques: optimization, fine-tuning, inference, alignment, data +- Meta: comparison, timeline, controversy, prediction + +Rule: every tag on a page must appear in this taxonomy. If a new tag is needed, +add it here first, then use it. This prevents tag sprawl. + +## Page Thresholds +- **Create a page** when an entity/concept appears in 2+ sources OR is central to one source +- **Add to existing page** when a source mentions something already covered +- **DON'T create a page** for passing mentions, minor details, or things outside the domain +- **Split a page** when it exceeds ~200 lines — break into sub-topics with cross-links +- **Archive a page** when its content is fully superseded — move to `_archive/`, remove from index + +## Entity Pages +One page per notable entity. Include: +- Overview / what it is +- Key facts and dates +- Relationships to other entities ([[wikilinks]]) +- Source references + +## Concept Pages +One page per concept or topic. Include: +- Definition / explanation +- Current state of knowledge +- Open questions or debates +- Related concepts ([[wikilinks]]) + +## Comparison Pages +Side-by-side analyses. Include: +- What is being compared and why +- Dimensions of comparison (table format preferred) +- Verdict or synthesis +- Sources + +## Update Policy +When new information conflicts with existing content: +1. Check the dates — newer sources generally supersede older ones +2. If genuinely contradictory, note both positions with dates and sources +3. Mark the contradiction in frontmatter: `contradictions: [page-name]` +4. Flag for user review in the lint report +``` + +### index.md Template + +The index is sectioned by type. Each entry is one line: wikilink + summary. + +```markdown +# Wiki Index + +> Content catalog. Every wiki page listed under its type with a one-line summary. +> Read this first to find relevant pages for any query. +> Last updated: YYYY-MM-DD | Total pages: N + +## Entities + + +## Concepts + +## Comparisons + +## Queries +``` + +**Scaling rule:** When any section exceeds 50 entries, split it into sub-sections +by first letter or sub-domain. When the index exceeds 200 entries total, create +a `_meta/topic-map.md` that groups pages by theme for faster navigation. + +### log.md Template + +```markdown +# Wiki Log + +> Chronological record of all wiki actions. Append-only. +> Format: `## [YYYY-MM-DD] action | subject` +> Actions: ingest, update, query, lint, create, archive, delete +> When this file exceeds 500 entries, rotate: rename to log-YYYY.md, start fresh. + +## [YYYY-MM-DD] create | Wiki initialized +- Domain: [domain] +- Structure created with SCHEMA.md, index.md, log.md +``` + +## Core Operations + +### 1. Ingest + +When the user provides a source (URL, file, paste), integrate it into the wiki: + +① **Capture the raw source:** + - URL → use `web_extract` to get markdown, save to `raw/articles/` + - PDF → use `web_extract` (handles PDFs), save to `raw/papers/` + - Pasted text → save to appropriate `raw/` subdirectory + - Name the file descriptively: `raw/articles/karpathy-llm-wiki-2026.md` + +② **Discuss takeaways** with the user — what's interesting, what matters for + the domain. (Skip this in automated/cron contexts — proceed directly.) + +③ **Check what already exists** — search index.md and use `search_files` to find + existing pages for mentioned entities/concepts. This is the difference between + a growing wiki and a pile of duplicates. + +④ **Write or update wiki pages:** + - **New entities/concepts:** Create pages only if they meet the Page Thresholds + in SCHEMA.md (2+ source mentions, or central to one source) + - **Existing pages:** Add new information, update facts, bump `updated` date. + When new info contradicts existing content, follow the Update Policy. + - **Cross-reference:** Every new or updated page must link to at least 2 other + pages via `[[wikilinks]]`. Check that existing pages link back. + - **Tags:** Only use tags from the taxonomy in SCHEMA.md + +⑤ **Update navigation:** + - Add new pages to `index.md` under the correct section, alphabetically + - Update the "Total pages" count and "Last updated" date in index header + - Append to `log.md`: `## [YYYY-MM-DD] ingest | Source Title` + - List every file created or updated in the log entry + +⑥ **Report what changed** — list every file created or updated to the user. + +A single source can trigger updates across 5-15 wiki pages. This is normal +and desired — it's the compounding effect. + +### 2. Query + +When the user asks a question about the wiki's domain: + +① **Read `index.md`** to identify relevant pages. +② **For wikis with 100+ pages**, also `search_files` across all `.md` files + for key terms — the index alone may miss relevant content. +③ **Read the relevant pages** using `read_file`. +④ **Synthesize an answer** from the compiled knowledge. Cite the wiki pages + you drew from: "Based on [[page-a]] and [[page-b]]..." +⑤ **File valuable answers back** — if the answer is a substantial comparison, + deep dive, or novel synthesis, create a page in `queries/` or `comparisons/`. + Don't file trivial lookups — only answers that would be painful to re-derive. +⑥ **Update log.md** with the query and whether it was filed. + +### 3. Lint + +When the user asks to lint, health-check, or audit the wiki: + +① **Orphan pages:** Find pages with no inbound `[[wikilinks]]` from other pages. +```python +# Use execute_code for this — programmatic scan across all wiki pages +import os, re +from collections import defaultdict +wiki = "" +# Scan all .md files in entities/, concepts/, comparisons/, queries/ +# Extract all [[wikilinks]] — build inbound link map +# Pages with zero inbound links are orphans +``` + +② **Broken wikilinks:** Find `[[links]]` that point to pages that don't exist. + +③ **Index completeness:** Every wiki page should appear in `index.md`. Compare + the filesystem against index entries. + +④ **Frontmatter validation:** Every wiki page must have all required fields + (title, created, updated, type, tags, sources). Tags must be in the taxonomy. + +⑤ **Stale content:** Pages whose `updated` date is >90 days older than the most + recent source that mentions the same entities. + +⑥ **Contradictions:** Pages on the same topic with conflicting claims. Look for + pages that share tags/entities but state different facts. + +⑦ **Page size:** Flag pages over 200 lines — candidates for splitting. + +⑧ **Tag audit:** List all tags in use, flag any not in the SCHEMA.md taxonomy. + +⑨ **Log rotation:** If log.md exceeds 500 entries, rotate it. + +⑩ **Report findings** with specific file paths and suggested actions, grouped by + severity (broken links > orphans > stale content > style issues). + +⑪ **Append to log.md:** `## [YYYY-MM-DD] lint | N issues found` + +## Working with the Wiki + +### Searching + +```bash +# Find pages by content +search_files "transformer" path="$WIKI" file_glob="*.md" + +# Find pages by filename +search_files "*.md" target="files" path="$WIKI" + +# Find pages by tag +search_files "tags:.*alignment" path="$WIKI" file_glob="*.md" + +# Recent activity +read_file "$WIKI/log.md" offset= +``` + +### Bulk Ingest + +When ingesting multiple sources at once, batch the updates: +1. Read all sources first +2. Identify all entities and concepts across all sources +3. Check existing pages for all of them (one search pass, not N) +4. Create/update pages in one pass (avoids redundant updates) +5. Update index.md once at the end +6. Write a single log entry covering the batch + +### Archiving + +When content is fully superseded or the domain scope changes: +1. Create `_archive/` directory if it doesn't exist +2. Move the page to `_archive/` with its original path (e.g., `_archive/entities/old-page.md`) +3. Remove from `index.md` +4. Update any pages that linked to it — replace wikilink with plain text + "(archived)" +5. Log the archive action + +### Obsidian Integration + +The wiki directory works as an Obsidian vault out of the box: +- `[[wikilinks]]` render as clickable links +- Graph View visualizes the knowledge network +- YAML frontmatter powers Dataview queries +- The `raw/assets/` folder holds images referenced via `![[image.png]]` + +For best results: +- Set Obsidian's attachment folder to `raw/assets/` +- Enable "Wikilinks" in Obsidian settings (usually on by default) +- Install Dataview plugin for queries like `TABLE tags FROM "entities" WHERE contains(tags, "company")` + +If using the Obsidian skill alongside this one, set `OBSIDIAN_VAULT_PATH` to the +same directory as the wiki path. + +## Pitfalls + +- **Never modify files in `raw/`** — sources are immutable. Corrections go in wiki pages. +- **Always orient first** — read SCHEMA + index + recent log before any operation in a new session. + Skipping this causes duplicates and missed cross-references. +- **Always update index.md and log.md** — skipping this makes the wiki degrade. These are the + navigational backbone. +- **Don't create pages for passing mentions** — follow the Page Thresholds in SCHEMA.md. A name + appearing once in a footnote doesn't warrant an entity page. +- **Don't create pages without cross-references** — isolated pages are invisible. Every page must + link to at least 2 other pages. +- **Frontmatter is required** — it enables search, filtering, and staleness detection. +- **Tags must come from the taxonomy** — freeform tags decay into noise. Add new tags to SCHEMA.md + first, then use them. +- **Keep pages scannable** — a wiki page should be readable in 30 seconds. Split pages over + 200 lines. Move detailed analysis to dedicated deep-dive pages. +- **Ask before mass-updating** — if an ingest would touch 10+ existing pages, confirm + the scope with the user first. +- **Rotate the log** — when log.md exceeds 500 entries, rename it `log-YYYY.md` and start fresh. + The agent should check log size during lint. +- **Handle contradictions explicitly** — don't silently overwrite. Note both claims with dates, + mark in frontmatter, flag for user review. diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index e5660b61f99d..7ca16bff5c07 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -61,6 +61,11 @@ metadata: requires_tools: [web_search] # Optional — only show when these tools are available fallback_for_toolsets: [browser] # Optional — hide when these toolsets are active fallback_for_tools: [browser_navigate] # Optional — hide when these tools exist + config: # Optional — config.yaml settings the skill needs + - key: my.setting + description: "What this setting controls" + default: "sensible-default" + prompt: "Display prompt for setup" required_environment_variables: # Optional — env vars the skill needs - name: MY_API_KEY prompt: "Enter your API key" @@ -173,6 +178,59 @@ When your skill is loaded, any declared `required_environment_variables` that ar Legacy `prerequisites.env_vars` remains supported as a backward-compatible alias. +### Config Settings (config.yaml) + +Skills can declare non-secret settings that are stored in `config.yaml` under the `skills.config` namespace. Unlike environment variables (which are secrets stored in `.env`), config settings are for paths, preferences, and other non-sensitive values. + +```yaml +metadata: + hermes: + config: + - key: wiki.path + description: Path to the LLM Wiki knowledge base directory + default: "~/wiki" + prompt: Wiki directory path + - key: wiki.domain + description: Domain the wiki covers + default: "" + prompt: Wiki domain (e.g., AI/ML research) +``` + +Each entry supports: +- `key` (required) — dotpath for the setting (e.g., `wiki.path`) +- `description` (required) — explains what the setting controls +- `default` (optional) — default value if the user doesn't configure it +- `prompt` (optional) — prompt text shown during `hermes config migrate`; falls back to `description` + +**How it works:** + +1. **Storage:** Values are written to `config.yaml` under `skills.config.`: + ```yaml + skills: + config: + wiki: + path: ~/my-research + ``` + +2. **Discovery:** `hermes config migrate` scans all enabled skills, finds unconfigured settings, and prompts the user. Settings also appear in `hermes config show` under "Skill Settings." + +3. **Runtime injection:** When a skill loads, its config values are resolved and appended to the skill message: + ``` + [Skill config (from ~/.hermes/config.yaml): + wiki.path = /home/user/my-research + ] + ``` + The agent sees the configured values without needing to read `config.yaml` itself. + +4. **Manual setup:** Users can also set values directly: + ```bash + hermes config set skills.config.wiki.path ~/my-wiki + ``` + +:::tip When to use which +Use `required_environment_variables` for API keys, tokens, and other **secrets** (stored in `~/.hermes/.env`, never shown to the model). Use `config` for **paths, preferences, and non-sensitive settings** (stored in `config.yaml`, visible in config show). +::: + ### Credential File Requirements (OAuth tokens, etc.) Skills that use OAuth or file-based credentials can declare files that need to be mounted into remote sandboxes. This is for credentials stored as **files** (not env vars) — typically OAuth token files produced by a setup script. diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index c0d83212f0af..fe282bafb899 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -252,6 +252,7 @@ Skills for academic research, paper discovery, literature review, domain reconna |-------|-------------|------| | `arxiv` | Search and retrieve academic papers from arXiv using their free REST API. No API key needed. Search by keyword, author, category, or ID. Combine with web_extract or the ocr-and-documents skill to read full paper content. | `research/arxiv` | | `blogwatcher` | Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI. Add blogs, scan for new articles, and track what you've read. | `research/blogwatcher` | +| `llm-wiki` | Karpathy's LLM Wiki — build and maintain a persistent, interlinked markdown knowledge base. Ingest sources, query compiled knowledge, and lint for consistency. Unlike RAG, the wiki compiles knowledge once and keeps it current. Works as an Obsidian vault. Configurable via `skills.config.wiki.path`. | `research/llm-wiki` | | `domain-intel` | Passive domain reconnaissance using Python stdlib. Subdomain discovery, SSL certificate inspection, WHOIS lookups, DNS records, domain availability checks, and bulk multi-domain analysis. No API keys required. | `research/domain-intel` | | `duckduckgo-search` | Free web search via DuckDuckGo — text, news, images, videos. No API key needed. Prefer the `ddgs` CLI when installed; use the Python DDGS library only after verifying that `ddgs` is available in the current runtime. | `research/duckduckgo-search` | | `ml-paper-writing` | Write publication-ready ML/AI papers for NeurIPS, ICML, ICLR, ACL, AAAI, COLM. Use when drafting papers from research repos, structuring arguments, verifying citations, or preparing camera-ready submissions. Includes LaTeX templates, reviewer guidelines, and citation verificatio… | `research/ml-paper-writing` | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index f58ee21ab282..7148b423d3d5 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -352,6 +352,31 @@ Commands that require `stdin_data` or sudo automatically fall back to one-shot m See [Code Execution](features/code-execution.md) and the [Terminal section of the README](features/tools.md) for details on each backend. +## Skill Settings + +Skills can declare their own configuration settings via their SKILL.md frontmatter. These are non-secret values (paths, preferences, domain settings) stored under the `skills.config` namespace in `config.yaml`. + +```yaml +skills: + config: + wiki: + path: ~/wiki # Used by the llm-wiki skill +``` + +**How skill settings work:** + +- `hermes config migrate` scans all enabled skills, finds unconfigured settings, and offers to prompt you +- `hermes config show` displays all skill settings under "Skill Settings" with the skill they belong to +- When a skill loads, its resolved config values are injected into the skill context automatically + +**Setting values manually:** + +```bash +hermes config set skills.config.wiki.path ~/my-research-wiki +``` + +For details on declaring config settings in your own skills, see [Creating Skills — Config Settings](/docs/developer-guide/creating-skills#config-settings-configyaml). + ## Memory Configuration ```yaml diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 3d166b978269..69663a26e15e 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -67,6 +67,11 @@ metadata: category: devops fallback_for_toolsets: [web] # Optional — conditional activation (see below) requires_toolsets: [terminal] # Optional — conditional activation (see below) + config: # Optional — config.yaml settings + - key: my.setting + description: "What this controls" + default: "value" + prompt: "Prompt for setup" --- # Skill Title @@ -142,6 +147,24 @@ When a missing value is encountered, Hermes asks for it securely only when the s Once set, declared env vars are **automatically passed through** to `execute_code` and `terminal` sandboxes — the skill's scripts can use `$TENOR_API_KEY` directly. For non-skill env vars, use the `terminal.env_passthrough` config option. See [Environment Variable Passthrough](/docs/user-guide/security#environment-variable-passthrough) for details. +### Skill Config Settings + +Skills can also declare non-secret config settings (paths, preferences) stored in `config.yaml`: + +```yaml +metadata: + hermes: + config: + - key: wiki.path + description: Path to the wiki directory + default: "~/wiki" + prompt: Wiki directory path +``` + +Settings are stored under `skills.config` in your config.yaml. `hermes config migrate` prompts for unconfigured settings, and `hermes config show` displays them. When a skill loads, its resolved config values are injected into the context so the agent knows the configured values automatically. + +See [Skill Settings](/docs/user-guide/configuration#skill-settings) and [Creating Skills — Config Settings](/docs/developer-guide/creating-skills#config-settings-configyaml) for details. + ## Skill Directory Structure ```text From 7b129636f0926e70873af0e74c422894a19882ee Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:05:26 -0700 Subject: [PATCH 145/213] feat(tools): add Firecrawl cloud browser provider (#5628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tools): add Firecrawl cloud browser provider Adds Firecrawl (https://firecrawl.dev) as a cloud browser provider alongside Browserbase and Browser Use. All browser tools route through Firecrawl's cloud browser via CDP when selected. - tools/browser_providers/firecrawl.py — FirecrawlProvider - tools/browser_tool.py — register in _PROVIDER_REGISTRY - hermes_cli/tools_config.py — add to onboarding provider picker - hermes_cli/setup.py — add to setup summary - hermes_cli/config.py — add FIRECRAWL_BROWSER_TTL config - website/docs/ — browser docs and env var reference Based on #4490 by @developersdigest. Co-Authored-By: Developers Digest <124798203+developersdigest@users.noreply.github.com> * refactor: simplify FirecrawlProvider.emergency_cleanup Use self._headers() and self._api_url() instead of duplicating env-var reads and header construction. * fix: recognize Firecrawl in subscription browser detection _resolve_browser_feature_state() now handles "firecrawl" as a direct browser provider (same pattern as "browser-use"), so hermes setup summary correctly shows "Browser Automation (Firecrawl)" instead of misreporting as "Local browser". Also fixes test_config_version_unchanged assertion (11 → 12). --------- Co-authored-by: Developers Digest <124798203+developersdigest@users.noreply.github.com> --- hermes_cli/config.py | 7 ++ hermes_cli/nous_subscription.py | 7 ++ hermes_cli/setup.py | 2 +- hermes_cli/tools_config.py | 9 ++ tests/tools/test_browser_camofox_state.py | 2 +- tools/browser_providers/firecrawl.py | 107 ++++++++++++++++++ tools/browser_tool.py | 12 +- .../docs/reference/environment-variables.md | 3 +- website/docs/user-guide/features/browser.md | 29 ++++- 9 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 tools/browser_providers/firecrawl.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 94f087e9866c..369fe7acf0c8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -868,6 +868,13 @@ def ensure_hermes_home(): "password": True, "category": "tool", }, + "FIRECRAWL_BROWSER_TTL": { + "description": "Firecrawl browser session TTL in seconds (optional, default 300)", + "prompt": "Browser session TTL (seconds)", + "tools": ["browser_navigate", "browser_click"], + "password": False, + "category": "tool", + }, "CAMOFOX_URL": { "description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)", "prompt": "Camofox server URL", diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index 02814f75d391..821529111524 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -131,6 +131,7 @@ def _browser_label(current_provider: str) -> str: mapping = { "browserbase": "Browserbase", "browser-use": "Browser Use", + "firecrawl": "Firecrawl", "camofox": "Camofox", "local": "Local browser", } @@ -156,6 +157,7 @@ def _resolve_browser_feature_state( direct_camofox: bool, direct_browserbase: bool, direct_browser_use: bool, + direct_firecrawl: bool, managed_browser_available: bool, ) -> tuple[str, bool, bool, bool]: """Resolve browser availability using the same precedence as runtime.""" @@ -179,6 +181,10 @@ def _resolve_browser_feature_state( available = bool(browser_local_available and direct_browser_use) active = bool(browser_tool_enabled and available) return current_provider, available, active, False + if current_provider == "firecrawl": + available = bool(browser_local_available and direct_firecrawl) + active = bool(browser_tool_enabled and available) + return current_provider, available, active, False if current_provider == "camofox": return current_provider, False, False, False @@ -315,6 +321,7 @@ def get_nous_subscription_features( direct_camofox=direct_camofox, direct_browserbase=direct_browserbase, direct_browser_use=direct_browser_use, + direct_firecrawl=direct_firecrawl, managed_browser_available=managed_browser_available, ) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 82a30b3caf20..5abde51ba488 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -657,7 +657,7 @@ def _print_setup_summary(config: dict, hermes_home): else: tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, or TAVILY_API_KEY")) - # Browser tools (local Chromium, Camofox, Browserbase, or Browser Use) + # Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl) browser_provider = subscription_features.browser.current_provider if subscription_features.browser.managed_by_nous: tool_status.append(("Browser Automation (Nous Browserbase)", True, None)) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 9c2088b1d47e..8a28e2247c53 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -315,6 +315,15 @@ def _get_plugin_toolset_keys() -> set: "browser_provider": "browser-use", "post_setup": "browserbase", }, + { + "name": "Firecrawl", + "tag": "Cloud browser with remote execution", + "env_vars": [ + {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"}, + ], + "browser_provider": "firecrawl", + "post_setup": "browserbase", + }, { "name": "Camofox", "tag": "Local anti-detection browser (Firefox/Camoufox)", diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index 002908d12f6d..7fe4c3d4c227 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -63,4 +63,4 @@ def test_config_version_unchanged(self): from hermes_cli.config import DEFAULT_CONFIG # managed_persistence is auto-merged by _deep_merge, no version bump needed - assert DEFAULT_CONFIG["_config_version"] == 11 + assert DEFAULT_CONFIG["_config_version"] == 12 diff --git a/tools/browser_providers/firecrawl.py b/tools/browser_providers/firecrawl.py new file mode 100644 index 000000000000..3f8556fc1246 --- /dev/null +++ b/tools/browser_providers/firecrawl.py @@ -0,0 +1,107 @@ +"""Firecrawl cloud browser provider.""" + +import logging +import os +import uuid +from typing import Dict + +import requests + +from tools.browser_providers.base import CloudBrowserProvider + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://api.firecrawl.dev" + + +class FirecrawlProvider(CloudBrowserProvider): + """Firecrawl (https://firecrawl.dev) cloud browser backend.""" + + def provider_name(self) -> str: + return "Firecrawl" + + def is_configured(self) -> bool: + return bool(os.environ.get("FIRECRAWL_API_KEY")) + + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ + + def _api_url(self) -> str: + return os.environ.get("FIRECRAWL_API_URL", _BASE_URL) + + def _headers(self) -> Dict[str, str]: + api_key = os.environ.get("FIRECRAWL_API_KEY") + if not api_key: + raise ValueError( + "FIRECRAWL_API_KEY environment variable is required. " + "Get your key at https://firecrawl.dev" + ) + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + + def create_session(self, task_id: str) -> Dict[str, object]: + ttl = int(os.environ.get("FIRECRAWL_BROWSER_TTL", "300")) + + body: Dict[str, object] = {"ttl": ttl} + + response = requests.post( + f"{self._api_url()}/v2/browser", + headers=self._headers(), + json=body, + timeout=30, + ) + + if not response.ok: + raise RuntimeError( + f"Failed to create Firecrawl browser session: " + f"{response.status_code} {response.text}" + ) + + data = response.json() + session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" + + logger.info("Created Firecrawl browser session %s", session_name) + + return { + "session_name": session_name, + "bb_session_id": data["id"], + "cdp_url": data["cdpUrl"], + "features": {"firecrawl": True}, + } + + def close_session(self, session_id: str) -> bool: + try: + response = requests.delete( + f"{self._api_url()}/v2/browser/{session_id}", + headers=self._headers(), + timeout=10, + ) + if response.status_code in (200, 201, 204): + logger.debug("Successfully closed Firecrawl session %s", session_id) + return True + else: + logger.warning( + "Failed to close Firecrawl session %s: HTTP %s - %s", + session_id, + response.status_code, + response.text[:200], + ) + return False + except Exception as e: + logger.error("Exception closing Firecrawl session %s: %s", session_id, e) + return False + + def emergency_cleanup(self, session_id: str) -> None: + try: + requests.delete( + f"{self._api_url()}/v2/browser/{session_id}", + headers=self._headers(), + timeout=5, + ) + except ValueError: + logger.warning("Cannot emergency-cleanup Firecrawl session %s — missing credentials", session_id) + except Exception as e: + logger.debug("Emergency cleanup failed for Firecrawl session %s: %s", session_id, e) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 8a495bed69e3..a6043e0bf336 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -79,6 +79,7 @@ from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider +from tools.browser_providers.firecrawl import FirecrawlProvider from tools.tool_backend_helpers import normalize_browser_cloud_provider # Camofox local anti-detection browser backend (optional). @@ -235,6 +236,7 @@ def _get_cdp_override() -> str: _PROVIDER_REGISTRY: Dict[str, type] = { "browserbase": BrowserbaseProvider, "browser-use": BrowserUseProvider, + "firecrawl": FirecrawlProvider, } _cached_cloud_provider: Optional[CloudBrowserProvider] = None @@ -2036,12 +2038,12 @@ def check_browser_requirements() -> bool: """ Check if browser tool requirements are met. - In **local mode** (no Browserbase credentials): only the ``agent-browser`` - CLI must be findable. + In **local mode** (no cloud provider configured): only the + ``agent-browser`` CLI must be findable. + + In **cloud mode** (Browserbase, Browser Use, or Firecrawl): the CLI + *and* the provider's required credentials must be present. - In **cloud mode** (BROWSERBASE_API_KEY set): the CLI *and* both - ``BROWSERBASE_API_KEY`` / ``BROWSERBASE_PROJECT_ID`` must be present. - Returns: True if all requirements are met, False otherwise """ diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 8917072a491f..fb2a67523643 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -77,13 +77,14 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| | `PARALLEL_API_KEY` | AI-native web search ([parallel.ai](https://parallel.ai/)) | -| `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) | +| `FIRECRAWL_API_KEY` | Web scraping and cloud browser ([firecrawl.dev](https://firecrawl.dev/)) | | `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) | | `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) | | `EXA_API_KEY` | Exa API key for AI-native web search and contents ([exa.ai](https://exa.ai/)) | | `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) | | `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | +| `FIRECRAWL_BROWSER_TTL` | Firecrawl browser session TTL in seconds (default: 300) | | `BROWSER_CDP_URL` | Chrome DevTools Protocol URL for local browser (set via `/browser connect`, e.g. `ws://localhost:9222`) | | `CAMOFOX_URL` | Camofox local anti-detection browser URL (default: `http://localhost:9377`) | | `BROWSER_INACTIVITY_TIMEOUT` | Browser session inactivity timeout in seconds | diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 10a6ccee8c8d..8f9fc24ebb62 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -11,6 +11,7 @@ Hermes Agent includes a full browser automation toolset with multiple backend op - **Browserbase cloud mode** via [Browserbase](https://browserbase.com) for managed cloud browsers and anti-bot tooling - **Browser Use cloud mode** via [Browser Use](https://browser-use.com) as an alternative cloud browser provider +- **Firecrawl cloud mode** via [Firecrawl](https://firecrawl.dev) for cloud browsers with built-in scraping - **Camofox local mode** via [Camofox](https://github.com/jo-inc/camofox-browser) for local anti-detection browsing (Firefox-based fingerprint spoofing) - **Local Chrome via CDP** — connect browser tools to your own Chrome instance using `/browser connect` - **Local browser mode** via the `agent-browser` CLI and a local Chromium installation @@ -23,7 +24,7 @@ Pages are represented as **accessibility trees** (text-based snapshots), making Key capabilities: -- **Multi-provider cloud execution** — Browserbase or Browser Use, no local browser needed +- **Multi-provider cloud execution** — Browserbase, Browser Use, or Firecrawl — no local browser needed - **Local Chrome integration** — attach to your running Chrome via CDP for hands-on browsing - **Built-in stealth** — random fingerprints, CAPTCHA solving, residential proxies (Browserbase) - **Session isolation** — each task gets its own browser session @@ -55,6 +56,32 @@ BROWSER_USE_API_KEY=*** Get your API key at [browser-use.com](https://browser-use.com). Browser Use provides a cloud browser via its REST API. If both Browserbase and Browser Use credentials are set, Browserbase takes priority. +### Firecrawl cloud mode + +To use Firecrawl as your cloud browser provider, add: + +```bash +# Add to ~/.hermes/.env +FIRECRAWL_API_KEY=fc-*** +``` + +Get your API key at [firecrawl.dev](https://firecrawl.dev). Then select Firecrawl as your browser provider: + +```bash +hermes setup tools +# → Browser Automation → Firecrawl +``` + +Optional settings: + +```bash +# Self-hosted Firecrawl instance (default: https://api.firecrawl.dev) +FIRECRAWL_API_URL=http://localhost:3002 + +# Session TTL in seconds (default: 300) +FIRECRAWL_BROWSER_TTL=600 +``` + ### Camofox local mode [Camofox](https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js server wrapping Camoufox (a Firefox fork with C++ fingerprint spoofing). It provides local anti-detection browsing without cloud dependencies. From e651e04100049264fd4f4f013020715feef44542 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:51:19 -0700 Subject: [PATCH 146/213] fix(nix): read version, regen uv.lock, fix packages.nix to add hermes_logging (#5651) * - read version from pyproject for nix - regen uv.lock - add hermes_logging to packages.nix * fix secret regen w/ sops --- nix/nixosModules.nix | 2 +- nix/packages.nix | 2 +- pyproject.toml | 2 +- uv.lock | 10 ++++++---- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index 0e15c6f537e6..acf9a6e9d5f2 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -561,7 +561,7 @@ # ── Activation: link config + auth + documents ──────────────────── { - system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] '' + system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" "setupSecrets" ] '' # Ensure directories exist (activation runs before tmpfiles) mkdir -p ${cfg.stateDir}/.hermes mkdir -p ${cfg.stateDir}/home diff --git a/nix/packages.nix b/nix/packages.nix index 805f76605236..9a65b889d346 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -21,7 +21,7 @@ in { packages.default = pkgs.stdenv.mkDerivation { pname = "hermes-agent"; - version = "0.1.0"; + version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version; dontUnpack = true; dontBuild = true; diff --git a/pyproject.toml b/pyproject.toml index 14a35607ab5e..c35c94e21f7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ 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", "rl_cli", "utils"] +py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"] [tool.setuptools.packages.find] include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"] diff --git a/uv.lock b/uv.lock index d0bf6e923e68..8a5db5436711 100644 --- a/uv.lock +++ b/uv.lock @@ -10,14 +10,14 @@ resolution-markers = [ [[package]] name = "agent-client-protocol" -version = "0.8.1" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/7b/7cdac86db388809d9e3bc58cac88cc7dfa49b7615b98fab304a828cd7f8a/agent_client_protocol-0.8.1.tar.gz", hash = "sha256:1bbf15663bf51f64942597f638e32a6284c5da918055d9672d3510e965143dbd", size = 68866, upload-time = "2026-02-13T15:34:54.567Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/13/3b893421369767e7043cc115d6ef0df417c298b84563be3a12df0416158d/agent_client_protocol-0.9.0.tar.gz", hash = "sha256:f744c48ab9af0f0b4452e5ab5498d61bcab97c26dbe7d6feec5fd36de49be30b", size = 71853, upload-time = "2026-03-26T01:21:00.379Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/f3/219eeca0ad4a20843d4b9eaac5532f87018b9d25730a62a16f54f6c52d1a/agent_client_protocol-0.8.1-py3-none-any.whl", hash = "sha256:9421a11fd435b4831660272d169c3812d553bb7247049c138c3ca127e4b8af8e", size = 54529, upload-time = "2026-02-13T15:34:53.344Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/c284543c08aa443a4ef2c8bd120be51da8433dd174c01749b5d87c333f22/agent_client_protocol-0.9.0-py3-none-any.whl", hash = "sha256:06911500b51d8cb69112544e2be01fc5e7db39ef88fecbc3848c5c6f194798ee", size = 56850, upload-time = "2026-03-26T01:20:59.252Z" }, ] [[package]] @@ -1725,6 +1725,7 @@ honcho = [ { name = "honcho-ai" }, ] matrix = [ + { name = "markdown" }, { name = "matrix-nio", extra = ["e2e"] }, ] mcp = [ @@ -1772,7 +1773,7 @@ yc-bench = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.8.1,<0.9" }, + { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0,<1.0" }, { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" }, { name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" }, { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" }, @@ -1812,6 +1813,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<1" }, { name = "jinja2", specifier = ">=3.1.5,<4" }, { name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" }, + { name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" }, { name = "matrix-nio", extras = ["e2e"], marker = "extra == 'matrix'", specifier = ">=0.24.0,<1" }, { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" }, From 539629923c05e98fee06258a3341af94f2dcccba Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:37:14 -0700 Subject: [PATCH 147/213] docs(llm-wiki): add Obsidian Headless setup for servers (#5660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds obsidian-headless (npm) setup guide to the Obsidian Integration section — Node 22+, ob login, sync-create-remote, sync-setup, systemd service for continuous background sync. Covers the full headless workflow for agents running on servers syncing to Obsidian desktop on other devices. --- skills/research/llm-wiki/SKILL.md | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/skills/research/llm-wiki/SKILL.md b/skills/research/llm-wiki/SKILL.md index db172438f317..753bc3af05d9 100644 --- a/skills/research/llm-wiki/SKILL.md +++ b/skills/research/llm-wiki/SKILL.md @@ -380,6 +380,62 @@ For best results: If using the Obsidian skill alongside this one, set `OBSIDIAN_VAULT_PATH` to the same directory as the wiki path. +### Obsidian Headless (servers and headless machines) + +On machines without a display, use `obsidian-headless` instead of the desktop app. +It syncs vaults via Obsidian Sync without a GUI — perfect for agents running on +servers that write to the wiki while Obsidian desktop reads it on another device. + +**Setup:** +```bash +# Requires Node.js 22+ +npm install -g obsidian-headless + +# Login (requires Obsidian account with Sync subscription) +ob login --email --password '' + +# Create a remote vault for the wiki +ob sync-create-remote --name "LLM Wiki" + +# Connect the wiki directory to the vault +cd ~/wiki +ob sync-setup --vault "" + +# Initial sync +ob sync + +# Continuous sync (foreground — use systemd for background) +ob sync --continuous +``` + +**Continuous background sync via systemd:** +```ini +# ~/.config/systemd/user/obsidian-wiki-sync.service +[Unit] +Description=Obsidian LLM Wiki Sync +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/path/to/ob sync --continuous +WorkingDirectory=/home/user/wiki +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target +``` + +```bash +systemctl --user daemon-reload +systemctl --user enable --now obsidian-wiki-sync +# Enable linger so sync survives logout: +sudo loginctl enable-linger $USER +``` + +This lets the agent write to `~/wiki` on a server while you browse the same +vault in Obsidian on your laptop/phone — changes appear within seconds. + ## Pitfalls - **Never modify files in `raw/`** — sources are immutable. Corrections go in wiki pages. From 9201370c7ef54d6a3c1a582e5632567c02dd687c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:17:43 +0530 Subject: [PATCH 148/213] feat(plugins): prompt for required env vars during hermes plugins install Read requires_env from plugin.yaml after install and interactively prompt for any missing environment variables, saving them to ~/.hermes/.env. Supports two manifest formats: Simple (backwards-compatible): requires_env: - MY_API_KEY Rich (with metadata): requires_env: - name: MY_API_KEY description: "API key for Acme" url: "https://acme.com/keys" secret: true Already-set variables are skipped. Empty input skips gracefully. Secret values use getpass (hidden input). Ctrl+C aborts remaining prompts without error. --- hermes_cli/plugins_cmd.py | 81 +++++++++++++++++++++++++++ tests/test_plugins_cmd.py | 112 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 68a31544c69f..e794a3a1f61f 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -147,6 +147,81 @@ def _copy_example_files(plugin_dir: Path, console) -> None: ) +def _prompt_plugin_env_vars(manifest: dict, console) -> None: + """Prompt for required environment variables declared in plugin.yaml. + + ``requires_env`` accepts two formats: + + Simple list (backwards-compatible):: + + requires_env: + - MY_API_KEY + + Rich list with metadata:: + + requires_env: + - name: MY_API_KEY + description: "API key for Acme service" + url: "https://acme.com/keys" + secret: true + + Already-set variables are skipped. Values are saved to ``~/.hermes/.env``. + """ + requires_env = manifest.get("requires_env") or [] + if not requires_env: + return + + from hermes_cli.config import get_env_value, save_env_value # noqa: F811 + + # Normalise to list-of-dicts + env_specs: list[dict] = [] + for entry in requires_env: + if isinstance(entry, str): + env_specs.append({"name": entry}) + elif isinstance(entry, dict) and entry.get("name"): + env_specs.append(entry) + + # Filter to only vars that aren't already set + missing = [s for s in env_specs if not get_env_value(s["name"])] + if not missing: + return + + plugin_name = manifest.get("name", "this plugin") + console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n") + + for spec in missing: + name = spec["name"] + desc = spec.get("description", "") + url = spec.get("url", "") + secret = spec.get("secret", False) + + label = f" {name}" + if desc: + label += f" — {desc}" + console.print(label) + if url: + console.print(f" [dim]Get yours at: {url}[/dim]") + + try: + if secret: + import getpass + value = getpass.getpass(f" {name}: ").strip() + else: + value = input(f" {name}: ").strip() + except (EOFError, KeyboardInterrupt): + console.print("\n[dim] Skipped (you can set these later in ~/.hermes/.env)[/dim]") + return + + if value: + save_env_value(name, value) + os.environ[name] = value + console.print(f" [green]✓[/green] Saved to ~/.hermes/.env") + else: + console.print(f" [dim] Skipped (set {name} in ~/.hermes/.env later)[/dim]") + + console.print() + + def _display_after_install(plugin_dir: Path, identifier: str) -> None: """Show after-install.md if it exists, otherwise a default message.""" from rich.console import Console @@ -306,6 +381,12 @@ def cmd_install(identifier: str, force: bool = False) -> None: # Copy .example files to their real names (e.g. config.yaml.example → config.yaml) _copy_example_files(target, console) + # Re-read manifest from installed location (for env var prompting) + installed_manifest = _read_manifest(target) + + # Prompt for required environment variables before showing after-install docs + _prompt_plugin_env_vars(installed_manifest, console) + _display_after_install(target, identifier) console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]") diff --git a/tests/test_plugins_cmd.py b/tests/test_plugins_cmd.py index 492f94ad0fab..b3d3eb7b65e1 100644 --- a/tests/test_plugins_cmd.py +++ b/tests/test_plugins_cmd.py @@ -443,3 +443,115 @@ def test_handles_copy_error_gracefully(self, tmp_path): # Should have printed a warning assert any("Warning" in str(c) for c in console.print.call_args_list) + + +class TestPromptPluginEnvVars: + """Tests for _prompt_plugin_env_vars.""" + + def test_skips_when_no_requires_env(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock + + console = MagicMock() + _prompt_plugin_env_vars({}, console) + console.print.assert_not_called() + + def test_skips_already_set_vars(self, monkeypatch): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + with patch("hermes_cli.config.get_env_value", return_value="already-set"): + _prompt_plugin_env_vars({"requires_env": ["MY_KEY"]}, console) + # No prompt should appear — all vars are set + console.print.assert_not_called() + + def test_prompts_for_missing_var_simple_format(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = { + "name": "test_plugin", + "requires_env": ["MY_API_KEY"], + } + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", return_value="sk-test-123"), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + mock_save.assert_called_once_with("MY_API_KEY", "sk-test-123") + + def test_prompts_for_missing_var_rich_format(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = { + "name": "langfuse_tracing", + "requires_env": [ + { + "name": "LANGFUSE_PUBLIC_KEY", + "description": "Public key", + "url": "https://langfuse.com", + "secret": False, + }, + ], + } + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", return_value="pk-lf-123"), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + mock_save.assert_called_once_with("LANGFUSE_PUBLIC_KEY", "pk-lf-123") + # Should show url hint + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "langfuse.com" in printed + + def test_secret_uses_getpass(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = { + "name": "test", + "requires_env": [{"name": "SECRET_KEY", "secret": True}], + } + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("getpass.getpass", return_value="s3cret") as mock_gp, \ + patch("hermes_cli.config.save_env_value"): + _prompt_plugin_env_vars(manifest, console) + + mock_gp.assert_called_once() + + def test_empty_input_skips(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = {"name": "test", "requires_env": ["OPTIONAL_VAR"]} + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", return_value=""), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + mock_save.assert_not_called() + + def test_keyboard_interrupt_skips_gracefully(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = {"name": "test", "requires_env": ["KEY1", "KEY2"]} + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", side_effect=KeyboardInterrupt), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + # Should not crash, and not save anything + mock_save.assert_not_called() From 631d1598646d021a600cae56e5b3366b28d813c4 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 16:40:15 -0700 Subject: [PATCH 149/213] fix: use display_hermes_home() for profile-aware paths in plugin env prompts Follow-up to PR #5470. Replaces hardcoded ~/.hermes/.env references with display_hermes_home() for correct behavior under profiles. Also updates PluginManifest.requires_env type hint to List[Union[str, Dict[str, Any]]] to document the rich format introduced in #5470. --- hermes_cli/plugins.py | 4 ++-- hermes_cli/plugins_cmd.py | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 73591443cd31..ce57695fa0de 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -36,7 +36,7 @@ import types from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Set +from typing import Any, Callable, Dict, List, Optional, Set, Union from utils import env_var_enabled @@ -95,7 +95,7 @@ class PluginManifest: version: str = "" description: str = "" author: str = "" - requires_env: List[str] = field(default_factory=list) + requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list) provides_tools: List[str] = field(default_factory=list) provides_hooks: List[str] = field(default_factory=list) source: str = "" # "user", "project", or "entrypoint" diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index e794a3a1f61f..bd6d7faba321 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -165,13 +165,14 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None: url: "https://acme.com/keys" secret: true - Already-set variables are skipped. Values are saved to ``~/.hermes/.env``. + Already-set variables are skipped. Values are saved to the user's ``.env``. """ requires_env = manifest.get("requires_env") or [] if not requires_env: return from hermes_cli.config import get_env_value, save_env_value # noqa: F811 + from hermes_constants import display_hermes_home # Normalise to list-of-dicts env_specs: list[dict] = [] @@ -209,15 +210,15 @@ def _prompt_plugin_env_vars(manifest: dict, console) -> None: else: value = input(f" {name}: ").strip() except (EOFError, KeyboardInterrupt): - console.print("\n[dim] Skipped (you can set these later in ~/.hermes/.env)[/dim]") + console.print(f"\n[dim] Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]") return if value: save_env_value(name, value) os.environ[name] = value - console.print(f" [green]✓[/green] Saved to ~/.hermes/.env") + console.print(f" [green]✓[/green] Saved to {display_hermes_home()}/.env") else: - console.print(f" [dim] Skipped (set {name} in ~/.hermes/.env later)[/dim]") + console.print(f" [dim] Skipped (set {name} in {display_hermes_home()}/.env later)[/dim]") console.print() From 4f03b9a419bcba023bcdb2fd5e9ef9ee57f46f9a Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 6 Apr 2026 08:26:39 +1200 Subject: [PATCH 150/213] feat(webhook): add {__raw__} template token and thread_id passthrough for forum topics - {__raw__} in webhook prompt templates dumps the full JSON payload (truncated at 4000 chars) - _deliver_cross_platform now passes thread_id/message_thread_id from deliver_extra as metadata, enabling Telegram forum topic delivery - Tests for both features --- gateway/platforms/webhook.py | 15 +++- tests/gateway/test_webhook_adapter.py | 104 ++++++++++++++++++++++ tests/gateway/test_webhook_integration.py | 2 +- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 5f7c78cfaf99..ae2e7f27af94 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -484,6 +484,10 @@ def _render_prompt( Supports dot-notation access into nested dicts: ``{pull_request.title}`` → ``payload["pull_request"]["title"]`` + + Special token ``{__raw__}`` dumps the entire payload as indented + JSON (truncated to 4000 chars). Useful for monitoring alerts or + any webhook where the agent needs to see the full payload. """ if not template: truncated = json.dumps(payload, indent=2)[:4000] @@ -494,6 +498,9 @@ def _render_prompt( def _resolve(match: re.Match) -> str: key = match.group(1) + # Special token: dump the entire payload as JSON + if key == "__raw__": + return json.dumps(payload, indent=2)[:4000] value: Any = payload for part in key.split("."): if isinstance(value, dict): @@ -613,4 +620,10 @@ async def _deliver_cross_platform( error=f"No chat_id or home channel for {platform_name}", ) - return await adapter.send(chat_id, content) + # Pass thread_id from deliver_extra so Telegram forum topics work + metadata = None + thread_id = extra.get("message_thread_id") or extra.get("thread_id") + if thread_id: + metadata = {"thread_id": thread_id} + + return await adapter.send(chat_id, content, metadata=metadata) diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 9b8a91318a9a..f323b95af216 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -617,3 +617,107 @@ def test_returns_true_when_aiohttp_available(self): @patch("gateway.platforms.webhook.AIOHTTP_AVAILABLE", False) def test_returns_false_without_aiohttp(self): assert check_webhook_requirements() is False + + +# =================================================================== +# __raw__ template token +# =================================================================== + + +class TestRawTemplateToken: + """Tests for the {__raw__} special token in _render_prompt.""" + + def test_raw_resolves_to_full_json_payload(self): + """{__raw__} in a template dumps the entire payload as JSON.""" + adapter = _make_adapter() + payload = {"action": "opened", "number": 42} + result = adapter._render_prompt( + "Payload: {__raw__}", payload, "push", "test" + ) + expected_json = json.dumps(payload, indent=2) + assert result == f"Payload: {expected_json}" + + def test_raw_truncated_at_4000_chars(self): + """{__raw__} output is truncated at 4000 characters for large payloads.""" + adapter = _make_adapter() + # Build a payload whose JSON repr exceeds 4000 chars + payload = {"data": "x" * 5000} + result = adapter._render_prompt("{__raw__}", payload, "push", "test") + assert len(result) <= 4000 + + def test_raw_mixed_with_other_variables(self): + """{__raw__} can be mixed with regular template variables.""" + adapter = _make_adapter() + payload = {"action": "closed", "number": 7} + result = adapter._render_prompt( + "Action={action} Raw={__raw__}", payload, "push", "test" + ) + assert result.startswith("Action=closed Raw=") + assert '"action": "closed"' in result + assert '"number": 7' in result + + +# =================================================================== +# Cross-platform delivery thread_id passthrough +# =================================================================== + + +class TestDeliverCrossPlatformThreadId: + """Tests for thread_id passthrough in _deliver_cross_platform.""" + + def _setup_adapter_with_mock_target(self): + """Set up a webhook adapter with a mocked gateway_runner and target adapter.""" + adapter = _make_adapter() + mock_target = AsyncMock() + mock_target.send = AsyncMock(return_value=SendResult(success=True)) + + mock_runner = MagicMock() + mock_runner.adapters = {Platform("telegram"): mock_target} + mock_runner.config.get_home_channel.return_value = None + + adapter.gateway_runner = mock_runner + return adapter, mock_target + + @pytest.mark.asyncio + async def test_thread_id_passed_as_metadata(self): + """thread_id from deliver_extra is passed as metadata to adapter.send().""" + adapter, mock_target = self._setup_adapter_with_mock_target() + delivery = { + "deliver_extra": { + "chat_id": "12345", + "thread_id": "999", + } + } + await adapter._deliver_cross_platform("telegram", "hello", delivery) + mock_target.send.assert_awaited_once_with( + "12345", "hello", metadata={"thread_id": "999"} + ) + + @pytest.mark.asyncio + async def test_message_thread_id_passed_as_thread_id(self): + """message_thread_id from deliver_extra is mapped to thread_id in metadata.""" + adapter, mock_target = self._setup_adapter_with_mock_target() + delivery = { + "deliver_extra": { + "chat_id": "12345", + "message_thread_id": "888", + } + } + await adapter._deliver_cross_platform("telegram", "hello", delivery) + mock_target.send.assert_awaited_once_with( + "12345", "hello", metadata={"thread_id": "888"} + ) + + @pytest.mark.asyncio + async def test_no_thread_id_sends_no_metadata(self): + """When no thread_id is present, metadata is None.""" + adapter, mock_target = self._setup_adapter_with_mock_target() + delivery = { + "deliver_extra": { + "chat_id": "12345", + } + } + await adapter._deliver_cross_platform("telegram", "hello", delivery) + mock_target.send.assert_awaited_once_with( + "12345", "hello", metadata=None + ) diff --git a/tests/gateway/test_webhook_integration.py b/tests/gateway/test_webhook_integration.py index 14b9b6974487..8999898103ce 100644 --- a/tests/gateway/test_webhook_integration.py +++ b/tests/gateway/test_webhook_integration.py @@ -257,7 +257,7 @@ async def test_cross_platform_delivery(self): assert result.success is True mock_tg_adapter.send.assert_awaited_once_with( - "12345", "I've acknowledged the alert." + "12345", "I've acknowledged the alert.", metadata=None ) # Delivery info should be cleaned up assert chat_id not in adapter._delivery_info From f071b1832a446f09137fea625045bb4be3a0d781 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 16:43:31 -0700 Subject: [PATCH 151/213] docs: document rich requires_env format and install-time prompting Updates the plugin build guide and features page to reflect the interactive env var prompting added in PR #5470. Documents the rich manifest format (name/description/url/secret) alongside the simple string format. --- website/docs/guides/build-a-hermes-plugin.md | 32 ++++++++++++++++++-- website/docs/user-guide/features/plugins.md | 2 +- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index 7a63ac04a70f..e0a7f662c261 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -44,8 +44,12 @@ This tells Hermes: "I'm a plugin called calculator, I provide tools and hooks." Optional fields you could add: ```yaml author: Your Name -requires_env: # gate loading on env vars - - SOME_API_KEY # plugin disabled if missing +requires_env: # gate loading on env vars; prompted during install + - SOME_API_KEY # simple format — plugin disabled if missing + - name: OTHER_KEY # rich format — shows description/url during install + description: "Key for the Other service" + url: "https://other.com/keys" + secret: true ``` ## Step 3: Write the tool schemas @@ -336,13 +340,35 @@ def register(ctx): If your plugin needs an API key: ```yaml -# plugin.yaml +# plugin.yaml — simple format (backwards-compatible) requires_env: - WEATHER_API_KEY ``` If `WEATHER_API_KEY` isn't set, the plugin is disabled with a clear message. No crash, no error in the agent — just "Plugin weather disabled (missing: WEATHER_API_KEY)". +When users run `hermes plugins install`, they're **prompted interactively** for any missing `requires_env` variables. Values are saved to `.env` automatically. + +For a better install experience, use the rich format with descriptions and signup URLs: + +```yaml +# plugin.yaml — rich format +requires_env: + - name: WEATHER_API_KEY + description: "API key for OpenWeather" + url: "https://openweathermap.org/api" + secret: true +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Environment variable name | +| `description` | No | Shown to user during install prompt | +| `url` | No | Where to get the credential | +| `secret` | No | If `true`, input is hidden (like a password field) | + +Both formats can be mixed in the same list. Already-set variables are skipped silently. + ### Conditional tool availability For tools that depend on optional libraries: diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 18191cb74f4c..a8f984fed4a4 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -87,7 +87,7 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable | Inject messages | `ctx.inject_message(content, role="user")` — see [Injecting Messages](#injecting-messages) | | Ship data files | `Path(__file__).parent / "data" / "file.yaml"` | | Bundle skills | Copy `skill.md` to `~/.hermes/skills/` at load time | -| Gate on env vars | `requires_env: [API_KEY]` in plugin.yaml | +| Gate on env vars | `requires_env: [API_KEY]` in plugin.yaml — prompted during `hermes plugins install` | | Distribute via pip | `[project.entry-points."hermes_agent.plugins"]` | ## Plugin discovery From e7698521e7ff98b8344c3c683b7cd3235d9cf07a Mon Sep 17 00:00:00 2001 From: dagbs Date: Mon, 6 Apr 2026 16:45:13 -0700 Subject: [PATCH 152/213] fix(openviking): add atexit safety net for session commit Ensures pending sessions are committed on process exit even if shutdown_memory_provider is never called (gateway crash, SIGKILL, or exception in _async_flush_memories preventing shutdown). Also reorders on_session_end to wait for the pending sync thread before checking turn_count, so the last turn's messages are flushed. Based on PR #4919 by dagbs. --- plugins/memory/openviking/__init__.py | 42 +++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 410979a0ec59..596080c32ea7 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -23,6 +23,7 @@ from __future__ import annotations +import atexit import json import logging import os @@ -37,6 +38,30 @@ _TIMEOUT = 30.0 +# --------------------------------------------------------------------------- +# Process-level atexit safety net — ensures pending sessions are committed +# even if shutdown_memory_provider is never called (e.g. gateway crash, +# SIGKILL, or exception in _async_flush_memories preventing shutdown). +# --------------------------------------------------------------------------- +_last_active_provider: Optional["OpenVikingMemoryProvider"] = None + + +def _atexit_commit_sessions(): + """Fire on_session_end for the last active provider on process exit.""" + global _last_active_provider + provider = _last_active_provider + if provider is None: + return + _last_active_provider = None + try: + provider.on_session_end([]) + except Exception: + pass # best-effort at shutdown time + + +atexit.register(_atexit_commit_sessions) + + # --------------------------------------------------------------------------- # HTTP helper — uses httpx to avoid requiring the openviking SDK # --------------------------------------------------------------------------- @@ -277,6 +302,10 @@ def initialize(self, session_id: str, **kwargs) -> None: logger.warning("httpx not installed — OpenViking plugin disabled") self._client = None + # Register as the last active provider for atexit safety net + global _last_active_provider + _last_active_provider = self + def system_prompt_block(self) -> str: if not self._client: return "" @@ -387,13 +416,18 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None: OpenViking automatically extracts 6 categories of memories: profile, preferences, entities, events, cases, and patterns. """ - if not self._client or self._turn_count == 0: + if not self._client: return - # Wait for any pending sync to finish first + # Wait for any pending sync to finish first — do this before the + # turn_count check so the last turn's messages are flushed even if + # the count hasn't been incremented yet. if self._sync_thread and self._sync_thread.is_alive(): self._sync_thread.join(timeout=10.0) + if self._turn_count == 0: + return + try: self._client.post(f"/api/v1/sessions/{self._session_id}/commit") logger.info("OpenViking session %s committed (%d turns)", self._session_id, self._turn_count) @@ -449,6 +483,10 @@ def shutdown(self) -> None: for t in (self._sync_thread, self._prefetch_thread): if t and t.is_alive(): t.join(timeout=5.0) + # Clear atexit reference so it doesn't double-commit + global _last_active_provider + if _last_active_provider is self: + _last_active_provider = None # -- Tool implementations ------------------------------------------------ From e131f13662d46b266dec1d666d5ac3ad59953725 Mon Sep 17 00:00:00 2001 From: Simon Brumfield Date: Mon, 6 Apr 2026 17:39:52 -0400 Subject: [PATCH 153/213] fix(doctor): use recall_mode instead of memory_mode on HonchoClientConfig --- hermes_cli/doctor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 40cbfe20ac40..08cf7233493e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -836,7 +836,7 @@ def run_doctor(args): get_honcho_client(hcfg) check_ok( "Honcho connected", - f"workspace={hcfg.workspace_id} mode={hcfg.memory_mode} freq={hcfg.write_frequency}", + f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}", ) except Exception as _e: check_fail("Honcho connection failed", str(_e)) From 77610961be12c369d6b33a4948149b703b46e773 Mon Sep 17 00:00:00 2001 From: KangYu Date: Tue, 7 Apr 2026 04:48:15 +0900 Subject: [PATCH 154/213] Lower Telegram fallback activation log to info --- gateway/platforms/telegram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index b463870365c4..7575c10f377e 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -518,7 +518,7 @@ async def connect(self) -> bool: ", ".join(fallback_ips), ) if fallback_ips: - logger.warning( + logger.info( "[%s] Telegram fallback IPs active: %s", self.name, ", ".join(fallback_ips), From 3b4dfc8e226cb7d703cd669073a1af1101ceca27 Mon Sep 17 00:00:00 2001 From: charliekerfoot Date: Mon, 6 Apr 2026 18:25:54 -0500 Subject: [PATCH 155/213] fix(tools): portable base64 encoding for image reading on macOS --- tools/file_operations.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/file_operations.py b/tools/file_operations.py index 4202e79728b2..8305eb9c4b47 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -580,8 +580,10 @@ def _read_image(self, path: str) -> ReadResult: ), ) - # Get base64 content - b64_cmd = f"base64 -w 0 {self._escape_shell_arg(path)} 2>/dev/null" + # Get base64 content — pipe through tr to strip newlines portably. + # GNU base64 supports -w 0 but macOS base64 does not; both wrap by + # default, so stripping with tr is portable across all backends. + b64_cmd = f"base64 {self._escape_shell_arg(path)} 2>/dev/null | tr -d '\\n'" b64_result = self._exec(b64_cmd, timeout=30) if b64_result.exit_code != 0: From ea16949422768d759941272f630c0e25e40f9689 Mon Sep 17 00:00:00 2001 From: Myeongwon Choi Date: Tue, 7 Apr 2026 07:43:30 +0900 Subject: [PATCH 156/213] fix(cron): suppress delivery when [SILENT] appears anywhere in response Previously the scheduler checked startswith('[SILENT]'), so agents that appended [SILENT] after an explanation (e.g. 'N items filtered.\n\n[SILENT]') would still trigger delivery. Change the check to 'in' so the marker is caught regardless of position. Add test_silent_trailing_suppresses_delivery to cover this case. --- cron/scheduler.py | 2 +- tests/cron/test_scheduler.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 63018d6fff9b..5f3feba07b63 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -817,7 +817,7 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int: # output is already saved above). Failed jobs always deliver. deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}" should_deliver = bool(deliver_content) - if should_deliver and success and deliver_content.strip().upper().startswith(SILENT_MARKER): + if should_deliver and success and SILENT_MARKER in deliver_content.strip().upper(): logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER) should_deliver = False diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 33f265de3380..c12828977fb8 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -709,6 +709,18 @@ def test_silent_with_note_suppresses_delivery(self): tick(verbose=False) deliver_mock.assert_not_called() + def test_silent_trailing_suppresses_delivery(self): + """Agent appended [SILENT] after explanation text — must still suppress.""" + response = "2 deals filtered out (like<10, reply<15).\n\n[SILENT]" + with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ + patch("cron.scheduler.run_job", return_value=(True, "# output", response, None)), \ + patch("cron.scheduler.save_job_output", return_value="/tmp/out.md"), \ + patch("cron.scheduler._deliver_result") as deliver_mock, \ + patch("cron.scheduler.mark_job_run"): + from cron.scheduler import tick + tick(verbose=False) + deliver_mock.assert_not_called() + def test_silent_is_case_insensitive(self): with patch("cron.scheduler.get_due_jobs", return_value=[self._make_job()]), \ patch("cron.scheduler.run_job", return_value=(True, "# output", "[silent] nothing new", None)), \ From 0f9aa570695df978d8256b93a462c1c443e4769f Mon Sep 17 00:00:00 2001 From: ryanautomated Date: Mon, 6 Apr 2026 22:10:47 +0100 Subject: [PATCH 157/213] fix: silent memory flush failure on /new and /resume commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _async_flush_memories() helper accepts (session_id) but both the /new and /resume handlers passed two arguments (session_id, session_key). The TypeError was silently swallowed at DEBUG level, so memory extraction never ran when users typed /new or /resume. One call site (the session expiry watcher) was already fixed in 9c96f669, but /new and /resume were missed. - gateway/run.py:3247 — remove stray session_key from /new handler - gateway/run.py:4989 — remove stray session_key from /resume handler - tests/gateway/test_resume_command.py:222 — update test assertion --- gateway/run.py | 4 ++-- tests/gateway/test_resume_command.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 731bc8c0345a..82cb10b4e343 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3244,7 +3244,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: old_entry = self.session_store._entries.get(session_key) if old_entry: _flush_task = asyncio.create_task( - self._async_flush_memories(old_entry.session_id, session_key) + self._async_flush_memories(old_entry.session_id) ) self._background_tasks.add(_flush_task) _flush_task.add_done_callback(self._background_tasks.discard) @@ -4990,7 +4990,7 @@ async def _handle_resume_command(self, event: MessageEvent) -> str: # Flush memories for current session before switching try: _flush_task = asyncio.create_task( - self._async_flush_memories(current_entry.session_id, session_key) + self._async_flush_memories(current_entry.session_id) ) self._background_tasks.add(_flush_task) _flush_task.add_done_callback(self._background_tasks.discard) diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index 739bc149b947..dc788f74f359 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -201,8 +201,8 @@ async def test_resume_clears_running_agent(self, tmp_path): db.close() @pytest.mark.asyncio - async def test_resume_flushes_memories_with_gateway_session_key(self, tmp_path): - """Resume should preserve the gateway session key for Honcho flushes.""" + async def test_resume_flushes_memories(self, tmp_path): + """Resume should flush memories from the current session before switching.""" from hermes_state import SessionDB db = SessionDB(db_path=tmp_path / "state.db") @@ -221,6 +221,5 @@ async def test_resume_flushes_memories_with_gateway_session_key(self, tmp_path): runner._async_flush_memories.assert_called_once_with( "current_session_001", - _session_key_for_event(event), ) db.close() From 3282b7066c7c49e8be4d8679d0148aef6baf5483 Mon Sep 17 00:00:00 2001 From: Neri Cervin Date: Mon, 6 Apr 2026 16:32:56 -0300 Subject: [PATCH 158/213] fix(mattermost): set message type to DOCUMENT when post has file attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mattermost adapter downloads file attachments correctly but never updates msg_type from TEXT to DOCUMENT. This means the document enrichment block in gateway/run.py (which requires MessageType.DOCUMENT) never executes — text files are not inlined, and the agent is never notified about attached files. The user sends a file, the adapter downloads it to the local cache, but the agent sees an empty message and responds with 'I didn't receive any file'. Set msg_type to DOCUMENT when file_ids is non-empty, matching the behavior of the Telegram and Discord adapters. --- gateway/platforms/mattermost.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 7c9c2d29baca..0e689bed9c56 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -661,6 +661,8 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: msg_type = MessageType.TEXT if message_text.startswith("/"): msg_type = MessageType.COMMAND + elif file_ids: + msg_type = MessageType.DOCUMENT # Download file attachments immediately (URLs require auth headers # that downstream tools won't have). From f3ae2491a3f9b315d0dd5bdffe58aae6783ae529 Mon Sep 17 00:00:00 2001 From: Neri Cervin Date: Mon, 6 Apr 2026 17:03:50 -0300 Subject: [PATCH 159/213] fix: detect correct message type from file mime instead of blanket DOCUMENT Images need PHOTO for vision, audio needs VOICE for STT, and other files get DOCUMENT for text inlining. --- gateway/platforms/mattermost.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 0e689bed9c56..95702603725f 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -661,8 +661,6 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: msg_type = MessageType.TEXT if message_text.startswith("/"): msg_type = MessageType.COMMAND - elif file_ids: - msg_type = MessageType.DOCUMENT # Download file attachments immediately (URLs require auth headers # that downstream tools won't have). @@ -703,6 +701,15 @@ async def _handle_ws_event(self, event: Dict[str, Any]) -> None: except Exception as exc: logger.warning("Mattermost: error downloading file %s: %s", fid, exc) + # Set message type based on downloaded media types. + if media_types and msg_type == MessageType.TEXT: + if any(m.startswith("image/") for m in media_types): + msg_type = MessageType.PHOTO + elif any(m.startswith("audio/") for m in media_types): + msg_type = MessageType.VOICE + elif media_types: + msg_type = MessageType.DOCUMENT + source = self.build_source( chat_id=channel_id, chat_type=chat_type, From c1818b7e9ee2cf03d2ea7ddfa7a74d658ce98faa Mon Sep 17 00:00:00 2001 From: WAXLYY Date: Tue, 7 Apr 2026 01:02:56 +0300 Subject: [PATCH 160/213] fix(tools): redact query secrets in send_message errors --- .../test_send_message_missing_platforms.py | 23 +++++ tests/tools/test_send_message_tool.py | 27 ++++++ tools/send_message_tool.py | 93 ++++++++++++------- 3 files changed, 111 insertions(+), 32 deletions(-) diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index 8943109e02e4..881ae33d2b27 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -314,6 +314,29 @@ def test_http_error(self): assert "error" in result assert "DingTalk send failed" in result["error"] + def test_http_error_redacts_access_token_in_exception_text(self): + token = "supersecret-access-token-123456789" + resp = self._make_httpx_resp(status_code=401) + resp.raise_for_status = MagicMock( + side_effect=Exception( + f"POST https://oapi.dingtalk.com/robot/send?access_token={token} returned 401" + ) + ) + client_ctx, _ = self._make_httpx_client(resp) + + with patch("httpx.AsyncClient", return_value=client_ctx): + result = asyncio.run( + _send_dingtalk( + {"webhook_url": f"https://oapi.dingtalk.com/robot/send?access_token={token}"}, + "ch", + "hi", + ) + ) + + assert "error" in result + assert token not in result["error"] + assert "access_token=***" in result["error"] + def test_missing_config(self): with patch.dict(os.environ, {"DINGTALK_WEBHOOK_URL": ""}, clear=False): result = asyncio.run(_send_dingtalk({}, "ch", "hi")) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 7b4643af88d0..34cea278d75d 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -276,6 +276,33 @@ def test_media_only_message_uses_placeholder_for_mirroring(self): thread_id=None, ) + def test_top_level_send_failure_redacts_query_token(self): + config, _telegram_cfg = _make_config() + leaked = "very-secret-query-token-123456" + + def _raise_and_close(coro): + coro.close() + raise RuntimeError( + f"transport error: https://api.example.com/send?access_token={leaked}" + ) + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch("model_tools._run_async", side_effect=_raise_and_close): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "telegram:-1001", + "message": "hello", + } + ) + ) + + assert "error" in result + assert leaked not in result["error"] + assert "access_token=***" in result["error"] + class TestSendTelegramMediaDelivery: def test_sends_text_then_photo_for_media_tag(self, tmp_path, monkeypatch): diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 32741f08ac73..eff0e7b55d45 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -12,6 +12,8 @@ import ssl import time +from agent.redact import redact_sensitive_text + logger = logging.getLogger(__name__) _TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$") @@ -20,6 +22,27 @@ _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"} _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"} _VOICE_EXTS = {".ogg", ".opus"} +_URL_SECRET_QUERY_RE = re.compile( + r"([?&](?:access_token|api[_-]?key|auth[_-]?token|token|signature|sig)=)([^&#\s]+)", + re.IGNORECASE, +) +_GENERIC_SECRET_ASSIGN_RE = re.compile( + r"\b(access_token|api[_-]?key|auth[_-]?token|signature|sig)\s*=\s*([^\s,;]+)", + re.IGNORECASE, +) + + +def _sanitize_error_text(text) -> str: + """Redact secrets from error text before surfacing it to users/models.""" + redacted = redact_sensitive_text(text) + redacted = _URL_SECRET_QUERY_RE.sub(lambda m: f"{m.group(1)}***", redacted) + redacted = _GENERIC_SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", redacted) + return redacted + + +def _error(message: str) -> dict: + """Build a standardized error payload with redacted content.""" + return {"error": _sanitize_error_text(message)} SEND_MESSAGE_SCHEMA = { @@ -70,7 +93,7 @@ def _handle_list(): from gateway.channel_directory import format_directory_for_display return json.dumps({"targets": format_directory_for_display()}) except Exception as e: - return json.dumps({"error": f"Failed to load channel directory: {e}"}) + return json.dumps(_error(f"Failed to load channel directory: {e}")) def _handle_send(args): @@ -117,7 +140,7 @@ def _handle_send(args): from gateway.config import load_gateway_config, Platform config = load_gateway_config() except Exception as e: - return json.dumps({"error": f"Failed to load gateway config: {e}"}) + return json.dumps(_error(f"Failed to load gateway config: {e}")) platform_map = { "telegram": Platform.TELEGRAM, @@ -190,9 +213,11 @@ def _handle_send(args): except Exception: pass + if isinstance(result, dict) and "error" in result: + result["error"] = _sanitize_error_text(result["error"]) return json.dumps(result) except Exception as e: - return json.dumps({"error": f"Send failed: {e}"}) + return json.dumps(_error(f"Send failed: {e}")) def _parse_target_ref(platform_name: str, target_ref: str): @@ -434,7 +459,11 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No except Exception as md_error: # Parse failed, fall back to plain text if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower() or "html" in str(md_error).lower(): - logger.warning("Parse mode %s failed in _send_telegram, falling back to plain text: %s", send_parse_mode, md_error) + logger.warning( + "Parse mode %s failed in _send_telegram, falling back to plain text: %s", + send_parse_mode, + _sanitize_error_text(md_error), + ) if not _has_html: try: from gateway.platforms.telegram import _strip_mdv2 @@ -481,7 +510,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No chat_id=int_chat_id, document=f, **thread_kwargs ) except Exception as e: - warning = f"Failed to send media {media_path}: {e}" + warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}") logger.error(warning) warnings.append(warning) @@ -503,7 +532,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No except ImportError: return {"error": "python-telegram-bot not installed. Run: pip install python-telegram-bot"} except Exception as e: - return {"error": f"Telegram send failed: {e}"} + return _error(f"Telegram send failed: {e}") async def _send_discord(token, chat_id, message): @@ -522,11 +551,11 @@ async def _send_discord(token, chat_id, message): async with session.post(url, headers=headers, json={"content": message}) as resp: if resp.status not in (200, 201): body = await resp.text() - return {"error": f"Discord API error ({resp.status}): {body}"} + return _error(f"Discord API error ({resp.status}): {body}") data = await resp.json() return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": data.get("id")} except Exception as e: - return {"error": f"Discord send failed: {e}"} + return _error(f"Discord send failed: {e}") async def _send_slack(token, chat_id, message): @@ -543,9 +572,9 @@ async def _send_slack(token, chat_id, message): data = await resp.json() if data.get("ok"): return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")} - return {"error": f"Slack API error: {data.get('error', 'unknown')}"} + return _error(f"Slack API error: {data.get('error', 'unknown')}") except Exception as e: - return {"error": f"Slack send failed: {e}"} + return _error(f"Slack send failed: {e}") async def _send_whatsapp(extra, chat_id, message): @@ -571,9 +600,9 @@ async def _send_whatsapp(extra, chat_id, message): "message_id": data.get("messageId"), } body = await resp.text() - return {"error": f"WhatsApp bridge error ({resp.status}): {body}"} + return _error(f"WhatsApp bridge error ({resp.status}): {body}") except Exception as e: - return {"error": f"WhatsApp send failed: {e}"} + return _error(f"WhatsApp send failed: {e}") async def _send_signal(extra, chat_id, message): @@ -606,10 +635,10 @@ async def _send_signal(extra, chat_id, message): resp.raise_for_status() data = resp.json() if "error" in data: - return {"error": f"Signal RPC error: {data['error']}"} + return _error(f"Signal RPC error: {data['error']}") return {"success": True, "platform": "signal", "chat_id": chat_id} except Exception as e: - return {"error": f"Signal send failed: {e}"} + return _error(f"Signal send failed: {e}") async def _send_email(extra, chat_id, message): @@ -638,7 +667,7 @@ async def _send_email(extra, chat_id, message): server.quit() return {"success": True, "platform": "email", "chat_id": chat_id} except Exception as e: - return {"error": f"Email send failed: {e}"} + return _error(f"Email send failed: {e}") async def _send_sms(auth_token, chat_id, message): @@ -687,11 +716,11 @@ async def _send_sms(auth_token, chat_id, message): body = await resp.json() if resp.status >= 400: error_msg = body.get("message", str(body)) - return {"error": f"Twilio API error ({resp.status}): {error_msg}"} + return _error(f"Twilio API error ({resp.status}): {error_msg}") msg_sid = body.get("sid", "") return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": msg_sid} except Exception as e: - return {"error": f"SMS send failed: {e}"} + return _error(f"SMS send failed: {e}") async def _send_mattermost(token, extra, chat_id, message): @@ -711,11 +740,11 @@ async def _send_mattermost(token, extra, chat_id, message): async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp: if resp.status not in (200, 201): body = await resp.text() - return {"error": f"Mattermost API error ({resp.status}): {body}"} + return _error(f"Mattermost API error ({resp.status}): {body}") data = await resp.json() return {"success": True, "platform": "mattermost", "chat_id": chat_id, "message_id": data.get("id")} except Exception as e: - return {"error": f"Mattermost send failed: {e}"} + return _error(f"Mattermost send failed: {e}") async def _send_matrix(token, extra, chat_id, message): @@ -753,11 +782,11 @@ async def _send_matrix(token, extra, chat_id, message): async with session.put(url, headers=headers, json=payload) as resp: if resp.status not in (200, 201): body = await resp.text() - return {"error": f"Matrix API error ({resp.status}): {body}"} + return _error(f"Matrix API error ({resp.status}): {body}") data = await resp.json() return {"success": True, "platform": "matrix", "chat_id": chat_id, "message_id": data.get("event_id")} except Exception as e: - return {"error": f"Matrix send failed: {e}"} + return _error(f"Matrix send failed: {e}") async def _send_homeassistant(token, extra, chat_id, message): @@ -777,10 +806,10 @@ async def _send_homeassistant(token, extra, chat_id, message): async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp: if resp.status not in (200, 201): body = await resp.text() - return {"error": f"Home Assistant API error ({resp.status}): {body}"} + return _error(f"Home Assistant API error ({resp.status}): {body}") return {"success": True, "platform": "homeassistant", "chat_id": chat_id} except Exception as e: - return {"error": f"Home Assistant send failed: {e}"} + return _error(f"Home Assistant send failed: {e}") async def _send_dingtalk(extra, chat_id, message): @@ -808,10 +837,10 @@ async def _send_dingtalk(extra, chat_id, message): resp.raise_for_status() data = resp.json() if data.get("errcode", 0) != 0: - return {"error": f"DingTalk API error: {data.get('errmsg', 'unknown')}"} + return _error(f"DingTalk API error: {data.get('errmsg', 'unknown')}") return {"success": True, "platform": "dingtalk", "chat_id": chat_id} except Exception as e: - return {"error": f"DingTalk send failed: {e}"} + return _error(f"DingTalk send failed: {e}") async def _send_wecom(extra, chat_id, message): @@ -829,16 +858,16 @@ async def _send_wecom(extra, chat_id, message): adapter = WeComAdapter(pconfig) connected = await adapter.connect() if not connected: - return {"error": f"WeCom: failed to connect — {adapter.fatal_error_message or 'unknown error'}"} + return _error(f"WeCom: failed to connect - {adapter.fatal_error_message or 'unknown error'}") try: result = await adapter.send(chat_id, message) if not result.success: - return {"error": f"WeCom send failed: {result.error}"} + return _error(f"WeCom send failed: {result.error}") return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id} finally: await adapter.disconnect() except Exception as e: - return {"error": f"WeCom send failed: {e}"} + return _error(f"WeCom send failed: {e}") async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None): @@ -864,11 +893,11 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No if message.strip(): last_result = await adapter.send(chat_id, message, metadata=metadata) if not last_result.success: - return {"error": f"Feishu send failed: {last_result.error}"} + return _error(f"Feishu send failed: {last_result.error}") for media_path, is_voice in media_files: if not os.path.exists(media_path): - return {"error": f"Media file not found: {media_path}"} + return _error(f"Media file not found: {media_path}") ext = os.path.splitext(media_path)[1].lower() if ext in _IMAGE_EXTS: @@ -883,7 +912,7 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No last_result = await adapter.send_document(chat_id, media_path, metadata=metadata) if not last_result.success: - return {"error": f"Feishu media send failed: {last_result.error}"} + return _error(f"Feishu media send failed: {last_result.error}") if last_result is None: return {"error": "No deliverable text or media remained after processing MEDIA tags"} @@ -895,7 +924,7 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No "message_id": last_result.message_id, } except Exception as e: - return {"error": f"Feishu send failed: {e}"} + return _error(f"Feishu send failed: {e}") def _check_send_message(): From e9b5864b3f059d111b031f3506db739d81122315 Mon Sep 17 00:00:00 2001 From: charliekerfoot Date: Mon, 6 Apr 2026 18:13:54 -0500 Subject: [PATCH 161/213] fix: multiple platform adaptors concurrency --- gateway/pairing.py | 133 +++++++++++++++++++++++++++------------------ 1 file changed, 79 insertions(+), 54 deletions(-) diff --git a/gateway/pairing.py b/gateway/pairing.py index 34b3d902309a..09b61fef2249 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -21,6 +21,8 @@ import json import os import secrets +import tempfile +import threading import time from pathlib import Path from typing import Optional @@ -45,13 +47,29 @@ def _secure_write(path: Path, data: str) -> None: - """Write data to file with restrictive permissions (owner read/write only).""" + """Write data to file with restrictive permissions (owner read/write only). + + Uses a temp-file + atomic rename so readers always see either the old + complete file or the new one — never a partial write. + """ path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(data, encoding="utf-8") + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") try: - os.chmod(path, 0o600) - except OSError: - pass # Windows doesn't support chmod the same way + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, str(path)) + try: + os.chmod(path, 0o600) + except OSError: + pass # Windows doesn't support chmod the same way + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise class PairingStore: @@ -66,6 +84,9 @@ class PairingStore: def __init__(self): PAIRING_DIR.mkdir(parents=True, exist_ok=True) + # Protects all read-modify-write cycles. The gateway runs multiple + # platform adapters concurrently in threads sharing one PairingStore. + self._lock = threading.RLock() def _pending_path(self, platform: str) -> Path: return PAIRING_DIR / f"{platform}-pending.json" @@ -105,7 +126,7 @@ def list_approved(self, platform: str = None) -> list: return results def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None: - """Add a user to the approved list.""" + """Add a user to the approved list. Must be called under self._lock.""" approved = self._load_json(self._approved_path(platform)) approved[user_id] = { "user_name": user_name, @@ -116,11 +137,12 @@ def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> Non def revoke(self, platform: str, user_id: str) -> bool: """Remove a user from the approved list. Returns True if found.""" path = self._approved_path(platform) - approved = self._load_json(path) - if user_id in approved: - del approved[user_id] - self._save_json(path, approved) - return True + with self._lock: + approved = self._load_json(path) + if user_id in approved: + del approved[user_id] + self._save_json(path, approved) + return True return False # ----- Pending codes ----- @@ -136,36 +158,37 @@ def generate_code( - Max pending codes reached for this platform - User/platform is in lockout due to failed attempts """ - self._cleanup_expired(platform) + with self._lock: + self._cleanup_expired(platform) - # Check lockout - if self._is_locked_out(platform): - return None + # Check lockout + if self._is_locked_out(platform): + return None - # Check rate limit for this specific user - if self._is_rate_limited(platform, user_id): - return None + # Check rate limit for this specific user + if self._is_rate_limited(platform, user_id): + return None - # Check max pending - pending = self._load_json(self._pending_path(platform)) - if len(pending) >= MAX_PENDING_PER_PLATFORM: - return None + # Check max pending + pending = self._load_json(self._pending_path(platform)) + if len(pending) >= MAX_PENDING_PER_PLATFORM: + return None - # Generate cryptographically random code - code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH)) + # Generate cryptographically random code + code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH)) - # Store pending request - pending[code] = { - "user_id": user_id, - "user_name": user_name, - "created_at": time.time(), - } - self._save_json(self._pending_path(platform), pending) + # Store pending request + pending[code] = { + "user_id": user_id, + "user_name": user_name, + "created_at": time.time(), + } + self._save_json(self._pending_path(platform), pending) - # Record rate limit - self._record_rate_limit(platform, user_id) + # Record rate limit + self._record_rate_limit(platform, user_id) - return code + return code def approve_code(self, platform: str, code: str) -> Optional[dict]: """ @@ -173,24 +196,25 @@ def approve_code(self, platform: str, code: str) -> Optional[dict]: Returns {user_id, user_name} on success, None if code is invalid/expired. """ - self._cleanup_expired(platform) - code = code.upper().strip() + with self._lock: + self._cleanup_expired(platform) + code = code.upper().strip() - pending = self._load_json(self._pending_path(platform)) - if code not in pending: - self._record_failed_attempt(platform) - return None + pending = self._load_json(self._pending_path(platform)) + if code not in pending: + self._record_failed_attempt(platform) + return None - entry = pending.pop(code) - self._save_json(self._pending_path(platform), pending) + entry = pending.pop(code) + self._save_json(self._pending_path(platform), pending) - # Add to approved list - self._approve_user(platform, entry["user_id"], entry.get("user_name", "")) + # Add to approved list + self._approve_user(platform, entry["user_id"], entry.get("user_name", "")) - return { - "user_id": entry["user_id"], - "user_name": entry.get("user_name", ""), - } + return { + "user_id": entry["user_id"], + "user_name": entry.get("user_name", ""), + } def list_pending(self, platform: str = None) -> list: """List pending pairing requests, optionally filtered by platform.""" @@ -212,12 +236,13 @@ def list_pending(self, platform: str = None) -> list: def clear_pending(self, platform: str = None) -> int: """Clear all pending requests. Returns count removed.""" - count = 0 - platforms = [platform] if platform else self._all_platforms("pending") - for p in platforms: - pending = self._load_json(self._pending_path(p)) - count += len(pending) - self._save_json(self._pending_path(p), {}) + with self._lock: + count = 0 + platforms = [platform] if platform else self._all_platforms("pending") + for p in platforms: + pending = self._load_json(self._pending_path(p)) + count += len(pending) + self._save_json(self._pending_path(p), {}) return count # ----- Rate limiting and lockout ----- From b26e85bf9d6266c3691f152ed677085b3eeaf04c Mon Sep 17 00:00:00 2001 From: KangYu Date: Tue, 7 Apr 2026 04:28:11 +0900 Subject: [PATCH 162/213] Fix compaction summary retries for temperature-restricted models --- agent/context_compressor.py | 28 ++++++++++++++++--- tests/agent/test_context_compressor.py | 38 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 6fdb38b29b38..0d971e4b5693 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -14,6 +14,7 @@ """ import logging +import time from typing import Any, Dict, List, Optional from agent.auxiliary_client import call_llm @@ -46,6 +47,7 @@ # Chars per token rough estimate _CHARS_PER_TOKEN = 4 +_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 class ContextCompressor: @@ -118,6 +120,7 @@ def __init__( # Stores the previous compaction summary for iterative updates self._previous_summary: Optional[str] = None + self._summary_failure_cooldown_until: float = 0.0 def update_from_response(self, usage: Dict[str, Any]): """Update tracked token usage from API response.""" @@ -258,6 +261,14 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optiona the middle turns without a summary rather than inject a useless placeholder. """ + now = time.monotonic() + if now < self._summary_failure_cooldown_until: + logger.debug( + "Skipping context summary during cooldown (%.0fs remaining)", + self._summary_failure_cooldown_until - now, + ) + return None + summary_budget = self._compute_summary_budget(turns_to_summarize) content_to_summarize = self._serialize_for_summary(turns_to_summarize) @@ -345,7 +356,6 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optiona call_kwargs = { "task": "compression", "messages": [{"role": "user", "content": prompt}], - "temperature": 0.3, "max_tokens": summary_budget * 2, # timeout resolved from auxiliary.compression.timeout config by call_llm } @@ -359,13 +369,23 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optiona summary = content.strip() # Store for iterative updates on next compaction self._previous_summary = summary + self._summary_failure_cooldown_until = 0.0 return self._with_summary_prefix(summary) except RuntimeError: + self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS logging.warning("Context compression: no provider available for " - "summary. Middle turns will be dropped without summary.") + "summary. Middle turns will be dropped without summary " + "for %d seconds.", + _SUMMARY_FAILURE_COOLDOWN_SECONDS) return None except Exception as e: - logging.warning("Failed to generate context summary: %s", e) + self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS + logging.warning( + "Failed to generate context summary: %s. " + "Further summary attempts paused for %d seconds.", + e, + _SUMMARY_FAILURE_COOLDOWN_SECONDS, + ) return None @staticmethod @@ -648,7 +668,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None) - compressed.append({"role": summary_role, "content": summary}) else: if not self.quiet_mode: - logger.warning("No summary model available — middle turns dropped without summary") + logger.debug("No summary model available — middle turns dropped without summary") for i in range(compress_end, n_messages): msg = messages[i].copy() diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 45c832dfc00c..257cf90395ee 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -197,6 +197,44 @@ def test_none_content_coerced_to_empty(self): assert summary is not None assert summary == SUMMARY_PREFIX + def test_summary_call_does_not_force_temperature(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "ok" + + 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) as mock_call: + c._generate_summary(messages) + + kwargs = mock_call.call_args.kwargs + assert "temperature" not in kwargs + + +class TestSummaryFailureCooldown: + def test_summary_failure_enters_cooldown_and_skips_retry(self): + 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", side_effect=Exception("boom")) as mock_call: + first = c._generate_summary(messages) + second = c._generate_summary(messages) + + assert first is None + assert second is None + assert mock_call.call_count == 1 + class TestSummaryPrefixNormalization: def test_legacy_prefix_is_replaced(self): From 1c0183ec71b10c3fcf2bf502aeb6ed11f9fa630b Mon Sep 17 00:00:00 2001 From: WAXLYY Date: Mon, 6 Apr 2026 23:27:54 +0300 Subject: [PATCH 163/213] fix(gateway): sanitize media URLs in base platform logs --- gateway/platforms/base.py | 65 ++++++++++++++++++++++++++--- tests/gateway/test_platform_base.py | 26 ++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5261aceea532..0ba00d890177 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -12,6 +12,7 @@ import re import uuid from abc import ABC, abstractmethod +from urllib.parse import urlsplit logger = logging.getLogger(__name__) from dataclasses import dataclass, field @@ -36,6 +37,43 @@ ) +def _safe_url_for_log(url: str, max_len: int = 80) -> str: + """Return a URL string safe for logs (no query/fragment/userinfo).""" + if max_len <= 0: + return "" + + if url is None: + return "" + + raw = str(url) + if not raw: + return "" + + try: + parsed = urlsplit(raw) + except Exception: + return raw[:max_len] + + if parsed.scheme and parsed.netloc: + # Strip potential embedded credentials (user:pass@host). + netloc = parsed.netloc.rsplit("@", 1)[-1] + base = f"{parsed.scheme}://{netloc}" + path = parsed.path or "" + if path and path != "/": + basename = path.rsplit("/", 1)[-1] + safe = f"{base}/.../{basename}" if basename else f"{base}/..." + else: + safe = base + else: + safe = raw + + if len(safe) <= max_len: + return safe + if max_len <= 3: + return "." * max_len + return f"{safe[:max_len - 3]}..." + + # --------------------------------------------------------------------------- # Image cache utilities # @@ -112,8 +150,14 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) -> raise if attempt < retries: wait = 1.5 * (attempt + 1) - _log.debug("Media cache retry %d/%d for %s (%.1fs): %s", - attempt + 1, retries, url[:80], wait, exc) + _log.debug( + "Media cache retry %d/%d for %s (%.1fs): %s", + attempt + 1, + retries, + _safe_url_for_log(url), + wait, + exc, + ) await asyncio.sleep(wait) continue raise @@ -214,8 +258,14 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) -> raise if attempt < retries: wait = 1.5 * (attempt + 1) - _log.debug("Audio cache retry %d/%d for %s (%.1fs): %s", - attempt + 1, retries, url[:80], wait, exc) + _log.debug( + "Audio cache retry %d/%d for %s (%.1fs): %s", + attempt + 1, + retries, + _safe_url_for_log(url), + wait, + exc, + ) await asyncio.sleep(wait) continue raise @@ -1266,7 +1316,12 @@ def _record_delivery(result): if human_delay > 0: await asyncio.sleep(human_delay) try: - logger.info("[%s] Sending image: %s (alt=%s)", self.name, image_url[:80], alt_text[:30] if alt_text else "") + logger.info( + "[%s] Sending image: %s (alt=%s)", + self.name, + _safe_url_for_log(image_url), + alt_text[:30] if alt_text else "", + ) # Route animated GIFs through send_animation for proper playback if self._is_animation_url(image_url): img_result = await self.send_animation( diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 13b52f24f1a6..43dd17bd81e5 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -8,6 +8,7 @@ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE, MessageEvent, MessageType, + _safe_url_for_log, ) @@ -18,6 +19,31 @@ def test_gateway_secret_capture_message_points_to_local_setup(self): assert "~/.hermes/.env" in message +class TestSafeUrlForLog: + def test_strips_query_fragment_and_userinfo(self): + url = ( + "https://user:pass@example.com/private/path/image.png" + "?X-Amz-Signature=supersecret&token=abc#frag" + ) + result = _safe_url_for_log(url) + assert result == "https://example.com/.../image.png" + assert "supersecret" not in result + assert "token=abc" not in result + assert "user:pass@" not in result + + def test_truncates_long_values(self): + long_url = "https://example.com/" + ("a" * 300) + result = _safe_url_for_log(long_url, max_len=40) + assert len(result) == 40 + assert result.endswith("...") + + def test_handles_small_and_non_positive_max_len(self): + url = "https://example.com/very/long/path/file.png?token=secret" + assert _safe_url_for_log(url, max_len=3) == "..." + assert _safe_url_for_log(url, max_len=2) == ".." + assert _safe_url_for_log(url, max_len=0) == "" + + # --------------------------------------------------------------------------- # MessageEvent — command parsing # --------------------------------------------------------------------------- From 83df001d01e0de7fa84640ccd87c6b38777d8730 Mon Sep 17 00:00:00 2001 From: Zainan Victor Zhou Date: Mon, 6 Apr 2026 13:01:14 -0700 Subject: [PATCH 164/213] fix: allow google-workspace skill scripts to run directly - fall back to adding the repo root to sys.path when hermes_constants is not importable - fixes direct execution of setup.py and google_api.py from the repo checkout - keeps the upstream PR scoped to the google-workspace compatibility fix --- .../productivity/google-workspace/scripts/google_api.py | 8 +++++++- skills/productivity/google-workspace/scripts/setup.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 2a5c662a6511..ece0c3ea039b 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -27,7 +27,13 @@ from email.mime.text import MIMEText from pathlib import Path -from hermes_constants import display_hermes_home, get_hermes_home +try: + from hermes_constants import display_hermes_home, get_hermes_home +except ModuleNotFoundError: + HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4] + if HERMES_AGENT_ROOT.exists(): + sys.path.insert(0, str(HERMES_AGENT_ROOT)) + from hermes_constants import display_hermes_home, get_hermes_home HERMES_HOME = get_hermes_home() TOKEN_PATH = HERMES_HOME / "google_token.json" diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 52a07427dff2..5e4924f9d2cb 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -27,7 +27,13 @@ import sys from pathlib import Path -from hermes_constants import display_hermes_home, get_hermes_home +try: + from hermes_constants import display_hermes_home, get_hermes_home +except ModuleNotFoundError: + HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4] + if HERMES_AGENT_ROOT.exists(): + sys.path.insert(0, str(HERMES_AGENT_ROOT)) + from hermes_constants import display_hermes_home, get_hermes_home HERMES_HOME = get_hermes_home() TOKEN_PATH = HERMES_HOME / "google_token.json" From 190471fdc0d07d79dec23daa14c50d6c71eb1da2 Mon Sep 17 00:00:00 2001 From: Zainan Victor Zhou Date: Mon, 6 Apr 2026 13:09:10 -0700 Subject: [PATCH 165/213] docs: use HERMES_HOME in google-workspace skill examples - avoid hard-coded ~/.hermes paths in the setup and API shorthands - prefer HERMES_HOME with a sane default to /Users/peteradams/.hermes - keep the examples aligned with profile-aware Hermes installs --- skills/productivity/google-workspace/SKILL.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/skills/productivity/google-workspace/SKILL.md b/skills/productivity/google-workspace/SKILL.md index 6252c671e484..60b9693d17a2 100644 --- a/skills/productivity/google-workspace/SKILL.md +++ b/skills/productivity/google-workspace/SKILL.md @@ -37,7 +37,13 @@ on CLI, Telegram, Discord, or any platform. Define a shorthand first: ```bash -GSETUP="python ~/.hermes/skills/productivity/google-workspace/scripts/setup.py" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +GWORKSPACE_SKILL_DIR="$HERMES_HOME/skills/productivity/google-workspace" +PYTHON_BIN="${HERMES_PYTHON:-python3}" +if [ -x "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then + PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" +fi +GSETUP="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/setup.py" ``` ### Step 0: Check if already set up @@ -135,7 +141,13 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall All commands go through the API script. Set `GAPI` as a shorthand: ```bash -GAPI="python ~/.hermes/skills/productivity/google-workspace/scripts/google_api.py" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +GWORKSPACE_SKILL_DIR="$HERMES_HOME/skills/productivity/google-workspace" +PYTHON_BIN="${HERMES_PYTHON:-python3}" +if [ -x "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then + PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" +fi +GAPI="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/google_api.py" ``` ### Gmail From 40527ff5e35db7fc6f501d8aa5997caa4a382a79 Mon Sep 17 00:00:00 2001 From: tymrtn Date: Mon, 6 Apr 2026 21:12:57 +0200 Subject: [PATCH 166/213] fix(auth): actionable error message when Codex refresh token is reused When the Codex CLI (or VS Code extension) consumes a refresh token before Hermes can use it, Hermes previously surfaced a generic 401 error with no actionable guidance. - In `refresh_codex_oauth_pure`: detect `refresh_token_reused` from the OAuth endpoint and raise an AuthError explaining the cause and the exact steps to recover (run `codex` to refresh, then `hermes login`). - In `run_agent.py`: when provider is `openai-codex` and HTTP 401 is received, show Codex-specific recovery steps instead of the generic "check your API key" message. Co-Authored-By: Claude Sonnet 4.6 --- hermes_cli/auth.py | 8 ++++++++ run_agent.py | 16 +++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 5a02c9233524..bfbeb8182530 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1030,6 +1030,14 @@ def refresh_codex_oauth_pure( pass if code in {"invalid_grant", "invalid_token", "invalid_request"}: relogin_required = True + if code == "refresh_token_reused": + message = ( + "Codex refresh token was already consumed by another client " + "(e.g. Codex CLI or VS Code extension). " + "Run `codex` in your terminal to generate fresh tokens, " + "then run `hermes login --provider openai-codex` to re-authenticate." + ) + relogin_required = True raise AuthError( message, provider="openai-codex", diff --git a/run_agent.py b/run_agent.py index d85682a16f2e..d97e08ad555b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8187,11 +8187,17 @@ def _stop_spinner(): self._vprint(f"{self.log_prefix} 🌐 Endpoint: {_base}", force=True) # Actionable guidance for common auth errors if status_code in (401, 403) or "unauthorized" in error_msg or "forbidden" in error_msg or "permission" in error_msg: - self._vprint(f"{self.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True) - self._vprint(f"{self.log_prefix} • Is the key valid? Run: hermes setup", force=True) - self._vprint(f"{self.log_prefix} • Does your account have access to {_model}?", force=True) - if "openrouter" in str(_base).lower(): - self._vprint(f"{self.log_prefix} • Check credits: https://openrouter.ai/settings/credits", force=True) + if _provider == "openai-codex" and status_code == 401: + self._vprint(f"{self.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True) + self._vprint(f"{self.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True) + self._vprint(f"{self.log_prefix} 1. Run `codex` in your terminal to generate fresh tokens.", force=True) + self._vprint(f"{self.log_prefix} 2. Then run `hermes login --provider openai-codex` to re-authenticate.", force=True) + else: + self._vprint(f"{self.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True) + self._vprint(f"{self.log_prefix} • Is the key valid? Run: hermes setup", force=True) + self._vprint(f"{self.log_prefix} • Does your account have access to {_model}?", force=True) + if "openrouter" in str(_base).lower(): + self._vprint(f"{self.log_prefix} • Check credits: https://openrouter.ai/settings/credits", force=True) else: self._vprint(f"{self.log_prefix} 💡 This type of error won't be fixed by retrying.", force=True) logging.error(f"{self.log_prefix}Non-retryable client error: {api_error}") From 05f9267938b9701e16eb5c4ae1d8b4a2c62a12cc Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:07:10 +0530 Subject: [PATCH 167/213] fix(matrix): hard-fail E2EE when python-olm missing + stable MATRIX_DEVICE_ID Two issues caused Matrix E2EE to silently not work in encrypted rooms: 1. When matrix-nio is installed without the [e2e] extra (no python-olm / libolm), nio.crypto.ENCRYPTION_ENABLED is False and client.olm is never initialized. The adapter logged warnings but returned True from connect(), so the bot appeared online but could never decrypt messages. Now: check_matrix_requirements() and connect() both hard-fail with a clear error message when MATRIX_ENCRYPTION=true but E2EE deps are missing. 2. Without a stable device_id, the bot gets a new device identity on each restart. Other clients see it as "unknown device" and refuse to share Megolm session keys. Now: MATRIX_DEVICE_ID env var lets users pin a stable device identity that persists across restarts and is passed to nio.AsyncClient constructor + restore_login(). Changes: - gateway/platforms/matrix.py: add _check_e2ee_deps(), hard-fail in connect() and check_matrix_requirements(), MATRIX_DEVICE_ID support in constructor + restore_login - gateway/config.py: plumb MATRIX_DEVICE_ID into platform extras - hermes_cli/config.py: add MATRIX_DEVICE_ID to OPTIONAL_ENV_VARS Closes #3521 --- gateway/config.py | 3 + gateway/platforms/matrix.py | 98 +++++++-- hermes_cli/config.py | 10 +- tests/gateway/test_matrix.py | 380 ++++++++++++++++++++++++++++++++++- 4 files changed, 464 insertions(+), 27 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 0ff3127ce117..470eee7f2f21 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -779,6 +779,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.MATRIX].extra["password"] = matrix_password matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee + matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "") + if matrix_device_id: + config.platforms[Platform.MATRIX].extra["device_id"] = matrix_device_id matrix_home = os.getenv("MATRIX_HOME_ROOM") if matrix_home and Platform.MATRIX in config.platforms: config.platforms[Platform.MATRIX].home_channel = HomeChannel( diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 35cf72ad414a..2dc0c5a9b6f8 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -10,6 +10,7 @@ MATRIX_USER_ID Full user ID (@bot:server) — required for password login MATRIX_PASSWORD Password (alternative to access token) MATRIX_ENCRYPTION Set "true" to enable E2EE + MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) MATRIX_HOME_ROOM Room ID for cron/notification delivery MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions @@ -65,6 +66,21 @@ _PENDING_EVENT_TTL = 300 # seconds — stop retrying after 5 min +_E2EE_INSTALL_HINT = ( + "Install with: pip install 'matrix-nio[e2e]' " + "(requires libolm C library)" +) + + +def _check_e2ee_deps() -> bool: + """Return True if matrix-nio E2EE dependencies (python-olm) are available.""" + try: + from nio.crypto import ENCRYPTION_ENABLED + return bool(ENCRYPTION_ENABLED) + except (ImportError, AttributeError): + return False + + def check_matrix_requirements() -> bool: """Return True if the Matrix adapter can be used.""" token = os.getenv("MATRIX_ACCESS_TOKEN", "") @@ -79,7 +95,6 @@ def check_matrix_requirements() -> bool: return False try: import nio # noqa: F401 - return True except ImportError: logger.warning( "Matrix: matrix-nio not installed. " @@ -87,6 +102,20 @@ def check_matrix_requirements() -> bool: ) return False + # If encryption is requested, verify E2EE deps are available at startup + # rather than silently degrading to plaintext-only at connect time. + encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") + if encryption_requested and not _check_e2ee_deps(): + logger.error( + "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " + "Without this, encrypted rooms will not work. " + "Set MATRIX_ENCRYPTION=false to disable E2EE.", + _E2EE_INSTALL_HINT, + ) + return False + + return True + class MatrixAdapter(BasePlatformAdapter): """Gateway adapter for Matrix (any homeserver).""" @@ -111,6 +140,10 @@ def __init__(self, config: PlatformConfig): "encryption", os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"), ) + self._device_id: str = ( + config.extra.get("device_id", "") + or os.getenv("MATRIX_DEVICE_ID", "") + ) self._client: Any = None # nio.AsyncClient self._sync_task: Optional[asyncio.Task] = None @@ -169,24 +202,42 @@ async def connect(self) -> bool: _STORE_DIR.mkdir(parents=True, exist_ok=True) # Create the client. + # When a stable device_id is configured, pass it to the constructor + # so matrix-nio binds to it from the start (important for E2EE + # crypto-store persistence across restarts). + ctor_device_id = self._device_id or None if self._encryption: + if not _check_e2ee_deps(): + logger.error( + "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " + "Refusing to connect — encrypted rooms would silently fail.", + _E2EE_INSTALL_HINT, + ) + return False try: client = nio.AsyncClient( self._homeserver, self._user_id or "", + device_id=ctor_device_id, store_path=store_path, ) - logger.info("Matrix: E2EE enabled (store: %s)", store_path) + logger.info( + "Matrix: E2EE enabled (store: %s%s)", + store_path, + f", device_id={self._device_id}" if self._device_id else "", + ) except Exception as exc: - logger.warning( - "Matrix: failed to create E2EE client (%s), " - "falling back to plain client. Install: " - "pip install 'matrix-nio[e2e]'", - exc, + logger.error( + "Matrix: failed to create E2EE client: %s. %s", + exc, _E2EE_INSTALL_HINT, ) - client = nio.AsyncClient(self._homeserver, self._user_id or "") + return False else: - client = nio.AsyncClient(self._homeserver, self._user_id or "") + client = nio.AsyncClient( + self._homeserver, + self._user_id or "", + device_id=ctor_device_id, + ) self._client = client @@ -205,30 +256,36 @@ async def connect(self) -> bool: if resolved_user_id: self._user_id = resolved_user_id + # Prefer the user-configured device_id (MATRIX_DEVICE_ID) so + # the bot reuses a stable identity across restarts. Fall back + # to whatever whoami returned. + effective_device_id = self._device_id or resolved_device_id + # restore_login() is the matrix-nio path that binds the access # token to a specific device and loads the crypto store. - if resolved_device_id and hasattr(client, "restore_login"): + if effective_device_id and hasattr(client, "restore_login"): client.restore_login( self._user_id or resolved_user_id, - resolved_device_id, + effective_device_id, self._access_token, ) else: if self._user_id: client.user_id = self._user_id - if resolved_device_id: - client.device_id = resolved_device_id + if effective_device_id: + client.device_id = effective_device_id client.access_token = self._access_token if self._encryption: logger.warning( "Matrix: access-token login did not restore E2EE state; " - "encrypted rooms may fail until a device_id is available" + "encrypted rooms may fail until a device_id is available. " + "Set MATRIX_DEVICE_ID to a stable value." ) logger.info( "Matrix: using access token for %s%s", self._user_id or "(unknown user)", - f" (device {resolved_device_id})" if resolved_device_id else "", + f" (device {effective_device_id})" if effective_device_id else "", ) else: logger.error( @@ -271,10 +328,15 @@ async def connect(self) -> bool: except Exception as exc: logger.debug("Matrix: could not import keys: %s", exc) elif self._encryption: - logger.warning( - "Matrix: E2EE requested but crypto store is not loaded; " - "encrypted rooms may fail" + # E2EE was requested but the crypto store failed to load — + # this means encrypted rooms will silently not work. Hard-fail. + logger.error( + "Matrix: E2EE requested but crypto store is not loaded — " + "cannot decrypt or encrypt messages. %s", + _E2EE_INSTALL_HINT, ) + await client.close() + return False # Register event callbacks. client.add_event_callback(self._on_room_message, nio.RoomMessageText) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 369fe7acf0c8..bf0b27c205f7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -42,7 +42,7 @@ "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", - "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM", + "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", }) import yaml @@ -1079,6 +1079,14 @@ def ensure_hermes_home(): "category": "messaging", "advanced": True, }, + "MATRIX_DEVICE_ID": { + "description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)", + "prompt": "Matrix device ID (stable across restarts)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, "GATEWAY_ALLOW_ALL_USERS": { "description": "Allow all users to interact with messaging bots (true/false). Default: false.", "prompt": "Allow all users (true/false)", diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index fb2d47f4976a..09f0ab9590f1 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -428,6 +428,7 @@ class TestMatrixRequirements: def test_check_requirements_with_token(self, monkeypatch): monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) from gateway.platforms.matrix import check_matrix_requirements try: import nio # noqa: F401 @@ -448,6 +449,45 @@ def test_check_requirements_without_homeserver(self, monkeypatch): from gateway.platforms.matrix import check_matrix_requirements assert check_matrix_requirements() is False + def test_check_requirements_encryption_true_no_e2ee_deps(self, monkeypatch): + """MATRIX_ENCRYPTION=true should fail if python-olm is not installed.""" + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_ENCRYPTION", "true") + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + assert matrix_mod.check_matrix_requirements() is False + + def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): + """Without encryption, missing E2EE deps should not block startup.""" + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + # Still needs nio itself to be importable + try: + import nio # noqa: F401 + assert matrix_mod.check_matrix_requirements() is True + except ImportError: + assert matrix_mod.check_matrix_requirements() is False + + def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch): + """MATRIX_ENCRYPTION=true should pass if E2EE deps are available.""" + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_ENCRYPTION", "true") + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + try: + import nio # noqa: F401 + assert matrix_mod.check_matrix_requirements() is True + except ImportError: + assert matrix_mod.check_matrix_requirements() is False + # --------------------------------------------------------------------------- # Access-token auth / E2EE bootstrap @@ -516,10 +556,12 @@ def _restore_login(user_id, device_id, access_token): fake_nio.InviteMemberEvent = type("InviteMemberEvent", (), {}) fake_nio.MegolmEvent = type("MegolmEvent", (), {}) - with patch.dict("sys.modules", {"nio": fake_nio}): - with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): - with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): - assert await adapter.connect() is True + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True fake_client.restore_login.assert_called_once_with( "@bot:example.org", "DEV123", "syt_test_access_token" @@ -532,6 +574,326 @@ def _restore_login(user_id, device_id, access_token): await adapter.disconnect() +class TestMatrixE2EEHardFail: + """connect() must refuse to start when E2EE is requested but deps are missing.""" + + @pytest.mark.asyncio + async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + }, + ) + adapter = MatrixAdapter(config) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock() + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + with patch.dict("sys.modules", {"nio": fake_nio}): + result = await adapter.connect() + + assert result is False + + @pytest.mark.asyncio + async def test_connect_fails_when_olm_not_loaded_after_login(self): + """Even if _check_e2ee_deps passes, if olm is None after auth, hard-fail.""" + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + }, + ) + adapter = MatrixAdapter(config) + + class FakeWhoamiResponse: + def __init__(self, user_id, device_id): + self.user_id = user_id + self.device_id = device_id + + fake_client = MagicMock() + fake_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "DEV123")) + fake_client.close = AsyncMock() + # olm is None — crypto store not loaded + fake_client.olm = None + fake_client.should_upload_keys = False + + def _restore_login(user_id, device_id, access_token): + fake_client.user_id = user_id + fake_client.device_id = device_id + fake_client.access_token = access_token + + fake_client.restore_login = MagicMock(side_effect=_restore_login) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(return_value=fake_client) + fake_nio.WhoamiResponse = FakeWhoamiResponse + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + result = await adapter.connect() + + assert result is False + fake_client.close.assert_awaited_once() + + +class TestMatrixDeviceId: + """MATRIX_DEVICE_ID should be used for stable device identity.""" + + def test_device_id_from_config_extra(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test", + extra={ + "homeserver": "https://matrix.example.org", + "device_id": "HERMES_BOT_STABLE", + }, + ) + adapter = MatrixAdapter(config) + assert adapter._device_id == "HERMES_BOT_STABLE" + + def test_device_id_from_env(self, monkeypatch): + monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV") + + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test", + extra={ + "homeserver": "https://matrix.example.org", + }, + ) + adapter = MatrixAdapter(config) + assert adapter._device_id == "FROM_ENV" + + def test_device_id_config_takes_precedence_over_env(self, monkeypatch): + monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV") + + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test", + extra={ + "homeserver": "https://matrix.example.org", + "device_id": "FROM_CONFIG", + }, + ) + adapter = MatrixAdapter(config) + assert adapter._device_id == "FROM_CONFIG" + + @pytest.mark.asyncio + async def test_connect_uses_configured_device_id_over_whoami(self): + """When MATRIX_DEVICE_ID is set, it should be used instead of whoami device_id.""" + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + "device_id": "MY_STABLE_DEVICE", + }, + ) + adapter = MatrixAdapter(config) + + class FakeWhoamiResponse: + def __init__(self, user_id, device_id): + self.user_id = user_id + self.device_id = device_id + + class FakeSyncResponse: + def __init__(self): + self.rooms = MagicMock(join={}) + + fake_client = MagicMock() + fake_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "WHOAMI_DEV")) + fake_client.sync = AsyncMock(return_value=FakeSyncResponse()) + fake_client.keys_upload = AsyncMock() + fake_client.keys_query = AsyncMock() + fake_client.keys_claim = AsyncMock() + fake_client.send_to_device_messages = AsyncMock(return_value=[]) + fake_client.get_users_for_key_claiming = MagicMock(return_value={}) + fake_client.close = AsyncMock() + fake_client.add_event_callback = MagicMock() + fake_client.rooms = {} + fake_client.account_data = {} + fake_client.olm = object() + fake_client.should_upload_keys = False + fake_client.should_query_keys = False + fake_client.should_claim_keys = False + + def _restore_login(user_id, device_id, access_token): + fake_client.user_id = user_id + fake_client.device_id = device_id + fake_client.access_token = access_token + + fake_client.restore_login = MagicMock(side_effect=_restore_login) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(return_value=fake_client) + fake_nio.WhoamiResponse = FakeWhoamiResponse + fake_nio.SyncResponse = FakeSyncResponse + fake_nio.LoginResponse = type("LoginResponse", (), {}) + fake_nio.RoomMessageText = type("RoomMessageText", (), {}) + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.InviteMemberEvent = type("InviteMemberEvent", (), {}) + fake_nio.MegolmEvent = type("MegolmEvent", (), {}) + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True + + # The configured device_id should override the whoami device_id + fake_client.restore_login.assert_called_once_with( + "@bot:example.org", "MY_STABLE_DEVICE", "syt_test_access_token" + ) + assert fake_client.device_id == "MY_STABLE_DEVICE" + + # Verify device_id was passed to nio.AsyncClient constructor + ctor_call = fake_nio.AsyncClient.call_args + assert ctor_call.kwargs.get("device_id") == "MY_STABLE_DEVICE" + + await adapter.disconnect() + + +class TestMatrixE2EEClientConstructorFailure: + """connect() should hard-fail if nio.AsyncClient() raises when encryption is on.""" + + @pytest.mark.asyncio + async def test_connect_fails_when_e2ee_client_constructor_raises(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + }, + ) + adapter = MatrixAdapter(config) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(side_effect=Exception("olm init failed")) + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + result = await adapter.connect() + + assert result is False + + +class TestMatrixPasswordLoginDeviceId: + """MATRIX_DEVICE_ID should be passed to nio.AsyncClient even with password login.""" + + @pytest.mark.asyncio + async def test_password_login_passes_device_id_to_constructor(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "password": "secret", + "device_id": "STABLE_PW_DEVICE", + }, + ) + adapter = MatrixAdapter(config) + + class FakeLoginResponse: + pass + + class FakeSyncResponse: + def __init__(self): + self.rooms = MagicMock(join={}) + + fake_client = MagicMock() + fake_client.login = AsyncMock(return_value=FakeLoginResponse()) + fake_client.sync = AsyncMock(return_value=FakeSyncResponse()) + fake_client.close = AsyncMock() + fake_client.add_event_callback = MagicMock() + fake_client.rooms = {} + fake_client.account_data = {} + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(return_value=fake_client) + fake_nio.LoginResponse = FakeLoginResponse + fake_nio.SyncResponse = FakeSyncResponse + fake_nio.RoomMessageText = type("RoomMessageText", (), {}) + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.InviteMemberEvent = type("InviteMemberEvent", (), {}) + + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True + + # Verify device_id was passed to the nio.AsyncClient constructor + ctor_call = fake_nio.AsyncClient.call_args + assert ctor_call.kwargs.get("device_id") == "STABLE_PW_DEVICE" + + await adapter.disconnect() + + +class TestMatrixDeviceIdConfig: + """MATRIX_DEVICE_ID should be plumbed through gateway config.""" + + def test_device_id_in_config_extra(self, monkeypatch): + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_DEVICE_ID", "HERMES_BOT") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + mc = config.platforms[Platform.MATRIX] + assert mc.extra.get("device_id") == "HERMES_BOT" + + def test_device_id_not_set_when_env_empty(self, monkeypatch): + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.delenv("MATRIX_DEVICE_ID", raising=False) + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + mc = config.platforms[Platform.MATRIX] + assert "device_id" not in mc.extra + + class TestMatrixE2EEMaintenance: @pytest.mark.asyncio async def test_sync_loop_runs_e2ee_maintenance_requests(self): @@ -1071,10 +1433,12 @@ class FakeMegolmEvent: ... fake_nio.InviteMemberEvent = FakeInviteMemberEvent fake_nio.MegolmEvent = FakeMegolmEvent - with patch.dict("sys.modules", {"nio": fake_nio}): - with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): - with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): - assert await adapter.connect() is True + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True callback_classes = [call.args[1] for call in fake_client.add_event_callback.call_args_list] assert FakeRoomEncryptedImage in callback_classes From 8a29b4903619c7a6dcb79995dbc1909d095dbe27 Mon Sep 17 00:00:00 2001 From: Tianxiao Date: Mon, 6 Apr 2026 16:30:58 +0200 Subject: [PATCH 168/213] fix(cli): handle CJK wide chars in TUI input height --- cli.py | 18 +++++--- tests/test_cli_status_bar.py | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index ff097532c23d..ed9e08131a9a 100644 --- a/cli.py +++ b/cli.py @@ -7469,18 +7469,26 @@ def get_prompt(): # wrapping of long lines so the input area always fits its content. def _input_height(): try: + from prompt_toolkit.application import get_app + from prompt_toolkit.utils import get_cwidth + doc = input_area.buffer.document - prompt_width = max(2, len(self._get_tui_prompt_text())) - available_width = shutil.get_terminal_size().columns - prompt_width + prompt_width = max(2, get_cwidth(self._get_tui_prompt_text())) + try: + available_width = get_app().output.get_size().columns - prompt_width + except Exception: + available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width if available_width < 10: available_width = 40 visual_lines = 0 for line in doc.lines: - # Each logical line takes at least 1 visual row; long lines wrap - if len(line) == 0: + # Each logical line takes at least 1 visual row; long lines wrap. + # Use prompt_toolkit's cell width so CJK wide characters count as 2. + line_width = get_cwidth(line) + if line_width <= 0: visual_lines += 1 else: - visual_lines += max(1, -(-len(line) // available_width)) # ceil division + visual_lines += max(1, -(-line_width // available_width)) # ceil division return min(max(visual_lines, 1), 8) except Exception: return 1 diff --git a/tests/test_cli_status_bar.py b/tests/test_cli_status_bar.py index 104c58b1f88c..e728328b8c10 100644 --- a/tests/test_cli_status_bar.py +++ b/tests/test_cli_status_bar.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta from types import SimpleNamespace +from unittest.mock import MagicMock, patch from cli import HermesCLI @@ -78,6 +79,92 @@ def test_build_status_bar_text_for_wide_terminal(self): assert "$0.06" not in text # cost hidden by default assert "15m" in text + def test_input_height_counts_wide_characters_using_cell_width(self): + cli_obj = _make_cli() + + class _Doc: + lines = ["你" * 10] + + class _Buffer: + document = _Doc() + + input_area = SimpleNamespace(buffer=_Buffer()) + + def _input_height(): + try: + from prompt_toolkit.application import get_app + from prompt_toolkit.utils import get_cwidth + + doc = input_area.buffer.document + prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text())) + try: + available_width = get_app().output.get_size().columns - prompt_width + except Exception: + import shutil + available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width + if available_width < 10: + available_width = 40 + visual_lines = 0 + for line in doc.lines: + line_width = get_cwidth(line) + if line_width <= 0: + visual_lines += 1 + else: + visual_lines += max(1, -(-line_width // available_width)) + return min(max(visual_lines, 1), 8) + except Exception: + return 1 + + mock_app = MagicMock() + mock_app.output.get_size.return_value = MagicMock(columns=14) + with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \ + patch("prompt_toolkit.application.get_app", return_value=mock_app): + assert _input_height() == 2 + + def test_input_height_uses_prompt_toolkit_width_over_shutil(self): + cli_obj = _make_cli() + + class _Doc: + lines = ["你" * 10] + + class _Buffer: + document = _Doc() + + input_area = SimpleNamespace(buffer=_Buffer()) + + def _input_height(): + try: + from prompt_toolkit.application import get_app + from prompt_toolkit.utils import get_cwidth + + doc = input_area.buffer.document + prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text())) + try: + available_width = get_app().output.get_size().columns - prompt_width + except Exception: + import shutil + available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width + if available_width < 10: + available_width = 40 + visual_lines = 0 + for line in doc.lines: + line_width = get_cwidth(line) + if line_width <= 0: + visual_lines += 1 + else: + visual_lines += max(1, -(-line_width // available_width)) + return min(max(visual_lines, 1), 8) + except Exception: + return 1 + + mock_app = MagicMock() + mock_app.output.get_size.return_value = MagicMock(columns=14) + with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \ + patch("prompt_toolkit.application.get_app", return_value=mock_app), \ + patch("shutil.get_terminal_size") as mock_shutil: + assert _input_height() == 2 + mock_shutil.assert_not_called() + def test_build_status_bar_text_no_cost_in_status_bar(self): cli_obj = _attach_agent( _make_cli(), From abd24d381bcb0f1dc5a91b76d10c51998c8cbbd6 Mon Sep 17 00:00:00 2001 From: Ruzzgar Date: Mon, 6 Apr 2026 20:50:38 +0300 Subject: [PATCH 169/213] Implement comprehensive browser path discovery for Windows --- cli.py | 77 +++++++++++++++++++++++-------- tests/test_cli_browser_connect.py | 43 +++++++++++++++++ 2 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 tests/test_cli_browser_connect.py diff --git a/cli.py b/cli.py index ed9e08131a9a..29e6257d1e55 100644 --- a/cli.py +++ b/cli.py @@ -120,6 +120,63 @@ def _parse_reasoning_config(effort: str) -> dict | None: return result +def _get_chrome_debug_candidates(system: str) -> list[str]: + """Return likely browser executables for local CDP auto-launch.""" + candidates: list[str] = [] + seen: set[str] = set() + + def _add_candidate(path: str | None) -> None: + if not path: + return + normalized = os.path.normcase(os.path.normpath(path)) + if normalized in seen: + return + if os.path.isfile(path): + candidates.append(path) + seen.add(normalized) + + def _add_from_path(*names: str) -> None: + for name in names: + _add_candidate(shutil.which(name)) + + if system == "Darwin": + for app in ( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ): + _add_candidate(app) + elif system == "Windows": + _add_from_path( + "chrome.exe", "msedge.exe", "brave.exe", "chromium.exe", + "chrome", "msedge", "brave", "chromium", + ) + + for base in ( + os.environ.get("ProgramFiles"), + os.environ.get("ProgramFiles(x86)"), + os.environ.get("LOCALAPPDATA"), + ): + if not base: + continue + for parts in ( + ("Google", "Chrome", "Application", "chrome.exe"), + ("Chromium", "Application", "chrome.exe"), + ("Chromium", "Application", "chromium.exe"), + ("BraveSoftware", "Brave-Browser", "Application", "brave.exe"), + ("Microsoft", "Edge", "Application", "msedge.exe"), + ): + _add_candidate(os.path.join(base, *parts)) + else: + _add_from_path( + "google-chrome", "google-chrome-stable", "chromium-browser", + "chromium", "brave-browser", "microsoft-edge", + ) + + return candidates + + def load_cli_config() -> Dict[str, Any]: """ Load CLI configuration from config files. @@ -4838,27 +4895,9 @@ def _try_launch_chrome_debug(port: int, system: str) -> bool: Returns True if a launch command was executed (doesn't guarantee success). """ - import shutil import subprocess as _sp - candidates = [] - if system == "Darwin": - # macOS: try common app bundle locations - for app in ( - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - ): - if os.path.isfile(app): - candidates.append(app) - else: - # Linux: try common binary names - for name in ("google-chrome", "google-chrome-stable", "chromium-browser", - "chromium", "brave-browser", "microsoft-edge"): - path = shutil.which(name) - if path: - candidates.append(path) + candidates = _get_chrome_debug_candidates(system) if not candidates: return False diff --git a/tests/test_cli_browser_connect.py b/tests/test_cli_browser_connect.py new file mode 100644 index 000000000000..a913d96fe5d5 --- /dev/null +++ b/tests/test_cli_browser_connect.py @@ -0,0 +1,43 @@ +"""Tests for CLI browser CDP auto-launch helpers.""" + +from unittest.mock import patch + +from cli import HermesCLI + + +class TestChromeDebugLaunch: + def test_windows_launch_uses_browser_found_on_path(self): + captured = {} + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return object() + + with patch("cli.shutil.which", side_effect=lambda name: r"C:\Chrome\chrome.exe" if name == "chrome.exe" else None), \ + patch("cli.os.path.isfile", side_effect=lambda path: path == r"C:\Chrome\chrome.exe"), \ + patch("subprocess.Popen", side_effect=fake_popen): + assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True + + assert captured["cmd"] == [r"C:\Chrome\chrome.exe", "--remote-debugging-port=9333"] + assert captured["kwargs"]["start_new_session"] is True + + def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch): + captured = {} + installed = r"C:\Program Files\Google\Chrome\Application\chrome.exe" + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return object() + + monkeypatch.setenv("ProgramFiles", r"C:\Program Files") + monkeypatch.delenv("ProgramFiles(x86)", raising=False) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + + with patch("cli.shutil.which", return_value=None), \ + patch("cli.os.path.isfile", side_effect=lambda path: path == installed), \ + patch("subprocess.Popen", side_effect=fake_popen): + assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True + + assert captured["cmd"] == [installed, "--remote-debugging-port=9222"] From 7cf4bd06bfad5a51ea1e381fd8994defcb7632a0 Mon Sep 17 00:00:00 2001 From: jtuki Date: Sun, 5 Apr 2026 21:56:58 +0800 Subject: [PATCH 170/213] fix(gateway): fix Feishu reconnect message drops and shutdown hang This commit fixes two critical bugs in the Feishu adapter that affect message reliability and process lifecycle. **Bug Fix 1: Intermittent Message Drops** Root cause: Event handler was created once in __init__ and reused across reconnects, causing callbacks to capture stale loop references. When the adapter disconnected and reconnected, old callbacks continued firing with invalid loop references, resulting in dropped messages with warnings: "[Feishu] Dropping inbound message before adapter loop is ready" Fix: - Rebuild event handler on each connect (websocket/webhook) - Clear handler on disconnect - Ensure callbacks always capture current valid loop - Add defensive loop.is_closed() checks with getattr for test compatibility - Unify webhook dispatch path to use same loop checks as websocket mode **Bug Fix 2: Process Hangs on Ctrl+C / SIGTERM** Root cause: Feishu SDK's websocket client runs in a background thread with an infinite _select() loop that never exits naturally. The thread was never properly joined on disconnect, causing processes to hang indefinitely after Ctrl+C or gateway stop commands. Fix: - Store reference to thread-local event loop (_ws_thread_loop) - On disconnect, cancel all tasks in thread loop and stop it gracefully via call_soon_threadsafe() - Await thread future with 10s timeout - Clean up pending tasks in thread's finally block before closing loop - Add detailed debug logging for disconnect flow **Additional Improvements:** - Add regression tests for disconnect cleanup and webhook dispatch - Ensure all event callbacks check loop readiness before dispatching Tested on Linux with websocket mode. All Feishu tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/feishu.py | 87 +++++++++++++++++++++++++++++++----- tests/gateway/test_feishu.py | 63 ++++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 16 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index bee8b01d8a03..de4d97e64764 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -913,14 +913,33 @@ def _unique_lines(lines: List[str]) -> List[str]: return unique -def _run_official_feishu_ws_client(ws_client: Any) -> None: +def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: """Run the official Lark WS client in its own thread-local event loop.""" import lark_oapi.ws.client as ws_client_module loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) ws_client_module.loop = loop - ws_client.start() + adapter._ws_thread_loop = loop + try: + ws_client.start() + except Exception: + pass + finally: + pending = [t for t in asyncio.all_tasks(loop) if not t.done()] + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + try: + loop.stop() + except Exception: + pass + try: + loop.close() + except Exception: + pass + adapter._ws_thread_loop = None def check_feishu_requirements() -> bool: @@ -945,10 +964,11 @@ def __init__(self, config: PlatformConfig): self._client: Optional[Any] = None self._ws_client: Optional[Any] = None self._ws_future: Optional[asyncio.Future] = None + self._ws_thread_loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._webhook_runner: Optional[Any] = None self._webhook_site: Optional[Any] = None - self._event_handler = self._build_event_handler() + self._event_handler: Optional[Any] = None self._seen_message_ids: Dict[str, float] = {} # message_id → seen_at (time.time()) self._seen_message_order: List[str] = [] self._dedup_state_path = get_hermes_home() / "feishu_seen_message_ids.json" @@ -1116,8 +1136,37 @@ async def disconnect(self) -> None: self._reset_batch_buffers() self._disable_websocket_auto_reconnect() await self._stop_webhook_server() + + ws_thread_loop = self._ws_thread_loop + if ws_thread_loop is not None and not ws_thread_loop.is_closed(): + logger.debug("[Feishu] Cancelling websocket thread tasks and stopping loop") + + def cancel_all_tasks() -> None: + tasks = [t for t in asyncio.all_tasks(ws_thread_loop) if not t.done()] + logger.debug("[Feishu] Found %d pending tasks in websocket thread", len(tasks)) + for task in tasks: + task.cancel() + ws_thread_loop.call_later(0.1, ws_thread_loop.stop) + + ws_thread_loop.call_soon_threadsafe(cancel_all_tasks) + + ws_future = self._ws_future + if ws_future is not None: + try: + logger.debug("[Feishu] Waiting for websocket thread to exit (timeout=10s)") + await asyncio.wait_for(asyncio.shield(ws_future), timeout=10.0) + logger.debug("[Feishu] Websocket thread exited cleanly") + except asyncio.TimeoutError: + logger.warning("[Feishu] Websocket thread did not exit within 10s - may be stuck") + except asyncio.CancelledError: + logger.debug("[Feishu] Websocket thread cancelled during disconnect") + except Exception as exc: + logger.debug("[Feishu] Websocket thread exited with error: %s", exc, exc_info=True) + self._ws_future = None + self._ws_thread_loop = None self._loop = None + self._event_handler = None self._persist_seen_message_ids() await self._release_app_lock() @@ -1476,12 +1525,13 @@ def format_message(self, content: str) -> str: def _on_message_event(self, data: Any) -> None: """Normalize Feishu inbound events into MessageEvent.""" - if self._loop is None: + loop = self._loop + if loop is None or bool(getattr(loop, "is_closed", lambda: False)()): logger.warning("[Feishu] Dropping inbound message before adapter loop is ready") return future = asyncio.run_coroutine_threadsafe( self._handle_message_event_data(data), - self._loop, + loop, ) future.add_done_callback(self._log_background_failure) @@ -1553,27 +1603,30 @@ def _on_reaction_event(self, event_type: str, data: Any) -> None: ) # Only process reactions from real users. Ignore app/bot-generated reactions # and Hermes' own ACK emoji to avoid feedback loops. + loop = self._loop if ( operator_type in {"bot", "app"} or emoji_type == _FEISHU_ACK_EMOJI or not message_id - or self._loop is None + or loop is None + or bool(getattr(loop, "is_closed", lambda: False)()) ): return future = asyncio.run_coroutine_threadsafe( self._handle_reaction_event(event_type, data), - self._loop, + loop, ) future.add_done_callback(self._log_background_failure) def _on_card_action_trigger(self, data: Any) -> Any: """Schedule Feishu card actions on the adapter loop and acknowledge immediately.""" - if self._loop is None: + loop = self._loop + if loop is None or bool(getattr(loop, "is_closed", lambda: False)()): logger.warning("[Feishu] Dropping card action before adapter loop is ready") else: future = asyncio.run_coroutine_threadsafe( self._handle_card_action_event(data), - self._loop, + loop, ) future.add_done_callback(self._log_background_failure) if P2CardActionTriggerResponse is None: @@ -2083,7 +2136,7 @@ async def _handle_webhook_request(self, request: Any) -> Any: event_type = str((payload.get("header") or {}).get("event_type") or "") data = self._namespace_from_mapping(payload) if event_type == "im.message.receive_v1": - await self._handle_message_event_data(data) + self._on_message_event(data) elif event_type == "im.message.message_read_v1": self._on_message_read_event(data) elif event_type == "im.chat.member.bot.added_v1": @@ -2093,7 +2146,7 @@ async def _handle_webhook_request(self, request: Any) -> Any: elif event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"): self._on_reaction_event(event_type, data) elif event_type == "card.action.trigger": - asyncio.ensure_future(self._handle_card_action_event(data)) + self._on_card_action_trigger(data) else: logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown") return web.json_response({"code": 0, "msg": "ok"}) @@ -2965,6 +3018,12 @@ async def _connect_websocket(self) -> None: raise RuntimeError("websockets not installed; websocket mode unavailable") domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN self._client = self._build_lark_client(domain) + self._event_handler = self._build_event_handler() + if self._event_handler is None: + raise RuntimeError("failed to build Feishu event handler") + loop = self._loop + if loop is None or loop.is_closed(): + raise RuntimeError("adapter loop is not ready") await self._hydrate_bot_identity() self._ws_client = FeishuWSClient( app_id=self._app_id, @@ -2973,10 +3032,11 @@ async def _connect_websocket(self) -> None: event_handler=self._event_handler, domain=domain, ) - self._ws_future = self._loop.run_in_executor( + self._ws_future = loop.run_in_executor( None, _run_official_feishu_ws_client, self._ws_client, + self, ) async def _connect_webhook(self) -> None: @@ -2984,6 +3044,9 @@ async def _connect_webhook(self) -> None: raise RuntimeError("aiohttp not installed; webhook mode unavailable") domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN self._client = self._build_lark_client(domain) + self._event_handler = self._build_event_handler() + if self._event_handler is None: + raise RuntimeError("failed to build Feishu event handler") await self._hydrate_bot_identity() app = web.Application() app.router.add_post(self._webhook_path, self._handle_webhook_request) diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 5344cda52af0..41f92d60a08a 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -8,7 +8,7 @@ import unittest from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch try: import lark_oapi @@ -289,7 +289,7 @@ def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self): patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), - patch("gateway.platforms.feishu.EventDispatcherHandler", object()), + patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client), patch("gateway.platforms.feishu._run_official_feishu_ws_client"), patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)) as acquire_lock, @@ -297,6 +297,15 @@ def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self): patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): + mock_builder = Mock() + mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_receive_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_created_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_deleted_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_card_action_trigger = Mock(return_value=mock_builder) + mock_builder.build = Mock(return_value=object()) + mock_handler_class.builder = Mock(return_value=mock_builder) + loop = asyncio.new_event_loop() future = loop.create_future() future.set_result(None) @@ -305,6 +314,9 @@ class _Loop: def run_in_executor(self, *_args, **_kwargs): return future + def is_closed(self): + return False + try: with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=_Loop()): connected = asyncio.run(adapter.connect()) @@ -313,6 +325,7 @@ def run_in_executor(self, *_args, **_kwargs): loop.close() self.assertTrue(connected) + self.assertIsNone(adapter._event_handler) acquire_lock.assert_called_once_with( "feishu-app-id", "cli_app", @@ -361,7 +374,7 @@ def test_connect_retries_transient_startup_failure(self): patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), - patch("gateway.platforms.feishu.EventDispatcherHandler", object()), + patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client), patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)), patch("gateway.platforms.feishu.release_scoped_lock"), @@ -369,6 +382,15 @@ def test_connect_retries_transient_startup_failure(self): patch("gateway.platforms.feishu.asyncio.sleep", side_effect=lambda delay: sleeps.append(delay)), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): + mock_builder = Mock() + mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_receive_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_created_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_deleted_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_card_action_trigger = Mock(return_value=mock_builder) + mock_builder.build = Mock(return_value=object()) + mock_handler_class.builder = Mock(return_value=mock_builder) + loop = asyncio.new_event_loop() future = loop.create_future() future.set_result(None) @@ -383,6 +405,9 @@ def run_in_executor(self, *_args, **_kwargs): raise OSError("temporary websocket failure") return future + def is_closed(self): + return False + fake_loop = _Loop() try: with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=fake_loop): @@ -1196,7 +1221,12 @@ def test_message_event_submits_to_adapter_loop(self): from gateway.platforms.feishu import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) - adapter._loop = object() + + class _Loop: + def is_closed(self): + return False + + adapter._loop = _Loop() message = SimpleNamespace( message_id="om_text", @@ -1210,6 +1240,7 @@ def test_message_event_submits_to_adapter_loop(self): data = SimpleNamespace(event=SimpleNamespace(message=message, sender=sender)) future = SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None) + def _submit(coro, _loop): coro.close() return future @@ -1219,6 +1250,30 @@ def _submit(coro, _loop): self.assertTrue(submit.called) + @patch.dict(os.environ, {}, clear=True) + def test_webhook_request_uses_same_message_dispatch_path(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + adapter._on_message_event = Mock() + + body = json.dumps({ + "header": {"event_type": "im.message.receive_v1"}, + "event": {"message": {"message_id": "om_test"}}, + }).encode("utf-8") + request = SimpleNamespace( + remote="127.0.0.1", + content_length=None, + headers={}, + read=AsyncMock(return_value=body), + ) + + response = asyncio.run(adapter._handle_webhook_request(request)) + + self.assertEqual(response.status, 200) + adapter._on_message_event.assert_called_once() + @patch.dict(os.environ, {}, clear=True) def test_process_inbound_message_uses_event_sender_identity_only(self): from gateway.config import PlatformConfig From 7d0bf151213a09d65f6bbece5f741955c2f6519d Mon Sep 17 00:00:00 2001 From: jtuki Date: Sun, 5 Apr 2026 22:45:49 +0800 Subject: [PATCH 171/213] feat(gateway): add configurable Feishu websocket reconnect timing Allow users to configure websocket reconnect behavior via platform extra config to reduce reconnect latency in production environments. The official Feishu SDK defaults to: - First reconnect: random jitter 0-30 seconds - Subsequent retries: 120 second intervals This can cause 20-30 second delays before reconnection after network interruptions. This commit makes these values configurable while keeping the SDK defaults for backward compatibility. Configuration via ~/.hermes/config.yaml: ```yaml platforms: feishu: extra: ws_reconnect_nonce: 0 # Disable first-reconnect jitter (default: 30) ws_reconnect_interval: 3 # Retry every 3 seconds (default: 120) ``` Invalid values (negative numbers, non-integers) fall back to SDK defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/feishu.py | 27 +++++++++++++++++++++++++++ tests/gateway/test_feishu.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index de4d97e64764..3048393235c7 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -270,6 +270,8 @@ class FeishuAdapterSettings: webhook_host: str webhook_port: int webhook_path: str + ws_reconnect_nonce: int = 30 + ws_reconnect_interval: int = 120 @dataclass @@ -358,6 +360,22 @@ def _strip_markdown_to_plain_text(text: str) -> str: return plain.strip() +def _coerce_non_negative_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _coerce_positive_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 1 else default + + # --------------------------------------------------------------------------- # Post payload builders and parsers # --------------------------------------------------------------------------- @@ -1040,6 +1058,8 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: str(extra.get("webhook_path") or os.getenv("FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH)).strip() or _DEFAULT_WEBHOOK_PATH ), + ws_reconnect_nonce=_coerce_non_negative_int(extra.get("ws_reconnect_nonce"), 30), + ws_reconnect_interval=_coerce_positive_int(extra.get("ws_reconnect_interval"), 120), ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1062,6 +1082,8 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._webhook_host = settings.webhook_host self._webhook_port = settings.webhook_port self._webhook_path = settings.webhook_path + self._ws_reconnect_nonce = settings.ws_reconnect_nonce + self._ws_reconnect_interval = settings.ws_reconnect_interval def _build_event_handler(self) -> Any: if EventDispatcherHandler is None: @@ -3032,6 +3054,11 @@ async def _connect_websocket(self) -> None: event_handler=self._event_handler, domain=domain, ) + try: + setattr(self._ws_client, "_reconnect_nonce", self._ws_reconnect_nonce) + setattr(self._ws_client, "_reconnect_interval", self._ws_reconnect_interval) + except Exception: + logger.debug("[Feishu] Failed to override websocket reconnect settings", exc_info=True) self._ws_future = loop.run_in_executor( None, _run_official_feishu_ws_client, diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 41f92d60a08a..db532ba799f7 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -283,7 +283,7 @@ def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self): from gateway.platforms.feishu import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) - ws_client = object() + ws_client = SimpleNamespace() with ( patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), @@ -367,7 +367,7 @@ def test_connect_retries_transient_startup_failure(self): from gateway.platforms.feishu import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) - ws_client = object() + ws_client = SimpleNamespace() sleeps = [] with ( @@ -561,6 +561,32 @@ def test_adapter_registers_reaction_and_card_handlers_for_websocket(self): self.assertIn("register_p2_im_message_reaction_deleted_v1", source) self.assertIn("register_p2_card_action_trigger", source) + def test_load_settings_uses_sdk_defaults_for_invalid_ws_reconnect_values(self): + from gateway.platforms.feishu import FeishuAdapter + + settings = FeishuAdapter._load_settings( + { + "ws_reconnect_nonce": -1, + "ws_reconnect_interval": "bad", + } + ) + + self.assertEqual(settings.ws_reconnect_nonce, 30) + self.assertEqual(settings.ws_reconnect_interval, 120) + + def test_load_settings_accepts_custom_ws_reconnect_values(self): + from gateway.platforms.feishu import FeishuAdapter + + settings = FeishuAdapter._load_settings( + { + "ws_reconnect_nonce": 0, + "ws_reconnect_interval": 3, + } + ) + + self.assertEqual(settings.ws_reconnect_nonce, 0) + self.assertEqual(settings.ws_reconnect_interval, 3) + class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) From ea31d9077c1109f94fde5b97cd92e7920f5a3d18 Mon Sep 17 00:00:00 2001 From: jtuki Date: Sun, 5 Apr 2026 23:23:07 +0800 Subject: [PATCH 172/213] feat(gateway): add Feishu websocket ping timing overrides Allow Feishu websocket keepalive timing to be configured via platform extra config so disconnects can be detected faster in unstable networks. New optional extra settings: - ws_ping_interval - ws_ping_timeout These values are applied only when explicitly configured. Invalid values fall back to the websocket library defaults by leaving the options unset. This complements the reconnect timing settings added previously and helps reduce total recovery time after network interruptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/feishu.py | 26 ++++++++++++++++++++++++++ tests/gateway/test_feishu.py | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 3048393235c7..7fa6e3a9dae9 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -272,6 +272,8 @@ class FeishuAdapterSettings: webhook_path: str ws_reconnect_nonce: int = 30 ws_reconnect_interval: int = 120 + ws_ping_interval: Optional[int] = None + ws_ping_timeout: Optional[int] = None @dataclass @@ -376,6 +378,14 @@ def _coerce_positive_int(value: Any, default: int) -> int: return parsed if parsed >= 1 else default +def _coerce_optional_positive_int(value: Any) -> Optional[int]: + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed >= 1 else None + + # --------------------------------------------------------------------------- # Post payload builders and parsers # --------------------------------------------------------------------------- @@ -939,11 +949,23 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: asyncio.set_event_loop(loop) ws_client_module.loop = loop adapter._ws_thread_loop = loop + + original_connect = ws_client_module.websockets.connect + + async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any: + if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs: + kwargs["ping_interval"] = adapter._ws_ping_interval + if adapter._ws_ping_timeout is not None and "ping_timeout" not in kwargs: + kwargs["ping_timeout"] = adapter._ws_ping_timeout + return await original_connect(*args, **kwargs) + + ws_client_module.websockets.connect = _connect_with_overrides try: ws_client.start() except Exception: pass finally: + ws_client_module.websockets.connect = original_connect pending = [t for t in asyncio.all_tasks(loop) if not t.done()] for task in pending: task.cancel() @@ -1060,6 +1082,8 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: ), ws_reconnect_nonce=_coerce_non_negative_int(extra.get("ws_reconnect_nonce"), 30), ws_reconnect_interval=_coerce_positive_int(extra.get("ws_reconnect_interval"), 120), + ws_ping_interval=_coerce_optional_positive_int(extra.get("ws_ping_interval")), + ws_ping_timeout=_coerce_optional_positive_int(extra.get("ws_ping_timeout")), ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1084,6 +1108,8 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._webhook_path = settings.webhook_path self._ws_reconnect_nonce = settings.ws_reconnect_nonce self._ws_reconnect_interval = settings.ws_reconnect_interval + self._ws_ping_interval = settings.ws_ping_interval + self._ws_ping_timeout = settings.ws_ping_timeout def _build_event_handler(self) -> Any: if EventDispatcherHandler is None: diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index db532ba799f7..64ee36979c87 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -587,6 +587,32 @@ def test_load_settings_accepts_custom_ws_reconnect_values(self): self.assertEqual(settings.ws_reconnect_nonce, 0) self.assertEqual(settings.ws_reconnect_interval, 3) + def test_load_settings_accepts_custom_ws_ping_values(self): + from gateway.platforms.feishu import FeishuAdapter + + settings = FeishuAdapter._load_settings( + { + "ws_ping_interval": 10, + "ws_ping_timeout": 8, + } + ) + + self.assertEqual(settings.ws_ping_interval, 10) + self.assertEqual(settings.ws_ping_timeout, 8) + + def test_load_settings_ignores_invalid_ws_ping_values(self): + from gateway.platforms.feishu import FeishuAdapter + + settings = FeishuAdapter._load_settings( + { + "ws_ping_interval": 0, + "ws_ping_timeout": -1, + } + ) + + self.assertIsNone(settings.ws_ping_interval) + self.assertIsNone(settings.ws_ping_timeout) + class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) From 157d6184e3ac51463401725d92216a9a68040a49 Mon Sep 17 00:00:00 2001 From: jtuki Date: Mon, 6 Apr 2026 00:40:10 +0800 Subject: [PATCH 173/213] fix(gateway): make Feishu websocket overrides effective at runtime Reapply local reconnect and ping settings after the Feishu SDK refreshes its client config so user-provided websocket tuning actually takes effect. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/feishu.py | 25 +++++++++++--- tests/gateway/test_feishu.py | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 7fa6e3a9dae9..6b43ea55cf69 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -951,6 +951,16 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: adapter._ws_thread_loop = loop original_connect = ws_client_module.websockets.connect + original_configure = getattr(ws_client, "_configure", None) + + def _apply_runtime_ws_overrides() -> None: + try: + setattr(ws_client, "_reconnect_nonce", adapter._ws_reconnect_nonce) + setattr(ws_client, "_reconnect_interval", adapter._ws_reconnect_interval) + if adapter._ws_ping_interval is not None: + setattr(ws_client, "_ping_interval", adapter._ws_ping_interval) + except Exception: + logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True) async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any: if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs: @@ -959,13 +969,23 @@ async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any: kwargs["ping_timeout"] = adapter._ws_ping_timeout return await original_connect(*args, **kwargs) + def _configure_with_overrides(conf: Any) -> Any: + result = original_configure(conf) + _apply_runtime_ws_overrides() + return result + ws_client_module.websockets.connect = _connect_with_overrides + if callable(original_configure): + setattr(ws_client, "_configure", _configure_with_overrides) + _apply_runtime_ws_overrides() try: ws_client.start() except Exception: pass finally: ws_client_module.websockets.connect = original_connect + if callable(original_configure): + setattr(ws_client, "_configure", original_configure) pending = [t for t in asyncio.all_tasks(loop) if not t.done()] for task in pending: task.cancel() @@ -3080,11 +3100,6 @@ async def _connect_websocket(self) -> None: event_handler=self._event_handler, domain=domain, ) - try: - setattr(self._ws_client, "_reconnect_nonce", self._ws_reconnect_nonce) - setattr(self._ws_client, "_reconnect_interval", self._ws_reconnect_interval) - except Exception: - logger.debug("[Feishu] Failed to override websocket reconnect settings", exc_info=True) self._ws_future = loop.run_in_executor( None, _run_official_feishu_ws_client, diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 64ee36979c87..33ed55d3df3c 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -262,12 +262,21 @@ def test_connect_webhook_mode_starts_local_server(self): with ( patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), patch("gateway.platforms.feishu.FEISHU_WEBHOOK_AVAILABLE", True), + patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)), patch("gateway.platforms.feishu.release_scoped_lock"), patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), patch("gateway.platforms.feishu.web", web_module), ): + mock_builder = Mock() + mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_receive_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_created_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_deleted_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_card_action_trigger = Mock(return_value=mock_builder) + mock_builder.build = Mock(return_value=object()) + mock_handler_class.builder = Mock(return_value=mock_builder) connected = asyncio.run(adapter.connect()) self.assertTrue(connected) @@ -613,6 +622,61 @@ def test_load_settings_ignores_invalid_ws_ping_values(self): self.assertIsNone(settings.ws_ping_interval) self.assertIsNone(settings.ws_ping_timeout) + def test_runtime_ws_overrides_reapply_after_sdk_configure(self): + import sys + from types import ModuleType + + class _FakeWSClient: + def __init__(self): + self._reconnect_nonce = 30 + self._reconnect_interval = 120 + self._ping_interval = 120 + self.configure_calls = [] + + def _configure(self, conf): + self.configure_calls.append(conf) + self._reconnect_nonce = conf.ReconnectNonce + self._reconnect_interval = conf.ReconnectInterval + self._ping_interval = conf.PingInterval + + def start(self): + conf = SimpleNamespace(ReconnectNonce=99, ReconnectInterval=88, PingInterval=77) + self._configure(conf) + raise RuntimeError("stop test client") + + fake_client = _FakeWSClient() + fake_adapter = SimpleNamespace( + _ws_thread_loop=None, + _ws_reconnect_nonce=2, + _ws_reconnect_interval=3, + _ws_ping_interval=4, + _ws_ping_timeout=5, + ) + fake_client_module = ModuleType("lark_oapi.ws.client") + fake_client_module.loop = None + fake_client_module.websockets = SimpleNamespace(connect=AsyncMock()) + fake_ws_module = ModuleType("lark_oapi.ws") + fake_ws_module.client = fake_client_module + fake_root_module = ModuleType("lark_oapi") + fake_root_module.ws = fake_ws_module + + original_modules = sys.modules.copy() + sys.modules["lark_oapi"] = fake_root_module + sys.modules["lark_oapi.ws"] = fake_ws_module + sys.modules["lark_oapi.ws.client"] = fake_client_module + try: + from gateway.platforms.feishu import _run_official_feishu_ws_client + + _run_official_feishu_ws_client(fake_client, fake_adapter) + finally: + sys.modules.clear() + sys.modules.update(original_modules) + + self.assertEqual(len(fake_client.configure_calls), 1) + self.assertEqual(fake_client._reconnect_nonce, 2) + self.assertEqual(fake_client._reconnect_interval, 3) + self.assertEqual(fake_client._ping_interval, 4) + class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) From 18727ca9aae48c5a4caeb1ab113bd7413ad81c53 Mon Sep 17 00:00:00 2001 From: jtuki Date: Mon, 6 Apr 2026 01:04:39 +0800 Subject: [PATCH 174/213] refactor(gateway): simplify Feishu websocket config helpers Consolidate coercion functions, extract loop readiness check, and deduplicate test mock setup to improve maintainability without changing behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/feishu.py | 35 ++++++++++++++------------------ tests/gateway/test_feishu.py | 39 ++++++++++++++---------------------- 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 6b43ea55cf69..e39290b7d3e9 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -362,28 +362,22 @@ def _strip_markdown_to_plain_text(text: str) -> str: return plain.strip() -def _coerce_non_negative_int(value: Any, default: int) -> int: +def _coerce_int(value: Any, default: Optional[int] = None, min_value: int = 0) -> Optional[int]: + """Coerce value to int with optional default and minimum constraint.""" try: parsed = int(value) except (TypeError, ValueError): return default - return parsed if parsed >= 0 else default + return parsed if parsed >= min_value else default -def _coerce_positive_int(value: Any, default: int) -> int: - try: - parsed = int(value) - except (TypeError, ValueError): - return default - return parsed if parsed >= 1 else default +def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int: + parsed = _coerce_int(value, default=default, min_value=min_value) + return default if parsed is None else parsed -def _coerce_optional_positive_int(value: Any) -> Optional[int]: - try: - parsed = int(value) - except (TypeError, ValueError): - return None - return parsed if parsed >= 1 else None +def _is_loop_ready(loop: Optional[asyncio.AbstractEventLoop]) -> bool: + return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)()) # --------------------------------------------------------------------------- @@ -970,12 +964,13 @@ async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any: return await original_connect(*args, **kwargs) def _configure_with_overrides(conf: Any) -> Any: + assert original_configure is not None result = original_configure(conf) _apply_runtime_ws_overrides() return result ws_client_module.websockets.connect = _connect_with_overrides - if callable(original_configure): + if original_configure is not None: setattr(ws_client, "_configure", _configure_with_overrides) _apply_runtime_ws_overrides() try: @@ -984,7 +979,7 @@ def _configure_with_overrides(conf: Any) -> Any: pass finally: ws_client_module.websockets.connect = original_connect - if callable(original_configure): + if original_configure is not None: setattr(ws_client, "_configure", original_configure) pending = [t for t in asyncio.all_tasks(loop) if not t.done()] for task in pending: @@ -1100,10 +1095,10 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: str(extra.get("webhook_path") or os.getenv("FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH)).strip() or _DEFAULT_WEBHOOK_PATH ), - ws_reconnect_nonce=_coerce_non_negative_int(extra.get("ws_reconnect_nonce"), 30), - ws_reconnect_interval=_coerce_positive_int(extra.get("ws_reconnect_interval"), 120), - ws_ping_interval=_coerce_optional_positive_int(extra.get("ws_ping_interval")), - ws_ping_timeout=_coerce_optional_positive_int(extra.get("ws_ping_timeout")), + ws_reconnect_nonce=_coerce_required_int(extra.get("ws_reconnect_nonce"), default=30, min_value=0), + ws_reconnect_interval=_coerce_required_int(extra.get("ws_reconnect_interval"), default=120, min_value=1), + ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1), + ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1), ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 33ed55d3df3c..4e8d18e315cb 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -17,6 +17,18 @@ _HAS_LARK_OAPI = False +def _mock_event_dispatcher_builder(mock_handler_class): + mock_builder = Mock() + mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_receive_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_created_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_im_message_reaction_deleted_v1 = Mock(return_value=mock_builder) + mock_builder.register_p2_card_action_trigger = Mock(return_value=mock_builder) + mock_builder.build = Mock(return_value=object()) + mock_handler_class.builder = Mock(return_value=mock_builder) + return mock_builder + + class TestPlatformEnum(unittest.TestCase): def test_feishu_in_platform_enum(self): from gateway.config import Platform @@ -269,14 +281,7 @@ def test_connect_webhook_mode_starts_local_server(self): patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), patch("gateway.platforms.feishu.web", web_module), ): - mock_builder = Mock() - mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_receive_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_reaction_created_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_reaction_deleted_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_card_action_trigger = Mock(return_value=mock_builder) - mock_builder.build = Mock(return_value=object()) - mock_handler_class.builder = Mock(return_value=mock_builder) + _mock_event_dispatcher_builder(mock_handler_class) connected = asyncio.run(adapter.connect()) self.assertTrue(connected) @@ -306,14 +311,7 @@ def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self): patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): - mock_builder = Mock() - mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_receive_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_reaction_created_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_reaction_deleted_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_card_action_trigger = Mock(return_value=mock_builder) - mock_builder.build = Mock(return_value=object()) - mock_handler_class.builder = Mock(return_value=mock_builder) + _mock_event_dispatcher_builder(mock_handler_class) loop = asyncio.new_event_loop() future = loop.create_future() @@ -391,14 +389,7 @@ def test_connect_retries_transient_startup_failure(self): patch("gateway.platforms.feishu.asyncio.sleep", side_effect=lambda delay: sleeps.append(delay)), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): - mock_builder = Mock() - mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_receive_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_reaction_created_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_im_message_reaction_deleted_v1 = Mock(return_value=mock_builder) - mock_builder.register_p2_card_action_trigger = Mock(return_value=mock_builder) - mock_builder.build = Mock(return_value=object()) - mock_handler_class.builder = Mock(return_value=mock_builder) + _mock_event_dispatcher_builder(mock_handler_class) loop = asyncio.new_event_loop() future = loop.create_future() From 57abc9931509dd0da9e7faee6855d20b8cb18830 Mon Sep 17 00:00:00 2001 From: jtuki Date: Mon, 6 Apr 2026 22:18:41 +0800 Subject: [PATCH 175/213] feat(gateway): add per-group access control for Feishu Add fine-grained authorization policies per Feishu group chat via platforms.feishu.extra configuration. - Add global bot-level admins that bypass all group restrictions - Add per-group policies: open, allowlist, blacklist, admin_only, disabled - Add default_group_policy fallback for chats without explicit rules - Thread chat_id through group message gate for per-chat rule selection - Match both open_id and user_id for backward compatibility - Preserve existing FEISHU_ALLOWED_USERS / FEISHU_GROUP_POLICY behavior - Add focused regression tests for all policy modes Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/feishu.py | 80 +++++++++-- tests/gateway/test_feishu.py | 252 ++++++++++++++++++++++++++++++----- 2 files changed, 288 insertions(+), 44 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index e39290b7d3e9..9bbf2f62dd2d 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -274,6 +274,18 @@ class FeishuAdapterSettings: ws_reconnect_interval: int = 120 ws_ping_interval: Optional[int] = None ws_ping_timeout: Optional[int] = None + admins: frozenset[str] = frozenset() + default_group_policy: str = "" + group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict) + + +@dataclass +class FeishuGroupRule: + """Per-group policy rule for controlling which users may interact with the bot.""" + + policy: str # "open" | "allowlist" | "blacklist" | "admin_only" | "disabled" + allowlist: set[str] = field(default_factory=set) + blacklist: set[str] = field(default_factory=set) @dataclass @@ -1049,6 +1061,26 @@ def __init__(self, config: PlatformConfig): @staticmethod def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: + # Parse per-group rules from config + raw_group_rules = extra.get("group_rules", {}) + group_rules: Dict[str, FeishuGroupRule] = {} + if isinstance(raw_group_rules, dict): + for chat_id, rule_cfg in raw_group_rules.items(): + if not isinstance(rule_cfg, dict): + continue + group_rules[str(chat_id)] = FeishuGroupRule( + policy=str(rule_cfg.get("policy", "open")).strip().lower(), + allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()), + blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()), + ) + + # Bot-level admins + raw_admins = extra.get("admins", []) + admins = frozenset(str(u).strip() for u in raw_admins if str(u).strip()) + + # Default group policy (for groups not in group_rules) + default_group_policy = str(extra.get("default_group_policy", "")).strip().lower() + return FeishuAdapterSettings( app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(), app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(), @@ -1099,6 +1131,9 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: ws_reconnect_interval=_coerce_required_int(extra.get("ws_reconnect_interval"), default=120, min_value=1), ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1), ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1), + admins=admins, + default_group_policy=default_group_policy, + group_rules=group_rules, ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1110,6 +1145,9 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._verification_token = settings.verification_token self._group_policy = settings.group_policy self._allowed_group_users = set(settings.allowed_group_users) + self._admins = set(settings.admins) + self._default_group_policy = settings.default_group_policy or settings.group_policy + self._group_rules = settings.group_rules self._bot_open_id = settings.bot_open_id self._bot_user_id = settings.bot_user_id self._bot_name = settings.bot_name @@ -1617,7 +1655,8 @@ async def _handle_message_event_data(self, data: Any) -> None: return chat_type = getattr(message, "chat_type", "p2p") - if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id): + chat_id = getattr(message, "chat_id", "") or "" + if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id, chat_id): logger.debug("[Feishu] Dropping group message that failed mention/policy gate: %s", message_id) return await self._process_inbound_message( @@ -2773,18 +2812,41 @@ def _log_background_failure(future: Any) -> None: # Group policy and mention gating # ========================================================================= - def _allow_group_message(self, sender_id: Any) -> bool: - """Current group policy gate for non-DM traffic.""" - if self._group_policy == "disabled": + def _allow_group_message(self, sender_id: Any, chat_id: str = "") -> bool: + """Per-group policy gate for non-DM traffic.""" + sender_open_id = getattr(sender_id, "open_id", None) + sender_user_id = getattr(sender_id, "user_id", None) + sender_ids = {sender_open_id, sender_user_id} - {None} + + if sender_ids and self._admins and (sender_ids & self._admins): + return True + + rule = self._group_rules.get(chat_id) if chat_id else None + if rule: + policy = rule.policy + allowlist = rule.allowlist + blacklist = rule.blacklist + else: + policy = self._default_group_policy or self._group_policy + allowlist = self._allowed_group_users + blacklist = set() + + if policy == "disabled": return False - sender_open_id = getattr(sender_id, "open_id", None) or getattr(sender_id, "user_id", None) - if self._group_policy == "open": + if policy == "open": return True - return bool(sender_open_id and sender_open_id in self._allowed_group_users) + if policy == "admin_only": + return False + if policy == "allowlist": + return bool(sender_ids and (sender_ids & allowlist)) + if policy == "blacklist": + return bool(sender_ids and not (sender_ids & blacklist)) + + return bool(sender_ids and (sender_ids & self._allowed_group_users)) - def _should_accept_group_message(self, message: Any, sender_id: Any) -> bool: + def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = "") -> bool: """Require an explicit @mention before group messages enter the agent.""" - if not self._allow_group_message(sender_id): + if not self._allow_group_message(sender_id, chat_id): return False # @_all is Feishu's @everyone placeholder — always route to the bot. raw_content = getattr(message, "content", "") or "" diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 4e8d18e315cb..47f274d1b7e4 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -822,10 +822,10 @@ def test_group_message_requires_mentions_even_when_policy_open(self): adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace(mentions=[]) sender_id = SimpleNamespace(open_id="ou_any", user_id=None) - self.assertFalse(adapter._should_accept_group_message(message, sender_id)) + self.assertFalse(adapter._should_accept_group_message(message, sender_id, "")) message_with_mention = SimpleNamespace(mentions=[SimpleNamespace(key="@_user_1")]) - self.assertFalse(adapter._should_accept_group_message(message_with_mention, sender_id)) + self.assertFalse(adapter._should_accept_group_message(message_with_mention, sender_id, "")) @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self): @@ -839,7 +839,7 @@ def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unk id=SimpleNamespace(open_id="ou_other", user_id="u_other"), ) - self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id)) + self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, "")) @patch.dict( os.environ, @@ -868,28 +868,222 @@ def test_group_message_allowlist_and_mention_both_required(self): adapter._should_accept_group_message( mentioned, SimpleNamespace(open_id="ou_allowed", user_id=None), + "", ) ) self.assertFalse( adapter._should_accept_group_message( mentioned, SimpleNamespace(open_id="ou_blocked", user_id=None), + "", ) ) - @patch.dict( - os.environ, - { - "FEISHU_GROUP_POLICY": "open", - "FEISHU_BOT_OPEN_ID": "ou_bot", - }, - clear=True, - ) + def test_per_group_allowlist_policy_gates_by_sender(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig( + extra={ + "group_rules": { + "oc_chat_a": { + "policy": "allowlist", + "allowlist": ["ou_alice", "ou_bob"], + } + } + } + ) + adapter = FeishuAdapter(config) + adapter._bot_open_id = "ou_bot" + + message = SimpleNamespace( + mentions=[SimpleNamespace(name="Bot", id=SimpleNamespace(open_id="ou_bot", user_id=None))] + ) + + self.assertTrue( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_alice", user_id=None), + "oc_chat_a", + ) + ) + self.assertFalse( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_charlie", user_id=None), + "oc_chat_a", + ) + ) + + def test_per_group_blacklist_policy_blocks_specific_users(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig( + extra={ + "group_rules": { + "oc_chat_b": { + "policy": "blacklist", + "blacklist": ["ou_blocked"], + } + } + } + ) + adapter = FeishuAdapter(config) + adapter._bot_open_id = "ou_bot" + + message = SimpleNamespace( + mentions=[SimpleNamespace(name="Bot", id=SimpleNamespace(open_id="ou_bot", user_id=None))] + ) + + self.assertTrue( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_alice", user_id=None), + "oc_chat_b", + ) + ) + self.assertFalse( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_blocked", user_id=None), + "oc_chat_b", + ) + ) + + def test_per_group_admin_only_policy_requires_admin(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig( + extra={ + "admins": ["ou_admin"], + "group_rules": { + "oc_chat_c": { + "policy": "admin_only", + } + }, + } + ) + adapter = FeishuAdapter(config) + adapter._bot_open_id = "ou_bot" + + message = SimpleNamespace( + mentions=[SimpleNamespace(name="Bot", id=SimpleNamespace(open_id="ou_bot", user_id=None))] + ) + + self.assertTrue( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_admin", user_id=None), + "oc_chat_c", + ) + ) + self.assertFalse( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_regular", user_id=None), + "oc_chat_c", + ) + ) + + def test_per_group_disabled_policy_blocks_all(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig( + extra={ + "admins": ["ou_admin"], + "group_rules": { + "oc_chat_d": { + "policy": "disabled", + } + }, + } + ) + adapter = FeishuAdapter(config) + adapter._bot_open_id = "ou_bot" + + message = SimpleNamespace( + mentions=[SimpleNamespace(name="Bot", id=SimpleNamespace(open_id="ou_bot", user_id=None))] + ) + + self.assertTrue( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_admin", user_id=None), + "oc_chat_d", + ) + ) + self.assertFalse( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_regular", user_id=None), + "oc_chat_d", + ) + ) + + def test_global_admins_bypass_all_group_rules(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig( + extra={ + "admins": ["ou_admin"], + "group_rules": { + "oc_chat_e": { + "policy": "allowlist", + "allowlist": ["ou_alice"], + } + }, + } + ) + adapter = FeishuAdapter(config) + adapter._bot_open_id = "ou_bot" + + message = SimpleNamespace( + mentions=[SimpleNamespace(name="Bot", id=SimpleNamespace(open_id="ou_bot", user_id=None))] + ) + + self.assertTrue( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_admin", user_id=None), + "oc_chat_e", + ) + ) + + def test_default_group_policy_fallback_for_chats_without_explicit_rule(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + config = PlatformConfig( + extra={ + "default_group_policy": "open", + } + ) + adapter = FeishuAdapter(config) + adapter._bot_open_id = "ou_bot" + + message = SimpleNamespace( + mentions=[SimpleNamespace(name="Bot", id=SimpleNamespace(open_id="ou_bot", user_id=None))] + ) + + self.assertTrue( + adapter._should_accept_group_message( + message, + SimpleNamespace(open_id="ou_anyone", user_id=None), + "oc_chat_unknown", + ) + ) + + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_matches_bot_open_id_when_configured(self): from gateway.config import PlatformConfig from gateway.platforms.feishu import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) + adapter._bot_open_id = "ou_bot" sender_id = SimpleNamespace(open_id="ou_any", user_id=None) bot_mention = SimpleNamespace( @@ -901,22 +1095,16 @@ def test_group_message_matches_bot_open_id_when_configured(self): id=SimpleNamespace(open_id="ou_other", user_id="u_other"), ) - self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id)) - self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id)) + self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, "")) + self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, "")) - @patch.dict( - os.environ, - { - "FEISHU_GROUP_POLICY": "open", - "FEISHU_BOT_NAME": "Hermes Bot", - }, - clear=True, - ) + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_matches_bot_name_when_only_name_available(self): from gateway.config import PlatformConfig from gateway.platforms.feishu import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) + adapter._bot_name = "Hermes Bot" sender_id = SimpleNamespace(open_id="ou_any", user_id=None) named_mention = SimpleNamespace( @@ -928,22 +1116,16 @@ def test_group_message_matches_bot_name_when_only_name_available(self): id=SimpleNamespace(open_id="ou_other", user_id="u_other"), ) - self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[named_mention]), sender_id)) - self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id)) + self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[named_mention]), sender_id, "")) + self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id, "")) - @patch.dict( - os.environ, - { - "FEISHU_GROUP_POLICY": "open", - "FEISHU_BOT_OPEN_ID": "ou_bot", - }, - clear=True, - ) + @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_post_message_uses_parsed_mentions_when_sdk_mentions_missing(self): from gateway.config import PlatformConfig from gateway.platforms.feishu import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) + adapter._bot_open_id = "ou_bot" sender_id = SimpleNamespace(open_id="ou_any", user_id=None) message = SimpleNamespace( message_type="post", @@ -951,7 +1133,7 @@ def test_group_post_message_uses_parsed_mentions_when_sdk_mentions_missing(self) content='{"en_us":{"content":[[{"tag":"at","user_name":"Hermes","open_id":"ou_bot"}]]}}', ) - self.assertTrue(adapter._should_accept_group_message(message, sender_id)) + self.assertTrue(adapter._should_accept_group_message(message, sender_id, "")) @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_as_text(self): @@ -2618,7 +2800,7 @@ def test_at_all_in_content_accepts_without_explicit_bot_mention(self): mentions=[], ) sender_id = SimpleNamespace(open_id="ou_any", user_id=None) - self.assertTrue(adapter._should_accept_group_message(message, sender_id)) + self.assertTrue(adapter._should_accept_group_message(message, sender_id, "")) @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "allowlist", "FEISHU_ALLOWED_USERS": "ou_allowed"}, clear=True) def test_at_all_still_requires_policy_gate(self): @@ -2630,10 +2812,10 @@ def test_at_all_still_requires_policy_gate(self): message = SimpleNamespace(content='{"text":"@_all attention"}', mentions=[]) # Non-allowlisted user — should be blocked even with @_all. blocked_sender = SimpleNamespace(open_id="ou_blocked", user_id=None) - self.assertFalse(adapter._should_accept_group_message(message, blocked_sender)) + self.assertFalse(adapter._should_accept_group_message(message, blocked_sender, "")) # Allowlisted user — should pass. allowed_sender = SimpleNamespace(open_id="ou_allowed", user_id=None) - self.assertTrue(adapter._should_accept_group_message(message, allowed_sender)) + self.assertTrue(adapter._should_accept_group_message(message, allowed_sender, "")) @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed") From adb418fb5390d77b4516a7413cbc53b09717f1d5 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 16:53:24 -0700 Subject: [PATCH 176/213] fix: cross-platform browser test path separators Use os.path.join for Windows install path so test passes on Linux (os.path.join uses / on Linux, \ on Windows). --- tests/test_cli_browser_connect.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_cli_browser_connect.py b/tests/test_cli_browser_connect.py index a913d96fe5d5..f01475bf8a94 100644 --- a/tests/test_cli_browser_connect.py +++ b/tests/test_cli_browser_connect.py @@ -1,5 +1,6 @@ """Tests for CLI browser CDP auto-launch helpers.""" +import os from unittest.mock import patch from cli import HermesCLI @@ -24,14 +25,16 @@ def fake_popen(cmd, **kwargs): def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch): captured = {} - installed = r"C:\Program Files\Google\Chrome\Application\chrome.exe" + program_files = r"C:\Program Files" + # Use os.path.join so path separators match cross-platform + installed = os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe") def fake_popen(cmd, **kwargs): captured["cmd"] = cmd captured["kwargs"] = kwargs return object() - monkeypatch.setenv("ProgramFiles", r"C:\Program Files") + monkeypatch.setenv("ProgramFiles", program_files) monkeypatch.delenv("ProgramFiles(x86)", raising=False) monkeypatch.delenv("LOCALAPPDATA", raising=False) From 8cf013ecd9c00c0113171ceb2dedb0aeec9010d3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:17:57 -0700 Subject: [PATCH 177/213] fix: replace stale 'hermes login' refs with 'hermes auth' + fix credential removal re-seeding (#5670) Two fixes: 1. Replace all stale 'hermes login' references with 'hermes auth' across auth.py, auxiliary_client.py, delegate_tool.py, config.py, run_agent.py, and documentation. The 'hermes login' command was deprecated; 'hermes auth' now handles OAuth credential management. 2. Fix credential removal not persisting for singleton-sourced credentials (device_code for openai-codex/nous, hermes_pkce for anthropic). auth_remove_command already cleared env vars for env-sourced credentials, but singleton credentials stored in the auth store were re-seeded by _seed_from_singletons() on the next load_pool() call. Now clears the underlying auth store entry when removing singleton-sourced credentials. --- agent/auxiliary_client.py | 4 +-- hermes_cli/auth.py | 20 +++++++------- hermes_cli/auth_commands.py | 26 +++++++++++++++++++ hermes_cli/config.py | 8 +++--- run_agent.py | 2 +- tools/delegate_tool.py | 2 +- website/docs/reference/cli-commands.md | 23 +++++----------- website/docs/user-guide/configuration.md | 2 +- .../user-guide/features/fallback-providers.md | 4 +-- 9 files changed, 53 insertions(+), 38 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 95d5def0ac07..7cb8f9f52e56 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -849,7 +849,7 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st if forced == "nous": client, model = _try_nous() if client is None: - logger.warning("auxiliary.provider=nous but Nous Portal not configured (run: hermes login)") + logger.warning("auxiliary.provider=nous but Nous Portal not configured (run: hermes auth)") return client, model if forced == "codex": @@ -1119,7 +1119,7 @@ def resolve_provider_client( client, default = _try_nous() if client is None: logger.warning("resolve_provider_client: nous requested " - "but Nous Portal not configured (run: hermes login)") + "but Nous Portal not configured (run: hermes auth)") return None, None final_model = model or default return (_to_async_client(client, final_model) if async_mode diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index bfbeb8182530..fd65246a3346 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -936,7 +936,7 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: state = _load_provider_state(auth_store, "openai-codex") if not state: raise AuthError( - "No Codex credentials stored. Run `hermes login` to authenticate.", + "No Codex credentials stored. Run `hermes auth` to authenticate.", provider="openai-codex", code="codex_auth_missing", relogin_required=True, @@ -944,7 +944,7 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: tokens = state.get("tokens") if not isinstance(tokens, dict): raise AuthError( - "Codex auth state is missing tokens. Run `hermes login` to re-authenticate.", + "Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.", provider="openai-codex", code="codex_auth_invalid_shape", relogin_required=True, @@ -953,14 +953,14 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: refresh_token = tokens.get("refresh_token") if not isinstance(access_token, str) or not access_token.strip(): raise AuthError( - "Codex auth is missing access_token. Run `hermes login` to re-authenticate.", + "Codex auth is missing access_token. Run `hermes auth` to re-authenticate.", provider="openai-codex", code="codex_auth_missing_access_token", relogin_required=True, ) if not isinstance(refresh_token, str) or not refresh_token.strip(): raise AuthError( - "Codex auth is missing refresh_token. Run `hermes login` to re-authenticate.", + "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", provider="openai-codex", code="codex_auth_missing_refresh_token", relogin_required=True, @@ -995,7 +995,7 @@ def refresh_codex_oauth_pure( del access_token # Access token is only used by callers to decide whether to refresh. if not isinstance(refresh_token, str) or not refresh_token.strip(): raise AuthError( - "Codex auth is missing refresh_token. Run `hermes login` to re-authenticate.", + "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.", provider="openai-codex", code="codex_auth_missing_refresh_token", relogin_required=True, @@ -1035,7 +1035,7 @@ def refresh_codex_oauth_pure( "Codex refresh token was already consumed by another client " "(e.g. Codex CLI or VS Code extension). " "Run `codex` in your terminal to generate fresh tokens, " - "then run `hermes login --provider openai-codex` to re-authenticate." + "then run `hermes auth` to re-authenticate." ) relogin_required = True raise AuthError( @@ -1140,7 +1140,7 @@ def resolve_codex_runtime_credentials( logger.info("Migrating Codex credentials from ~/.codex/ to Hermes auth store") print("⚠️ Migrating Codex credentials to Hermes's own auth store.") print(" This avoids conflicts with Codex CLI and VS Code.") - print(" Run `hermes login` to create a fully independent session.\n") + print(" Run `hermes auth` to create a fully independent session.\n") _save_codex_tokens(cli_tokens) data = _read_codex_tokens() else: @@ -2096,7 +2096,7 @@ def detect_external_credentials() -> List[Dict[str, Any]]: found.append({ "provider": "openai-codex", "path": str(codex_path), - "label": f"Codex CLI credentials found ({codex_path}) — run `hermes login` to create a separate session", + "label": f"Codex CLI credentials found ({codex_path}) — run `hermes auth` to create a separate session", }) return found @@ -2345,8 +2345,8 @@ def _save_model_choice(model_id: str) -> None: def login_command(args) -> None: """Deprecated: use 'hermes model' or 'hermes setup' instead.""" print("The 'hermes login' command has been removed.") - print("Use 'hermes model' to select a provider and model,") - print("or 'hermes setup' for full interactive setup.") + print("Use 'hermes auth' to manage credentials,") + print("'hermes model' to select a provider, or 'hermes setup' for full setup.") raise SystemExit(0) diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 1564c1000e88..395dbb76c79d 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -305,6 +305,32 @@ def auth_remove_command(args) -> None: if cleared: print(f"Cleared {env_var} from .env") + # If this was a singleton-seeded credential (OAuth device_code, hermes_pkce), + # clear the underlying auth store / credential file so it doesn't get + # re-seeded on the next load_pool() call. + elif removed.source == "device_code" and provider in ("openai-codex", "nous"): + from hermes_cli.auth import ( + _load_auth_store, _save_auth_store, _auth_store_lock, + ) + with _auth_store_lock(): + auth_store = _load_auth_store() + providers_dict = auth_store.get("providers") + if isinstance(providers_dict, dict) and provider in providers_dict: + del providers_dict[provider] + _save_auth_store(auth_store) + print(f"Cleared {provider} OAuth tokens from auth store") + + elif removed.source == "hermes_pkce" and provider == "anthropic": + from hermes_constants import get_hermes_home + oauth_file = get_hermes_home() / ".anthropic_oauth.json" + if oauth_file.exists(): + oauth_file.unlink() + print("Cleared Hermes Anthropic OAuth credentials") + + elif removed.source == "claude_code" and provider == "anthropic": + print("Note: Claude Code credentials live in ~/.claude/.credentials.json") + print(" Remove them manually if you want to deauthorize Claude Code.") + def auth_reset_command(args) -> None: provider = _normalize_provider(getattr(args, "provider", "")) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bf0b27c205f7..8863bda59332 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1932,8 +1932,8 @@ def load_config() -> Dict[str, Any]: # # Supported providers: # openrouter (OPENROUTER_API_KEY) — routes to any model -# openai-codex (OAuth — hermes login) — OpenAI Codex -# nous (OAuth — hermes login) — Nous Portal +# openai-codex (OAuth — hermes auth) — OpenAI Codex +# nous (OAuth — hermes auth) — Nous Portal # zai (ZAI_API_KEY) — Z.AI / GLM # kimi-coding (KIMI_API_KEY) — Kimi / Moonshot # minimax (MINIMAX_API_KEY) — MiniMax @@ -1975,8 +1975,8 @@ def load_config() -> Dict[str, Any]: # # Supported providers: # openrouter (OPENROUTER_API_KEY) — routes to any model -# openai-codex (OAuth — hermes login) — OpenAI Codex -# nous (OAuth — hermes login) — Nous Portal +# openai-codex (OAuth — hermes auth) — OpenAI Codex +# nous (OAuth — hermes auth) — Nous Portal # zai (ZAI_API_KEY) — Z.AI / GLM # kimi-coding (KIMI_API_KEY) — Kimi / Moonshot # minimax (MINIMAX_API_KEY) — MiniMax diff --git a/run_agent.py b/run_agent.py index d97e08ad555b..ffe94774e57b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8191,7 +8191,7 @@ def _stop_spinner(): self._vprint(f"{self.log_prefix} 💡 Codex OAuth token was rejected (HTTP 401). Your token may have been", force=True) self._vprint(f"{self.log_prefix} refreshed by another client (Codex CLI, VS Code). To fix:", force=True) self._vprint(f"{self.log_prefix} 1. Run `codex` in your terminal to generate fresh tokens.", force=True) - self._vprint(f"{self.log_prefix} 2. Then run `hermes login --provider openai-codex` to re-authenticate.", force=True) + self._vprint(f"{self.log_prefix} 2. Then run `hermes auth` to re-authenticate.", force=True) else: self._vprint(f"{self.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True) self._vprint(f"{self.log_prefix} • Is the key valid? Run: hermes setup", force=True) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 71a78ea664da..28ffc795a991 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -701,7 +701,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: if not api_key: raise ValueError( f"Delegation provider '{configured_provider}' resolved but has no API key. " - f"Set the appropriate environment variable or run 'hermes login'." + f"Set the appropriate environment variable or run 'hermes auth'." ) return { diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index b2df9bca8d85..5fbe921b5ebb 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -37,8 +37,8 @@ hermes [global-options] [subcommand/options] | `hermes gateway` | Run or manage the messaging gateway service. | | `hermes setup` | Interactive setup wizard for all or part of the configuration. | | `hermes whatsapp` | Configure and pair the WhatsApp bridge. | -| `hermes login` / `logout` | Authenticate with OAuth-backed providers. | -| `hermes auth` | Manage credential pools — add, list, remove, reset, set strategy. | +| `hermes auth` | Manage credentials — add, list, remove, reset, set strategy. Handles OAuth flows for Codex/Nous/Anthropic. | +| `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. | | `hermes status` | Show agent, auth, and platform status. | | `hermes cron` | Inspect and tick the cron scheduler. | | `hermes webhook` | Manage dynamic webhook subscriptions for event-driven activation. | @@ -178,22 +178,11 @@ hermes whatsapp Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairing. -## `hermes login` / `hermes logout` +## `hermes login` / `hermes logout` *(Deprecated)* -```bash -hermes login [--provider nous|openai-codex] [--portal-url ...] [--inference-url ...] -hermes logout [--provider nous|openai-codex] -``` - -`login` supports: -- Nous Portal OAuth/device flow -- OpenAI Codex OAuth/device flow - -Useful options for `login`: -- `--no-browser` -- `--timeout ` -- `--ca-bundle ` -- `--insecure` +:::caution +`hermes login` has been removed. Use `hermes auth` to manage OAuth credentials, `hermes model` to select a provider, or `hermes setup` for full interactive setup. +::: ## `hermes auth` diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 7148b423d3d5..063329084ffe 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -651,7 +651,7 @@ AUXILIARY_VISION_MODEL=openai/gpt-4o |----------|-------------|-------------| | `"auto"` | Best available (default). Vision tries OpenRouter → Nous → Codex. | — | | `"openrouter"` | Force OpenRouter — routes to any model (Gemini, GPT-4o, Claude, etc.) | `OPENROUTER_API_KEY` | -| `"nous"` | Force Nous Portal | `hermes login` | +| `"nous"` | Force Nous Portal | `hermes auth` | | `"codex"` | Force Codex OAuth (ChatGPT account). Supports vision (gpt-5.3-codex). | `hermes model` → Codex | | `"main"` | Use your active custom/main endpoint. This can come from `OPENAI_BASE_URL` + `OPENAI_API_KEY` or from a custom endpoint saved via `hermes model` / `config.yaml`. Works with OpenAI, local models, or any OpenAI-compatible API. | Custom endpoint credentials + base URL | diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index a5cdc5bac13b..8868162e8372 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -37,7 +37,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback |----------|-------|-------------| | AI Gateway | `ai-gateway` | `AI_GATEWAY_API_KEY` | | OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | -| Nous Portal | `nous` | `hermes login` (OAuth) | +| Nous Portal | `nous` | `hermes auth` (OAuth) | | OpenAI Codex | `openai-codex` | `hermes model` (ChatGPT OAuth) | | GitHub Copilot | `copilot` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` | | GitHub Copilot ACP | `copilot-acp` | External process (editor integration) | @@ -244,7 +244,7 @@ All three — auxiliary, compression, fallback — work the same way: set `provi |----------|-------------|-------------| | `"auto"` | Try providers in order until one works (default) | At least one provider configured | | `"openrouter"` | Force OpenRouter | `OPENROUTER_API_KEY` | -| `"nous"` | Force Nous Portal | `hermes login` | +| `"nous"` | Force Nous Portal | `hermes auth` | | `"codex"` | Force Codex OAuth | `hermes model` → Codex | | `"main"` | Use whatever provider the main agent uses | Active main provider configured | | `"anthropic"` | Force Anthropic native | `ANTHROPIC_API_KEY` or Claude Code credentials | From dc4c07ed9d1653919bd8f6201662326de062196a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:10:33 -0700 Subject: [PATCH 178/213] fix: codex OAuth credential pool disconnect + expired token import (#5681) Three bugs causing OpenAI Codex sessions to fail silently: 1. Credential pool vs legacy store disconnect: hermes auth and hermes model store device_code tokens in the credential pool, but get_codex_auth_status(), resolve_codex_runtime_credentials(), and _model_flow_openai_codex() only read from the legacy provider state. Fresh pool tokens were invisible to the auth status checks and model selection flow. 2. _import_codex_cli_tokens() imported expired tokens from ~/.codex/ without checking JWT expiry. Combined with _login_openai_codex() saying 'Login successful!' for expired credentials, users got stuck in a loop of dead tokens being recycled. 3. _login_openai_codex() accepted expired tokens from resolve_codex_runtime_credentials() without validating expiry before telling the user login succeeded. Fixes: - get_codex_auth_status() now checks credential pool first, falls back to legacy provider state - _model_flow_openai_codex() uses pool-aware auth status for token retrieval when fetching model lists - _import_codex_cli_tokens() validates JWT exp claim, rejects expired - _login_openai_codex() verifies resolved token isn't expiring before accepting existing credentials - _run_codex_stream() logs response.incomplete/failed terminal events with status and incomplete_details for diagnostics - Codex empty output recovery: captures streamed text during streaming and synthesizes a response when get_final_response() returns empty output (handles chatgpt.com backend-api edge cases) --- hermes_cli/auth.py | 77 +++++++++++++++++++++++++++++++++++++--------- hermes_cli/main.py | 15 +++++++-- run_agent.py | 63 +++++++++++++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 19 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index fd65246a3346..588d06d40920 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1099,7 +1099,8 @@ def _refresh_codex_auth_tokens( def _import_codex_cli_tokens() -> Optional[Dict[str, str]]: """Try to read tokens from ~/.codex/auth.json (Codex CLI shared file). - Returns tokens dict if valid, None otherwise. Does NOT write to the shared file. + Returns tokens dict if valid and not expired, None otherwise. + Does NOT write to the shared file. """ codex_home = os.getenv("CODEX_HOME", "").strip() if not codex_home: @@ -1112,7 +1113,17 @@ def _import_codex_cli_tokens() -> Optional[Dict[str, str]]: tokens = payload.get("tokens") if not isinstance(tokens, dict): return None - if not tokens.get("access_token") or not tokens.get("refresh_token"): + access_token = tokens.get("access_token") + refresh_token = tokens.get("refresh_token") + if not access_token or not refresh_token: + return None + # Reject expired tokens — importing stale tokens from ~/.codex/ + # that can't be refreshed leaves the user stuck with "Login successful!" + # but no working credentials. + if _codex_access_token_is_expiring(access_token, 0): + logger.debug( + "Codex CLI tokens at %s are expired — skipping import.", auth_path, + ) return None return dict(tokens) except Exception: @@ -1904,7 +1915,36 @@ def get_nous_auth_status() -> Dict[str, Any]: def get_codex_auth_status() -> Dict[str, Any]: - """Status snapshot for Codex auth.""" + """Status snapshot for Codex auth. + + Checks the credential pool first (where `hermes auth` stores credentials), + then falls back to the legacy provider state. + """ + # Check credential pool first — this is where `hermes auth` and + # `hermes model` store device_code tokens. + try: + from agent.credential_pool import load_pool + pool = load_pool("openai-codex") + if pool and pool.has_credentials(): + entry = pool.select() + if entry is not None: + api_key = ( + getattr(entry, "runtime_api_key", None) + or getattr(entry, "access_token", "") + ) + if api_key and not _codex_access_token_is_expiring(api_key, 0): + return { + "logged_in": True, + "auth_store": str(_auth_file_path()), + "last_refresh": getattr(entry, "last_refresh", None), + "auth_mode": "chatgpt", + "source": f"pool:{getattr(entry, 'label', 'unknown')}", + "api_key": api_key, + } + except Exception: + pass + + # Fall back to legacy provider state try: creds = resolve_codex_runtime_credentials() return { @@ -1913,6 +1953,7 @@ def get_codex_auth_status() -> Dict[str, Any]: "last_refresh": creds.get("last_refresh"), "auth_mode": creds.get("auth_mode"), "source": creds.get("source"), + "api_key": creds.get("api_key"), } except AuthError as exc: return { @@ -2356,17 +2397,25 @@ def _login_openai_codex(args, pconfig: ProviderConfig) -> None: # Check for existing Hermes-owned credentials try: existing = resolve_codex_runtime_credentials() - print("Existing Codex credentials found in Hermes auth store.") - try: - reuse = input("Use existing credentials? [Y/n]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - reuse = "y" - if reuse in ("", "y", "yes"): - config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL)) - print() - print("Login successful!") - print(f" Config updated: {config_path} (model.provider=openai-codex)") - return + # Verify the resolved token is actually usable (not expired). + # resolve_codex_runtime_credentials attempts refresh, so if we get + # here the token should be valid — but double-check before telling + # the user "Login successful!". + _resolved_key = existing.get("api_key", "") + if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60): + print("Existing Codex credentials found in Hermes auth store.") + try: + reuse = input("Use existing credentials? [Y/n]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + reuse = "y" + if reuse in ("", "y", "yes"): + config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL)) + print() + print("Login successful!") + print(f" Config updated: {config_path} (model.provider=openai-codex)") + return + else: + print("Existing Codex credentials are expired. Starting fresh login...") except AuthError: pass diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 205cef8d4772..1a968952aee4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1294,12 +1294,21 @@ def _model_flow_openai_codex(config, current_model=""): return _codex_token = None + # Prefer credential pool (where `hermes auth` stores device_code tokens), + # fall back to legacy provider state. try: - from hermes_cli.auth import resolve_codex_runtime_credentials - _codex_creds = resolve_codex_runtime_credentials() - _codex_token = _codex_creds.get("api_key") + _codex_status = get_codex_auth_status() + if _codex_status.get("logged_in"): + _codex_token = _codex_status.get("api_key") except Exception: pass + if not _codex_token: + try: + from hermes_cli.auth import resolve_codex_runtime_credentials + _codex_creds = resolve_codex_runtime_credentials() + _codex_token = _codex_creds.get("api_key") + except Exception: + pass codex_models = get_codex_model_ids(access_token=_codex_token) diff --git a/run_agent.py b/run_agent.py index ffe94774e57b..95926ff807eb 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3868,6 +3868,10 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta has_tool_calls = False first_delta_fired = False self._reasoning_deltas_fired = False + # Accumulate streamed text so we can recover if get_final_response() + # returns empty output (e.g. chatgpt.com backend-api sends + # response.incomplete instead of response.completed). + self._codex_streamed_text_parts: list = [] for attempt in range(max_stream_retries + 1): try: with active_client.responses.stream(**api_kwargs) as stream: @@ -3887,6 +3891,7 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta except Exception: pass self._fire_stream_delta(delta_text) + self._codex_streamed_text_parts.append(delta_text) # Track tool calls to suppress text streaming elif "function_call" in event_type: has_tool_calls = True @@ -3895,6 +3900,18 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta reasoning_text = getattr(event, "delta", "") if reasoning_text: self._fire_reasoning_delta(reasoning_text) + # Log non-completed terminal events for diagnostics + elif event_type in ("response.incomplete", "response.failed"): + resp_obj = getattr(event, "response", None) + status = getattr(resp_obj, "status", None) if resp_obj else None + incomplete_details = getattr(resp_obj, "incomplete_details", None) if resp_obj else None + logger.warning( + "Codex Responses stream received terminal event %s " + "(status=%s, incomplete_details=%s, streamed_chars=%d). %s", + event_type, status, incomplete_details, + sum(len(p) for p in self._codex_streamed_text_parts), + self._client_log_context(), + ) return stream.get_final_response() except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: if attempt < max_stream_retries: @@ -7366,8 +7383,50 @@ def _stop_spinner(): response_invalid = True error_details.append("response.output is not a list") elif len(output_items) == 0: - response_invalid = True - error_details.append("response.output is empty") + # Log diagnostics for empty output + _resp_status = getattr(response, "status", None) + _resp_incomplete = getattr(response, "incomplete_details", None) + _streamed_parts = getattr(self, "_codex_streamed_text_parts", []) + _streamed_text = "".join(_streamed_parts).strip() if _streamed_parts else "" + logging.warning( + "Codex response.output is empty " + "(status=%s, incomplete_details=%s, streamed_chars=%d, " + "output_text=%r, model=%s). %s", + _resp_status, _resp_incomplete, len(_streamed_text), + getattr(response, "output_text", None), + getattr(response, "model", None), + f"api_mode={self.api_mode} provider={self.provider}", + ) + # Recovery: if we streamed text but the final response + # lost it (e.g. response.incomplete from chatgpt backend-api), + # synthesize a minimal response so the user gets the answer + # the model already delivered. + if _streamed_text: + logging.info( + "Recovering %d chars of streamed text as response " + "(status was %s).", len(_streamed_text), _resp_status, + ) + response = SimpleNamespace( + output=[SimpleNamespace( + type="message", + role="assistant", + status="completed", + content=[SimpleNamespace( + type="output_text", + text=_streamed_text, + )], + )], + status=_resp_status or "completed", + model=getattr(response, "model", self.model), + usage=getattr(response, "usage", None), + id=getattr(response, "id", None), + output_text=_streamed_text, + ) + # Clear the accumulated parts so we don't double-recover + self._codex_streamed_text_parts = [] + else: + response_invalid = True + error_details.append("response.output is empty") elif self.api_mode == "anthropic_messages": content_blocks = getattr(response, "content", None) if response is not None else None if response is None: From e5aaa38ca7295ca02309c926e3b6896ecd17e138 Mon Sep 17 00:00:00 2001 From: Grateful Dave Date: Mon, 6 Apr 2026 21:16:56 -0400 Subject: [PATCH 179/213] fix: sync openai-codex pool entry from ~/.codex/auth.json on exhaustion (#5610) OpenAI OAuth refresh tokens are single-use and rotate on every refresh. When the Codex CLI (or another Hermes profile) refreshes its token, the pool entry's refresh_token becomes stale. Subsequent refresh attempts fail with invalid_grant, and the entry enters a 24-hour exhaustion cooldown with no recovery path. This mirrors the existing _sync_anthropic_entry_from_credentials_file() pattern: when an openai-codex entry is exhausted, compare its refresh_token against ~/.codex/auth.json and sync the fresh pair if they differ. Fixes the common scenario where users run 'codex login' to refresh their token externally and Hermes never picks it up. Co-authored-by: David Andrews (LexGenius.ai) --- agent/credential_pool.py | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 740fc59d4ae8..472f65f2d129 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -23,6 +23,7 @@ _agent_key_is_usable, _codex_access_token_is_expiring, _decode_jwt_claims, + _import_codex_cli_tokens, _is_expiring, _load_auth_store, _load_provider_state, @@ -440,6 +441,39 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) - logger.debug("Failed to sync from credentials file: %s", exc) return entry + def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential: + """Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ. + + OpenAI OAuth refresh tokens are single-use and rotate on every refresh. + When the Codex CLI (or another Hermes profile) refreshes its token, + the pool entry's refresh_token becomes stale. This method detects that + by comparing against ~/.codex/auth.json and syncing the fresh pair. + """ + if self.provider != "openai-codex": + return entry + try: + cli_tokens = _import_codex_cli_tokens() + if not cli_tokens: + return entry + cli_refresh = cli_tokens.get("refresh_token", "") + cli_access = cli_tokens.get("access_token", "") + if cli_refresh and cli_refresh != entry.refresh_token: + logger.debug("Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)", entry.id) + updated = replace( + entry, + access_token=cli_access, + refresh_token=cli_refresh, + last_status=None, + last_status_at=None, + last_error_code=None, + ) + self._replace_entry(entry, updated) + self._persist() + return updated + except Exception as exc: + logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc) + return entry + def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]: if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token: if force: @@ -629,6 +663,16 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal if synced is not entry: entry = synced cleared_any = True + # For openai-codex entries, sync from ~/.codex/auth.json before + # any status/refresh checks. This picks up tokens refreshed by + # the Codex CLI or another Hermes profile. + if (self.provider == "openai-codex" + and entry.last_status == STATUS_EXHAUSTED + and entry.refresh_token): + synced = self._sync_codex_entry_from_cli(entry) + if synced is not entry: + entry = synced + cleared_any = True if entry.last_status == STATUS_EXHAUSTED: exhausted_until = _exhausted_until(entry) if exhausted_until is not None and now < exhausted_until: From 0e336b0e717027cbb81fcb5816246b7aec2d4a47 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:19:30 -0700 Subject: [PATCH 180/213] fix: backfill codex stream output from output_item.done events (#5689) Salvages the core fix from PR #5673 (egerev) onto current main. The chatgpt.com/backend-api/codex endpoint streams valid output items via response.output_item.done events, but the OpenAI SDK's get_final_response() returns an empty output list. This caused every Codex response to be rejected as invalid. Fix: collect output_item.done events during streaming and backfill response.output when get_final_response() returns empty. Falls back to synthesizing from text deltas when no done events were received. Also moves the synthesis logic from the validation loop (too late, from #5681) into _run_codex_stream() (before the response leaves the streaming function), and simplifies the validation to just log diagnostics since recovery now happens upstream. Co-authored-by: Egor --- run_agent.py | 84 +++++++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/run_agent.py b/run_agent.py index 95926ff807eb..f5af556bbcbc 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3873,6 +3873,7 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta # response.incomplete instead of response.completed). self._codex_streamed_text_parts: list = [] for attempt in range(max_stream_retries + 1): + collected_output_items: list = [] try: with active_client.responses.stream(**api_kwargs) as stream: for event in stream: @@ -3882,6 +3883,8 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta # Fire callbacks on text content deltas (suppress during tool calls) if "output_text.delta" in event_type or event_type == "response.output_text.delta": delta_text = getattr(event, "delta", "") + if delta_text: + self._codex_streamed_text_parts.append(delta_text) if delta_text and not has_tool_calls: if not first_delta_fired: first_delta_fired = True @@ -3891,7 +3894,6 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta except Exception: pass self._fire_stream_delta(delta_text) - self._codex_streamed_text_parts.append(delta_text) # Track tool calls to suppress text streaming elif "function_call" in event_type: has_tool_calls = True @@ -3900,6 +3902,14 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta reasoning_text = getattr(event, "delta", "") if reasoning_text: self._fire_reasoning_delta(reasoning_text) + # Collect completed output items — some backends + # (chatgpt.com/backend-api/codex) stream valid items + # via response.output_item.done but the SDK's + # get_final_response() returns an empty output list. + elif event_type == "response.output_item.done": + done_item = getattr(event, "item", None) + if done_item is not None: + collected_output_items.append(done_item) # Log non-completed terminal events for diagnostics elif event_type in ("response.incomplete", "response.failed"): resp_obj = getattr(event, "response", None) @@ -3912,7 +3922,31 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta sum(len(p) for p in self._codex_streamed_text_parts), self._client_log_context(), ) - return stream.get_final_response() + final_response = stream.get_final_response() + # PATCH: ChatGPT Codex backend streams valid output items + # but get_final_response() can return an empty output list. + # Backfill from collected items or synthesize from deltas. + _out = getattr(final_response, "output", None) + if isinstance(_out, list) and not _out: + if collected_output_items: + final_response.output = list(collected_output_items) + logger.debug( + "Codex stream: backfilled %d output items from stream events", + len(collected_output_items), + ) + elif self._codex_streamed_text_parts and not has_tool_calls: + assembled = "".join(self._codex_streamed_text_parts) + final_response.output = [SimpleNamespace( + type="message", + role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text=assembled)], + )] + logger.debug( + "Codex stream: synthesized output from %d text deltas (%d chars)", + len(self._codex_streamed_text_parts), len(assembled), + ) + return final_response except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: if attempt < max_stream_retries: logger.debug( @@ -7383,50 +7417,20 @@ def _stop_spinner(): response_invalid = True error_details.append("response.output is not a list") elif len(output_items) == 0: - # Log diagnostics for empty output + # If we reach here, _run_codex_stream's backfill + # from output_item.done events and text-delta + # synthesis both failed to populate output. _resp_status = getattr(response, "status", None) _resp_incomplete = getattr(response, "incomplete_details", None) - _streamed_parts = getattr(self, "_codex_streamed_text_parts", []) - _streamed_text = "".join(_streamed_parts).strip() if _streamed_parts else "" logging.warning( - "Codex response.output is empty " - "(status=%s, incomplete_details=%s, streamed_chars=%d, " - "output_text=%r, model=%s). %s", - _resp_status, _resp_incomplete, len(_streamed_text), - getattr(response, "output_text", None), + "Codex response.output is empty after stream backfill " + "(status=%s, incomplete_details=%s, model=%s). %s", + _resp_status, _resp_incomplete, getattr(response, "model", None), f"api_mode={self.api_mode} provider={self.provider}", ) - # Recovery: if we streamed text but the final response - # lost it (e.g. response.incomplete from chatgpt backend-api), - # synthesize a minimal response so the user gets the answer - # the model already delivered. - if _streamed_text: - logging.info( - "Recovering %d chars of streamed text as response " - "(status was %s).", len(_streamed_text), _resp_status, - ) - response = SimpleNamespace( - output=[SimpleNamespace( - type="message", - role="assistant", - status="completed", - content=[SimpleNamespace( - type="output_text", - text=_streamed_text, - )], - )], - status=_resp_status or "completed", - model=getattr(response, "model", self.model), - usage=getattr(response, "usage", None), - id=getattr(response, "id", None), - output_text=_streamed_text, - ) - # Clear the accumulated parts so we don't double-recover - self._codex_streamed_text_parts = [] - else: - response_invalid = True - error_details.append("response.output is empty") + response_invalid = True + error_details.append("response.output is empty") elif self.api_mode == "anthropic_messages": content_blocks = getattr(response, "content", None) if response is not None else None if response is None: From 2021442c8a5cc982cf787e829de064c00ccc8a3d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:58:47 -0700 Subject: [PATCH 181/213] fix: cover remaining codex empty-output gaps in fallback + normalizer (#5724) Two gaps in the codex empty-output handling: 1. _run_codex_create_stream_fallback() skipped all non-terminal events, so when the fallback path was used and the terminal response had empty output, there was no recovery. Now collects output_item.done and text deltas during the fallback stream, backfills on empty output. 2. _normalize_codex_response() hard-crashed with RuntimeError when output was empty, even when the response had output_text set. The function already had fallback logic at line 3562 to use output_text, but the guard at line 3446 killed it first. Now checks output_text before raising and synthesizes a minimal output item. --- run_agent.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index f5af556bbcbc..cc0e06bdcd91 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3444,7 +3444,22 @@ def _normalize_codex_response(self, response: Any) -> tuple[Any, str]: """Normalize a Responses API object to an assistant_message-like object.""" output = getattr(response, "output", None) if not isinstance(output, list) or not output: - raise RuntimeError("Responses API returned no output items") + # The Codex backend can return empty output when the answer was + # delivered entirely via stream events. Check output_text as a + # last-resort fallback before raising. + out_text = getattr(response, "output_text", None) + if isinstance(out_text, str) and out_text.strip(): + logger.debug( + "Codex response has empty output but output_text is present (%d chars); " + "synthesizing output item.", len(out_text.strip()), + ) + output = [SimpleNamespace( + type="message", role="assistant", status="completed", + content=[SimpleNamespace(type="output_text", text=out_text.strip())], + )] + response.output = output + else: + raise RuntimeError("Responses API returned no output items") response_status = getattr(response, "status", None) if isinstance(response_status, str): @@ -3997,11 +4012,28 @@ def _run_codex_create_stream_fallback(self, api_kwargs: dict, client: Any = None return stream_or_response terminal_response = None + collected_output_items: list = [] + collected_text_deltas: list = [] try: for event in stream_or_response: event_type = getattr(event, "type", None) if not event_type and isinstance(event, dict): event_type = event.get("type") + + # Collect output items and text deltas for backfill + if event_type == "response.output_item.done": + done_item = getattr(event, "item", None) + if done_item is None and isinstance(event, dict): + done_item = event.get("item") + if done_item is not None: + collected_output_items.append(done_item) + elif event_type in ("response.output_text.delta",): + delta = getattr(event, "delta", "") + if not delta and isinstance(event, dict): + delta = event.get("delta", "") + if delta: + collected_text_deltas.append(delta) + if event_type not in {"response.completed", "response.incomplete", "response.failed"}: continue @@ -4009,6 +4041,26 @@ def _run_codex_create_stream_fallback(self, api_kwargs: dict, client: Any = None if terminal_response is None and isinstance(event, dict): terminal_response = event.get("response") if terminal_response is not None: + # Backfill empty output from collected stream events + _out = getattr(terminal_response, "output", None) + if isinstance(_out, list) and not _out: + if collected_output_items: + terminal_response.output = list(collected_output_items) + logger.debug( + "Codex fallback stream: backfilled %d output items", + len(collected_output_items), + ) + elif collected_text_deltas: + assembled = "".join(collected_text_deltas) + terminal_response.output = [SimpleNamespace( + type="message", role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text=assembled)], + )] + logger.debug( + "Codex fallback stream: synthesized from %d deltas (%d chars)", + len(collected_text_deltas), len(assembled), + ) return terminal_response finally: close_fn = getattr(stream_or_response, "close", None) From 21b48b2ff552b42d8df11272b8c7436bcf6e0b7f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:13:22 -0700 Subject: [PATCH 182/213] fix: backfill empty codex output in auxiliary client (#5730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _CodexCompletionsAdapter (used for compression, vision, web_extract, session_search, and memory flush when on the codex provider) streamed responses but discarded all events with 'for _event in stream: pass'. When get_final_response() returned empty output (the same chatgpt.com backend-api shape change), auxiliary calls silently returned None content. Now collects response.output_item.done and text deltas during streaming and backfills empty output — same pattern as _run_codex_stream(). Tested live against chatgpt.com/backend-api/codex with OAuth. --- agent/auxiliary_client.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 7cb8f9f52e56..8052d020ed2c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -260,11 +260,44 @@ def create(self, **kwargs) -> Any: usage = None try: + # Collect output items and text deltas during streaming — + # the Codex backend can return empty response.output from + # get_final_response() even when items were streamed. + collected_output_items: List[Any] = [] + collected_text_deltas: List[str] = [] with self._client.responses.stream(**resp_kwargs) as stream: for _event in stream: - pass + _etype = getattr(_event, "type", "") + if _etype == "response.output_item.done": + _done = getattr(_event, "item", None) + if _done is not None: + collected_output_items.append(_done) + elif "output_text.delta" in _etype: + _delta = getattr(_event, "delta", "") + if _delta: + collected_text_deltas.append(_delta) final = stream.get_final_response() + # Backfill empty output from collected stream events + _output = getattr(final, "output", None) + if isinstance(_output, list) and not _output: + if collected_output_items: + final.output = list(collected_output_items) + logger.debug( + "Codex auxiliary: backfilled %d output items from stream events", + len(collected_output_items), + ) + elif collected_text_deltas: + assembled = "".join(collected_text_deltas) + final.output = [SimpleNamespace( + type="message", role="assistant", status="completed", + content=[SimpleNamespace(type="output_text", text=assembled)], + )] + logger.debug( + "Codex auxiliary: synthesized from %d deltas (%d chars)", + len(collected_text_deltas), len(assembled), + ) + # Extract text and tool calls from the Responses output for item in getattr(final, "output", []): item_type = getattr(item, "type", None) From a23fcae943ca0c022dd626acefb1a84184aba20b Mon Sep 17 00:00:00 2001 From: Matthew Hardwick Date: Mon, 6 Apr 2026 21:04:06 -0700 Subject: [PATCH 183/213] docs: add 'setup' command to docker run example The docker container needs the explicit 'setup' subcommand to launch the setup wizard. Without it, the container starts in default mode. Co-authored-by: Omar Cherry-picked from PR #4896 (also submitted independently as PR #5532). --- website/docs/user-guide/docker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index 2940b8678f0a..c98f4abb4d5d 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -21,7 +21,7 @@ If this is your first time running Hermes Agent, create a data directory on the mkdir -p ~/.hermes docker run -it --rm \ -v ~/.hermes:/opt/data \ - nousresearch/hermes-agent + nousresearch/hermes-agent setup ``` This drops you into the setup wizard, which will prompt you for your API keys and write them to `~/.hermes/.env`. You only need to do this once. It is highly recommended to set up a chat system for the gateway to work with at this point. From bccd7d098c81730714aa004ed8ff8684463cee6a Mon Sep 17 00:00:00 2001 From: Jay Weeldreyer Date: Mon, 6 Apr 2026 21:04:43 -0700 Subject: [PATCH 184/213] docs: add post-update validation guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a concise post-update validation checklist (git status, hermes doctor, version check, gateway status). Adapted from PR #3050 with corrections — removed inaccurate submodule claim (hermes update already handles submodules) and tightened the checklist. Cherry-picked and adapted from PR #3050. --- website/docs/getting-started/updating.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 04abcc40e470..16bb0ce4714a 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -45,6 +45,20 @@ Already up to date. (or: Updating abc1234..def5678) ✅ Hermes Agent updated successfully! ``` +### Recommended Post-Update Validation + +`hermes update` handles the main update path, but a quick validation confirms everything landed cleanly: + +1. `git status --short` — if the tree is unexpectedly dirty, inspect before continuing +2. `hermes doctor` — checks config, dependencies, and service health +3. `hermes --version` — confirm the version bumped as expected +4. If you use the gateway: `hermes gateway status` +5. If `doctor` reports npm audit issues: run `npm audit fix` in the flagged directory + +:::warning Dirty working tree after update +If `git status --short` shows unexpected changes after `hermes update`, stop and inspect them before continuing. This usually means local modifications were reapplied on top of the updated code, or a dependency step refreshed lockfiles. +::: + ### Checking your current version ```bash From 9ce8d59470b63556714d97ccfabc674f2c36b645 Mon Sep 17 00:00:00 2001 From: OmniWired Date: Mon, 6 Apr 2026 21:05:32 -0700 Subject: [PATCH 185/213] docs: add local LLM on Mac guide (llama.cpp + MLX) Comprehensive guide covering: - llama.cpp and MLX (omlx) setup on Apple Silicon - Model selection and memory optimization (quantized KV cache) - Real benchmarks on M5 Max comparing both backends - Hermes connection instructions Cherry-picked from PR #2590. --- website/docs/guides/local-llm-on-mac.md | 219 ++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 website/docs/guides/local-llm-on-mac.md diff --git a/website/docs/guides/local-llm-on-mac.md b/website/docs/guides/local-llm-on-mac.md new file mode 100644 index 000000000000..eaa8b238230f --- /dev/null +++ b/website/docs/guides/local-llm-on-mac.md @@ -0,0 +1,219 @@ +--- +sidebar_position: 8 +title: "Run Local LLMs on Mac" +description: "Set up a local OpenAI-compatible LLM server on macOS with llama.cpp or MLX, including model selection, memory optimization, and real benchmarks on Apple Silicon" +--- + +# Run Local LLMs on Mac + +This guide walks you through running a local LLM server on macOS with an OpenAI-compatible API. You get full privacy, zero API costs, and surprisingly good performance on Apple Silicon. + +We cover two backends: + +| Backend | Install | Best at | Format | +|---------|---------|---------|--------| +| **llama.cpp** | `brew install llama.cpp` | Fastest time-to-first-token, quantized KV cache for low memory | GGUF | +| **omlx** | [omlx.ai](https://omlx.ai) | Fastest token generation, native Metal optimization | MLX (safetensors) | + +Both expose an OpenAI-compatible `/v1/chat/completions` endpoint. Hermes works with either one — just point it at `http://localhost:8080` or `http://localhost:8000`. + +:::info Apple Silicon only +This guide targets Macs with Apple Silicon (M1 and later). Intel Macs will work with llama.cpp but without GPU acceleration — expect significantly slower performance. +::: + +--- + +## Choosing a model + +For getting started, we recommend **Qwen3.5-9B** — it's a strong reasoning model that fits comfortably in 8GB+ of unified memory with quantization. + +| Variant | Size on disk | RAM needed (128K context) | Backend | +|---------|-------------|---------------------------|---------| +| Qwen3.5-9B-Q4_K_M (GGUF) | 5.3 GB | ~10 GB with quantized KV cache | llama.cpp | +| Qwen3.5-9B-mlx-lm-mxfp4 (MLX) | ~5 GB | ~12 GB | omlx | + +**Memory rule of thumb:** model size + KV cache. A 9B Q4 model is ~5 GB. The KV cache at 128K context with Q4 quantization adds ~4-5 GB. With default (f16) KV cache, that balloons to ~16 GB. The quantized KV cache flags in llama.cpp are the key trick for memory-constrained systems. + +For larger models (27B, 35B), you'll need 32 GB+ of unified memory. The 9B is the sweet spot for 8-16 GB machines. + +--- + +## Option A: llama.cpp + +llama.cpp is the most portable local LLM runtime. On macOS it uses Metal for GPU acceleration out of the box. + +### Install + +```bash +brew install llama.cpp +``` + +This gives you the `llama-server` command globally. + +### Download the model + +You need a GGUF-format model. The easiest source is Hugging Face via the `huggingface-cli`: + +```bash +brew install huggingface-cli +``` + +Then download: + +```bash +huggingface-cli download unsloth/Qwen3.5-9B-GGUF Qwen3.5-9B-Q4_K_M.gguf --local-dir ~/models +``` + +:::tip Gated models +Some models on Hugging Face require authentication. Run `huggingface-cli login` first if you get a 401 or 404 error. +::: + +### Start the server + +```bash +llama-server -m ~/models/Qwen3.5-9B-Q4_K_M.gguf \ + -ngl 99 \ + -c 131072 \ + -np 1 \ + -fa on \ + --cache-type-k q4_0 \ + --cache-type-v q4_0 \ + --host 0.0.0.0 +``` + +Here's what each flag does: + +| Flag | Purpose | +|------|---------| +| `-ngl 99` | Offload all layers to GPU (Metal). Use a high number to ensure nothing stays on CPU. | +| `-c 131072` | Context window size (128K tokens). Reduce this if you're low on memory. | +| `-np 1` | Number of parallel slots. Keep at 1 for single-user use — more slots split your memory budget. | +| `-fa on` | Flash attention. Reduces memory usage and speeds up long-context inference. | +| `--cache-type-k q4_0` | Quantize the key cache to 4-bit. **This is the big memory saver.** | +| `--cache-type-v q4_0` | Quantize the value cache to 4-bit. Together with the above, this cuts KV cache memory by ~75% vs f16. | +| `--host 0.0.0.0` | Listen on all interfaces. Use `127.0.0.1` if you don't need network access. | + +The server is ready when you see: + +``` +main: server is listening on http://0.0.0.0:8080 +srv update_slots: all slots are idle +``` + +### Memory optimization for constrained systems + +The `--cache-type-k q4_0 --cache-type-v q4_0` flags are the most important optimization for systems with limited memory. Here's the impact at 128K context: + +| KV cache type | KV cache memory (128K ctx, 9B model) | +|---------------|--------------------------------------| +| f16 (default) | ~16 GB | +| q8_0 | ~8 GB | +| **q4_0** | **~4 GB** | + +On an 8 GB Mac, use `q4_0` KV cache and reduce context to `-c 32768` (32K). On 16 GB, you can comfortably do 128K context. On 32 GB+, you can run larger models or multiple parallel slots. + +If you're still running out of memory, reduce context size first (`-c`), then try a smaller quantization (Q3_K_M instead of Q4_K_M). + +### Test it + +```bash +curl -s http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "Qwen3.5-9B-Q4_K_M.gguf", + "messages": [{"role": "user", "content": "Hello!"}], + "max_tokens": 50 + }' | jq .choices[0].message.content +``` + +### Get the model name + +If you forget the model name, query the models endpoint: + +```bash +curl -s http://localhost:8080/v1/models | jq '.data[].id' +``` + +--- + +## Option B: MLX via omlx + +[omlx](https://omlx.ai) is a macOS-native app that manages and serves MLX models. MLX is Apple's own machine learning framework, optimized specifically for Apple Silicon's unified memory architecture. + +### Install + +Download and install from [omlx.ai](https://omlx.ai). It provides a GUI for model management and a built-in server. + +### Download the model + +Use the omlx app to browse and download models. Search for `Qwen3.5-9B-mlx-lm-mxfp4` and download it. Models are stored locally (typically in `~/.omlx/models/`). + +### Start the server + +omlx serves models on `http://127.0.0.1:8000` by default. Start serving from the app UI, or use the CLI if available. + +### Test it + +```bash +curl -s http://127.0.0.1:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "Qwen3.5-9B-mlx-lm-mxfp4", + "messages": [{"role": "user", "content": "Hello!"}], + "max_tokens": 50 + }' | jq .choices[0].message.content +``` + +### List available models + +omlx can serve multiple models simultaneously: + +```bash +curl -s http://127.0.0.1:8000/v1/models | jq '.data[].id' +``` + +--- + +## Benchmarks: llama.cpp vs MLX + +Both backends tested on the same machine (Apple M5 Max, 128 GB unified memory) running the same model (Qwen3.5-9B) at comparable quantization levels (Q4_K_M for GGUF, mxfp4 for MLX). Five diverse prompts, three runs each, backends tested sequentially to avoid resource contention. + +### Results + +| Metric | llama.cpp (Q4_K_M) | MLX (mxfp4) | Winner | +|--------|-------------------|-------------|--------| +| **TTFT (avg)** | **67 ms** | 289 ms | llama.cpp (4.3x faster) | +| **TTFT (p50)** | **66 ms** | 286 ms | llama.cpp (4.3x faster) | +| **Generation (avg)** | 70 tok/s | **96 tok/s** | MLX (37% faster) | +| **Generation (p50)** | 70 tok/s | **96 tok/s** | MLX (37% faster) | +| **Total time (512 tokens)** | 7.3s | **5.5s** | MLX (25% faster) | + +### What this means + +- **llama.cpp** excels at prompt processing — its flash attention + quantized KV cache pipeline gets you the first token in ~66ms. If you're building interactive applications where perceived responsiveness matters (chatbots, autocomplete), this is a meaningful advantage. + +- **MLX** generates tokens ~37% faster once it gets going. For batch workloads, long-form generation, or any task where total completion time matters more than initial latency, MLX finishes sooner. + +- Both backends are **extremely consistent** — variance across runs was negligible. You can rely on these numbers. + +### Which one should you pick? + +| Use case | Recommendation | +|----------|---------------| +| Interactive chat, low-latency tools | llama.cpp | +| Long-form generation, bulk processing | MLX (omlx) | +| Memory-constrained (8-16 GB) | llama.cpp (quantized KV cache is unmatched) | +| Serving multiple models simultaneously | omlx (built-in multi-model support) | +| Maximum compatibility (Linux too) | llama.cpp | + +--- + +## Connect to Hermes + +Once your local server is running: + +```bash +hermes model +``` + +Select **Custom endpoint** and follow the prompts. It will ask for the base URL and model name — use the values from whichever backend you set up above. From 43cf68055b688fcfe3b4d7c8689b3abf57de588a Mon Sep 17 00:00:00 2001 From: Andrian Date: Mon, 6 Apr 2026 21:08:28 -0700 Subject: [PATCH 186/213] docs: fix signal-cli install instructions signal-cli is not available via apt or snap. Replace the incorrect 'sudo apt install signal-cli' with the official install method: downloading from GitHub releases (Linux) or brew (macOS). Updated both signal.md docs and the gateway.py setup hint. Inspired by PR #4225 (which proposed snap, also incorrect). --- hermes_cli/gateway.py | 3 +-- website/docs/user-guide/messaging/signal.md | 16 ++++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c99761d5c6a5..4a12a34bb0e1 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1803,8 +1803,7 @@ def _setup_signal(): print_warning("signal-cli not found on PATH.") print_info(" Signal requires signal-cli running as an HTTP daemon.") print_info(" Install options:") - print_info(" Linux: sudo apt install signal-cli") - print_info(" or download from https://github.com/AsamK/signal-cli") + print_info(" Linux: download from https://github.com/AsamK/signal-cli/releases") print_info(" macOS: brew install signal-cli") print_info(" Docker: bbernhard/signal-cli-rest-api") print() diff --git a/website/docs/user-guide/messaging/signal.md b/website/docs/user-guide/messaging/signal.md index d47b7ca034b1..3fc9eba6ae81 100644 --- a/website/docs/user-guide/messaging/signal.md +++ b/website/docs/user-guide/messaging/signal.md @@ -25,17 +25,21 @@ The Signal adapter uses `httpx` (already a core Hermes dependency) for all commu ### Installing signal-cli ```bash -# Linux (Debian/Ubuntu) -sudo apt install signal-cli - # macOS brew install signal-cli -# Manual install (any platform) -# Download from https://github.com/AsamK/signal-cli/releases -# Extract and add to PATH +# Linux (download latest release) +VERSION=$(curl -Ls -o /dev/null -w %{url_effective} \ + https://github.com/AsamK/signal-cli/releases/latest | sed 's/^.*\/v//') +curl -L -O "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}.tar.gz" +sudo tar xf "signal-cli-${VERSION}.tar.gz" -C /opt +sudo ln -sf "/opt/signal-cli-${VERSION}/bin/signal-cli" /usr/local/bin/ ``` +:::caution +signal-cli is **not** in apt or snap repositories. The Linux install above downloads directly from [GitHub releases](https://github.com/AsamK/signal-cli/releases). +::: + --- ## Step 1: Link Your Signal Account From 9b6e5f6a04996a936d08ad0b3f3ccfd6cc3e9384 Mon Sep 17 00:00:00 2001 From: eizus Date: Mon, 6 Apr 2026 11:59:54 -0400 Subject: [PATCH 187/213] fix(gateway): Apply markdown-to-mrkdwn conversion in edit_message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit_message method was sending raw content directly to Slack's chat_update API without converting standard markdown to Slack's mrkdwn format. This caused broken formatting and malformed URLs (e.g., trailing ** from bold syntax became part of clickable links → 404 errors). The send() method already calls format_message() to handle this conversion, but edit_message() was bypassing it. This change ensures edited messages receive the same markdown → mrkdwn transformation as new messages. Closes: PR #5558 formatting issue where links had trailing markdown syntax. --- gateway/platforms/slack.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 2e7bbee739ba..b4bf5ffd73a9 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -276,10 +276,13 @@ async def edit_message( if not self._app: return SendResult(success=False, error="Not connected") try: + # Convert standard markdown → Slack mrkdwn + formatted = self.format_message(content) + await self._get_client(chat_id).chat_update( channel=chat_id, ts=message_id, - text=content, + text=formatted, ) return SendResult(success=True, message_id=message_id) except Exception as e: # pragma: no cover - defensive logging From 4ec615b0c245d5dc4011cac6490ada5916efcdd9 Mon Sep 17 00:00:00 2001 From: eizus Date: Mon, 6 Apr 2026 11:46:17 -0400 Subject: [PATCH 188/213] feat(gateway): Enable Slack thread replies without explicit @mentions When a user replies in a Slack thread where the bot has an active conversation session, the bot now processes the message even without an explicit @mention. This improves UX for ongoing threaded discussions. Changes: - Added set_session_store() to BasePlatformAdapter for adapters to check active sessions - Modified SlackAdapter to detect thread replies and check if a session exists for that thread before requiring @mentions - Updated GatewayRunner to inject the session store into adapters - Added comprehensive tests for the new behavior Fixes: Thread replies without @jarvis are now processed if there is an active session, matching user expectations for conversation flow --- gateway/platforms/base.py | 10 +++ gateway/platforms/slack.py | 85 +++++++++++++++++++++- gateway/run.py | 2 + tests/gateway/test_slack.py | 141 ++++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 0ba00d890177..4335a51f1129 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -569,6 +569,16 @@ def set_message_handler(self, handler: MessageHandler) -> None: """ self._message_handler = handler + def set_session_store(self, session_store: Any) -> None: + """ + Set the session store for checking active sessions. + + Used by adapters that need to check if a thread/conversation + has an active session before processing messages (e.g., Slack + thread replies without explicit mentions). + """ + self._session_store = session_store + @abstractmethod async def connect(self) -> bool: """ diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index b4bf5ffd73a9..384f379d768a 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -766,11 +766,28 @@ async def _handle_slack_message(self, event: dict) -> None: else: thread_ts = event.get("thread_ts") or ts # ts fallback for channels - # In channels, only respond if bot is mentioned + # In channels, only respond if bot is mentioned OR if this is a + # reply in a thread where the bot has an active session. bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) - if not is_dm and bot_uid: - if f"<@{bot_uid}>" not in text: + is_mentioned = bot_uid and f"<@{bot_uid}>" in text + + if not is_dm and bot_uid and not is_mentioned: + # Check if this is a thread reply (thread_ts exists and differs from ts) + event_thread_ts = event.get("thread_ts") + is_thread_reply = event_thread_ts and event_thread_ts != ts + + if is_thread_reply and self._has_active_session_for_thread( + channel_id=channel_id, + thread_ts=event_thread_ts, + user_id=user_id, + ): + # Allow thread replies without mention if there's an active session + pass + else: + # Not a thread reply or no active session - ignore return + + if is_mentioned: # Strip the bot mention from the text text = text.replace(f"<@{bot_uid}>", "").strip() @@ -936,6 +953,68 @@ async def _handle_slash_command(self, command: dict) -> None: await self.handle_message(event) + def _has_active_session_for_thread( + self, + channel_id: str, + thread_ts: str, + user_id: str, + ) -> bool: + """Check if there's an active session for a thread. + + Used to determine if thread replies without @mentions should be + processed (they should if there's an active session). + + Args: + channel_id: The Slack channel ID + thread_ts: The thread timestamp (parent message ts) + user_id: The user ID of the sender + + Returns: + True if there's an active session for this thread + """ + session_store = getattr(self, "_session_store", None) + if not session_store: + return False + + try: + # Build a SessionSource for this thread + from gateway.session import SessionSource + from gateway.config import Platform + + source = SessionSource( + platform=Platform.SLACK, + chat_id=channel_id, + chat_type="group", + user_id=user_id, + thread_id=thread_ts, + ) + + # Generate the session key using the same logic as SessionStore + # This mirrors the logic in build_session_key for group sessions + key_parts = ["agent:main", "slack", "group", channel_id, thread_ts] + + # Include user_id if group_sessions_per_user is enabled + # We check the session store config if available + group_sessions_per_user = getattr( + session_store, "config", {} + ) + if hasattr(group_sessions_per_user, "group_sessions_per_user"): + group_sessions_per_user = group_sessions_per_user.group_sessions_per_user + else: + group_sessions_per_user = True # Default + + if group_sessions_per_user and user_id: + key_parts.append(str(user_id)) + + session_key = ":".join(key_parts) + + # Check if the session exists in the store + session_store._ensure_loaded() + return session_key in session_store._entries + except Exception: + # If anything goes wrong, default to False (require mention) + return False + async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: """Download a Slack file using the bot token for auth, with retry.""" import asyncio diff --git a/gateway/run.py b/gateway/run.py index 82cb10b4e343..9d5ac5aa2c63 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1127,6 +1127,7 @@ async def start(self) -> bool: # Set up message + fatal error handlers adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) + adapter.set_session_store(self.session_store) # Try to connect logger.info("Connecting to %s...", platform.value) @@ -1424,6 +1425,7 @@ async def _platform_reconnect_watcher(self) -> None: adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) + adapter.set_session_store(self.session_store) success = await adapter.connect() if success: diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 81f8077ad6b3..89b447183443 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -699,6 +699,147 @@ async def test_reactions_in_message_flow(self, adapter): assert remove_calls[0].kwargs["name"] == "eyes" +# --------------------------------------------------------------------------- +# TestThreadReplyHandling +# --------------------------------------------------------------------------- + + +class TestThreadReplyHandling: + """Test thread reply processing without explicit bot mentions.""" + + @pytest.fixture() + def mock_session_store(self): + """Create a mock session store with entries dict.""" + store = MagicMock() + store._entries = {} + store._ensure_loaded = MagicMock() + store.config = MagicMock() + store.config.group_sessions_per_user = True + return store + + @pytest.fixture() + def adapter_with_session_store(self, mock_session_store): + """Create an adapter with a mock session store attached.""" + config = PlatformConfig(enabled=True, token="***") + a = SlackAdapter(config) + a._app = MagicMock() + a._app.client = AsyncMock() + a._bot_user_id = "U_BOT" + a._team_bot_user_ids = {"T_TEAM": "U_BOT"} + a._running = True + a.handle_message = AsyncMock() + a.set_session_store(mock_session_store) + return a + + @pytest.mark.asyncio + async def test_thread_reply_without_mention_no_session_ignored( + self, adapter_with_session_store, mock_session_store + ): + """Thread replies without mention should be ignored if no active session.""" + mock_session_store._entries = {} # No active sessions + + event = { + "text": "Just replying in the thread", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", # Different from ts - this is a reply + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_thread_reply_without_mention_with_session_processed( + self, adapter_with_session_store, mock_session_store + ): + """Thread replies without mention should be processed if there's an active session.""" + # Simulate an active session for this thread + session_key = "agent:main:slack:group:C123:123.000:U_USER" + mock_session_store._entries = {session_key: MagicMock()} + + event = { + "text": "Follow-up question", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", # Reply in thread 123.000 + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_called_once() + + # Verify the text is passed through unchanged (no mention stripping needed) + msg_event = adapter_with_session_store.handle_message.call_args[0][0] + assert msg_event.text == "Follow-up question" + + @pytest.mark.asyncio + async def test_thread_reply_with_mention_strips_bot_id( + self, adapter_with_session_store, mock_session_store + ): + """Thread replies with @mention should still strip the bot ID.""" + # Even with a session, mentions should be stripped + session_key = "agent:main:slack:group:C123:123.000:U_USER" + mock_session_store._entries = {session_key: MagicMock()} + + event = { + "text": "<@U_BOT> thanks for the help", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_called_once() + + msg_event = adapter_with_session_store.handle_message.call_args[0][0] + assert "<@U_BOT>" not in msg_event.text + assert msg_event.text == "thanks for the help" + + @pytest.mark.asyncio + async def test_top_level_message_requires_mention_even_with_session( + self, adapter_with_session_store, mock_session_store + ): + """Top-level channel messages should require mention even if session exists.""" + # Session exists but this is a top-level message (no thread_ts) + session_key = "agent:main:slack:group:C123:123.000:U_USER" + mock_session_store._entries = {session_key: MagicMock()} + + event = { + "text": "New question without mention", + "user": "U_USER", + "channel": "C123", + "ts": "456.789", + # No thread_ts - this is a top-level message + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter_with_session_store._handle_slack_message(event) + adapter_with_session_store.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_no_session_store_ignores_thread_replies( + self, adapter + ): + """If no session store is attached, thread replies without mention should be ignored.""" + # adapter fixture has no session store attached + event = { + "text": "Thread reply without mention", + "user": "U_USER", + "channel": "C123", + "ts": "123.456", + "thread_ts": "123.000", + "channel_type": "channel", + "team": "T_TEAM", + } + await adapter._handle_slack_message(event) + adapter.handle_message.assert_not_called() + + # --------------------------------------------------------------------------- # TestUserNameResolution # --------------------------------------------------------------------------- From 888dc1e68079e50ca8ee148a29623decb85c99b3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:35:33 -0700 Subject: [PATCH 189/213] =?UTF-8?q?fix:=20harden=20auxiliary=20codex=20ada?= =?UTF-8?q?pter=20=E2=80=94=20dict-shaped=20items=20+=20tool=20call=20guar?= =?UTF-8?q?d=20(#5734)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two remaining gaps from the codex empty-output spec: 1. Normalize dict-shaped streamed items: output_item.done events may yield dicts (raw/fallback paths) instead of SDK objects. The extraction loop now uses _item_get() that handles both getattr and dict .get() access. 2. Avoid plain-text synthesis when function_call events were streamed: tracks has_function_calls during streaming and skips text-delta synthesis when tool calls are present — prevents collapsing a tool-call response into a fake text message. --- agent/auxiliary_client.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 8052d020ed2c..9edc505e39b8 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -265,6 +265,7 @@ def create(self, **kwargs) -> Any: # get_final_response() even when items were streamed. collected_output_items: List[Any] = [] collected_text_deltas: List[str] = [] + has_function_calls = False with self._client.responses.stream(**resp_kwargs) as stream: for _event in stream: _etype = getattr(_event, "type", "") @@ -276,6 +277,8 @@ def create(self, **kwargs) -> Any: _delta = getattr(_event, "delta", "") if _delta: collected_text_deltas.append(_delta) + elif "function_call" in _etype: + has_function_calls = True final = stream.get_final_response() # Backfill empty output from collected stream events @@ -287,7 +290,10 @@ def create(self, **kwargs) -> Any: "Codex auxiliary: backfilled %d output items from stream events", len(collected_output_items), ) - elif collected_text_deltas: + elif collected_text_deltas and not has_function_calls: + # Only synthesize text when no tool calls were streamed — + # a function_call response with incidental text should not + # be collapsed into a plain-text message. assembled = "".join(collected_text_deltas) final.output = [SimpleNamespace( type="message", role="assistant", status="completed", @@ -298,21 +304,29 @@ def create(self, **kwargs) -> Any: len(collected_text_deltas), len(assembled), ) - # Extract text and tool calls from the Responses output + # Extract text and tool calls from the Responses output. + # Items may be SDK objects (attrs) or dicts (raw/fallback paths), + # so use a helper that handles both shapes. + def _item_get(obj: Any, key: str, default: Any = None) -> Any: + val = getattr(obj, key, None) + if val is None and isinstance(obj, dict): + val = obj.get(key, default) + return val if val is not None else default + for item in getattr(final, "output", []): - item_type = getattr(item, "type", None) + item_type = _item_get(item, "type") if item_type == "message": - for part in getattr(item, "content", []): - ptype = getattr(part, "type", None) + for part in (_item_get(item, "content") or []): + ptype = _item_get(part, "type") if ptype in ("output_text", "text"): - text_parts.append(getattr(part, "text", "")) + text_parts.append(_item_get(part, "text", "")) elif item_type == "function_call": tool_calls_raw.append(SimpleNamespace( - id=getattr(item, "call_id", ""), + id=_item_get(item, "call_id", ""), type="function", function=SimpleNamespace( - name=getattr(item, "name", ""), - arguments=getattr(item, "arguments", "{}"), + name=_item_get(item, "name", ""), + arguments=_item_get(item, "arguments", "{}"), ), )) From 972482e28e36d81ed84026538c9a882d5fc218c5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:02:47 -0700 Subject: [PATCH 190/213] =?UTF-8?q?docs:=20guides=20section=20overhaul=20?= =?UTF-8?q?=E2=80=94=20fix=20existing=20+=20add=203=20new=20tutorials=20(#?= =?UTF-8?q?5735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: fix guides section — sidebar ordering, broken links, position conflicts - Add local-llm-on-mac.md to sidebars.ts (was missing after salvage PR) - Reorder sidebar: tips first, then local LLM guide, then tutorials - Fix 10 broken links in team-telegram-assistant.md (missing /docs/ prefix) - Fix relative link in migrate-from-openclaw.md - Fix installation link pointing to learning-path instead of installation - Renumber all sidebar_position values to eliminate conflicts and match the explicit sidebars.ts ordering * docs: add 3 new guides — cron automation, skills, delegation New tutorial-style guides covering core features: - automate-with-cron.md (261 lines): 5 real-world patterns — website monitoring with scripts, weekly reports, GitHub watchers, data collection pipelines, multi-skill workflows. Covers [SILENT] trick, delivery targets, job management. - work-with-skills.md (268 lines): End-to-end skill workflow — finding, installing from Hub, configuring, creating from scratch with reference files, per-platform management, skills vs memory comparison. - delegation-patterns.md (239 lines): 5 patterns — parallel research, code review, alternative comparison, multi-file refactoring, gather-then-analyze (execute_code + delegate). Covers the context problem, toolset selection, constraints. Added all three to sidebars.ts in the Guides & Tutorials section. --- website/docs/guides/automate-with-cron.md | 261 +++++++++++++++++ website/docs/guides/build-a-hermes-plugin.md | 2 +- website/docs/guides/daily-briefing-bot.md | 2 +- website/docs/guides/delegation-patterns.md | 239 ++++++++++++++++ website/docs/guides/local-llm-on-mac.md | 2 +- website/docs/guides/migrate-from-openclaw.md | 4 +- website/docs/guides/python-library.md | 2 +- .../docs/guides/team-telegram-assistant.md | 18 +- website/docs/guides/use-mcp-with-hermes.md | 2 +- website/docs/guides/use-soul-with-hermes.md | 2 +- .../docs/guides/use-voice-mode-with-hermes.md | 2 +- website/docs/guides/work-with-skills.md | 268 ++++++++++++++++++ website/sidebars.ts | 6 +- 13 files changed, 791 insertions(+), 19 deletions(-) create mode 100644 website/docs/guides/automate-with-cron.md create mode 100644 website/docs/guides/delegation-patterns.md create mode 100644 website/docs/guides/work-with-skills.md diff --git a/website/docs/guides/automate-with-cron.md b/website/docs/guides/automate-with-cron.md new file mode 100644 index 000000000000..fba8a08284d1 --- /dev/null +++ b/website/docs/guides/automate-with-cron.md @@ -0,0 +1,261 @@ +--- +sidebar_position: 11 +title: "Automate Anything with Cron" +description: "Real-world automation patterns using Hermes cron — monitoring, reports, pipelines, and multi-skill workflows" +--- + +# Automate Anything with Cron + +The [daily briefing bot tutorial](/docs/guides/daily-briefing-bot) covers the basics. This guide goes further — five real-world automation patterns you can adapt for your own workflows. + +For the full feature reference, see [Scheduled Tasks (Cron)](/docs/user-guide/features/cron). + +:::info Key Concept +Cron jobs run in fresh agent sessions with no memory of your current chat. Prompts must be **completely self-contained** — include everything the agent needs to know. +::: + +--- + +## Pattern 1: Website Change Monitor + +Watch a URL for changes and get notified only when something is different. + +The `script` parameter is the secret weapon here. A Python script runs before each execution, and its stdout becomes context for the agent. The script handles the mechanical work (fetching, diffing); the agent handles the reasoning (is this change interesting?). + +Create the monitoring script: + +```bash +mkdir -p ~/.hermes/scripts +``` + +```python title="~/.hermes/scripts/watch-site.py" +import hashlib, json, os, urllib.request + +URL = "https://example.com/pricing" +STATE_FILE = os.path.expanduser("~/.hermes/scripts/.watch-site-state.json") + +# Fetch current content +req = urllib.request.Request(URL, headers={"User-Agent": "Hermes-Monitor/1.0"}) +content = urllib.request.urlopen(req, timeout=30).read().decode() +current_hash = hashlib.sha256(content.encode()).hexdigest() + +# Load previous state +prev_hash = None +if os.path.exists(STATE_FILE): + with open(STATE_FILE) as f: + prev_hash = json.load(f).get("hash") + +# Save current state +with open(STATE_FILE, "w") as f: + json.dump({"hash": current_hash, "url": URL}, f) + +# Output for the agent +if prev_hash and prev_hash != current_hash: + print(f"CHANGE DETECTED on {URL}") + print(f"Previous hash: {prev_hash}") + print(f"Current hash: {current_hash}") + print(f"\nCurrent content (first 2000 chars):\n{content[:2000]}") +else: + print("NO_CHANGE") +``` + +Set up the cron job: + +```bash +/cron add "every 1h" "If the script output says CHANGE DETECTED, summarize what changed on the page and why it might matter. If it says NO_CHANGE, respond with just [SILENT]." --script ~/.hermes/scripts/watch-site.py --name "Pricing monitor" --deliver telegram +``` + +:::tip The [SILENT] Trick +When the agent's final response contains `[SILENT]`, delivery is suppressed. This means you only get notified when something actually happens — no spam on quiet hours. +::: + +--- + +## Pattern 2: Weekly Report + +Compile information from multiple sources into a formatted summary. This runs once a week and delivers to your home channel. + +```bash +/cron add "0 9 * * 1" "Generate a weekly report covering: + +1. Search the web for the top 5 AI news stories from the past week +2. Search GitHub for trending repositories in the 'machine-learning' topic +3. Check Hacker News for the most discussed AI/ML posts + +Format as a clean summary with sections for each source. Include links. +Keep it under 500 words — highlight only what matters." --name "Weekly AI digest" --deliver telegram +``` + +From the CLI: + +```bash +hermes cron create "0 9 * * 1" \ + "Generate a weekly report covering the top AI news, trending ML GitHub repos, and most-discussed HN posts. Format with sections, include links, keep under 500 words." \ + --name "Weekly AI digest" \ + --deliver telegram +``` + +The `0 9 * * 1` is a standard cron expression: 9:00 AM every Monday. + +--- + +## Pattern 3: GitHub Repository Watcher + +Monitor a repository for new issues, PRs, or releases. + +```bash +/cron add "every 6h" "Check the GitHub repository NousResearch/hermes-agent for: +- New issues opened in the last 6 hours +- New PRs opened or merged in the last 6 hours +- Any new releases + +Use the terminal to run gh commands: + gh issue list --repo NousResearch/hermes-agent --state open --json number,title,author,createdAt --limit 10 + gh pr list --repo NousResearch/hermes-agent --state all --json number,title,author,createdAt,mergedAt --limit 10 + +Filter to only items from the last 6 hours. If nothing new, respond with [SILENT]. +Otherwise, provide a concise summary of the activity." --name "Repo watcher" --deliver discord +``` + +:::warning Self-Contained Prompts +Notice how the prompt includes the exact `gh` commands. The cron agent has no memory of previous runs or your preferences — spell everything out. +::: + +--- + +## Pattern 4: Data Collection Pipeline + +Scrape data at regular intervals, save to files, and detect trends over time. This pattern combines a script (for collection) with the agent (for analysis). + +```python title="~/.hermes/scripts/collect-prices.py" +import json, os, urllib.request +from datetime import datetime + +DATA_DIR = os.path.expanduser("~/.hermes/data/prices") +os.makedirs(DATA_DIR, exist_ok=True) + +# Fetch current data (example: crypto prices) +url = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd" +data = json.loads(urllib.request.urlopen(url, timeout=30).read()) + +# Append to history file +entry = {"timestamp": datetime.now().isoformat(), "prices": data} +history_file = os.path.join(DATA_DIR, "history.jsonl") +with open(history_file, "a") as f: + f.write(json.dumps(entry) + "\n") + +# Load recent history for analysis +lines = open(history_file).readlines() +recent = [json.loads(l) for l in lines[-24:]] # Last 24 data points + +# Output for the agent +print(f"Current: BTC=${data['bitcoin']['usd']}, ETH=${data['ethereum']['usd']}") +print(f"Data points collected: {len(lines)} total, showing last {len(recent)}") +print(f"\nRecent history:") +for r in recent[-6:]: + print(f" {r['timestamp']}: BTC=${r['prices']['bitcoin']['usd']}, ETH=${r['prices']['ethereum']['usd']}") +``` + +```bash +/cron add "every 1h" "Analyze the price data from the script output. Report: +1. Current prices +2. Trend direction over the last 6 data points (up/down/flat) +3. Any notable movements (>5% change) + +If prices are flat and nothing notable, respond with [SILENT]. +If there's a significant move, explain what happened." \ + --script ~/.hermes/scripts/collect-prices.py \ + --name "Price tracker" \ + --deliver telegram +``` + +The script does the mechanical collection; the agent adds the reasoning layer. + +--- + +## Pattern 5: Multi-Skill Workflow + +Chain skills together for complex scheduled tasks. Skills are loaded in order before the prompt executes. + +```bash +# Use the arxiv skill to find papers, then the obsidian skill to save notes +/cron add "0 8 * * *" "Search arXiv for the 3 most interesting papers on 'language model reasoning' from the past day. For each paper, create an Obsidian note with the title, authors, abstract summary, and key contribution." \ + --skill arxiv \ + --skill obsidian \ + --name "Paper digest" +``` + +From the tool directly: + +```python +cronjob( + action="create", + skills=["arxiv", "obsidian"], + prompt="Search arXiv for papers on 'language model reasoning' from the past day. Save the top 3 as Obsidian notes.", + schedule="0 8 * * *", + name="Paper digest", + deliver="local" +) +``` + +Skills are loaded in order — `arxiv` first (teaches the agent how to search papers), then `obsidian` (teaches how to write notes). The prompt ties them together. + +--- + +## Managing Your Jobs + +```bash +# List all active jobs +/cron list + +# Trigger a job immediately (for testing) +/cron run + +# Pause a job without deleting it +/cron pause + +# Edit a running job's schedule or prompt +/cron edit --schedule "every 4h" +/cron edit --prompt "Updated task description" + +# Add or remove skills from an existing job +/cron edit --skill arxiv --skill obsidian +/cron edit --clear-skills + +# Remove a job permanently +/cron remove +``` + +--- + +## Delivery Targets + +The `--deliver` flag controls where results go: + +| Target | Example | Use case | +|--------|---------|----------| +| `origin` | `--deliver origin` | Same chat that created the job (default) | +| `local` | `--deliver local` | Save to local file only | +| `telegram` | `--deliver telegram` | Your Telegram home channel | +| `discord` | `--deliver discord` | Your Discord home channel | +| `slack` | `--deliver slack` | Your Slack home channel | +| Specific chat | `--deliver telegram:-1001234567890` | A specific Telegram group | +| Threaded | `--deliver telegram:-1001234567890:17585` | A specific Telegram topic thread | + +--- + +## Tips + +**Make prompts self-contained.** The agent in a cron job has no memory of your conversations. Include URLs, repo names, format preferences, and delivery instructions directly in the prompt. + +**Use `[SILENT]` liberally.** For monitoring jobs, always include instructions like "if nothing changed, respond with `[SILENT]`." This prevents notification noise. + +**Use scripts for data collection.** The `script` parameter lets a Python script handle the boring parts (HTTP requests, file I/O, state tracking). The agent only sees the script's stdout and applies reasoning to it. This is cheaper and more reliable than having the agent do the fetching itself. + +**Test with `/cron run`.** Before waiting for the schedule to trigger, use `/cron run ` to execute immediately and verify the output looks right. + +**Schedule expressions.** Human-readable formats like `every 2h`, `30m`, and `daily at 9am` all work alongside standard cron expressions like `0 9 * * *`. + +--- + +*For the complete cron reference — all parameters, edge cases, and internals — see [Scheduled Tasks (Cron)](/docs/user-guide/features/cron).* diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index e0a7f662c261..91fb62f3a0d8 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 sidebar_label: "Build a Plugin" title: "Build a Hermes Plugin" description: "Step-by-step guide to building a complete Hermes plugin with tools, hooks, data files, and skills" diff --git a/website/docs/guides/daily-briefing-bot.md b/website/docs/guides/daily-briefing-bot.md index 78bfd6909bff..dc1ac445690d 100644 --- a/website/docs/guides/daily-briefing-bot.md +++ b/website/docs/guides/daily-briefing-bot.md @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 3 title: "Tutorial: Daily Briefing Bot" description: "Build an automated daily briefing bot that researches topics, summarizes findings, and delivers them to Telegram or Discord every morning" --- diff --git a/website/docs/guides/delegation-patterns.md b/website/docs/guides/delegation-patterns.md new file mode 100644 index 000000000000..e2eaa05cb7ef --- /dev/null +++ b/website/docs/guides/delegation-patterns.md @@ -0,0 +1,239 @@ +--- +sidebar_position: 13 +title: "Delegation & Parallel Work" +description: "When and how to use subagent delegation — patterns for parallel research, code review, and multi-file work" +--- + +# Delegation & Parallel Work + +Hermes can spawn isolated child agents to work on tasks in parallel. Each subagent gets its own conversation, terminal session, and toolset. Only the final summary comes back — intermediate tool calls never enter your context window. + +For the full feature reference, see [Subagent Delegation](/docs/user-guide/features/delegation). + +--- + +## When to Delegate + +**Good candidates for delegation:** +- Reasoning-heavy subtasks (debugging, code review, research synthesis) +- Tasks that would flood your context with intermediate data +- Parallel independent workstreams (research A and B simultaneously) +- Fresh-context tasks where you want the agent to approach without bias + +**Use something else:** +- Single tool call → just use the tool directly +- Mechanical multi-step work with logic between steps → `execute_code` +- Tasks needing user interaction → subagents can't use `clarify` +- Quick file edits → do them directly + +--- + +## Pattern: Parallel Research + +Research three topics simultaneously and get structured summaries back: + +``` +Research these three topics in parallel: +1. Current state of WebAssembly outside the browser +2. RISC-V server chip adoption in 2025 +3. Practical quantum computing applications + +Focus on recent developments and key players. +``` + +Behind the scenes, Hermes uses: + +```python +delegate_task(tasks=[ + { + "goal": "Research WebAssembly outside the browser in 2025", + "context": "Focus on: runtimes (Wasmtime, Wasmer), cloud/edge use cases, WASI progress", + "toolsets": ["web"] + }, + { + "goal": "Research RISC-V server chip adoption", + "context": "Focus on: server chips shipping, cloud providers adopting, software ecosystem", + "toolsets": ["web"] + }, + { + "goal": "Research practical quantum computing applications", + "context": "Focus on: error correction breakthroughs, real-world use cases, key companies", + "toolsets": ["web"] + } +]) +``` + +All three run concurrently. Each subagent searches the web independently and returns a summary. The parent agent then synthesizes them into a coherent briefing. + +--- + +## Pattern: Code Review + +Delegate a security review to a fresh-context subagent that approaches the code without preconceptions: + +``` +Review the authentication module at src/auth/ for security issues. +Check for SQL injection, JWT validation problems, password handling, +and session management. Fix anything you find and run the tests. +``` + +The key is the `context` field — it must include everything the subagent needs: + +```python +delegate_task( + goal="Review src/auth/ for security issues and fix any found", + context="""Project at /home/user/webapp. Python 3.11, Flask, PyJWT, bcrypt. + Auth files: src/auth/login.py, src/auth/jwt.py, src/auth/middleware.py + Test command: pytest tests/auth/ -v + Focus on: SQL injection, JWT validation, password hashing, session management. + Fix issues found and verify tests pass.""", + toolsets=["terminal", "file"] +) +``` + +:::warning The Context Problem +Subagents know **absolutely nothing** about your conversation. They start completely fresh. If you delegate "fix the bug we were discussing," the subagent has no idea what bug you mean. Always pass file paths, error messages, project structure, and constraints explicitly. +::: + +--- + +## Pattern: Compare Alternatives + +Evaluate multiple approaches to the same problem in parallel, then pick the best: + +``` +I need to add full-text search to our Django app. Evaluate three approaches +in parallel: +1. PostgreSQL tsvector (built-in) +2. Elasticsearch via django-elasticsearch-dsl +3. Meilisearch via meilisearch-python + +For each: setup complexity, query capabilities, resource requirements, +and maintenance overhead. Compare them and recommend one. +``` + +Each subagent researches one option independently. Because they're isolated, there's no cross-contamination — each evaluation stands on its own merits. The parent agent gets all three summaries and makes the comparison. + +--- + +## Pattern: Multi-File Refactoring + +Split a large refactoring task across parallel subagents, each handling a different part of the codebase: + +```python +delegate_task(tasks=[ + { + "goal": "Refactor all API endpoint handlers to use the new response format", + "context": """Project at /home/user/api-server. + Files: src/handlers/users.py, src/handlers/auth.py, src/handlers/billing.py + Old format: return {"data": result, "status": "ok"} + New format: return APIResponse(data=result, status=200).to_dict() + Import: from src.responses import APIResponse + Run tests after: pytest tests/handlers/ -v""", + "toolsets": ["terminal", "file"] + }, + { + "goal": "Update all client SDK methods to handle the new response format", + "context": """Project at /home/user/api-server. + Files: sdk/python/client.py, sdk/python/models.py + Old parsing: result = response.json()["data"] + New parsing: result = response.json()["data"] (same key, but add status code checking) + Also update sdk/python/tests/test_client.py""", + "toolsets": ["terminal", "file"] + }, + { + "goal": "Update API documentation to reflect the new response format", + "context": """Project at /home/user/api-server. + Docs at: docs/api/. Format: Markdown with code examples. + Update all response examples from old format to new format. + Add a 'Response Format' section to docs/api/overview.md explaining the schema.""", + "toolsets": ["terminal", "file"] + } +]) +``` + +:::tip +Each subagent gets its own terminal session. They can work on the same project directory without stepping on each other — as long as they're editing different files. If two subagents might touch the same file, handle that file yourself after the parallel work completes. +::: + +--- + +## Pattern: Gather Then Analyze + +Use `execute_code` for mechanical data gathering, then delegate the reasoning-heavy analysis: + +```python +# Step 1: Mechanical gathering (execute_code is better here — no reasoning needed) +execute_code(""" +from hermes_tools import web_search, web_extract + +results = [] +for query in ["AI funding Q1 2026", "AI startup acquisitions 2026", "AI IPOs 2026"]: + r = web_search(query, limit=5) + for item in r["data"]["web"]: + results.append({"title": item["title"], "url": item["url"], "desc": item["description"]}) + +# Extract full content from top 5 most relevant +urls = [r["url"] for r in results[:5]] +content = web_extract(urls) + +# Save for the analysis step +import json +with open("/tmp/ai-funding-data.json", "w") as f: + json.dump({"search_results": results, "extracted": content["results"]}, f) +print(f"Collected {len(results)} results, extracted {len(content['results'])} pages") +""") + +# Step 2: Reasoning-heavy analysis (delegation is better here) +delegate_task( + goal="Analyze AI funding data and write a market report", + context="""Raw data at /tmp/ai-funding-data.json contains search results and + extracted web pages about AI funding, acquisitions, and IPOs in Q1 2026. + Write a structured market report: key deals, trends, notable players, + and outlook. Focus on deals over $100M.""", + toolsets=["terminal", "file"] +) +``` + +This is often the most efficient pattern: `execute_code` handles the 10+ sequential tool calls cheaply, then a subagent does the single expensive reasoning task with a clean context. + +--- + +## Toolset Selection + +Choose toolsets based on what the subagent needs: + +| Task type | Toolsets | Why | +|-----------|----------|-----| +| Web research | `["web"]` | web_search + web_extract only | +| Code work | `["terminal", "file"]` | Shell access + file operations | +| Full-stack | `["terminal", "file", "web"]` | Everything except messaging | +| Read-only analysis | `["file"]` | Can only read files, no shell | + +Restricting toolsets keeps the subagent focused and prevents accidental side effects (like a research subagent running shell commands). + +--- + +## Constraints + +- **Max 3 parallel tasks** — batches are capped at 3 concurrent subagents +- **No nesting** — subagents cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code` +- **Separate terminals** — each subagent gets its own terminal session with separate working directory and state +- **No conversation history** — subagents see only what you put in `goal` and `context` +- **Default 50 iterations** — set `max_iterations` lower for simple tasks to save cost + +--- + +## Tips + +**Be specific in goals.** "Fix the bug" is too vague. "Fix the TypeError in api/handlers.py line 47 where process_request() receives None from parse_body()" gives the subagent enough to work with. + +**Include file paths.** Subagents don't know your project structure. Always include absolute paths to relevant files, the project root, and the test command. + +**Use delegation for context isolation.** Sometimes you want a fresh perspective. Delegating forces you to articulate the problem clearly, and the subagent approaches it without the assumptions that built up in your conversation. + +**Check results.** Subagent summaries are just that — summaries. If a subagent says "fixed the bug and tests pass," verify by running the tests yourself or reading the diff. + +--- + +*For the complete delegation reference — all parameters, ACP integration, and advanced configuration — see [Subagent Delegation](/docs/user-guide/features/delegation).* diff --git a/website/docs/guides/local-llm-on-mac.md b/website/docs/guides/local-llm-on-mac.md index eaa8b238230f..e0a82c7ff4b9 100644 --- a/website/docs/guides/local-llm-on-mac.md +++ b/website/docs/guides/local-llm-on-mac.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 2 title: "Run Local LLMs on Mac" description: "Set up a local OpenAI-compatible LLM server on macOS with llama.cpp or MLX, including model selection, memory optimization, and real benchmarks on Apple Silicon" --- diff --git a/website/docs/guides/migrate-from-openclaw.md b/website/docs/guides/migrate-from-openclaw.md index 6c8304a6e43c..88dd752d88a1 100644 --- a/website/docs/guides/migrate-from-openclaw.md +++ b/website/docs/guides/migrate-from-openclaw.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 10 title: "Migrate from OpenClaw" description: "Complete guide to migrating your OpenClaw / Clawdbot setup to Hermes Agent — what gets migrated, how config maps, and what to check after." --- @@ -166,7 +166,7 @@ These are saved to `~/.hermes/migration/openclaw//archive/` for manua | `HEARTBEAT.md` | `archive/workspace/HEARTBEAT.md` | Use cron jobs for periodic tasks | | `BOOTSTRAP.md` | `archive/workspace/BOOTSTRAP.md` | Use context files or skills | | Cron jobs | `archive/cron-config.json` | Recreate with `hermes cron create` | -| Plugins | `archive/plugins-config.json` | See [plugins guide](../user-guide/features/hooks.md) | +| Plugins | `archive/plugins-config.json` | See [plugins guide](/docs/user-guide/features/hooks) | | Hooks/webhooks | `archive/hooks-config.json` | Use `hermes webhook` or gateway hooks | | Memory backend | `archive/memory-backend-config.json` | Configure via `hermes honcho` | | Skills registry | `archive/skills-registry-config.json` | Use `hermes skills config` | diff --git a/website/docs/guides/python-library.md b/website/docs/guides/python-library.md index 5f75f9a0e8e1..3e857f7dd114 100644 --- a/website/docs/guides/python-library.md +++ b/website/docs/guides/python-library.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 title: "Using Hermes as a Python Library" description: "Embed AIAgent in your own Python scripts, web apps, or automation pipelines — no CLI required" --- diff --git a/website/docs/guides/team-telegram-assistant.md b/website/docs/guides/team-telegram-assistant.md index 04350bfab84e..582f2eafa4f1 100644 --- a/website/docs/guides/team-telegram-assistant.md +++ b/website/docs/guides/team-telegram-assistant.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 4 title: "Tutorial: Team Telegram Assistant" description: "Step-by-step guide to setting up a Telegram bot that your whole team can use for code help, research, system admin, and more" --- @@ -24,7 +24,7 @@ A Telegram bot that: Before starting, make sure you have: -- **Hermes Agent installed** on a server or VPS (not your laptop — the bot needs to stay running). Follow the [installation guide](/getting-started/learning-path) if you haven't yet. +- **Hermes Agent installed** on a server or VPS (not your laptop — the bot needs to stay running). Follow the [installation guide](/docs/getting-started/installation) if you haven't yet. - **A Telegram account** for yourself (the bot owner) - **An LLM provider configured** — at minimum, an API key for OpenAI, Anthropic, or another supported provider in `~/.hermes/.env` @@ -428,13 +428,13 @@ hermes gateway stop && hermes gateway start You've got a working team Telegram assistant. Here are some next steps: -- **[Security Guide](/user-guide/security)** — deep dive into authorization, container isolation, and command approval -- **[Messaging Gateway](/user-guide/messaging)** — full reference for gateway architecture, session management, and chat commands -- **[Telegram Setup](/user-guide/messaging/telegram)** — platform-specific details including voice messages and TTS -- **[Scheduled Tasks](/user-guide/features/cron)** — advanced cron scheduling with delivery options and cron expressions -- **[Context Files](/user-guide/features/context-files)** — AGENTS.md, SOUL.md, and .cursorrules for project knowledge -- **[Personality](/user-guide/features/personality)** — built-in personality presets and custom persona definitions -- **Add more platforms** — the same gateway can simultaneously run [Discord](/user-guide/messaging/discord), [Slack](/user-guide/messaging/slack), and [WhatsApp](/user-guide/messaging/whatsapp) +- **[Security Guide](/docs/user-guide/security)** — deep dive into authorization, container isolation, and command approval +- **[Messaging Gateway](/docs/user-guide/messaging)** — full reference for gateway architecture, session management, and chat commands +- **[Telegram Setup](/docs/user-guide/messaging/telegram)** — platform-specific details including voice messages and TTS +- **[Scheduled Tasks](/docs/user-guide/features/cron)** — advanced cron scheduling with delivery options and cron expressions +- **[Context Files](/docs/user-guide/features/context-files)** — AGENTS.md, SOUL.md, and .cursorrules for project knowledge +- **[Personality](/docs/user-guide/features/personality)** — built-in personality presets and custom persona definitions +- **Add more platforms** — the same gateway can simultaneously run [Discord](/docs/user-guide/messaging/discord), [Slack](/docs/user-guide/messaging/slack), and [WhatsApp](/docs/user-guide/messaging/whatsapp) --- diff --git a/website/docs/guides/use-mcp-with-hermes.md b/website/docs/guides/use-mcp-with-hermes.md index 9083bdae8b37..23f3813886e9 100644 --- a/website/docs/guides/use-mcp-with-hermes.md +++ b/website/docs/guides/use-mcp-with-hermes.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 6 title: "Use MCP with Hermes" description: "A practical guide to connecting MCP servers to Hermes Agent, filtering their tools, and using them safely in real workflows" --- diff --git a/website/docs/guides/use-soul-with-hermes.md b/website/docs/guides/use-soul-with-hermes.md index a4cc19ef5d8d..7767faa4d177 100644 --- a/website/docs/guides/use-soul-with-hermes.md +++ b/website/docs/guides/use-soul-with-hermes.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 title: "Use SOUL.md with Hermes" description: "How to use SOUL.md to shape Hermes Agent's default voice, what belongs there, and how it differs from AGENTS.md and /personality" --- diff --git a/website/docs/guides/use-voice-mode-with-hermes.md b/website/docs/guides/use-voice-mode-with-hermes.md index dd8b1317efee..8aca66bc1d60 100644 --- a/website/docs/guides/use-voice-mode-with-hermes.md +++ b/website/docs/guides/use-voice-mode-with-hermes.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 title: "Use Voice Mode with Hermes" description: "A practical guide to setting up and using Hermes voice mode across CLI, Telegram, Discord, and Discord voice channels" --- diff --git a/website/docs/guides/work-with-skills.md b/website/docs/guides/work-with-skills.md new file mode 100644 index 000000000000..18e180e40ca9 --- /dev/null +++ b/website/docs/guides/work-with-skills.md @@ -0,0 +1,268 @@ +--- +sidebar_position: 12 +title: "Working with Skills" +description: "Find, install, use, and create skills — on-demand knowledge that teaches Hermes new workflows" +--- + +# Working with Skills + +Skills are on-demand knowledge documents that teach Hermes how to handle specific tasks — from generating ASCII art to managing GitHub PRs. This guide walks you through using them day to day. + +For the full technical reference, see [Skills System](/docs/user-guide/features/skills). + +--- + +## Finding Skills + +Every Hermes installation ships with bundled skills. See what's available: + +```bash +# In any chat session: +/skills + +# Or from the CLI: +hermes skills list +``` + +This shows a compact list with names and descriptions: + +``` +ascii-art Generate ASCII art using pyfiglet, cowsay, boxes... +arxiv Search and retrieve academic papers from arXiv... +github-pr-workflow Full PR lifecycle — create branches, commit... +plan Plan mode — inspect context, write a markdown... +excalidraw Create hand-drawn style diagrams using Excalidraw... +``` + +### Searching for a Skill + +```bash +# Search by keyword +/skills search docker +/skills search music +``` + +### The Skills Hub + +Official optional skills (heavier or niche skills not active by default) are available via the Hub: + +```bash +# Browse official optional skills +/skills browse + +# Search the hub +/skills search blockchain +``` + +--- + +## Using a Skill + +Every installed skill is automatically a slash command. Just type its name: + +```bash +# Load a skill and give it a task +/ascii-art Make a banner that says "HELLO WORLD" +/plan Design a REST API for a todo app +/github-pr-workflow Create a PR for the auth refactor + +# Just the skill name (no task) loads it and lets you describe what you need +/excalidraw +``` + +You can also trigger skills through natural conversation — ask Hermes to use a specific skill, and it will load it via the `skill_view` tool. + +### Progressive Disclosure + +Skills use a token-efficient loading pattern. The agent doesn't load everything at once: + +1. **`skills_list()`** — compact list of all skills (~3k tokens). Loaded at session start. +2. **`skill_view(name)`** — full SKILL.md content for one skill. Loaded when the agent decides it needs that skill. +3. **`skill_view(name, file_path)`** — a specific reference file within the skill. Only loaded if needed. + +This means skills don't cost tokens until they're actually used. + +--- + +## Installing from the Hub + +Official optional skills ship with Hermes but aren't active by default. Install them explicitly: + +```bash +# Install an official optional skill +hermes skills install official/research/arxiv + +# Install from the hub in a chat session +/skills install official/creative/songwriting-and-ai-music +``` + +What happens: +1. The skill directory is copied to `~/.hermes/skills/` +2. It appears in your `skills_list` output +3. It becomes available as a slash command + +:::tip +Installed skills take effect in new sessions. If you want it available in the current session, use `/reset` to start fresh, or add `--now` to invalidate the prompt cache immediately (costs more tokens on the next turn). +::: + +### Verifying Installation + +```bash +# Check it's there +hermes skills list | grep arxiv + +# Or in chat +/skills search arxiv +``` + +--- + +## Configuring Skill Settings + +Some skills declare configuration they need in their frontmatter: + +```yaml +metadata: + hermes: + config: + - key: tenor.api_key + description: "Tenor API key for GIF search" + prompt: "Enter your Tenor API key" + url: "https://developers.google.com/tenor/guides/quickstart" +``` + +When a skill with config is first loaded, Hermes prompts you for the values. They're stored in `config.yaml` under `skills.config.*`. + +Manage skill config from the CLI: + +```bash +# Interactive config for a specific skill +hermes skills config gif-search + +# View all skill config +hermes config get skills.config +``` + +--- + +## Creating Your Own Skill + +Skills are just markdown files with YAML frontmatter. Creating one takes under five minutes. + +### 1. Create the Directory + +```bash +mkdir -p ~/.hermes/skills/my-category/my-skill +``` + +### 2. Write SKILL.md + +```markdown title="~/.hermes/skills/my-category/my-skill/SKILL.md" +--- +name: my-skill +description: Brief description of what this skill does +version: 1.0.0 +metadata: + hermes: + tags: [my-tag, automation] + category: my-category +--- + +# My Skill + +## When to Use +Use this skill when the user asks about [specific topic] or needs to [specific task]. + +## Procedure +1. First, check if [prerequisite] is available +2. Run `command --with-flags` +3. Parse the output and present results + +## Pitfalls +- Common failure: [description]. Fix: [solution] +- Watch out for [edge case] + +## Verification +Run `check-command` to confirm the result is correct. +``` + +### 3. Add Reference Files (Optional) + +Skills can include supporting files the agent loads on demand: + +``` +my-skill/ +├── SKILL.md # Main skill document +├── references/ +│ ├── api-docs.md # API reference the agent can consult +│ └── examples.md # Example inputs/outputs +├── templates/ +│ └── config.yaml # Template files the agent can use +└── scripts/ + └── setup.sh # Scripts the agent can execute +``` + +Reference these in your SKILL.md: + +```markdown +For API details, load the reference: `skill_view("my-skill", "references/api-docs.md")` +``` + +### 4. Test It + +Start a new session and try your skill: + +```bash +hermes chat -q "/my-skill help me with the thing" +``` + +The skill appears automatically — no registration needed. Drop it in `~/.hermes/skills/` and it's live. + +:::info +The agent can also create and update skills itself using `skill_manage`. After solving a complex problem, Hermes may offer to save the approach as a skill for next time. +::: + +--- + +## Per-Platform Skill Management + +Control which skills are available on which platforms: + +```bash +hermes skills +``` + +This opens an interactive TUI where you can enable or disable skills per platform (CLI, Telegram, Discord, etc.). Useful when you want certain skills only available in specific contexts — for example, keeping development skills off Telegram. + +--- + +## Skills vs Memory + +Both are persistent across sessions, but they serve different purposes: + +| | Skills | Memory | +|---|---|---| +| **What** | Procedural knowledge — how to do things | Factual knowledge — what things are | +| **When** | Loaded on demand, only when relevant | Injected into every session automatically | +| **Size** | Can be large (hundreds of lines) | Should be compact (key facts only) | +| **Cost** | Zero tokens until loaded | Small but constant token cost | +| **Examples** | "How to deploy to Kubernetes" | "User prefers dark mode, lives in PST" | +| **Who creates** | You, the agent, or installed from Hub | The agent, based on conversations | + +**Rule of thumb:** If you'd put it in a reference document, it's a skill. If you'd put it on a sticky note, it's memory. + +--- + +## Tips + +**Keep skills focused.** A skill that tries to cover "all of DevOps" will be too long and too vague. A skill that covers "deploy a Python app to Fly.io" is specific enough to be genuinely useful. + +**Let the agent create skills.** After a complex multi-step task, Hermes will often offer to save the approach as a skill. Say yes — these agent-authored skills capture the exact workflow including pitfalls that were discovered along the way. + +**Use categories.** Organize skills into subdirectories (`~/.hermes/skills/devops/`, `~/.hermes/skills/research/`, etc.). This keeps the list manageable and helps the agent find relevant skills faster. + +**Update skills when they go stale.** If you use a skill and hit issues not covered by it, tell Hermes to update the skill with what you learned. Skills that aren't maintained become liabilities. + +--- + +*For the complete skills reference — frontmatter fields, conditional activation, external directories, and more — see [Skills System](/docs/user-guide/features/skills).* diff --git a/website/sidebars.ts b/website/sidebars.ts index cd227306cd85..5e1ebf2d6bd2 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -132,13 +132,17 @@ const sidebars: SidebarsConfig = { collapsed: true, items: [ 'guides/tips', - 'guides/build-a-hermes-plugin', + 'guides/local-llm-on-mac', 'guides/daily-briefing-bot', 'guides/team-telegram-assistant', 'guides/python-library', 'guides/use-mcp-with-hermes', 'guides/use-soul-with-hermes', 'guides/use-voice-mode-with-hermes', + 'guides/build-a-hermes-plugin', + 'guides/automate-with-cron', + 'guides/work-with-skills', + 'guides/delegation-patterns', 'guides/migrate-from-openclaw', ], }, From 76f19775c3c1531591267e33d77d3dcdc7941719 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 5 Apr 2026 04:37:02 +0000 Subject: [PATCH 191/213] feat(memory): add Supermemory memory provider --- plugins/memory/supermemory/README.md | 54 ++ plugins/memory/supermemory/__init__.py | 657 ++++++++++++++++++ plugins/memory/supermemory/plugin.yaml | 7 + .../memory/test_supermemory_provider.py | 212 ++++++ 4 files changed, 930 insertions(+) create mode 100644 plugins/memory/supermemory/README.md create mode 100644 plugins/memory/supermemory/__init__.py create mode 100644 plugins/memory/supermemory/plugin.yaml create mode 100644 tests/plugins/memory/test_supermemory_provider.py diff --git a/plugins/memory/supermemory/README.md b/plugins/memory/supermemory/README.md new file mode 100644 index 000000000000..465d46838f02 --- /dev/null +++ b/plugins/memory/supermemory/README.md @@ -0,0 +1,54 @@ +# Supermemory Memory Provider + +Semantic long-term memory with profile recall, semantic search, explicit memory tools, and session-end conversation ingest. + +## Requirements + +- `pip install supermemory` +- Supermemory API key from [supermemory.ai](https://supermemory.ai) + +## Setup + +```bash +hermes memory setup # select "supermemory" +``` + +Or manually: + +```bash +hermes config set memory.provider supermemory +echo "SUPERMEMORY_API_KEY=***" >> ~/.hermes/.env +``` + +## Config + +Config file: `$HERMES_HOME/supermemory.json` + +| Key | Default | Description | +|-----|---------|-------------| +| `container_tag` | `hermes` | Container tag used for search and writes | +| `auto_recall` | `true` | Inject relevant memory context before turns | +| `auto_capture` | `true` | Store cleaned user-assistant turns after each response | +| `max_recall_results` | `10` | Max recalled items to format into context | +| `profile_frequency` | `50` | Include profile facts on first turn and every N turns | +| `capture_mode` | `all` | Skip tiny or trivial turns by default | +| `entity_context` | built-in default | Extraction guidance passed to Supermemory | +| `api_timeout` | `5.0` | Timeout for SDK and ingest requests | + +## Tools + +| Tool | Description | +|------|-------------| +| `supermemory_store` | Store an explicit memory | +| `supermemory_search` | Search memories by semantic similarity | +| `supermemory_forget` | Forget a memory by ID or best-match query | +| `supermemory_profile` | Retrieve persistent profile and recent context | + +## Behavior + +When enabled, Hermes can: + +- prefetch relevant memory context before each turn +- store cleaned conversation turns after each completed response +- ingest the full session on session end for richer graph updates +- expose explicit tools for search, store, forget, and profile access diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py new file mode 100644 index 000000000000..05583fae3586 --- /dev/null +++ b/plugins/memory/supermemory/__init__.py @@ -0,0 +1,657 @@ +"""Supermemory memory plugin using the MemoryProvider interface. + +Provides semantic long-term memory with profile recall, semantic search, +explicit memory tools, cleaned turn capture, and session-end conversation ingest. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import threading +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from agent.memory_provider import MemoryProvider + +logger = logging.getLogger(__name__) + +_DEFAULT_CONTAINER_TAG = "hermes" +_DEFAULT_MAX_RECALL_RESULTS = 10 +_DEFAULT_PROFILE_FREQUENCY = 50 +_DEFAULT_CAPTURE_MODE = "all" +_DEFAULT_API_TIMEOUT = 5.0 +_MIN_CAPTURE_LENGTH = 10 +_MAX_ENTITY_CONTEXT_LENGTH = 1500 +_CONVERSATIONS_URL = "https://api.supermemory.ai/v4/conversations" +_TRIVIAL_RE = re.compile( + r"^(ok|okay|thanks|thank you|got it|sure|yes|no|yep|nope|k|ty|thx|np)\.?$", + re.IGNORECASE, +) +_CONTEXT_STRIP_RE = re.compile( + r"[\s\S]*?\s*", re.DOTALL +) +_CONTAINERS_STRIP_RE = re.compile( + r"[\s\S]*?\s*", re.DOTALL +) +_DEFAULT_ENTITY_CONTEXT = ( + "User-assistant conversation. Format: [role: user]...[user:end] and " + "[role: assistant]...[assistant:end].\n\n" + "Only extract things useful in future conversations. Most messages are not worth remembering.\n\n" + "Remember lasting personal facts, preferences, routines, tools, ongoing projects, working context, " + "and explicit requests to remember something.\n\n" + "Do not remember temporary intents, one-time tasks, assistant actions, implementation details, or in-progress status.\n\n" + "When in doubt, store less." +) + + +def _default_config() -> dict: + return { + "container_tag": _DEFAULT_CONTAINER_TAG, + "auto_recall": True, + "auto_capture": True, + "max_recall_results": _DEFAULT_MAX_RECALL_RESULTS, + "profile_frequency": _DEFAULT_PROFILE_FREQUENCY, + "capture_mode": _DEFAULT_CAPTURE_MODE, + "entity_context": _DEFAULT_ENTITY_CONTEXT, + "api_timeout": _DEFAULT_API_TIMEOUT, + } + + +def _sanitize_tag(raw: str) -> str: + tag = re.sub(r"[^a-zA-Z0-9_]", "_", raw or "") + tag = re.sub(r"_+", "_", tag) + return tag.strip("_") or _DEFAULT_CONTAINER_TAG + + +def _clamp_entity_context(text: str) -> str: + if not text: + return _DEFAULT_ENTITY_CONTEXT + text = text.strip() + return text[:_MAX_ENTITY_CONTEXT_LENGTH] + + +def _as_bool(value: Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "1", "yes", "y", "on"): + return True + if lowered in ("false", "0", "no", "n", "off"): + return False + return default + + +def _load_supermemory_config(hermes_home: str) -> dict: + config = _default_config() + config_path = Path(hermes_home) / "supermemory.json" + if config_path.exists(): + try: + raw = json.loads(config_path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + config.update({k: v for k, v in raw.items() if v is not None}) + except Exception: + logger.debug("Failed to parse %s", config_path, exc_info=True) + + config["container_tag"] = _sanitize_tag(str(config.get("container_tag", _DEFAULT_CONTAINER_TAG))) + config["auto_recall"] = _as_bool(config.get("auto_recall"), True) + config["auto_capture"] = _as_bool(config.get("auto_capture"), True) + try: + config["max_recall_results"] = max(1, min(20, int(config.get("max_recall_results", _DEFAULT_MAX_RECALL_RESULTS)))) + except Exception: + config["max_recall_results"] = _DEFAULT_MAX_RECALL_RESULTS + try: + config["profile_frequency"] = max(1, min(500, int(config.get("profile_frequency", _DEFAULT_PROFILE_FREQUENCY)))) + except Exception: + config["profile_frequency"] = _DEFAULT_PROFILE_FREQUENCY + config["capture_mode"] = "everything" if config.get("capture_mode") == "everything" else "all" + config["entity_context"] = _clamp_entity_context(str(config.get("entity_context", _DEFAULT_ENTITY_CONTEXT))) + try: + config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT)))) + except Exception: + config["api_timeout"] = _DEFAULT_API_TIMEOUT + return config + + +def _save_supermemory_config(values: dict, hermes_home: str) -> None: + config_path = Path(hermes_home) / "supermemory.json" + existing = {} + if config_path.exists(): + try: + raw = json.loads(config_path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + existing = raw + except Exception: + existing = {} + existing.update(values) + config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _detect_category(text: str) -> str: + lowered = text.lower() + if re.search(r"prefer|like|love|hate|want", lowered): + return "preference" + if re.search(r"decided|will use|going with", lowered): + return "decision" + if re.search(r"\bis\b|\bare\b|\bhas\b|\bhave\b", lowered): + return "fact" + return "other" + + +def _format_relative_time(iso_timestamp: str) -> str: + try: + dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + seconds = (now - dt).total_seconds() + if seconds < 1800: + return "just now" + if seconds < 3600: + return f"{int(seconds / 60)}m ago" + if seconds < 86400: + return f"{int(seconds / 3600)}h ago" + if seconds < 604800: + return f"{int(seconds / 86400)}d ago" + if dt.year == now.year: + return dt.strftime("%d %b") + return dt.strftime("%d %b %Y") + except Exception: + return "" + + +def _deduplicate_recall(static_facts: list, dynamic_facts: list, search_results: list) -> tuple[list, list, list]: + seen = set() + out_static, out_dynamic, out_search = [], [], [] + for fact in static_facts or []: + if fact and fact not in seen: + seen.add(fact) + out_static.append(fact) + for fact in dynamic_facts or []: + if fact and fact not in seen: + seen.add(fact) + out_dynamic.append(fact) + for item in search_results or []: + memory = item.get("memory", "") + if memory and memory not in seen: + seen.add(memory) + out_search.append(item) + return out_static, out_dynamic, out_search + + +def _format_prefetch_context(static_facts: list, dynamic_facts: list, search_results: list, max_results: int) -> str: + statics, dynamics, search = _deduplicate_recall(static_facts, dynamic_facts, search_results) + statics = statics[:max_results] + dynamics = dynamics[:max_results] + search = search[:max_results] + if not statics and not dynamics and not search: + return "" + + sections = [] + if statics: + sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in statics)) + if dynamics: + sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in dynamics)) + if search: + lines = [] + for item in search: + memory = item.get("memory", "") + if not memory: + continue + similarity = item.get("similarity") + updated = item.get("updated_at") or item.get("updatedAt") or "" + prefix_bits = [] + rel = _format_relative_time(updated) + if rel: + prefix_bits.append(f"[{rel}]") + if similarity is not None: + try: + prefix_bits.append(f"[{round(float(similarity) * 100)}%]") + except Exception: + pass + prefix = " ".join(prefix_bits) + lines.append(f"- {prefix} {memory}".strip()) + if lines: + sections.append("## Relevant Memories\n" + "\n".join(lines)) + if not sections: + return "" + + intro = ( + "The following is background context from long-term memory. Use it silently when relevant. " + "Do not force memories into the conversation." + ) + body = "\n\n".join(sections) + return f"\n{intro}\n\n{body}\n" + + +def _clean_text_for_capture(text: str) -> str: + text = _CONTEXT_STRIP_RE.sub("", text or "") + text = _CONTAINERS_STRIP_RE.sub("", text) + return text.strip() + + +def _is_trivial_message(text: str) -> bool: + return bool(_TRIVIAL_RE.match((text or "").strip())) + + +class _SupermemoryClient: + def __init__(self, api_key: str, timeout: float, container_tag: str): + from supermemory import Supermemory + + self._api_key = api_key + self._container_tag = container_tag + self._timeout = timeout + self._client = Supermemory(api_key=api_key, timeout=timeout, max_retries=0) + + def add_memory(self, content: str, metadata: Optional[dict] = None, *, entity_context: str = "") -> dict: + kwargs = { + "content": content.strip(), + "container_tags": [self._container_tag], + } + if metadata: + kwargs["metadata"] = metadata + if entity_context: + kwargs["entity_context"] = _clamp_entity_context(entity_context) + result = self._client.documents.add(**kwargs) + return {"id": getattr(result, "id", "")} + + def search_memories(self, query: str, *, limit: int = 5) -> list[dict]: + response = self._client.search.memories(q=query, container_tag=self._container_tag, limit=limit) + results = [] + for item in (getattr(response, "results", None) or []): + results.append({ + "id": getattr(item, "id", ""), + "memory": getattr(item, "memory", "") or "", + "similarity": getattr(item, "similarity", None), + "updated_at": getattr(item, "updated_at", None) or getattr(item, "updatedAt", None), + "metadata": getattr(item, "metadata", None), + }) + return results + + def get_profile(self, query: Optional[str] = None) -> dict: + kwargs = {"container_tag": self._container_tag} + if query: + kwargs["q"] = query + response = self._client.profile(**kwargs) + profile_data = getattr(response, "profile", None) + search_data = getattr(response, "search_results", None) or getattr(response, "searchResults", None) + static = getattr(profile_data, "static", []) or [] if profile_data else [] + dynamic = getattr(profile_data, "dynamic", []) or [] if profile_data else [] + raw_results = getattr(search_data, "results", None) or search_data or [] + search_results = [] + if isinstance(raw_results, list): + for item in raw_results: + if isinstance(item, dict): + search_results.append(item) + else: + search_results.append({ + "memory": getattr(item, "memory", ""), + "updated_at": getattr(item, "updated_at", None) or getattr(item, "updatedAt", None), + "similarity": getattr(item, "similarity", None), + }) + return {"static": static, "dynamic": dynamic, "search_results": search_results} + + def forget_memory(self, memory_id: str) -> None: + self._client.memories.forget(container_tag=self._container_tag, id=memory_id) + + def forget_by_query(self, query: str) -> dict: + results = self.search_memories(query, limit=5) + if not results: + return {"success": False, "message": "No matching memory found to forget."} + target = results[0] + memory_id = target.get("id", "") + if not memory_id: + return {"success": False, "message": "Best matching memory has no id."} + self.forget_memory(memory_id) + preview = (target.get("memory") or "")[:100] + return {"success": True, "message": f'Forgot: "{preview}"', "id": memory_id} + + def ingest_conversation(self, session_id: str, messages: list[dict]) -> None: + payload = json.dumps({ + "conversationId": session_id, + "messages": messages, + "containerTags": [self._container_tag], + }).encode("utf-8") + req = urllib.request.Request( + _CONVERSATIONS_URL, + data=payload, + headers={ + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=self._timeout + 3): + return + + +STORE_SCHEMA = { + "name": "supermemory_store", + "description": "Store an explicit memory for future recall.", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "The memory content to store."}, + "metadata": {"type": "object", "description": "Optional metadata attached to the memory."}, + }, + "required": ["content"], + }, +} + +SEARCH_SCHEMA = { + "name": "supermemory_search", + "description": "Search long-term memory by semantic similarity.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "What to search for."}, + "limit": {"type": "integer", "description": "Maximum results to return, 1 to 20."}, + }, + "required": ["query"], + }, +} + +FORGET_SCHEMA = { + "name": "supermemory_forget", + "description": "Forget a memory by exact id or by best-match query.", + "parameters": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Exact memory id to delete."}, + "query": {"type": "string", "description": "Query used to find the memory to forget."}, + }, + }, +} + +PROFILE_SCHEMA = { + "name": "supermemory_profile", + "description": "Retrieve persistent profile facts and recent memory context.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Optional query to focus the profile response."}, + }, + }, +} + + +class SupermemoryMemoryProvider(MemoryProvider): + def __init__(self): + self._config = _default_config() + self._api_key = "" + self._client: Optional[_SupermemoryClient] = None + self._container_tag = _DEFAULT_CONTAINER_TAG + self._session_id = "" + self._turn_count = 0 + self._prefetch_result = "" + self._prefetch_lock = threading.Lock() + self._prefetch_thread: Optional[threading.Thread] = None + self._sync_thread: Optional[threading.Thread] = None + self._auto_recall = True + self._auto_capture = True + self._max_recall_results = _DEFAULT_MAX_RECALL_RESULTS + self._profile_frequency = _DEFAULT_PROFILE_FREQUENCY + self._capture_mode = _DEFAULT_CAPTURE_MODE + self._entity_context = _DEFAULT_ENTITY_CONTEXT + self._api_timeout = _DEFAULT_API_TIMEOUT + self._hermes_home = os.path.expanduser("~/.hermes") + self._write_enabled = True + self._active = False + + @property + def name(self) -> str: + return "supermemory" + + def is_available(self) -> bool: + api_key = os.environ.get("SUPERMEMORY_API_KEY", "") + if not api_key: + return False + try: + __import__("supermemory") + return True + except Exception: + return False + + def get_config_schema(self): + return [ + {"key": "api_key", "description": "Supermemory API key", "secret": True, "required": True, "env_var": "SUPERMEMORY_API_KEY", "url": "https://supermemory.ai"}, + {"key": "container_tag", "description": "Container tag for reads and writes", "default": _DEFAULT_CONTAINER_TAG}, + {"key": "auto_recall", "description": "Enable automatic recall before each turn", "default": "true", "choices": ["true", "false"]}, + {"key": "auto_capture", "description": "Enable automatic capture after each completed turn", "default": "true", "choices": ["true", "false"]}, + {"key": "max_recall_results", "description": "Maximum recalled items to inject", "default": str(_DEFAULT_MAX_RECALL_RESULTS)}, + {"key": "profile_frequency", "description": "Include profile facts on first turn and every N turns", "default": str(_DEFAULT_PROFILE_FREQUENCY)}, + {"key": "capture_mode", "description": "Capture mode", "default": _DEFAULT_CAPTURE_MODE, "choices": ["all", "everything"]}, + {"key": "entity_context", "description": "Extraction guidance passed to Supermemory", "default": _DEFAULT_ENTITY_CONTEXT}, + {"key": "api_timeout", "description": "Timeout in seconds for SDK and ingest calls", "default": str(_DEFAULT_API_TIMEOUT)}, + ] + + def save_config(self, values, hermes_home): + sanitized = dict(values or {}) + if "container_tag" in sanitized: + sanitized["container_tag"] = _sanitize_tag(str(sanitized["container_tag"])) + if "entity_context" in sanitized: + sanitized["entity_context"] = _clamp_entity_context(str(sanitized["entity_context"])) + _save_supermemory_config(sanitized, hermes_home) + + def initialize(self, session_id: str, **kwargs) -> None: + self._hermes_home = kwargs.get("hermes_home") or os.path.expanduser("~/.hermes") + self._session_id = session_id + self._turn_count = 0 + self._config = _load_supermemory_config(self._hermes_home) + self._api_key = os.environ.get("SUPERMEMORY_API_KEY", "") + self._container_tag = self._config["container_tag"] + self._auto_recall = self._config["auto_recall"] + self._auto_capture = self._config["auto_capture"] + self._max_recall_results = self._config["max_recall_results"] + self._profile_frequency = self._config["profile_frequency"] + self._capture_mode = self._config["capture_mode"] + self._entity_context = self._config["entity_context"] + self._api_timeout = self._config["api_timeout"] + agent_context = kwargs.get("agent_context", "") + self._write_enabled = agent_context not in ("cron", "flush", "subagent") + self._active = bool(self._api_key) + self._client = None + if self._active: + try: + self._client = _SupermemoryClient( + api_key=self._api_key, + timeout=self._api_timeout, + container_tag=self._container_tag, + ) + except Exception: + logger.warning("Supermemory initialization failed", exc_info=True) + self._active = False + self._client = None + + def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None: + self._turn_count = max(turn_number, 0) + + def system_prompt_block(self) -> str: + if not self._active: + return "" + return ( + "# Supermemory\n" + f"Active. Container: {self._container_tag}.\n" + "Use supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile for explicit memory operations." + ) + + def prefetch(self, query: str, *, session_id: str = "") -> str: + if not self._active or not self._auto_recall or not self._client or not query.strip(): + return "" + try: + profile = self._client.get_profile(query=query[:200]) + include_profile = self._turn_count <= 1 or (self._turn_count % self._profile_frequency == 0) + context = _format_prefetch_context( + static_facts=profile["static"] if include_profile else [], + dynamic_facts=profile["dynamic"] if include_profile else [], + search_results=profile["search_results"], + max_results=self._max_recall_results, + ) + return context + except Exception: + logger.debug("Supermemory prefetch failed", exc_info=True) + return "" + + def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: + if not self._active or not self._auto_capture or not self._write_enabled or not self._client: + return + + clean_user = _clean_text_for_capture(user_content) + clean_assistant = _clean_text_for_capture(assistant_content) + if not clean_user or not clean_assistant: + return + if self._capture_mode == "all": + if len(clean_user) < _MIN_CAPTURE_LENGTH or len(clean_assistant) < _MIN_CAPTURE_LENGTH: + return + if _is_trivial_message(clean_user): + return + + content = ( + f"[role: user]\n{clean_user}\n[user:end]\n\n" + f"[role: assistant]\n{clean_assistant}\n[assistant:end]" + ) + metadata = {"source": "hermes", "type": "conversation_turn"} + + def _run(): + try: + self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context) + except Exception: + logger.debug("Supermemory sync_turn failed", exc_info=True) + + if self._sync_thread and self._sync_thread.is_alive(): + self._sync_thread.join(timeout=2.0) + self._sync_thread = threading.Thread(target=_run, daemon=True, name="supermemory-sync") + self._sync_thread.start() + + def on_session_end(self, messages: List[Dict[str, Any]]) -> None: + if not self._active or not self._write_enabled or not self._client or not self._session_id: + return + cleaned = [] + for message in messages or []: + role = message.get("role") + if role not in ("user", "assistant"): + continue + content = _clean_text_for_capture(str(message.get("content", ""))) + if content: + cleaned.append({"role": role, "content": content}) + if not cleaned: + return + if len(cleaned) == 1 and len(cleaned[0].get("content", "")) < 20: + return + try: + self._client.ingest_conversation(self._session_id, cleaned) + except urllib.error.HTTPError: + logger.warning("Supermemory session ingest failed", exc_info=True) + except Exception: + logger.warning("Supermemory session ingest failed", exc_info=True) + + def on_memory_write(self, action: str, target: str, content: str) -> None: + if not self._active or not self._write_enabled or not self._client: + return + if action != "add" or not (content or "").strip(): + return + + def _run(): + try: + self._client.add_memory( + content.strip(), + metadata={"source": "hermes_memory", "target": target, "type": "explicit_memory"}, + entity_context=self._entity_context, + ) + except Exception: + logger.debug("Supermemory on_memory_write failed", exc_info=True) + + threading.Thread(target=_run, daemon=True, name="supermemory-memory-write").start() + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + return [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA] + + def _tool_store(self, args: dict) -> str: + content = str(args.get("content") or "").strip() + if not content: + return json.dumps({"error": "content is required"}) + metadata = args.get("metadata") or {} + if not isinstance(metadata, dict): + metadata = {} + metadata.setdefault("type", _detect_category(content)) + metadata["source"] = "hermes_tool" + try: + result = self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context) + preview = content[:80] + ("..." if len(content) > 80 else "") + return json.dumps({"saved": True, "id": result.get("id", ""), "preview": preview}) + except Exception as exc: + return json.dumps({"error": f"Failed to store memory: {exc}"}) + + def _tool_search(self, args: dict) -> str: + query = str(args.get("query") or "").strip() + if not query: + return json.dumps({"error": "query is required"}) + try: + limit = max(1, min(20, int(args.get("limit", 5) or 5))) + except Exception: + limit = 5 + try: + results = self._client.search_memories(query, limit=limit) + formatted = [] + for item in results: + entry = {"id": item.get("id", ""), "content": item.get("memory", "")} + if item.get("similarity") is not None: + try: + entry["similarity"] = round(float(item["similarity"]) * 100) + except Exception: + pass + formatted.append(entry) + return json.dumps({"results": formatted, "count": len(formatted)}) + except Exception as exc: + return json.dumps({"error": f"Search failed: {exc}"}) + + def _tool_forget(self, args: dict) -> str: + memory_id = str(args.get("id") or "").strip() + query = str(args.get("query") or "").strip() + if not memory_id and not query: + return json.dumps({"error": "Provide either id or query"}) + try: + if memory_id: + self._client.forget_memory(memory_id) + return json.dumps({"forgotten": True, "id": memory_id}) + return json.dumps(self._client.forget_by_query(query)) + except Exception as exc: + return json.dumps({"error": f"Forget failed: {exc}"}) + + def _tool_profile(self, args: dict) -> str: + query = str(args.get("query") or "").strip() or None + try: + profile = self._client.get_profile(query=query) + sections = [] + if profile["static"]: + sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in profile["static"])) + if profile["dynamic"]: + sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in profile["dynamic"])) + return json.dumps({ + "profile": "\n\n".join(sections), + "static_count": len(profile["static"]), + "dynamic_count": len(profile["dynamic"]), + }) + except Exception as exc: + return json.dumps({"error": f"Profile failed: {exc}"}) + + def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str: + if not self._active or not self._client: + return json.dumps({"error": "Supermemory is not configured"}) + if tool_name == "supermemory_store": + return self._tool_store(args) + if tool_name == "supermemory_search": + return self._tool_search(args) + if tool_name == "supermemory_forget": + return self._tool_forget(args) + if tool_name == "supermemory_profile": + return self._tool_profile(args) + return json.dumps({"error": f"Unknown tool: {tool_name}"}) + + +def register(ctx): + ctx.register_memory_provider(SupermemoryMemoryProvider()) diff --git a/plugins/memory/supermemory/plugin.yaml b/plugins/memory/supermemory/plugin.yaml new file mode 100644 index 000000000000..372edb2b7c51 --- /dev/null +++ b/plugins/memory/supermemory/plugin.yaml @@ -0,0 +1,7 @@ +name: supermemory +version: 1.0.0 +description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest." +pip_dependencies: + - supermemory +hooks: + - on_session_end diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py new file mode 100644 index 000000000000..f61a904194df --- /dev/null +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -0,0 +1,212 @@ +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from plugins.memory.supermemory import ( + SupermemoryMemoryProvider, + _clean_text_for_capture, + _format_prefetch_context, + _load_supermemory_config, + _save_supermemory_config, +) + + +class FakeClient: + def __init__(self, api_key: str, timeout: float, container_tag: str): + self.api_key = api_key + self.timeout = timeout + self.container_tag = container_tag + self.add_calls = [] + self.search_results = [] + self.profile_response = {"static": [], "dynamic": [], "search_results": []} + self.ingest_calls = [] + self.forgotten_ids = [] + self.forget_by_query_response = {"success": True, "message": "Forgot"} + + def add_memory(self, content, metadata=None, *, entity_context=""): + self.add_calls.append({ + "content": content, + "metadata": metadata, + "entity_context": entity_context, + }) + return {"id": "mem_123"} + + def search_memories(self, query, *, limit=5): + return self.search_results + + def get_profile(self, query=None): + return self.profile_response + + def forget_memory(self, memory_id): + self.forgotten_ids.append(memory_id) + + def forget_by_query(self, query): + return self.forget_by_query_response + + def ingest_conversation(self, session_id, messages): + self.ingest_calls.append({"session_id": session_id, "messages": messages}) + + +@pytest.fixture +def provider(monkeypatch, tmp_path): + monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key") + monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient) + p = SupermemoryMemoryProvider() + p.initialize("session-1", hermes_home=str(tmp_path), platform="cli") + return p + + +def test_is_available_false_without_api_key(monkeypatch): + monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False) + p = SupermemoryMemoryProvider() + assert p.is_available() is False + + +def test_is_available_false_when_import_missing(monkeypatch): + monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key") + + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "supermemory": + raise ImportError("missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + p = SupermemoryMemoryProvider() + assert p.is_available() is False + + +def test_load_and_save_config_round_trip(tmp_path): + _save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path)) + cfg = _load_supermemory_config(str(tmp_path)) + assert cfg["container_tag"] == "demo_tag" + assert cfg["auto_capture"] is False + assert cfg["auto_recall"] is True + + +def test_clean_text_for_capture_strips_injected_context(): + text = "hello\nignore me\nworld" + assert _clean_text_for_capture(text) == "hello\nworld" + + +def test_format_prefetch_context_deduplicates_overlap(): + result = _format_prefetch_context( + static_facts=["Jordan prefers short answers"], + dynamic_facts=["Jordan prefers short answers", "Uses Hermes"], + search_results=[{"memory": "Uses Hermes", "similarity": 0.9}], + max_results=10, + ) + assert result.count("Jordan prefers short answers") == 1 + assert result.count("Uses Hermes") == 1 + assert "" in result + + +def test_prefetch_includes_profile_on_first_turn(provider): + provider._client.profile_response = { + "static": ["Jordan prefers short answers"], + "dynamic": ["Current project is Supermemory provider"], + "search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}], + } + provider.on_turn_start(1, "start") + result = provider.prefetch("what am I working on?") + assert "User Profile (Persistent)" in result + assert "Recent Context" in result + assert "Relevant Memories" in result + + +def test_prefetch_skips_profile_between_frequency(provider): + provider._client.profile_response = { + "static": ["Jordan prefers short answers"], + "dynamic": ["Current project is Supermemory provider"], + "search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}], + } + provider.on_turn_start(2, "next") + result = provider.prefetch("what am I working on?") + assert "Relevant Memories" in result + assert "User Profile (Persistent)" not in result + + +def test_sync_turn_skips_trivial_message(provider): + provider.sync_turn("ok", "sure", session_id="session-1") + assert provider._client.add_calls == [] + + +def test_sync_turn_persists_cleaned_exchange(provider): + provider.sync_turn( + "Please remember this\nignore", + "Got it, storing the context", + session_id="session-1", + ) + provider._sync_thread.join(timeout=1) + assert len(provider._client.add_calls) == 1 + content = provider._client.add_calls[0]["content"] + assert "ignore" not in content + assert "[role: user]" in content + assert "[role: assistant]" in content + + +def test_on_session_end_ingests_clean_messages(provider): + messages = [ + {"role": "system", "content": "skip"}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ] + provider.on_session_end(messages) + assert len(provider._client.ingest_calls) == 1 + payload = provider._client.ingest_calls[0] + assert payload["session_id"] == "session-1" + assert payload["messages"] == [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ] + + +def test_store_tool_returns_saved_payload(provider): + result = json.loads(provider.handle_tool_call("supermemory_store", {"content": "Jordan likes concise docs"})) + assert result["saved"] is True + assert result["id"] == "mem_123" + + +def test_search_tool_formats_results(provider): + provider._client.search_results = [ + {"id": "m1", "memory": "Jordan likes concise docs", "similarity": 0.92} + ] + result = json.loads(provider.handle_tool_call("supermemory_search", {"query": "concise docs"})) + assert result["count"] == 1 + assert result["results"][0]["similarity"] == 92 + + +def test_forget_tool_by_id(provider): + result = json.loads(provider.handle_tool_call("supermemory_forget", {"id": "m1"})) + assert result == {"forgotten": True, "id": "m1"} + assert provider._client.forgotten_ids == ["m1"] + + +def test_forget_tool_by_query(provider): + provider._client.forget_by_query_response = {"success": True, "message": "Forgot one", "id": "m7"} + result = json.loads(provider.handle_tool_call("supermemory_forget", {"query": "that thing"})) + assert result["success"] is True + assert result["id"] == "m7" + + +def test_profile_tool_formats_sections(provider): + provider._client.profile_response = { + "static": ["Jordan prefers concise docs"], + "dynamic": ["Working on Supermemory provider"], + "search_results": [], + } + result = json.loads(provider.handle_tool_call("supermemory_profile", {})) + assert result["static_count"] == 1 + assert result["dynamic_count"] == 1 + assert "User Profile (Persistent)" in result["profile"] + + +def test_handle_tool_call_returns_error_when_unconfigured(monkeypatch): + monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False) + p = SupermemoryMemoryProvider() + result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"})) + assert "error" in result From dc333388ec01afe55a47c5a4fdae377cfd000278 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 5 Apr 2026 04:42:16 +0000 Subject: [PATCH 192/213] docs(memory): add Supermemory PR draft and cleanup --- SUPERMEMORY_PR_DRAFT.md | 82 +++++++++++++++++++ plugins/memory/supermemory/README.md | 2 +- .../memory/test_supermemory_provider.py | 2 - 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 SUPERMEMORY_PR_DRAFT.md diff --git a/SUPERMEMORY_PR_DRAFT.md b/SUPERMEMORY_PR_DRAFT.md new file mode 100644 index 000000000000..64bb7c2103e6 --- /dev/null +++ b/SUPERMEMORY_PR_DRAFT.md @@ -0,0 +1,82 @@ +Title +feat(memory): add Supermemory memory provider + +Summary +This PR adds Supermemory as a native Hermes memory provider. + +It implements the upstream `MemoryProvider` interface rather than the general hook-based plugin system, so it works with the current memory-provider lifecycle and setup flow. The provider supports automatic recall, cleaned turn capture, session-end conversation ingest, and four explicit memory tools. + +What is included +- `plugins/memory/supermemory/plugin.yaml` +- `plugins/memory/supermemory/README.md` +- `plugins/memory/supermemory/__init__.py` +- focused tests for provider behavior and failure modes + +Behavior +When enabled, the provider can: +- prefetch relevant memory context before turns +- include profile facts on the first turn and on a configurable cadence +- store cleaned user-assistant turns after each completed response +- ingest the full session on session end via Supermemory conversations API +- expose explicit tools for memory store, search, forget, and profile access + +Tools +- `supermemory_store` +- `supermemory_search` +- `supermemory_forget` +- `supermemory_profile` + +Setup +Use the standard memory setup flow: + +```bash +hermes memory setup +``` + +Select `supermemory`, then provide: +- `SUPERMEMORY_API_KEY` in `.env` +- optional non-secret config written to `$HERMES_HOME/supermemory.json` + +Config surface +The provider currently supports: +- `container_tag` +- `auto_recall` +- `auto_capture` +- `max_recall_results` +- `profile_frequency` +- `capture_mode` +- `entity_context` +- `api_timeout` + +Design notes +- Implemented as a native memory provider so it fits Hermes's pluggable memory system and `hermes memory setup` / `status` flow. +- The implementation preserves the useful behavior of a working local Supermemory integration, but removes local-only naming and packaging assumptions. +- Recall context is fenced and stripped before capture to avoid recursive memory pollution. +- `on_memory_write()` is intentionally conservative in v1 and mirrors `add` writes only. Supermemory is not a simple CRUD table, so pretending replace/remove are lossless would be dishonest. +- Session-end ingest is kept in the provider because Supermemory's conversation ingestion endpoint is a meaningful part of the backend's graph-building behavior. + +Failure behavior +- `is_available()` performs no network calls +- missing API key or missing SDK leaves the provider unavailable without crashing Hermes +- recall, capture, and ingest failures degrade quietly and do not break the agent loop + +Validation +Ran: + +```bash +python3 -m pytest tests/plugins/memory/test_supermemory_provider.py tests/agent/test_memory_provider.py tests/agent/test_memory_plugin_e2e.py -q +``` + +Result: +- `76 passed` + +Reviewer-facing summary +This PR adds Supermemory as a first-class Hermes memory provider in the same structural shape as the existing built-in providers. It is setup-compatible, failure-tolerant, and intentionally conservative where backend semantics differ from simple CRUD expectations. + +The main reason for this PR is straightforward: Supermemory is a real external memory backend, and the right way to integrate it upstream is through the native `MemoryProvider` interface, not through a user-local hook plugin. + +Points to review +- provider shape and config UX alignment with other memory providers +- recall formatting and profile cadence behavior +- whether the add-only `on_memory_write()` bridge is the right v1 scope +- test coverage for the provider lifecycle and failure paths diff --git a/plugins/memory/supermemory/README.md b/plugins/memory/supermemory/README.md index 465d46838f02..f34e4c997f30 100644 --- a/plugins/memory/supermemory/README.md +++ b/plugins/memory/supermemory/README.md @@ -17,7 +17,7 @@ Or manually: ```bash hermes config set memory.provider supermemory -echo "SUPERMEMORY_API_KEY=***" >> ~/.hermes/.env +echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env ``` ## Config diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py index f61a904194df..0bee1d2150c0 100644 --- a/tests/plugins/memory/test_supermemory_provider.py +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -1,6 +1,4 @@ import json -from pathlib import Path -from unittest.mock import MagicMock import pytest From 4fc7f3eaa59a50000bd839e2c2271e84f48843f1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 5 Apr 2026 05:50:29 +0000 Subject: [PATCH 193/213] fix(memory): clean up supermemory provider threads --- plugins/memory/supermemory/__init__.py | 15 +++++- .../memory/test_supermemory_provider.py | 47 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index 05583fae3586..f798b4a14f68 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -391,6 +391,7 @@ def __init__(self): self._prefetch_lock = threading.Lock() self._prefetch_thread: Optional[threading.Thread] = None self._sync_thread: Optional[threading.Thread] = None + self._write_thread: Optional[threading.Thread] = None self._auto_recall = True self._auto_capture = True self._max_recall_results = _DEFAULT_MAX_RECALL_RESULTS @@ -524,6 +525,7 @@ def _run(): if self._sync_thread and self._sync_thread.is_alive(): self._sync_thread.join(timeout=2.0) + self._sync_thread = None self._sync_thread = threading.Thread(target=_run, daemon=True, name="supermemory-sync") self._sync_thread.start() @@ -565,7 +567,18 @@ def _run(): except Exception: logger.debug("Supermemory on_memory_write failed", exc_info=True) - threading.Thread(target=_run, daemon=True, name="supermemory-memory-write").start() + if self._write_thread and self._write_thread.is_alive(): + self._write_thread.join(timeout=2.0) + self._write_thread = None + self._write_thread = threading.Thread(target=_run, daemon=False, name="supermemory-memory-write") + self._write_thread.start() + + def shutdown(self) -> None: + for attr_name in ("_prefetch_thread", "_sync_thread", "_write_thread"): + thread = getattr(self, attr_name, None) + if thread and thread.is_alive(): + thread.join(timeout=5.0) + setattr(self, attr_name, None) def get_tool_schemas(self) -> List[Dict[str, Any]]: return [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA] diff --git a/tests/plugins/memory/test_supermemory_provider.py b/tests/plugins/memory/test_supermemory_provider.py index 0bee1d2150c0..689793f157a9 100644 --- a/tests/plugins/memory/test_supermemory_provider.py +++ b/tests/plugins/memory/test_supermemory_provider.py @@ -1,4 +1,5 @@ import json +import threading import pytest @@ -163,6 +164,52 @@ def test_on_session_end_ingests_clean_messages(provider): ] +def test_on_memory_write_tracks_thread(provider): + provider.on_memory_write("add", "memory", "Jordan likes concise docs") + assert provider._write_thread is not None + provider._write_thread.join(timeout=1) + assert len(provider._client.add_calls) == 1 + assert provider._client.add_calls[0]["metadata"]["type"] == "explicit_memory" + + +def test_shutdown_joins_and_clears_threads(provider, monkeypatch): + started = threading.Event() + release = threading.Event() + + def slow_add_memory(content, metadata=None, *, entity_context=""): + started.set() + release.wait(timeout=1) + provider._client.add_calls.append({ + "content": content, + "metadata": metadata, + "entity_context": entity_context, + }) + return {"id": "mem_slow"} + + monkeypatch.setattr(provider._client, "add_memory", slow_add_memory) + + provider.sync_turn( + "Please remember this request in long-term memory", + "Absolutely, I will keep that in long-term memory.", + session_id="session-1", + ) + assert started.wait(timeout=1) + assert provider._sync_thread is not None + + started.clear() + provider.on_memory_write("add", "memory", "Jordan likes concise docs") + assert started.wait(timeout=1) + assert provider._write_thread is not None + + release.set() + provider.shutdown() + + assert provider._sync_thread is None + assert provider._write_thread is None + assert provider._prefetch_thread is None + assert len(provider._client.add_calls) == 2 + + def test_store_tool_returns_saved_payload(provider): result = json.loads(provider.handle_tool_call("supermemory_store", {"content": "Jordan likes concise docs"})) assert result["saved"] is True From ac80d595cd6604fa2ab7bc0a18e880cbc0bce22d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 6 Apr 2026 02:13:27 +0000 Subject: [PATCH 194/213] chore(memory): remove supermemory PR scaffolding --- SUPERMEMORY_PR_DRAFT.md | 82 -------------------------- plugins/memory/supermemory/plugin.yaml | 2 - 2 files changed, 84 deletions(-) delete mode 100644 SUPERMEMORY_PR_DRAFT.md diff --git a/SUPERMEMORY_PR_DRAFT.md b/SUPERMEMORY_PR_DRAFT.md deleted file mode 100644 index 64bb7c2103e6..000000000000 --- a/SUPERMEMORY_PR_DRAFT.md +++ /dev/null @@ -1,82 +0,0 @@ -Title -feat(memory): add Supermemory memory provider - -Summary -This PR adds Supermemory as a native Hermes memory provider. - -It implements the upstream `MemoryProvider` interface rather than the general hook-based plugin system, so it works with the current memory-provider lifecycle and setup flow. The provider supports automatic recall, cleaned turn capture, session-end conversation ingest, and four explicit memory tools. - -What is included -- `plugins/memory/supermemory/plugin.yaml` -- `plugins/memory/supermemory/README.md` -- `plugins/memory/supermemory/__init__.py` -- focused tests for provider behavior and failure modes - -Behavior -When enabled, the provider can: -- prefetch relevant memory context before turns -- include profile facts on the first turn and on a configurable cadence -- store cleaned user-assistant turns after each completed response -- ingest the full session on session end via Supermemory conversations API -- expose explicit tools for memory store, search, forget, and profile access - -Tools -- `supermemory_store` -- `supermemory_search` -- `supermemory_forget` -- `supermemory_profile` - -Setup -Use the standard memory setup flow: - -```bash -hermes memory setup -``` - -Select `supermemory`, then provide: -- `SUPERMEMORY_API_KEY` in `.env` -- optional non-secret config written to `$HERMES_HOME/supermemory.json` - -Config surface -The provider currently supports: -- `container_tag` -- `auto_recall` -- `auto_capture` -- `max_recall_results` -- `profile_frequency` -- `capture_mode` -- `entity_context` -- `api_timeout` - -Design notes -- Implemented as a native memory provider so it fits Hermes's pluggable memory system and `hermes memory setup` / `status` flow. -- The implementation preserves the useful behavior of a working local Supermemory integration, but removes local-only naming and packaging assumptions. -- Recall context is fenced and stripped before capture to avoid recursive memory pollution. -- `on_memory_write()` is intentionally conservative in v1 and mirrors `add` writes only. Supermemory is not a simple CRUD table, so pretending replace/remove are lossless would be dishonest. -- Session-end ingest is kept in the provider because Supermemory's conversation ingestion endpoint is a meaningful part of the backend's graph-building behavior. - -Failure behavior -- `is_available()` performs no network calls -- missing API key or missing SDK leaves the provider unavailable without crashing Hermes -- recall, capture, and ingest failures degrade quietly and do not break the agent loop - -Validation -Ran: - -```bash -python3 -m pytest tests/plugins/memory/test_supermemory_provider.py tests/agent/test_memory_provider.py tests/agent/test_memory_plugin_e2e.py -q -``` - -Result: -- `76 passed` - -Reviewer-facing summary -This PR adds Supermemory as a first-class Hermes memory provider in the same structural shape as the existing built-in providers. It is setup-compatible, failure-tolerant, and intentionally conservative where backend semantics differ from simple CRUD expectations. - -The main reason for this PR is straightforward: Supermemory is a real external memory backend, and the right way to integrate it upstream is through the native `MemoryProvider` interface, not through a user-local hook plugin. - -Points to review -- provider shape and config UX alignment with other memory providers -- recall formatting and profile cadence behavior -- whether the add-only `on_memory_write()` bridge is the right v1 scope -- test coverage for the provider lifecycle and failure paths diff --git a/plugins/memory/supermemory/plugin.yaml b/plugins/memory/supermemory/plugin.yaml index 372edb2b7c51..23321bdb52cd 100644 --- a/plugins/memory/supermemory/plugin.yaml +++ b/plugins/memory/supermemory/plugin.yaml @@ -3,5 +3,3 @@ version: 1.0.0 description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest." pip_dependencies: - supermemory -hooks: - - on_session_end From 88bba31b7d652b9bc9a5f85fd9d24326611b8f1e Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 21:50:37 -0700 Subject: [PATCH 195/213] fix: use get_hermes_home() for profile-scoped storage, fix README - Replace hardcoded os.path.expanduser('~/.hermes') with get_hermes_home() from hermes_constants for profile isolation - Fix README echo command quoting error --- plugins/memory/supermemory/README.md | 2 +- plugins/memory/supermemory/__init__.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/memory/supermemory/README.md b/plugins/memory/supermemory/README.md index f34e4c997f30..7c1310fe1850 100644 --- a/plugins/memory/supermemory/README.md +++ b/plugins/memory/supermemory/README.md @@ -17,7 +17,7 @@ Or manually: ```bash hermes config set memory.provider supermemory -echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env +echo 'SUPERMEMORY_API_KEY=your-key-here' >> ~/.hermes/.env ``` ## Config diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index f798b4a14f68..ee2c35e6faa8 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -399,7 +399,7 @@ def __init__(self): self._capture_mode = _DEFAULT_CAPTURE_MODE self._entity_context = _DEFAULT_ENTITY_CONTEXT self._api_timeout = _DEFAULT_API_TIMEOUT - self._hermes_home = os.path.expanduser("~/.hermes") + self._hermes_home = "" self._write_enabled = True self._active = False @@ -439,7 +439,8 @@ def save_config(self, values, hermes_home): _save_supermemory_config(sanitized, hermes_home) def initialize(self, session_id: str, **kwargs) -> None: - self._hermes_home = kwargs.get("hermes_home") or os.path.expanduser("~/.hermes") + from hermes_constants import get_hermes_home + self._hermes_home = kwargs.get("hermes_home") or str(get_hermes_home()) self._session_id = session_id self._turn_count = 0 self._config = _load_supermemory_config(self._hermes_home) From c7768137fa058f8462a24e804c2e4b694ba9c5fb Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 21:54:42 -0700 Subject: [PATCH 196/213] docs: add Supermemory to memory providers docs, env vars, CLI reference - Add full Supermemory section to memory-providers.md with config table, tools, setup instructions, and key features - Update provider count from 7 to 8 across memory.md and memory-providers.md - Add SUPERMEMORY_API_KEY to environment-variables.md - Add Supermemory to integrations/providers.md optional API keys table - Add supermemory to cli-commands.md provider list - Add Supermemory to profile isolation section (config file providers) --- website/docs/integrations/providers.md | 1 + website/docs/reference/cli-commands.md | 2 +- .../docs/reference/environment-variables.md | 1 + .../user-guide/features/memory-providers.md | 50 +++++++++++++++++-- website/docs/user-guide/features/memory.md | 2 +- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 643cdbf5bcec..ca6a0c511806 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -846,6 +846,7 @@ You can switch between providers at any time with `hermes model` — no restart | OpenAI TTS + voice transcription | [OpenAI](https://platform.openai.com/api-keys) | `VOICE_TOOLS_OPENAI_KEY` | | RL Training | [Tinker](https://tinker-console.thinkingmachines.ai/) + [WandB](https://wandb.ai/) | `TINKER_API_KEY`, `WANDB_API_KEY` | | Cross-session user modeling | [Honcho](https://honcho.dev/) | `HONCHO_API_KEY` | +| Semantic long-term memory | [Supermemory](https://supermemory.ai) | `SUPERMEMORY_API_KEY` | ### Self-Hosting Firecrawl diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 5fbe921b5ebb..55983b1c6998 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -383,7 +383,7 @@ Subcommands: hermes memory ``` -Set up and manage external memory provider plugins. Available providers: honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. Only one external provider can be active at a time. Built-in memory (MEMORY.md/USER.md) is always active. +Set up and manage external memory provider plugins. Available providers: honcho, openviking, mem0, hindsight, holographic, retaindb, byterover, supermemory. Only one external provider can be active at a time. Built-in memory (MEMORY.md/USER.md) is always active. Subcommands: diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index fb2a67523643..8993493251e9 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -98,6 +98,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `GITHUB_TOKEN` | GitHub token for Skills Hub (higher API rate limits, skill publish) | | `HONCHO_API_KEY` | Cross-session user modeling ([honcho.dev](https://honcho.dev/)) | | `HONCHO_BASE_URL` | Base URL for self-hosted Honcho instances (default: Honcho cloud). No API key required for local instances | +| `SUPERMEMORY_API_KEY` | Semantic long-term memory with profile recall and session ingest ([supermemory.ai](https://supermemory.ai)) | | `TINKER_API_KEY` | RL training ([tinker-console.thinkingmachines.ai](https://tinker-console.thinkingmachines.ai/)) | | `WANDB_API_KEY` | RL training metrics ([wandb.ai](https://wandb.ai/)) | | `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) | diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 3c4150ffdb21..3396c70e7e31 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -1,12 +1,12 @@ --- sidebar_position: 4 title: "Memory Providers" -description: "External memory provider plugins — Honcho, OpenViking, Mem0, Hindsight, Holographic, RetainDB, ByteRover" +description: "External memory provider plugins — Honcho, OpenViking, Mem0, Hindsight, Holographic, RetainDB, ByteRover, Supermemory" --- # Memory Providers -Hermes Agent ships with 7 external memory provider plugins that give the agent persistent, cross-session knowledge beyond the built-in MEMORY.md and USER.md. Only **one** external provider can be active at a time — the built-in memory is always active alongside it. +Hermes Agent ships with 8 external memory provider plugins that give the agent persistent, cross-session knowledge beyond the built-in MEMORY.md and USER.md. Only **one** external provider can be active at a time — the built-in memory is always active alongside it. ## Quick Start @@ -20,7 +20,7 @@ Or set manually in `~/.hermes/config.yaml`: ```yaml memory: - provider: openviking # or honcho, mem0, hindsight, holographic, retaindb, byterover + provider: openviking # or honcho, mem0, hindsight, holographic, retaindb, byterover, supermemory ``` ## How It Works @@ -382,6 +382,47 @@ hermes config set memory.provider byterover --- +### Supermemory + +Semantic long-term memory with profile recall, semantic search, explicit memory tools, and session-end conversation ingest via the Supermemory graph API. + +| | | +|---|---| +| **Best for** | Semantic recall with user profiling and session-level graph building | +| **Requires** | `pip install supermemory` + [API key](https://supermemory.ai) | +| **Data storage** | Supermemory Cloud | +| **Cost** | Supermemory pricing | + +**Tools:** `supermemory_store` (save explicit memories), `supermemory_search` (semantic similarity search), `supermemory_forget` (forget by ID or best-match query), `supermemory_profile` (persistent profile + recent context) + +**Setup:** +```bash +hermes memory setup # select "supermemory" +# Or manually: +hermes config set memory.provider supermemory +echo 'SUPERMEMORY_API_KEY=your-key-here' >> ~/.hermes/.env +``` + +**Config:** `$HERMES_HOME/supermemory.json` + +| Key | Default | Description | +|-----|---------|-------------| +| `container_tag` | `hermes` | Container tag used for search and writes | +| `auto_recall` | `true` | Inject relevant memory context before turns | +| `auto_capture` | `true` | Store cleaned user-assistant turns after each response | +| `max_recall_results` | `10` | Max recalled items to format into context | +| `profile_frequency` | `50` | Include profile facts on first turn and every N turns | +| `capture_mode` | `all` | Skip tiny or trivial turns by default | +| `api_timeout` | `5.0` | Timeout for SDK and ingest requests | + +**Key features:** +- Automatic context fencing — strips recalled memories from captured turns to prevent recursive memory pollution +- Session-end conversation ingest for richer graph-level knowledge building +- Profile facts injected on first turn and at configurable intervals +- Trivial message filtering (skips "ok", "thanks", etc.) + +--- + ## Provider Comparison | Provider | Storage | Cost | Tools | Dependencies | Unique Feature | @@ -393,13 +434,14 @@ hermes config set memory.provider byterover | **Holographic** | Local | Free | 2 | None | HRR algebra + trust scoring | | **RetainDB** | Cloud | $20/mo | 5 | `requests` | Delta compression | | **ByteRover** | Local/Cloud | Free/Paid | 3 | `brv` CLI | Pre-compression extraction | +| **Supermemory** | Cloud | Paid | 4 | `supermemory` | Context fencing + session graph ingest | ## Profile Isolation Each provider's data is isolated per [profile](/docs/user-guide/profiles): - **Local storage providers** (Holographic, ByteRover) use `$HERMES_HOME/` paths which differ per profile -- **Config file providers** (Honcho, Mem0, Hindsight) store config in `$HERMES_HOME/` so each profile has its own credentials +- **Config file providers** (Honcho, Mem0, Hindsight, Supermemory) store config in `$HERMES_HOME/` so each profile has its own credentials - **Cloud providers** (RetainDB) auto-derive profile-scoped project names - **Env var providers** (OpenViking) are configured via each profile's `.env` file diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index 8be3f748f012..77f74d28a8b4 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -209,7 +209,7 @@ memory: ## External Memory Providers -For deeper, persistent memory that goes beyond MEMORY.md and USER.md, Hermes ships with 7 external memory provider plugins — including Honcho, OpenViking, Mem0, Hindsight, Holographic, RetainDB, and ByteRover. +For deeper, persistent memory that goes beyond MEMORY.md and USER.md, Hermes ships with 8 external memory provider plugins — including Honcho, OpenViking, Mem0, Hindsight, Holographic, RetainDB, ByteRover, and Supermemory. External providers run **alongside** built-in memory (never replacing it) and add capabilities like knowledge graphs, semantic search, automatic fact extraction, and cross-session user modeling. From bff47eee486858ec00744b08962c8854cc3dd030 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Apr 2026 15:48:16 +1000 Subject: [PATCH 197/213] fix: HERMES_PORTAL_BASE_URL env var ignored during Nous login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _login_nous() was passing pconfig.portal_base_url (hardcoded production URL) as a fallback when no --portal-url CLI flag was given. This meant _nous_device_code_login() received a truthy portal_base_url argument and never reached the env var fallback chain. Users setting HERMES_PORTAL_BASE_URL or NOUS_PORTAL_BASE_URL in .env to point at a staging portal were silently ignored — login always went to production. Fix: pass None when no CLI flag is provided, letting the downstream function properly check env vars before falling back to the default. Fallback chain is now: 1. --portal-url CLI arg 2. HERMES_PORTAL_BASE_URL env var 3. NOUS_PORTAL_BASE_URL env var 4. DEFAULT_NOUS_PORTAL_URL (production) Same fix applied to inference_base_url for consistency. --- hermes_cli/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 94cc08f2a4e1..d8c628bbaa17 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2577,8 +2577,8 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: try: auth_state = _nous_device_code_login( - portal_base_url=getattr(args, "portal_url", None) or pconfig.portal_base_url, - inference_base_url=getattr(args, "inference_url", None) or pconfig.inference_base_url, + portal_base_url=getattr(args, "portal_url", None), + inference_base_url=getattr(args, "inference_url", None), client_id=getattr(args, "client_id", None) or pconfig.client_id, scope=getattr(args, "scope", None) or pconfig.scope, open_browser=not getattr(args, "no_browser", False), From 5a2cf280a3d652b10cfc15c4944dbc167df3ed5e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:00:04 -0700 Subject: [PATCH 198/213] feat: interactive model picker for Telegram and Discord (#5742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /model with no args now shows an interactive UI on Telegram and Discord instead of a text list: Telegram: Inline keyboard buttons — two-step drill-down. Step 1: Provider buttons with model counts (e.g. 'OpenRouter (15)') Step 2: Model buttons within the selected provider Edits the same message in-place as the user navigates. Back/Cancel buttons for navigation. Discord: Embed + Select dropdown menus via discord.ui.View. Step 1: Provider dropdown with model counts Step 2: Model dropdown within the selected provider Back/Cancel buttons. Auth-gated to allowed users. Platforms without picker support (Slack, WhatsApp, Signal, etc.) fall back to the existing text list. /model continues to work as a direct text switch on all platforms — the interactive picker is only for bare /model. Implementation: - TelegramAdapter.send_model_picker() + _handle_model_picker_callback() with compact callback_data (mp:/mm:/mb/mx, all within 64-byte limit) - DiscordAdapter.send_model_picker() + ModelPickerView (discord.ui.View) with Select menus (up to 25 options per dropdown) - GatewayRunner._handle_model_command() detects adapter capability via getattr(type(adapter), 'send_model_picker', None) (safe with mocks) and sends picker with async callback closure for the switch logic - Callback performs full switch: switch_model(), cached agent update, session override, pending model note — same as /model --- gateway/platforms/discord.py | 276 ++++++++++++++++++++++++++++++++++ gateway/platforms/telegram.py | 242 ++++++++++++++++++++++++++++- gateway/run.py | 116 +++++++++++++- 3 files changed, 630 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 75ba3d1153db..5e1be74ad4b9 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2039,6 +2039,66 @@ async def send_update_prompt( except Exception as e: return SendResult(success=False, error=str(e)) + async def send_model_picker( + self, + chat_id: str, + providers: list, + current_model: str, + current_provider: str, + session_key: str, + on_model_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an interactive select-menu model picker. + + Two-step drill-down: provider dropdown → model dropdown. + Uses Discord embeds + Select menus via ``ModelPickerView``. + """ + if not self._client or not DISCORD_AVAILABLE: + return SendResult(success=False, error="Not connected") + + try: + # Resolve target channel (use thread_id if present) + target_id = chat_id + if metadata and metadata.get("thread_id"): + target_id = metadata["thread_id"] + + channel = self._client.get_channel(int(target_id)) + if not channel: + channel = await self._client.fetch_channel(int(target_id)) + + try: + from hermes_cli.providers import get_label + provider_label = get_label(current_provider) + except Exception: + provider_label = current_provider + + embed = discord.Embed( + title="⚙ Model Configuration", + description=( + f"Current model: `{current_model or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ), + color=discord.Color.blue(), + ) + + view = ModelPickerView( + providers=providers, + current_model=current_model, + current_provider=current_provider, + session_key=session_key, + on_model_selected=on_model_selected, + allowed_user_ids=self._allowed_user_ids, + ) + + msg = await channel.send(embed=embed, view=view) + return SendResult(success=True, message_id=str(msg.id)) + + except Exception as e: + logger.warning("[%s] send_model_picker failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + def _get_parent_channel_id(self, channel: Any) -> Optional[str]: """Return the parent channel ID for a Discord thread-like channel, if present.""" parent = getattr(channel, "parent", None) @@ -2530,3 +2590,219 @@ async def on_timeout(self): self.resolved = True for child in self.children: child.disabled = True + + class ModelPickerView(discord.ui.View): + """Interactive select-menu view for model switching. + + Two-step drill-down: provider dropdown → model dropdown. + Edits the original message in-place as the user navigates. + Times out after 2 minutes. + """ + + def __init__( + self, + providers: list, + current_model: str, + current_provider: str, + session_key: str, + on_model_selected, + allowed_user_ids: set, + ): + super().__init__(timeout=120) + self.providers = providers + self.current_model = current_model + self.current_provider = current_provider + self.session_key = session_key + self.on_model_selected = on_model_selected + self.allowed_user_ids = allowed_user_ids + self.resolved = False + self._selected_provider: str = "" + + self._build_provider_select() + + def _check_auth(self, interaction: discord.Interaction) -> bool: + if not self.allowed_user_ids: + return True + return str(interaction.user.id) in self.allowed_user_ids + + def _build_provider_select(self): + """Build the provider dropdown menu.""" + self.clear_items() + options = [] + for p in self.providers: + count = p.get("total_models", len(p.get("models", []))) + label = f"{p['name']} ({count} models)" + desc = "current" if p.get("is_current") else None + options.append( + discord.SelectOption( + label=label[:100], + value=p["slug"], + default=bool(p.get("is_current")), + description=desc, + ) + ) + if not options: + return + + select = discord.ui.Select( + placeholder="Choose a provider...", + options=options[:25], + custom_id="model_provider_select", + ) + select.callback = self._on_provider_selected + self.add_item(select) + + cancel_btn = discord.ui.Button( + label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel" + ) + cancel_btn.callback = self._on_cancel + self.add_item(cancel_btn) + + def _build_model_select(self, provider_slug: str): + """Build the model dropdown for a specific provider.""" + self.clear_items() + provider = next( + (p for p in self.providers if p["slug"] == provider_slug), None + ) + if not provider: + return + + models = provider.get("models", []) + options = [] + for model_id in models[:25]: + short = model_id.split("/")[-1] if "/" in model_id else model_id + options.append( + discord.SelectOption( + label=short[:100], + value=model_id[:100], + ) + ) + if not options: + return + + select = discord.ui.Select( + placeholder=f"Choose a model from {provider.get('name', provider_slug)}...", + options=options, + custom_id="model_model_select", + ) + select.callback = self._on_model_selected + self.add_item(select) + + back_btn = discord.ui.Button( + label="◀ Back", style=discord.ButtonStyle.grey, custom_id="model_back" + ) + back_btn.callback = self._on_back + self.add_item(back_btn) + + cancel_btn = discord.ui.Button( + label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel2" + ) + cancel_btn.callback = self._on_cancel + self.add_item(cancel_btn) + + async def _on_provider_selected(self, interaction: discord.Interaction): + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + provider_slug = interaction.data["values"][0] + self._selected_provider = provider_slug + provider = next( + (p for p in self.providers if p["slug"] == provider_slug), None + ) + pname = provider.get("name", provider_slug) if provider else provider_slug + + self._build_model_select(provider_slug) + + total = provider.get("total_models", 0) if provider else 0 + shown = min(len(provider.get("models", [])), 25) if provider else 0 + extra = f"\n*{total - shown} more available — type `/model ` directly*" if total > shown else "" + + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Configuration", + description=f"Provider: **{pname}**\nSelect a model:{extra}", + color=discord.Color.blue(), + ), + view=self, + ) + + async def _on_model_selected(self, interaction: discord.Interaction): + if self.resolved: + await interaction.response.send_message( + "Already resolved~", ephemeral=True + ) + return + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + self.resolved = True + model_id = interaction.data["values"][0] + + try: + result_text = await self.on_model_selected( + str(interaction.channel_id), + model_id, + self._selected_provider, + ) + except Exception as exc: + result_text = f"Error switching model: {exc}" + + self.clear_items() + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Switched", + description=result_text, + color=discord.Color.green(), + ), + view=self, + ) + + async def _on_back(self, interaction: discord.Interaction): + if not self._check_auth(interaction): + await interaction.response.send_message( + "You're not authorized~", ephemeral=True + ) + return + + self._build_provider_select() + + try: + from hermes_cli.providers import get_label + provider_label = get_label(self.current_provider) + except Exception: + provider_label = self.current_provider + + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Configuration", + description=( + f"Current model: `{self.current_model or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ), + color=discord.Color.blue(), + ), + view=self, + ) + + async def _on_cancel(self, interaction: discord.Interaction): + self.resolved = True + self.clear_items() + await interaction.response.edit_message( + embed=discord.Embed( + title="⚙ Model Configuration", + description="Model selection cancelled.", + color=discord.Color.greyple(), + ), + view=self, + ) + + async def on_timeout(self): + self.resolved = True + self.clear_items() diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 7575c10f377e..0362b9f96a7a 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -151,6 +151,8 @@ def __init__(self, config: PlatformConfig): self._dm_topics: Dict[str, int] = {} # DM Topics config from extra.dm_topics self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", []) + # Interactive model picker state per chat + self._model_picker_state: Dict[str, dict] = {} def _fallback_ips(self) -> list[str]: """Return validated fallback IPs from config (populated by _apply_env_overrides).""" @@ -1008,14 +1010,252 @@ async def send_update_prompt( logger.warning("[%s] send_update_prompt failed: %s", self.name, e) return SendResult(success=False, error=str(e)) + async def send_model_picker( + self, + chat_id: str, + providers: list, + current_model: str, + current_provider: str, + session_key: str, + on_model_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an interactive inline-keyboard model picker. + + Two-step drill-down: provider selection → model selection. + Edits the same message in-place as the user navigates. + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + from hermes_cli.providers import get_label + except ImportError: + def get_label(slug): + return slug + + try: + # Build provider buttons — 2 per row + buttons: list = [] + for p in providers: + count = p.get("total_models", len(p.get("models", []))) + label = f"{p['name']} ({count})" + if p.get("is_current"): + label = f"✓ {label}" + # Compact callback data: mp: (max 64 bytes) + buttons.append( + InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}") + ) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")]) + keyboard = InlineKeyboardMarkup(rows) + + provider_label = get_label(current_provider) + text = ( + f"⚙ *Model Configuration*\n\n" + f"Current model: `{current_model or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ) + + thread_id = metadata.get("thread_id") if metadata else None + msg = await self._bot.send_message( + chat_id=int(chat_id), + text=text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + message_thread_id=int(thread_id) if thread_id else None, + ) + + # Store picker state keyed by chat_id + self._model_picker_state[str(chat_id)] = { + "msg_id": msg.message_id, + "providers": providers, + "session_key": session_key, + "on_model_selected": on_model_selected, + "current_model": current_model, + "current_provider": current_provider, + } + + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning("[%s] send_model_picker failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + async def _handle_model_picker_callback( + self, query, data: str, chat_id: str + ) -> None: + """Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:).""" + state = self._model_picker_state.get(chat_id) + if not state: + await query.answer(text="Picker expired — use /model again.") + return + + try: + from hermes_cli.providers import get_label + except ImportError: + def get_label(slug): + return slug + + if data.startswith("mp:"): + # --- Provider selected: show model buttons --- + provider_slug = data[3:] + provider = next( + (p for p in state["providers"] if p["slug"] == provider_slug), + None, + ) + if not provider: + await query.answer(text="Provider not found.") + return + + models = provider.get("models", []) + state["selected_provider"] = provider_slug + state["selected_provider_name"] = provider.get("name", provider_slug) + state["model_list"] = models + + buttons: list = [] + for i, model_id in enumerate(models): + # Short display label: strip vendor prefix + short = model_id.split("/")[-1] if "/" in model_id else model_id + # Truncate long model names for button label (max ~40 chars) + if len(short) > 38: + short = short[:35] + "..." + buttons.append( + InlineKeyboardButton(short, callback_data=f"mm:{i}") + ) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append([ + InlineKeyboardButton("◀ Back", callback_data="mb"), + InlineKeyboardButton("✗ Cancel", callback_data="mx"), + ]) + keyboard = InlineKeyboardMarkup(rows) + + pname = provider.get("name", provider_slug) + total = provider.get("total_models", len(models)) + shown = len(models) + extra = f"\n_{total - shown} more available — type `/model ` directly_" if total > shown else "" + + await query.edit_message_text( + text=( + f"⚙ *Model Configuration*\n\n" + f"Provider: *{pname}*\n" + f"Select a model:{extra}" + ), + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + await query.answer() + + elif data.startswith("mm:"): + # --- Model selected: perform the switch --- + try: + idx = int(data[3:]) + except ValueError: + await query.answer(text="Invalid selection.") + return + + model_list = state.get("model_list", []) + if idx < 0 or idx >= len(model_list): + await query.answer(text="Invalid model index.") + return + + model_id = model_list[idx] + provider_slug = state.get("selected_provider", "") + callback = state.get("on_model_selected") + + if not callback: + await query.answer(text="Picker expired.") + return + + try: + result_text = await callback(chat_id, model_id, provider_slug) + except Exception as exc: + logger.error("Model picker switch failed: %s", exc) + result_text = f"Error switching model: {exc}" + + # Edit message to show confirmation, remove buttons + try: + await query.edit_message_text( + text=result_text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=None, + ) + except Exception: + # Markdown parse failure — retry as plain text + try: + await query.edit_message_text( + text=result_text, + parse_mode=None, + reply_markup=None, + ) + except Exception: + pass + await query.answer(text="Model switched!") + + # Clean up state + self._model_picker_state.pop(chat_id, None) + + elif data == "mb": + # --- Back to provider list --- + buttons = [] + for p in state["providers"]: + count = p.get("total_models", len(p.get("models", []))) + label = f"{p['name']} ({count})" + if p.get("is_current"): + label = f"✓ {label}" + buttons.append( + InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}") + ) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")]) + keyboard = InlineKeyboardMarkup(rows) + + try: + provider_label = get_label(state["current_provider"]) + except Exception: + provider_label = state["current_provider"] + + await query.edit_message_text( + text=( + f"⚙ *Model Configuration*\n\n" + f"Current model: `{state['current_model'] or 'unknown'}`\n" + f"Provider: {provider_label}\n\n" + f"Select a provider:" + ), + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + await query.answer() + + elif data == "mx": + # --- Cancel --- + self._model_picker_state.pop(chat_id, None) + await query.edit_message_text( + text="Model selection cancelled.", + reply_markup=None, + ) + await query.answer() + async def _handle_callback_query( self, update: "Update", context: "ContextTypes.DEFAULT_TYPE" ) -> None: - """Handle inline keyboard button clicks (update prompts).""" + """Handle inline keyboard button clicks.""" query = update.callback_query if not query or not query.data: return data = query.data + + # --- Model picker callbacks --- + if data.startswith(("mp:", "mm:", "mb", "mx")): + chat_id = str(query.message.chat_id) if query.message else None + if chat_id: + await self._handle_model_picker_callback(query, data, chat_id) + return + + # --- Update prompt callbacks --- if not data.startswith("update_prompt:"): return answer = data.split(":", 1)[1] # "y" or "n" diff --git a/gateway/run.py b/gateway/run.py index 9d5ac5aa2c63..08be2b9db2cb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3464,11 +3464,11 @@ async def _handle_commands_command(self, event: MessageEvent) -> str: lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") return "\n".join(lines) - async def _handle_model_command(self, event: MessageEvent) -> str: + async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: """Handle /model command — switch model for this session. Supports: - /model — show current model info + /model — interactive picker (Telegram/Discord) or text list /model — switch for this session only /model --global — switch and persist to config.yaml /model --provider — switch provider + model @@ -3516,8 +3516,118 @@ async def _handle_model_command(self, event: MessageEvent) -> str: current_base_url = override.get("base_url", current_base_url) current_api_key = override.get("api_key", current_api_key) - # No args: show authenticated providers with models + # No args: show interactive picker (Telegram/Discord) or text list if not model_input and not explicit_provider: + # Try interactive picker if the platform supports it + adapter = self.adapters.get(source.platform) + has_picker = ( + adapter is not None + and getattr(type(adapter), "send_model_picker", None) is not None + ) + + if has_picker: + try: + providers = list_authenticated_providers( + current_provider=current_provider, + user_providers=user_provs, + max_models=8, + ) + except Exception: + providers = [] + + if providers: + # Build a callback closure for when the user picks a model. + # Captures self + locals needed for the switch logic. + _self = self + _session_key = session_key + _cur_model = current_model + _cur_provider = current_provider + _cur_base_url = current_base_url + _cur_api_key = current_api_key + + async def _on_model_selected( + _chat_id: str, model_id: str, provider_slug: str + ) -> str: + """Perform the model switch and return confirmation text.""" + result = _switch_model( + raw_input=model_id, + current_provider=_cur_provider, + current_model=_cur_model, + current_base_url=_cur_base_url, + current_api_key=_cur_api_key, + is_global=False, + explicit_provider=provider_slug, + ) + if not result.success: + return f"Error: {result.error_message}" + + # Update cached agent in-place + cached_entry = None + _cache_lock = getattr(_self, "_agent_cache_lock", None) + _cache = getattr(_self, "_agent_cache", None) + if _cache_lock and _cache is not None: + with _cache_lock: + cached_entry = _cache.get(_session_key) + if cached_entry and cached_entry[0] is not None: + try: + cached_entry[0].switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + except Exception as exc: + logger.warning("Picker model switch failed for cached agent: %s", exc) + + # Store model note + session override + if not hasattr(_self, "_pending_model_notes"): + _self._pending_model_notes = {} + _self._pending_model_notes[_session_key] = ( + f"[Note: model was just switched from {_cur_model} to {result.new_model} " + f"via {result.provider_label or result.target_provider}. " + f"Adjust your self-identification accordingly.]" + ) + if not hasattr(_self, "_session_model_overrides"): + _self._session_model_overrides = {} + _self._session_model_overrides[_session_key] = { + "model": result.new_model, + "provider": result.target_provider, + "api_key": result.api_key, + "base_url": result.base_url, + "api_mode": result.api_mode, + } + + # Build confirmation text + plabel = result.provider_label or result.target_provider + lines = [f"Model switched to `{result.new_model}`"] + lines.append(f"Provider: {plabel}") + mi = result.model_info + if mi: + if mi.context_window: + lines.append(f"Context: {mi.context_window:,} tokens") + if mi.max_output: + lines.append(f"Max output: {mi.max_output:,} tokens") + if mi.has_cost_data(): + lines.append(f"Cost: {mi.format_cost()}") + lines.append(f"Capabilities: {mi.format_capabilities()}") + lines.append("_(session only — use `/model --global` to persist)_") + return "\n".join(lines) + + metadata = {"thread_id": source.thread_id} if source.thread_id else None + result = await adapter.send_model_picker( + chat_id=source.chat_id, + providers=providers, + current_model=current_model, + current_provider=current_provider, + session_key=session_key, + on_model_selected=_on_model_selected, + metadata=metadata, + ) + if result.success: + return None # Picker sent — adapter handles the response + + # Fallback: text list (for platforms without picker or if picker failed) provider_label = get_label(current_provider) lines = [f"Current: `{current_model or 'unknown'}` on {provider_label}", ""] From 8dee82ea1e1783b95b380f074386c7a9c5cf376c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:00:14 -0700 Subject: [PATCH 199/213] fix: stream consumer creates new message after tool boundaries (#5739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When streaming was enabled on the gateway, the stream consumer created a single message at the start and kept editing it as tokens arrived. Tool progress messages were sent as separate messages below it. Since edits don't change message position on Telegram/Matrix/Discord, the final response ended up stuck above all tool progress messages — users had to scroll up past potentially dozens of tool call lines to read the answer. The agent already sends stream_delta_callback(None) at tool boundaries (before _execute_tool_calls). The stream consumer was ignoring this signal. Now it treats None as a segment break: finalizes the current message (removes cursor), resets _message_id, and the next text chunk creates a fresh message below the tool progress messages. Timeline before: [msg 1: 'Let me search...' → edits → 'Here is the answer'] ← top [msg 2: tool progress lines] ← bottom Timeline after: [msg 1: 'Let me search...'] ← top [msg 2: tool progress lines] [msg 3: 'Here is the answer'] ← bottom (visible) Reported by SkyLinx on Discord. --- gateway/stream_consumer.py | 29 ++++- tests/gateway/test_stream_consumer.py | 147 ++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 7f4a73d0b776..59e72755a005 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -28,6 +28,10 @@ # Sentinel to signal the stream is complete _DONE = object() +# Sentinel to signal a tool boundary — finalize current message and start a +# new one so that subsequent text appears below tool progress messages. +_NEW_SEGMENT = object() + @dataclass class StreamConsumerConfig: @@ -78,9 +82,16 @@ def already_sent(self) -> bool: return self._already_sent def on_delta(self, text: str) -> None: - """Thread-safe callback — called from the agent's worker thread.""" + """Thread-safe callback — called from the agent's worker thread. + + When *text* is ``None``, signals a tool boundary: the current message + is finalized and subsequent text will be sent as a new message so it + appears below any tool-progress messages the gateway sent in between. + """ if text: self._queue.put(text) + elif text is None: + self._queue.put(_NEW_SEGMENT) def finish(self) -> None: """Signal that the stream is complete.""" @@ -96,12 +107,16 @@ async def run(self) -> None: while True: # Drain all available items from the queue got_done = False + got_segment_break = False while True: try: item = self._queue.get_nowait() if item is _DONE: got_done = True break + if item is _NEW_SEGMENT: + got_segment_break = True + break self._accumulated += item except queue.Empty: break @@ -111,6 +126,7 @@ async def run(self) -> None: elapsed = now - self._last_edit_time should_edit = ( got_done + or got_segment_break or (elapsed >= self.cfg.edit_interval and len(self._accumulated) > 0) or len(self._accumulated) >= self.cfg.buffer_threshold @@ -133,7 +149,7 @@ async def run(self) -> None: self._last_sent_text = "" display_text = self._accumulated - if not got_done: + if not got_done and not got_segment_break: display_text += self.cfg.cursor await self._send_or_edit(display_text) @@ -145,6 +161,15 @@ async def run(self) -> None: await self._send_or_edit(self._accumulated) return + # Tool boundary: the should_edit block above already flushed + # accumulated text without a cursor. Reset state so the next + # text chunk creates a fresh message below any tool-progress + # messages the gateway sent in between. + if got_segment_break: + self._message_id = None + self._accumulated = "" + self._last_sent_text = "" + await asyncio.sleep(0.05) # Small yield to not busy-loop except asyncio.CancelledError: diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 1234307ca212..6c908bbe400a 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -177,3 +177,150 @@ async def test_stream_with_media_tag(self): assert "MEDIA:" not in sent_text, f"MEDIA: leaked into display: {sent_text!r}" assert consumer.already_sent + + +# ── Segment break (tool boundary) tests ────────────────────────────────── + + +class TestSegmentBreakOnToolBoundary: + """Verify that on_delta(None) finalizes the current message and starts a + new one so the final response appears below tool-progress messages.""" + + @pytest.mark.asyncio + async def test_segment_break_creates_new_message(self): + """After a None boundary, next text creates a fresh message.""" + adapter = MagicMock() + send_result_1 = SimpleNamespace(success=True, message_id="msg_1") + send_result_2 = SimpleNamespace(success=True, message_id="msg_2") + edit_result = SimpleNamespace(success=True) + adapter.send = AsyncMock(side_effect=[send_result_1, send_result_2]) + adapter.edit_message = AsyncMock(return_value=edit_result) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Phase 1: intermediate text before tool calls + consumer.on_delta("Let me search for that...") + # Tool boundary — model is about to call tools + consumer.on_delta(None) + # Phase 2: final response text after tools finished + consumer.on_delta("Here are the results.") + consumer.finish() + + await consumer.run() + + # Should have sent TWO separate messages (two adapter.send calls), + # not just edited the first one. + assert adapter.send.call_count == 2 + first_text = adapter.send.call_args_list[0][1]["content"] + second_text = adapter.send.call_args_list[1][1]["content"] + assert "search" in first_text + assert "results" in second_text + + @pytest.mark.asyncio + async def test_segment_break_no_text_before(self): + """A None boundary with no preceding text is a no-op.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + adapter.send = AsyncMock(return_value=send_result) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # No text before the boundary — model went straight to tool calls + consumer.on_delta(None) + consumer.on_delta("Final answer.") + consumer.finish() + + await consumer.run() + + # Only one send call (the final answer) + assert adapter.send.call_count == 1 + assert "Final answer" in adapter.send.call_args_list[0][1]["content"] + + @pytest.mark.asyncio + async def test_segment_break_removes_cursor(self): + """The finalized segment message should not have a cursor.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + edit_result = SimpleNamespace(success=True) + adapter.send = AsyncMock(return_value=send_result) + adapter.edit_message = AsyncMock(return_value=edit_result) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5, cursor=" ▉") + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + consumer.on_delta("Thinking...") + consumer.on_delta(None) + consumer.on_delta("Done.") + consumer.finish() + + await consumer.run() + + # The first segment should have been finalized without cursor. + # Check all edit_message calls + the initial send for the first segment. + # The last state of msg_1 should NOT have the cursor. + all_texts = [] + for call in adapter.send.call_args_list: + all_texts.append(call[1].get("content", "")) + for call in adapter.edit_message.call_args_list: + all_texts.append(call[1].get("content", "")) + + # Find the text(s) that contain "Thinking" — the finalized version + # should not have the cursor. + thinking_texts = [t for t in all_texts if "Thinking" in t] + assert thinking_texts, "Expected at least one message with 'Thinking'" + # The LAST occurrence is the finalized version + assert "▉" not in thinking_texts[-1], ( + f"Cursor found in finalized segment: {thinking_texts[-1]!r}" + ) + + @pytest.mark.asyncio + async def test_multiple_segment_breaks(self): + """Multiple tool boundaries create multiple message segments.""" + adapter = MagicMock() + msg_counter = iter(["msg_1", "msg_2", "msg_3"]) + adapter.send = AsyncMock( + side_effect=lambda **kw: SimpleNamespace(success=True, message_id=next(msg_counter)) + ) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + consumer.on_delta("Phase 1") + consumer.on_delta(None) # tool boundary + consumer.on_delta("Phase 2") + consumer.on_delta(None) # another tool boundary + consumer.on_delta("Phase 3") + consumer.finish() + + await consumer.run() + + # Three separate messages + assert adapter.send.call_count == 3 + + @pytest.mark.asyncio + async def test_already_sent_stays_true_after_segment(self): + """already_sent remains True after a segment break.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + adapter.send = AsyncMock(return_value=send_result) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + consumer.on_delta("Text") + consumer.on_delta(None) + consumer.finish() + + await consumer.run() + + assert consumer.already_sent From f2c11ff30cd5601a4017cae64cbbeac0a481f5c9 Mon Sep 17 00:00:00 2001 From: Mateus Scheuer Macedo Date: Mon, 6 Apr 2026 22:59:14 -0700 Subject: [PATCH 200/213] fix(delegate): share credential pools with subagents + per-task leasing Cherry-picked from PR #5580 by MestreY0d4-Uninter. - Share parent's credential pool with child agents for key rotation - Leasing layer spreads parallel children across keys (least-loaded) - Thread-safe acquire_lease/release_lease in CredentialPool - Reverted sneaked-in tool-name restoration change (kept original getattr + isinstance guard pattern) --- agent/credential_pool.py | 50 ++++++++++++++ tests/test_credential_pool.py | 81 +++++++++++++++++++++- tests/tools/test_delegate.py | 122 ++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 58 ++++++++++++++++ 4 files changed, 308 insertions(+), 3 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 472f65f2d129..f57ae049c142 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -348,6 +348,9 @@ def get_pool_strategy(provider: str) -> str: return STRATEGY_FILL_FIRST +DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1 + + class CredentialPool: def __init__(self, provider: str, entries: List[PooledCredential]): self.provider = provider @@ -355,6 +358,8 @@ def __init__(self, provider: str, entries: List[PooledCredential]): self._current_id: Optional[str] = None self._strategy = get_pool_strategy(provider) self._lock = threading.Lock() + self._active_leases: Dict[str, int] = {} + self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL def has_credentials(self) -> bool: return bool(self._entries) @@ -760,6 +765,51 @@ def mark_exhausted_and_rotate( logger.info("credential pool: rotated to %s", _next_label) return next_entry + def acquire_lease(self, credential_id: Optional[str] = None) -> Optional[str]: + """Acquire a soft lease on a credential. + + If a specific credential_id is provided, lease that entry directly. + Otherwise prefer the least-leased available credential, using priority as + a stable tie-breaker. When every credential is already at the soft cap, + still return the least-leased one instead of blocking. + """ + with self._lock: + if credential_id: + self._active_leases[credential_id] = self._active_leases.get(credential_id, 0) + 1 + self._current_id = credential_id + return credential_id + + available = self._available_entries(clear_expired=True, refresh=True) + if not available: + return None + + below_cap = [ + entry for entry in available + if self._active_leases.get(entry.id, 0) < self._max_concurrent + ] + candidates = below_cap if below_cap else available + chosen = min( + candidates, + key=lambda entry: (self._active_leases.get(entry.id, 0), entry.priority), + ) + self._active_leases[chosen.id] = self._active_leases.get(chosen.id, 0) + 1 + self._current_id = chosen.id + return chosen.id + + def release_lease(self, credential_id: str) -> None: + """Release a previously acquired credential lease.""" + with self._lock: + count = self._active_leases.get(credential_id, 0) + if count <= 1: + self._active_leases.pop(credential_id, None) + else: + self._active_leases[credential_id] = count - 1 + + def active_lease_count(self, credential_id: str) -> int: + """Return the number of active leases for a credential.""" + with self._lock: + return self._active_leases.get(credential_id, 0) + def try_refresh_current(self) -> Optional[PooledCredential]: with self._lock: return self._try_refresh_current_unlocked() diff --git a/tests/test_credential_pool.py b/tests/test_credential_pool.py index ff6e037be306..891ab68a825f 100644 --- a/tests/test_credential_pool.py +++ b/tests/test_credential_pool.py @@ -947,7 +947,7 @@ def test_list_custom_pool_providers(tmp_path, monkeypatch): "auth_type": "api_key", "priority": 0, "source": "manual", - "access_token": "sk-ant-xxx", + "access_token": "***", } ], "custom:together.ai": [ @@ -957,7 +957,7 @@ def test_list_custom_pool_providers(tmp_path, monkeypatch): "auth_type": "api_key", "priority": 0, "source": "manual", - "access_token": "sk-tog-xxx", + "access_token": "***", } ], "custom:fireworks": [ @@ -967,7 +967,7 @@ def test_list_custom_pool_providers(tmp_path, monkeypatch): "auth_type": "api_key", "priority": 0, "source": "manual", - "access_token": "sk-fw-xxx", + "access_token": "***", } ], "custom:empty": [], @@ -980,3 +980,78 @@ def test_list_custom_pool_providers(tmp_path, monkeypatch): result = list_custom_pool_providers() assert result == ["custom:fireworks", "custom:together.ai"] # "custom:empty" not included because it's empty + + + +def test_acquire_lease_prefers_unleased_entry(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openrouter": [ + { + "id": "cred-1", + "label": "primary", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "***", + }, + { + "id": "cred-2", + "label": "secondary", + "auth_type": "api_key", + "priority": 1, + "source": "manual", + "access_token": "***", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("openrouter") + first = pool.acquire_lease() + second = pool.acquire_lease() + + assert first == "cred-1" + assert second == "cred-2" + assert pool.active_lease_count("cred-1") == 1 + assert pool.active_lease_count("cred-2") == 1 + + + +def test_release_lease_decrements_counter(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openrouter": [ + { + "id": "cred-1", + "label": "primary", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "***", + } + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("openrouter") + leased = pool.acquire_lease() + assert leased == "cred-1" + assert pool.active_lease_count("cred-1") == 1 + + pool.release_lease("cred-1") + assert pool.active_lease_count("cred-1") == 0 diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 0e5e63a70679..ebdf60d296b3 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -26,6 +26,7 @@ _build_child_agent, _build_child_system_prompt, _strip_blocked_tools, + _resolve_child_credential_pool, _resolve_delegation_credentials, ) @@ -930,5 +931,126 @@ def test_model_only_no_provider_inherits_parent_credentials(self, mock_creds, mo self.assertEqual(kwargs["base_url"], parent.base_url) +class TestChildCredentialPoolResolution(unittest.TestCase): + def test_same_provider_shares_parent_pool(self): + parent = _make_mock_parent() + mock_pool = MagicMock() + parent._credential_pool = mock_pool + + result = _resolve_child_credential_pool("openrouter", parent) + self.assertIs(result, mock_pool) + + def test_no_provider_inherits_parent_pool(self): + parent = _make_mock_parent() + mock_pool = MagicMock() + parent._credential_pool = mock_pool + + result = _resolve_child_credential_pool(None, parent) + self.assertIs(result, mock_pool) + + def test_different_provider_loads_own_pool(self): + parent = _make_mock_parent() + parent._credential_pool = MagicMock() + mock_pool = MagicMock() + mock_pool.has_credentials.return_value = True + + with patch("agent.credential_pool.load_pool", return_value=mock_pool): + result = _resolve_child_credential_pool("anthropic", parent) + + self.assertIs(result, mock_pool) + + def test_different_provider_empty_pool_returns_none(self): + parent = _make_mock_parent() + parent._credential_pool = MagicMock() + mock_pool = MagicMock() + mock_pool.has_credentials.return_value = False + + with patch("agent.credential_pool.load_pool", return_value=mock_pool): + result = _resolve_child_credential_pool("anthropic", parent) + + self.assertIsNone(result) + + def test_different_provider_load_failure_returns_none(self): + parent = _make_mock_parent() + parent._credential_pool = MagicMock() + + with patch("agent.credential_pool.load_pool", side_effect=Exception("disk error")): + result = _resolve_child_credential_pool("anthropic", parent) + + self.assertIsNone(result) + + def test_build_child_agent_assigns_parent_pool_when_shared(self): + parent = _make_mock_parent() + mock_pool = MagicMock() + parent._credential_pool = mock_pool + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + MockAgent.return_value = mock_child + + _build_child_agent( + task_index=0, + goal="Test pool assignment", + context=None, + toolsets=["terminal"], + model=None, + max_iterations=10, + parent_agent=parent, + ) + + self.assertEqual(mock_child._credential_pool, mock_pool) + + +class TestChildCredentialLeasing(unittest.TestCase): + def test_run_single_child_acquires_and_releases_lease(self): + from tools.delegate_tool import _run_single_child + + leased_entry = MagicMock() + leased_entry.id = "cred-b" + + child = MagicMock() + child._credential_pool = MagicMock() + child._credential_pool.acquire_lease.return_value = "cred-b" + child._credential_pool.current.return_value = leased_entry + child.run_conversation.return_value = { + "final_response": "done", + "completed": True, + "interrupted": False, + "api_calls": 1, + "messages": [], + } + + result = _run_single_child( + task_index=0, + goal="Investigate rate limits", + child=child, + parent_agent=_make_mock_parent(), + ) + + self.assertEqual(result["status"], "completed") + child._credential_pool.acquire_lease.assert_called_once_with() + child._swap_credential.assert_called_once_with(leased_entry) + child._credential_pool.release_lease.assert_called_once_with("cred-b") + + def test_run_single_child_releases_lease_after_failure(self): + from tools.delegate_tool import _run_single_child + + child = MagicMock() + child._credential_pool = MagicMock() + child._credential_pool.acquire_lease.return_value = "cred-a" + child._credential_pool.current.return_value = MagicMock(id="cred-a") + child.run_conversation.side_effect = RuntimeError("boom") + + result = _run_single_child( + task_index=1, + goal="Trigger failure", + child=child, + parent_agent=_make_mock_parent(), + ) + + self.assertEqual(result["status"], "error") + child._credential_pool.release_lease.assert_called_once_with("cred-a") + + if __name__ == "__main__": unittest.main() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 28ffc795a991..9cae3ddd0667 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -279,6 +279,12 @@ def _child_thinking(text: str) -> None: # Set delegation depth so children can't spawn grandchildren child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1 + # Share a credential pool with the child when possible so subagents can + # rotate credentials on rate limits instead of getting pinned to one key. + child_pool = _resolve_child_credential_pool(effective_provider, parent_agent) + if child_pool is not None: + child._credential_pool = child_pool + # Register child for interrupt propagation if hasattr(parent_agent, '_active_children'): lock = getattr(parent_agent, '_active_children_lock', None) @@ -312,6 +318,18 @@ def _run_single_child( _saved_tool_names = getattr(child, "_delegate_saved_tool_names", list(model_tools._last_resolved_tool_names)) + child_pool = getattr(child, '_credential_pool', None) + leased_cred_id = None + if child_pool is not None: + leased_cred_id = child_pool.acquire_lease() + if leased_cred_id is not None: + try: + leased_entry = child_pool.current() + if leased_entry is not None and hasattr(child, '_swap_credential'): + child._swap_credential(leased_entry) + except Exception as exc: + logger.debug("Failed to bind child to leased credential: %s", exc) + try: result = child.run_conversation(user_message=goal) @@ -422,6 +440,12 @@ def _run_single_child( } finally: + if child_pool is not None and leased_cred_id is not None: + try: + child_pool.release_lease(leased_cred_id) + except Exception as exc: + logger.debug("Failed to release credential lease: %s", exc) + # Restore the parent's tool names so the process-global is correct # for any subsequent execute_code calls or other consumers. import model_tools @@ -430,6 +454,8 @@ def _run_single_child( if isinstance(saved_tool_names, list): model_tools._last_resolved_tool_names = list(saved_tool_names) + # Remove child from active tracking + # Unregister child from interrupt propagation if hasattr(parent_agent, '_active_children'): try: @@ -626,6 +652,38 @@ def delegate_task( }, ensure_ascii=False) +def _resolve_child_credential_pool(effective_provider: Optional[str], parent_agent): + """Resolve a credential pool for the child agent. + + Rules: + 1. Same provider as the parent -> share the parent's pool so cooldown state + and rotation stay synchronized. + 2. Different provider -> try to load that provider's own pool. + 3. No pool available -> return None and let the child keep the inherited + fixed credential behavior. + """ + if not effective_provider: + return getattr(parent_agent, "_credential_pool", None) + + parent_provider = getattr(parent_agent, "provider", None) or "" + parent_pool = getattr(parent_agent, "_credential_pool", None) + if parent_pool is not None and effective_provider == parent_provider: + return parent_pool + + try: + from agent.credential_pool import load_pool + pool = load_pool(effective_provider) + if pool is not None and pool.has_credentials(): + return pool + except Exception as exc: + logger.debug( + "Could not load credential pool for child provider '%s': %s", + effective_provider, + exc, + ) + return None + + def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: """Resolve credentials for subagent delegation. From c706568993ab51d18a15acc5cb2dc8ec2b54abfc Mon Sep 17 00:00:00 2001 From: Mateus Scheuer Macedo Date: Mon, 6 Apr 2026 23:00:26 -0700 Subject: [PATCH 201/213] fix(delegate): pass workspace path hints to child agents Selectively cherry-picked from PR #5501 by MestreY0d4-Uninter. - Add _resolve_workspace_hint() to detect parent's working directory - Inject WORKSPACE PATH into child system prompts - Add rule: never assume /workspace/ container paths - Excludes the cli.py queue-busy-input changes from the original PR --- tools/delegate_tool.py | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 9cae3ddd0667..ad9b54c44ee5 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -45,7 +45,12 @@ def check_delegate_requirements() -> bool: return True -def _build_child_system_prompt(goal: str, context: Optional[str] = None) -> str: +def _build_child_system_prompt( + goal: str, + context: Optional[str] = None, + *, + workspace_path: Optional[str] = None, +) -> str: """Build a focused system prompt for a child agent.""" parts = [ "You are a focused subagent working on a specific delegated task.", @@ -54,6 +59,12 @@ def _build_child_system_prompt(goal: str, context: Optional[str] = None) -> str: ] if context and context.strip(): parts.append(f"\nCONTEXT:\n{context}") + if workspace_path and str(workspace_path).strip(): + parts.append( + "\nWORKSPACE PATH:\n" + f"{workspace_path}\n" + "Use this exact path for local repository/workdir operations unless the task explicitly says otherwise." + ) parts.append( "\nComplete this task using the tools available to you. " "When finished, provide a clear, concise summary of:\n" @@ -61,12 +72,39 @@ def _build_child_system_prompt(goal: str, context: Optional[str] = None) -> str: "- What you found or accomplished\n" "- Any files you created or modified\n" "- Any issues encountered\n\n" + "Important workspace rule: Never assume a repository lives at /workspace/... or any other container-style path unless the task/context explicitly gives that path. " + "If no exact local path is provided, discover it first before issuing git/workdir-specific commands.\n\n" "Be thorough but concise -- your response is returned to the " "parent agent as a summary." ) return "\n".join(parts) +def _resolve_workspace_hint(parent_agent) -> Optional[str]: + """Best-effort local workspace hint for child prompts. + + We only inject a path when we have a concrete absolute directory. This avoids + teaching subagents a fake container path while still helping them avoid + guessing `/workspace/...` for local repo tasks. + """ + candidates = [ + os.getenv("TERMINAL_CWD"), + getattr(getattr(parent_agent, "_subdirectory_hints", None), "working_dir", None), + getattr(parent_agent, "terminal_cwd", None), + getattr(parent_agent, "cwd", None), + ] + for candidate in candidates: + if not candidate: + continue + try: + text = os.path.abspath(os.path.expanduser(str(candidate))) + except Exception: + continue + if os.path.isabs(text) and os.path.isdir(text): + return text + return None + + def _strip_blocked_tools(toolsets: List[str]) -> List[str]: """Remove toolsets that contain only blocked tools.""" blocked_toolset_names = { @@ -210,7 +248,8 @@ def _build_child_agent( else: child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS) - child_prompt = _build_child_system_prompt(goal, context) + workspace_hint = _resolve_workspace_hint(parent_agent) + child_prompt = _build_child_system_prompt(goal, context, workspace_path=workspace_hint) # Extract parent's API key so subagents inherit auth (e.g. Nous Portal). parent_api_key = getattr(parent_agent, "api_key", None) if (not parent_api_key) and hasattr(parent_agent, "_client_kwargs"): From 8e64f795a1d1427be93059adfd81a32de78284b7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:01:43 -0700 Subject: [PATCH 202/213] fix: stale OAuth credentials block OpenRouter users on auto-detect (#5746) When resolve_runtime_provider is called with requested='auto' and auth.json has a stale active_provider (nous or openai-codex) whose OAuth refresh token has been revoked, the AuthError now falls through to the next provider in the chain (e.g. OpenRouter via env vars) instead of propagating to the user as a blocking error. When the user explicitly requested the OAuth provider, the error still propagates so they know to re-authenticate. Root cause: resolve_provider('auto') checks auth.json for an active OAuth provider before checking env vars. get_nous_auth_status() reports logged_in=True if any access_token exists (even expired), so the Nous path is taken. resolve_nous_runtime_credentials() then tries to refresh the token, fails with 'Refresh session has been revoked', and the AuthError bubbles up to the CLI bold-red display. Adds 3 tests: Nous fallthrough, Codex fallthrough, explicit-request still raises. --- hermes_cli/runtime_provider.py | 62 ++++++++++------- tests/test_runtime_provider_resolution.py | 83 +++++++++++++++++++++++ 2 files changed, 122 insertions(+), 23 deletions(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 8ed601913f21..9c82ef62afdf 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -639,31 +639,47 @@ def resolve_runtime_provider( ) if provider == "nous": - creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), - timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - ) - return { - "provider": "nous", - "api_mode": "chat_completions", - "base_url": creds.get("base_url", "").rstrip("/"), - "api_key": creds.get("api_key", ""), - "source": creds.get("source", "portal"), - "expires_at": creds.get("expires_at"), - "requested_provider": requested_provider, - } + try: + creds = resolve_nous_runtime_credentials( + min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), + timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), + ) + return { + "provider": "nous", + "api_mode": "chat_completions", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "portal"), + "expires_at": creds.get("expires_at"), + "requested_provider": requested_provider, + } + except AuthError: + if requested_provider != "auto": + raise + # Auto-detected Nous but credentials are stale/revoked — + # fall through to env-var providers (e.g. OpenRouter). + logger.info("Auto-detected Nous provider but credentials failed; " + "falling through to next provider.") if provider == "openai-codex": - creds = resolve_codex_runtime_credentials() - return { - "provider": "openai-codex", - "api_mode": "codex_responses", - "base_url": creds.get("base_url", "").rstrip("/"), - "api_key": creds.get("api_key", ""), - "source": creds.get("source", "hermes-auth-store"), - "last_refresh": creds.get("last_refresh"), - "requested_provider": requested_provider, - } + try: + creds = resolve_codex_runtime_credentials() + return { + "provider": "openai-codex", + "api_mode": "codex_responses", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "hermes-auth-store"), + "last_refresh": creds.get("last_refresh"), + "requested_provider": requested_provider, + } + except AuthError: + if requested_provider != "auto": + raise + # Auto-detected Codex but credentials are stale/revoked — + # fall through to env-var providers (e.g. OpenRouter). + logger.info("Auto-detected Codex provider but credentials failed; " + "falling through to next provider.") if provider == "copilot-acp": creds = resolve_external_process_provider_credentials(provider) diff --git a/tests/test_runtime_provider_resolution.py b/tests/test_runtime_provider_resolution.py index 116047040942..ded0c9202f80 100644 --- a/tests/test_runtime_provider_resolution.py +++ b/tests/test_runtime_provider_resolution.py @@ -996,6 +996,89 @@ def test_custom_provider_no_key_gets_placeholder(monkeypatch): assert resolved["base_url"] == "http://localhost:8080/v1" +def test_auto_detected_nous_auth_failure_falls_through_to_openrouter(monkeypatch): + """When auto-detect picks Nous but credentials are revoked, fall through to OpenRouter.""" + from hermes_cli.auth import AuthError + + monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.setattr(rp, "load_config", lambda: {}) + + # resolve_provider returns "nous" (stale active_provider in auth.json) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous") + # load_pool returns empty pool so we hit the direct credential resolution + monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), { + "has_credentials": lambda self: False, + })()) + # Nous credential resolution fails with revoked token + monkeypatch.setattr( + rp, "resolve_nous_runtime_credentials", + lambda **kw: (_ for _ in ()).throw( + AuthError("Refresh session has been revoked", + provider="nous", code="invalid_grant", relogin_required=True) + ), + ) + + # With requested="auto", should fall through to OpenRouter + resolved = rp.resolve_runtime_provider(requested="auto") + assert resolved["provider"] == "openrouter" + assert resolved["api_key"] == "test-or-key" + + +def test_auto_detected_codex_auth_failure_falls_through_to_openrouter(monkeypatch): + """When auto-detect picks Codex but credentials are revoked, fall through to OpenRouter.""" + from hermes_cli.auth import AuthError + + monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.setattr(rp, "load_config", lambda: {}) + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex") + monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), { + "has_credentials": lambda self: False, + })()) + monkeypatch.setattr( + rp, "resolve_codex_runtime_credentials", + lambda **kw: (_ for _ in ()).throw( + AuthError("Codex token refresh failed: session revoked", + provider="openai-codex", code="invalid_grant", relogin_required=True) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="auto") + assert resolved["provider"] == "openrouter" + assert resolved["api_key"] == "test-or-key" + + +def test_explicit_nous_auth_failure_still_raises(monkeypatch): + """When user explicitly requests Nous and auth fails, the error should propagate.""" + from hermes_cli.auth import AuthError + import pytest + + monkeypatch.setenv("OPENROUTER_API_KEY", "test-or-key") + monkeypatch.setattr(rp, "load_config", lambda: {}) + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous") + monkeypatch.setattr(rp, "load_pool", lambda p: type("P", (), { + "has_credentials": lambda self: False, + })()) + monkeypatch.setattr( + rp, "resolve_nous_runtime_credentials", + lambda **kw: (_ for _ in ()).throw( + AuthError("Refresh session has been revoked", + provider="nous", code="invalid_grant", relogin_required=True) + ), + ) + + # With explicit "nous", should raise — don't silently switch providers + with pytest.raises(AuthError, match="Refresh session has been revoked"): + rp.resolve_runtime_provider(requested="nous") + + def test_openrouter_provider_not_affected_by_custom_fix(monkeypatch): """Fixing custom must not change openrouter behavior.""" monkeypatch.delenv("OPENAI_API_KEY", raising=False) From 2b79569a07aae2b0fb7a04e7a054278b51c3ac26 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 23:06:33 -0700 Subject: [PATCH 203/213] fix(discord): remove default selection from model picker provider dropdown Discord doesn't fire the select callback when clicking an already-selected default option (no change detected). This prevented users from selecting the current provider to browse its models. The 'current' indicator is already shown via the description field. --- gateway/platforms/discord.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 5e1be74ad4b9..83ea2694da9b 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2637,7 +2637,6 @@ def _build_provider_select(self): discord.SelectOption( label=label[:100], value=p["slug"], - default=bool(p.get("is_current")), description=desc, ) ) From 3bc2fe802e81a337728b48ed8d39b5c5cbc453db Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 6 Apr 2026 23:10:40 -0700 Subject: [PATCH 204/213] feat(telegram): paginated model picker with Next/Prev navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise max_models from 8 to 50 so all curated models come through - Add _build_model_keyboard() helper with 8-per-page pagination - Next ▶ / ◀ Prev buttons with page counter (e.g. 2/4) - mg: callback data for page navigation - Catch-all query.answer() for noop buttons --- gateway/platforms/telegram.py | 108 +++++++++++++++++++++++++++------- gateway/run.py | 2 +- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0362b9f96a7a..355bf3aee761 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1083,10 +1083,53 @@ def get_label(slug): logger.warning("[%s] send_model_picker failed: %s", self.name, e) return SendResult(success=False, error=str(e)) + _MODEL_PAGE_SIZE = 8 + + def _build_model_keyboard(self, models: list, page: int) -> tuple: + """Build paginated model buttons. Returns (keyboard, page_info_text).""" + page_size = self._MODEL_PAGE_SIZE + total = len(models) + total_pages = max(1, (total + page_size - 1) // page_size) + page = max(0, min(page, total_pages - 1)) + + start = page * page_size + end = min(start + page_size, total) + page_models = models[start:end] + + buttons: list = [] + for i, model_id in enumerate(page_models): + abs_idx = start + i + short = model_id.split("/")[-1] if "/" in model_id else model_id + if len(short) > 38: + short = short[:35] + "..." + buttons.append( + InlineKeyboardButton(short, callback_data=f"mm:{abs_idx}") + ) + + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + + # Pagination row (if needed) + if total_pages > 1: + nav: list = [] + if page > 0: + nav.append(InlineKeyboardButton("◀ Prev", callback_data=f"mg:{page - 1}")) + nav.append(InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="mx:noop")) + if page < total_pages - 1: + nav.append(InlineKeyboardButton("Next ▶", callback_data=f"mg:{page + 1}")) + rows.append(nav) + + rows.append([ + InlineKeyboardButton("◀ Back", callback_data="mb"), + InlineKeyboardButton("✗ Cancel", callback_data="mx"), + ]) + + page_info = f" ({start + 1}–{end} of {total})" if total_pages > 1 else "" + return InlineKeyboardMarkup(rows), page_info + async def _handle_model_picker_callback( self, query, data: str, chat_id: str ) -> None: - """Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:).""" + """Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:).""" state = self._model_picker_state.get(chat_id) if not state: await query.answer(text="Picker expired — use /model again.") @@ -1099,7 +1142,7 @@ def get_label(slug): return slug if data.startswith("mp:"): - # --- Provider selected: show model buttons --- + # --- Provider selected: show model buttons (page 0) --- provider_slug = data[3:] provider = next( (p for p in state["providers"] if p["slug"] == provider_slug), @@ -1113,24 +1156,9 @@ def get_label(slug): state["selected_provider"] = provider_slug state["selected_provider_name"] = provider.get("name", provider_slug) state["model_list"] = models + state["model_page"] = 0 - buttons: list = [] - for i, model_id in enumerate(models): - # Short display label: strip vendor prefix - short = model_id.split("/")[-1] if "/" in model_id else model_id - # Truncate long model names for button label (max ~40 chars) - if len(short) > 38: - short = short[:35] + "..." - buttons.append( - InlineKeyboardButton(short, callback_data=f"mm:{i}") - ) - - rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] - rows.append([ - InlineKeyboardButton("◀ Back", callback_data="mb"), - InlineKeyboardButton("✗ Cancel", callback_data="mx"), - ]) - keyboard = InlineKeyboardMarkup(rows) + keyboard, page_info = self._build_model_keyboard(models, 0) pname = provider.get("name", provider_slug) total = provider.get("total_models", len(models)) @@ -1140,7 +1168,41 @@ def get_label(slug): await query.edit_message_text( text=( f"⚙ *Model Configuration*\n\n" - f"Provider: *{pname}*\n" + f"Provider: *{pname}*{page_info}\n" + f"Select a model:{extra}" + ), + parse_mode=ParseMode.MARKDOWN, + reply_markup=keyboard, + ) + await query.answer() + + elif data.startswith("mg:"): + # --- Page navigation --- + try: + page = int(data[3:]) + except ValueError: + await query.answer(text="Invalid page.") + return + + models = state.get("model_list", []) + state["model_page"] = page + + keyboard, page_info = self._build_model_keyboard(models, page) + + pname = state.get("selected_provider_name", "") + provider_slug = state.get("selected_provider", "") + provider = next( + (p for p in state["providers"] if p["slug"] == provider_slug), + None, + ) + total = provider.get("total_models", len(models)) if provider else len(models) + shown = len(models) + extra = f"\n_{total - shown} more available — type `/model ` directly_" if total > shown else "" + + await query.edit_message_text( + text=( + f"⚙ *Model Configuration*\n\n" + f"Provider: *{pname}*{page_info}\n" f"Select a model:{extra}" ), parse_mode=ParseMode.MARKDOWN, @@ -1239,6 +1301,10 @@ def get_label(slug): ) await query.answer() + else: + # Catch-all (e.g. page counter button "mx:noop") + await query.answer() + async def _handle_callback_query( self, update: "Update", context: "ContextTypes.DEFAULT_TYPE" ) -> None: @@ -1249,7 +1315,7 @@ async def _handle_callback_query( data = query.data # --- Model picker callbacks --- - if data.startswith(("mp:", "mm:", "mb", "mx")): + if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")): chat_id = str(query.message.chat_id) if query.message else None if chat_id: await self._handle_model_picker_callback(query, data, chat_id) diff --git a/gateway/run.py b/gateway/run.py index 08be2b9db2cb..0030f434722b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3530,7 +3530,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: providers = list_authenticated_providers( current_provider=current_provider, user_providers=user_provs, - max_models=8, + max_models=50, ) except Exception: providers = [] From f609bf277db4d174e16d9f872f5aae25461bc0a6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:59:26 -0700 Subject: [PATCH 205/213] feat: update blogwatcher skill to JulienTant's fork (#5759) Replace Hyaxia/blogwatcher with JulienTant/blogwatcher-cli fork which adds: - Docker support with BLOGWATCHER_DB env var for persistent storage - SQL injection prevention - SSRF protection (blocks private IPs/metadata endpoints) - HTML scraping fallback when RSS unavailable - OPML import from Feedly/Inoreader/NewsBlur - Category filtering for articles - Direct binary downloads (no Go required) - Migration guide from original blogwatcher Binary name changed: blogwatcher -> blogwatcher-cli Community contribution by Ao (JulienTant). Closes discussion about Docker compatibility. --- skills/research/blogwatcher/SKILL.md | 118 ++++++++++++++++++++++----- 1 file changed, 99 insertions(+), 19 deletions(-) diff --git a/skills/research/blogwatcher/SKILL.md b/skills/research/blogwatcher/SKILL.md index c1ea4ac240f2..bfcc4f1d4d9d 100644 --- a/skills/research/blogwatcher/SKILL.md +++ b/skills/research/blogwatcher/SKILL.md @@ -1,48 +1,106 @@ --- name: blogwatcher -description: Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI. Add blogs, scan for new articles, and track what you've read. -version: 1.0.0 -author: community +description: Monitor blogs and RSS/Atom feeds for updates using the blogwatcher-cli tool. Add blogs, scan for new articles, track read status, and filter by category. +version: 2.0.0 +author: JulienTant (fork of Hyaxia/blogwatcher) license: MIT metadata: hermes: tags: [RSS, Blogs, Feed-Reader, Monitoring] - homepage: https://github.com/Hyaxia/blogwatcher + homepage: https://github.com/JulienTant/blogwatcher-cli prerequisites: - commands: [blogwatcher] + commands: [blogwatcher-cli] --- # Blogwatcher -Track blog and RSS/Atom feed updates with the `blogwatcher` CLI. +Track blog and RSS/Atom feed updates with the `blogwatcher-cli` tool. Supports automatic feed discovery, HTML scraping fallback, OPML import, and read/unread article management. -## Prerequisites +## Installation -- Go installed (`go version` to check) -- Install: `go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest` +Pick one method: + +- **Go:** `go install github.com/JulienTant/blogwatcher-cli/cmd/blogwatcher-cli@latest` +- **Docker:** `docker run --rm -v blogwatcher-cli:/data ghcr.io/julientant/blogwatcher-cli` +- **Binary (Linux amd64):** `curl -sL https://github.com/JulienTant/blogwatcher-cli/releases/latest/download/blogwatcher-cli_linux_amd64.tar.gz | tar xz -C /usr/local/bin blogwatcher-cli` +- **Binary (Linux arm64):** `curl -sL https://github.com/JulienTant/blogwatcher-cli/releases/latest/download/blogwatcher-cli_linux_arm64.tar.gz | tar xz -C /usr/local/bin blogwatcher-cli` +- **Binary (macOS Apple Silicon):** `curl -sL https://github.com/JulienTant/blogwatcher-cli/releases/latest/download/blogwatcher-cli_darwin_arm64.tar.gz | tar xz -C /usr/local/bin blogwatcher-cli` +- **Binary (macOS Intel):** `curl -sL https://github.com/JulienTant/blogwatcher-cli/releases/latest/download/blogwatcher-cli_darwin_amd64.tar.gz | tar xz -C /usr/local/bin blogwatcher-cli` + +All releases: https://github.com/JulienTant/blogwatcher-cli/releases + +### Docker with persistent storage + +By default the database lives at `~/.blogwatcher-cli/blogwatcher-cli.db`. In Docker this is lost on container restart. Use `BLOGWATCHER_DB` or a volume mount to persist it: + +```bash +# Named volume (simplest) +docker run --rm -v blogwatcher-cli:/data -e BLOGWATCHER_DB=/data/blogwatcher-cli.db ghcr.io/julientant/blogwatcher-cli scan + +# Host bind mount +docker run --rm -v /path/on/host:/data -e BLOGWATCHER_DB=/data/blogwatcher-cli.db ghcr.io/julientant/blogwatcher-cli scan +``` + +### Migrating from the original blogwatcher + +If upgrading from `Hyaxia/blogwatcher`, move your database: + +```bash +mv ~/.blogwatcher/blogwatcher.db ~/.blogwatcher-cli/blogwatcher-cli.db +``` + +The binary name changed from `blogwatcher` to `blogwatcher-cli`. ## Common Commands -- Add a blog: `blogwatcher add "My Blog" https://example.com` -- List blogs: `blogwatcher blogs` -- Scan for updates: `blogwatcher scan` -- List articles: `blogwatcher articles` -- Mark an article read: `blogwatcher read 1` -- Mark all articles read: `blogwatcher read-all` -- Remove a blog: `blogwatcher remove "My Blog"` +### Managing blogs + +- Add a blog: `blogwatcher-cli add "My Blog" https://example.com` +- Add with explicit feed: `blogwatcher-cli add "My Blog" https://example.com --feed-url https://example.com/feed.xml` +- Add with HTML scraping: `blogwatcher-cli add "My Blog" https://example.com --scrape-selector "article h2 a"` +- List tracked blogs: `blogwatcher-cli blogs` +- Remove a blog: `blogwatcher-cli remove "My Blog" --yes` +- Import from OPML: `blogwatcher-cli import subscriptions.opml` + +### Scanning and reading + +- Scan all blogs: `blogwatcher-cli scan` +- Scan one blog: `blogwatcher-cli scan "My Blog"` +- List unread articles: `blogwatcher-cli articles` +- List all articles: `blogwatcher-cli articles --all` +- Filter by blog: `blogwatcher-cli articles --blog "My Blog"` +- Filter by category: `blogwatcher-cli articles --category "Engineering"` +- Mark article read: `blogwatcher-cli read 1` +- Mark article unread: `blogwatcher-cli unread 1` +- Mark all read: `blogwatcher-cli read-all` +- Mark all read for a blog: `blogwatcher-cli read-all --blog "My Blog" --yes` + +## Environment Variables + +All flags can be set via environment variables with the `BLOGWATCHER_` prefix: + +| Variable | Description | +|---|---| +| `BLOGWATCHER_DB` | Path to SQLite database file | +| `BLOGWATCHER_WORKERS` | Number of concurrent scan workers (default: 8) | +| `BLOGWATCHER_SILENT` | Only output "scan done" when scanning | +| `BLOGWATCHER_YES` | Skip confirmation prompts | +| `BLOGWATCHER_CATEGORY` | Default filter for articles by category | ## Example Output ``` -$ blogwatcher blogs +$ blogwatcher-cli blogs Tracked blogs (1): xkcd URL: https://xkcd.com + Feed: https://xkcd.com/atom.xml + Last scanned: 2026-04-03 10:30 ``` ``` -$ blogwatcher scan +$ blogwatcher-cli scan Scanning 1 blog(s)... xkcd @@ -51,6 +109,28 @@ Scanning 1 blog(s)... Found 4 new article(s) total! ``` +``` +$ blogwatcher-cli articles +Unread articles (2): + + [1] [new] Barrel - Part 13 + Blog: xkcd + URL: https://xkcd.com/3095/ + Published: 2026-04-02 + Categories: Comics, Science + + [2] [new] Volcano Fact + Blog: xkcd + URL: https://xkcd.com/3094/ + Published: 2026-04-01 + Categories: Comics +``` + ## Notes -- Use `blogwatcher --help` to discover flags and options. +- Auto-discovers RSS/Atom feeds from blog homepages when no `--feed-url` is provided. +- Falls back to HTML scraping if RSS fails and `--scrape-selector` is configured. +- Categories from RSS/Atom feeds are stored and can be used to filter articles. +- Import blogs in bulk from OPML files exported by Feedly, Inoreader, NewsBlur, etc. +- Database stored at `~/.blogwatcher-cli/blogwatcher-cli.db` by default (override with `--db` or `BLOGWATCHER_DB`). +- Use `blogwatcher-cli --help` to discover all flags and options. From 9e844160f9b6e6485abe4294cb1962d7ef7813e3 Mon Sep 17 00:00:00 2001 From: Yang Zhi Date: Tue, 7 Apr 2026 00:52:29 +0800 Subject: [PATCH 206/213] fix(credential_pool): auto-detect Z.AI endpoint via probe and cache The credential pool seeder and runtime credential resolver hardcoded api.z.ai/api/paas/v4 for all Z.AI keys. Keys on the Coding Plan (or CN endpoint) would hit the wrong endpoint, causing 401/429 errors on the first request even though a working endpoint exists. Add _resolve_zai_base_url() that: - Respects GLM_BASE_URL env var (no probe when explicitly set) - Probes all candidate endpoints (global, cn, coding-global, coding-cn) via detect_zai_endpoint() to find one that returns HTTP 200 - Caches the detected endpoint in provider state (auth.json) keyed on a SHA-256 hash of the API key so subsequent starts skip the probe - Falls back to the default URL if all probes fail Wire into both _seed_from_env() in the credential pool and resolve_api_key_provider_credentials() in the runtime resolver, matching the pattern from the kimi-coding fix (PR #5566). Fixes the same class of bug as #5561 but for the zai provider. --- agent/credential_pool.py | 3 ++ hermes_cli/auth.py | 43 +++++++++++++++++++++++++++ tests/test_api_key_providers.py | 52 ++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index f57ae049c142..144a91015445 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -27,6 +27,7 @@ _is_expiring, _load_auth_store, _load_provider_state, + _resolve_zai_base_url, read_credential_pool, write_credential_pool, ) @@ -1086,6 +1087,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool active_sources.add(source) auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY base_url = env_url or pconfig.inference_base_url + if provider == "zai": + base_url = _resolve_zai_base_url(token, pconfig.inference_base_url, env_url) changed |= _upsert_entry( entries, provider, diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 588d06d40920..23119c66102b 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -404,6 +404,47 @@ def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str return None +def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str: + """Return the correct Z.AI base URL by probing endpoints. + + If the user has explicitly set GLM_BASE_URL, that always wins. + Otherwise, probe the candidate endpoints to find one that accepts the + key. The detected endpoint is cached in provider state (auth.json) keyed + on a hash of the API key so subsequent starts skip the probe. + """ + if env_override: + return env_override + + # Check provider-state cache for a previously-detected endpoint. + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "zai") or {} + cached = state.get("detected_endpoint") + if isinstance(cached, dict) and cached.get("base_url"): + key_hash = cached.get("key_hash", "") + if key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]: + logger.debug("Z.AI: using cached endpoint %s", cached["base_url"]) + return cached["base_url"] + + # Probe — may take up to ~8s per endpoint. + detected = detect_zai_endpoint(api_key) + if detected and detected.get("base_url"): + # Persist the detection result keyed on the API key hash. + key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16] + state["detected_endpoint"] = { + "base_url": detected["base_url"], + "endpoint_id": detected.get("id", ""), + "model": detected.get("model", ""), + "label": detected.get("label", ""), + "key_hash": key_hash, + } + _save_provider_state(auth_store, "zai", state) + logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"]) + return detected["base_url"] + + logger.debug("Z.AI: probe failed, falling back to default %s", default_url) + return default_url + + # ============================================================================= # Error Types # ============================================================================= @@ -2063,6 +2104,8 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if provider_id == "kimi-coding": base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) + elif provider_id == "zai": + base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) elif env_url: base_url = env_url.rstrip("/") else: diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py index ddf1d9722dc4..ee86507a16ce 100644 --- a/tests/test_api_key_providers.py +++ b/tests/test_api_key_providers.py @@ -350,6 +350,7 @@ class TestResolveApiKeyProviderCredentials: def test_resolve_zai_with_key(self, monkeypatch): monkeypatch.setenv("GLM_API_KEY", "glm-secret-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) creds = resolve_api_key_provider_credentials("zai") assert creds["provider"] == "zai" assert creds["api_key"] == "glm-secret-key" @@ -471,6 +472,7 @@ def test_glm_key_priority(self, monkeypatch): """GLM_API_KEY takes priority over ZAI_API_KEY.""" monkeypatch.setenv("GLM_API_KEY", "primary") monkeypatch.setenv("ZAI_API_KEY", "secondary") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) creds = resolve_api_key_provider_credentials("zai") assert creds["api_key"] == "primary" assert creds["source"] == "GLM_API_KEY" @@ -478,6 +480,7 @@ def test_glm_key_priority(self, monkeypatch): def test_zai_key_fallback(self, monkeypatch): """ZAI_API_KEY used when GLM_API_KEY not set.""" monkeypatch.setenv("ZAI_API_KEY", "secondary") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) creds = resolve_api_key_provider_credentials("zai") assert creds["api_key"] == "secondary" assert creds["source"] == "ZAI_API_KEY" @@ -830,11 +833,58 @@ def test_env_override_wins(self, monkeypatch): def test_non_kimi_providers_unaffected(self, monkeypatch): """Ensure the auto-detect logic doesn't leak to other providers.""" - monkeypatch.setenv("GLM_API_KEY", "sk-kimi-looks-like-kimi-but-isnt") + monkeypatch.setenv("GLM_API_KEY", "sk-kim...isnt") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) creds = resolve_api_key_provider_credentials("zai") assert creds["base_url"] == "https://api.z.ai/api/paas/v4" +class TestZaiEndpointAutoDetect: + """Test that resolve_api_key_provider_credentials auto-detects Z.AI endpoints.""" + + def test_probe_success_returns_detected_url(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-coding-key") + monkeypatch.setattr( + "hermes_cli.auth.detect_zai_endpoint", + lambda *a, **kw: { + "id": "coding-global", + "base_url": "https://api.z.ai/api/coding/paas/v4", + "model": "glm-4.7", + "label": "Global (Coding Plan)", + }, + ) + creds = resolve_api_key_provider_credentials("zai") + assert creds["base_url"] == "https://api.z.ai/api/coding/paas/v4" + + def test_probe_failure_falls_back_to_default(self, monkeypatch): + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + creds = resolve_api_key_provider_credentials("zai") + assert creds["base_url"] == "https://api.z.ai/api/paas/v4" + + def test_env_override_skips_probe(self, monkeypatch): + """GLM_BASE_URL should always win without probing.""" + monkeypatch.setenv("GLM_API_KEY", "glm-key") + monkeypatch.setenv("GLM_BASE_URL", "https://custom.example/v4") + probe_called = False + + def _never_called(*a, **kw): + nonlocal probe_called + probe_called = True + return None + + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", _never_called) + creds = resolve_api_key_provider_credentials("zai") + assert creds["base_url"] == "https://custom.example/v4" + assert not probe_called + + def test_no_key_skips_probe(self, monkeypatch): + """Without an API key, no probe should occur.""" + monkeypatch.setattr("hermes_cli.auth.detect_zai_endpoint", lambda *a, **kw: None) + creds = resolve_api_key_provider_credentials("zai") + assert creds["api_key"] == "" + + # ============================================================================= # Kimi / Moonshot model list isolation tests # ============================================================================= From eb7c4084451722f05a1508173b5275800c4860c9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:53:45 -0700 Subject: [PATCH 207/213] fix(gateway): /stop and /new bypass Level 1 active-session guard (#5765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): /stop and /new bypass Level 1 active-session guard The base adapter's Level 1 guard intercepted ALL messages while an agent was running, including /stop and /new. These commands were queued as pending messages instead of being dispatched to the gateway runner's Level 2 handler. When the agent eventually stopped (via the interrupt mechanism), the command text leaked into the conversation as a user message — the model would receive '/stop' as input and respond to it. Fix: Add /stop, /new, and /reset to the bypass set in base.py alongside /approve, /deny, and /status. Consolidate the three separate bypass blocks into one. Commands in the bypass set are dispatched inline to the gateway runner, where Level 2 handles them correctly (hard-kill for /stop, session reset for /new). Also add a safety net in _run_agent's pending-message processing: if the pending text resolves to a known slash command, discard it instead of passing it to the agent. This catches edge cases where command text leaks through the interrupt_message fallback. Refs: #5244 * test: regression tests for command bypass of active-session guard 17 tests covering: - /stop, /new, /reset bypass the Level 1 guard when agent is running - /approve, /deny, /status bypass (existing behavior, now tested) - Regular text and unknown commands still queued (not bypassed) - File paths like '/path/to/file' not treated as commands - Telegram @botname suffix handled correctly - Safety net command resolution (resolve_command detects known commands) --- gateway/platforms/base.py | 44 +-- gateway/run.py | 21 ++ .../test_command_bypass_active_session.py | 313 ++++++++++++++++++ 3 files changed, 347 insertions(+), 31 deletions(-) create mode 100644 tests/gateway/test_command_bypass_active_session.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 4335a51f1129..66fc5bac22f4 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1103,16 +1103,20 @@ async def handle_message(self, event: MessageEvent) -> None: # Check if there's already an active handler for this session if session_key in self._active_sessions: - # /approve and /deny must bypass the active-session guard. - # The agent thread is blocked on threading.Event.wait() inside - # tools/approval.py — queuing these commands creates a deadlock: - # the agent waits for approval, approval waits for agent to finish. - # Dispatch directly to the message handler without touching session - # lifecycle (no competing background task, no session guard removal). + # Certain commands must bypass the active-session guard and be + # dispatched directly to the gateway runner. Without this, they + # are queued as pending messages and either: + # - leak into the conversation as user text (/stop, /new), or + # - deadlock (/approve, /deny — agent is blocked on Event.wait) + # + # Dispatch inline: call the message handler directly and send the + # response. Do NOT use _process_message_background — it manages + # session lifecycle and its cleanup races with the running task + # (see PR #4926). cmd = event.get_command() - if cmd in ("approve", "deny"): + if cmd in ("approve", "deny", "status", "stop", "new", "reset"): logger.debug( - "[%s] Approval command '/%s' bypassing active-session guard for %s", + "[%s] Command '/%s' bypassing active-session guard for %s", self.name, cmd, session_key, ) try: @@ -1126,29 +1130,7 @@ async def handle_message(self, event: MessageEvent) -> None: metadata=_thread_meta, ) except Exception as e: - logger.error("[%s] Approval dispatch failed: %s", self.name, e, exc_info=True) - return - - # /status must also bypass the active-session guard so it always - # returns a system-generated response instead of being queued as - # user text and passed to the agent (#5046). - if cmd == "status": - logger.debug( - "[%s] Status command bypassing active-session guard for %s", - self.name, session_key, - ) - try: - _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None - response = await self._message_handler(event) - if response: - await self._send_with_retry( - chat_id=event.source.chat_id, - content=response, - reply_to=event.message_id, - metadata=_thread_meta, - ) - except Exception as e: - logger.error("[%s] Status dispatch failed: %s", self.name, e, exc_info=True) + logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True) return # Special case: photo bursts/albums frequently arrive as multiple near- diff --git a/gateway/run.py b/gateway/run.py index 0030f434722b..e4a5324acb49 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7131,6 +7131,27 @@ async def _notify_long_running(): if pending: logger.debug("Processing queued message after agent completion: '%s...'", pending[:40]) + # Safety net: if the pending text is a slash command (e.g. "/stop", + # "/new"), discard it — commands should never be passed to the agent + # as user input. The primary fix is in base.py (commands bypass the + # active-session guard), but this catches edge cases where command + # text leaks through the interrupt_message fallback. + if pending and pending.strip().startswith("/"): + _pending_parts = pending.strip().split(None, 1) + _pending_cmd_word = _pending_parts[0][1:].lower() if _pending_parts else "" + if _pending_cmd_word: + try: + from hermes_cli.commands import resolve_command as _rc_pending + if _rc_pending(_pending_cmd_word): + logger.info( + "Discarding command '/%s' from pending queue — " + "commands must not be passed as agent input", + _pending_cmd_word, + ) + pending = None + except Exception: + pass + if pending: logger.debug("Processing pending message: '%s...'", pending[:40]) diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py new file mode 100644 index 000000000000..e90dee69c154 --- /dev/null +++ b/tests/gateway/test_command_bypass_active_session.py @@ -0,0 +1,313 @@ +"""Regression tests: slash commands must bypass the base adapter's active-session guard. + +When an agent is running, the base adapter's Level 1 guard in +handle_message() intercepts all incoming messages and queues them as +pending. Certain commands (/stop, /new, /reset, /approve, /deny, +/status) must bypass this guard and be dispatched directly to the gateway +runner — otherwise they are queued as user text and either: + - leak into the conversation as agent input (/stop, /new), or + - deadlock (/approve, /deny — agent blocks on Event.wait) + +These tests verify that the bypass works at the adapter level and that +the safety net in _run_agent discards leaked command text. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType +from gateway.session import SessionSource, build_session_key + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _StubAdapter(BasePlatformAdapter): + """Concrete adapter with abstract methods stubbed out.""" + + async def connect(self): + pass + + async def disconnect(self): + pass + + async def send(self, chat_id, text, **kwargs): + pass + + async def get_chat_info(self, chat_id): + return {} + + +def _make_adapter(): + """Create a minimal adapter for testing the active-session guard.""" + config = PlatformConfig(enabled=True, token="test-token") + adapter = _StubAdapter(config, Platform.TELEGRAM) + adapter.sent_responses = [] + + async def _mock_handler(event): + cmd = event.get_command() + return f"handled:{cmd}" if cmd else f"handled:text:{event.text}" + + adapter._message_handler = _mock_handler + + async def _mock_send_retry(chat_id, content, **kwargs): + adapter.sent_responses.append(content) + + adapter._send_with_retry = _mock_send_retry + return adapter + + +def _make_event(text="/stop", chat_id="12345"): + source = SessionSource( + platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm" + ) + return MessageEvent(text=text, message_type=MessageType.TEXT, source=source) + + +def _session_key(chat_id="12345"): + source = SessionSource( + platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm" + ) + return build_session_key(source) + + +# --------------------------------------------------------------------------- +# Tests: commands bypass Level 1 when session is active +# --------------------------------------------------------------------------- + + +class TestCommandBypassActiveSession: + """Commands that must bypass the active-session guard.""" + + @pytest.mark.asyncio + async def test_stop_bypasses_guard(self): + """/stop must be dispatched directly, not queued.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/stop")) + + assert sk not in adapter._pending_messages, ( + "/stop was queued as a pending message instead of being dispatched" + ) + assert any("handled:stop" in r for r in adapter.sent_responses), ( + "/stop response was not sent back to the user" + ) + + @pytest.mark.asyncio + async def test_new_bypasses_guard(self): + """/new must be dispatched directly, not queued.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/new")) + + assert sk not in adapter._pending_messages + assert any("handled:new" in r for r in adapter.sent_responses) + + @pytest.mark.asyncio + async def test_reset_bypasses_guard(self): + """/reset (alias for /new) must be dispatched directly.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/reset")) + + assert sk not in adapter._pending_messages + assert any("handled:reset" in r for r in adapter.sent_responses) + + @pytest.mark.asyncio + async def test_approve_bypasses_guard(self): + """/approve must bypass (deadlock prevention).""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/approve")) + + assert sk not in adapter._pending_messages + assert any("handled:approve" in r for r in adapter.sent_responses) + + @pytest.mark.asyncio + async def test_deny_bypasses_guard(self): + """/deny must bypass (deadlock prevention).""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/deny")) + + assert sk not in adapter._pending_messages + assert any("handled:deny" in r for r in adapter.sent_responses) + + @pytest.mark.asyncio + async def test_status_bypasses_guard(self): + """/status must bypass so it returns a system response.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/status")) + + assert sk not in adapter._pending_messages + assert any("handled:status" in r for r in adapter.sent_responses) + + +# --------------------------------------------------------------------------- +# Tests: non-bypass messages still get queued +# --------------------------------------------------------------------------- + + +class TestNonBypassStillQueued: + """Regular messages and unknown commands must be queued, not dispatched.""" + + @pytest.mark.asyncio + async def test_regular_text_queued(self): + """Plain text while agent is running must be queued as pending.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("hello world")) + + assert sk in adapter._pending_messages, ( + "Regular text was not queued — it should be pending" + ) + assert len(adapter.sent_responses) == 0, ( + "Regular text should not produce a direct response" + ) + + @pytest.mark.asyncio + async def test_unknown_command_queued(self): + """Unknown /commands must be queued, not dispatched.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/foobar")) + + assert sk in adapter._pending_messages + assert len(adapter.sent_responses) == 0 + + @pytest.mark.asyncio + async def test_file_path_not_treated_as_command(self): + """A message like '/path/to/file' must not bypass the guard.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/path/to/file.py")) + + assert sk in adapter._pending_messages + assert len(adapter.sent_responses) == 0 + + +# --------------------------------------------------------------------------- +# Tests: no active session — commands go through normally +# --------------------------------------------------------------------------- + + +class TestNoActiveSessionNormalDispatch: + """When no agent is running, messages spawn a background task normally.""" + + @pytest.mark.asyncio + async def test_stop_when_no_session_active(self): + """/stop without an active session spawns a background task + (the Level 2 handler will return 'No active task').""" + adapter = _make_adapter() + sk = _session_key() + + # No active session — _active_sessions is empty + assert sk not in adapter._active_sessions + + await adapter.handle_message(_make_event("/stop")) + + # Should have gone through the normal path (background task spawned) + # and NOT be in _pending_messages (that's the queued-during-active path) + assert sk not in adapter._pending_messages + + +# --------------------------------------------------------------------------- +# Tests: safety net in _run_agent discards command text from pending queue +# --------------------------------------------------------------------------- + + +class TestPendingCommandSafetyNet: + """The safety net in gateway/run.py _run_agent must discard command text + that leaks into the pending queue via interrupt_message fallback.""" + + def test_stop_command_detected(self): + """resolve_command must recognize /stop so the safety net can + discard it.""" + from hermes_cli.commands import resolve_command + + assert resolve_command("stop") is not None + assert resolve_command("stop").name == "stop" + + def test_new_command_detected(self): + from hermes_cli.commands import resolve_command + + assert resolve_command("new") is not None + assert resolve_command("new").name == "new" + + def test_reset_alias_detected(self): + from hermes_cli.commands import resolve_command + + assert resolve_command("reset") is not None + assert resolve_command("reset").name == "new" # alias + + def test_unknown_command_not_detected(self): + from hermes_cli.commands import resolve_command + + assert resolve_command("foobar") is None + + def test_file_path_not_detected_as_command(self): + """'/path/to/file' should not resolve as a command.""" + from hermes_cli.commands import resolve_command + + # The safety net splits on whitespace and takes the first word + # after stripping '/'. For '/path/to/file', that's 'path/to/file'. + assert resolve_command("path/to/file") is None + + +# --------------------------------------------------------------------------- +# Tests: bypass with @botname suffix (Telegram-style) +# --------------------------------------------------------------------------- + + +class TestBypassWithBotnameSuffix: + """Telegram appends @botname to commands. The bypass must still work.""" + + @pytest.mark.asyncio + async def test_stop_with_botname(self): + """/stop@MyHermesBot must bypass the guard.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/stop@MyHermesBot")) + + assert sk not in adapter._pending_messages, ( + "/stop@MyHermesBot was queued instead of bypassing" + ) + assert any("handled:stop" in r for r in adapter.sent_responses) + + @pytest.mark.asyncio + async def test_new_with_botname(self): + """/new@MyHermesBot must bypass the guard.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/new@MyHermesBot")) + + assert sk not in adapter._pending_messages + assert any("handled:new" in r for r in adapter.sent_responses) From d9e7e42d0b692b91168b4d69cb5a87e6460f3100 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:00:02 -0700 Subject: [PATCH 208/213] fix(approval): load permanent command allowlist on startup (#5076) Co-authored-by: Timo Karp Co-authored-by: Claude Opus 4.6 (1M context) --- tools/approval.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/approval.py b/tools/approval.py index 193998362534..b49e444a4e29 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -871,3 +871,7 @@ def check_all_command_guards(command: str, env_type: str, return {"approved": True, "message": None, "user_approved": True, "description": combined_desc} + + +# Load permanent allowlist from config on module import +load_permanent_allowlist() From 1c425f219ecde160daddeec82283c735f1df9aeb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:03:52 -0700 Subject: [PATCH 209/213] fix(cli): defer response content until reasoning block completes (#5773) When show_reasoning is on with streaming, content tokens could arrive while the reasoning box was still rendering (interleaved thinking mode). This caused the response box to open before reasoning finished, resulting in reasoning appearing after the response in the terminal. Fix: buffer content in _deferred_content while _reasoning_box_opened is True. Flush the buffer through _emit_stream_text when _close_reasoning_box runs, ensuring reasoning always renders before the response. --- cli.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cli.py b/cli.py index 29e6257d1e55..09cf2094a79e 100644 --- a/cli.py +++ b/cli.py @@ -1920,6 +1920,12 @@ def _close_reasoning_box(self) -> None: _cprint(f"{_DIM}└{'─' * (w - 2)}┘{_RST}") self._reasoning_box_opened = False + # Flush any content that was deferred while reasoning was rendering. + deferred = getattr(self, "_deferred_content", "") + if deferred: + self._deferred_content = "" + self._emit_stream_text(deferred) + def _stream_delta(self, text) -> None: """Line-buffered streaming callback for real-time token rendering. @@ -2022,6 +2028,13 @@ def _emit_stream_text(self, text: str) -> None: if not text: return + # When show_reasoning is on and reasoning is still rendering, + # defer content until the reasoning box closes. This ensures the + # reasoning block always appears BEFORE the response in the terminal. + if self.show_reasoning and getattr(self, "_reasoning_box_opened", False): + self._deferred_content = getattr(self, "_deferred_content", "") + text + return + # Close the live reasoning box before opening the response box self._close_reasoning_box() @@ -2088,6 +2101,7 @@ def _reset_stream_state(self) -> None: self._reasoning_box_opened = False self._reasoning_buf = "" self._reasoning_preview_buf = "" + self._deferred_content = "" def _slow_command_status(self, command: str) -> str: """Return a user-facing status message for slower slash commands.""" From e120d2afacf90f3fec243c814da27216cba1943b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:40:16 -0700 Subject: [PATCH 210/213] feat: notify_on_complete for background processes (#5779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: notify_on_complete for background processes When terminal(background=true, notify_on_complete=true), the system auto-triggers a new agent turn when the process exits — no polling needed. Changes: - ProcessSession: add notify_on_complete field - ProcessRegistry: add completion_queue, populate on _move_to_finished() - Terminal tool: add notify_on_complete parameter to schema + handler - CLI: drain completion_queue after agent turn AND during idle loop - Gateway: enhanced _run_process_watcher injects synthetic MessageEvent on completion, triggering a full agent turn - Checkpoint persistence includes notify_on_complete for crash recovery - code_execution_tool: block notify_on_complete in sandbox scripts - 15 new tests covering queue mechanics, checkpoint round-trip, schema * docs: update terminal tool descriptions for notify_on_complete - background: remove 'ONLY for servers' language, describe both patterns (long-lived processes AND long-running tasks with notify_on_complete) - notify_on_complete: more prescriptive about when to use it - TERMINAL_TOOL_DESCRIPTION: remove 'Do NOT use background for builds' guidance that contradicted the new feature --- cli.py | 43 ++++- gateway/run.py | 51 ++++- tests/tools/test_notify_on_complete.py | 247 +++++++++++++++++++++++++ tools/code_execution_tool.py | 2 +- tools/process_registry.py | 21 +++ tools/terminal_tool.py | 44 ++++- 6 files changed, 398 insertions(+), 10 deletions(-) create mode 100644 tests/tools/test_notify_on_complete.py diff --git a/cli.py b/cli.py index 09cf2094a79e..6f02dc936082 100644 --- a/cli.py +++ b/cli.py @@ -8134,6 +8134,25 @@ def process_loop(): # Periodic config watcher — auto-reload MCP on mcp_servers change if not self._agent_running: self._check_config_mcp_changes() + # Check for background process completion notifications + # while the agent is idle (user hasn't typed anything yet). + try: + from tools.process_registry import process_registry + if not process_registry.completion_queue.empty(): + completion = process_registry.completion_queue.get_nowait() + _exit = completion.get("exit_code", "?") + _cmd = completion.get("command", "unknown") + _sid = completion.get("session_id", "unknown") + _out = completion.get("output", "") + _synth = ( + f"[SYSTEM: Background process {_sid} completed " + f"(exit code {_exit}).\n" + f"Command: {_cmd}\n" + f"Output:\n{_out}]" + ) + self._pending_input.put(_synth) + except Exception: + pass continue if not user_input: @@ -8247,7 +8266,29 @@ def _restart_recording(): except Exception as e: _cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}") threading.Thread(target=_restart_recording, daemon=True).start() - + + # Drain process completion notifications — any background + # process that finished with notify_on_complete while the + # agent was running (or before) gets auto-injected as a + # new user message so the agent can react to it. + try: + from tools.process_registry import process_registry + while not process_registry.completion_queue.empty(): + completion = process_registry.completion_queue.get_nowait() + _exit = completion.get("exit_code", "?") + _cmd = completion.get("command", "unknown") + _sid = completion.get("session_id", "unknown") + _out = completion.get("output", "") + _synth = ( + f"[SYSTEM: Background process {_sid} completed " + f"(exit code {_exit}).\n" + f"Command: {_cmd}\n" + f"Output:\n{_out}]" + ) + self._pending_input.put(_synth) + except Exception: + pass # Non-fatal — don't break the main loop + except Exception as e: print(f"Error: {e}") diff --git a/gateway/run.py b/gateway/run.py index e4a5324acb49..7a45be62d473 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6048,12 +6048,13 @@ async def _run_process_watcher(self, watcher: dict) -> None: platform_name = watcher.get("platform", "") chat_id = watcher.get("chat_id", "") thread_id = watcher.get("thread_id", "") + agent_notify = watcher.get("notify_on_complete", False) notify_mode = self._load_background_notifications_mode() - logger.debug("Process watcher started: %s (every %ss, notify=%s)", - session_id, interval, notify_mode) + logger.debug("Process watcher started: %s (every %ss, notify=%s, agent_notify=%s)", + session_id, interval, notify_mode, agent_notify) - if notify_mode == "off": + if notify_mode == "off" and not agent_notify: # Still wait for the process to exit so we can log it, but don't # push any messages to the user. while True: @@ -6077,6 +6078,47 @@ async def _run_process_watcher(self, watcher: dict) -> None: last_output_len = current_output_len if session.exited: + # --- Agent-triggered completion: inject synthetic message --- + if agent_notify: + from tools.ansi_strip import strip_ansi + _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" + synth_text = ( + f"[SYSTEM: Background process {session_id} completed " + f"(exit code {session.exit_code}).\n" + f"Command: {session.command}\n" + f"Output:\n{_out}]" + ) + adapter = None + for p, a in self.adapters.items(): + if p.value == platform_name: + adapter = a + break + if adapter and chat_id: + try: + from gateway.platforms.base import MessageEvent, MessageType + from gateway.session import SessionSource + from gateway.config import Platform + _platform_enum = Platform(platform_name) + _source = SessionSource( + platform=_platform_enum, + chat_id=chat_id, + thread_id=thread_id or None, + ) + synth_event = MessageEvent( + text=synth_text, + message_type=MessageType.TEXT, + source=_source, + ) + logger.info( + "Process %s finished — injecting agent notification for session %s", + session_id, session_key, + ) + await adapter.handle_message(synth_event) + except Exception as e: + logger.error("Agent notify injection error: %s", e) + break + + # --- Normal text-only notification --- # Decide whether to notify based on mode should_notify = ( notify_mode in ("all", "result") @@ -6101,8 +6143,9 @@ async def _run_process_watcher(self, watcher: dict) -> None: logger.error("Watcher delivery error: %s", e) break - elif has_new_output and notify_mode == "all": + elif has_new_output and notify_mode == "all" and not agent_notify: # New output available -- deliver status update (only in "all" mode) + # Skip periodic updates for agent_notify watchers (they only care about completion) new_output = session.output_buffer[-500:] if session.output_buffer else "" message_text = ( f"[Background process {session_id} is still running~ " diff --git a/tests/tools/test_notify_on_complete.py b/tests/tools/test_notify_on_complete.py new file mode 100644 index 000000000000..888721906dee --- /dev/null +++ b/tests/tools/test_notify_on_complete.py @@ -0,0 +1,247 @@ +"""Tests for notify_on_complete background process feature. + +Covers: + - ProcessSession.notify_on_complete field + - ProcessRegistry.completion_queue population on _move_to_finished() + - Checkpoint persistence of notify_on_complete + - Terminal tool schema includes notify_on_complete + - Terminal tool handler passes notify_on_complete through +""" + +import json +import os +import queue +import time +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from tools.process_registry import ( + ProcessRegistry, + ProcessSession, +) + + +@pytest.fixture() +def registry(): + """Create a fresh ProcessRegistry.""" + return ProcessRegistry() + + +def _make_session( + sid="proc_test_notify", + command="echo hello", + task_id="t1", + exited=False, + exit_code=None, + output="", + notify_on_complete=False, +) -> ProcessSession: + s = ProcessSession( + id=sid, + command=command, + task_id=task_id, + started_at=time.time(), + exited=exited, + exit_code=exit_code, + output_buffer=output, + notify_on_complete=notify_on_complete, + ) + return s + + +# ========================================================================= +# ProcessSession field +# ========================================================================= + +class TestProcessSessionField: + def test_default_false(self): + s = ProcessSession(id="proc_1", command="echo hi") + assert s.notify_on_complete is False + + def test_set_true(self): + s = ProcessSession(id="proc_1", command="echo hi", notify_on_complete=True) + assert s.notify_on_complete is True + + +# ========================================================================= +# Completion queue +# ========================================================================= + +class TestCompletionQueue: + def test_queue_exists(self, registry): + assert hasattr(registry, "completion_queue") + assert registry.completion_queue.empty() + + def test_move_to_finished_no_notify(self, registry): + """Processes without notify_on_complete don't enqueue.""" + s = _make_session(notify_on_complete=False, output="done") + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + assert registry.completion_queue.empty() + + def test_move_to_finished_with_notify(self, registry): + """Processes with notify_on_complete push to queue.""" + s = _make_session( + notify_on_complete=True, + output="build succeeded", + exit_code=0, + ) + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + assert not registry.completion_queue.empty() + completion = registry.completion_queue.get_nowait() + assert completion["session_id"] == s.id + assert completion["command"] == "echo hello" + assert completion["exit_code"] == 0 + assert "build succeeded" in completion["output"] + + def test_move_to_finished_nonzero_exit(self, registry): + """Nonzero exit codes are captured correctly.""" + s = _make_session( + notify_on_complete=True, + output="FAILED", + exit_code=1, + ) + s.exited = True + s.exit_code = 1 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + completion = registry.completion_queue.get_nowait() + assert completion["exit_code"] == 1 + assert "FAILED" in completion["output"] + + def test_output_truncated_to_2000(self, registry): + """Long output is truncated to last 2000 chars.""" + long_output = "x" * 5000 + s = _make_session( + notify_on_complete=True, + output=long_output, + ) + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + completion = registry.completion_queue.get_nowait() + assert len(completion["output"]) == 2000 + + def test_multiple_completions_queued(self, registry): + """Multiple notify processes all push to the same queue.""" + for i in range(3): + s = _make_session( + sid=f"proc_{i}", + notify_on_complete=True, + output=f"output_{i}", + ) + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + completions = [] + while not registry.completion_queue.empty(): + completions.append(registry.completion_queue.get_nowait()) + assert len(completions) == 3 + ids = {c["session_id"] for c in completions} + assert ids == {"proc_0", "proc_1", "proc_2"} + + +# ========================================================================= +# Checkpoint persistence +# ========================================================================= + +class TestCheckpointNotify: + def test_checkpoint_includes_notify(self, registry, tmp_path): + with patch("tools.process_registry.CHECKPOINT_PATH", tmp_path / "procs.json"): + s = _make_session(notify_on_complete=True) + registry._running[s.id] = s + registry._write_checkpoint() + + data = json.loads((tmp_path / "procs.json").read_text()) + assert len(data) == 1 + assert data[0]["notify_on_complete"] is True + + def test_checkpoint_without_notify(self, registry, tmp_path): + with patch("tools.process_registry.CHECKPOINT_PATH", tmp_path / "procs.json"): + s = _make_session(notify_on_complete=False) + registry._running[s.id] = s + registry._write_checkpoint() + + data = json.loads((tmp_path / "procs.json").read_text()) + assert data[0]["notify_on_complete"] is False + + def test_recover_preserves_notify(self, registry, tmp_path): + checkpoint = tmp_path / "procs.json" + checkpoint.write_text(json.dumps([{ + "session_id": "proc_live", + "command": "sleep 999", + "pid": os.getpid(), + "task_id": "t1", + "notify_on_complete": True, + }])) + with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint): + recovered = registry.recover_from_checkpoint() + assert recovered == 1 + s = registry.get("proc_live") + assert s.notify_on_complete is True + + def test_recover_defaults_false(self, registry, tmp_path): + """Old checkpoint entries without the field default to False.""" + checkpoint = tmp_path / "procs.json" + checkpoint.write_text(json.dumps([{ + "session_id": "proc_live", + "command": "sleep 999", + "pid": os.getpid(), + "task_id": "t1", + }])) + with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint): + recovered = registry.recover_from_checkpoint() + assert recovered == 1 + s = registry.get("proc_live") + assert s.notify_on_complete is False + + +# ========================================================================= +# Terminal tool schema +# ========================================================================= + +class TestTerminalSchema: + def test_schema_has_notify_on_complete(self): + from tools.terminal_tool import TERMINAL_SCHEMA + props = TERMINAL_SCHEMA["parameters"]["properties"] + assert "notify_on_complete" in props + assert props["notify_on_complete"]["type"] == "boolean" + assert props["notify_on_complete"]["default"] is False + + def test_handler_passes_notify(self): + """_handle_terminal passes notify_on_complete to terminal_tool.""" + from tools.terminal_tool import _handle_terminal + with patch("tools.terminal_tool.terminal_tool", return_value='{"ok":true}') as mock_tt: + _handle_terminal( + {"command": "echo hi", "background": True, "notify_on_complete": True}, + task_id="t1", + ) + _, kwargs = mock_tt.call_args + assert kwargs["notify_on_complete"] is True + + +# ========================================================================= +# Code execution blocked params +# ========================================================================= + +class TestCodeExecutionBlocked: + def test_notify_on_complete_blocked_in_sandbox(self): + from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS + assert "notify_on_complete" in _TERMINAL_BLOCKED_PARAMS diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index ff5c7f7fedd0..5c4658b6f4e0 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -300,7 +300,7 @@ def _call(tool_name, args): # --------------------------------------------------------------------------- # Terminal parameters that must not be used from ephemeral sandbox scripts -_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty"} +_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty", "notify_on_complete"} def _rpc_server_loop( diff --git a/tools/process_registry.py b/tools/process_registry.py index a3796c8ae33e..f5ac9543f98b 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -81,6 +81,7 @@ class ProcessSession: watcher_chat_id: str = "" watcher_thread_id: str = "" watcher_interval: int = 0 # 0 = no watcher configured + notify_on_complete: bool = False # Queue agent notification on exit _lock: threading.Lock = field(default_factory=threading.Lock) _reader_thread: Optional[threading.Thread] = field(default=None, repr=False) _pty: Any = field(default=None, repr=False) # ptyprocess handle (when use_pty=True) @@ -112,6 +113,12 @@ def __init__(self): # Side-channel for check_interval watchers (gateway reads after agent run) self.pending_watchers: List[Dict[str, Any]] = [] + # Completion notifications — processes with notify_on_complete push here + # on exit. CLI process_loop and gateway drain this after each agent turn + # to auto-trigger a new agent turn with the process results. + import queue as _queue_mod + self.completion_queue: _queue_mod.Queue = _queue_mod.Queue() + @staticmethod def _clean_shell_noise(text: str) -> str: """Strip shell startup warnings from the beginning of output.""" @@ -415,6 +422,18 @@ def _move_to_finished(self, session: ProcessSession): self._finished[session.id] = session self._write_checkpoint() + # If the caller requested agent notification, enqueue the completion + # so the CLI/gateway can auto-trigger a new agent turn. + if session.notify_on_complete: + from tools.ansi_strip import strip_ansi + output_tail = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" + self.completion_queue.put({ + "session_id": session.id, + "command": session.command, + "exit_code": session.exit_code, + "output": output_tail, + }) + # ----- Query Methods ----- def get(self, session_id: str) -> Optional[ProcessSession]: @@ -721,6 +740,7 @@ def _write_checkpoint(self): "watcher_chat_id": s.watcher_chat_id, "watcher_thread_id": s.watcher_thread_id, "watcher_interval": s.watcher_interval, + "notify_on_complete": s.notify_on_complete, }) # Atomic write to avoid corruption on crash @@ -771,6 +791,7 @@ def recover_from_checkpoint(self) -> int: watcher_chat_id=entry.get("watcher_chat_id", ""), watcher_thread_id=entry.get("watcher_thread_id", ""), watcher_interval=entry.get("watcher_interval", 0), + notify_on_complete=entry.get("notify_on_complete", False), ) with self._lock: self._running[session.id] = session diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index be565f19666e..305d08011fc9 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -421,9 +421,11 @@ def replace_sudo(match): Do NOT use echo/cat heredoc to create files — use write_file instead. Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell. -Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for everything that finishes. -Background: ONLY for long-running servers, watchers, or processes that never exit. Set background=true to get a session_id, then use process(action="wait") to block until done — it returns instantly on completion, same as foreground. Use process(action="poll") only when you need a progress check without blocking. -Do NOT use background for scripts, builds, or installs — foreground with a generous timeout is always better (fewer tool calls, instant results). +Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for short commands. +Background: Set background=true to get a session_id. Two patterns: + (1) Long-lived processes that never exit (servers, watchers). + (2) Long-running tasks with notify_on_complete=true — you can keep working on other things and the system auto-notifies you when the task finishes. Great for test suites, builds, deployments, or anything that takes more than a minute. +Use process(action="poll") for progress checks, process(action="wait") to block until done. Working directory: Use 'workdir' for per-command cwd. PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL). @@ -1009,6 +1011,7 @@ def terminal_tool( workdir: Optional[str] = None, check_interval: Optional[int] = None, pty: bool = False, + notify_on_complete: bool = False, ) -> str: """ Execute a command in the configured terminal environment. @@ -1022,6 +1025,7 @@ def terminal_tool( workdir: Working directory for this command (optional, uses session cwd if not set) check_interval: Seconds between auto-checks for background processes (gateway only, min 30) pty: If True, use pseudo-terminal for interactive CLI tools (local backend only) + notify_on_complete: If True and background=True, auto-notify the agent when the process exits Returns: str: JSON string with output, exit_code, and error fields @@ -1254,6 +1258,32 @@ def terminal_tool( f"configured limit of {max_timeout}s" ) + # Mark for agent notification on completion + if notify_on_complete and background: + proc_session.notify_on_complete = True + result_data["notify_on_complete"] = True + + # In gateway mode, auto-register a fast watcher so the + # gateway can detect completion and trigger a new agent + # turn. CLI mode uses the completion_queue directly. + _gw_platform = os.getenv("HERMES_SESSION_PLATFORM", "") + if _gw_platform and not check_interval: + _gw_chat_id = os.getenv("HERMES_SESSION_CHAT_ID", "") + _gw_thread_id = os.getenv("HERMES_SESSION_THREAD_ID", "") + proc_session.watcher_platform = _gw_platform + proc_session.watcher_chat_id = _gw_chat_id + proc_session.watcher_thread_id = _gw_thread_id + proc_session.watcher_interval = 5 + process_registry.pending_watchers.append({ + "session_id": proc_session.id, + "check_interval": 5, + "session_key": session_key, + "platform": _gw_platform, + "chat_id": _gw_chat_id, + "thread_id": _gw_thread_id, + "notify_on_complete": True, + }) + # Register check_interval watcher (gateway picks this up after agent run) if check_interval and background: effective_interval = max(30, check_interval) @@ -1550,7 +1580,7 @@ def check_terminal_requirements() -> bool: }, "background": { "type": "boolean", - "description": "ONLY for servers/watchers that never exit. For scripts, builds, installs — use foreground with timeout instead (it returns instantly when done).", + "description": "Run the command in the background. Two patterns: (1) Long-lived processes that never exit (servers, watchers). (2) Long-running tasks paired with notify_on_complete=true — you can keep working and get notified when the task finishes. For short commands, prefer foreground with a generous timeout instead.", "default": False }, "timeout": { @@ -1571,6 +1601,11 @@ def check_terminal_requirements() -> bool: "type": "boolean", "description": "Run in pseudo-terminal (PTY) mode for interactive CLI tools like Codex, Claude Code, or Python REPL. Only works with local and SSH backends. Default: false.", "default": False + }, + "notify_on_complete": { + "type": "boolean", + "description": "When true (and background=true), you'll be automatically notified when the process finishes — no polling needed. Use this for tasks that take a while (tests, builds, deployments) so you can keep working on other things in the meantime.", + "default": False } }, "required": ["command"] @@ -1587,6 +1622,7 @@ def _handle_terminal(args, **kw): workdir=args.get("workdir"), check_interval=args.get("check_interval"), pty=args.get("pty", False), + notify_on_complete=args.get("notify_on_complete", False), ) From cafdfd36549538713b0a91aaef42877c2be2845a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:49:20 -0700 Subject: [PATCH 211/213] fix: sync bundled skills to default profile when updating from a named profile (#5795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter in cmd_update() excluded is_default profiles from the cross-profile skill sync loop. When running 'hermes update' from a named profile (e.g. hermes -p coder update), the default profile (~/.hermes) never received new bundled skills. Remove the 'not p.is_default' condition so all profiles — including default — are synced regardless of which profile runs the update. Reported by olafgeibig. --- hermes_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1a968952aee4..55faf841316c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3566,7 +3566,7 @@ def cmd_update(args): try: from hermes_cli.profiles import list_profiles, get_active_profile_name, seed_profile_skills active = get_active_profile_name() - other_profiles = [p for p in list_profiles() if not p.is_default and p.name != active] + other_profiles = [p for p in list_profiles() if p.name != active] if other_profiles: print() print("→ Syncing bundled skills to other profiles...") From 8b861b77c1f854a2b7914be1afa52facebfb046f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:28:44 -0700 Subject: [PATCH 212/213] =?UTF-8?q?refactor:=20remove=20browser=5Fclose=20?= =?UTF-8?q?tool=20=E2=80=94=20auto-cleanup=20handles=20it=20(#5792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove browser_close tool — auto-cleanup handles it The browser_close tool was called in only 9% of browser sessions (13/144 navigations across 66 sessions), always redundantly — cleanup_browser() already runs via _cleanup_task_resources() at conversation end, and the background inactivity reaper catches anything else. Removing it saves one tool schema slot in every browser-enabled API call. Also fixes a latent bug: cleanup_browser() now handles Camofox sessions too (previously only Browserbase). Camofox sessions were never auto-cleaned per-task because they live in a separate dict from _active_sessions. Files changed (13): - tools/browser_tool.py: remove function, schema, registry entry; add camofox cleanup to cleanup_browser() - toolsets.py, model_tools.py, prompt_builder.py, display.py, acp_adapter/tools.py: remove browser_close from all tool lists - tests/: remove browser_close test, update toolset assertion - docs/skills: remove all browser_close references * fix: repeat browser_scroll 5x per call for meaningful page movement Most backends scroll ~100px per call — barely visible on a typical viewport. Repeating 5x gives ~500px (~half a viewport), making each scroll tool call actually useful. Backend-agnostic approach: works across all 7+ browser backends without needing to configure each one's scroll amount individually. Breaks early on error for the agent-browser path. * feat: auto-return compact snapshot from browser_navigate Every browser session starts with navigate → snapshot. Now navigate returns the compact accessibility tree snapshot inline, saving one tool call per browser task. The snapshot captures the full page DOM (not viewport-limited), so scroll position doesn't affect it. browser_snapshot remains available for refreshing after interactions or getting full=true content. Both Browserbase and Camofox paths auto-snapshot. If the snapshot fails for any reason, navigation still succeeds — the snapshot is a bonus, not a requirement. Schema descriptions updated to guide models: navigate mentions it returns a snapshot, snapshot mentions it's for refresh/full content. * refactor: slim cronjob tool schema — consolidate model/provider, drop unused params Session data (151 calls across 67 sessions) showed several schema properties were never used by models. Consolidated and cleaned up: Removed from schema (still work via backend/CLI): - skill (singular): use skills array instead - reason: pause-only, unnecessary - include_disabled: now defaults to true - base_url: extreme edge case, zero usage - provider (standalone): merged into model object Consolidated: - model + provider → single 'model' object with {model, provider} fields. If provider is omitted, the current main provider is pinned at creation time so the job stays stable even if the user changes their default. Kept: - script: useful data collection feature - skills array: standard interface for skill loading Schema shrinks from 14 to 10 properties. All backend functionality preserved — the Python function signature and handler lambda still accept every parameter. * fix: remove mixture_of_agents from core toolsets — opt-in only via hermes tools MoA was in _HERMES_CORE_TOOLS and composite toolsets (hermes-cli, hermes-messaging, safe), which meant it appeared in every session for anyone with OPENROUTER_API_KEY set. The _DEFAULT_OFF_TOOLSETS gate only works after running 'hermes tools' explicitly. Now MoA only appears when a user explicitly enables it via 'hermes tools'. The moa toolset definition and check_fn remain unchanged — it just needs to be opted into. --- acp_adapter/tools.py | 1 - agent/display.py | 2 - agent/prompt_builder.py | 1 - cli-config.yaml.example | 2 +- model_tools.py | 2 +- skills/dogfood/SKILL.md | 3 +- tests/gateway/test_api_server_toolset.py | 2 +- tests/tools/test_browser_cleanup.py | 12 -- tools/browser_camofox.py | 19 +++ tools/browser_tool.py | 123 +++++++++---------- tools/cronjob_tools.py | 86 +++++++------ toolsets.py | 14 +-- website/docs/reference/tools-reference.md | 1 - website/docs/reference/toolsets-reference.md | 2 +- website/docs/user-guide/features/browser.md | 8 +- 15 files changed, 136 insertions(+), 142 deletions(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 8756aa92967e..52313220b7f6 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -39,7 +39,6 @@ "browser_scroll": "execute", "browser_press": "execute", "browser_back": "execute", - "browser_close": "execute", "browser_get_images": "read", # Agent internals "delegate_task": "execute", diff --git a/agent/display.py b/agent/display.py index 94259fa80a89..5eac70a4b7fd 100644 --- a/agent/display.py +++ b/agent/display.py @@ -890,8 +890,6 @@ def _wrap(line: str) -> str: return _wrap(f"┊ ◀️ back {dur}") if tool_name == "browser_press": return _wrap(f"┊ ⌨️ press {args.get('key', '?')} {dur}") - if tool_name == "browser_close": - return _wrap(f"┊ 🚪 close browser {dur}") if tool_name == "browser_get_images": return _wrap(f"┊ 🖼️ images extracting {dur}") if tool_name == "browser_vision": diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 0a2cbe37418c..d6c296f61dce 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -744,7 +744,6 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) - "browser_type", "browser_scroll", "browser_console", - "browser_close", "browser_press", "browser_get_images", "browser_vision", diff --git a/cli-config.yaml.example b/cli-config.yaml.example index e26ee920e78c..73bff981f9c8 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -539,7 +539,7 @@ platform_toolsets: # terminal - terminal, process # file - read_file, write_file, patch, search # browser - browser_navigate, browser_snapshot, browser_click, browser_type, -# browser_scroll, browser_back, browser_press, browser_close, +# browser_scroll, browser_back, browser_press, # browser_get_images, browser_vision (requires BROWSERBASE_API_KEY) # vision - vision_analyze (requires OPENROUTER_API_KEY) # image_gen - image_generate (requires FAL_KEY) diff --git a/model_tools.py b/model_tools.py index da5ba7154ead..c37007c413ce 100644 --- a/model_tools.py +++ b/model_tools.py @@ -211,7 +211,7 @@ def _discover_tools(): "browser_tools": [ "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console" ], "cronjob_tools": ["cronjob"], diff --git a/skills/dogfood/SKILL.md b/skills/dogfood/SKILL.md index 81a4ebfdeb00..b7ba3663953e 100644 --- a/skills/dogfood/SKILL.md +++ b/skills/dogfood/SKILL.md @@ -16,7 +16,7 @@ This skill guides you through systematic exploratory QA testing of web applicati ## Prerequisites -- Browser toolset must be available (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_vision`, `browser_console`, `browser_scroll`, `browser_back`, `browser_press`, `browser_close`) +- Browser toolset must be available (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_vision`, `browser_console`, `browser_scroll`, `browser_back`, `browser_press`) - A target URL and testing scope from the user ## Inputs @@ -148,7 +148,6 @@ Save the report to `{output_dir}/report.md`. | `browser_press` | Press a keyboard key | | `browser_vision` | Screenshot + AI analysis; use `annotate=true` for element labels | | `browser_console` | Get JS console output and errors | -| `browser_close` | Close the browser session | ## Tips diff --git a/tests/gateway/test_api_server_toolset.py b/tests/gateway/test_api_server_toolset.py index 3b4ff254d8e7..943d867e6132 100644 --- a/tests/gateway/test_api_server_toolset.py +++ b/tests/gateway/test_api_server_toolset.py @@ -39,7 +39,7 @@ def test_toolset_includes_browser_tools(self): tools = resolve_toolset("hermes-api-server") for tool in ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close"]: + "browser_press"]: assert tool in tools, f"Missing browser tool: {tool}" def test_toolset_includes_homeassistant_tools(self): diff --git a/tests/tools/test_browser_cleanup.py b/tests/tools/test_browser_cleanup.py index 9dfabe64042d..df21f3a0eadd 100644 --- a/tests/tools/test_browser_cleanup.py +++ b/tests/tools/test_browser_cleanup.py @@ -65,18 +65,6 @@ def test_cleanup_browser_clears_tracking_state(self): mock_stop.assert_called_once_with("task-1") mock_run.assert_called_once_with("task-1", "close", [], timeout=10) - def test_browser_close_delegates_to_cleanup_browser(self): - import json - - browser_tool = self.browser_tool - browser_tool._active_sessions["task-2"] = {"session_name": "sess-2"} - - with patch("tools.browser_tool.cleanup_browser") as mock_cleanup: - result = json.loads(browser_tool.browser_close("task-2")) - - assert result == {"success": True, "closed": True} - mock_cleanup.assert_called_once_with("task-2") - def test_emergency_cleanup_clears_all_tracking_state(self): browser_tool = self.browser_tool browser_tool._cleanup_done = False diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index c2278f83effa..91f8fa4fdd7e 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -240,6 +240,25 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: "Browser is visible via VNC. " "Share this link with the user so they can watch the browser live." ) + + # Auto-take a compact snapshot so the model can act immediately + try: + snap_data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + snapshot_text = snap_data.get("snapshot", "") + from tools.browser_tool import ( + SNAPSHOT_SUMMARIZE_THRESHOLD, + _truncate_snapshot, + ) + if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD: + snapshot_text = _truncate_snapshot(snapshot_text) + result["snapshot"] = snapshot_text + result["element_count"] = snap_data.get("refsCount", 0) + except Exception: + pass # Navigation succeeded; snapshot is a bonus + return json.dumps(result) except requests.HTTPError as e: return json.dumps({"success": False, "error": f"Navigation failed: {e}"}) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index a6043e0bf336..ba2f81cf4ab0 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -518,7 +518,7 @@ def _update_session_activity(task_id: str): BROWSER_TOOL_SCHEMAS = [ { "name": "browser_navigate", - "description": "Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need to interact with a page (click, fill forms, dynamic content).", + "description": "Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need to interact with a page (click, fill forms, dynamic content). Returns a compact page snapshot with interactive elements and ref IDs — no need to call browser_snapshot separately after navigating.", "parameters": { "type": "object", "properties": { @@ -532,7 +532,7 @@ def _update_session_activity(task_id: str): }, { "name": "browser_snapshot", - "description": "Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: complete page content. Snapshots over 8000 chars are truncated or LLM-summarized. Requires browser_navigate first.", + "description": "Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: complete page content. Snapshots over 8000 chars are truncated or LLM-summarized. Requires browser_navigate first. Note: browser_navigate already returns a compact snapshot — use this to refresh after interactions that change the page, or with full=true for complete content.", "parameters": { "type": "object", "properties": { @@ -615,15 +615,7 @@ def _update_session_activity(task_id: str): "required": ["key"] } }, - { - "name": "browser_close", - "description": "Close the browser session and release resources. Call this when done with browser tasks to free up Browserbase session quota.", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - }, + { "name": "browser_get_images", "description": "Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first.", @@ -1229,7 +1221,22 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: "Consider upgrading Browserbase plan for proxy support." ) response["stealth_features"] = active_features - + + # Auto-take a compact snapshot so the model can act immediately + # without a separate browser_snapshot call. + try: + snap_result = _run_browser_command(effective_task_id, "snapshot", ["-c"]) + if snap_result.get("success"): + snap_data = snap_result.get("data", {}) + snapshot_text = snap_data.get("snapshot", "") + refs = snap_data.get("refs", {}) + if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD: + snapshot_text = _truncate_snapshot(snapshot_text) + response["snapshot"] = snapshot_text + response["element_count"] = len(refs) if refs else 0 + except Exception as e: + logger.debug("Auto-snapshot after navigate failed: %s", e) + return json.dumps(response, ensure_ascii=False) else: return json.dumps({ @@ -1376,31 +1383,40 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str: Returns: JSON string with scroll result """ - if _is_camofox_mode(): - from tools.browser_camofox import camofox_scroll - return camofox_scroll(direction, task_id) - - effective_task_id = task_id or "default" - # Validate direction if direction not in ["up", "down"]: return json.dumps({ "success": False, "error": f"Invalid direction '{direction}'. Use 'up' or 'down'." }, ensure_ascii=False) - - result = _run_browser_command(effective_task_id, "scroll", [direction]) - - if result.get("success"): - return json.dumps({ - "success": True, - "scrolled": direction - }, ensure_ascii=False) - else: - return json.dumps({ - "success": False, - "error": result.get("error", f"Failed to scroll {direction}") - }, ensure_ascii=False) + + # Repeat the scroll 5 times to get meaningful page movement. + # Most backends scroll ~100px per call, which is barely visible. + # 5x gives roughly half a viewport of travel, backend-agnostic. + _SCROLL_REPEATS = 5 + + if _is_camofox_mode(): + from tools.browser_camofox import camofox_scroll + result = None + for _ in range(_SCROLL_REPEATS): + result = camofox_scroll(direction, task_id) + return result + + effective_task_id = task_id or "default" + + result = None + for _ in range(_SCROLL_REPEATS): + result = _run_browser_command(effective_task_id, "scroll", [direction]) + if not result.get("success"): + return json.dumps({ + "success": False, + "error": result.get("error", f"Failed to scroll {direction}") + }, ensure_ascii=False) + + return json.dumps({ + "success": True, + "scrolled": direction + }, ensure_ascii=False) def browser_back(task_id: Optional[str] = None) -> str: @@ -1463,33 +1479,7 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str: }, ensure_ascii=False) -def browser_close(task_id: Optional[str] = None) -> str: - """ - Close the browser session. - Args: - task_id: Task identifier for session isolation - - Returns: - JSON string with close result - """ - if _is_camofox_mode(): - from tools.browser_camofox import camofox_close - return camofox_close(task_id) - - effective_task_id = task_id or "default" - with _cleanup_lock: - had_session = effective_task_id in _active_sessions - - cleanup_browser(effective_task_id) - - response = { - "success": True, - "closed": True, - } - if not had_session: - response["warning"] = "Session may not have been active" - return json.dumps(response, ensure_ascii=False) def browser_console(clear: bool = False, expression: Optional[str] = None, task_id: Optional[str] = None) -> str: @@ -1942,7 +1932,7 @@ def cleanup_browser(task_id: Optional[str] = None) -> None: Clean up browser session for a task. Called automatically when a task completes or when inactivity timeout is reached. - Closes both the agent-browser session and the Browserbase session. + Closes both the agent-browser/Browserbase session and Camofox sessions. Args: task_id: Task identifier to clean up @@ -1950,6 +1940,14 @@ def cleanup_browser(task_id: Optional[str] = None) -> None: if task_id is None: task_id = "default" + # Also clean up Camofox session if running in Camofox mode + if _is_camofox_mode(): + try: + from tools.browser_camofox import camofox_close + camofox_close(task_id) + except Exception as e: + logger.debug("Camofox cleanup for task %s: %s", task_id, e) + logger.debug("cleanup_browser called for task_id: %s", task_id) logger.debug("Active sessions: %s", list(_active_sessions.keys())) @@ -2168,14 +2166,7 @@ def check_browser_requirements() -> bool: check_fn=check_browser_requirements, emoji="⌨️", ) -registry.register( - name="browser_close", - toolset="browser", - schema=_BROWSER_SCHEMA_MAP["browser_close"], - handler=lambda args, **kw: browser_close(task_id=kw.get("task_id")), - check_fn=check_browser_requirements, - emoji="🚪", -) + registry.register( name="browser_get_images", toolset="browser", diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index eb13240b15f8..8dbcf7c3a204 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -103,6 +103,32 @@ def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) + +def _resolve_model_override(model_obj: Optional[Dict[str, Any]]) -> tuple: + """Resolve a model override object into (provider, model) for job storage. + + If provider is omitted, pins the current main provider from config so the + job doesn't drift when the user later changes their default via hermes model. + + Returns (provider_str_or_none, model_str_or_none). + """ + if not model_obj or not isinstance(model_obj, dict): + return (None, None) + model_name = (model_obj.get("model") or "").strip() or None + provider_name = (model_obj.get("provider") or "").strip() or None + if model_name and not provider_name: + # Pin to the current main provider so the job is stable + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + provider_name = model_cfg.get("provider") or None + except Exception: + pass # Best-effort; provider stays None + return (provider_name, model_name) + + def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: bool = False) -> Optional[str]: if value is None: return None @@ -392,14 +418,9 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job. Jobs run in a fresh session with no current-chat context, so prompts must be self-contained. -If skill or skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction. +If skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction. On update, passing skills=[] clears attached skills. -If script is provided on create, the referenced Python script runs before each agent turn. -Its stdout is injected into the prompt as context. Use this for data collection and change -detection — the script handles gathering data, the agent analyzes and reports. -On update, pass script="" to clear an attached script. - NOTE: The agent's final response is auto-delivered to the target. Put the primary user-facing content in the final response. Cron jobs run autonomously with no user present — they cannot ask questions or request clarification. @@ -418,7 +439,7 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: }, "prompt": { "type": "string", - "description": "For create: the full self-contained prompt. If skill or skills are also provided, this becomes the task instruction paired with those skills." + "description": "For create: the full self-contained prompt. If skills are also provided, this becomes the task instruction paired with those skills." }, "schedule": { "type": "string", @@ -436,39 +457,30 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: "type": "string", "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" }, - "model": { - "type": "string", - "description": "Optional per-job model override used when the cron job runs" - }, - "provider": { - "type": "string", - "description": "Optional per-job provider override used when resolving runtime credentials" - }, - "base_url": { - "type": "string", - "description": "Optional per-job base URL override paired with provider/model routing" - }, - "include_disabled": { - "type": "boolean", - "description": "For list: include paused/completed jobs" - }, - "skill": { - "type": "string", - "description": "Optional single skill name to load before executing the cron prompt" - }, "skills": { "type": "array", "items": {"type": "string"}, - "description": "Optional ordered list of skills to load before executing the cron prompt. On update, pass an empty array to clear attached skills." + "description": "Optional ordered list of skill names to load before executing the cron prompt. On update, pass an empty array to clear attached skills." }, - "reason": { - "type": "string", - "description": "Optional pause reason" + "model": { + "type": "object", + "description": "Optional per-job model override. If provider is omitted, the current main provider is pinned at creation time so the job stays stable.", + "properties": { + "provider": { + "type": "string", + "description": "Provider name (e.g. 'openrouter', 'anthropic'). Omit to use and pin the current provider." + }, + "model": { + "type": "string", + "description": "Model name (e.g. 'anthropic/claude-sonnet-4', 'claude-sonnet-4')" + } + }, + "required": ["model"] }, "script": { "type": "string", "description": "Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under ~/.hermes/scripts/. On update, pass empty string to clear." - } + }, }, "required": ["action"] } @@ -502,7 +514,7 @@ def get_cronjob_tool_definitions(): name="cronjob", toolset="cronjob", schema=CRONJOB_SCHEMA, - handler=lambda args, **kw: cronjob( + handler=lambda args, **kw: (lambda _mo=_resolve_model_override(args.get("model")): cronjob( action=args.get("action", ""), job_id=args.get("job_id"), prompt=args.get("prompt"), @@ -510,16 +522,16 @@ def get_cronjob_tool_definitions(): name=args.get("name"), repeat=args.get("repeat"), deliver=args.get("deliver"), - include_disabled=args.get("include_disabled", False), + include_disabled=args.get("include_disabled", True), skill=args.get("skill"), skills=args.get("skills"), - model=args.get("model"), - provider=args.get("provider"), + model=_mo[1], + provider=_mo[0] or args.get("provider"), base_url=args.get("base_url"), reason=args.get("reason"), script=args.get("script"), task_id=kw.get("task_id"), - ), + ))(), check_fn=check_cronjob_requirements, emoji="⏰", ) diff --git a/toolsets.py b/toolsets.py index 84c19637f940..04e43b2861ff 100644 --- a/toolsets.py +++ b/toolsets.py @@ -37,14 +37,12 @@ "read_file", "write_file", "patch", "search_files", # Vision + image generation "vision_analyze", "image_generate", - # MoA - "mixture_of_agents", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", # Text-to-speech "text_to_speech", @@ -116,7 +114,7 @@ "tools": [ "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", "web_search" ], "includes": [] @@ -214,7 +212,7 @@ "safe": { "description": "Safe toolkit without terminal access", - "tools": ["mixture_of_agents"], + "tools": [], "includes": ["web", "vision", "image_gen"] }, @@ -235,7 +233,7 @@ "skills_list", "skill_view", "skill_manage", "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", "todo", "memory", "session_search", @@ -255,14 +253,12 @@ "read_file", "write_file", "patch", "search_files", # Vision + image generation "vision_analyze", "image_generate", - # MoA - "mixture_of_agents", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", # Planning & memory "todo", "memory", diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 5353ca5ff7b5..cd798697df3f 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -20,7 +20,6 @@ In addition to built-in tools, Hermes can load tools dynamically from MCP server |------|-------------|----------------------| | `browser_back` | Navigate back to the previous page in browser history. Requires browser_navigate to be called first. | — | | `browser_click` | Click on an element identified by its ref ID from the snapshot (e.g., '@e5'). The ref IDs are shown in square brackets in the snapshot output. Requires browser_navigate and browser_snapshot to be called first. | — | -| `browser_close` | Close the browser session and release resources. Call this when done with browser tasks to free up Browserbase session quota. | — | | `browser_console` | Get browser console output and JavaScript errors from the current page. Returns console.log/warn/error/info messages and uncaught JS exceptions. Use this to detect silent JavaScript errors, failed API calls, and application warnings. Requi… | — | | `browser_get_images` | Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first. | — | | `browser_navigate` | Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need… | — | diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 19ff00a3f2cb..7d566e60243f 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -52,7 +52,7 @@ Or in-session: | Toolset | Tools | Purpose | |---------|-------|---------| -| `browser` | `browser_back`, `browser_click`, `browser_close`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | Full browser automation. Includes `web_search` as a fallback for quick lookups. | +| `browser` | `browser_back`, `browser_click`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | Full browser automation. Includes `web_search` as a fallback for quick lookups. | | `clarify` | `clarify` | Ask the user a question when the agent needs clarification. | | `code_execution` | `execute_code` | Run Python scripts that call Hermes tools programmatically. | | `cronjob` | `cronjob` | Schedule and manage recurring tasks. | diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 8f9fc24ebb62..0dafec10b67d 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -277,10 +277,6 @@ Check the browser console for any JavaScript errors Use `clear=True` to clear the console after reading, so subsequent calls only show new messages. -### `browser_close` - -Close the browser session and release resources. Call this when done to free up Browserbase session quota. - ## Practical Examples ### Filling Out a Web Form @@ -295,7 +291,6 @@ Agent workflow: 4. browser_type(ref="@e5", text="SecurePass123") 5. browser_click(ref="@e8") → clicks "Create Account" 6. browser_snapshot() → confirms success -7. browser_close() ``` ### Researching Dynamic Content @@ -307,7 +302,6 @@ Agent workflow: 1. browser_navigate("https://github.com/trending") 2. browser_snapshot(full=true) → reads trending repo list 3. Returns formatted results -4. browser_close() ``` ## Session Recording @@ -349,5 +343,5 @@ If paid features aren't available on your plan, Hermes automatically falls back - **Text-based interaction** — relies on accessibility tree, not pixel coordinates - **Snapshot size** — large pages may be truncated or LLM-summarized at 8000 characters - **Session timeout** — cloud sessions expire based on your provider's plan settings -- **Cost** — cloud sessions consume provider credits; use `browser_close` when done. Use `/browser connect` for free local browsing. +- **Cost** — cloud sessions consume provider credits; sessions are automatically cleaned up when the conversation ends or after inactivity. Use `/browser connect` for free local browsing. - **No file downloads** — cannot download files from the browser From 937720b628556196ac9831b0ed180cebcf838ba6 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Wed, 1 Apr 2026 03:46:41 -0400 Subject: [PATCH 213/213] fix: gracefully handle missing systemctl in container environments hermes status and gateway status crash with FileNotFoundError when running in K8s containers that don't have systemd/systemctl. Detect container environments via KUBERNETES_SERVICE_HOST env var or missing systemctl binary, and show informative N/A output instead of crashing. Tests: 10 tests (4 unit for _is_container_env, 2 for systemd_status, 2 for _is_service_running, 2 e2e for full status output). --- hermes_cli/gateway.py | 25 +++ hermes_cli/status.py | 49 +++-- tests/hermes_cli/test_container_detection.py | 188 +++++++++++++++++++ 3 files changed, 245 insertions(+), 17 deletions(-) create mode 100644 tests/hermes_cli/test_container_detection.py diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 4a12a34bb0e1..a2442b792b00 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -231,6 +231,10 @@ def is_macos() -> bool: def is_windows() -> bool: return sys.platform == 'win32' +def _is_container_env() -> bool: + """Detect container/K8s environment where systemd is unavailable.""" + return bool(os.environ.get("KUBERNETES_SERVICE_HOST") or not shutil.which("systemctl")) + # ============================================================================= # Service Configuration @@ -875,6 +879,24 @@ def systemd_restart(system: bool = False): def systemd_status(deep: bool = False, system: bool = False): + if _is_container_env(): + try: + from gateway.status import is_gateway_running, read_runtime_status + _running = is_gateway_running() + print(f"Gateway service: {'running' if _running else 'stopped'} (container/K8s)") + # Show runtime health details if available + _state = read_runtime_status() + if _state: + gw_state = _state.get("gateway_state", "unknown") + print(f" Runtime state: {gw_state}") + platforms = _state.get("platforms", {}) + for pname, pstate in platforms.items(): + status = pstate.get("status", "unknown") + print(f" Platform {pname}: {status}") + except Exception: + print("Gateway service: unknown (container/K8s, status check failed)") + return + system = _select_systemd_scope(system) unit_path = get_systemd_unit_path(system=system) scope_flag = " --system" if system else "" @@ -1741,6 +1763,9 @@ def _is_service_installed() -> bool: def _is_service_running() -> bool: """Check if the gateway service is currently running.""" if is_linux(): + if _is_container_env(): + return False + user_unit_exists = get_systemd_unit_path(system=False).exists() system_unit_exists = get_systemd_unit_path(system=True).exists() diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 67b15bab7892..b9e8cf9980e6 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -5,6 +5,7 @@ """ import os +import shutil import sys import subprocess from pathlib import Path @@ -307,23 +308,37 @@ def show_status(args): print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) if sys.platform.startswith('linux'): - try: - from hermes_cli.gateway import get_service_name - _gw_svc = get_service_name() - except Exception: - _gw_svc = "hermes-gateway" - try: - result = subprocess.run( - ["systemctl", "--user", "is-active", _gw_svc], - capture_output=True, - text=True, - timeout=5 - ) - is_active = result.stdout.strip() == "active" - except subprocess.TimeoutExpired: - is_active = False - print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") - print(" Manager: systemd (user)") + # Detect container environment (no systemd) + _in_container = ( + os.environ.get("KUBERNETES_SERVICE_HOST") + or not shutil.which("systemctl") + ) + if _in_container: + try: + from gateway.status import is_gateway_running + _gw_running = is_gateway_running() + except Exception: + _gw_running = False + print(f" Status: {check_mark(_gw_running)} {'running' if _gw_running else 'stopped'}") + print(" Manager: container/K8s") + else: + try: + from hermes_cli.gateway import get_service_name + _gw_svc = get_service_name() + except Exception: + _gw_svc = "hermes-gateway" + try: + result = subprocess.run( + ["systemctl", "--user", "is-active", _gw_svc], + capture_output=True, + text=True, + timeout=5 + ) + is_active = result.stdout.strip() == "active" + except subprocess.TimeoutExpired: + is_active = False + print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") + print(" Manager: systemd (user)") elif sys.platform == 'darwin': from hermes_cli.gateway import get_launchd_label diff --git a/tests/hermes_cli/test_container_detection.py b/tests/hermes_cli/test_container_detection.py new file mode 100644 index 000000000000..ed21b2daf8dc --- /dev/null +++ b/tests/hermes_cli/test_container_detection.py @@ -0,0 +1,188 @@ +"""Tests for container/K8s environment detection across gateway and status modules.""" + +from types import SimpleNamespace + +import hermes_cli.gateway as gateway_mod +import hermes_cli.status as status_mod + + +# ============================================================================= +# Unit Tests: _is_container_env() +# ============================================================================= + + +class TestIsContainerEnv: + def test_returns_true_when_kubernetes_service_host_set(self, monkeypatch): + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1") + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemctl") + + assert gateway_mod._is_container_env() is True + + def test_returns_true_when_systemctl_missing(self, monkeypatch): + monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False) + monkeypatch.setattr( + "shutil.which", + lambda name: None if name == "systemctl" else f"/usr/bin/{name}", + ) + + assert gateway_mod._is_container_env() is True + + def test_returns_false_on_normal_linux(self, monkeypatch): + monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/systemctl" if name == "systemctl" else None, + ) + + assert gateway_mod._is_container_env() is False + + def test_returns_true_when_both_k8s_and_no_systemctl(self, monkeypatch): + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1") + monkeypatch.setattr("shutil.which", lambda name: None) + + assert gateway_mod._is_container_env() is True + + +# ============================================================================= +# Unit Tests: systemd_status() container early-return +# ============================================================================= + + +class TestSystemdStatusContainer: + def test_prints_running_in_container(self, monkeypatch, capsys): + monkeypatch.setattr(gateway_mod, "_is_container_env", lambda: True) + monkeypatch.setattr("gateway.status.is_gateway_running", lambda: True) + + gateway_mod.systemd_status() + + output = capsys.readouterr().out + assert "running" in output + assert "container/K8s" in output + + def test_prints_stopped_in_container(self, monkeypatch, capsys): + monkeypatch.setattr(gateway_mod, "_is_container_env", lambda: True) + monkeypatch.setattr("gateway.status.is_gateway_running", lambda: False) + + gateway_mod.systemd_status() + + output = capsys.readouterr().out + assert "stopped" in output + assert "container/K8s" in output + + def test_does_not_call_systemctl_in_container(self, monkeypatch, capsys): + monkeypatch.setattr(gateway_mod, "_is_container_env", lambda: True) + calls = [] + monkeypatch.setattr( + gateway_mod.subprocess, + "run", + lambda cmd, **kw: calls.append(cmd), + ) + + gateway_mod.systemd_status() + + assert calls == [], "systemctl should not be called in container env" + + +# ============================================================================= +# Unit Tests: _is_service_running() container path +# ============================================================================= + + +class TestIsServiceRunningContainer: + def test_returns_false_in_container_on_linux(self, monkeypatch): + monkeypatch.setattr(gateway_mod, "is_linux", lambda: True) + monkeypatch.setattr(gateway_mod, "_is_container_env", lambda: True) + + assert gateway_mod._is_service_running() is False + + def test_calls_systemctl_when_not_container_on_linux(self, monkeypatch, tmp_path): + monkeypatch.setattr(gateway_mod, "is_linux", lambda: True) + monkeypatch.setattr(gateway_mod, "_is_container_env", lambda: False) + + # Both unit paths must exist for the systemctl call to happen + user_unit = tmp_path / "user" / "hermes-gateway.service" + user_unit.parent.mkdir(parents=True) + user_unit.write_text("[Unit]\n") + monkeypatch.setattr( + gateway_mod, + "get_systemd_unit_path", + lambda system=False: user_unit if not system else tmp_path / "nope", + ) + + calls = [] + monkeypatch.setattr( + gateway_mod.subprocess, + "run", + lambda cmd, **kw: ( + calls.append(cmd), + SimpleNamespace(returncode=0, stdout="active\n", stderr=""), + )[1], + ) + + result = gateway_mod._is_service_running() + + assert len(calls) > 0, "should have called systemctl" + assert result is True + + +# ============================================================================= +# E2E Tests: status.py container detection output +# ============================================================================= + + +class TestStatusOutputContainer: + def test_status_shows_running_in_container_on_linux(self, monkeypatch, capsys): + """hermes status on Linux in a container shows running gateway.""" + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1") + monkeypatch.setattr( + "shutil.which", + lambda name: None if name == "systemctl" else f"/usr/bin/{name}", + ) + monkeypatch.setattr("gateway.status.is_gateway_running", lambda: True) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "container/K8s" in output + assert "running" in output + + def test_status_shows_stopped_in_container_on_linux(self, monkeypatch, capsys): + """hermes status on Linux in a container shows stopped gateway.""" + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.43.0.1") + monkeypatch.setattr( + "shutil.which", + lambda name: None if name == "systemctl" else f"/usr/bin/{name}", + ) + monkeypatch.setattr("gateway.status.is_gateway_running", lambda: False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "container/K8s" in output + assert "stopped" in output + + def test_status_shows_systemd_on_normal_linux(self, monkeypatch, capsys): + """hermes status on normal Linux shows systemd manager.""" + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.delenv("KUBERNETES_SERVICE_HOST", raising=False) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/systemctl" if name == "systemctl" else None, + ) + + # Mock the systemctl call that checks gateway status + monkeypatch.setattr( + status_mod.subprocess, + "run", + lambda cmd, **kw: SimpleNamespace( + returncode=0, stdout="active\n", stderr="" + ), + ) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "systemd (user)" in output + assert "container/K8s" not in output