Compare commits

...

131 Commits

Author SHA1 Message Date
teknium1
75382200e1 refactor: tighten MoA traceback logging scope
Follow up on salvaged PR #998 by limiting exc_info logging to terminal
failure paths, avoiding duplicate aggregator errors, and refreshing the
MoA default OpenRouter model lineup to current frontier options.
2026-03-14 06:50:01 -07:00
aydnOktay
82de0b191b improve: add exc_info to MoA error logging 2026-03-14 06:45:09 -07:00
Teknium
c1cca65168 Merge pull request #1302 from NousResearch/hermes/hermes-315847fd
feat(mcp): salvage selective tool loading with utility policies
2026-03-14 06:40:45 -07:00
teknium1
67e80def53 docs(mcp): add comprehensive Hermes MCP docs
Expand the MCP feature docs with filtering and capability-aware registration details, add a practical 'Use MCP with Hermes' tutorial, add a config reference page, and wire the new docs into the sidebar and landing page.
2026-03-14 06:36:01 -07:00
Teknium
63309065b6 Merge pull request #1305 from NousResearch/hermes/hermes-2ba57c8a
fix: email adapter IMAP UID tracking and SMTP TLS verification
2026-03-14 06:32:35 -07:00
teknium1
71cffbfa4f fix: verify SMTP TLS in send_message_tool
Add regression coverage for the standalone email send path and pass an explicit default SSL context to STARTTLS for certificate verification, matching the gateway email adapter hardening salvaged from PR #994.
2026-03-14 06:31:52 -07:00
Himess
344adc72a1 fix: update email test mocks to use imap.uid() instead of imap.search/fetch
Tests were still mocking imap.search() and imap.fetch() but the
implementation was changed to use imap.uid("search", ...) and
imap.uid("fetch", ...) for proper UID-based IMAP operations.
2026-03-14 06:29:00 -07:00
Himess
fa72f4ff55 fix: email adapter IMAP UID tracking and SMTP TLS verification
- Use imap.uid() for search and fetch instead of imap.search/fetch.
  Sequence numbers shift when messages are deleted, causing the adapter
  to skip new messages or reprocess old ones. UIDs are stable.

- Pass ssl.create_default_context() to starttls() so the server
  certificate is actually verified. Without it smtplib uses
  ssl._create_stdlib_context() which skips verification.
2026-03-14 06:29:00 -07:00
Teknium
914bb12035 Merge pull request #1301 from NousResearch/hermes/hermes-2ba57c8a
feat: add Parallel CLI research skill
2026-03-14 06:24:16 -07:00
teknium1
04e151714f feat(mcp): make selective tool loading capability-aware
Extend the salvaged MCP filtering work so utility tools are also governed by policy and server capabilities. Store the registered tool subset per server so rediscovery and status reporting stay accurate after filtering.
2026-03-14 06:22:02 -07:00
Teknium
2ff03ebafe fix: use non-greedy regex in DeepSeek V3 parser for multi-tool calls (#1300)
The greedy `.*` captures with `re.DOTALL` cause `findall()` to merge
multiple tool calls into a single match — silently dropping all but the
last tool call. Switching to `.*?` (non-greedy) fixes extraction when
models return multiple tool calls in one response.

Adds test coverage for the DeepSeek V3 parser including a multi-tool
call regression test.

Co-authored-by: Himess <semihcvlk53@gmail.com>
2026-03-14 06:19:28 -07:00
teknium1
d2869de477 docs: tighten Parallel CLI skill guidance
Clarify that Parallel is an optional paid vendor workflow, add headless auth and context-chaining guidance, and align command examples more closely with upstream docs before salvaging PR #985.
2026-03-14 06:18:04 -07:00
kshitij
8d61ebe183 feat: add Parallel CLI research skill 2026-03-14 06:15:16 -07:00
Teknium
a0f0f4fe52 Merge pull request #1297 from NousResearch/hermes/hermes-5556ee7e
docs: salvage #980 terminal backend and Windows troubleshooting
2026-03-14 06:14:03 -07:00
teyrebaz33
3198cc8fd9 feat(mcp): per-server tool filtering via include/exclude and enabled flag
Add optional config keys under each mcp_servers entry:
- tools.include: whitelist, only listed tools are registered
- tools.exclude: blacklist, all tools except listed are registered
- enabled: false: skip server entirely, no connection attempt

Backward-compatible: no config keys = all tools registered as before.

Tests: TestMCPSelectiveToolLoading (4 tests), 134 passed total.
2026-03-14 06:12:17 -07:00
Teknium
fb3c163612 fix(gateway): surface missing linger in status and doctor (#1296)
* fix(gateway): surface missing linger in status and doctor

Warn when a systemd user gateway service has linger disabled so users can
spot the common 'gateway sleeps after logout' deployment issue from both
hermes doctor and hermes gateway status.

* fix(gateway): check linger status after install

After installing the systemd user service, report whether linger is
already enabled instead of always printing the generic hint. This makes
post-install guidance match the user's actual deployment state.
2026-03-14 06:11:33 -07:00
Teknium
6fa197f973 Merge pull request #1298 from NousResearch/hermes/hermes-aa653753
fix: clearer terminal backend requirement errors
2026-03-14 06:05:58 -07:00
Oktay Aydin
00a0f18544 fix: clearer terminal backend requirement errors
Salvaged from PR #979 onto current main.

Preserve the current terminal backend checks while surfacing actionable
preflight errors for unknown TERMINAL_ENV values, missing SSH host/user
configuration, and missing Modal credentials/config. Tighten the modal
regression test so it deterministically exercises the config-missing
path.
2026-03-14 06:04:39 -07:00
teknium1
dd6a5732e7 docs: fix salvaged PR #980 troubleshooting details
Correct the PowerShell UTF-8 snippet in the new Windows encoding tip
and soften the Docker CLI wording to match Hermes' actual lookup
behavior.
2026-03-14 06:02:57 -07:00
aydnOktay
767b5463f9 docs: add terminal backend and windows troubleshooting 2026-03-14 06:01:22 -07:00
Teknium
acc669645f Merge pull request #1294 from NousResearch/hermes/hermes-315847fd
fix(update): salvage autostash update flow from PR #978
2026-03-14 05:59:03 -07:00
teknium1
42c778b5eb fix(update): warn and prompt before restoring autostash
Add a restore prompt for interactive updates, keep the stash when the user declines, and print a post-restore warning that local changes were reapplied on top of updated code.
2026-03-14 05:50:18 -07:00
smillunchick
f764c7135d fix: auto-stash local changes during updates 2026-03-14 05:44:48 -07:00
Teknium
b646440ca0 fix(mcp): resolve npx stdio connection failures (#1291)
Salvaged from PR #977 onto current main.
Preserves the MCP stdio command resolution and improved error diagnostics,
with deterministic regression tests for the npx/node PATH cases.

Co-authored-by: kshitij <82637225+kshitijk4poor@users.noreply.github.com>
2026-03-14 05:44:00 -07:00
Teknium
1a857123b3 feat(skills): add optional telephony skill with Twilio, SMS, and AI calls (#1289)
* feat: improve context compaction handoff summaries

Adapt PR #916 onto current main by replacing the old context summary marker
with a clearer handoff wrapper, updating the summarization prompt for
resume-oriented summaries, and preserving the current call_llm-based
compression path.

* fix: clearer error when docker backend is unavailable

* fix: preserve docker discovery in backend preflight

Follow up on salvaged PR #940 by reusing find_docker() during the new
availability check so non-PATH Docker Desktop installs still work. Add
a regression test covering the resolved executable path.

* test: make gateway async tests xdist-safe

Replace sync test usage of asyncio.get_event_loop().run_until_complete()
with asyncio.run() so tests do not depend on an ambient current event loop.
Also create the email disconnect poll task inside a running loop. This fixes
xdist/CI failures where workers have no current loop in MainThread.

* feat(skills): add phone-calls skill for outbound AI voice calls

Reformulated from core tool (PR #847 feedback) into a skill with a
standalone helper script. No new dependencies — uses only Python stdlib.

Two providers supported:
- Bland.ai (default): simple setup, one API key
- Vapi: flexible, better voice quality via ElevenLabs/Deepgram + Twilio

Includes:
- SKILL.md with full procedure, safety rules, provider docs, pitfalls
- scripts/phone_call.py CLI helper (call, status, diagnose commands)

* feat(skills): expand phone-calls into optional telephony skill

Follow up on salvaged PR #965 by moving the capability into optional-skills
and broadening it from outbound AI calling to a full telephony skill. Add
Twilio number provisioning, env/state persistence, SMS/MMS, inbound SMS
polling, Vapi import helpers, and a provider decision tree while keeping
telephony out of core runtime code.

* docs(skills): clarify Hermes TTS telephony workflow

---------

Co-authored-by: aydnOktay <xaydinoktay@gmail.com>
Co-authored-by: mormio <morganemoss@gmai.com>
2026-03-14 04:16:48 -07:00
Teknium
02752c83b4 Merge pull request #1287 from NousResearch/hermes/hermes-cc060dd9
fix(gateway): avoid slash-command crash with GatewayConfig
2026-03-14 04:13:56 -07:00
Teknium
a48ebc68f4 Merge pull request #1288 from NousResearch/hermes/hermes-de3d4e49-pr976
fix: reliably notify gateway users when updates finish
2026-03-14 04:13:13 -07:00
Teknium
b42ee3050e Merge pull request #1290 from NousResearch/hermes/hermes-f48b210a
fix(send_message): salvage and complete MEDIA delivery from #971
2026-03-14 04:12:54 -07:00
teknium1
5c9a84219d fix: complete send_message MEDIA delivery salvage
- prevent raw MEDIA tag leakage outside the gateway pipeline
- make extract_media handle quoted/backticked paths and optional whitespace
- send Telegram media natively with explicit error/warning handling
- add regression tests for Telegram media dispatch and MEDIA parsing
2026-03-14 04:02:03 -07:00
quabug
50d6659392 fix: handle MEDIA tags in send_message tool for native file delivery
The send_message tool's _send_telegram() sent MEDIA:<path> tags as
literal text instead of delivering actual files. This fixes it by
extracting MEDIA tags via BasePlatformAdapter.extract_media() and
routing files to the appropriate Telegram Bot API method by extension.

Changes:
- send_message_tool: extract MEDIA tags and send files natively as
  photo/video/voice/audio/document based on file extension
- send_message_tool: add per-file error handling and missing-file logging
- send_message_tool: use cleaned text in fallback to avoid leaking tags
- base.py extract_media: handle optional space after MEDIA: colon
- base.py extract_media: strip surrounding backticks/quotes from paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 04:02:03 -07:00
Teknium
9525db913f feat(skills): add X/Twitter xitter skill via upstream x-cli (#1285)
* feat(skills): salvage xitter skill from PR #1065

Adapt the X/Twitter skill onto current main without vendoring an external CLI.
Use upstream x-cli installation instructions, add a social-media category,
and align credential/setup guidance with Hermes conventions.

* docs(skills): explain X credential requirements in xitter skill

Clarify why the official X flow needs five credentials and call out the setup/cost friction explicitly.
2026-03-14 04:00:27 -07:00
clabbe-bot
3126c60885 fix: notify gateway users when updates finish or fail 2026-03-14 03:59:05 -07:00
Teknium
cac238c2a3 Merge pull request #1286 from NousResearch/hermes/hermes-315847fd
fix(patch): avoid corrupting pipe chars in v4a patch apply
2026-03-14 03:58:27 -07:00
teknium1
7e52e8eb54 fix(gateway): bridge quick commands into GatewayConfig runtime
Follow-up on salvaged PR #975.

Bridge quick_commands from config.yaml into load_gateway_config(),
normalize non-dict quick command config at runtime, and add coverage
for GatewayConfig round-trips plus config.yaml bridging. This makes the
GatewayConfig quick-command fix complete for the real user-facing config
path implicated by issue #973.
2026-03-14 03:57:25 -07:00
teknium1
96c250e538 test: cover pipe characters in v4a patch apply
Add a regression test for apply_v4a_operations when read content contains a literal pipe character outside a line-number prefix.
2026-03-14 03:54:46 -07:00
stablegenius49
ce56b45514 fix(gateway): support quick commands from GatewayConfig 2026-03-14 03:51:28 -07:00
alireza78a
1182aeea00 fix(patch): use regex to detect line-number prefix to avoid corrupting pipe chars 2026-03-14 03:47:13 -07:00
Teknium
cf3dceafe1 Merge pull request #1284 from NousResearch/hermes/hermes-de3d4e49-pr964
fix: show effective model and provider in status
2026-03-14 03:42:16 -07:00
teknium1
b5a7e807d0 test: cover provider label formatting 2026-03-14 03:39:12 -07:00
luisv-1
c2c37ef158 Show configured model and provider in status output
Made-with: Cursor
2026-03-14 03:35:37 -07:00
Teknium
2f8dbe4e77 Merge pull request #1283 from NousResearch/hermes/hermes-f48b210a
fix(setup): salvage keep-current provider handling from #951
2026-03-14 03:26:44 -07:00
Teknium
95d49401ee Merge pull request #1282 from NousResearch/hermes/hermes-cc060dd9
fix(cli): make TUI prompt and accent output skin-aware
2026-03-14 03:24:24 -07:00
StefanIsMe
26f8b790c9 fix(setup): persist provider when switching model endpoints 2026-03-14 03:21:46 -07:00
Teknium
7901d863dd Merge pull request #1280 from NousResearch/hermes/hermes-de3d4e49-pr944
fix: make session log writes reuse shared atomic JSON helper
2026-03-14 03:15:52 -07:00
teknium1
e9a7441c9b test: restore default event loop for sync tests 2026-03-14 03:14:34 -07:00
Wayne
41f22de20f fix(cli): make TUI prompt and accent output skin-aware
Salvaged from PR #932 by Wayne onto current main.

Apply skin-aware prompt symbols and live prompt_toolkit color refresh,
replace lingering hardcoded accent output with active-skin colors, keep
ANSI-safe response rendering, preserve secret-capture and approval-prompt
state handling, and add integration coverage for prompt state and style
refresh behavior.
2026-03-14 03:12:52 -07:00
Teknium
b91cac7b4b test: make gateway async tests xdist-safe (#1281)
* feat: improve context compaction handoff summaries

Adapt PR #916 onto current main by replacing the old context summary marker
with a clearer handoff wrapper, updating the summarization prompt for
resume-oriented summaries, and preserving the current call_llm-based
compression path.

* fix: clearer error when docker backend is unavailable

* fix: preserve docker discovery in backend preflight

Follow up on salvaged PR #940 by reusing find_docker() during the new
availability check so non-PATH Docker Desktop installs still work. Add
a regression test covering the resolved executable path.

* test: make gateway async tests xdist-safe

Replace sync test usage of asyncio.get_event_loop().run_until_complete()
with asyncio.run() so tests do not depend on an ambient current event loop.
Also create the email disconnect poll task inside a running loop. This fixes
xdist/CI failures where workers have no current loop in MainThread.

---------

Co-authored-by: aydnOktay <xaydinoktay@gmail.com>
2026-03-14 03:12:15 -07:00
Teknium
29312a23d9 Merge pull request #1279 from NousResearch/hermes/hermes-315847fd
refactor: salvage adapter and CLI cleanup from PR #939
2026-03-14 03:10:01 -07:00
kshitij
0bb7ed1d95 refactor: salvage adapter and CLI cleanup from PR #939
Salvaged from PR #939 by kshitij.

- deduplicate Discord slash command dispatch and local file send helpers
- deduplicate Slack file uploads while preserving thread metadata
- extract shared CLI session relative-time formatting
- hoist browser PATH cleanup constants and throttle screenshot pruning
- tidy small type and import cleanups
2026-03-14 03:07:11 -07:00
Teknium
f279bb004f Merge pull request #1278 from NousResearch/hermes/hermes-f48b210a
test: fix gateway async tests without implicit event loop
2026-03-14 02:57:47 -07:00
teknium1
cbbba87099 fix: reuse shared atomic session log helper 2026-03-14 02:56:13 -07:00
Teknium
6036793f60 fix: clearer docker backend preflight errors (#1276)
* feat: improve context compaction handoff summaries

Adapt PR #916 onto current main by replacing the old context summary marker
with a clearer handoff wrapper, updating the summarization prompt for
resume-oriented summaries, and preserving the current call_llm-based
compression path.

* fix: clearer error when docker backend is unavailable

* fix: preserve docker discovery in backend preflight

Follow up on salvaged PR #940 by reusing find_docker() during the new
availability check so non-PATH Docker Desktop installs still work. Add
a regression test covering the resolved executable path.

---------

Co-authored-by: aydnOktay <xaydinoktay@gmail.com>
2026-03-14 02:53:02 -07:00
alireza78a
f685741481 fix(agent): use atomic write in _save_session_log to prevent data loss 2026-03-14 02:53:01 -07:00
teknium1
115dd17b3c test: fix gateway async test event loop usage
Use asyncio.run in sync tests that were relying on an implicit current event loop. This makes the gateway send-image and Slack connect tests pass reliably under Python 3.11+ and xdist workers.
2026-03-14 02:52:47 -07:00
Teknium
486cb772b8 Merge pull request #1275 from NousResearch/hermes/hermes-f48b210a
feat(gateway): salvage reasoning hot reload from #938
2026-03-14 02:47:11 -07:00
Teknium
11e6775f98 Merge pull request #1274 from NousResearch/hermes/hermes-de3d4e49-pr920
fix: handle headless setup flows end-to-end
2026-03-14 02:45:19 -07:00
Verne
52ba940c9b feat(gateway): add reasoning hot reload
Add a /reasoning command across gateway adapters so users can
inspect or change reasoning effort without editing config by hand.

Reload reasoning settings from config.yaml before each agent run,
including background tasks, so the next message picks up the new
value consistently.
2026-03-14 02:42:47 -07:00
teknium1
9492f42aa7 fix: cover headless first-run setup flow 2026-03-14 02:37:29 -07:00
Teknium
5c479eedf1 feat: improve context compaction handoff summaries (#1273)
Adapt PR #916 onto current main by replacing the old context summary marker
with a clearer handoff wrapper, updating the summarization prompt for
resume-oriented summaries, and preserving the current call_llm-based
compression path.
2026-03-14 02:33:31 -07:00
teyrebaz33
4aa94ae7cc fix: detect non-interactive TTY in setup wizard to prevent hang
hermes setup hung indefinitely on headless SSH sessions, Docker
containers, and CI/CD environments because the interactive provider
selection menu could not receive input.

Two-layer fix:
1. sys.stdin.isatty() check — auto-detects non-interactive environments
2. --non-interactive flag support — already in CLI parser, now honored

In both cases the wizard exits immediately with helpful guidance
pointing users to 'hermes config set' commands.

Closes #905
2026-03-14 02:32:46 -07:00
Teknium
728fa66ef0 Merge pull request #1272 from NousResearch/hermes/hermes-315847fd
fix: log prompt builder skill parsing fallbacks
2026-03-14 02:23:52 -07:00
teknium1
1e23d14568 fix: log prompt builder skill parsing fallbacks 2026-03-14 02:22:17 -07:00
Teknium
1117a21065 Merge pull request #1271 from NousResearch/hermes/hermes-de3d4e49
fix: guard init-time stdio writes
2026-03-14 02:21:39 -07:00
teknium1
936040d8f7 fix: guard init-time stdio writes 2026-03-14 02:19:46 -07:00
Teknium
74d7964688 Merge pull request #1259 from NousResearch/docs/internal-systems-and-acp
docs: add ACP and internal systems implementation guides
2026-03-14 00:58:19 -07:00
teknium1
d87a1615ce docs: add ACP and internal systems implementation guides
- add ACP user and developer docs covering setup, lifecycle, callbacks,
  permissions, tool rendering, and runtime behavior
- add developer guides for agent loop, provider runtime resolution,
  prompt assembly, context caching/compression, gateway internals,
  session storage, tools runtime, trajectories, and cron internals
- refresh architecture, quickstart, installation, CLI reference, and
  environments docs to link the new implementation pages and ACP support
2026-03-14 00:29:48 -07:00
Teknium
1869e88169 Merge pull request #1256 from NousResearch/hermes/hermes-720acdad
feat(security): add tirith pre-exec command scanning
2026-03-14 00:24:56 -07:00
teknium1
6f1889b0fa fix: preserve current approval semantics for tirith guard
Restore gateway/run.py to current main behavior while keeping tirith startup
and pattern_keys replay, preserve yolo and non-interactive bypass semantics in
the combined guard, and add regression tests for yolo and view-full flows.
2026-03-14 00:17:04 -07:00
Teknium
4250a7eb90 Merge pull request #1255 from NousResearch/hermes/hermes-7ef267b0
fix(cron): persist cron sessions to SQLite
2026-03-14 00:13:51 -07:00
teknium1
f5cf1f8a45 fix(cron): tag persisted cron sessions and test wiring
- store cron-run sessions with source=cron instead of falling back to cli
- close the per-run SessionDB after completion
- add regression coverage for cron session_db/platform wiring
2026-03-14 00:12:34 -07:00
sheeki003
375ce8a881 feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.

Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.

New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
  mandatory cosign provenance verification, non-blocking background
  download, disk-persistent failure markers with retryable-cause
  tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
  mapping, fail_open, cosign verification, background install,
  HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
  combined guard orchestration

Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
  add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
  consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
  call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
  call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
  commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-14 00:11:27 -07:00
Ömer Kaz
9283877204 fix(cron): pass session_db to AIAgent so cron messages are persisted
Cron jobs create AIAgent without passing session_db, so messages from
cron runs (and their delegate_task subagents) are never written to the
SQLite session store. This means session_search cannot find any cron
conversation history — the same class of bug fixed for the gateway in
8aa531c (PR #105).

Initialize SessionDB in run_job() and pass it to AIAgent, following the
identical pattern used in gateway/run.py.
2026-03-14 00:10:02 -07:00
Teknium
29176f302e fix: sanitize chat payloads and provider precedence (#1253)
fix: sanitize chat payloads and provider precedence
2026-03-14 00:09:14 -07:00
Teknium
25481d4286 feat: restore ACP server implementation from PR #949 (#1254)
Restore the ACP editor-integration implementation that was present on the
original PR branch but did not actually land in main.

Includes:
- acp_adapter/ server, session manager, event bridge, auth, permissions,
  and tool helpers
- hermes acp subcommand and hermes-acp entry point
- hermes-acp curated toolset
- ACP registry manifest, setup guide, and ACP test suite
- jupyter-live-kernel data science skill from the original branch

Also updates the revived ACP code for current main by:
- resolving runtime providers through the modern shared provider router
- binding ACP sessions to per-session cwd task overrides
- tracking duplicate same-name tool calls with FIFO IDs
- restoring terminal approval callbacks after prompts
- normalizing supporting docs/skill metadata

Validated with tests/acp and the full pytest suite (-n0).
2026-03-14 00:09:05 -07:00
Teknium
2fe853bcc9 Merge pull request #1251 from NousResearch/hermes/hermes-f7e92273
fix: prevent logging handler accumulation in gateway mode
2026-03-14 00:04:13 -07:00
teknium1
2166292157 fix: clarify provider precedence docstring 2026-03-13 23:59:47 -07:00
teknium1
163fa4a9d1 refactor(cli): implement approval locking mechanism to serialize concurrent requests
- Introduced _approval_lock to ensure that approval prompts are handled sequentially, preventing state clobbering from parallel delegation subtasks.
- Updated approval_callback and HermesCLI methods to utilize the lock for managing approval state and deadlines.
- Added tests for the config bridging logic to ensure correct environment variable mapping from config.yaml.
2026-03-13 23:59:18 -07:00
Adavya Sharma
a628c607f0 fix: preserve chat kwargs identity when no sanitization is needed 2026-03-13 23:59:12 -07:00
Adavya Sharma
08208323f2 test: cover fireworks tool-call payload sanitization 2026-03-13 23:59:12 -07:00
Adavya Sharma
358dab52ce fix: sanitize chat payloads and provider precedence 2026-03-13 23:59:12 -07:00
teknium1
806b79b589 test: cover errors.log handler reuse 2026-03-13 23:56:51 -07:00
Eris
c2a7921f3b fix: prevent logging handler accumulation in gateway mode
Use exact Path comparison instead of endswith to detect existing
errors.log handlers, avoiding false positives from similarly-named
log files.
2026-03-13 23:56:22 -07:00
Teknium
a20d373945 fix: worktree-aware minisweagent path discovery + clean up requirements check (#1248)
Salvage of PR #1246 by ChatGPT (teknium1 session), resolved against
current main which already includes #1239.

Changes:
- Add minisweagent_path.py: worktree-aware helper that finds
  mini-swe-agent/src from either the current checkout or the main
  checkout behind a git worktree
- Use the helper in tools/terminal_tool.py and mini_swe_runner.py
  instead of naive path-relative lookup that fails in worktrees
- Clean up check_terminal_requirements():
  - local: return True (no minisweagent dep, per #1239)
  - singularity/ssh: remove unnecessary minisweagent imports
  - docker/modal: use importlib.util.find_spec with clear error
- Add regression tests for worktree path discovery and tool resolution
2026-03-13 23:39:51 -07:00
Teknium
21422dba44 Merge pull request #1239 from NousResearch/hermes/hermes-07d947aa
fix: stop local terminal warning without minisweagent
2026-03-13 22:14:44 -07:00
teknium1
329f83ff2d fix: stop local terminal warning without minisweagent 2026-03-13 22:00:36 -07:00
Teknium
af8791a49d test: fix stale CI assumptions in parser and quick-command coverage (#1236)
- update managed-server compatibility tests to match the current
  ServerManager.tool_parser wiring used by hermes_base_env
- make quick-command CLI assertions accept Rich Text objects, which is how
  ANSI-safe output is rendered now
- set HERMES_HOME explicitly in the Discord auto-thread config bridge test
  so it loads the intended temporary config file

Validated with the targeted test set and the full pytest suite.
2026-03-13 21:56:12 -07:00
Teknium
7c3cb9bb31 Merge pull request #1227 from NousResearch/hermes/hermes-07d947aa
fix: surface gpt-5.4 in codex setup
2026-03-13 21:55:51 -07:00
Teknium
a154a13811 Merge pull request #1237 from NousResearch/hermes/hermes-58b0a1f1
fix(cli): make /new, /reset, and /clear start real fresh sessions
2026-03-13 21:55:07 -07:00
teknium1
253d54a9e1 fix(cli): make /new, /reset, and /clear start real fresh sessions
Create a new session DB row when starting fresh from the CLI, reset the
agent DB flush cursor and todo state, and update session timing/session ID
bookkeeping so follow-up logging stays correct.

Also update slash-command descriptions and add regression tests for /new,
/reset, and /clear.

Supersedes PR #899.
Closes #641.
2026-03-13 21:53:54 -07:00
Teknium
22990ed378 Merge pull request #1233 from NousResearch/hermes/hermes-7c22e5c1
fix: respect HERMES_HOME in remaining hardcoded paths
2026-03-13 21:37:02 -07:00
teknium1
206e56cc5e fix: finish HERMES_HOME path cleanup
- route CLI interrupt debug logging through HERMES_HOME
- update the remaining channel_directory test to patch HERMES_HOME
  instead of Path.home()
2026-03-13 21:35:07 -07:00
Teknium
984f00e0b0 docs: expand Docusaurus coverage across CLI, tools, skills, and skins (#1232)
- add code-derived reference pages for slash commands, tools, toolsets,
  bundled skills, and official optional skills
- document the skin system and link visual theming separately from
  conversational personality
- refresh quickstart, configuration, environment variable, and messaging
  docs to match current provider, gateway, and browser behavior
- fix stale command, session, and Home Assistant configuration guidance
2026-03-13 21:34:41 -07:00
teknium1
607689095e fix: add codex forward-compat model listing 2026-03-13 21:34:01 -07:00
0xIbra
437ec17125 fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.

Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.

Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)

Tests updated to use HERMES_HOME env var instead of patching Path.home().

Closes #892

(cherry picked from commit 78ac1bba43)
2026-03-13 21:32:53 -07:00
Teknium
2bf6b7ad1a feat(skills): add Linear project management skill (#1230)
Comprehensive Linear GraphQL API skill with API key auth (no OAuth
needed). Includes all common queries (issues, projects, teams, search,
filters) and mutations (create, update, assign, comment, status changes).

Addresses user pain point: Linear MCP server OAuth flow is unreliable
in headless agent sessions. This skill uses personal API keys which
work reliably without browser-based auth flows.

Requires: LINEAR_API_KEY env var (personal API key from Linear settings)
2026-03-13 21:20:32 -07:00
teknium1
899cb52e7a refactor: drop codex oauth model warning 2026-03-13 21:18:29 -07:00
teknium1
529729831c fix: explain codex oauth gpt-5.4 limits 2026-03-13 21:12:55 -07:00
Teknium
938e887b4c fix: keep honcho recall out of cached system prefix (#1201)
Attach later-turn Honcho recall to the current-turn user message at API
call time instead of appending it to the system prompt. This preserves the
stable system-prefix cache while keeping Honcho continuity context
available for the turn.

Also adds regression coverage for the injection helper and for continuing
sessions so Honcho recall stays out of the system prompt.
2026-03-13 21:07:00 -07:00
teknium1
57e98fe6c9 fix: surface gpt-5.4 in codex setup 2026-03-13 21:06:06 -07:00
Teknium
07d70a0345 test: cover empty cached Anthropic tool-call turns (#1222)
Add an integration-style regression test that runs prompt caching output
through the Anthropic adapter for an assistant tool-call turn with empty
content. This locks in the empty-text-block hotfix merged in PR #1216.
2026-03-13 20:44:25 -07:00
Teknium
cf78349911 Merge pull request #1216 from brandtcormorant/main
fix(cache_control) treat empty text like None to avoid anthropic api …
2026-03-13 20:31:25 -07:00
brandtcormorant
76efb0153a fix(cache_control) treat empty text like None to avoid anthropic api cache_control error 2026-03-13 18:08:46 -07:00
SHL0MS
6733a9a538 Update README 2026-03-13 19:31:29 -04:00
SHL0MS
58475261c4 Merge pull request #1213 from SHL0MS/ascii-video/design-patterns
ascii-video skill upgrades
2026-03-13 19:27:15 -04:00
SHL0MS
cda5910ab0 update ascii-video skill: design patterns, local time, examples
- New references/design-patterns.md: layer hierarchy (bg/content/accent),
  directional parameter arcs, scene concepts and visual metaphors,
  counter-rotating systems, wave collision, progressive fragmentation,
  entropy/consumption, staggered crescendo buildup, scene ordering
- New references/examples.md: copy-paste-ready scenes at every complexity
- Update scenes.md: local time convention (t=0 at scene start)
- Update SKILL.md: add design-patterns.md to reference table
- Add README.md to hermes-agent copy
- Sync all reference docs with canonical source (SHL0MS/ascii-video)
2026-03-13 19:13:12 -04:00
Teknium
bfb82b5cee fix: preserve Anthropic cache markers through adapter (#1205)
Keep assistant cache-control blocks intact when converting OpenAI-format
messages to Anthropic format, and propagate tool-message cache markers onto
generated tool_result blocks.

Adds regression tests covering assistant and tool cache marker preservation
through convert_messages_to_anthropic().
2026-03-13 13:27:03 -07:00
Teknium
c8bfb1db8f fix(gateway): add platform-specific notes to session context prompt (#1184)
Tell the agent what it CANNOT do on Slack and Discord — no searching
channel history, no pinning messages, no managing channels/roles.
Prevents the agent from hallucinating capabilities it doesn't have
and promising actions it can't deliver.

Addresses user feedback: agent says 'I'll search your Slack history'
then goes silent because no Slack-specific tools exist.
2026-03-13 12:34:11 -07:00
Austin Pickett
ebd4f2c6a8 fix: redesign landing page with Nous blue palette and cleaner layout (#974)
* fix: redesign landing page with Nous blue palette and cleaner layout

* fix: add features link

* fix: misc refactors, easings

* fix: animations, easings

* fix: mobile
2026-03-13 12:03:38 -07:00
Teknium
b74facd119 fix: handle YAML null values in session reset policy + configurable API timeout (#1194)
* fix: Home Assistant event filtering now closed by default

Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.

Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)

A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.

All 49 gateway HA tests + 52 HA tool tests pass.

* docs: update Home Assistant integration documentation

- homeassistant.md: Fix event filtering docs to reflect closed-by-default
  behavior. Add watch_all option. Replace Python dict config example with
  YAML. Fix defaults table (was incorrectly showing 'all'). Add required
  configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
  diagram, platform toolsets table, and Next Steps links.

* fix(terminal): strip provider env vars from background and PTY subprocesses

Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:

- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)

Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.

Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.

Gap identified by PR #1004 (@PeterFile).

* feat(delegate): add observability metadata to subagent results

Enrich delegate_task results with metadata from the child AIAgent:

- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status

Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.

Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness

Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>

* feat(stt): add free local whisper transcription via faster-whisper

Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):

  STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)

Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
  config loading, local faster-whisper backend, and OpenAI API backend.
  Auto-downloads model (~150MB for 'base') on first voice message.
  Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
  provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
  non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
  config examples, and fallback behavior

Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user

Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>

* fix: handle YAML null values in session reset policy + configurable API timeout

Two fixes from PR #888 by @Jah-yee:

1. SessionResetPolicy.from_dict() — data.get('at_hour', 4) returns None
   when the YAML key exists with a null value. Now explicitly checks for
   None and falls back to defaults. Zero remains a valid value.

2. API timeout — hardcoded 900s is now configurable via HERMES_API_TIMEOUT
   env var. Useful for slow local models (llama.cpp) that need longer.

Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>

---------

Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:16:42 -07:00
Teknium
07927f6bf2 feat(stt): add free local whisper transcription via faster-whisper (#1185)
* fix: Home Assistant event filtering now closed by default

Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.

Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)

A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.

All 49 gateway HA tests + 52 HA tool tests pass.

* docs: update Home Assistant integration documentation

- homeassistant.md: Fix event filtering docs to reflect closed-by-default
  behavior. Add watch_all option. Replace Python dict config example with
  YAML. Fix defaults table (was incorrectly showing 'all'). Add required
  configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
  diagram, platform toolsets table, and Next Steps links.

* fix(terminal): strip provider env vars from background and PTY subprocesses

Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:

- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)

Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.

Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.

Gap identified by PR #1004 (@PeterFile).

* feat(delegate): add observability metadata to subagent results

Enrich delegate_task results with metadata from the child AIAgent:

- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status

Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.

Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness

Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>

* feat(stt): add free local whisper transcription via faster-whisper

Replace OpenAI-only STT with a dual-provider system mirroring the TTS
architecture (Edge TTS free / ElevenLabs paid):

  STT: faster-whisper local (free, default) / OpenAI Whisper API (paid)

Changes:
- tools/transcription_tools.py: Full rewrite with provider dispatch,
  config loading, local faster-whisper backend, and OpenAI API backend.
  Auto-downloads model (~150MB for 'base') on first voice message.
  Singleton model instance reused across calls.
- pyproject.toml: Add faster-whisper>=1.0.0 as core dependency
- hermes_cli/config.py: Expand stt config to match TTS pattern with
  provider selection and per-provider model settings
- agent/context_compressor.py: Fix .strip() crash when LLM returns
  non-string content (dict from llama.cpp, None). Fixes #1100 partially.
- tests/: 23 new tests for STT providers + 2 for compressor fix
- docs/: Updated Voice & TTS page with STT provider table, model sizes,
  config examples, and fallback behavior

Fallback behavior:
- Local not installed → OpenAI API (if key set)
- OpenAI key not set → local whisper (if installed)
- Neither → graceful error message to user

Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>

---------

Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
Co-authored-by: Jah-yee <Jah-yee@users.noreply.github.com>
2026-03-13 11:11:05 -07:00
Teknium
11b577671b fix: auxiliary client uses main model for custom/local endpoints instead of gpt-4o-mini (#1189)
* fix: prevent model/provider mismatch when switching providers during active gateway

When _update_config_for_provider() writes the new provider and base_url
to config.yaml, the gateway (which re-reads config per-message) can pick
up the change before model selection completes. This causes the old model
name (e.g. 'anthropic/claude-opus-4.6') to be sent to the new provider's
API (e.g. MiniMax), which fails.

Changes:
- _update_config_for_provider() now accepts an optional default_model
  parameter. When provided and the current model.default is empty or
  uses OpenRouter format (contains '/'), it sets a safe default model
  for the new provider.
- All setup.py callers for direct-API providers (zai, kimi, minimax,
  minimax-cn, anthropic) now pass a provider-appropriate default model.
- _setup_provider_model_selection() now validates the 'Keep current'
  choice: if the current model uses OpenRouter format and wouldn't work
  with the new provider, it warns and switches to the provider's first
  default model instead of silently keeping the incompatible name.

Reported by a user on Home Assistant whose gateway started sending
'anthropic/claude-opus-4.6' to MiniMax's API after running hermes setup.

* fix: auxiliary client uses main model for custom/local endpoints instead of gpt-4o-mini

When a user runs a local server (e.g. Qwen3.5-9B via OPENAI_BASE_URL),
the auxiliary client (context compression, vision, session search) would
send requests for 'gpt-4o-mini' or 'google/gemini-3-flash-preview' to
the local server, which only serves one model — causing 404 errors
mid-task.

Changes:
- _try_custom_endpoint() now reads the user's configured main model via
  _read_main_model() (checks OPENAI_MODEL → HERMES_MODEL → LLM_MODEL →
  config.yaml model.default) instead of hardcoding 'gpt-4o-mini'.
- resolve_provider_client() auto mode now detects when an OpenRouter-
  formatted model override (containing '/') would be sent to a non-
  OpenRouter provider (like a local server) and drops it in favor of
  the provider's default model.
- Test isolation fixes: properly clear env vars in 'nothing available'
  tests to prevent host environment leakage.
2026-03-13 10:02:16 -07:00
teknium1
153ccbfd61 fix: strip user: prefix from Discord allowed user IDs in onboarding
Users sometimes paste Discord IDs with prefixes like 'user:123456',
'<@123456>', or '<@!123456>' from Discord's UI or third-party tools.
This caused auth failures since the allowlist contained 'user:123' but
the actual user_id from messages was just '123'.

Fixes:
- Added _clean_discord_id() helper in discord.py to strip common prefixes
- Applied sanitization at runtime when parsing DISCORD_ALLOWED_USERS env var
- Applied sanitization in hermes setup and hermes gateway setup input flows
- Handles user:, <@>, and <@!> prefix formats
2026-03-13 09:35:46 -07:00
Teknium
e8c9bcea2b fix: prevent model/provider mismatch when switching providers during active gateway (#1183)
When _update_config_for_provider() writes the new provider and base_url
to config.yaml, the gateway (which re-reads config per-message) can pick
up the change before model selection completes. This causes the old model
name (e.g. 'anthropic/claude-opus-4.6') to be sent to the new provider's
API (e.g. MiniMax), which fails.

Changes:
- _update_config_for_provider() now accepts an optional default_model
  parameter. When provided and the current model.default is empty or
  uses OpenRouter format (contains '/'), it sets a safe default model
  for the new provider.
- All setup.py callers for direct-API providers (zai, kimi, minimax,
  minimax-cn, anthropic) now pass a provider-appropriate default model.
- _setup_provider_model_selection() now validates the 'Keep current'
  choice: if the current model uses OpenRouter format and wouldn't work
  with the new provider, it warns and switches to the provider's first
  default model instead of silently keeping the incompatible name.

Reported by a user on Home Assistant whose gateway started sending
'anthropic/claude-opus-4.6' to MiniMax's API after running hermes setup.
2026-03-13 09:03:48 -07:00
Teknium
7aea893b5a Merge pull request #1181 from NousResearch/hermes/hermes-294208e8
fix(skills): use generic example in 1password op run snippet
2026-03-13 08:56:16 -07:00
teknium1
938edc6466 fix(skills): use generic example in 1password op run snippet
Replace OPENAI_API_KEY with DB_PASSWORD to avoid implying the
skill is OpenAI-related.
2026-03-13 08:56:06 -07:00
Teknium
b8b45bfb77 feat(discord): add /thread command, auto_thread config, and media metadata fix (#1178)
- Add /thread slash command that creates a Discord thread and starts a
  new Hermes session in it. The starter message (if provided) becomes
  the first user input in the new session.
- Add discord.auto_thread config option (DISCORD_AUTO_THREAD env var):
  when enabled, every message in a text channel automatically creates
  a thread, allowing parallel isolated sessions.
- Fix Discord media method signatures to accept metadata kwarg
  (send_voice, send_image_file, send_image) — prevents TypeError
  when the base adapter passes platform metadata.
- Fix test mock isolation: add app_commands and ForumChannel to
  discord mocks so tests pass in full-suite runs.

Based on PRs #866 and #1109 by insecurejezza, modified per review:
removed /channel command (unsafe), added auto_thread feature,
made /thread dispatch new sessions.

Co-authored-by: insecurejezza <insecurejezza@users.noreply.github.com>
2026-03-13 08:52:54 -07:00
Teknium
d425901bae fix: report cronjob tool as available in hermes doctor
Set HERMES_INTERACTIVE=1 via setdefault in run_doctor() so CLI-gated
tool checks (like cronjob) see the same context as the interactive CLI.

Cherry-picked from PR #895 by @stablegenius49.

Fixes #878

Co-authored-by: stablegenius49 <stablegenius49@users.noreply.github.com>
2026-03-13 08:51:45 -07:00
Teknium
bcefc2a475 fix(skills): improve 1password skill — env var prompting, auth docs, broken examples
fix(skills): improve 1password skill — env var prompting, auth docs, broken examples
2026-03-13 08:47:08 -07:00
teknium1
9667c71df8 fix(skills): improve 1password skill — env var prompting, auth docs, broken examples
Follow-up to PR #883 (arceus77-7):

- Add setup.collect_secrets for OP_SERVICE_ACCOUNT_TOKEN so the skill
  prompts users to configure their token on first load
- Fix broken code examples: garbled op run export line, truncated
  secret reference in cli-examples.md
- Add Authentication Methods section documenting all 3 auth flows
  (service account, desktop app, connect server) with service account
  recommended for Hermes
- Clarify tmux pattern is only needed for desktop app flow, not
  service account token flow
- Credit original author (arceus77-7) in frontmatter
- Add DESCRIPTION.md for security/ category

Co-authored-by: arceus77-7 <arceus77-7@users.noreply.github.com>
2026-03-13 08:46:49 -07:00
Teknium
808d81f921 Merge PR #883: feat(skills): add official optional 1password skill
feat(skills): add official optional 1password skill
2026-03-13 08:45:04 -07:00
Teknium
9f676d1394 feat(skills): add bundled opencode autonomous-agent skill
Cherry-picked from PR #880 by @arceus77-7, rebased onto current main with corrections.

Adds opencode skill under skills/autonomous-ai-agents/ with:
- One-shot opencode run workflow
- Interactive/background TUI session workflow
- PR review workflow (including opencode pr command)
- Parallel work patterns
- TUI keybindings reference
- Session/cost management
- Smoke verification

Tested with OpenCode v1.2.25. Fixed /exit bug (not a valid command),
added missing flags (--file, --thinking, --variant), expanded docs.

Co-authored-by: arceus77-7 <261276524+arceus77-7@users.noreply.github.com>
2026-03-13 08:39:21 -07:00
Teknium
02a819b16e feat(delegate): add observability metadata to subagent results (#1175)
* fix: Home Assistant event filtering now closed by default

Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.

Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)

A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.

All 49 gateway HA tests + 52 HA tool tests pass.

* docs: update Home Assistant integration documentation

- homeassistant.md: Fix event filtering docs to reflect closed-by-default
  behavior. Add watch_all option. Replace Python dict config example with
  YAML. Fix defaults table (was incorrectly showing 'all'). Add required
  configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
  diagram, platform toolsets table, and Next Steps links.

* fix(terminal): strip provider env vars from background and PTY subprocesses

Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:

- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)

Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.

Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.

Gap identified by PR #1004 (@PeterFile).

* feat(delegate): add observability metadata to subagent results

Enrich delegate_task results with metadata from the child AIAgent:

- model: which model the child used
- exit_reason: completed | interrupted | max_iterations
- tokens.input / tokens.output: token counts
- tool_trace: per-tool-call trace with byte sizes and ok/error status

Tool trace uses tool_call_id matching to correctly pair parallel tool
calls with their results, with a fallback for messages without IDs.

Cherry-picked from PR #872 by @omerkaz, with fixes:
- Fixed parallel tool call trace pairing (was always updating last entry)
- Removed redundant 'iterations' field (identical to existing 'api_calls')
- Added test for parallel tool call trace correctness

Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>

---------

Co-authored-by: omerkaz <omerkaz@users.noreply.github.com>
2026-03-13 08:07:12 -07:00
Teknium
4644f71faf Merge pull request #1173 from NousResearch/hermes/hermes-4cde5efa
fix(cron): use atomic write in save_job_output to prevent data loss on crash
2026-03-13 08:05:52 -07:00
alireza78a
9a7ed81b4b fix(cron): use atomic write in save_job_output to prevent data loss on crash
save_job_output() used bare open('w') which truncates the output file
immediately. A crash or OOM kill between truncation and the completed
write would silently wipe the job output.

Write now goes to a temp file first, then os.replace() swaps it
atomically — matching the existing save_jobs() pattern in the same file.
Preserves _secure_file() permissions and uses safe cleanup on error.

Cherry-picked from PR #874 by alireza78a, rebased onto current main
with conflict resolution and fixes:
- Kept _secure_dir/_secure_file security calls from PR #757
- Used except BaseException (not bare except) to match save_jobs pattern
- Wrapped os.unlink in try/except OSError to avoid masking errors

Co-authored-by: alireza78a <alireza78a@users.noreply.github.com>
2026-03-13 08:04:36 -07:00
Teknium
646b4ec533 fix(terminal): strip provider env vars from background and PTY subprocesses (#1172)
* fix: Home Assistant event filtering now closed by default

Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.

Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)

A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.

All 49 gateway HA tests + 52 HA tool tests pass.

* docs: update Home Assistant integration documentation

- homeassistant.md: Fix event filtering docs to reflect closed-by-default
  behavior. Add watch_all option. Replace Python dict config example with
  YAML. Fix defaults table (was incorrectly showing 'all'). Add required
  configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
  diagram, platform toolsets table, and Next Steps links.

* fix(terminal): strip provider env vars from background and PTY subprocesses

Extends the env var blocklist from #1157 to also cover the two remaining
leaky paths in process_registry.py:

- spawn_local() PTY path (line 156)
- spawn_local() background Popen path (line 197)

Both were still using raw os.environ, leaking provider vars to background
processes and interactive PTY sessions. Now uses the same dynamic
_HERMES_PROVIDER_ENV_BLOCKLIST from local.py.

Explicit env_vars passed to spawn_local() still override the blocklist,
matching the existing behavior for callers that intentionally need these.

Gap identified by PR #1004 (@PeterFile).
2026-03-13 07:54:46 -07:00
Muhammet Eren Karakuş
c92507e53d fix(terminal): strip Hermes provider env vars from subprocess environment (#1157)
Terminal subprocesses inherit OPENAI_BASE_URL and other provider env
vars loaded from ~/.hermes/.env, silently misrouting external CLIs
like codex.  Build a blocklist dynamically from the provider registry
so new providers are automatically covered.  Callers that truly need
a blocked var can opt in via the _HERMES_FORCE_ prefix.

Closes #1002

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:52:03 -07:00
Teknium
4b53ecb1c7 docs: update Home Assistant integration documentation (#1170)
* fix: Home Assistant event filtering now closed by default

Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.

Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)

A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.

All 49 gateway HA tests + 52 HA tool tests pass.

* docs: update Home Assistant integration documentation

- homeassistant.md: Fix event filtering docs to reflect closed-by-default
  behavior. Add watch_all option. Replace Python dict config example with
  YAML. Fix defaults table (was incorrectly showing 'all'). Add required
  configuration warning admonition.
- environment-variables.md: Add HASS_TOKEN and HASS_URL to Messaging section.
- messaging/index.md: Add Home Assistant to description, architecture
  diagram, platform toolsets table, and Next Steps links.
2026-03-13 07:45:06 -07:00
Teknium
61531396a0 fix: Home Assistant event filtering now closed by default (#1169)
Previously, when no watch_domains or watch_entities were configured,
ALL state_changed events passed through to the agent, causing users
to be flooded with notifications for every HA entity change.

Now events are dropped by default unless the user explicitly configures:
- watch_domains: list of domains to monitor (e.g. climate, light)
- watch_entities: list of specific entity IDs to monitor
- watch_all: true (new option — opt-in to receive all events)

A warning is logged at connect time if no filters are configured,
guiding users to set up their HA platform config.

All 49 gateway HA tests + 52 HA tool tests pass.
2026-03-13 07:40:38 -07:00
Teknium
6235fdde75 fix: raise session hygiene threshold from 50% to 85%
Session hygiene was firing at the same threshold (50%) as the agent's
own context compressor, causing premature compression on every turn
in long gateway sessions (especially Telegram).

Hygiene is a safety net for pathologically large sessions that would
cause API failures — it should NOT be doing normal compression work.
The agent's own compressor handles that during its tool loop with
accurate real token counts from the API.

Changes:
- Default hygiene threshold: 0.50 → 0.85 (fires only when truly large)
- Hygiene threshold is now independent of compression.threshold config
  (that setting controls the agent's compressor, not the pre-agent safety net)
- Removed env var override for hygiene threshold (CONTEXT_COMPRESSION_THRESHOLD
  still controls the agent's own compressor)
2026-03-13 04:17:45 -07:00
Teknium
8f8dd83443 fix: sync session_id after mid-run context compression
Critical bug: when the agent's context compressor fires during a tool
loop (_compress_context), it creates a new session_id and writes the
compressed messages there. But the gateway's session_entry still pointed
to the old session_id. On the next message, load_transcript() loaded
the stale pre-compression transcript, causing:

- Context bloat returning every turn
- Repeated compression cycles
- Loss of carefully compressed context

Fix: after run_conversation() returns, check if the agent's session_id
changed (compression split) and sync it back to the session store entry.
Also pass the effective session_id in the result dict so _handle_message
writes transcript entries to the correct session.

This affects ALL gateway adapters, not just webhook.
2026-03-13 04:14:35 -07:00
arceus77-7
d41a214c1a feat(skills): add official optional 1password skill 2026-03-10 20:45:29 -04:00
220 changed files with 23683 additions and 3013 deletions

1
acp_adapter/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""ACP (Agent Communication Protocol) adapter for hermes-agent."""

5
acp_adapter/__main__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Allow running the ACP adapter as ``python -m acp_adapter``."""
from .entry import main
main()

24
acp_adapter/auth.py Normal file
View File

@@ -0,0 +1,24 @@
"""ACP auth helpers — detect the currently configured Hermes provider."""
from __future__ import annotations
from typing import Optional
def detect_provider() -> Optional[str]:
"""Resolve the active Hermes runtime provider, or None if unavailable."""
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
runtime = resolve_runtime_provider()
api_key = runtime.get("api_key")
provider = runtime.get("provider")
if isinstance(api_key, str) and api_key.strip() and isinstance(provider, str) and provider.strip():
return provider.strip().lower()
except Exception:
return None
return None
def has_provider() -> bool:
"""Return True if Hermes can resolve any runtime provider credentials."""
return detect_provider() is not None

88
acp_adapter/entry.py Normal file
View File

@@ -0,0 +1,88 @@
"""CLI entry point for the hermes-agent ACP adapter.
Loads environment variables from ``~/.hermes/.env``, configures logging
to write to stderr (so stdout is reserved for ACP JSON-RPC transport),
and starts the ACP agent server.
Usage::
python -m acp_adapter.entry
# or
hermes acp
# or
hermes-acp
"""
import asyncio
import logging
import os
import sys
from pathlib import Path
def _setup_logging() -> None:
"""Route all logging to stderr so stdout stays clean for ACP stdio."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(logging.INFO)
# Quiet down noisy libraries
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("openai").setLevel(logging.WARNING)
def _load_env() -> None:
"""Load .env from HERMES_HOME (default ``~/.hermes``)."""
from dotenv import load_dotenv
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
env_file = hermes_home / ".env"
if env_file.exists():
try:
load_dotenv(dotenv_path=env_file, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(dotenv_path=env_file, encoding="latin-1")
logging.getLogger(__name__).info("Loaded env from %s", env_file)
else:
logging.getLogger(__name__).info(
"No .env found at %s, using system env", env_file
)
def main() -> None:
"""Entry point: load env, configure logging, run the ACP agent."""
_setup_logging()
_load_env()
logger = logging.getLogger(__name__)
logger.info("Starting hermes-agent ACP adapter")
# Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
import acp
from .server import HermesACPAgent
agent = HermesACPAgent()
try:
asyncio.run(acp.run_agent(agent))
except KeyboardInterrupt:
logger.info("Shutting down (KeyboardInterrupt)")
except Exception:
logger.exception("ACP agent crashed")
sys.exit(1)
if __name__ == "__main__":
main()

171
acp_adapter/events.py Normal file
View File

@@ -0,0 +1,171 @@
"""Callback factories for bridging AIAgent events to ACP notifications.
Each factory returns a callable with the signature that AIAgent expects
for its callbacks. Internally, the callbacks push ACP session updates
to the client via ``conn.session_update()`` using
``asyncio.run_coroutine_threadsafe()`` (since AIAgent runs in a worker
thread while the event loop lives on the main thread).
"""
import asyncio
import json
import logging
from collections import defaultdict, deque
from typing import Any, Callable, Deque, Dict
import acp
from .tools import (
build_tool_complete,
build_tool_start,
make_tool_call_id,
)
logger = logging.getLogger(__name__)
def _send_update(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
update: Any,
) -> None:
"""Fire-and-forget an ACP session update from a worker thread."""
try:
future = asyncio.run_coroutine_threadsafe(
conn.session_update(session_id, update), loop
)
future.result(timeout=5)
except Exception:
logger.debug("Failed to send ACP update", exc_info=True)
# ------------------------------------------------------------------
# Tool progress callback
# ------------------------------------------------------------------
def make_tool_progress_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
tool_call_ids: Dict[str, Deque[str]],
) -> Callable:
"""Create a ``tool_progress_callback`` for AIAgent.
Signature expected by AIAgent::
tool_progress_callback(name: str, preview: str, args: dict)
Emits ``ToolCallStart`` for each tool invocation and tracks IDs in a FIFO
queue per tool name so duplicate/parallel same-name calls still complete
against the correct ACP tool call.
"""
def _tool_progress(name: str, preview: str, args: Any = None) -> None:
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"raw": args}
if not isinstance(args, dict):
args = {}
tc_id = make_tool_call_id()
queue = tool_call_ids.get(name)
if queue is None:
queue = deque()
tool_call_ids[name] = queue
elif isinstance(queue, str):
queue = deque([queue])
tool_call_ids[name] = queue
queue.append(tc_id)
update = build_tool_start(tc_id, name, args)
_send_update(conn, session_id, loop, update)
return _tool_progress
# ------------------------------------------------------------------
# Thinking callback
# ------------------------------------------------------------------
def make_thinking_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
) -> Callable:
"""Create a ``thinking_callback`` for AIAgent."""
def _thinking(text: str) -> None:
if not text:
return
update = acp.update_agent_thought_text(text)
_send_update(conn, session_id, loop, update)
return _thinking
# ------------------------------------------------------------------
# Step callback
# ------------------------------------------------------------------
def make_step_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
tool_call_ids: Dict[str, Deque[str]],
) -> Callable:
"""Create a ``step_callback`` for AIAgent.
Signature expected by AIAgent::
step_callback(api_call_count: int, prev_tools: list)
"""
def _step(api_call_count: int, prev_tools: Any = None) -> None:
if prev_tools and isinstance(prev_tools, list):
for tool_info in prev_tools:
tool_name = None
result = None
if isinstance(tool_info, dict):
tool_name = tool_info.get("name") or tool_info.get("function_name")
result = tool_info.get("result") or tool_info.get("output")
elif isinstance(tool_info, str):
tool_name = tool_info
queue = tool_call_ids.get(tool_name or "")
if isinstance(queue, str):
queue = deque([queue])
tool_call_ids[tool_name] = queue
if tool_name and queue:
tc_id = queue.popleft()
update = build_tool_complete(
tc_id, tool_name, result=str(result) if result is not None else None
)
_send_update(conn, session_id, loop, update)
if not queue:
tool_call_ids.pop(tool_name, None)
return _step
# ------------------------------------------------------------------
# Agent message callback
# ------------------------------------------------------------------
def make_message_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
) -> Callable:
"""Create a callback that streams agent response text to the editor."""
def _message(text: str) -> None:
if not text:
return
update = acp.update_agent_message_text(text)
_send_update(conn, session_id, loop, update)
return _message

View File

@@ -0,0 +1,80 @@
"""ACP permission bridging — maps ACP approval requests to hermes approval callbacks."""
from __future__ import annotations
import asyncio
import logging
from concurrent.futures import TimeoutError as FutureTimeout
from typing import Any, Callable, Optional
from acp.schema import (
AllowedOutcome,
DeniedOutcome,
PermissionOption,
RequestPermissionRequest,
SelectedPermissionOutcome,
)
logger = logging.getLogger(__name__)
# Maps ACP PermissionOptionKind -> hermes approval result strings
_KIND_TO_HERMES = {
"allow_once": "once",
"allow_always": "always",
"reject_once": "deny",
"reject_always": "deny",
}
def make_approval_callback(
request_permission_fn: Callable,
loop: asyncio.AbstractEventLoop,
session_id: str,
timeout: float = 60.0,
) -> Callable[[str, str], str]:
"""
Return a hermes-compatible ``approval_callback(command, description) -> str``
that bridges to the ACP client's ``request_permission`` call.
Args:
request_permission_fn: The ACP connection's ``request_permission`` coroutine.
loop: The event loop on which the ACP connection lives.
session_id: Current ACP session id.
timeout: Seconds to wait for a response before auto-denying.
"""
def _callback(command: str, description: str) -> str:
options = [
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
PermissionOption(option_id="allow_always", kind="allow_always", name="Allow always"),
PermissionOption(option_id="deny", kind="reject_once", name="Deny"),
]
import acp as _acp
tool_call = _acp.start_tool_call("perm-check", command, kind="execute")
coro = request_permission_fn(
session_id=session_id,
tool_call=tool_call,
options=options,
)
try:
future = asyncio.run_coroutine_threadsafe(coro, loop)
response = future.result(timeout=timeout)
except (FutureTimeout, Exception) as exc:
logger.warning("Permission request timed out or failed: %s", exc)
return "deny"
outcome = response.outcome
if isinstance(outcome, AllowedOutcome):
option_id = outcome.option_id
# Look up the kind from our options list
for opt in options:
if opt.option_id == option_id:
return _KIND_TO_HERMES.get(opt.kind, "deny")
return "once" # fallback for unknown option_id
else:
return "deny"
return _callback

333
acp_adapter/server.py Normal file
View File

@@ -0,0 +1,333 @@
"""ACP agent server — exposes Hermes Agent via the Agent Client Protocol."""
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict, deque
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Deque, Optional
import acp
from acp.schema import (
AgentCapabilities,
AuthenticateResponse,
AuthMethod,
ClientCapabilities,
EmbeddedResourceContentBlock,
ForkSessionResponse,
ImageContentBlock,
AudioContentBlock,
Implementation,
InitializeResponse,
ListSessionsResponse,
LoadSessionResponse,
NewSessionResponse,
PromptResponse,
ResumeSessionResponse,
ResourceContentBlock,
SessionCapabilities,
SessionForkCapabilities,
SessionListCapabilities,
SessionInfo,
TextContentBlock,
Usage,
)
from acp_adapter.auth import detect_provider, has_provider
from acp_adapter.events import (
make_message_cb,
make_step_cb,
make_thinking_cb,
make_tool_progress_cb,
)
from acp_adapter.permissions import make_approval_callback
from acp_adapter.session import SessionManager
logger = logging.getLogger(__name__)
try:
from hermes_cli import __version__ as HERMES_VERSION
except Exception:
HERMES_VERSION = "0.0.0"
# Thread pool for running AIAgent (synchronous) in parallel.
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent")
def _extract_text(
prompt: list[
TextContentBlock
| ImageContentBlock
| AudioContentBlock
| ResourceContentBlock
| EmbeddedResourceContentBlock
],
) -> str:
"""Extract plain text from ACP content blocks."""
parts: list[str] = []
for block in prompt:
if isinstance(block, TextContentBlock):
parts.append(block.text)
elif hasattr(block, "text"):
parts.append(str(block.text))
# Non-text blocks are ignored for now.
return "\n".join(parts)
class HermesACPAgent(acp.Agent):
"""ACP Agent implementation wrapping Hermes AIAgent."""
def __init__(self, session_manager: SessionManager | None = None):
super().__init__()
self.session_manager = session_manager or SessionManager()
self._conn: Optional[acp.Client] = None
# ---- Connection lifecycle -----------------------------------------------
def on_connect(self, conn: acp.Client) -> None:
"""Store the client connection for sending session updates."""
self._conn = conn
logger.info("ACP client connected")
# ---- ACP lifecycle ------------------------------------------------------
async def initialize(
self,
protocol_version: int,
client_capabilities: ClientCapabilities | None = None,
client_info: Implementation | None = None,
**kwargs: Any,
) -> InitializeResponse:
provider = detect_provider()
auth_methods = None
if provider:
auth_methods = [
AuthMethod(
id=provider,
name=f"{provider} runtime credentials",
description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.",
)
]
client_name = client_info.name if client_info else "unknown"
logger.info("Initialize from %s (protocol v%s)", client_name, protocol_version)
return InitializeResponse(
protocol_version=acp.PROTOCOL_VERSION,
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
agent_capabilities=AgentCapabilities(
session_capabilities=SessionCapabilities(
fork=SessionForkCapabilities(),
list=SessionListCapabilities(),
),
),
auth_methods=auth_methods,
)
async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None:
if has_provider():
return AuthenticateResponse()
return None
# ---- Session management -------------------------------------------------
async def new_session(
self,
cwd: str,
mcp_servers: list | None = None,
**kwargs: Any,
) -> NewSessionResponse:
state = self.session_manager.create_session(cwd=cwd)
logger.info("New session %s (cwd=%s)", state.session_id, cwd)
return NewSessionResponse(session_id=state.session_id)
async def load_session(
self,
cwd: str,
session_id: str,
mcp_servers: list | None = None,
**kwargs: Any,
) -> LoadSessionResponse | None:
state = self.session_manager.update_cwd(session_id, cwd)
if state is None:
logger.warning("load_session: session %s not found", session_id)
return None
logger.info("Loaded session %s", session_id)
return LoadSessionResponse()
async def resume_session(
self,
cwd: str,
session_id: str,
mcp_servers: list | None = None,
**kwargs: Any,
) -> ResumeSessionResponse:
state = self.session_manager.update_cwd(session_id, cwd)
if state is None:
logger.warning("resume_session: session %s not found, creating new", session_id)
state = self.session_manager.create_session(cwd=cwd)
logger.info("Resumed session %s", state.session_id)
return ResumeSessionResponse()
async def cancel(self, session_id: str, **kwargs: Any) -> None:
state = self.session_manager.get_session(session_id)
if state and state.cancel_event:
state.cancel_event.set()
try:
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
state.agent.interrupt()
except Exception:
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
logger.info("Cancelled session %s", session_id)
async def fork_session(
self,
cwd: str,
session_id: str,
mcp_servers: list | None = None,
**kwargs: Any,
) -> ForkSessionResponse:
state = self.session_manager.fork_session(session_id, cwd=cwd)
new_id = state.session_id if state else ""
logger.info("Forked session %s -> %s", session_id, new_id)
return ForkSessionResponse(session_id=new_id)
async def list_sessions(
self,
cursor: str | None = None,
cwd: str | None = None,
**kwargs: Any,
) -> ListSessionsResponse:
infos = self.session_manager.list_sessions()
sessions = [
SessionInfo(session_id=s["session_id"], cwd=s["cwd"])
for s in infos
]
return ListSessionsResponse(sessions=sessions)
# ---- Prompt (core) ------------------------------------------------------
async def prompt(
self,
prompt: list[
TextContentBlock
| ImageContentBlock
| AudioContentBlock
| ResourceContentBlock
| EmbeddedResourceContentBlock
],
session_id: str,
**kwargs: Any,
) -> PromptResponse:
"""Run Hermes on the user's prompt and stream events back to the editor."""
state = self.session_manager.get_session(session_id)
if state is None:
logger.error("prompt: session %s not found", session_id)
return PromptResponse(stop_reason="refusal")
user_text = _extract_text(prompt)
if not user_text.strip():
return PromptResponse(stop_reason="end_turn")
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
conn = self._conn
loop = asyncio.get_running_loop()
if state.cancel_event:
state.cancel_event.clear()
tool_call_ids: dict[str, Deque[str]] = defaultdict(deque)
previous_approval_cb = None
if conn:
tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids)
thinking_cb = make_thinking_cb(conn, session_id, loop)
step_cb = make_step_cb(conn, session_id, loop, tool_call_ids)
message_cb = make_message_cb(conn, session_id, loop)
approval_cb = make_approval_callback(conn.request_permission, loop, session_id)
else:
tool_progress_cb = None
thinking_cb = None
step_cb = None
message_cb = None
approval_cb = None
agent = state.agent
agent.tool_progress_callback = tool_progress_cb
agent.thinking_callback = thinking_cb
agent.step_callback = step_cb
agent.message_callback = message_cb
if approval_cb:
try:
from tools import terminal_tool as _terminal_tool
previous_approval_cb = getattr(_terminal_tool, "_approval_callback", None)
_terminal_tool.set_approval_callback(approval_cb)
except Exception:
logger.debug("Could not set ACP approval callback", exc_info=True)
def _run_agent() -> dict:
try:
result = agent.run_conversation(
user_message=user_text,
conversation_history=state.history,
task_id=session_id,
)
return result
except Exception as e:
logger.exception("Agent error in session %s", session_id)
return {"final_response": f"Error: {e}", "messages": state.history}
finally:
if approval_cb:
try:
from tools import terminal_tool as _terminal_tool
_terminal_tool.set_approval_callback(previous_approval_cb)
except Exception:
logger.debug("Could not restore approval callback", exc_info=True)
try:
result = await loop.run_in_executor(_executor, _run_agent)
except Exception:
logger.exception("Executor error for session %s", session_id)
return PromptResponse(stop_reason="end_turn")
if result.get("messages"):
state.history = result["messages"]
final_response = result.get("final_response", "")
if final_response and conn:
update = acp.update_agent_message_text(final_response)
await conn.session_update(session_id, update)
usage = None
usage_data = result.get("usage")
if usage_data and isinstance(usage_data, dict):
usage = Usage(
input_tokens=usage_data.get("prompt_tokens", 0),
output_tokens=usage_data.get("completion_tokens", 0),
total_tokens=usage_data.get("total_tokens", 0),
thought_tokens=usage_data.get("reasoning_tokens"),
cached_read_tokens=usage_data.get("cached_tokens"),
)
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
return PromptResponse(stop_reason=stop_reason, usage=usage)
# ---- Model switching ----------------------------------------------------
async def set_session_model(
self, model_id: str, session_id: str, **kwargs: Any
):
"""Switch the model for a session."""
state = self.session_manager.get_session(session_id)
if state:
state.model = model_id
state.agent = self.session_manager._make_agent(
session_id=session_id,
cwd=state.cwd,
model=model_id,
)
logger.info("Session %s: model switched to %s", session_id, model_id)
return None

203
acp_adapter/session.py Normal file
View File

@@ -0,0 +1,203 @@
"""ACP session manager — maps ACP sessions to Hermes AIAgent instances."""
from __future__ import annotations
import copy
import logging
import uuid
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _register_task_cwd(task_id: str, cwd: str) -> None:
"""Bind a task/session id to the editor's working directory for tools."""
if not task_id:
return
try:
from tools.terminal_tool import register_task_env_overrides
register_task_env_overrides(task_id, {"cwd": cwd})
except Exception:
logger.debug("Failed to register ACP task cwd override", exc_info=True)
def _clear_task_cwd(task_id: str) -> None:
"""Remove task-specific cwd overrides for an ACP session."""
if not task_id:
return
try:
from tools.terminal_tool import clear_task_env_overrides
clear_task_env_overrides(task_id)
except Exception:
logger.debug("Failed to clear ACP task cwd override", exc_info=True)
@dataclass
class SessionState:
"""Tracks per-session state for an ACP-managed Hermes agent."""
session_id: str
agent: Any # AIAgent instance
cwd: str = "."
model: str = ""
history: List[Dict[str, Any]] = field(default_factory=list)
cancel_event: Any = None # threading.Event
class SessionManager:
"""Thread-safe manager for ACP sessions backed by Hermes AIAgent instances."""
def __init__(self, agent_factory=None):
"""
Args:
agent_factory: Optional callable that creates an AIAgent-like object.
Used by tests. When omitted, a real AIAgent is created
using the current Hermes runtime provider configuration.
"""
self._sessions: Dict[str, SessionState] = {}
self._lock = Lock()
self._agent_factory = agent_factory
# ---- public API ---------------------------------------------------------
def create_session(self, cwd: str = ".") -> SessionState:
"""Create a new session with a unique ID and a fresh AIAgent."""
import threading
session_id = str(uuid.uuid4())
agent = self._make_agent(session_id=session_id, cwd=cwd)
state = SessionState(
session_id=session_id,
agent=agent,
cwd=cwd,
model=getattr(agent, "model", "") or "",
cancel_event=threading.Event(),
)
with self._lock:
self._sessions[session_id] = state
_register_task_cwd(session_id, cwd)
logger.info("Created ACP session %s (cwd=%s)", session_id, cwd)
return state
def get_session(self, session_id: str) -> Optional[SessionState]:
"""Return the session for *session_id*, or ``None``."""
with self._lock:
return self._sessions.get(session_id)
def remove_session(self, session_id: str) -> bool:
"""Remove a session. Returns True if it existed."""
with self._lock:
existed = self._sessions.pop(session_id, None) is not None
if existed:
_clear_task_cwd(session_id)
return existed
def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState]:
"""Deep-copy a session's history into a new session."""
import threading
with self._lock:
original = self._sessions.get(session_id)
if original is None:
return None
new_id = str(uuid.uuid4())
agent = self._make_agent(
session_id=new_id,
cwd=cwd,
model=original.model or None,
)
state = SessionState(
session_id=new_id,
agent=agent,
cwd=cwd,
model=getattr(agent, "model", original.model) or original.model,
history=copy.deepcopy(original.history),
cancel_event=threading.Event(),
)
self._sessions[new_id] = state
_register_task_cwd(new_id, cwd)
logger.info("Forked ACP session %s -> %s", session_id, new_id)
return state
def list_sessions(self) -> List[Dict[str, Any]]:
"""Return lightweight info dicts for all sessions."""
with self._lock:
return [
{
"session_id": s.session_id,
"cwd": s.cwd,
"model": s.model,
"history_len": len(s.history),
}
for s in self._sessions.values()
]
def update_cwd(self, session_id: str, cwd: str) -> Optional[SessionState]:
"""Update the working directory for a session and its tool overrides."""
with self._lock:
state = self._sessions.get(session_id)
if state is None:
return None
state.cwd = cwd
_register_task_cwd(session_id, cwd)
return state
def cleanup(self) -> None:
"""Remove all sessions and clear task-specific cwd overrides."""
with self._lock:
session_ids = list(self._sessions.keys())
self._sessions.clear()
for session_id in session_ids:
_clear_task_cwd(session_id)
# ---- internal -----------------------------------------------------------
def _make_agent(
self,
*,
session_id: str,
cwd: str,
model: str | None = None,
):
if self._agent_factory is not None:
return self._agent_factory()
from run_agent import AIAgent
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_runtime_provider
config = load_config()
model_cfg = config.get("model")
default_model = "anthropic/claude-opus-4.6"
requested_provider = None
if isinstance(model_cfg, dict):
default_model = str(model_cfg.get("default") or default_model)
requested_provider = model_cfg.get("provider")
elif isinstance(model_cfg, str) and model_cfg.strip():
default_model = model_cfg.strip()
kwargs = {
"platform": "acp",
"enabled_toolsets": ["hermes-acp"],
"quiet_mode": True,
"session_id": session_id,
"model": model or default_model,
}
try:
runtime = resolve_runtime_provider(requested=requested_provider)
kwargs.update(
{
"provider": runtime.get("provider"),
"api_mode": runtime.get("api_mode"),
"base_url": runtime.get("base_url"),
"api_key": runtime.get("api_key"),
}
)
except Exception:
logger.debug("ACP session falling back to default provider resolution", exc_info=True)
_register_task_cwd(session_id, cwd)
return AIAgent(**kwargs)

215
acp_adapter/tools.py Normal file
View File

@@ -0,0 +1,215 @@
"""ACP tool-call helpers for mapping hermes tools to ACP ToolKind and building content."""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional
import acp
from acp.schema import (
ToolCallLocation,
ToolCallStart,
ToolCallProgress,
ToolKind,
)
# ---------------------------------------------------------------------------
# Map hermes tool names -> ACP ToolKind
# ---------------------------------------------------------------------------
TOOL_KIND_MAP: Dict[str, ToolKind] = {
# File operations
"read_file": "read",
"write_file": "edit",
"patch": "edit",
"search_files": "search",
# Terminal / execution
"terminal": "execute",
"process": "execute",
"execute_code": "execute",
# Web / fetch
"web_search": "fetch",
"web_extract": "fetch",
# Browser
"browser_navigate": "fetch",
"browser_click": "execute",
"browser_type": "execute",
"browser_snapshot": "read",
"browser_vision": "read",
"browser_scroll": "execute",
"browser_press": "execute",
"browser_back": "execute",
"browser_close": "execute",
"browser_get_images": "read",
# Agent internals
"delegate_task": "execute",
"vision_analyze": "read",
"image_generate": "execute",
"text_to_speech": "execute",
# Thinking / meta
"_thinking": "think",
}
def get_tool_kind(tool_name: str) -> ToolKind:
"""Return the ACP ToolKind for a hermes tool, defaulting to 'other'."""
return TOOL_KIND_MAP.get(tool_name, "other")
def make_tool_call_id() -> str:
"""Generate a unique tool call ID."""
return f"tc-{uuid.uuid4().hex[:12]}"
def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
"""Build a human-readable title for a tool call."""
if tool_name == "terminal":
cmd = args.get("command", "")
if len(cmd) > 80:
cmd = cmd[:77] + "..."
return f"terminal: {cmd}"
if tool_name == "read_file":
return f"read: {args.get('path', '?')}"
if tool_name == "write_file":
return f"write: {args.get('path', '?')}"
if tool_name == "patch":
mode = args.get("mode", "replace")
path = args.get("path", "?")
return f"patch ({mode}): {path}"
if tool_name == "search_files":
return f"search: {args.get('pattern', '?')}"
if tool_name == "web_search":
return f"web search: {args.get('query', '?')}"
if tool_name == "web_extract":
urls = args.get("urls", [])
if urls:
return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
return "web extract"
if tool_name == "delegate_task":
goal = args.get("goal", "")
if goal and len(goal) > 60:
goal = goal[:57] + "..."
return f"delegate: {goal}" if goal else "delegate task"
if tool_name == "execute_code":
return "execute code"
if tool_name == "vision_analyze":
return f"analyze image: {args.get('question', '?')[:50]}"
return tool_name
# ---------------------------------------------------------------------------
# Build ACP content objects for tool-call events
# ---------------------------------------------------------------------------
def build_tool_start(
tool_call_id: str,
tool_name: str,
arguments: Dict[str, Any],
) -> ToolCallStart:
"""Create a ToolCallStart event for the given hermes tool invocation."""
kind = get_tool_kind(tool_name)
title = build_tool_title(tool_name, arguments)
locations = extract_locations(arguments)
if tool_name == "patch":
mode = arguments.get("mode", "replace")
if mode == "replace":
path = arguments.get("path", "")
old = arguments.get("old_string", "")
new = arguments.get("new_string", "")
content = [acp.tool_diff_content(path=path, new_text=new, old_text=old)]
else:
# Patch mode — show the patch content as text
patch_text = arguments.get("patch", "")
content = [acp.tool_content(acp.text_block(patch_text))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
if tool_name == "write_file":
path = arguments.get("path", "")
file_content = arguments.get("content", "")
content = [acp.tool_diff_content(path=path, new_text=file_content)]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
if tool_name == "terminal":
command = arguments.get("command", "")
content = [acp.tool_content(acp.text_block(f"$ {command}"))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
if tool_name == "read_file":
path = arguments.get("path", "")
content = [acp.tool_content(acp.text_block(f"Reading {path}"))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
if tool_name == "search_files":
pattern = arguments.get("pattern", "")
target = arguments.get("target", "content")
content = [acp.tool_content(acp.text_block(f"Searching for '{pattern}' ({target})"))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
# Generic fallback
import json
try:
args_text = json.dumps(arguments, indent=2, default=str)
except (TypeError, ValueError):
args_text = str(arguments)
content = [acp.tool_content(acp.text_block(args_text))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
def build_tool_complete(
tool_call_id: str,
tool_name: str,
result: Optional[str] = None,
) -> ToolCallProgress:
"""Create a ToolCallUpdate (progress) event for a completed tool call."""
kind = get_tool_kind(tool_name)
# Truncate very large results for the UI
display_result = result or ""
if len(display_result) > 5000:
display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)"
content = [acp.tool_content(acp.text_block(display_result))]
return acp.update_tool_call(
tool_call_id,
kind=kind,
status="completed",
content=content,
raw_output=result,
)
# ---------------------------------------------------------------------------
# Location extraction
# ---------------------------------------------------------------------------
def extract_locations(
arguments: Dict[str, Any],
) -> List[ToolCallLocation]:
"""Extract file-system locations from tool arguments."""
locations: List[ToolCallLocation] = []
path = arguments.get("path")
if path:
line = arguments.get("offset") or arguments.get("line")
locations.append(ToolCallLocation(path=path, line=line))
return locations

12
acp_registry/agent.json Normal file
View File

@@ -0,0 +1,12 @@
{
"schema_version": 1,
"name": "hermes-agent",
"display_name": "Hermes Agent",
"description": "AI agent by Nous Research with 90+ tools, persistent memory, and multi-platform support",
"icon": "icon.svg",
"distribution": {
"type": "command",
"command": "hermes",
"args": ["acp"]
}
}

25
acp_registry/icon.svg Normal file
View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="gold" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1" />
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Staff -->
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#gold)" />
<!-- Wings (left) -->
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9" />
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8" />
<!-- Wings (right) -->
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9" />
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8" />
<!-- Left serpent -->
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round" />
<!-- Right serpent -->
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round" />
<!-- Orb at top -->
<circle cx="32" cy="10" r="4" fill="#F5C542" />
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -404,8 +404,14 @@ def convert_messages_to_anthropic(
if role == "assistant":
blocks = []
if content:
text = content if isinstance(content, str) else json.dumps(content)
blocks.append({"type": "text", "text": text})
if isinstance(content, list):
for part in content:
if isinstance(part, dict):
blocks.append(dict(part))
elif part is not None:
blocks.append({"type": "text", "text": str(part)})
else:
blocks.append({"type": "text", "text": str(content)})
for tc in m.get("tool_calls", []):
fn = tc.get("function", {})
args = fn.get("arguments", "{}")
@@ -436,6 +442,8 @@ def convert_messages_to_anthropic(
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
"content": result_content,
}
if isinstance(m.get("cache_control"), dict):
tool_result["cache_control"] = dict(m["cache_control"])
# Merge consecutive tool results into one user message
if (
result

View File

@@ -41,6 +41,7 @@ from typing import Any, Dict, List, Optional, Tuple
from openai import OpenAI
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
@@ -73,7 +74,7 @@ auxiliary_is_nous: bool = False
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
_NOUS_MODEL = "gemini-3-flash"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
# Codex fallback: uses the Responses API (the only endpoint the Codex
# OAuth token can access) with a fast model for auxiliary tasks.
@@ -439,12 +440,37 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:
)
def _read_main_model() -> str:
"""Read the user's configured main model from config/env.
Falls back through HERMES_MODEL → LLM_MODEL → config.yaml model.default
so the auxiliary client can use the same model as the main agent when no
dedicated auxiliary model is available.
"""
from_env = os.getenv("OPENAI_MODEL") or os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL")
if from_env:
return from_env.strip()
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, str) and model_cfg.strip():
return model_cfg.strip()
if isinstance(model_cfg, dict):
default = model_cfg.get("default", "")
if isinstance(default, str) and default.strip():
return default.strip()
except Exception:
pass
return ""
def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
custom_base = os.getenv("OPENAI_BASE_URL")
custom_key = os.getenv("OPENAI_API_KEY")
if not custom_base or not custom_key:
return None, None
model = os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
model = _read_main_model() or "gpt-4o-mini"
logger.debug("Auxiliary client: custom endpoint (%s)", model)
return OpenAI(api_key=custom_key, base_url=custom_base), model
@@ -575,6 +601,15 @@ def resolve_provider_client(
client, resolved = _resolve_auto()
if client is None:
return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a
# local server), an OpenRouter-formatted model override like
# "google/gemini-3-flash-preview" won't work. Drop it and use
# the provider's own default model instead.
if model and "/" in model and resolved and "/" not in resolved:
logger.debug(
"Dropping OpenRouter-format model %r for non-OpenRouter "
"auxiliary provider (using %r instead)", model, resolved)
model = None
final_model = model or resolved
return (_to_async_client(client, final_model) if async_mode
else (client, final_model))

View File

@@ -17,6 +17,16 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION] Earlier turns in this conversation were compacted "
"to save context space. The summary below describes work that was "
"already completed, and the current session state may still reflect "
"that work (for example, files may already be changed). Use the summary "
"and the current state to continue from where things left off, and "
"avoid repeating work:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
class ContextCompressor:
"""Compresses conversation context when approaching the model's context limit.
@@ -102,22 +112,22 @@ class ContextCompressor:
parts.append(f"[{role.upper()}]: {content}")
content_to_summarize = "\n\n".join(parts)
prompt = f"""Summarize these conversation turns concisely. This summary will replace these turns in the conversation history.
prompt = f"""Create a concise handoff summary for a later assistant that will continue this conversation after earlier turns are compacted.
Write from a neutral perspective describing:
Describe:
1. What actions were taken (tool calls, searches, file operations)
2. Key information or results obtained
3. Important decisions or findings
4. Relevant data, file names, or outputs
3. Important decisions, constraints, or user preferences
4. Relevant data, file names, outputs, or next steps needed to continue
Keep factual and informative. Target ~{self.summary_target_tokens} tokens.
Keep it factual, concise, and focused on helping the next assistant resume without repeating work. Target ~{self.summary_target_tokens} tokens.
---
TURNS TO SUMMARIZE:
{content_to_summarize}
---
Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
Write only the summary body. Do not include any preamble or prefix; the system will add the handoff wrapper."""
# Use the centralized LLM router — handles provider resolution,
# auth, and fallback internally.
@@ -132,10 +142,12 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
if self.summary_model:
call_kwargs["model"] = self.summary_model
response = call_llm(**call_kwargs)
summary = response.choices[0].message.content.strip()
if not summary.startswith("[CONTEXT SUMMARY]:"):
summary = "[CONTEXT SUMMARY]: " + summary
return summary
content = response.choices[0].message.content
# Handle cases where content is not a string (e.g., dict from llama.cpp)
if not isinstance(content, str):
content = str(content) if content else ""
summary = content.strip()
return self._with_summary_prefix(summary)
except RuntimeError:
logging.warning("Context compression: no provider available for "
"summary. Middle turns will be dropped without summary.")
@@ -144,6 +156,16 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
logging.warning("Failed to generate context summary: %s", e)
return None
@staticmethod
def _with_summary_prefix(summary: str) -> str:
"""Normalize summary text to the current compaction handoff format."""
text = (summary or "").strip()
for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX):
if text.startswith(prefix):
text = text[len(prefix):].lstrip()
break
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
# ------------------------------------------------------------------
# Tool-call / tool-result pair integrity helpers
# ------------------------------------------------------------------
@@ -283,7 +305,10 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
for i in range(compress_start):
msg = messages[i].copy()
if i == 0 and msg.get("role") == "system" and self.compression_count == 0:
msg["content"] = (msg.get("content") or "") + "\n\n[Note: Some earlier conversation turns may be summarized to preserve context space.]"
msg["content"] = (
(msg.get("content") or "")
+ "\n\n[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
)
compressed.append(msg)
if summary:

View File

@@ -68,7 +68,7 @@ def _oneline(text: str) -> str:
return " ".join(text.split())
def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str:
def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | None:
"""Build a short preview of a tool call's primary argument for display."""
if not args:
return None

View File

@@ -177,7 +177,8 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
desc = desc[:57] + "..."
return True, frontmatter, desc
except Exception:
except Exception as e:
logger.debug("Failed to parse skill file %s: %s", skill_file, e)
return True, {}, ""
@@ -194,7 +195,8 @@ def _read_skill_conditions(skill_file: Path) -> dict:
"fallback_for_tools": hermes.get("fallback_for_tools", []),
"requires_tools": hermes.get("requires_tools", []),
}
except Exception:
except Exception as e:
logger.debug("Failed to read skill conditions from %s: %s", skill_file, e)
return {}
@@ -420,7 +422,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str:
soul_path = candidate
break
if not soul_path:
global_soul = Path.home() / ".hermes" / "SOUL.md"
global_soul = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "SOUL.md"
if global_soul.exists():
soul_path = global_soul

View File

@@ -21,12 +21,14 @@ def _apply_cache_marker(msg: dict, cache_marker: dict) -> None:
msg["cache_control"] = cache_marker
return
if content is None:
if content is None or content == "":
msg["cache_control"] = cache_marker
return
if isinstance(content, str):
msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
msg["content"] = [
{"type": "text", "text": content, "cache_control": cache_marker}
]
return
if isinstance(content, list) and content:

View File

@@ -178,6 +178,20 @@ terminal:
# Example (add to your terminal section):
# sudo_password: "your-password-here"
# =============================================================================
# Security Scanning (tirith)
# =============================================================================
# Optional pre-exec command security scanning via tirith.
# Detects homograph URLs, pipe-to-shell, terminal injection, env manipulation.
# Install: brew install sheeki03/tap/tirith
# Docs: https://github.com/sheeki03/tirith
#
# security:
# tirith_enabled: true # Enable/disable tirith scanning
# tirith_path: "tirith" # Path to tirith binary (supports ~ expansion)
# tirith_timeout: 5 # Scan timeout in seconds
# tirith_fail_open: true # Allow commands if tirith unavailable
# =============================================================================
# Browser Tool Configuration
# =============================================================================

405
cli.py
View File

@@ -96,7 +96,7 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
return []
path = Path(file_path).expanduser()
if not path.is_absolute():
path = Path.home() / ".hermes" / path
path = _hermes_home / path
if not path.exists():
logger.warning("Prefill messages file not found: %s", path)
return []
@@ -141,16 +141,16 @@ def load_cli_config() -> Dict[str, Any]:
Environment variables take precedence over config file values.
Returns default values if no config file exists.
"""
# Check user config first (~/.hermes/config.yaml)
user_config_path = Path.home() / '.hermes' / 'config.yaml'
# Check user config first ({HERMES_HOME}/config.yaml)
user_config_path = _hermes_home / 'config.yaml'
project_config_path = Path(__file__).parent / 'cli-config.yaml'
# Use user config if it exists, otherwise project config
if user_config_path.exists():
config_path = user_config_path
else:
config_path = project_config_path
# Default configuration
defaults = {
"model": {
@@ -404,8 +404,10 @@ except Exception:
from rich import box as rich_box
from rich.console import Console
from rich.markup import escape as _escape
from rich.panel import Panel
from rich.table import Table
from rich.text import Text as _RichText
import fire
@@ -696,6 +698,24 @@ _BOLD = "\033[1m"
_DIM = "\033[2m"
_RST = "\033[0m"
def _accent_hex() -> str:
"""Return the active skin accent color for legacy CLI output lines."""
try:
from hermes_cli.skin_engine import get_active_skin
return get_active_skin().get_color("ui_accent", "#FFBF00")
except Exception:
return "#FFBF00"
def _rich_text_from_ansi(text: str) -> _RichText:
"""Safely render assistant/tool output that may contain ANSI escapes.
Using Rich Text.from_ansi preserves literal bracketed text like
``[not markup]`` while still interpreting real ANSI color codes.
"""
return _RichText.from_ansi(text or "")
def _cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's native renderer.
@@ -718,7 +738,12 @@ class ChatConsole:
def __init__(self):
from io import StringIO
self._buffer = StringIO()
self._inner = Console(file=self._buffer, force_terminal=True, highlight=False)
self._inner = Console(
file=self._buffer,
force_terminal=True,
color_system="truecolor",
highlight=False,
)
def print(self, *args, **kwargs):
self._buffer.seek(0)
@@ -1037,7 +1062,7 @@ def save_config_value(key_path: str, value: any) -> bool:
True if successful, False otherwise
"""
# Use the same precedence as load_cli_config: user config first, then project config
user_config_path = Path.home() / '.hermes' / 'config.yaml'
user_config_path = _hermes_home / 'config.yaml'
project_config_path = Path(__file__).parent / 'cli-config.yaml'
config_path = user_config_path if user_config_path.exists() else project_config_path
@@ -1151,8 +1176,8 @@ class HermesCLI:
# Provider selection is resolved lazily at use-time via _ensure_runtime_credentials().
self.requested_provider = (
provider
or os.getenv("HERMES_INFERENCE_PROVIDER")
or CLI_CONFIG["model"].get("provider")
or os.getenv("HERMES_INFERENCE_PROVIDER")
or "auto"
)
self._provider_source: Optional[str] = None
@@ -1259,7 +1284,7 @@ class HermesCLI:
self.session_id = f"{timestamp_str}_{short_uuid}"
# History file for persistent input recall across sessions
self._history_file = Path.home() / ".hermes_history"
self._history_file = _hermes_home / ".hermes_history"
self._last_invalidate: float = 0.0 # throttle UI repaints
self._app = None
self._secret_state = None
@@ -1472,13 +1497,16 @@ class HermesCLI:
title_part = ""
if session_meta.get("title"):
title_part = f" \"{session_meta['title']}\""
_cprint(
f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
f"{len(restored)} total messages){_RST}"
ChatConsole().print(
f"[bold {_accent_hex()}]↻ Resumed session[/] "
f"[bold]{_escape(self.session_id)}[/]"
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
)
else:
_cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}")
ChatConsole().print(
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
)
# Re-open the session (clear ended_at so it's active again)
try:
self._session_db._conn.execute(
@@ -1738,6 +1766,19 @@ class HermesCLI:
from rich.panel import Panel
from rich.text import Text
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
_history_text_c = _skin.get_color("banner_text", "#FFF8DC")
_session_label_c = _skin.get_color("session_label", "#DAA520")
_session_border_c = _skin.get_color("session_border", "#8B8682")
_assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F")
except Exception:
_history_text_c = "#FFF8DC"
_session_label_c = "#DAA520"
_session_border_c = "#8B8682"
_assistant_label_c = "#8FBC8F"
lines = Text()
if skipped:
lines.append(
@@ -1747,14 +1788,14 @@ class HermesCLI:
for i, (role, text) in enumerate(entries):
if role == "user":
lines.append(" ● You: ", style="dim bold #DAA520")
lines.append(" ● You: ", style=f"dim bold {_session_label_c}")
# Show first line inline, indent rest
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="dim")
else:
lines.append(" ◆ Hermes: ", style="dim bold #8FBC8F")
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
@@ -1764,9 +1805,10 @@ class HermesCLI:
panel = Panel(
lines,
title="[dim #DAA520]Previous Conversation[/]",
border_style="dim #8B8682",
title=f"[dim {_session_label_c}]Previous Conversation[/]",
border_style=f"dim {_session_border_c}",
padding=(0, 1),
style=_history_text_c,
)
self.console.print(panel)
@@ -1778,7 +1820,7 @@ class HermesCLI:
"""
from hermes_cli.clipboard import save_clipboard_image
img_dir = Path.home() / ".hermes" / "images"
img_dir = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "images"
self._image_counter += 1
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
@@ -1976,19 +2018,30 @@ class HermesCLI:
"""Display help information with categorized commands."""
from hermes_cli.commands import COMMANDS_BY_CATEGORY
_cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}")
_cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 15}|{_RST}")
_cprint(f"{_BOLD}+{'-' * 55}+{_RST}")
try:
from hermes_cli.skin_engine import get_active_help_header
header = get_active_help_header("(^_^)? Available Commands")
except Exception:
header = "(^_^)? Available Commands"
header = (header or "").strip() or "(^_^)? Available Commands"
inner_width = 55
if len(header) > inner_width:
header = header[:inner_width]
_cprint(f"\n{_BOLD}+{'-' * inner_width}+{_RST}")
_cprint(f"{_BOLD}|{header:^{inner_width}}|{_RST}")
_cprint(f"{_BOLD}+{'-' * inner_width}+{_RST}")
for category, commands in COMMANDS_BY_CATEGORY.items():
_cprint(f"\n {_BOLD}── {category} ──{_RST}")
for cmd, desc in commands.items():
_cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}")
ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}")
if _skill_commands:
_cprint(f"\n{_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
for cmd, info in sorted(_skill_commands.items()):
_cprint(f" {_GOLD}{cmd:<22}{_RST} {_DIM}-{_RST} {info['description']}")
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
)
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
@@ -2074,7 +2127,7 @@ class HermesCLI:
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
user_config_path = Path.home() / '.hermes' / 'config.yaml'
user_config_path = _hermes_home / 'config.yaml'
project_config_path = Path(__file__).parent / 'cli-config.yaml'
if user_config_path.exists():
config_path = user_config_path
@@ -2183,15 +2236,63 @@ class HermesCLI:
flush_tool_summary()
print()
def reset_conversation(self):
"""Reset the conversation history."""
def new_session(self, silent=False):
"""Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
try:
self.agent.flush_memories(self.conversation_history)
except Exception:
pass
old_session_id = self.session_id
if self._session_db and old_session_id:
try:
self._session_db.end_session(old_session_id, "new_session")
except Exception:
pass
self.session_start = datetime.now()
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
short_uuid = uuid.uuid4().hex[:6]
self.session_id = f"{timestamp_str}_{short_uuid}"
self.conversation_history = []
print("(^_^)b Conversation reset!")
self._pending_title = None
self._resumed = False
if self.agent:
self.agent.session_id = self.session_id
self.agent.session_start = self.session_start
if hasattr(self.agent, "_last_flushed_db_idx"):
self.agent._last_flushed_db_idx = 0
if hasattr(self.agent, "_todo_store"):
try:
from tools.todo_tool import TodoStore
self.agent._todo_store = TodoStore()
except Exception:
pass
if hasattr(self.agent, "_invalidate_system_prompt"):
self.agent._invalidate_system_prompt()
if self._session_db:
try:
self._session_db.create_session(
session_id=self.session_id,
source="cli",
model=self.model,
model_config={
"max_iterations": self.max_turns,
"reasoning_config": self.reasoning_config,
},
)
except Exception:
pass
if not silent:
print("(^_^)v New session started!")
def reset_conversation(self):
"""Reset the conversation by starting a new session."""
self.new_session()
def save_conversation(self):
"""Save the current conversation to a file."""
@@ -2675,12 +2776,7 @@ class HermesCLI:
elif cmd_lower == "/config":
self.show_config()
elif cmd_lower == "/clear":
# Flush memories before clearing
if self.agent and self.conversation_history:
try:
self.agent.flush_memories(self.conversation_history)
except Exception:
pass
self.new_session(silent=True)
# Clear terminal screen. Inside the TUI, Rich's console.clear()
# goes through patch_stdout's StdoutProxy which swallows the
# screen-clear escape sequences. Use prompt_toolkit's output
@@ -2692,8 +2788,6 @@ class HermesCLI:
out.flush()
else:
self.console.clear()
# Reset conversation
self.conversation_history = []
# Show fresh banner. Inside the TUI we must route Rich output
# through ChatConsole (which uses prompt_toolkit's native ANSI
# renderer) instead of self.console (which writes raw to stdout
@@ -2796,7 +2890,7 @@ class HermesCLI:
else:
_cprint(" Session database not available.")
elif cmd_lower in ("/reset", "/new"):
self.reset_conversation()
self.new_session()
elif cmd_lower.startswith("/model"):
# Use original case so model names like "Anthropic/Claude-Opus-4" are preserved
parts = cmd_original.split(maxsplit=1)
@@ -2940,8 +3034,7 @@ class HermesCLI:
)
output = result.stdout.strip() or result.stderr.strip()
if output:
from rich.text import Text as _RichText
self.console.print(_RichText.from_ansi(output))
self.console.print(_rich_text_from_ansi(output))
else:
self.console.print("[dim]Command returned no output[/]")
except subprocess.TimeoutExpired:
@@ -3035,27 +3128,29 @@ class HermesCLI:
# Display result in the CLI (thread-safe via patch_stdout)
print()
_cprint(f"{_GOLD}{'' * 40}{_RST}")
ChatConsole().print(f"[{_accent_hex()}]{'' * 40}[/]")
_cprint(f" ✅ Background task #{task_num} complete")
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
_cprint(f"{_GOLD}{'' * 40}{_RST}")
ChatConsole().print(f"[{_accent_hex()}]{'' * 40}[/]")
if response:
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _skin.get_color("response_border", "#CD7F32")
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
except Exception:
label = "⚕ Hermes"
_resp_color = "#CD7F32"
_resp_text = "#FFF8DC"
from rich.text import Text as _RichText
_chat_console = ChatConsole()
_chat_console.print(Panel(
_RichText.from_ansi(response),
title=f"[bold]{label} (background #{task_num})[/bold]",
_rich_text_from_ansi(response),
title=f"[{_resp_color} bold]{label} (background #{task_num})[/]",
title_align="left",
border_style=_resp_color,
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 2),
))
@@ -3115,6 +3210,8 @@ class HermesCLI:
else:
print(f" Skin set to: {new_skin}")
print(" Note: banner colors will update on next session start.")
if self._apply_tui_skin_style():
print(" Prompt + TUI colors updated.")
def _toggle_verbose(self):
"""Cycle tool progress mode: off → new → all → verbose → off."""
@@ -3524,54 +3621,59 @@ class HermesCLI:
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
return ""
def _approval_callback(self, command: str, description: str) -> str:
def _approval_callback(self, command: str, description: str,
*, allow_permanent: bool = True) -> str:
"""
Prompt for dangerous command approval through the prompt_toolkit UI.
Called from the agent thread. Shows a selection UI similar to clarify
with choices: once / session / always / deny.
with choices: once / session / always / deny. When allow_permanent
is False (tirith warnings present), the 'always' option is hidden.
Uses _approval_lock to serialize concurrent requests (e.g. from
parallel delegation subtasks) so each prompt gets its own turn
and the shared _approval_state / _approval_deadline aren't clobbered.
"""
import time as _time
timeout = 60
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
with self._approval_lock:
timeout = 60
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"]
self._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
self._approval_deadline = _time.monotonic() + timeout
self._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
self._approval_deadline = _time.monotonic() + timeout
self._invalidate()
self._invalidate()
# Same throttled countdown as _clarify_callback — repaint only
# every 5 s to avoid flicker in Kitty / ghostty / etc.
_last_countdown_refresh = _time.monotonic()
while True:
try:
result = response_queue.get(timeout=1)
self._approval_state = None
self._approval_deadline = 0
self._invalidate()
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 5.0:
_last_countdown_refresh = now
_last_countdown_refresh = _time.monotonic()
while True:
try:
result = response_queue.get(timeout=1)
self._approval_state = None
self._approval_deadline = 0
self._invalidate()
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
if remaining <= 0:
break
now = _time.monotonic()
if now - _last_countdown_refresh >= 5.0:
_last_countdown_refresh = now
self._invalidate()
self._approval_state = None
self._approval_deadline = 0
self._invalidate()
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "deny"
self._approval_state = None
self._approval_deadline = 0
self._invalidate()
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "deny"
def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
return prompt_for_secret(self, var_name, prompt, metadata)
@@ -3643,8 +3745,8 @@ class HermesCLI:
# Add user message to history
self.conversation_history.append({"role": "user", "content": message})
_cprint(f"{_GOLD}{'' * 40}{_RST}")
ChatConsole().print(f"[{_accent_hex()}]{'' * 40}[/]")
print(flush=True)
try:
@@ -3684,8 +3786,7 @@ class HermesCLI:
self.agent.interrupt(interrupt_msg)
# Debug: log to file (stdout may be devnull from redirect_stdout)
try:
import pathlib as _pl
_dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
_dbg = _hermes_home / "interrupt_debug.log"
with open(_dbg, "a") as _f:
import time as _t
_f.write(f"{_t.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, "
@@ -3758,17 +3859,19 @@ class HermesCLI:
_skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _skin.get_color("response_border", "#CD7F32")
_resp_text = _skin.get_color("banner_text", "#FFF8DC")
except Exception:
label = "⚕ Hermes"
_resp_color = "#CD7F32"
_resp_text = "#FFF8DC"
from rich.text import Text as _RichText
_chat_console = ChatConsole()
_chat_console.print(Panel(
_RichText.from_ansi(response),
title=f"[bold]{label}[/bold]",
_rich_text_from_ansi(response),
title=f"[{_resp_color} bold]{label}[/]",
title_align="left",
border_style=_resp_color,
style=_resp_text,
box=rich_box.HORIZONTALS,
padding=(1, 2),
))
@@ -3824,7 +3927,80 @@ class HermesCLI:
print(f"Duration: {duration_str}")
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
else:
print("Goodbye! ⚕")
try:
from hermes_cli.skin_engine import get_active_goodbye
goodbye = get_active_goodbye("Goodbye! ⚕")
except Exception:
goodbye = "Goodbye! ⚕"
print(goodbye)
def _get_tui_prompt_symbols(self) -> tuple[str, str]:
"""Return ``(normal_prompt, state_suffix)`` for the active skin.
``normal_prompt`` is the full ``branding.prompt_symbol``.
``state_suffix`` is what special states (sudo/secret/approval/agent)
should render after their leading icon.
"""
try:
from hermes_cli.skin_engine import get_active_prompt_symbol
symbol = get_active_prompt_symbol(" ")
except Exception:
symbol = " "
symbol = (symbol or " ").rstrip() + " "
stripped = symbol.rstrip()
if not stripped:
return " ", " "
parts = stripped.split()
candidate = parts[-1] if parts else ""
arrow_chars = ("", ">", "$", "#", "", "»", "")
if any(ch in candidate for ch in arrow_chars):
return symbol, candidate.rstrip() + " "
# Icon-only custom prompts should still remain visible in special states.
return symbol, symbol
def _get_tui_prompt_fragments(self):
"""Return the prompt_toolkit fragments for the current interactive state."""
symbol, state_suffix = self._get_tui_prompt_symbols()
if self._sudo_state:
return [("class:sudo-prompt", f"🔐 {state_suffix}")]
if self._secret_state:
return [("class:sudo-prompt", f"🔑 {state_suffix}")]
if self._approval_state:
return [("class:prompt-working", f"{state_suffix}")]
if self._clarify_freetext:
return [("class:clarify-selected", f"{state_suffix}")]
if self._clarify_state:
return [("class:prompt-working", f"? {state_suffix}")]
if self._command_running:
return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")]
if self._agent_running:
return [("class:prompt-working", f"{state_suffix}")]
return [("class:prompt", symbol)]
def _get_tui_prompt_text(self) -> str:
"""Return the visible prompt text for width calculations."""
return "".join(text for _, text in self._get_tui_prompt_fragments())
def _build_tui_style_dict(self) -> dict[str, str]:
"""Layer the active skin's prompt_toolkit colors over the base TUI style."""
style_dict = dict(getattr(self, "_tui_style_base", {}) or {})
try:
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
style_dict.update(get_prompt_toolkit_style_overrides())
except Exception:
pass
return style_dict
def _apply_tui_skin_style(self) -> bool:
"""Refresh prompt_toolkit styling for a running interactive TUI."""
if not getattr(self, "_app", None) or not getattr(self, "_tui_style_base", None):
return False
self._app.style = PTStyle.from_dict(self._build_tui_style_dict())
self._invalidate(min_interval=0.0)
return True
def run(self):
"""Run the interactive CLI loop with persistent input at bottom."""
@@ -3880,6 +4056,7 @@ class HermesCLI:
# Dangerous command approval state (similar mechanism to clarify)
self._approval_state = None # dict with command, description, choices, selected, response_queue
self._approval_deadline = 0
self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix)
# Slash command loading state
self._command_running = False
@@ -3897,6 +4074,13 @@ class HermesCLI:
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
set_secret_capture_callback(self._secret_capture_callback)
# Ensure tirith security scanner is available (downloads if needed)
try:
from tools.tirith_security import ensure_installed
ensure_installed()
except Exception:
pass # Non-fatal — fail-open at scan time if unavailable
# Key bindings for the input area
kb = KeyBindings()
@@ -3993,8 +4177,7 @@ class HermesCLI:
self._interrupt_queue.put(payload)
# Debug: log to file when message enters interrupt queue
try:
import pathlib as _pl
_dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
_dbg = _hermes_home / "interrupt_debug.log"
with open(_dbg, "a") as _f:
import time as _t
_f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
@@ -4189,21 +4372,7 @@ class HermesCLI:
cli_ref = self
def get_prompt():
if cli_ref._sudo_state:
return [('class:sudo-prompt', '🔐 ')]
if cli_ref._secret_state:
return [('class:sudo-prompt', '🔑 ')]
if cli_ref._approval_state:
return [('class:prompt-working', ' ')]
if cli_ref._clarify_freetext:
return [('class:clarify-selected', ' ')]
if cli_ref._clarify_state:
return [('class:prompt-working', '? ')]
if cli_ref._command_running:
return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} ")]
if cli_ref._agent_running:
return [('class:prompt-working', ' ')]
return [('class:prompt', ' ')]
return cli_ref._get_tui_prompt_fragments()
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
input_area = TextArea(
@@ -4220,11 +4389,11 @@ class HermesCLI:
# Dynamic height: accounts for both explicit newlines AND visual
# wrapping of long lines so the input area always fits its content.
# The prompt characters (" " etc.) consume ~4 columns.
def _input_height():
try:
doc = input_area.buffer.document
available_width = shutil.get_terminal_size().columns - 4 # subtract prompt width
prompt_width = max(2, len(self._get_tui_prompt_text()))
available_width = shutil.get_terminal_size().columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
@@ -4255,7 +4424,7 @@ class HermesCLI:
if line_count >= 5 and chars_added > 1 and not text.startswith('/'):
_paste_counter[0] += 1
# Save to temp file
paste_dir = Path(os.path.expanduser("~/.hermes/pastes"))
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(text, encoding="utf-8")
@@ -4665,7 +4834,7 @@ class HermesCLI:
)
# Style for the application
style = PTStyle.from_dict({
self._tui_style_base = {
'input-area': '#FFF8DC',
'placeholder': '#555555 italic',
'prompt': '#FFF8DC',
@@ -4700,7 +4869,8 @@ class HermesCLI:
'approval-cmd': '#AAAAAA italic',
'approval-choice': '#AAAAAA',
'approval-selected': '#FFD700 bold',
})
}
style = PTStyle.from_dict(self._build_tui_style_dict())
# Create the application
app = Application(
@@ -4763,20 +4933,25 @@ class HermesCLI:
full_text = paste_path.read_text(encoding="utf-8")
line_count = full_text.count('\n') + 1
print()
_cprint(f"{_GOLD}{_RST} {_BOLD}[Pasted text: {line_count} lines]{_RST}")
ChatConsole().print(
f"[bold {_accent_hex()}]●[/] [bold]{_escape(f'[Pasted text: {line_count} lines]')}[/]"
)
user_input = full_text
else:
print()
_cprint(f"{_GOLD}{_RST} {_BOLD}{user_input}{_RST}")
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
else:
if '\n' in user_input:
first_line = user_input.split('\n')[0]
line_count = user_input.count('\n') + 1
print()
_cprint(f"{_GOLD}{_RST} {_BOLD}{first_line}{_RST} {_DIM}(+{line_count - 1} lines){_RST}")
ChatConsole().print(
f"[bold {_accent_hex()}]●[/] [bold]{_escape(first_line)}[/] "
f"[dim](+{line_count - 1} lines)[/]"
)
else:
print()
_cprint(f"{_GOLD}{_RST} {_BOLD}{user_input}{_RST}")
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
# Show image attachment count
if submit_images:

View File

@@ -431,8 +431,19 @@ def save_job_output(job_id: str, output: str):
timestamp = _hermes_now().strftime("%Y-%m-%d_%H-%M-%S")
output_file = job_output_dir / f"{timestamp}.md"
with open(output_file, 'w', encoding='utf-8') as f:
f.write(output)
_secure_file(output_file)
fd, tmp_path = tempfile.mkstemp(dir=str(job_output_dir), suffix='.tmp', prefix='.output_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(output)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, output_file)
_secure_file(output_file)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
return output_file

View File

@@ -156,6 +156,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
"""
from run_agent import AIAgent
# Initialize SQLite session store so cron job messages are persisted
# and discoverable via session_search (same pattern as gateway/run.py).
_session_db = None
try:
from hermes_state import SessionDB
_session_db = SessionDB()
except Exception as e:
logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e)
job_id = job["id"]
job_name = job["name"]
prompt = job["prompt"]
@@ -260,7 +269,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
providers_order=pr.get("order"),
provider_sort=pr.get("sort"),
quiet_mode=True,
session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
platform="cron",
session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}",
session_db=_session_db,
)
result = agent.run_conversation(prompt)
@@ -315,6 +326,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# Clean up injected env vars so they don't leak to other jobs
for key in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME"):
os.environ.pop(key, None)
if _session_db:
try:
_session_db.close()
except Exception as e:
logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e)
def tick(verbose: bool = True) -> int:

229
docs/acp-setup.md Normal file
View File

@@ -0,0 +1,229 @@
# Hermes Agent — ACP (Agent Client Protocol) Setup Guide
Hermes Agent supports the **Agent Client Protocol (ACP)**, allowing it to run as
a coding agent inside your editor. ACP lets your IDE send tasks to Hermes, and
Hermes responds with file edits, terminal commands, and explanations — all shown
natively in the editor UI.
---
## Prerequisites
- Hermes Agent installed and configured (`hermes setup` completed)
- An API key / provider set up in `~/.hermes/.env` or via `hermes login`
- Python 3.11+
Install the ACP extra:
```bash
pip install -e ".[acp]"
```
---
## VS Code Setup
### 1. Install the ACP Client extension
Open VS Code and install **ACP Client** from the marketplace:
- Press `Ctrl+Shift+X` (or `Cmd+Shift+X` on macOS)
- Search for **"ACP Client"**
- Click **Install**
Or install from the command line:
```bash
code --install-extension anysphere.acp-client
```
### 2. Configure settings.json
Open your VS Code settings (`Ctrl+,` → click the `{}` icon for JSON) and add:
```json
{
"acpClient.agents": [
{
"name": "hermes-agent",
"registryDir": "/path/to/hermes-agent/acp_registry"
}
]
}
```
Replace `/path/to/hermes-agent` with the actual path to your Hermes Agent
installation (e.g. `~/.hermes/hermes-agent`).
Alternatively, if `hermes` is on your PATH, the ACP Client can discover it
automatically via the registry directory.
### 3. Restart VS Code
After configuring, restart VS Code. You should see **Hermes Agent** appear in
the ACP agent picker in the chat/agent panel.
---
## Zed Setup
Zed has built-in ACP support.
### 1. Configure Zed settings
Open Zed settings (`Cmd+,` on macOS or `Ctrl+,` on Linux) and add to your
`settings.json`:
```json
{
"acp": {
"agents": [
{
"name": "hermes-agent",
"registry_dir": "/path/to/hermes-agent/acp_registry"
}
]
}
}
```
### 2. Restart Zed
Hermes Agent will appear in the agent panel. Select it and start a conversation.
---
## JetBrains Setup (IntelliJ, PyCharm, WebStorm, etc.)
### 1. Install the ACP plugin
- Open **Settings****Plugins****Marketplace**
- Search for **"ACP"** or **"Agent Client Protocol"**
- Install and restart the IDE
### 2. Configure the agent
- Open **Settings****Tools****ACP Agents**
- Click **+** to add a new agent
- Set the registry directory to your `acp_registry/` folder:
`/path/to/hermes-agent/acp_registry`
- Click **OK**
### 3. Use the agent
Open the ACP panel (usually in the right sidebar) and select **Hermes Agent**.
---
## What You Will See
Once connected, your editor provides a native interface to Hermes Agent:
### Chat Panel
A conversational interface where you can describe tasks, ask questions, and
give instructions. Hermes responds with explanations and actions.
### File Diffs
When Hermes edits files, you see standard diffs in the editor. You can:
- **Accept** individual changes
- **Reject** changes you don't want
- **Review** the full diff before applying
### Terminal Commands
When Hermes needs to run shell commands (builds, tests, installs), the editor
shows them in an integrated terminal. Depending on your settings:
- Commands may run automatically
- Or you may be prompted to **approve** each command
### Approval Flow
For potentially destructive operations, the editor will prompt you for
approval before Hermes proceeds. This includes:
- File deletions
- Shell commands
- Git operations
---
## Configuration
Hermes Agent under ACP uses the **same configuration** as the CLI:
- **API keys / providers**: `~/.hermes/.env`
- **Agent config**: `~/.hermes/config.yaml`
- **Skills**: `~/.hermes/skills/`
- **Sessions**: `~/.hermes/state.db`
You can run `hermes setup` to configure providers, or edit `~/.hermes/.env`
directly.
### Changing the model
Edit `~/.hermes/config.yaml`:
```yaml
model: openrouter/nous/hermes-3-llama-3.1-70b
```
Or set the `HERMES_MODEL` environment variable.
### Toolsets
ACP sessions use the curated `hermes-acp` toolset by default. It is designed for editor workflows and intentionally excludes things like messaging delivery, cronjob management, and audio-first UX features.
---
## Troubleshooting
### Agent doesn't appear in the editor
1. **Check the registry path** — make sure the `acp_registry/` directory path
in your editor settings is correct and contains `agent.json`.
2. **Check `hermes` is on PATH** — run `which hermes` in a terminal. If not
found, you may need to activate your virtualenv or add it to PATH.
3. **Restart the editor** after changing settings.
### Agent starts but errors immediately
1. Run `hermes doctor` to check your configuration.
2. Check that you have a valid API key: `hermes status`
3. Try running `hermes acp` directly in a terminal to see error output.
### "Module not found" errors
Make sure you installed the ACP extra:
```bash
pip install -e ".[acp]"
```
### Slow responses
- ACP streams responses, so you should see incremental output. If the agent
appears stuck, check your network connection and API provider status.
- Some providers have rate limits. Try switching to a different model/provider.
### Permission denied for terminal commands
If the editor blocks terminal commands, check your ACP Client extension
settings for auto-approval or manual-approval preferences.
### Logs
Hermes logs are written to stderr when running in ACP mode. Check:
- VS Code: **Output** panel → select **ACP Client** or **Hermes Agent**
- Zed: **View****Toggle Terminal** and check the process output
- JetBrains: **Event Log** or the ACP tool window
You can also enable verbose logging:
```bash
HERMES_LOG_LEVEL=DEBUG hermes acp
```
---
## Further Reading
- [ACP Specification](https://github.com/anysphere/acp)
- [Hermes Agent Documentation](https://github.com/NousResearch/hermes-agent)
- Run `hermes --help` for all CLI options

View File

@@ -38,7 +38,7 @@ class DeepSeekV3ToolCallParser(ToolCallParser):
# Regex captures: type, function_name, function_arguments
PATTERN = re.compile(
r"<tool▁call▁begin>(?P<type>.*)<tool▁sep>(?P<function_name>.*)\n```json\n(?P<function_arguments>.*)\n```<tool▁call▁end>",
r"<tool▁call▁begin>(?P<type>.*?)<tool▁sep>(?P<function_name>.*?)\n```json\n(?P<function_arguments>.*?)\n```<tool▁call▁end>",
re.DOTALL,
)

View File

@@ -12,9 +12,11 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
DIRECTORY_PATH = Path.home() / ".hermes" / "channel_directory.json"
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
@@ -129,7 +131,7 @@ def _build_slack(adapter) -> List[Dict[str, str]]:
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
"""Pull known channels/contacts from sessions.json origin data."""
sessions_path = Path.home() / ".hermes" / "sessions" / "sessions.json"
sessions_path = get_hermes_home() / "sessions" / "sessions.json"
if not sessions_path.exists():
return []

View File

@@ -16,6 +16,8 @@ from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from enum import Enum
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
@@ -83,10 +85,13 @@ class SessionResetPolicy:
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
# Handle both missing keys and explicit null values (YAML null → None)
at_hour = data.get("at_hour")
idle_minutes = data.get("idle_minutes")
return cls(
mode=data.get("mode", "both"),
at_hour=data.get("at_hour", 4),
idle_minutes=data.get("idle_minutes", 1440),
at_hour=at_hour if at_hour is not None else 4,
idle_minutes=idle_minutes if idle_minutes is not None else 1440,
)
@@ -146,9 +151,12 @@ class GatewayConfig:
# Reset trigger commands
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
# User-defined quick commands (slash commands that bypass the agent loop)
quick_commands: Dict[str, Any] = field(default_factory=dict)
# Storage paths
sessions_dir: Path = field(default_factory=lambda: Path.home() / ".hermes" / "sessions")
sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
# Delivery settings
always_log_local: bool = True # Always save cron outputs to local files
@@ -213,6 +221,7 @@ class GatewayConfig:
p.value: v.to_dict() for p, v in self.reset_by_platform.items()
},
"reset_triggers": self.reset_triggers,
"quick_commands": self.quick_commands,
"sessions_dir": str(self.sessions_dir),
"always_log_local": self.always_log_local,
}
@@ -243,16 +252,21 @@ class GatewayConfig:
if "default_reset_policy" in data:
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
sessions_dir = Path.home() / ".hermes" / "sessions"
sessions_dir = get_hermes_home() / "sessions"
if "sessions_dir" in data:
sessions_dir = Path(data["sessions_dir"])
quick_commands = data.get("quick_commands", {})
if not isinstance(quick_commands, dict):
quick_commands = {}
return cls(
platforms=platforms,
default_reset_policy=default_policy,
reset_by_type=reset_by_type,
reset_by_platform=reset_by_platform,
reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
quick_commands=quick_commands,
sessions_dir=sessions_dir,
always_log_local=data.get("always_log_local", True),
)
@@ -271,7 +285,8 @@ def load_gateway_config() -> GatewayConfig:
config = GatewayConfig()
# Try loading from ~/.hermes/gateway.json
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
_home = get_hermes_home()
gateway_config_path = _home / "gateway.json"
if gateway_config_path.exists():
try:
with open(gateway_config_path, "r", encoding="utf-8") as f:
@@ -279,13 +294,13 @@ def load_gateway_config() -> GatewayConfig:
config = GatewayConfig.from_dict(data)
except Exception as e:
print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
# Bridge session_reset from config.yaml (the user-facing config file)
# into the gateway config. config.yaml takes precedence over gateway.json
# for session reset policy since that's where hermes setup writes it.
try:
import yaml
config_yaml_path = Path.home() / ".hermes" / "config.yaml"
config_yaml_path = _home / "config.yaml"
if config_yaml_path.exists():
with open(config_yaml_path, encoding="utf-8") as f:
yaml_cfg = yaml.safe_load(f) or {}
@@ -293,6 +308,16 @@ def load_gateway_config() -> GatewayConfig:
if sr and isinstance(sr, dict):
config.default_reset_policy = SessionResetPolicy.from_dict(sr)
# Bridge quick commands from config.yaml into gateway runtime config.
# config.yaml is the user-facing config source, so when present it
# should override gateway.json for this setting.
qc = yaml_cfg.get("quick_commands")
if qc is not None:
if isinstance(qc, dict):
config.quick_commands = qc
else:
logger.warning("Ignoring invalid quick_commands in config.yaml (expected mapping, got %s)", type(qc).__name__)
# Bridge discord settings from config.yaml to env vars
# (env vars take precedence — only set if not already defined)
discord_cfg = yaml_cfg.get("discord", {})
@@ -304,6 +329,8 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
except Exception:
pass
@@ -476,7 +503,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
def save_gateway_config(config: GatewayConfig) -> None:
"""Save gateway configuration to ~/.hermes/gateway.json."""
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
gateway_config_path = get_hermes_home() / "gateway.json"
gateway_config_path.parent.mkdir(parents=True, exist_ok=True)
with open(gateway_config_path, "w", encoding="utf-8") as f:

View File

@@ -15,6 +15,8 @@ from dataclasses import dataclass
from typing import Dict, List, Optional, Any, Union
from enum import Enum
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
MAX_PLATFORM_OUTPUT = 4000
@@ -116,7 +118,7 @@ class DeliveryRouter:
"""
self.config = config
self.adapters = adapters or {}
self.output_dir = Path.home() / ".hermes" / "cron" / "output"
self.output_dir = get_hermes_home() / "cron" / "output"
def resolve_targets(
self,
@@ -256,7 +258,7 @@ class DeliveryRouter:
def _save_full_output(self, content: str, job_id: str) -> Path:
"""Save full cron output to disk and return the file path."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = Path.home() / ".hermes" / "cron" / "output"
out_dir = get_hermes_home() / "cron" / "output"
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{job_id}_{timestamp}.txt"
path.write_text(content)

View File

@@ -26,8 +26,10 @@ from typing import Any, Callable, Dict, List, Optional
import yaml
from hermes_cli.config import get_hermes_home
HOOKS_DIR = Path(os.path.expanduser("~/.hermes/hooks"))
HOOKS_DIR = get_hermes_home() / "hooks"
class HookRegistry:

View File

@@ -15,9 +15,11 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
_SESSIONS_DIR = Path.home() / ".hermes" / "sessions"
_SESSIONS_DIR = get_hermes_home() / "sessions"
_SESSIONS_INDEX = _SESSIONS_DIR / "sessions.json"

View File

@@ -25,6 +25,8 @@ import time
from pathlib import Path
from typing import Optional
from hermes_cli.config import get_hermes_home
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
@@ -39,7 +41,7 @@ LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
PAIRING_DIR = Path(os.path.expanduser("~/.hermes/pairing"))
PAIRING_DIR = get_hermes_home() / "pairing"
def _secure_write(path: Path, data: str) -> None:

View File

@@ -25,6 +25,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
from gateway.session import SessionSource, build_session_key
from hermes_cli.config import get_hermes_home
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
@@ -42,8 +43,8 @@ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
# (e.g. Telegram file URLs expire after ~1 hour).
# ---------------------------------------------------------------------------
# Default location: ~/.hermes/image_cache/
IMAGE_CACHE_DIR = Path(os.path.expanduser("~/.hermes/image_cache"))
# Default location: {HERMES_HOME}/image_cache/
IMAGE_CACHE_DIR = get_hermes_home() / "image_cache"
def get_image_cache_dir() -> Path:
@@ -125,7 +126,7 @@ def cleanup_image_cache(max_age_hours: int = 24) -> int:
# here so the STT tool (OpenAI Whisper) can transcribe them from local files.
# ---------------------------------------------------------------------------
AUDIO_CACHE_DIR = Path(os.path.expanduser("~/.hermes/audio_cache"))
AUDIO_CACHE_DIR = get_hermes_home() / "audio_cache"
def get_audio_cache_dir() -> Path:
@@ -184,7 +185,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg") -> str:
# here so the agent can reference them by local file path.
# ---------------------------------------------------------------------------
DOCUMENT_CACHE_DIR = Path(os.path.expanduser("~/.hermes/document_cache"))
DOCUMENT_CACHE_DIR = get_hermes_home() / "document_cache"
SUPPORTED_DOCUMENT_TYPES = {
".pdf": "application/pdf",
@@ -617,16 +618,22 @@ class BasePlatformAdapter(ABC):
has_voice_tag = "[[audio_as_voice]]" in content
cleaned = cleaned.replace("[[audio_as_voice]]", "")
# Extract MEDIA:<path> tags (path may contain spaces)
media_pattern = r'MEDIA:(\S+)'
for match in re.finditer(media_pattern, content):
path = match.group(1).strip()
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
# and quoted/backticked paths for LLM-formatted outputs.
media_pattern = re.compile(
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?'''
)
for match in media_pattern.finditer(content):
path = match.group("path").strip()
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
path = path[1:-1].strip()
path = path.lstrip("`\"'").rstrip("`\"',.;:)}]")
if path:
media.append((path, has_voice_tag))
# Remove MEDIA tags from content
# Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
if media:
cleaned = re.sub(media_pattern, '', cleaned)
cleaned = media_pattern.sub('', cleaned)
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
return media, cleaned

View File

@@ -14,6 +14,8 @@ from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
try:
import discord
from discord import Message as DiscordMessage, Intents
@@ -41,6 +43,23 @@ from gateway.platforms.base import (
)
def _clean_discord_id(entry: str) -> str:
"""Strip common prefixes from a Discord user ID or username entry.
Users sometimes paste IDs with prefixes like ``user:123``, ``<@123>``,
or ``<@!123>`` from Discord's UI or other tools. This normalises the
entry to just the bare ID or username.
"""
entry = entry.strip()
# Strip Discord mention syntax: <@123> or <@!123>
if entry.startswith("<@") and entry.endswith(">"):
entry = entry.lstrip("<@!").rstrip(">")
# Strip "user:" prefix (seen in some Discord tools / onboarding pastes)
if entry.lower().startswith("user:"):
entry = entry[5:]
return entry.strip()
def check_discord_requirements() -> bool:
"""Check if Discord dependencies are available."""
return DISCORD_AVAILABLE
@@ -97,7 +116,8 @@ class DiscordAdapter(BasePlatformAdapter):
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
if allowed_env:
self._allowed_user_ids = {
uid.strip() for uid in allowed_env.split(",") if uid.strip()
_clean_discord_id(uid) for uid in allowed_env.split(",")
if uid.strip()
}
adapter_self = self # capture for closure
@@ -245,80 +265,61 @@ class DiscordAdapter(BasePlatformAdapter):
logger.error("[%s] Failed to edit Discord message %s: %s", self.name, message_id, e, exc_info=True)
return SendResult(success=False, error=str(e))
async def _send_file_attachment(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
) -> SendResult:
"""Send a local file as a Discord attachment."""
if not self._client:
return SendResult(success=False, error="Not connected")
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
filename = os.path.basename(file_path)
with open(file_path, "rb") as fh:
file = discord.File(fh, filename=filename)
msg = await channel.send(content=caption if caption else None, file=file)
return SendResult(success=True, message_id=str(msg.id))
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send audio as a Discord file attachment."""
if not self._client:
return SendResult(success=False, error="Not connected")
try:
import io
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
if not os.path.exists(audio_path):
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
# Determine filename from path
filename = os.path.basename(audio_path)
with open(audio_path, "rb") as f:
file = discord.File(io.BytesIO(f.read()), filename=filename)
msg = await channel.send(
content=caption if caption else None,
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
return await self._send_file_attachment(chat_id, audio_path, caption)
except FileNotFoundError:
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True)
return await super().send_voice(chat_id, audio_path, caption, reply_to)
return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a local image file natively as a Discord file attachment."""
if not self._client:
return SendResult(success=False, error="Not connected")
try:
import io
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")
if not os.path.exists(image_path):
return SendResult(success=False, error=f"Image file not found: {image_path}")
filename = os.path.basename(image_path)
with open(image_path, "rb") as f:
file = discord.File(io.BytesIO(f.read()), filename=filename)
msg = await channel.send(
content=caption if caption else None,
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
return await self._send_file_attachment(chat_id, image_path, caption)
except FileNotFoundError:
return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to send local image, falling back to base adapter: %s", self.name, e, exc_info=True)
return await super().send_image_file(chat_id, image_path, caption, reply_to)
return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
async def send_image(
self,
@@ -326,6 +327,7 @@ class DiscordAdapter(BasePlatformAdapter):
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an image natively as a Discord file attachment."""
if not self._client:
@@ -505,7 +507,22 @@ class DiscordAdapter(BasePlatformAdapter):
"""
# Discord markdown is fairly standard, no special escaping needed
return content
async def _run_simple_slash(
self,
interaction: discord.Interaction,
command_text: str,
followup_msg: str = "Done~",
) -> None:
"""Common handler for simple slash commands that dispatch a command string."""
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, command_text)
await self.handle_message(event)
try:
await interaction.followup.send(followup_msg, ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
def _register_slash_commands(self) -> None:
"""Register Discord slash commands on the command tree."""
if not self._client:
@@ -528,29 +545,22 @@ class DiscordAdapter(BasePlatformAdapter):
@tree.command(name="new", description="Start a new conversation")
async def slash_new(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/reset")
await self.handle_message(event)
try:
await interaction.followup.send("New conversation started~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/reset", "New conversation started~")
@tree.command(name="reset", description="Reset your Hermes session")
async def slash_reset(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/reset")
await self.handle_message(event)
try:
await interaction.followup.send("Session reset~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/reset", "Session reset~")
@tree.command(name="model", description="Show or change the model")
@discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.")
async def slash_model(interaction: discord.Interaction, name: str = ""):
await self._run_simple_slash(interaction, f"/model {name}".strip())
@tree.command(name="reasoning", description="Show or change reasoning effort")
@discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
async def slash_reasoning(interaction: discord.Interaction, effort: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/model {name}".strip())
event = self._build_slash_event(interaction, f"/reasoning {effort}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
@@ -560,156 +570,81 @@ class DiscordAdapter(BasePlatformAdapter):
@tree.command(name="personality", description="Set a personality")
@discord.app_commands.describe(name="Personality name. Leave empty to list available.")
async def slash_personality(interaction: discord.Interaction, name: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/personality {name}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/personality {name}".strip())
@tree.command(name="retry", description="Retry your last message")
async def slash_retry(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/retry")
await self.handle_message(event)
try:
await interaction.followup.send("Retrying~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/retry", "Retrying~")
@tree.command(name="undo", description="Remove the last exchange")
async def slash_undo(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/undo")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/undo")
@tree.command(name="status", description="Show Hermes session status")
async def slash_status(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/status")
await self.handle_message(event)
try:
await interaction.followup.send("Status sent~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/status", "Status sent~")
@tree.command(name="sethome", description="Set this chat as the home channel")
async def slash_sethome(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/sethome")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/sethome")
@tree.command(name="stop", description="Stop the running Hermes agent")
async def slash_stop(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/stop")
await self.handle_message(event)
try:
await interaction.followup.send("Stop requested~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/stop", "Stop requested~")
@tree.command(name="compress", description="Compress conversation context")
async def slash_compress(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/compress")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/compress")
@tree.command(name="title", description="Set or show the session title")
@discord.app_commands.describe(name="Session title. Leave empty to show current.")
async def slash_title(interaction: discord.Interaction, name: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/title {name}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/title {name}".strip())
@tree.command(name="resume", description="Resume a previously-named session")
@discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.")
async def slash_resume(interaction: discord.Interaction, name: str = ""):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/resume {name}".strip())
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/resume {name}".strip())
@tree.command(name="usage", description="Show token usage for this session")
async def slash_usage(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/usage")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/usage")
@tree.command(name="provider", description="Show available providers")
async def slash_provider(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/provider")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/provider")
@tree.command(name="help", description="Show available commands")
async def slash_help(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/help")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/help")
@tree.command(name="insights", description="Show usage insights and analytics")
@discord.app_commands.describe(days="Number of days to analyze (default: 7)")
async def slash_insights(interaction: discord.Interaction, days: int = 7):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, f"/insights {days}")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, f"/insights {days}")
@tree.command(name="reload-mcp", description="Reload MCP servers from config")
async def slash_reload_mcp(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/reload-mcp")
await self.handle_message(event)
try:
await interaction.followup.send("Done~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._run_simple_slash(interaction, "/reload-mcp")
@tree.command(name="update", description="Update Hermes Agent to the latest version")
async def slash_update(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/update", "Update initiated~")
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
@discord.app_commands.describe(
name="Thread name",
message="Optional first message to send to Hermes in the thread",
auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)",
)
async def slash_thread(
interaction: discord.Interaction,
name: str,
message: str = "",
auto_archive_duration: int = 1440,
):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/update")
await self.handle_message(event)
try:
await interaction.followup.send("Update initiated~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration)
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
"""Build a MessageEvent from a Discord slash command interaction."""
@@ -741,6 +676,188 @@ class DiscordAdapter(BasePlatformAdapter):
raw_message=interaction,
)
# ------------------------------------------------------------------
# Thread creation helpers
# ------------------------------------------------------------------
async def _handle_thread_create_slash(
self,
interaction: discord.Interaction,
name: str,
message: str = "",
auto_archive_duration: int = 1440,
) -> None:
"""Create a Discord thread from a slash command and start a session in it."""
result = await self._create_thread(
interaction,
name=name,
message=message,
auto_archive_duration=auto_archive_duration,
)
if not result.get("success"):
error = result.get("error", "unknown error")
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
return
thread_id = result.get("thread_id")
thread_name = result.get("thread_name") or name
# Tell the user where the thread is
link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
# If a message was provided, kick off a new Hermes session in the thread
starter = (message or "").strip()
if starter and thread_id:
await self._dispatch_thread_session(interaction, thread_id, thread_name, starter)
async def _dispatch_thread_session(
self,
interaction: discord.Interaction,
thread_id: str,
thread_name: str,
text: str,
) -> None:
"""Build a MessageEvent pointing at a thread and send it through handle_message."""
guild_name = ""
if hasattr(interaction, "guild") and interaction.guild:
guild_name = interaction.guild.name
chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
source = self.build_source(
chat_id=thread_id,
chat_name=chat_name,
chat_type="thread",
user_id=str(interaction.user.id),
user_name=interaction.user.display_name,
thread_id=thread_id,
)
event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
raw_message=interaction,
)
await self.handle_message(event)
def _thread_parent_channel(self, channel: Any) -> Any:
"""Return the parent text channel when invoked from a thread."""
return getattr(channel, "parent", None) or channel
async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]:
"""Return the interaction channel, fetching it if the payload is partial."""
channel = getattr(interaction, "channel", None)
if channel is not None:
return channel
if not self._client:
return None
channel_id = getattr(interaction, "channel_id", None)
if channel_id is None:
return None
channel = self._client.get_channel(int(channel_id))
if channel is not None:
return channel
try:
return await self._client.fetch_channel(int(channel_id))
except Exception:
return None
async def _create_thread(
self,
interaction: discord.Interaction,
*,
name: str,
message: str = "",
auto_archive_duration: int = 1440,
) -> Dict[str, Any]:
"""Create a thread in the current Discord channel.
Tries ``parent_channel.create_thread()`` first. If Discord rejects
that (e.g. permission issues), falls back to sending a seed message
and creating the thread from it.
"""
name = (name or "").strip()
if not name:
return {"error": "Thread name is required."}
if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES:
allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES))
return {"error": f"auto_archive_duration must be one of: {allowed}."}
channel = await self._resolve_interaction_channel(interaction)
if channel is None:
return {"error": "Could not resolve the current Discord channel."}
if isinstance(channel, discord.DMChannel):
return {"error": "Discord threads can only be created inside server text channels, not DMs."}
parent_channel = self._thread_parent_channel(channel)
if parent_channel is None:
return {"error": "Could not determine a parent text channel for the new thread."}
display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user"
reason = f"Requested by {display_name} via /thread"
starter_message = (message or "").strip()
try:
thread = await parent_channel.create_thread(
name=name,
auto_archive_duration=auto_archive_duration,
reason=reason,
)
if starter_message:
await thread.send(starter_message)
return {
"success": True,
"thread_id": str(thread.id),
"thread_name": getattr(thread, "name", None) or name,
}
except Exception as direct_error:
try:
seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**"
seed_msg = await parent_channel.send(seed_content)
thread = await seed_msg.create_thread(
name=name,
auto_archive_duration=auto_archive_duration,
reason=reason,
)
return {
"success": True,
"thread_id": str(thread.id),
"thread_name": getattr(thread, "name", None) or name,
}
except Exception as fallback_error:
return {
"error": (
"Discord rejected direct thread creation and the fallback also failed. "
f"Direct error: {direct_error}. Fallback error: {fallback_error}"
)
}
# ------------------------------------------------------------------
# Auto-thread helpers
# ------------------------------------------------------------------
async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
"""Create a thread from a user message for auto-threading.
Returns the created thread object, or ``None`` on failure.
"""
# Build a short thread name from the message
content = (message.content or "").strip()
thread_name = content[:80] if content else "Hermes"
if len(content) > 80:
thread_name = thread_name[:77] + "..."
try:
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
return thread
except Exception as e:
logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
return None
async def send_exec_approval(
self, chat_id: str, command: str, approval_id: str
) -> SendResult:
@@ -852,6 +969,19 @@ class DiscordAdapter(BasePlatformAdapter):
message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
# Auto-thread: when enabled, automatically create a thread for every
# new message in a text channel so each conversation is isolated.
# Messages already inside threads or DMs are unaffected.
auto_threaded_channel = None
if not is_thread and not isinstance(message.channel, discord.DMChannel):
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "").lower() in ("true", "1", "yes")
if auto_thread:
thread = await self._auto_create_thread(message)
if thread:
is_thread = True
thread_id = str(thread.id)
auto_threaded_channel = thread
# Determine message type
msg_type = MessageType.TEXT
if message.content.startswith("/"):
@@ -870,13 +1000,16 @@ class DiscordAdapter(BasePlatformAdapter):
msg_type = MessageType.DOCUMENT
break
# When auto-threading kicked in, route responses to the new thread
effective_channel = auto_threaded_channel or message.channel
# Determine chat type
if isinstance(message.channel, discord.DMChannel):
chat_type = "dm"
chat_name = message.author.name
elif is_thread:
chat_type = "thread"
chat_name = self._format_thread_chat_name(message.channel)
chat_name = self._format_thread_chat_name(effective_channel)
else:
chat_type = "group"
chat_name = getattr(message.channel, "name", str(message.channel.id))
@@ -888,7 +1021,7 @@ class DiscordAdapter(BasePlatformAdapter):
# Build source
source = self.build_source(
chat_id=str(message.channel.id),
chat_id=str(effective_channel.id),
chat_name=chat_name,
chat_type=chat_type,
user_id=str(message.author.id),

View File

@@ -22,6 +22,7 @@ import logging
import os
import re
import smtplib
import ssl
import uuid
from datetime import datetime
from email.header import decode_header
@@ -212,7 +213,7 @@ class EmailAdapter(BasePlatformAdapter):
imap.login(self._address, self._password)
# Mark all existing messages as seen so we only process new ones
imap.select("INBOX")
status, data = imap.search(None, "ALL")
status, data = imap.uid("search", None, "ALL")
if status == "OK" and data[0]:
for uid in data[0].split():
self._seen_uids.add(uid)
@@ -225,7 +226,7 @@ class EmailAdapter(BasePlatformAdapter):
try:
# Test SMTP connection
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)
smtp.starttls()
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.quit()
logger.info("[Email] SMTP connection test passed.")
@@ -277,7 +278,7 @@ class EmailAdapter(BasePlatformAdapter):
imap.login(self._address, self._password)
imap.select("INBOX")
status, data = imap.search(None, "UNSEEN")
status, data = imap.uid("search", None, "UNSEEN")
if status != "OK" or not data[0]:
imap.logout()
return results
@@ -287,7 +288,7 @@ class EmailAdapter(BasePlatformAdapter):
continue
self._seen_uids.add(uid)
status, msg_data = imap.fetch(uid, "(RFC822)")
status, msg_data = imap.uid("fetch", uid, "(RFC822)")
if status != "OK":
continue
@@ -427,7 +428,7 @@ class EmailAdapter(BasePlatformAdapter):
msg.attach(MIMEText(body, "plain", "utf-8"))
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)
smtp.starttls()
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
smtp.quit()
@@ -515,7 +516,7 @@ class EmailAdapter(BasePlatformAdapter):
msg.attach(part)
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)
smtp.starttls()
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
smtp.quit()

View File

@@ -83,6 +83,7 @@ class HomeAssistantAdapter(BasePlatformAdapter):
self._watch_domains: Set[str] = set(extra.get("watch_domains", []))
self._watch_entities: Set[str] = set(extra.get("watch_entities", []))
self._ignore_entities: Set[str] = set(extra.get("ignore_entities", []))
self._watch_all: bool = bool(extra.get("watch_all", False))
self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30))
# Cooldown tracking: entity_id -> last_event_timestamp
@@ -115,6 +116,15 @@ class HomeAssistantAdapter(BasePlatformAdapter):
# Dedicated REST session for send() calls
self._rest_session = aiohttp.ClientSession()
# Warn if no event filters are configured
if not self._watch_domains and not self._watch_entities and not self._watch_all:
logger.warning(
"[%s] No watch_domains, watch_entities, or watch_all configured. "
"All state_changed events will be dropped. Configure filters in "
"your HA platform config to receive events.",
self.name,
)
# Start background listener
self._listen_task = asyncio.create_task(self._listen_loop())
self._running = True
@@ -257,13 +267,17 @@ class HomeAssistantAdapter(BasePlatformAdapter):
if entity_id in self._ignore_entities:
return
# Apply domain/entity watch filters
# Apply domain/entity watch filters (closed by default — require
# explicit watch_domains, watch_entities, or watch_all to forward)
domain = entity_id.split(".")[0] if "." in entity_id else ""
if self._watch_domains or self._watch_entities:
domain_match = domain in self._watch_domains if self._watch_domains else False
entity_match = entity_id in self._watch_entities if self._watch_entities else False
if not domain_match and not entity_match:
return
elif not self._watch_all:
# No filters configured and watch_all is off — drop the event
return
# Apply cooldown
now = time.time()

View File

@@ -260,6 +260,30 @@ class SlackAdapter(BasePlatformAdapter):
return metadata["thread_ts"]
return reply_to
async def _upload_file(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local file to Slack."""
if not self._app:
return SendResult(success=False, error="Not connected")
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
result = await self._app.client.files_upload_v2(
channel=chat_id,
file=file_path,
filename=os.path.basename(file_path),
initial_comment=caption or "",
thread_ts=self._resolve_thread_ts(reply_to, metadata),
)
return SendResult(success=True, raw_response=result)
# ----- Markdown → mrkdwn conversion -----
def format_message(self, content: str) -> str:
@@ -417,23 +441,10 @@ class SlackAdapter(BasePlatformAdapter):
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a local image file to Slack by uploading it."""
if not self._app:
return SendResult(success=False, error="Not connected")
try:
import os
if not os.path.exists(image_path):
return SendResult(success=False, error=f"Image file not found: {image_path}")
result = await self._app.client.files_upload_v2(
channel=chat_id,
file=image_path,
filename=os.path.basename(image_path),
initial_comment=caption or "",
thread_ts=self._resolve_thread_ts(reply_to, metadata),
)
return SendResult(success=True, raw_response=result)
return await self._upload_file(chat_id, image_path, caption, reply_to, metadata)
except FileNotFoundError:
return SendResult(success=False, error=f"Image file not found: {image_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error(
"[%s] Failed to send local Slack image %s: %s",
@@ -497,19 +508,10 @@ class SlackAdapter(BasePlatformAdapter):
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send an audio file to Slack."""
if not self._app:
return SendResult(success=False, error="Not connected")
try:
result = await self._app.client.files_upload_v2(
channel=chat_id,
file=audio_path,
filename=os.path.basename(audio_path),
initial_comment=caption or "",
thread_ts=self._resolve_thread_ts(reply_to, metadata),
)
return SendResult(success=True, raw_response=result)
return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata)
except FileNotFoundError:
return SendResult(success=False, error=f"Audio file not found: {audio_path}")
except Exception as e: # pragma: no cover - defensive logging
logger.error(
"[Slack] Failed to send audio file %s: %s",

View File

@@ -159,6 +159,7 @@ class TelegramAdapter(BasePlatformAdapter):
BotCommand("new", "Start a new conversation"),
BotCommand("reset", "Reset conversation history"),
BotCommand("model", "Show or change the model"),
BotCommand("reasoning", "Show or change reasoning effort"),
BotCommand("personality", "Set a personality"),
BotCommand("retry", "Retry your last message"),
BotCommand("undo", "Remove the last exchange"),

View File

@@ -26,6 +26,8 @@ _IS_WINDOWS = platform.system() == "Windows"
from pathlib import Path
from typing import Dict, List, Optional, Any
from hermes_cli.config import get_hermes_home
logger = logging.getLogger(__name__)
@@ -132,7 +134,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
)
self._session_path: Path = Path(config.extra.get(
"session_path",
Path.home() / ".hermes" / "whatsapp" / "session"
get_hermes_home() / "whatsapp" / "session"
))
self._message_queue: asyncio.Queue = asyncio.Queue()
self._bridge_log_fh = None

View File

@@ -17,6 +17,7 @@ import asyncio
import logging
import os
import re
import shlex
import sys
import signal
import threading
@@ -248,14 +249,21 @@ class GatewayRunner:
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
# Track pending exec approvals per session
# Key: session_key, Value: {"command": str, "pattern_key": str}
self._pending_approvals: Dict[str, Dict[str, str]] = {}
# Key: session_key, Value: {"command": str, "pattern_key": str, ...}
self._pending_approvals: Dict[str, Dict[str, Any]] = {}
# Persistent Honcho managers keyed by gateway session key.
# This preserves write_frequency="session" semantics across short-lived
# per-message AIAgent instances.
self._honcho_managers: Dict[str, Any] = {}
self._honcho_configs: Dict[str, Any] = {}
# Ensure tirith security scanner is available (downloads if needed)
try:
from tools.tirith_security import ensure_installed
ensure_installed()
except Exception:
pass # Non-fatal — fail-open at scan time if unavailable
# Initialize session database for session_search tool support
self._session_db = None
@@ -461,23 +469,25 @@ class GatewayRunner:
@staticmethod
def _load_reasoning_config() -> dict | None:
"""Load reasoning effort from config or env var.
Checks HERMES_REASONING_EFFORT env var first, then agent.reasoning_effort
in config.yaml. Valid: "xhigh", "high", "medium", "low", "minimal", "none".
Returns None to use default (medium).
"""Load reasoning effort from config with env fallback.
Checks agent.reasoning_effort in config.yaml first, then
HERMES_REASONING_EFFORT as a fallback. Valid: "xhigh", "high",
"medium", "low", "minimal", "none". Returns None to use default
(medium).
"""
effort = os.getenv("HERMES_REASONING_EFFORT", "")
effort = ""
try:
import yaml as _y
cfg_path = _hermes_home / "config.yaml"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as _f:
cfg = _y.safe_load(_f) or {}
effort = str(cfg.get("agent", {}).get("reasoning_effort", "") or "").strip()
except Exception:
pass
if not effort:
try:
import yaml as _y
cfg_path = _hermes_home / "config.yaml"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as _f:
cfg = _y.safe_load(_f) or {}
effort = str(cfg.get("agent", {}).get("reasoning_effort", "") or "").strip()
except Exception:
pass
effort = os.getenv("HERMES_REASONING_EFFORT", "")
if not effort:
return None
effort = effort.lower().strip()
@@ -665,8 +675,17 @@ class GatewayRunner:
except Exception as e:
logger.warning("Channel directory build failed: %s", e)
# Check if we're restarting after a /update command
await self._send_update_notification()
# Check if we're restarting after a /update command. If the update is
# still running, keep watching so we notify once it actually finishes.
notified = await self._send_update_notification()
if not notified and any(
path.exists()
for path in (
_hermes_home / ".update_pending.json",
_hermes_home / ".update_pending.claimed.json",
)
):
self._schedule_update_notification_watch()
# Start background session expiry watcher for proactive memory flushing
asyncio.create_task(self._session_expiry_watcher())
@@ -926,7 +945,7 @@ class GatewayRunner:
command = event.get_command()
# Emit command:* hook for any recognized slash command
_known_commands = {"new", "reset", "help", "status", "stop", "model",
_known_commands = {"new", "reset", "help", "status", "stop", "model", "reasoning",
"personality", "retry", "undo", "sethome", "set-home",
"compress", "usage", "insights", "reload-mcp", "reload_mcp",
"update", "title", "resume", "provider", "rollback",
@@ -953,7 +972,10 @@ class GatewayRunner:
if command == "model":
return await self._handle_model_command(event)
if command == "reasoning":
return await self._handle_reasoning_command(event)
if command == "provider":
return await self._handle_provider_command(event)
@@ -1001,7 +1023,12 @@ class GatewayRunner:
# User-defined quick commands (bypass agent loop, no LLM call)
if command:
quick_commands = self.config.get("quick_commands", {})
if isinstance(self.config, dict):
quick_commands = self.config.get("quick_commands", {}) or {}
else:
quick_commands = getattr(self.config, "quick_commands", {}) or {}
if not isinstance(quick_commands, dict):
quick_commands = {}
if command in quick_commands:
qcmd = quick_commands[command]
if qcmd.get("type") == "exec":
@@ -1049,11 +1076,15 @@ class GatewayRunner:
if user_text in ("yes", "y", "approve", "ok", "go", "do it"):
approval = self._pending_approvals.pop(session_key_preview)
cmd = approval["command"]
pattern_key = approval.get("pattern_key", "")
pattern_keys = approval.get("pattern_keys", [])
if not pattern_keys:
pk = approval.get("pattern_key", "")
pattern_keys = [pk] if pk else []
logger.info("User approved dangerous command: %s...", cmd[:60])
from tools.terminal_tool import terminal_tool
from tools.approval import approve_session
approve_session(session_key_preview, pattern_key)
for pk in pattern_keys:
approve_session(session_key_preview, pk)
result = terminal_tool(command=cmd, force=True)
return f"✅ Command approved and executed.\n\n```\n{result[:3500]}\n```"
elif user_text in ("no", "n", "deny", "cancel", "nope"):
@@ -1125,10 +1156,16 @@ class GatewayRunner:
get_model_context_length,
)
# Read model + compression config from config.yaml — same
# source of truth the agent itself uses.
# Read model + compression config from config.yaml.
# NOTE: hygiene threshold is intentionally HIGHER than the agent's
# own compressor (0.85 vs 0.50). Hygiene is a safety net for
# sessions that grew too large between turns — it fires pre-agent
# to prevent API failures. The agent's own compressor handles
# normal context management during its tool loop with accurate
# real token counts. Having hygiene at 0.50 caused premature
# compression on every turn in long gateway sessions.
_hyg_model = "anthropic/claude-sonnet-4.6"
_hyg_threshold_pct = 0.50
_hyg_threshold_pct = 0.85
_hyg_compression_enabled = True
try:
_hyg_cfg_path = _hermes_home / "config.yaml"
@@ -1144,22 +1181,18 @@ class GatewayRunner:
elif isinstance(_model_cfg, dict):
_hyg_model = _model_cfg.get("default", _hyg_model)
# Read compression settings
# Read compression settings — only use enabled flag.
# The threshold is intentionally separate from the agent's
# compression.threshold (hygiene runs higher).
_comp_cfg = _hyg_data.get("compression", {})
if isinstance(_comp_cfg, dict):
_hyg_threshold_pct = float(
_comp_cfg.get("threshold", _hyg_threshold_pct)
)
_hyg_compression_enabled = str(
_comp_cfg.get("enabled", True)
).lower() in ("true", "1", "yes")
except Exception:
pass
# Also check env overrides (same as run_agent.py)
_hyg_threshold_pct = float(
os.getenv("CONTEXT_COMPRESSION_THRESHOLD", str(_hyg_threshold_pct))
)
# Check env override for disabling compression entirely
if os.getenv("CONTEXT_COMPRESSION_ENABLED", "").lower() in ("false", "0", "no"):
_hyg_compression_enabled = False
@@ -1446,6 +1479,11 @@ class GatewayRunner:
response = agent_result.get("final_response", "")
agent_messages = agent_result.get("messages", [])
# If the agent's session_id changed during compression, update
# session_entry so transcript writes below go to the right session.
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
session_entry.session_id = agent_result["session_id"]
# Prepend reasoning/thinking if display is enabled
if getattr(self, "_show_reasoning", False) and response:
last_reasoning = agent_result.get("last_reasoning")
@@ -2185,6 +2223,8 @@ class GatewayRunner:
pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
reasoning_config = self._load_reasoning_config()
self._reasoning_config = reasoning_config
def run_sync():
agent = AIAgent(
@@ -2194,7 +2234,7 @@ class GatewayRunner:
quiet_mode=True,
verbose_logging=False,
enabled_toolsets=enabled_toolsets,
reasoning_config=self._reasoning_config,
reasoning_config=reasoning_config,
providers_allowed=pr.get("only"),
providers_ignored=pr.get("ignore"),
providers_order=pr.get("order"),
@@ -2292,6 +2332,8 @@ class GatewayRunner:
args = event.get_command_args().strip().lower()
config_path = _hermes_home / "config.yaml"
self._reasoning_config = self._load_reasoning_config()
self._show_reasoning = self._load_show_reasoning()
def _save_config_key(key_path: str, value):
"""Save a dot-separated key to config.yaml."""
@@ -2687,9 +2729,9 @@ class GatewayRunner:
"""Handle /update command — update Hermes Agent to the latest version.
Spawns ``hermes update`` in a separate systemd scope so it survives the
gateway restart that ``hermes update`` triggers at the end. A marker
file is written so the *new* gateway process can notify the user of the
result on startup.
gateway restart that ``hermes update`` may trigger at the end. Marker
files are written so either the current gateway process or the next one
can notify the user when the update finishes.
"""
import json
import shutil
@@ -2706,9 +2748,9 @@ class GatewayRunner:
if not hermes_bin:
return "✗ `hermes` command not found on PATH."
# Write marker so the restarted gateway can notify this chat
pending_path = _hermes_home / ".update_pending.json"
output_path = _hermes_home / ".update_output.txt"
exit_code_path = _hermes_home / ".update_exit_code"
pending = {
"platform": event.source.platform.value,
"chat_id": event.source.chat_id,
@@ -2716,10 +2758,14 @@ class GatewayRunner:
"timestamp": datetime.now().isoformat(),
}
pending_path.write_text(json.dumps(pending))
exit_code_path.unlink(missing_ok=True)
# Spawn `hermes update` in a separate cgroup so it survives gateway
# restart. systemd-run --user --scope creates a transient scope unit.
update_cmd = f"{hermes_bin} update > {output_path} 2>&1"
# restart. systemd-run --user --scope creates a transient scope unit.
update_cmd = (
f"{shlex.quote(hermes_bin)} update > {shlex.quote(str(output_path))} 2>&1; "
f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
)
try:
systemd_run = shutil.which("systemd-run")
if systemd_run:
@@ -2741,26 +2787,91 @@ class GatewayRunner:
)
except Exception as e:
pending_path.unlink(missing_ok=True)
exit_code_path.unlink(missing_ok=True)
return f"✗ Failed to start update: {e}"
self._schedule_update_notification_watch()
return "⚕ Starting Hermes update… I'll notify you when it's done."
async def _send_update_notification(self) -> None:
"""If the gateway is starting after a ``/update``, notify the user."""
def _schedule_update_notification_watch(self) -> None:
"""Ensure a background task is watching for update completion."""
existing_task = getattr(self, "_update_notification_task", None)
if existing_task and not existing_task.done():
return
try:
self._update_notification_task = asyncio.create_task(
self._watch_for_update_completion()
)
except RuntimeError:
logger.debug("Skipping update notification watcher: no running event loop")
async def _watch_for_update_completion(
self,
poll_interval: float = 2.0,
timeout: float = 1800.0,
) -> None:
"""Wait for ``hermes update`` to finish, then send its notification."""
pending_path = _hermes_home / ".update_pending.json"
claimed_path = _hermes_home / ".update_pending.claimed.json"
exit_code_path = _hermes_home / ".update_exit_code"
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():
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():
logger.warning("Update watcher timed out waiting for completion marker")
exit_code_path.write_text("124")
await self._send_update_notification()
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.
"""
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"
if not pending_path.exists():
return
if not pending_path.exists() and not claimed_path.exists():
return False
cleanup = True
active_pending_path = claimed_path
try:
pending = json.loads(pending_path.read_text())
if pending_path.exists():
try:
pending_path.replace(claimed_path)
except FileNotFoundError:
if not claimed_path.exists():
return True
elif not claimed_path.exists():
return True
pending = json.loads(claimed_path.read_text())
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
if not exit_code_path.exists():
logger.info("Update notification deferred: update still running")
cleanup = False
active_pending_path = pending_path
claimed_path.replace(pending_path)
return False
exit_code_raw = exit_code_path.read_text().strip() or "1"
exit_code = int(exit_code_raw)
# Read the captured update output
output = ""
if output_path.exists():
@@ -2774,19 +2885,34 @@ class GatewayRunner:
# Strip ANSI escape codes for clean display
output = _re.sub(r'\x1b\[[0-9;]*m', '', output).strip()
if output:
# Truncate if too long for a single message
if len(output) > 3500:
output = "" + output[-3500:]
msg = f"✅ Hermes update finished — gateway restarted.\n\n```\n{output}\n```"
if exit_code == 0:
msg = f"✅ Hermes update finished.\n\n```\n{output}\n```"
else:
msg = f"❌ Hermes update failed.\n\n```\n{output}\n```"
else:
msg = "✅ Hermes update finished — gateway restarted successfully."
if exit_code == 0:
msg = "✅ Hermes update finished successfully."
else:
msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details."
await adapter.send(chat_id, msg)
logger.info("Sent post-update notification to %s:%s", platform_str, chat_id)
logger.info(
"Sent post-update notification to %s:%s (exit=%s)",
platform_str,
chat_id,
exit_code,
)
except Exception as e:
logger.warning("Post-update notification failed: %s", e)
finally:
pending_path.unlink(missing_ok=True)
output_path.unlink(missing_ok=True)
if cleanup:
active_pending_path.unlink(missing_ok=True)
claimed_path.unlink(missing_ok=True)
output_path.unlink(missing_ok=True)
exit_code_path.unlink(missing_ok=True)
return True
def _set_session_env(self, context: SessionContext) -> None:
"""Set environment variables for the current session."""
@@ -3350,6 +3476,8 @@ class GatewayRunner:
pr = self._provider_routing
honcho_manager, honcho_config = self._get_or_create_gateway_honcho(session_key)
reasoning_config = self._load_reasoning_config()
self._reasoning_config = reasoning_config
agent = AIAgent(
model=model,
**runtime_kwargs,
@@ -3359,7 +3487,7 @@ class GatewayRunner:
enabled_toolsets=enabled_toolsets,
ephemeral_system_prompt=combined_ephemeral or None,
prefill_messages=self._prefill_messages or None,
reasoning_config=self._reasoning_config,
reasoning_config=reasoning_config,
providers_allowed=pr.get("only"),
providers_ignored=pr.get("ignore"),
providers_order=pr.get("order"),
@@ -3495,6 +3623,23 @@ class GatewayRunner:
unique_tags.insert(0, "[[audio_as_voice]]")
final_response = final_response + "\n" + "\n".join(unique_tags)
# Sync session_id: the agent may have created a new session during
# mid-run context compression (_compress_context splits sessions).
# If so, update the session store entry so the NEXT message loads
# the compressed transcript, not the stale pre-compression one.
agent = agent_holder[0]
if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
logger.info(
"Session split detected: %s%s (compression)",
session_id, agent.session_id,
)
entry = self.session_store._entries.get(session_key)
if entry:
entry.session_id = agent.session_id
self.session_store._save()
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
return {
"final_response": final_response,
"last_reasoning": result.get("last_reasoning"),
@@ -3503,6 +3648,7 @@ class GatewayRunner:
"tools": tools_holder[0] or [],
"history_offset": len(agent_history),
"last_prompt_tokens": _last_prompt_toks,
"session_id": effective_session_id,
}
# Start progress message sender if enabled

View File

@@ -177,6 +177,26 @@ def build_session_context_prompt(context: SessionContext) -> str:
elif context.source.user_id:
lines.append(f"**User ID:** {context.source.user_id}")
# Platform-specific behavioral notes
if context.source.platform == Platform.SLACK:
lines.append("")
lines.append(
"**Platform notes:** You are running inside Slack. "
"You do NOT have access to Slack-specific APIs — you cannot search "
"channel history, pin/unpin messages, manage channels, or list users. "
"Do not promise to perform these actions. If the user asks, explain "
"that you can only read messages sent directly to you and respond."
)
elif context.source.platform == Platform.DISCORD:
lines.append("")
lines.append(
"**Platform notes:** You are running inside Discord. "
"You do NOT have access to Discord-specific APIs — you cannot search "
"channel history, pin messages, manage roles, or list server members. "
"Do not promise to perform these actions. If the user asks, explain "
"that you can only read messages sent directly to you and respond."
)
# Connected platforms
platforms_list = ["local (files on this machine)"]
for p in context.connected_platforms:

View File

@@ -14,8 +14,10 @@ import time
from pathlib import Path
from typing import Optional
from hermes_cli.config import get_hermes_home
CACHE_PATH = Path(os.path.expanduser("~/.hermes/sticker_cache.json"))
CACHE_PATH = get_hermes_home() / "sticker_cache.json"
# Vision prompt for describing stickers -- kept concise to save tokens
STICKER_VISION_PROMPT = (

View File

@@ -1541,8 +1541,20 @@ def detect_external_credentials() -> List[Dict[str, Any]]:
# CLI Commands — login / logout
# =============================================================================
def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Path:
"""Update config.yaml and auth.json to reflect the active provider."""
def _update_config_for_provider(
provider_id: str,
inference_base_url: str,
default_model: Optional[str] = None,
) -> Path:
"""Update config.yaml and auth.json to reflect the active provider.
When *default_model* is provided the function also writes it as the
``model.default`` value. This prevents a race condition where the
gateway (which re-reads config per-message) picks up the new provider
before the caller has finished model selection, resulting in a
mismatched model/provider (e.g. ``anthropic/claude-opus-4.6`` sent to
MiniMax's API).
"""
# Set active_provider in auth.json so auto-resolution picks this provider
with _auth_store_lock():
auth_store = _load_auth_store()
@@ -1576,6 +1588,15 @@ def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Pa
else:
# Clear stale base_url to prevent contamination when switching providers
model_cfg.pop("base_url", None)
# When switching to a non-OpenRouter provider, ensure model.default is
# valid for the new provider. An OpenRouter-formatted name like
# "anthropic/claude-opus-4.6" will fail on direct-API providers.
if default_model:
cur_default = model_cfg.get("default", "")
if not cur_default or "/" in cur_default:
model_cfg["default"] = default_model
config["model"] = model_cfg
config_path.write_text(yaml.safe_dump(config, sort_keys=False))

View File

@@ -227,43 +227,53 @@ def approval_callback(cli, command: str, description: str) -> str:
Shows a selection UI with choices: once / session / always / deny.
When the command is longer than 70 characters, a "view" option is
included so the user can reveal the full text before deciding.
Uses cli._approval_lock to serialize concurrent requests (e.g. from
parallel delegation subtasks) so each prompt gets its own turn.
"""
timeout = 60
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
choices.append("view")
lock = getattr(cli, "_approval_lock", None)
if lock is None:
import threading
cli._approval_lock = threading.Lock()
lock = cli._approval_lock
cli._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
cli._approval_deadline = _time.monotonic() + timeout
with lock:
timeout = 60
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
if len(command) > 70:
choices.append("view")
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._approval_state = {
"command": command,
"description": description,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
cli._approval_deadline = _time.monotonic() + timeout
while True:
try:
result = response_queue.get(timeout=1)
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
return result
except queue.Empty:
remaining = cli._approval_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "deny"
while True:
try:
result = response_queue.get(timeout=1)
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
return result
except queue.Empty:
remaining = cli._approval_deadline - _time.monotonic()
if remaining <= 0:
break
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cli._approval_state = None
cli._approval_deadline = 0
if hasattr(cli, "_app") and cli._app:
cli._app.invalidate()
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
return "deny"

View File

@@ -18,6 +18,36 @@ DEFAULT_CODEX_MODELS: List[str] = [
"gpt-5.1-codex-mini",
]
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
("gpt-5.3-codex", ("gpt-5.2-codex",)),
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
]
def _add_forward_compat_models(model_ids: List[str]) -> List[str]:
"""Add Clawdbot-style synthetic forward-compat Codex models.
If a newer Codex slug isn't returned by live discovery, surface it when an
older compatible template model is present. This mirrors Clawdbot's
synthetic catalog / forward-compat behavior for GPT-5 Codex variants.
"""
ordered: List[str] = []
seen: set[str] = set()
for model_id in model_ids:
if model_id not in seen:
ordered.append(model_id)
seen.add(model_id)
for synthetic_model, template_models in _FORWARD_COMPAT_TEMPLATE_MODELS:
if synthetic_model in seen:
continue
if any(template in seen for template in template_models):
ordered.append(synthetic_model)
seen.add(synthetic_model)
return ordered
def _fetch_models_from_api(access_token: str) -> List[str]:
"""Fetch available models from the Codex API. Returns visible models sorted by priority."""
@@ -54,7 +84,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]:
sortable.append((rank, slug))
sortable.sort(key=lambda x: (x[0], x[1]))
return [slug for _, slug in sortable]
return _add_forward_compat_models([slug for _, slug in sortable])
def _read_default_model(codex_home: Path) -> Optional[str]:
@@ -125,7 +155,7 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
if access_token:
api_models = _fetch_models_from_api(access_token)
if api_models:
return api_models
return _add_forward_compat_models(api_models)
# Fall back to local sources
default_model = _read_default_model(codex_home)
@@ -140,4 +170,4 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
if model_id not in ordered:
ordered.append(model_id)
return ordered
return _add_forward_compat_models(ordered)

View File

@@ -16,9 +16,9 @@ from prompt_toolkit.completion import Completer, Completion
# Commands organized by category for better help display
COMMANDS_BY_CATEGORY = {
"Session": {
"/new": "Start a new conversation (reset history)",
"/reset": "Reset conversation only (keep screen)",
"/clear": "Clear screen and reset conversation (fresh start)",
"/new": "Start a new session (fresh session ID + history)",
"/reset": "Start a new session (alias for /new)",
"/clear": "Clear screen and start a new session",
"/history": "Show conversation history",
"/save": "Save the current conversation",
"/retry": "Retry the last message (resend to agent)",

View File

@@ -194,8 +194,13 @@ DEFAULT_CONFIG = {
},
"stt": {
"enabled": True,
"model": "whisper-1",
"provider": "local", # "local" (free, faster-whisper) | "openai" (Whisper API)
"local": {
"model": "base", # tiny, base, small, medium, large-v3
},
"openai": {
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
},
},
"human_delay": {
@@ -250,6 +255,15 @@ DEFAULT_CONFIG = {
# Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
"personalities": {},
# Pre-exec security scanning via tirith
"security": {
"redact_secrets": True,
"tirith_enabled": True,
"tirith_path": "tirith",
"tirith_timeout": 5,
"tirith_fail_open": True,
},
# Config schema version - bump this when adding new required fields
"_config_version": 7,
}
@@ -880,14 +894,23 @@ def load_config() -> Dict[str, Any]:
return _normalize_max_turns_config(config)
_COMMENTED_SECTIONS = """
_SECURITY_COMMENT = """
# ── Security ──────────────────────────────────────────────────────────
# API keys, tokens, and passwords are redacted from tool output by default.
# Set to false to see full values (useful for debugging auth issues).
# tirith pre-exec scanning is enabled by default when the tirith binary
# is available. Configure via security.tirith_* keys or env vars
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
#
# security:
# redact_secrets: false
# tirith_enabled: true
# tirith_path: "tirith"
# tirith_timeout: 5
# tirith_fail_open: true
"""
_FALLBACK_COMMENT = """
# ── Fallback Model ────────────────────────────────────────────────────
# Automatic provider failover when primary is unavailable.
# Uncomment and configure to enable. Triggers on rate limits (429),
@@ -950,18 +973,18 @@ def save_config(config: Dict[str, Any]):
# Build optional commented-out sections for features that are off by
# default or only relevant when explicitly configured.
sections = []
parts = []
sec = normalized.get("security", {})
if not sec or sec.get("redact_secrets") is None:
sections.append("security")
parts.append(_SECURITY_COMMENT)
fb = normalized.get("fallback_model", {})
if not fb or not (fb.get("provider") and fb.get("model")):
sections.append("fallback")
parts.append(_FALLBACK_COMMENT)
atomic_yaml_write(
config_path,
normalized,
extra_content=_COMMENTED_SECTIONS if sections else None,
extra_content="".join(parts) if parts else None,
)
_secure_file(config_path)

View File

@@ -94,9 +94,46 @@ def check_info(text: str):
print(f" {color('', Colors.CYAN)} {text}")
def _check_gateway_service_linger(issues: list[str]) -> None:
"""Warn when a systemd user gateway service will stop after logout."""
try:
from hermes_cli.gateway import (
get_systemd_linger_status,
get_systemd_unit_path,
is_linux,
)
except Exception as e:
check_warn("Gateway service linger", f"(could not import gateway helpers: {e})")
return
if not is_linux():
return
unit_path = get_systemd_unit_path()
if not unit_path.exists():
return
print()
print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD))
linger_enabled, linger_detail = get_systemd_linger_status()
if linger_enabled is True:
check_ok("Systemd linger enabled", "(gateway service survives logout)")
elif linger_enabled is False:
check_warn("Systemd linger disabled", "(gateway may stop after logout)")
check_info("Run: sudo loginctl enable-linger $USER")
issues.append("Enable linger for the gateway user service: sudo loginctl enable-linger $USER")
else:
check_warn("Could not verify systemd linger", f"({linger_detail})")
def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
# Doctor runs from the interactive CLI, so CLI-gated tool availability
# checks (like cronjob management) should see the same context as `hermes`.
os.environ.setdefault("HERMES_INTERACTIVE", "1")
issues = []
manual_issues = [] # issues that can't be auto-fixed
@@ -344,6 +381,8 @@ def run_doctor(args):
check_warn(f"~/.hermes/state.db exists but has issues: {e}")
else:
check_info("~/.hermes/state.db not created yet (will be created on first session)")
_check_gateway_service_linger(issues)
# =========================================================================
# Check: External tools

View File

@@ -13,7 +13,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value
from hermes_cli.setup import (
print_header, print_info, print_success, print_warning, print_error,
prompt, prompt_choice, prompt_yes_no,
@@ -122,9 +122,72 @@ def is_windows() -> bool:
SERVICE_NAME = "hermes-gateway"
SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
def get_systemd_unit_path() -> Path:
return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
def get_systemd_linger_status() -> tuple[bool | None, str]:
"""Return whether systemd user lingering is enabled for the current user.
Returns:
(True, "") when linger is enabled.
(False, "") when linger is disabled.
(None, detail) when the status could not be determined.
"""
if not is_linux():
return None, "not supported on this platform"
import shutil
if not shutil.which("loginctl"):
return None, "loginctl not found"
username = os.getenv("USER") or os.getenv("LOGNAME")
if not username:
try:
import pwd
username = pwd.getpwuid(os.getuid()).pw_name
except Exception:
return None, "could not determine current user"
try:
result = subprocess.run(
["loginctl", "show-user", username, "--property=Linger", "--value"],
capture_output=True,
text=True,
check=False,
)
except Exception as e:
return None, str(e)
if result.returncode != 0:
detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip()
return None, detail or "loginctl query failed"
value = (result.stdout or "").strip().lower()
if value in {"yes", "true", "1"}:
return True, ""
if value in {"no", "false", "0"}:
return False, ""
rendered = value or "<empty>"
return None, f"unexpected loginctl output: {rendered}"
def print_systemd_linger_guidance() -> None:
"""Print the current linger status and the fix when it is disabled."""
linger_enabled, linger_detail = get_systemd_linger_status()
if linger_enabled is True:
print("✓ Systemd linger is enabled (service survives logout)")
elif linger_enabled is False:
print("⚠ Systemd linger is disabled (gateway may stop when you log out)")
print(" Run: sudo loginctl enable-linger $USER")
else:
print(f"⚠ Could not verify systemd linger ({linger_detail})")
print(" If you want the gateway user service to survive logout, run:")
print(" sudo loginctl enable-linger $USER")
def get_launchd_plist_path() -> Path:
return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist"
@@ -211,8 +274,7 @@ def systemd_install(force: bool = False):
print(f" hermes gateway status # Check status")
print(f" journalctl --user -u {SERVICE_NAME} -f # View logs")
print()
print("To enable lingering (keeps running after logout):")
print(" sudo loginctl enable-linger $USER")
print_systemd_linger_guidance()
def systemd_uninstall():
subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False)
@@ -245,28 +307,38 @@ def systemd_status(deep: bool = False):
print("✗ Gateway service is not installed")
print(" Run: hermes gateway install")
return
# Show detailed status first
subprocess.run(
["systemctl", "--user", "status", SERVICE_NAME, "--no-pager"],
capture_output=False
)
# Check if service is active
result = subprocess.run(
["systemctl", "--user", "is-active", SERVICE_NAME],
capture_output=True,
text=True
)
status = result.stdout.strip()
if status == "active":
print("✓ Gateway service is running")
else:
print("✗ Gateway service is stopped")
print(" Run: hermes gateway start")
if deep:
print_systemd_linger_guidance()
else:
linger_enabled, _ = get_systemd_linger_status()
if linger_enabled is True:
print("✓ Systemd linger is enabled (service survives logout)")
elif linger_enabled is False:
print("⚠ Systemd linger is disabled (gateway may stop when you log out)")
print(" Run: sudo loginctl enable-linger $USER")
if deep:
print()
print("Recent logs:")
@@ -283,7 +355,7 @@ def systemd_status(deep: bool = False):
def generate_launchd_plist() -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
log_dir = Path.home() / ".hermes" / "logs"
log_dir = get_hermes_home() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
return f"""<?xml version="1.0" encoding="UTF-8"?>
@@ -380,7 +452,7 @@ def launchd_status(deep: bool = False):
print("✗ Gateway service is not loaded")
if deep:
log_file = Path.home() / ".hermes" / "logs" / "gateway.log"
log_file = get_hermes_home() / "logs" / "gateway.log"
if log_file.exists():
print()
print("Recent logs:")
@@ -557,7 +629,7 @@ def _platform_status(platform: dict) -> str:
val = get_env_value(token_var)
if token_var == "WHATSAPP_ENABLED":
if val and val.lower() == "true":
session_file = Path.home() / ".hermes" / "whatsapp" / "session" / "creds.json"
session_file = get_hermes_home() / "whatsapp" / "session" / "creds.json"
if session_file.exists():
return "configured + paired"
return "enabled, not paired"
@@ -623,6 +695,18 @@ def _setup_standard_platform(platform: dict):
value = prompt(f" {var['prompt']}", password=False)
if value:
cleaned = value.replace(" ", "")
# For Discord, strip common prefixes (user:123, <@123>, <@!123>)
if "DISCORD" in var["name"]:
parts = []
for uid in cleaned.split(","):
uid = uid.strip()
if uid.startswith("<@") and uid.endswith(">"):
uid = uid.lstrip("<@!").rstrip(">")
if uid.lower().startswith("user:"):
uid = uid[5:]
if uid:
parts.append(uid)
cleaned = ",".join(parts)
save_env_value(var["name"], cleaned)
print_success(f" Saved — only these users can interact with the bot.")
allowed_val_set = cleaned

View File

@@ -34,16 +34,18 @@ Usage:
hermes honcho identity # Show AI peer identity representation
hermes honcho identity <file> # Seed AI peer identity from a file (SOUL.md etc.)
hermes honcho migrate # Step-by-step migration guide: OpenClaw native → Hermes + Honcho
hermes version # Show version
hermes update # Update to latest version
hermes uninstall # Uninstall Hermes Agent
hermes sessions browse # Interactive session picker with search
hermes claw migrate # Migrate from OpenClaw to Hermes
hermes version Show version
hermes update Update to latest version
hermes uninstall Uninstall Hermes Agent
hermes acp Run as an ACP server for editor integration
hermes sessions browse Interactive session picker with search
hermes claw migrate --dry-run # Preview migration without changes
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
from typing import Optional
@@ -68,6 +70,8 @@ os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home()))
os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
import logging
import time as _time
from datetime import datetime
from hermes_cli import __version__, __release_date__
from hermes_constants import OPENROUTER_BASE_URL
@@ -75,6 +79,24 @@ from hermes_constants import OPENROUTER_BASE_URL
logger = logging.getLogger(__name__)
def _relative_time(ts) -> str:
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
if delta < 3600:
return f"{int(delta / 60)}m ago"
if delta < 86400:
return f"{int(delta / 3600)}h ago"
if delta < 172800:
return "yesterday"
if delta < 604800:
return f"{int(delta / 86400)}d ago"
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
def _has_any_provider_configured() -> bool:
"""Check if at least one inference provider is usable."""
from hermes_cli.config import get_env_path, get_hermes_home
@@ -139,28 +161,9 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
# Try curses-based picker first
try:
import curses
import time as _time
from datetime import datetime
result_holder = [None]
def _relative_time(ts):
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
elif delta < 3600:
return f"{int(delta / 60)}m ago"
elif delta < 86400:
return f"{int(delta / 3600)}h ago"
elif delta < 172800:
return "yesterday"
elif delta < 604800:
return f"{int(delta / 86400)}d ago"
else:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
def _format_row(s, max_x):
"""Format a session row for display."""
title = (s.get("title") or "").strip()
@@ -351,26 +354,6 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
pass
# Fallback: numbered list (Windows without curses, etc.)
import time as _time
from datetime import datetime
def _relative_time_fb(ts):
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
elif delta < 3600:
return f"{int(delta / 60)}m ago"
elif delta < 86400:
return f"{int(delta / 3600)}h ago"
elif delta < 172800:
return "yesterday"
elif delta < 604800:
return f"{int(delta / 86400)}d ago"
else:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
print("\n Browse sessions (enter number to resume, q to cancel)\n")
for i, s in enumerate(sessions):
title = (s.get("title") or "").strip()
@@ -378,7 +361,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
label = title or preview or s["id"]
if len(label) > 50:
label = label[:47] + "..."
last_active = _relative_time_fb(s.get("last_active"))
last_active = _relative_time(s.get("last_active"))
src = s.get("source", "")[:6]
print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}")
@@ -477,6 +460,15 @@ def cmd_chat(args):
print()
print(" Run: hermes setup")
print()
from hermes_cli.setup import is_interactive_stdin, print_noninteractive_setup_guidance
if not is_interactive_stdin():
print_noninteractive_setup_guidance(
"No interactive TTY detected for the first-run setup prompt."
)
sys.exit(1)
try:
reply = input("Run setup now? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
@@ -648,7 +640,7 @@ def cmd_whatsapp(args):
print("✓ Bridge dependencies already installed")
# ── Step 5: Check for existing session ───────────────────────────────
session_dir = Path.home() / ".hermes" / "whatsapp" / "session"
session_dir = get_hermes_home() / "whatsapp" / "session"
session_dir.mkdir(parents=True, exist_ok=True)
if (session_dir / "creds.json").exists():
@@ -745,8 +737,8 @@ def cmd_model(args):
config_provider = model_cfg.get("provider")
effective_provider = (
os.getenv("HERMES_INFERENCE_PROVIDER")
or config_provider
config_provider
or os.getenv("HERMES_INFERENCE_PROVIDER")
or "auto"
)
try:
@@ -1057,6 +1049,7 @@ def _model_flow_openai_codex(config, current_model=""):
_codex_token = _codex_creds.get("api_key")
except Exception:
pass
codex_models = get_codex_model_ids(access_token=_codex_token)
selected = _prompt_model_selection(codex_models, current_model=current_model)
@@ -1072,6 +1065,7 @@ def _model_flow_openai_codex(config, current_model=""):
print("No change.")
def _model_flow_custom(config):
"""Custom endpoint: collect URL, API key, and model name.
@@ -1937,9 +1931,82 @@ def _update_via_zip(args):
print("✓ Update complete!")
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
status = subprocess.run(
git_cmd + ["status", "--porcelain"],
cwd=cwd,
capture_output=True,
text=True,
check=True,
)
if not status.stdout.strip():
return None
from datetime import datetime, timezone
stash_name = datetime.now(timezone.utc).strftime("hermes-update-autostash-%Y%m%d-%H%M%S")
print("→ Local changes detected — stashing before update...")
subprocess.run(
git_cmd + ["stash", "push", "--include-untracked", "-m", stash_name],
cwd=cwd,
check=True,
)
stash_ref = subprocess.run(
git_cmd + ["rev-parse", "--verify", "refs/stash"],
cwd=cwd,
capture_output=True,
text=True,
check=True,
).stdout.strip()
return stash_ref
def _restore_stashed_changes(
git_cmd: list[str],
cwd: Path,
stash_ref: str,
prompt_user: bool = False,
) -> bool:
if prompt_user:
print()
print("⚠ Local changes were stashed before updating.")
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 response not in ("", "y", "yes"):
print("Skipped restoring local changes.")
print("Your changes are still preserved in git stash.")
print(f"Restore manually with: git stash apply {stash_ref}")
return False
print("→ Restoring local changes...")
restore = subprocess.run(
git_cmd + ["stash", "apply", stash_ref],
cwd=cwd,
capture_output=True,
text=True,
)
if restore.returncode != 0:
print("✗ Update pulled new code, but restoring local changes failed.")
if restore.stdout.strip():
print(restore.stdout.strip())
if restore.stderr.strip():
print(restore.stderr.strip())
print("Your changes are still preserved in git stash.")
print(f"Resolve manually with: git stash apply {stash_ref}")
sys.exit(1)
subprocess.run(git_cmd + ["stash", "drop", stash_ref], cwd=cwd, check=True)
print("⚠ Local changes were restored on top of the updated codebase.")
print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
return True
def cmd_update(args):
"""Update Hermes Agent to the latest version."""
import subprocess
import shutil
print("⚕ Updating Hermes Agent...")
@@ -2005,8 +2072,21 @@ def cmd_update(args):
return
print(f"→ Found {commit_count} new commit(s)")
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()
print("→ Pulling updates...")
subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
try:
subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
finally:
if auto_stash_ref is not None:
_restore_stashed_changes(
git_cmd,
PROJECT_ROOT,
auto_stash_ref,
prompt_user=prompt_for_restore,
)
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
print("→ Updating Python dependencies...")
@@ -2834,30 +2914,6 @@ For more help on a command:
if not sessions:
print("No sessions found.")
return
from datetime import datetime
import time as _time
def _relative_time(ts):
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
if not ts:
return "?"
delta = _time.time() - ts
if delta < 60:
return "just now"
elif delta < 3600:
mins = int(delta / 60)
return f"{mins}m ago"
elif delta < 86400:
hours = int(delta / 3600)
return f"{hours}h ago"
elif delta < 172800:
return "yesterday"
elif delta < 604800:
days = int(delta / 86400)
return f"{days}d ago"
else:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
has_titles = any(s.get("title") for s in sessions)
if has_titles:
print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}")
@@ -3100,6 +3156,27 @@ For more help on a command:
help="Skip confirmation prompts"
)
uninstall_parser.set_defaults(func=cmd_uninstall)
# =========================================================================
# acp command
# =========================================================================
acp_parser = subparsers.add_parser(
"acp",
help="Run Hermes Agent as an ACP (Agent Client Protocol) server",
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
)
def cmd_acp(args):
"""Launch Hermes Agent as an ACP server."""
try:
from acp_adapter.entry import main as acp_main
acp_main()
except ImportError:
print("ACP dependencies not installed.")
print("Install them with: pip install -e '.[acp]'")
sys.exit(1)
acp_parser.set_defaults(func=cmd_acp)
# =========================================================================
# Parse and execute

View File

@@ -40,6 +40,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"deepseek-v3.2",
],
"openai-codex": [
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.1-codex-mini",
"gpt-5.1-codex-max",
@@ -222,6 +223,16 @@ def normalize_provider(provider: Optional[str]) -> str:
return _PROVIDER_ALIASES.get(normalized, normalized)
def provider_label(provider: Optional[str]) -> str:
"""Return a human-friendly label for a provider id or alias."""
original = (provider or "openrouter").strip()
normalized = original.lower()
if normalized == "auto":
return "Auto"
normalized = normalize_provider(normalized)
return _PROVIDER_LABELS.get(normalized, original or "OpenRouter")
def provider_model_ids(provider: Optional[str]) -> list[str]:
"""Return the best known model catalog for a provider.

View File

@@ -29,19 +29,21 @@ def _get_model_config() -> Dict[str, Any]:
def resolve_requested_provider(requested: Optional[str] = None) -> str:
"""Resolve provider request from explicit arg, env, then config."""
"""Resolve provider request from explicit arg, config, then env."""
if requested and requested.strip():
return requested.strip().lower()
env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
if env_provider:
return env_provider
model_cfg = _get_model_config()
cfg_provider = model_cfg.get("provider")
if isinstance(cfg_provider, str) and cfg_provider.strip():
return cfg_provider.strip().lower()
# Prefer the persisted config selection over any stale shell/.env
# provider override so chat uses the endpoint the user last saved.
env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
if env_provider:
return env_provider
return "auto"

View File

@@ -111,7 +111,17 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
custom = prompt_fn("Enter model name")
if custom:
_set_default_model(config, custom)
# else: keep current
else:
# "Keep current" selected — validate it's compatible with the new
# provider. OpenRouter-formatted names (containing "/") won't work
# on direct-API providers and would silently break the gateway.
if "/" in (current_model or "") and provider_models:
print_warning(
f"Current model \"{current_model}\" looks like an OpenRouter model "
f"and won't work with {pconfig.name}. "
f"Switching to {provider_models[0]}."
)
_set_default_model(config, provider_models[0])
def _sync_model_from_disk(config: Dict[str, Any]) -> None:
@@ -166,6 +176,36 @@ def print_error(text: str):
print(color(f"{text}", Colors.RED))
def is_interactive_stdin() -> bool:
"""Return True when stdin looks like a usable interactive TTY."""
stdin = getattr(sys, "stdin", None)
if stdin is None:
return False
try:
return bool(stdin.isatty())
except Exception:
return False
def print_noninteractive_setup_guidance(reason: str | None = None) -> None:
"""Print guidance for headless/non-interactive setup flows."""
print()
print(color("⚕ Hermes Setup — Non-interactive mode", Colors.CYAN, Colors.BOLD))
print()
if reason:
print_info(reason)
print_info("The interactive wizard cannot be used here.")
print()
print_info("Configure Hermes using environment variables or config commands:")
print_info(" hermes config set model.provider custom")
print_info(" hermes config set model.base_url http://localhost:8080/v1")
print_info(" hermes config set model.default your-model-name")
print()
print_info("Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.")
print_info("Run 'hermes setup' in an interactive terminal to use the full wizard.")
print()
def prompt(question: str, default: str = None, password: bool = False) -> str:
"""Prompt for input with optional default."""
if default:
@@ -644,6 +684,7 @@ def setup_model_provider(config: dict):
_update_config_for_provider,
_login_openai_codex,
get_codex_auth_status,
resolve_codex_runtime_credentials,
DEFAULT_CODEX_BASE_URL,
detect_external_credentials,
)
@@ -656,6 +697,12 @@ def setup_model_provider(config: dict):
active_oauth = get_active_provider()
existing_custom = get_env_value("OPENAI_BASE_URL")
model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {}
current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None
if current_config_provider == "auto":
current_config_provider = None
current_config_base_url = str(model_cfg.get("base_url") or "").strip()
# Detect credentials from other CLI tools
detected_creds = detect_external_credentials()
if detected_creds:
@@ -668,10 +715,23 @@ def setup_model_provider(config: dict):
print()
# Detect if any provider is already configured
has_any_provider = bool(active_oauth or existing_custom or existing_or)
has_any_provider = bool(
current_config_provider or active_oauth or existing_custom or existing_or
)
# Build "keep current" label
if active_oauth and active_oauth in PROVIDER_REGISTRY:
if current_config_provider == "custom":
custom_label = current_config_base_url or existing_custom
keep_label = (
f"Keep current (Custom: {custom_label})"
if custom_label
else "Keep current (Custom)"
)
elif current_config_provider == "openrouter":
keep_label = "Keep current (OpenRouter)"
elif current_config_provider and current_config_provider in PROVIDER_REGISTRY:
keep_label = f"Keep current ({PROVIDER_REGISTRY[current_config_provider].name})"
elif active_oauth and active_oauth in PROVIDER_REGISTRY:
keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})"
elif existing_custom:
keep_label = f"Keep current (Custom: {existing_custom})"
@@ -967,7 +1027,7 @@ def setup_model_provider(config: dict):
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
_update_config_for_provider("zai", zai_base_url)
_update_config_for_provider("zai", zai_base_url, default_model="glm-5")
_set_model_provider(config, "zai", zai_base_url)
elif provider_idx == 5: # Kimi / Moonshot
@@ -1000,7 +1060,7 @@ def setup_model_provider(config: dict):
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
_update_config_for_provider("kimi-coding", pconfig.inference_base_url)
_update_config_for_provider("kimi-coding", pconfig.inference_base_url, default_model="kimi-k2.5")
_set_model_provider(config, "kimi-coding", pconfig.inference_base_url)
elif provider_idx == 6: # MiniMax
@@ -1033,7 +1093,7 @@ def setup_model_provider(config: dict):
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
_update_config_for_provider("minimax", pconfig.inference_base_url)
_update_config_for_provider("minimax", pconfig.inference_base_url, default_model="MiniMax-M2.5")
_set_model_provider(config, "minimax", pconfig.inference_base_url)
elif provider_idx == 7: # MiniMax China
@@ -1066,7 +1126,7 @@ def setup_model_provider(config: dict):
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
_update_config_for_provider("minimax-cn", pconfig.inference_base_url)
_update_config_for_provider("minimax-cn", pconfig.inference_base_url, default_model="MiniMax-M2.5")
_set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
elif provider_idx == 8: # Anthropic
@@ -1170,10 +1230,21 @@ def setup_model_provider(config: dict):
save_env_value("OPENAI_API_KEY", "")
# Don't save base_url for Anthropic — resolve_runtime_provider()
# always hardcodes it. Stale base_urls contaminate other providers.
_update_config_for_provider("anthropic", "")
_update_config_for_provider("anthropic", "", default_model="claude-opus-4-6")
_set_model_provider(config, "anthropic")
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists
# Normalize "keep current" to an explicit provider so downstream logic
# doesn't fall back to the generic OpenRouter/static-model path.
if selected_provider is None:
if current_config_provider:
selected_provider = current_config_provider
elif active_oauth and active_oauth in PROVIDER_REGISTRY:
selected_provider = active_oauth
elif existing_custom:
selected_provider = "custom"
elif existing_or:
selected_provider = "openrouter"
# ── OpenRouter API Key for tools (if not already set) ──
# Tools (vision, web, MoA) use OpenRouter independently of the main provider.
@@ -1256,7 +1327,15 @@ def setup_model_provider(config: dict):
elif selected_provider == "openai-codex":
from hermes_cli.codex_models import get_codex_model_ids
codex_models = get_codex_model_ids()
codex_token = None
try:
codex_creds = resolve_codex_runtime_credentials()
codex_token = codex_creds.get("api_key")
except Exception as exc:
logger.debug("Could not resolve Codex runtime credentials for model list: %s", exc)
codex_models = get_codex_model_ids(access_token=codex_token)
model_choices = codex_models + [f"Keep current ({current_model})"]
default_codex = 0
if current_model in codex_models:
@@ -1925,7 +2004,17 @@ def setup_gateway(config: dict):
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
)
if allowed_users:
save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", ""))
# Clean up common prefixes (user:123, <@123>, <@!123>)
cleaned_ids = []
for uid in allowed_users.replace(" ", "").split(","):
uid = uid.strip()
if uid.startswith("<@") and uid.endswith(">"):
uid = uid.lstrip("<@!").rstrip(">")
if uid.lower().startswith("user:"):
uid = uid[5:]
if uid:
cleaned_ids.append(uid)
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
print_success("Discord allowlist configured")
else:
print_info(
@@ -1960,8 +2049,18 @@ def setup_gateway(config: dict):
)
allowed_users = prompt("Allowed user IDs (comma-separated)")
if allowed_users:
# Clean up common prefixes (user:123, <@123>, <@!123>)
cleaned_ids = []
for uid in allowed_users.replace(" ", "").split(","):
uid = uid.strip()
if uid.startswith("<@") and uid.endswith(">"):
uid = uid.lstrip("<@!").rstrip(">")
if uid.lower().startswith("user:"):
uid = uid[5:]
if uid:
cleaned_ids.append(uid)
save_env_value(
"DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")
"DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)
)
print_success("Discord allowlist configured")
@@ -2299,6 +2398,17 @@ def run_setup_wizard(args):
config = load_config()
hermes_home = get_hermes_home()
# Detect non-interactive environments (headless SSH, Docker, CI/CD)
non_interactive = getattr(args, 'non_interactive', False)
if not non_interactive and not is_interactive_stdin():
non_interactive = True
if non_interactive:
print_noninteractive_setup_guidance(
"Running in a non-interactive environment (no TTY detected)."
)
return
# Check if a specific section was requested
section = getattr(args, "section", None)
if section:

View File

@@ -628,3 +628,88 @@ def init_skin_from_config(config: dict) -> None:
set_active_skin(skin_name.strip())
else:
set_active_skin("default")
# =============================================================================
# Convenience helpers for CLI modules
# =============================================================================
def get_active_prompt_symbol(fallback: str = " ") -> str:
"""Get the interactive prompt symbol from the active skin."""
try:
return get_active_skin().get_branding("prompt_symbol", fallback)
except Exception:
return fallback
def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str:
"""Get the /help header from the active skin."""
try:
return get_active_skin().get_branding("help_header", fallback)
except Exception:
return fallback
def get_active_goodbye(fallback: str = "Goodbye! ⚕") -> str:
"""Get the goodbye line from the active skin."""
try:
return get_active_skin().get_branding("goodbye", fallback)
except Exception:
return fallback
def get_prompt_toolkit_style_overrides() -> Dict[str, str]:
"""Return prompt_toolkit style overrides derived from the active skin.
These are layered on top of the CLI's base TUI style so /skin can refresh
the live prompt_toolkit UI immediately without rebuilding the app.
"""
try:
skin = get_active_skin()
except Exception:
return {}
prompt = skin.get_color("prompt", "#FFF8DC")
input_rule = skin.get_color("input_rule", "#CD7F32")
title = skin.get_color("banner_title", "#FFD700")
text = skin.get_color("banner_text", prompt)
dim = skin.get_color("banner_dim", "#555555")
label = skin.get_color("ui_label", title)
warn = skin.get_color("ui_warn", "#FF8C00")
error = skin.get_color("ui_error", "#FF6B6B")
return {
"input-area": prompt,
"placeholder": f"{dim} italic",
"prompt": prompt,
"prompt-working": f"{dim} italic",
"hint": f"{dim} italic",
"input-rule": input_rule,
"image-badge": f"{label} bold",
"completion-menu": f"bg:#1a1a2e {text}",
"completion-menu.completion": f"bg:#1a1a2e {text}",
"completion-menu.completion.current": f"bg:#333355 {title}",
"completion-menu.meta.completion": f"bg:#1a1a2e {dim}",
"completion-menu.meta.completion.current": f"bg:#333355 {label}",
"clarify-border": input_rule,
"clarify-title": f"{title} bold",
"clarify-question": f"{text} bold",
"clarify-choice": dim,
"clarify-selected": f"{title} bold",
"clarify-active-other": f"{title} italic",
"clarify-countdown": input_rule,
"sudo-prompt": f"{error} bold",
"sudo-border": input_rule,
"sudo-title": f"{error} bold",
"sudo-text": text,
"approval-border": input_rule,
"approval-title": f"{warn} bold",
"approval-desc": f"{text} bold",
"approval-cmd": f"{dim} italic",
"approval-choice": dim,
"approval-selected": f"{title} bold",
}

View File

@@ -11,8 +11,11 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.colors import Colors, color
from hermes_cli.config import get_env_path, get_env_value
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
from hermes_cli.models import provider_label
from hermes_cli.runtime_provider import resolve_requested_provider
from hermes_constants import OPENROUTER_MODELS_URL
def check_mark(ok: bool) -> str:
@@ -48,6 +51,32 @@ def _format_iso_timestamp(value) -> str:
return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
def _configured_model_label(config: dict) -> str:
"""Return the configured default model from config.yaml."""
model_cfg = config.get("model")
if isinstance(model_cfg, dict):
model = (model_cfg.get("default") or model_cfg.get("name") or "").strip()
elif isinstance(model_cfg, str):
model = model_cfg.strip()
else:
model = ""
return model or "(not set)"
def _effective_provider_label() -> str:
"""Return the provider label matching current CLI runtime resolution."""
requested = resolve_requested_provider()
try:
effective = resolve_provider(requested)
except AuthError:
effective = requested or "auto"
if effective == "openrouter" and get_env_value("OPENAI_BASE_URL"):
effective = "custom"
return provider_label(effective)
def show_status(args):
"""Show status of all Hermes Agent components."""
show_all = getattr(args, 'all', False)
@@ -68,6 +97,14 @@ def show_status(args):
env_path = get_env_path()
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
try:
config = load_config()
except Exception:
config = {}
print(f" Model: {_configured_model_label(config)}")
print(f" Provider: {_effective_provider_label()}")
# =========================================================================
# API Keys
@@ -181,7 +218,6 @@ def show_status(args):
# Fall back to config file value when env var isn't set
# (hermes status doesn't go through cli.py's config loading)
try:
from hermes_cli.config import load_config
_cfg = load_config()
terminal_env = _cfg.get("terminal", {}).get("backend", "local")
except Exception:
@@ -267,7 +303,7 @@ def show_status(args):
print()
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
jobs_file = Path.home() / ".hermes" / "cron" / "jobs.json"
jobs_file = get_hermes_home() / "cron" / "jobs.json"
if jobs_file.exists():
import json
try:
@@ -287,7 +323,7 @@ def show_status(args):
print()
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
sessions_file = Path.home() / ".hermes" / "sessions" / "sessions.json"
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
if sessions_file.exists():
import json
try:

View File

@@ -267,8 +267,6 @@ class SessionDB:
if not title:
return None
import re
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
# normalized to spaces by the whitespace collapsing step below
@@ -373,7 +371,6 @@ class SessionDB:
Strips any existing " #N" suffix to find the base name, then finds
the highest existing number and increments.
"""
import re
# Strip existing #N suffix to find the true base
match = re.match(r'^(.*?) #(\d+)$', base_title)
if match:

File diff suppressed because it is too large Load Diff

View File

@@ -4,339 +4,518 @@
// --- Platform install commands ---
const PLATFORMS = {
linux: {
command: 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash',
prompt: '$',
note: 'Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically',
stepNote: 'Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.',
},
linux: {
command:
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
prompt: "$",
note: "Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically",
stepNote:
"Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.",
},
};
function detectPlatform() {
return 'linux';
return "linux";
}
function switchPlatform(platform) {
const cfg = PLATFORMS[platform];
if (!cfg) return;
const cfg = PLATFORMS[platform];
if (!cfg) return;
// Update hero install widget
const commandEl = document.getElementById('install-command');
const promptEl = document.getElementById('install-prompt');
const noteEl = document.getElementById('install-note');
// Update hero install widget
const commandEl = document.getElementById("install-command");
const promptEl = document.getElementById("install-prompt");
const noteEl = document.getElementById("install-note");
if (commandEl) commandEl.textContent = cfg.command;
if (promptEl) promptEl.textContent = cfg.prompt;
if (noteEl) noteEl.textContent = cfg.note;
if (commandEl) commandEl.textContent = cfg.command;
if (promptEl) promptEl.textContent = cfg.prompt;
if (noteEl) noteEl.textContent = cfg.note;
// Update active tab in hero
document.querySelectorAll('.install-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.platform === platform);
});
// Update active tab in hero
document.querySelectorAll(".install-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.platform === platform);
});
// Sync the step section tabs too
switchStepPlatform(platform);
// Sync the step section tabs too
switchStepPlatform(platform);
}
function switchStepPlatform(platform) {
const cfg = PLATFORMS[platform];
if (!cfg) return;
const cfg = PLATFORMS[platform];
if (!cfg) return;
const commandEl = document.getElementById('step1-command');
const copyBtn = document.getElementById('step1-copy');
const noteEl = document.getElementById('step1-note');
const commandEl = document.getElementById("step1-command");
const copyBtn = document.getElementById("step1-copy");
const noteEl = document.getElementById("step1-note");
if (commandEl) commandEl.textContent = cfg.command;
if (copyBtn) copyBtn.setAttribute('data-text', cfg.command);
if (noteEl) noteEl.textContent = cfg.stepNote;
if (commandEl) commandEl.textContent = cfg.command;
if (copyBtn) copyBtn.setAttribute("data-text", cfg.command);
if (noteEl) noteEl.textContent = cfg.stepNote;
// Update active tab in step section
document.querySelectorAll('.code-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.platform === platform);
// Update active tab in step section
document.querySelectorAll(".code-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.platform === platform);
});
}
function toggleMobileNav() {
document.getElementById("nav-mobile").classList.toggle("open");
document.getElementById("nav-hamburger").classList.toggle("open");
}
function toggleSpecs() {
const wrapper = document.getElementById("specs-wrapper");
const btn = document.getElementById("specs-toggle");
const label = btn.querySelector(".toggle-label");
const isOpen = wrapper.classList.contains("open");
if (isOpen) {
wrapper.style.maxHeight = wrapper.scrollHeight + "px";
requestAnimationFrame(() => {
wrapper.style.maxHeight = "0";
});
wrapper.classList.remove("open");
btn.classList.remove("open");
if (label) label.textContent = "More details";
} else {
wrapper.classList.add("open");
wrapper.style.maxHeight = wrapper.scrollHeight + "px";
btn.classList.add("open");
if (label) label.textContent = "Less";
wrapper.addEventListener(
"transitionend",
() => {
if (wrapper.classList.contains("open")) {
wrapper.style.maxHeight = "none";
}
},
{ once: true }
);
}
}
// --- Copy to clipboard ---
function copyInstall() {
const text = document.getElementById('install-command').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('.install-widget-body .copy-btn');
const original = btn.querySelector('.copy-text').textContent;
btn.querySelector('.copy-text').textContent = 'Copied!';
btn.style.color = 'var(--gold)';
setTimeout(() => {
btn.querySelector('.copy-text').textContent = original;
btn.style.color = '';
}, 2000);
});
const text = document.getElementById("install-command").textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector(".install-widget-body .copy-btn");
const original = btn.querySelector(".copy-text").textContent;
btn.querySelector(".copy-text").textContent = "Copied!";
btn.style.color = "var(--primary-light)";
setTimeout(() => {
btn.querySelector(".copy-text").textContent = original;
btn.style.color = "";
}, 2000);
});
}
function copyText(btn) {
const text = btn.getAttribute('data-text');
navigator.clipboard.writeText(text).then(() => {
const original = btn.textContent;
btn.textContent = 'Copied!';
btn.style.color = 'var(--gold)';
setTimeout(() => {
btn.textContent = original;
btn.style.color = '';
}, 2000);
});
const text = btn.getAttribute("data-text");
navigator.clipboard.writeText(text).then(() => {
const original = btn.textContent;
btn.textContent = "Copied!";
btn.style.color = "var(--primary-light)";
setTimeout(() => {
btn.textContent = original;
btn.style.color = "";
}, 2000);
});
}
// --- Scroll-triggered fade-in ---
function initScrollAnimations() {
const elements = document.querySelectorAll(
'.feature-card, .tool-pill, .platform-group, .skill-category, ' +
'.install-step, .research-card, .footer-card, .section-header, ' +
'.lead-text, .section-desc, .terminal-window'
);
const elements = document.querySelectorAll(
".feature-card, .install-step, " +
".section-header, .terminal-window",
);
elements.forEach(el => el.classList.add('fade-in'));
elements.forEach((el) => el.classList.add("fade-in"));
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Stagger children within grids
const parent = entry.target.parentElement;
if (parent) {
const siblings = parent.querySelectorAll('.fade-in');
let idx = Array.from(siblings).indexOf(entry.target);
if (idx < 0) idx = 0;
setTimeout(() => {
entry.target.classList.add('visible');
}, idx * 60);
} else {
entry.target.classList.add('visible');
}
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Stagger children within grids
const parent = entry.target.parentElement;
if (parent) {
const siblings = parent.querySelectorAll(".fade-in");
let idx = Array.from(siblings).indexOf(entry.target);
if (idx < 0) idx = 0;
setTimeout(() => {
entry.target.classList.add("visible");
}, idx * 60);
} else {
entry.target.classList.add("visible");
}
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" },
);
elements.forEach(el => observer.observe(el));
elements.forEach((el) => observer.observe(el));
}
// --- Terminal Demo ---
const CURSOR = '<span class="terminal-cursor">█</span>';
const demoSequence = [
// Scene 1: Research task with delegation
{ type: 'prompt', text: ' ' },
{ type: 'type', text: 'Research the latest approaches to GRPO training and write a summary', delay: 30 },
{ type: 'pause', ms: 600 },
{ type: 'output', lines: [
'',
'<span class="t-dim">┊ 🔍 web_search "GRPO reinforcement learning 2026" 1.2s</span>',
]},
{ type: 'pause', ms: 400 },
{ type: 'output', lines: [
'<span class="t-dim">┊ 📄 web_extract arxiv.org/abs/2402.03300 3.1s</span>',
]},
{ type: 'pause', ms: 400 },
{ type: 'output', lines: [
'<span class="t-dim">┊ 🔍 web_search "GRPO vs PPO ablation results" 0.9s</span>',
]},
{ type: 'pause', ms: 400 },
{ type: 'output', lines: [
'<span class="t-dim">┊ 📄 web_extract huggingface.co/blog/grpo 2.8s</span>',
]},
{ type: 'pause', ms: 400 },
{ type: 'output', lines: [
'<span class="t-dim">┊ ✍️ write_file ~/research/grpo-summary.md 0.1s</span>',
]},
{ type: 'pause', ms: 500 },
{ type: 'output', lines: [
'',
'<span class="t-text">Done! I\'ve written a summary covering:</span>',
'',
'<span class="t-text"> <span class="t-green">✓</span> GRPO\'s group-relative advantage (no critic model needed)</span>',
'<span class="t-text"> <span class="t-green">✓</span> Comparison with PPO/DPO on reasoning benchmarks</span>',
'<span class="t-text"> <span class="t-green">✓</span> Implementation notes for Axolotl and TRL</span>',
'',
'<span class="t-text">Saved to</span> <span class="t-amber">~/research/grpo-summary.md</span>',
]},
{ type: 'pause', ms: 2500 },
{ type: "prompt", text: " " },
{
type: "type",
text: "Research the latest approaches to GRPO training and write a summary",
delay: 30,
},
{ type: "pause", ms: 600 },
{
type: "output",
lines: [
"",
'<span class="t-dim"> web_search "GRPO reinforcement learning 2026" 1.2s</span>',
],
},
{ type: "pause", ms: 400 },
{
type: "output",
lines: [
'<span class="t-dim"> web_extract arxiv.org/abs/2402.03300 3.1s</span>',
],
},
{ type: "pause", ms: 400 },
{
type: "output",
lines: [
'<span class="t-dim"> web_search "GRPO vs PPO ablation results" 0.9s</span>',
],
},
{ type: "pause", ms: 400 },
{
type: "output",
lines: [
'<span class="t-dim"> web_extract huggingface.co/blog/grpo 2.8s</span>',
],
},
{ type: "pause", ms: 400 },
{
type: "output",
lines: [
'<span class="t-dim"> write_file ~/research/grpo-summary.md 0.1s</span>',
],
},
{ type: "pause", ms: 500 },
{
type: "output",
lines: [
"",
'<span class="t-text">Done! I\'ve written a summary covering:</span>',
"",
'<span class="t-text"> <span class="t-green">✓</span> GRPO\'s group-relative advantage (no critic model needed)</span>',
'<span class="t-text"> <span class="t-green">✓</span> Comparison with PPO/DPO on reasoning benchmarks</span>',
'<span class="t-text"> <span class="t-green">✓</span> Implementation notes for Axolotl and TRL</span>',
"",
'<span class="t-text">Saved to</span> <span class="t-accent">~/research/grpo-summary.md</span>',
],
},
{ type: "pause", ms: 2500 },
// Scene 2: Quick delegation
{ type: 'clear' },
{ type: 'prompt', text: ' ' },
{ type: 'type', text: 'Review the PR at NousResearch/hermes-agent#42 and fix any issues', delay: 30 },
{ type: 'pause', ms: 600 },
{ type: 'output', lines: [
'',
'<span class="t-dim">┊ 🔀 delegate_task "review PR #42 changes" 2.1s</span>',
]},
{ type: 'pause', ms: 500 },
{ type: 'output', lines: [
'<span class="t-dim">┊ 💻 git diff main..pr-42 0.4s</span>',
]},
{ type: 'pause', ms: 400 },
{ type: 'output', lines: [
'<span class="t-dim">┊ ✏️ patch tools/registry.py 0.1s</span>',
]},
{ type: 'pause', ms: 400 },
{ type: 'output', lines: [
'<span class="t-dim">┊ 💻 python -m pytest tests/ -x 3.2s</span>',
]},
{ type: 'pause', ms: 400 },
{ type: 'output', lines: [
'<span class="t-dim">┊ 💻 git commit -m "fix: handle empty tool schemas" 0.3s</span>',
]},
{ type: 'pause', ms: 500 },
{ type: 'output', lines: [
'',
'<span class="t-text">Found 2 issues in the PR and fixed both:</span>',
'',
'<span class="t-text"> <span class="t-green">✓</span> Empty tool schema crash in registry.py — added guard</span>',
'<span class="t-text"> <span class="t-green">✓</span> Missing error handling in delegate_tool.py — added try/except</span>',
'',
'<span class="t-text">Tests pass. Committed the fix and pushed to the PR branch.</span>',
'<span class="t-text">I also saved a</span> <span class="t-amber">skill</span> <span class="t-text">for this PR review pattern.</span>',
]},
{ type: 'pause', ms: 2500 },
{ type: "clear" },
{ type: "prompt", text: " " },
{
type: "type",
text: "Review the PR at NousResearch/hermes-agent#42 and fix any issues",
delay: 30,
},
{ type: "pause", ms: 600 },
{
type: "output",
lines: [
"",
'<span class="t-dim"> delegate_task "review PR #42 changes" 2.1s</span>',
],
},
{ type: "pause", ms: 500 },
{
type: "output",
lines: [
'<span class="t-dim"> git diff main..pr-42 0.4s</span>',
],
},
{ type: "pause", ms: 400 },
{
type: "output",
lines: [
'<span class="t-dim"> patch tools/registry.py 0.1s</span>',
],
},
{ type: "pause", ms: 400 },
{
type: "output",
lines: [
'<span class="t-dim"> python -m pytest tests/ -x 3.2s</span>',
],
},
{ type: "pause", ms: 400 },
{
type: "output",
lines: [
'<span class="t-dim"> git commit -m "fix: handle empty tool schemas" 0.3s</span>',
],
},
{ type: "pause", ms: 500 },
{
type: "output",
lines: [
"",
'<span class="t-text">Found 2 issues in the PR and fixed both:</span>',
"",
'<span class="t-text"> <span class="t-green">✓</span> Empty tool schema crash in registry.py — added guard</span>',
'<span class="t-text"> <span class="t-green">✓</span> Missing error handling in delegate_tool.py — added try/except</span>',
"",
'<span class="t-text">Tests pass. Committed the fix and pushed to the PR branch.</span>',
'<span class="t-text">I also saved a</span> <span class="t-accent">skill</span> <span class="t-text">for this PR review pattern.</span>',
],
},
{ type: "pause", ms: 2500 },
// Scene 3: Session search by keyword
{ type: 'clear' },
{ type: 'prompt', text: ' ' },
{ type: 'type', text: 'How did we fix that Docker networking issue?', delay: 35 },
{ type: 'pause', ms: 500 },
{ type: 'output', lines: [
'',
'<span class="t-dim">┊ 🔎 session_search "Docker networking" 1.4s</span>',
]},
{ type: 'pause', ms: 500 },
{ type: 'output', lines: [
'',
'<span class="t-text">Found it — from a session on February 12th:</span>',
'',
'<span class="t-text">The containers couldn\'t reach each other because the compose</span>',
'<span class="t-text">file was using the default bridge network. We switched to a</span>',
'<span class="t-text">custom network with</span> <span class="t-amber">driver: overlay</span><span class="t-text">, added explicit</span>',
'<span class="t-text">aliases, and set</span> <span class="t-amber">dns: 8.8.8.8</span> <span class="t-text">as a fallback.</span>',
'',
'<span class="t-text">The fix was committed in</span> <span class="t-amber">docker-compose.prod.yml</span><span class="t-text">.</span>',
]},
{ type: 'pause', ms: 3000 },
{ type: "clear" },
{ type: "prompt", text: " " },
{
type: "type",
text: "How did we fix that Docker networking issue?",
delay: 35,
},
{ type: "pause", ms: 500 },
{
type: "output",
lines: [
"",
'<span class="t-dim"> session_search "Docker networking" 1.4s</span>',
],
},
{ type: "pause", ms: 500 },
{
type: "output",
lines: [
"",
'<span class="t-text">Found it — from a session on February 12th:</span>',
"",
'<span class="t-text">The containers couldn\'t reach each other because the compose</span>',
'<span class="t-text">file was using the default bridge network. We switched to a</span>',
'<span class="t-text">custom network with</span> <span class="t-accent">driver: overlay</span><span class="t-text">, added explicit</span>',
'<span class="t-text">aliases, and set</span> <span class="t-accent">dns: 8.8.8.8</span> <span class="t-text">as a fallback.</span>',
"",
'<span class="t-text">The fix was committed in</span> <span class="t-accent">docker-compose.prod.yml</span><span class="t-text">.</span>',
],
},
{ type: "pause", ms: 3000 },
];
class TerminalDemo {
constructor(element, cursorElement) {
this.el = element;
this.cursor = cursorElement;
this.running = false;
this.content = '';
this.observer = null;
}
constructor(container) {
this.container = container;
this.running = false;
this.content = "";
}
async start() {
if (this.running) return;
this.running = true;
while (this.running) {
for (const step of demoSequence) {
if (!this.running) return;
await this.execute(step);
}
// Loop
this.clear();
await this.sleep(1000);
async start() {
if (this.running) return;
this.running = true;
while (this.running) {
for (const step of demoSequence) {
if (!this.running) return;
await this.execute(step);
}
this.clear();
await this.sleep(1000);
}
}
stop() {
this.running = false;
}
async execute(step) {
switch (step.type) {
case "prompt":
this.append(`<span class="t-prompt">${step.text}</span>`);
break;
case "type":
for (const char of step.text) {
if (!this.running) return;
this.append(`<span class="t-cmd">${char}</span>`);
await this.sleep(step.delay || 30);
}
}
stop() {
this.running = false;
}
async execute(step) {
switch (step.type) {
case 'prompt':
this.append(`<span class="t-prompt">${step.text}</span>`);
break;
case 'type':
for (const char of step.text) {
if (!this.running) return;
this.append(`<span class="t-cmd">${char}</span>`);
await this.sleep(step.delay || 30);
}
break;
case 'output':
for (const line of step.lines) {
if (!this.running) return;
this.append('\n' + line);
await this.sleep(50);
}
break;
case 'pause':
await this.sleep(step.ms);
break;
case 'clear':
this.clear();
break;
break;
case "output":
for (const line of step.lines) {
if (!this.running) return;
this.append("\n" + line);
await this.sleep(50);
}
break;
case "pause":
await this.sleep(step.ms);
break;
case "clear":
this.clear();
break;
}
}
append(html) {
this.content += html;
this.el.innerHTML = this.content;
// Keep cursor at end
this.el.parentElement.scrollTop = this.el.parentElement.scrollHeight;
}
append(html) {
this.content += html;
this.render();
}
clear() {
this.content = '';
this.el.innerHTML = '';
}
render() {
this.container.innerHTML = this.content + CURSOR;
this.container.scrollTop = this.container.scrollHeight;
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
clear() {
this.content = "";
this.container.innerHTML = "";
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// --- Noise Overlay (ported from hermes-chat NoiseOverlay) ---
function initNoiseOverlay() {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (typeof THREE === "undefined") return;
const canvas = document.getElementById("noise-overlay");
if (!canvas) return;
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform vec2 uRes;
uniform float uDpr, uSize, uDensity, uOpacity;
uniform vec3 uColor;
varying vec2 vUv;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main() {
float n = hash(floor(vUv * uRes / (uSize * uDpr)));
gl_FragColor = vec4(uColor, step(1.0 - uDensity, n)) * uOpacity;
}
`;
function hexToVec3(hex) {
const c = hex.replace("#", "");
return new THREE.Vector3(
parseInt(c.substring(0, 2), 16) / 255,
parseInt(c.substring(2, 4), 16) / 255,
parseInt(c.substring(4, 6), 16) / 255,
);
}
const renderer = new THREE.WebGLRenderer({
alpha: true,
canvas,
premultipliedAlpha: false,
});
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const geo = new THREE.PlaneGeometry(2, 2);
const mat = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
uniforms: {
uColor: { value: hexToVec3("#8090BB") },
uDensity: { value: 0.1 },
uDpr: { value: 1 },
uOpacity: { value: 0.4 },
uRes: { value: new THREE.Vector2() },
uSize: { value: 1.0 },
},
});
scene.add(new THREE.Mesh(geo, mat));
function resize() {
const dpr = window.devicePixelRatio;
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setSize(w, h);
renderer.setPixelRatio(dpr);
mat.uniforms.uRes.value.set(w * dpr, h * dpr);
mat.uniforms.uDpr.value = dpr;
}
resize();
window.addEventListener("resize", resize);
function loop() {
requestAnimationFrame(loop);
renderer.render(scene, camera);
}
loop();
}
// --- Initialize ---
document.addEventListener('DOMContentLoaded', () => {
// Auto-detect platform and set the right install command
const detectedPlatform = detectPlatform();
switchPlatform(detectedPlatform);
document.addEventListener("DOMContentLoaded", () => {
const detectedPlatform = detectPlatform();
switchPlatform(detectedPlatform);
initScrollAnimations();
initScrollAnimations();
initNoiseOverlay();
// Terminal demo - start when visible
const terminalEl = document.getElementById('terminal-content');
const cursorEl = document.getElementById('terminal-cursor');
if (terminalEl && cursorEl) {
const demo = new TerminalDemo(terminalEl, cursorEl);
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
demo.start();
} else {
demo.stop();
}
});
}, { threshold: 0.3 });
const terminalEl = document.getElementById("terminal-demo");
observer.observe(document.querySelector('.terminal-window'));
}
if (terminalEl) {
const demo = new TerminalDemo(terminalEl);
// Smooth nav background on scroll
const nav = document.querySelector('.nav');
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
if (window.scrollY > 50) {
nav.style.borderBottomColor = 'rgba(255, 215, 0, 0.1)';
} else {
nav.style.borderBottomColor = '';
}
ticking = false;
});
ticking = true;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
demo.start();
} else {
demo.stop();
}
});
},
{ threshold: 0.3 },
);
observer.observe(document.querySelector(".terminal-window"));
}
const nav = document.querySelector(".nav");
let ticking = false;
window.addEventListener("scroll", () => {
if (!ticking) {
requestAnimationFrame(() => {
if (window.scrollY > 50) {
nav.style.borderBottomColor = "rgba(48, 80, 255, 0.15)";
} else {
nav.style.borderBottomColor = "";
}
});
ticking = false;
});
ticking = true;
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -42,10 +42,11 @@ from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Add mini-swe-agent to path if not installed
mini_swe_path = Path(__file__).parent / "mini-swe-agent" / "src"
if mini_swe_path.exists():
sys.path.insert(0, str(mini_swe_path))
# Add mini-swe-agent to path if not installed. In git worktrees the populated
# submodule may live in the main checkout rather than the worktree itself.
from minisweagent_path import ensure_minisweagent_on_path
ensure_minisweagent_on_path(Path(__file__).resolve().parent)
# ============================================================================

92
minisweagent_path.py Normal file
View File

@@ -0,0 +1,92 @@
"""Helpers for locating the mini-swe-agent source tree.
Hermes often runs from git worktrees. In that layout the worktree root may have
an empty ``mini-swe-agent/`` placeholder while the real populated submodule
lives under the main checkout that owns the shared ``.git`` directory.
These helpers locate a usable ``mini-swe-agent/src`` directory and optionally
prepend it to ``sys.path`` so imports like ``import minisweagent`` work from
both normal checkouts and worktrees.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from typing import Optional
def _read_gitdir(repo_root: Path) -> Optional[Path]:
"""Resolve the gitdir referenced by ``repo_root/.git`` when it is a file."""
git_marker = repo_root / ".git"
if not git_marker.is_file():
return None
try:
raw = git_marker.read_text(encoding="utf-8").strip()
except OSError:
return None
prefix = "gitdir:"
if not raw.lower().startswith(prefix):
return None
target = raw[len(prefix):].strip()
gitdir = Path(target)
if not gitdir.is_absolute():
gitdir = (repo_root / gitdir).resolve()
else:
gitdir = gitdir.resolve()
return gitdir
def discover_minisweagent_src(repo_root: Optional[Path] = None) -> Optional[Path]:
"""Return the best available ``mini-swe-agent/src`` path, if any.
Search order:
1. Current checkout/worktree root
2. Main checkout that owns the shared ``.git`` directory (for worktrees)
"""
repo_root = (repo_root or Path(__file__).resolve().parent).resolve()
candidates: list[Path] = [repo_root / "mini-swe-agent" / "src"]
gitdir = _read_gitdir(repo_root)
if gitdir is not None:
# Worktree layout: <main>/.git/worktrees/<name>
if len(gitdir.parents) >= 3 and gitdir.parent.name == "worktrees":
candidates.append(gitdir.parents[2] / "mini-swe-agent" / "src")
# Direct checkout with .git file pointing elsewhere
elif gitdir.name == ".git":
candidates.append(gitdir.parent / "mini-swe-agent" / "src")
seen = set()
for candidate in candidates:
candidate = candidate.resolve()
if candidate in seen:
continue
seen.add(candidate)
if candidate.exists() and candidate.is_dir():
return candidate
return None
def ensure_minisweagent_on_path(repo_root: Optional[Path] = None) -> Optional[Path]:
"""Ensure ``minisweagent`` is importable by prepending its src dir to sys.path.
Returns the inserted/discovered path, or ``None`` if the package is already
importable or no local source tree could be found.
"""
if importlib.util.find_spec("minisweagent") is not None:
return None
src = discover_minisweagent_src(repo_root)
if src is None:
return None
src_str = str(src)
if src_str not in sys.path:
sys.path.insert(0, src_str)
return src

View File

@@ -0,0 +1,417 @@
---
name: telephony
description: Give Hermes phone capabilities without core tool changes. Provision and persist a Twilio number, send and receive SMS/MMS, make direct calls, and place AI-driven outbound calls through Bland.ai or Vapi.
version: 1.0.0
author: Nous Research
license: MIT
metadata:
hermes:
tags: [telephony, phone, sms, mms, voice, twilio, bland.ai, vapi, calling, texting]
related_skills: [find-nearby, google-workspace, agentmail]
category: productivity
---
# Telephony — Numbers, Calls, and Texts without Core Tool Changes
This optional skill gives Hermes practical phone capabilities while keeping telephony out of the core tool list.
It ships with a helper script, `scripts/telephony.py`, that can:
- save provider credentials into `~/.hermes/.env`
- search for and buy a Twilio phone number
- remember that owned number for later sessions
- send SMS / MMS from the owned number
- poll inbound SMS for that number with no webhook server required
- make direct Twilio calls using TwiML `<Say>` or `<Play>`
- import the owned Twilio number into Vapi
- place outbound AI calls through Bland.ai or Vapi
## What this solves
This skill is meant to cover the practical phone tasks users actually want:
- outbound calls
- texting
- owning a reusable agent number
- checking messages that arrive to that number later
- preserving that number and related IDs between sessions
- future-friendly telephony identity for inbound SMS polling and other automations
It does **not** turn Hermes into a real-time inbound phone gateway. Inbound SMS is handled by polling the Twilio REST API. That is enough for many workflows, including notifications and some one-time-code retrieval, without adding core webhook infrastructure.
## Safety rules — mandatory
1. Always confirm before placing a call or sending a text.
2. Never dial emergency numbers.
3. Never use telephony for harassment, spam, impersonation, or anything illegal.
4. Treat third-party phone numbers as sensitive operational data:
- do not save them to Hermes memory
- do not include them in skill docs, summaries, or follow-up notes unless the user explicitly wants that
5. It is fine to persist the **agent-owned Twilio number** because that is part of the user's configuration.
6. VoIP numbers are **not guaranteed** to work for all third-party 2FA flows. Use with caution and set user expectations clearly.
## Decision tree — which service to use?
Use this logic instead of hardcoded provider routing:
### 1) "I want Hermes to own a real phone number"
Use **Twilio**.
Why:
- easiest path to buying and keeping a number
- best SMS / MMS support
- simplest inbound SMS polling story
- cleanest future path to inbound webhooks or call handling
Use cases:
- receive texts later
- send deployment alerts / cron notifications
- maintain a reusable phone identity for the agent
- experiment with phone-based auth flows later
### 2) "I only need the easiest outbound AI phone call right now"
Use **Bland.ai**.
Why:
- quickest setup
- one API key
- no need to first buy/import a number yourself
Tradeoff:
- less flexible
- voice quality is decent, but not the best
### 3) "I want the best conversational AI voice quality"
Use **Twilio + Vapi**.
Why:
- Twilio gives you the owned number
- Vapi gives you better conversational AI call quality and more voice/model flexibility
Recommended flow:
1. Buy/save a Twilio number
2. Import it into Vapi
3. Save the returned `VAPI_PHONE_NUMBER_ID`
4. Use `ai-call --provider vapi`
### 4) "I want to call with a custom prerecorded voice message"
Use **Twilio direct call** with a public audio URL.
Why:
- easiest way to play a custom MP3
- pairs well with Hermes `text_to_speech` plus a public file host or tunnel
## Files and persistent state
The skill persists telephony state in two places:
### `~/.hermes/.env`
Used for long-lived provider credentials and owned-number IDs, for example:
- `TWILIO_ACCOUNT_SID`
- `TWILIO_AUTH_TOKEN`
- `TWILIO_PHONE_NUMBER`
- `TWILIO_PHONE_NUMBER_SID`
- `BLAND_API_KEY`
- `VAPI_API_KEY`
- `VAPI_PHONE_NUMBER_ID`
- `PHONE_PROVIDER` (AI call provider: bland or vapi)
### `~/.hermes/telephony_state.json`
Used for skill-only state that should survive across sessions, for example:
- remembered default Twilio number / SID
- remembered Vapi phone number ID
- last inbound message SID/date for inbox polling checkpoints
This means:
- the next time the skill is loaded, `diagnose` can tell you what number is already configured
- `twilio-inbox --since-last --mark-seen` can continue from the previous checkpoint
## Locate the helper script
After installing this skill, locate the script like this:
```bash
SCRIPT="$(find ~/.hermes/skills -path '*/telephony/scripts/telephony.py' -print -quit)"
```
If `SCRIPT` is empty, the skill is not installed yet.
## Install
This is an official optional skill, so install it from the Skills Hub:
```bash
hermes skills search telephony
hermes skills install official/productivity/telephony
```
## Provider setup
### Twilio — owned number, SMS/MMS, direct calls, inbound SMS polling
Sign up at:
- https://www.twilio.com/try-twilio
Then save credentials into Hermes:
```bash
python3 "$SCRIPT" save-twilio ACXXXXXXXXXXXXXXXXXXXXXXXXXXXX your_auth_token_here
```
Search for available numbers:
```bash
python3 "$SCRIPT" twilio-search --country US --area-code 702 --limit 5
```
Buy and remember a number:
```bash
python3 "$SCRIPT" twilio-buy "+17025551234" --save-env
```
List owned numbers:
```bash
python3 "$SCRIPT" twilio-owned
```
Set one of them as the default later:
```bash
python3 "$SCRIPT" twilio-set-default "+17025551234" --save-env
# or
python3 "$SCRIPT" twilio-set-default PNXXXXXXXXXXXXXXXXXXXXXXXXXXXX --save-env
```
### Bland.ai — easiest outbound AI calling
Sign up at:
- https://app.bland.ai
Save config:
```bash
python3 "$SCRIPT" save-bland your_bland_api_key --voice mason
```
### Vapi — better conversational voice quality
Sign up at:
- https://dashboard.vapi.ai
Save the API key first:
```bash
python3 "$SCRIPT" save-vapi your_vapi_api_key
```
Import your owned Twilio number into Vapi and persist the returned phone number ID:
```bash
python3 "$SCRIPT" vapi-import-twilio --save-env
```
If you already know the Vapi phone number ID, save it directly:
```bash
python3 "$SCRIPT" save-vapi your_vapi_api_key --phone-number-id vapi_phone_number_id_here
```
## Diagnose current state
At any time, inspect what the skill already knows:
```bash
python3 "$SCRIPT" diagnose
```
Use this first when resuming work in a later session.
## Common workflows
### A. Buy an agent number and keep using it later
1. Save Twilio credentials:
```bash
python3 "$SCRIPT" save-twilio AC... auth_token_here
```
2. Search for a number:
```bash
python3 "$SCRIPT" twilio-search --country US --area-code 702 --limit 10
```
3. Buy it and save it into `~/.hermes/.env` + state:
```bash
python3 "$SCRIPT" twilio-buy "+17025551234" --save-env
```
4. Next session, run:
```bash
python3 "$SCRIPT" diagnose
```
This shows the remembered default number and inbox checkpoint state.
### B. Send a text from the agent number
```bash
python3 "$SCRIPT" twilio-send-sms "+15551230000" "Your deployment completed successfully."
```
With media:
```bash
python3 "$SCRIPT" twilio-send-sms "+15551230000" "Here is the chart." --media-url "https://example.com/chart.png"
```
### C. Check inbound texts later with no webhook server
Poll the inbox for the default Twilio number:
```bash
python3 "$SCRIPT" twilio-inbox --limit 20
```
Only show messages that arrived after the last checkpoint, and advance the checkpoint when you're done reading:
```bash
python3 "$SCRIPT" twilio-inbox --since-last --mark-seen
```
This is the main answer to “how do I access messages the number receives next time the skill is loaded?”
### D. Make a direct Twilio call with built-in TTS
```bash
python3 "$SCRIPT" twilio-call "+15551230000" --message "Hello! This is Hermes calling with your status update." --voice Polly.Joanna
```
### E. Call with a prerecorded / custom voice message
This is the main path for reusing Hermes's existing `text_to_speech` support.
Use this when:
- you want the call to use Hermes's configured TTS voice rather than Twilio `<Say>`
- you want a one-way voice delivery (briefing, alert, joke, reminder, status update)
- you do **not** need a live conversational phone call
Generate or host audio separately, then:
```bash
python3 "$SCRIPT" twilio-call "+155****0000" --audio-url "https://example.com/briefing.mp3"
```
Recommended Hermes TTS -> Twilio Play workflow:
1. Generate the audio with Hermes `text_to_speech`.
2. Make the resulting MP3 publicly reachable.
3. Place the Twilio call with `--audio-url`.
Example agent flow:
- Ask Hermes to create the message audio with `text_to_speech`
- If needed, expose the file with a temporary static host / tunnel / object storage URL
- Use `twilio-call --audio-url ...` to deliver it by phone
Good hosting options for the MP3:
- a temporary public object/storage URL
- a short-lived tunnel to a local static file server
- any existing HTTPS URL the phone provider can fetch directly
Important note:
- Hermes TTS is great for prerecorded outbound messages
- Bland/Vapi are better for **live conversational AI calls** because they handle the real-time telephony audio stack themselves
- Hermes STT/TTS alone is not being used here as a full duplex phone conversation engine; that would require a much heavier streaming/webhook integration than this skill is trying to introduce
### F. Navigate a phone tree / IVR with Twilio direct calling
If you need to press digits after the call connects, use `--send-digits`.
Twilio interprets `w` as a short wait.
```bash
python3 "$SCRIPT" twilio-call "+18005551234" --message "Connecting to billing now." --send-digits "ww1w2w3"
```
This is useful for reaching a specific menu branch before handing off to a human or delivering a short status message.
### G. Outbound AI phone call with Bland.ai
```bash
python3 "$SCRIPT" ai-call "+15551230000" "Call the dental office, ask for a cleaning appointment on Tuesday afternoon, and if they do not have Tuesday availability, ask for Wednesday or Thursday instead." --provider bland --voice mason --max-duration 3
```
Check status:
```bash
python3 "$SCRIPT" ai-status <call_id> --provider bland
```
Ask Bland analysis questions after completion:
```bash
python3 "$SCRIPT" ai-status <call_id> --provider bland --analyze "Was the appointment confirmed?,What date and time?,Any special instructions?"
```
### H. Outbound AI phone call with Vapi on your owned number
1. Import your Twilio number into Vapi:
```bash
python3 "$SCRIPT" vapi-import-twilio --save-env
```
2. Place the call:
```bash
python3 "$SCRIPT" ai-call "+15551230000" "You are calling to make a dinner reservation for two at 7:30 PM. If that is unavailable, ask for the nearest time between 6:30 and 8:30 PM." --provider vapi --max-duration 4
```
3. Check result:
```bash
python3 "$SCRIPT" ai-status <call_id> --provider vapi
```
## Suggested agent procedure
When the user asks for a call or text:
1. Determine which path fits the request via the decision tree.
2. Run `diagnose` if configuration state is unclear.
3. Gather the full task details.
4. Confirm with the user before dialing or texting.
5. Use the correct command.
6. Poll for results if needed.
7. Summarize the outcome without persisting third-party numbers to Hermes memory.
## What this skill still does not do
- real-time inbound call answering
- webhook-based live SMS push into the agent loop
- guaranteed support for arbitrary third-party 2FA providers
Those would require more infrastructure than a pure optional skill.
## Pitfalls
- Twilio trial accounts and regional rules can restrict who you can call/text.
- Some services reject VoIP numbers for 2FA.
- `twilio-inbox` polls the REST API; it is not instant push delivery.
- Vapi outbound calling still depends on having a valid imported number.
- Bland is easiest, but not always the best-sounding.
- Do not store arbitrary third-party phone numbers in Hermes memory.
## Verification checklist
After setup, you should be able to do all of the following with just this skill:
1. `diagnose` shows provider readiness and remembered state
2. search and buy a Twilio number
3. persist that number to `~/.hermes/.env`
4. send an SMS from the owned number
5. poll inbound texts for the owned number later
6. place a direct Twilio call
7. place an AI call via Bland or Vapi
## References
- Twilio phone numbers: https://www.twilio.com/docs/phone-numbers/api
- Twilio messaging: https://www.twilio.com/docs/messaging/api/message-resource
- Twilio voice: https://www.twilio.com/docs/voice/api/call-resource
- Vapi docs: https://docs.vapi.ai/
- Bland.ai: https://app.bland.ai/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
---
name: 1password
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands.
version: 1.0.0
author: arceus77-7, enhanced by Hermes Agent
license: MIT
metadata:
hermes:
tags: [security, secrets, 1password, op, cli]
category: security
setup:
help: "Create a service account at https://my.1password.com → Settings → Service Accounts"
collect_secrets:
- env_var: OP_SERVICE_ACCOUNT_TOKEN
prompt: "1Password Service Account Token"
provider_url: "https://developer.1password.com/docs/service-accounts/"
secret: true
---
# 1Password CLI
Use this skill when the user wants secrets managed through 1Password instead of plaintext env vars or files.
## Requirements
- 1Password account
- 1Password CLI (`op`) installed
- One of: desktop app integration, service account token (`OP_SERVICE_ACCOUNT_TOKEN`), or Connect server
- `tmux` available for stable authenticated sessions during Hermes terminal calls (desktop app flow only)
## When to Use
- Install or configure 1Password CLI
- Sign in with `op signin`
- Read secret references like `op://Vault/Item/field`
- Inject secrets into config/templates using `op inject`
- Run commands with secret env vars via `op run`
## Authentication Methods
### Service Account (recommended for Hermes)
Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load).
No desktop app needed. Supports `op read`, `op inject`, `op run`.
```bash
export OP_SERVICE_ACCOUNT_TOKEN="your-token-here"
op whoami # verify — should show Type: SERVICE_ACCOUNT
```
### Desktop App Integration (interactive)
1. Enable in 1Password desktop app: Settings → Developer → Integrate with 1Password CLI
2. Ensure app is unlocked
3. Run `op signin` and approve the biometric prompt
### Connect Server (self-hosted)
```bash
export OP_CONNECT_HOST="http://localhost:8080"
export OP_CONNECT_TOKEN="your-connect-token"
```
## Setup
1. Install CLI:
```bash
# macOS
brew install 1password-cli
# Linux (official package/install docs)
# See references/get-started.md for distro-specific links.
# Windows (winget)
winget install AgileBits.1Password.CLI
```
2. Verify:
```bash
op --version
```
3. Choose an auth method above and configure it.
## Hermes Execution Pattern (desktop app flow)
Hermes terminal commands are non-interactive by default and can lose auth context between calls.
For reliable `op` use with desktop app integration, run sign-in and secret operations inside a dedicated tmux session.
Note: This is NOT needed when using `OP_SERVICE_ACCOUNT_TOKEN` — the token persists across terminal calls automatically.
```bash
SOCKET_DIR="${TMPDIR:-/tmp}/hermes-tmux-sockets"
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/hermes-op.sock"
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
# Sign in (approve in desktop app when prompted)
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "eval \"\$(op signin --account my.1password.com)\"" Enter
# Verify auth
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
# Example read
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op read 'op://Private/Npmjs/one-time password?attribute=otp'" Enter
# Capture output when needed
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
# Cleanup
tmux -S "$SOCKET" kill-session -t "$SESSION"
```
## Common Operations
### Read a secret
```bash
op read "op://app-prod/db/password"
```
### Get OTP
```bash
op read "op://app-prod/npm/one-time password?attribute=otp"
```
### Inject into template
```bash
echo "db_password: {{ op://app-prod/db/password }}" | op inject
```
### Run a command with secret env var
```bash
export DB_PASSWORD="op://app-prod/db/password"
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set" || echo "DB_PASSWORD missing"'
```
## Guardrails
- Never print raw secrets back to user unless they explicitly request the value.
- Prefer `op run` / `op inject` instead of writing secrets into files.
- If command fails with "account is not signed in", run `op signin` again in the same tmux session.
- If desktop app integration is unavailable (headless/CI), use service account token flow.
## CI / Headless note
For non-interactive use, authenticate with `OP_SERVICE_ACCOUNT_TOKEN` and avoid interactive `op signin`.
Service accounts require CLI v2.18.0+.
## References
- `references/get-started.md`
- `references/cli-examples.md`
- https://developer.1password.com/docs/cli/
- https://developer.1password.com/docs/service-accounts/

View File

@@ -0,0 +1,31 @@
# op CLI examples
## Sign-in and identity
```bash
op signin
op signin --account my.1password.com
op whoami
op account list
```
## Read secrets
```bash
op read "op://app-prod/db/password"
op read "op://app-prod/npm/one-time password?attribute=otp"
```
## Inject secrets
```bash
echo "api_key: {{ op://app-prod/openai/api key }}" | op inject
op inject -i config.tpl.yml -o config.yml
```
## Run command with secrets
```bash
export DB_PASSWORD="op://app-prod/db/password"
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set"'
```

View File

@@ -0,0 +1,21 @@
# 1Password CLI get-started (summary)
Official docs: https://developer.1password.com/docs/cli/get-started/
## Core flow
1. Install `op` CLI.
2. Enable desktop app integration in 1Password app.
3. Unlock app.
4. Run `op signin` and approve prompt.
5. Verify with `op whoami`.
## Multiple accounts
- Use `op signin --account <subdomain.1password.com>`
- Or set `OP_ACCOUNT`
## Non-interactive / automation
- Use service accounts and `OP_SERVICE_ACCOUNT_TOKEN`
- Prefer `op run` and `op inject` for runtime secret handling

View File

@@ -0,0 +1,3 @@
# Security
Skills for secrets management, credential handling, and security tooling integrations.

View File

@@ -30,6 +30,7 @@ dependencies = [
"fal-client",
# Text-to-speech (Edge TTS is free, no API key needed)
"edge-tts",
"faster-whisper>=1.0.0",
# mini-swe-agent deps (terminal tool)
"litellm>=1.75.5",
"typer",
@@ -54,6 +55,7 @@ pty = [
honcho = ["honcho-ai>=2.0.1"]
mcp = ["mcp>=1.2.0"]
homeassistant = ["aiohttp>=3.9.0"]
acp = ["agent-client-protocol>=0.8.1,<1.0"]
rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
@@ -75,17 +77,19 @@ all = [
"hermes-agent[honcho]",
"hermes-agent[mcp]",
"hermes-agent[homeassistant]",
"hermes-agent[acp]",
]
[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "mini_swe_runner", "rl_cli", "utils"]
[tool.setuptools.packages.find]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration"]
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@@ -100,6 +100,7 @@ from agent.trajectory import (
convert_scratchpad_to_think, has_incomplete_scratchpad,
save_trajectory as _save_trajectory_to_file,
)
from utils import atomic_json_write
HONCHO_TOOL_NAMES = {
"honcho_context",
@@ -110,18 +111,17 @@ HONCHO_TOOL_NAMES = {
class _SafeWriter:
"""Transparent stdout wrapper that catches OSError from broken pipes.
"""Transparent stdio wrapper that catches OSError from broken pipes.
When hermes-agent runs as a systemd service, Docker container, or headless
daemon, the stdout pipe can become unavailable (idle timeout, buffer
daemon, the stdout/stderr pipe can become unavailable (idle timeout, buffer
exhaustion, socket reset). Any print() call then raises
``OSError: [Errno 5] Input/output error``, which can crash
run_conversation() — especially via double-fault when the except handler
``OSError: [Errno 5] Input/output error``, which can crash agent setup or
run_conversation() — especially via double-fault when an except handler
also tries to print.
This wrapper delegates all writes to the underlying stream and silently
catches OSError. It is installed once at the start of run_conversation()
and is transparent when stdout is healthy (zero overhead on the happy path).
catches OSError. It is transparent when the wrapped stream is healthy.
"""
__slots__ = ("_inner",)
@@ -154,6 +154,14 @@ class _SafeWriter:
return getattr(self._inner, name)
def _install_safe_stdio() -> None:
"""Wrap stdout/stderr so best-effort console output cannot crash the agent."""
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is not None and not isinstance(stream, _SafeWriter):
setattr(sys, stream_name, _SafeWriter(stream))
class IterationBudget:
"""Thread-safe shared iteration counter for parent and child agents.
@@ -202,6 +210,32 @@ _NEVER_PARALLEL_TOOLS = frozenset({"clarify"})
_MAX_TOOL_WORKERS = 8
def _inject_honcho_turn_context(content, turn_context: str):
"""Append Honcho recall to the current-turn user message without mutating history.
The returned content is sent to the API for this turn only. Keeping Honcho
recall out of the system prompt preserves the stable cache prefix while
still giving the model continuity context.
"""
if not turn_context:
return content
note = (
"[System note: The following Honcho memory was retrieved from prior "
"sessions. It is continuity context for this turn only, not new user "
"input.]\n\n"
f"{turn_context}"
)
if isinstance(content, list):
return list(content) + [{"type": "text", "text": note}]
text = "" if content is None else str(content)
if not text.strip():
return note
return f"{text}\n\n{note}"
class AIAgent:
"""
AI Agent with tool calling capabilities.
@@ -298,6 +332,8 @@ class AIAgent:
honcho_manager: Optional shared HonchoSessionManager owned by the caller.
honcho_config: Optional HonchoClientConfig corresponding to honcho_manager.
"""
_install_safe_stdio()
self.model = model
self.max_iterations = max_iterations
# Shared iteration budget — parent creates, children inherit.
@@ -381,19 +417,30 @@ class AIAgent:
# Persistent error log -- always writes WARNING+ to ~/.hermes/logs/errors.log
# so tool failures, API errors, etc. are inspectable after the fact.
from agent.redact import RedactingFormatter
_error_log_dir = Path.home() / ".hermes" / "logs"
_error_log_dir.mkdir(parents=True, exist_ok=True)
_error_log_path = _error_log_dir / "errors.log"
# In gateway mode, each incoming message creates a new AIAgent instance,
# while the root logger is process-global. Re-adding the same errors.log
# handler would cause each warning/error line to be written multiple times.
from logging.handlers import RotatingFileHandler
_error_file_handler = RotatingFileHandler(
_error_log_path, maxBytes=2 * 1024 * 1024, backupCount=2,
root_logger = logging.getLogger()
error_log_dir = _hermes_home / "logs"
error_log_path = error_log_dir / "errors.log"
resolved_error_log_path = error_log_path.resolve()
has_errors_log_handler = any(
isinstance(handler, RotatingFileHandler)
and Path(getattr(handler, "baseFilename", "")).resolve() == resolved_error_log_path
for handler in root_logger.handlers
)
_error_file_handler.setLevel(logging.WARNING)
_error_file_handler.setFormatter(RedactingFormatter(
'%(asctime)s %(levelname)s %(name)s: %(message)s',
))
logging.getLogger().addHandler(_error_file_handler)
if not has_errors_log_handler:
from agent.redact import RedactingFormatter
error_log_dir.mkdir(parents=True, exist_ok=True)
error_file_handler = RotatingFileHandler(
error_log_path, maxBytes=2 * 1024 * 1024, backupCount=2,
)
error_file_handler.setLevel(logging.WARNING)
error_file_handler.setFormatter(RedactingFormatter(
'%(asctime)s %(levelname)s %(name)s: %(message)s',
))
root_logger.addHandler(error_file_handler)
if self.verbose_logging:
logging.basicConfig(
@@ -1352,8 +1399,12 @@ class AIAgent:
"messages": cleaned,
}
with open(self.session_log_file, "w", encoding="utf-8") as f:
json.dump(entry, f, indent=2, ensure_ascii=False, default=str)
atomic_json_write(
self.session_log_file,
entry,
indent=2,
default=str,
)
except Exception as e:
if self.verbose_logging:
@@ -2711,6 +2762,42 @@ class AIAgent:
return kwargs
sanitized_messages = api_messages
needs_sanitization = False
for msg in api_messages:
if not isinstance(msg, dict):
continue
if "codex_reasoning_items" in msg:
needs_sanitization = True
break
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
if "call_id" in tool_call or "response_item_id" in tool_call:
needs_sanitization = True
break
if needs_sanitization:
break
if needs_sanitization:
sanitized_messages = copy.deepcopy(api_messages)
for msg in sanitized_messages:
if not isinstance(msg, dict):
continue
# Codex-only replay state must not leak into strict chat-completions APIs.
msg.pop("codex_reasoning_items", None)
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tool_call in tool_calls:
if isinstance(tool_call, dict):
tool_call.pop("call_id", None)
tool_call.pop("response_item_id", None)
provider_preferences = {}
if self.providers_allowed:
provider_preferences["only"] = self.providers_allowed
@@ -2727,9 +2814,9 @@ class AIAgent:
api_kwargs = {
"model": self.model,
"messages": api_messages,
"messages": sanitized_messages,
"tools": self.tools if self.tools else None,
"timeout": 900.0,
"timeout": float(os.getenv("HERMES_API_TIMEOUT", 900.0)),
}
if self.max_tokens is not None:
@@ -3842,10 +3929,9 @@ class AIAgent:
Returns:
Dict: Complete conversation result with final response and message history
"""
# Guard stdout against OSError from broken pipes (systemd/headless/daemon).
# Installed once, transparent when stdout is healthy, prevents crash on write.
if not isinstance(sys.stdout, _SafeWriter):
sys.stdout = _SafeWriter(sys.stdout)
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
# Installed once, transparent when streams are healthy, prevents crash on write.
_install_safe_stdio()
# Generate unique task_id if not provided to isolate VMs between concurrent tasks
effective_task_id = task_id or str(uuid.uuid4())
@@ -3909,10 +3995,11 @@ class AIAgent:
# Honcho prefetch consumption:
# - First turn: bake into cached system prompt (stable for the session).
# - Later turns: inject as ephemeral system context for this API call only.
# - Later turns: attach recall to the current-turn user message at
# API-call time only (never persisted to history / session DB).
#
# This keeps the persisted/cached prompt stable while still allowing
# turn N to consume background prefetch results from turn N-1.
# This keeps the system-prefix cache stable while still allowing turn N
# to consume background prefetch results from turn N-1.
self._honcho_context = ""
self._honcho_turn_context = ""
_recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid")
@@ -3930,6 +4017,7 @@ class AIAgent:
# Add user message
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
if not self.quiet_mode:
print(f"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'")
@@ -4079,9 +4167,14 @@ class AIAgent:
# However, providers like Moonshot AI require a separate 'reasoning_content' field
# on assistant messages with tool_calls. We handle both cases here.
api_messages = []
for msg in messages:
for idx, msg in enumerate(messages):
api_msg = msg.copy()
if idx == current_turn_user_idx and msg.get("role") == "user" and self._honcho_turn_context:
api_msg["content"] = _inject_honcho_turn_context(
api_msg.get("content", ""), self._honcho_turn_context
)
# For ALL assistant messages, pass reasoning back to the API
# This ensures multi-turn reasoning context is preserved
if msg.get("role") == "assistant":
@@ -4109,11 +4202,11 @@ class AIAgent:
# Build the final system message: cached prompt + ephemeral system prompt.
# Ephemeral additions are API-call-time only (not persisted to session DB).
# Honcho later-turn recall is intentionally kept OUT of the system prompt
# so the stable cache prefix remains unchanged.
effective_system = active_system_prompt or ""
if self.ephemeral_system_prompt:
effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip()
if self._honcho_turn_context:
effective_system = (effective_system + "\n\n" + self._honcho_turn_context).strip()
if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages

View File

@@ -562,9 +562,51 @@ clone_repo() {
if [ -d "$INSTALL_DIR/.git" ]; then
log_info "Existing installation found, updating..."
cd "$INSTALL_DIR"
local autostash_ref=""
if [ -n "$(git status --porcelain)" ]; then
local stash_name
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
log_info "Local changes detected, stashing before update..."
git stash push --include-untracked -m "$stash_name"
autostash_ref="$(git rev-parse --verify refs/stash)"
fi
git fetch origin
git checkout "$BRANCH"
git pull origin "$BRANCH"
if [ -n "$autostash_ref" ]; then
local restore_now="yes"
if [ -t 0 ] && [ -t 1 ]; then
echo
log_warn "Local changes were stashed before updating."
log_warn "Restoring them may reapply local customizations onto the updated codebase."
printf "Restore local changes now? [Y/n] "
read -r restore_answer
case "$restore_answer" in
""|y|Y|yes|YES|Yes) restore_now="yes" ;;
*) restore_now="no" ;;
esac
fi
if [ "$restore_now" = "yes" ]; then
log_info "Restoring local changes..."
if git stash apply "$autostash_ref"; then
git stash drop "$autostash_ref" >/dev/null
log_warn "Local changes were restored on top of the updated codebase."
log_warn "Review git diff / git status if Hermes behaves unexpectedly."
else
log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash."
log_info "Resolve manually with: git stash apply $autostash_ref"
exit 1
fi
else
log_info "Skipped restoring local changes."
log_info "Your changes are still preserved in git stash."
log_info "Restore manually with: git stash apply $autostash_ref"
fi
fi
else
log_error "Directory exists but is not a git repository: $INSTALL_DIR"
log_info "Remove it or choose a different directory with --dir"

View File

@@ -0,0 +1,218 @@
---
name: opencode
description: Delegate coding tasks to OpenCode CLI agent for feature implementation, refactoring, PR review, and long-running autonomous sessions. Requires the opencode CLI installed and authenticated.
version: 1.2.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review]
related_skills: [claude-code, codex, hermes-agent]
---
# OpenCode CLI
Use [OpenCode](https://opencode.ai) as an autonomous coding worker orchestrated by Hermes terminal/process tools. OpenCode is a provider-agnostic, open-source AI coding agent with a TUI and CLI.
## When to Use
- User explicitly asks to use OpenCode
- You want an external coding agent to implement/refactor/review code
- You need long-running coding sessions with progress checks
- You want parallel task execution in isolated workdirs/worktrees
## Prerequisites
- OpenCode installed: `npm i -g opencode-ai@latest` or `brew install anomalyco/tap/opencode`
- Auth configured: `opencode auth login` or set provider env vars (OPENROUTER_API_KEY, etc.)
- Verify: `opencode auth list` should show at least one provider
- Git repository for code tasks (recommended)
- `pty=true` for interactive TUI sessions
## Binary Resolution (Important)
Shell environments may resolve different OpenCode binaries. If behavior differs between your terminal and Hermes, check:
```
terminal(command="which -a opencode")
terminal(command="opencode --version")
```
If needed, pin an explicit binary path:
```
terminal(command="$HOME/.opencode/bin/opencode run '...'", workdir="~/project", pty=true)
```
## One-Shot Tasks
Use `opencode run` for bounded, non-interactive tasks:
```
terminal(command="opencode run 'Add retry logic to API calls and update tests'", workdir="~/project")
```
Attach context files with `-f`:
```
terminal(command="opencode run 'Review this config for security issues' -f config.yaml -f .env.example", workdir="~/project")
```
Show model thinking with `--thinking`:
```
terminal(command="opencode run 'Debug why tests fail in CI' --thinking", workdir="~/project")
```
Force a specific model:
```
terminal(command="opencode run 'Refactor auth module' --model openrouter/anthropic/claude-sonnet-4", workdir="~/project")
```
## Interactive Sessions (Background)
For iterative work requiring multiple exchanges, start the TUI in background:
```
terminal(command="opencode", workdir="~/project", background=true, pty=true)
# Returns session_id
# Send a prompt
process(action="submit", session_id="<id>", data="Implement OAuth refresh flow and add tests")
# Monitor progress
process(action="poll", session_id="<id>")
process(action="log", session_id="<id>")
# Send follow-up input
process(action="submit", session_id="<id>", data="Now add error handling for token expiry")
# Exit cleanly — Ctrl+C
process(action="write", session_id="<id>", data="\x03")
# Or just kill the process
process(action="kill", session_id="<id>")
```
**Important:** Do NOT use `/exit` — it is not a valid OpenCode command and will open an agent selector dialog instead. Use Ctrl+C (`\x03`) or `process(action="kill")` to exit.
### TUI Keybindings
| Key | Action |
|-----|--------|
| `Enter` | Submit message (press twice if needed) |
| `Tab` | Switch between agents (build/plan) |
| `Ctrl+P` | Open command palette |
| `Ctrl+X L` | Switch session |
| `Ctrl+X M` | Switch model |
| `Ctrl+X N` | New session |
| `Ctrl+X E` | Open editor |
| `Ctrl+C` | Exit OpenCode |
### Resuming Sessions
After exiting, OpenCode prints a session ID. Resume with:
```
terminal(command="opencode -c", workdir="~/project", background=true, pty=true) # Continue last session
terminal(command="opencode -s ses_abc123", workdir="~/project", background=true, pty=true) # Specific session
```
## Common Flags
| Flag | Use |
|------|-----|
| `run 'prompt'` | One-shot execution and exit |
| `--continue` / `-c` | Continue the last OpenCode session |
| `--session <id>` / `-s` | Continue a specific session |
| `--agent <name>` | Choose OpenCode agent (build or plan) |
| `--model provider/model` | Force specific model |
| `--format json` | Machine-readable output/events |
| `--file <path>` / `-f` | Attach file(s) to the message |
| `--thinking` | Show model thinking blocks |
| `--variant <level>` | Reasoning effort (high, max, minimal) |
| `--title <name>` | Name the session |
| `--attach <url>` | Connect to a running opencode server |
## Procedure
1. Verify tool readiness:
- `terminal(command="opencode --version")`
- `terminal(command="opencode auth list")`
2. For bounded tasks, use `opencode run '...'` (no pty needed).
3. For iterative tasks, start `opencode` with `background=true, pty=true`.
4. Monitor long tasks with `process(action="poll"|"log")`.
5. If OpenCode asks for input, respond via `process(action="submit", ...)`.
6. Exit with `process(action="write", data="\x03")` or `process(action="kill")`.
7. Summarize file changes, test results, and next steps back to user.
## PR Review Workflow
OpenCode has a built-in PR command:
```
terminal(command="opencode pr 42", workdir="~/project", pty=true)
```
Or review in a temporary clone for isolation:
```
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && opencode run 'Review this PR vs main. Report bugs, security risks, test gaps, and style issues.' -f $(git diff origin/main --name-only | head -20 | tr '\n' ' ')", pty=true)
```
## Parallel Work Pattern
Use separate workdirs/worktrees to avoid collisions:
```
terminal(command="opencode run 'Fix issue #101 and commit'", workdir="/tmp/issue-101", background=true, pty=true)
terminal(command="opencode run 'Add parser regression tests and commit'", workdir="/tmp/issue-102", background=true, pty=true)
process(action="list")
```
## Session & Cost Management
List past sessions:
```
terminal(command="opencode session list")
```
Check token usage and costs:
```
terminal(command="opencode stats")
terminal(command="opencode stats --days 7 --models anthropic/claude-sonnet-4")
```
## Pitfalls
- Interactive `opencode` (TUI) sessions require `pty=true`. The `opencode run` command does NOT need pty.
- `/exit` is NOT a valid command — it opens an agent selector. Use Ctrl+C to exit the TUI.
- PATH mismatch can select the wrong OpenCode binary/model config.
- If OpenCode appears stuck, inspect logs before killing:
- `process(action="log", session_id="<id>")`
- Avoid sharing one working directory across parallel OpenCode sessions.
- Enter may need to be pressed twice to submit in the TUI (once to finalize text, once to send).
## Verification
Smoke test:
```
terminal(command="opencode run 'Respond with exactly: OPENCODE_SMOKE_OK'")
```
Success criteria:
- Output includes `OPENCODE_SMOKE_OK`
- Command exits without provider/model errors
- For code tasks: expected files changed and tests pass
## Rules
1. Prefer `opencode run` for one-shot automation — it's simpler and doesn't need pty.
2. Use interactive background mode only when iteration is needed.
3. Always scope OpenCode sessions to a single repo/workdir.
4. For long tasks, provide progress updates from `process` logs.
5. Report concrete outcomes (files changed, tests, remaining risks).
6. Exit interactive sessions with Ctrl+C or kill, never `/exit`.

View File

@@ -0,0 +1,249 @@
# ☤ ASCII Video
Renders any content as colored ASCII character video. Audio, video, images, text, or pure math in, MP4/GIF/PNG sequence out. Full RGB color per character cell, 1080p 24fps default. No GPU.
Built for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Usable in any coding agent.
## What this is
A skill that teaches an agent how to build single-file Python renderers for ASCII video from scratch. The agent gets the full pipeline: grid system, font rasterization, effect library, shader chain, audio analysis, parallel encoding. It writes the renderer, runs it, gets video.
The output is actual video. Not terminal escape codes. Frames are computed as grids of colored characters, composited onto pixel canvases with pre-rasterized font bitmaps, post-processed through shaders, piped to ffmpeg.
## Modes
| Mode | Input | Output |
|------|-------|--------|
| Video-to-ASCII | A video file | ASCII recreation of the footage |
| Audio-reactive | An audio file | Visuals driven by frequency bands, beats, energy |
| Generative | Nothing | Procedural animation from math |
| Hybrid | Video + audio | ASCII video with audio-reactive overlays |
| Lyrics/text | Audio + timed text (SRT) | Karaoke-style text with effects |
| TTS narration | Text quotes + API key | Narrated video with typewriter text and generated speech |
## Pipeline
Every mode follows the same 6-stage path:
```
INPUT --> ANALYZE --> SCENE_FN --> TONEMAP --> SHADE --> ENCODE
```
1. **Input** loads source material (or nothing for generative).
2. **Analyze** extracts per-frame features. Audio gets 6-band FFT, RMS, spectral centroid, flatness, flux, beat detection with exponential decay. Video gets luminance, edges, motion.
3. **Scene function** returns a pixel canvas directly. Composes multiple character grids at different densities, value/hue fields, pixel blend modes. This is where the visuals happen.
4. **Tonemap** does adaptive percentile-based brightness normalization with per-scene gamma. ASCII on black is inherently dark. Linear multipliers don't work. This does.
5. **Shade** runs a `ShaderChain` (38 composable shaders) plus a `FeedbackBuffer` for temporal recursion with spatial transforms.
6. **Encode** pipes raw RGB frames to ffmpeg for H.264 encoding. Segments concatenated, audio muxed.
## Grid system
Characters render on fixed-size grids. Layer multiple densities for depth.
| Size | Font | Grid at 1080p | Use |
|------|------|---------------|-----|
| xs | 8px | 400x108 | Ultra-dense data fields |
| sm | 10px | 320x83 | Rain, starfields |
| md | 16px | 192x56 | Default balanced |
| lg | 20px | 160x45 | Readable text |
| xl | 24px | 137x37 | Large titles |
| xxl | 40px | 80x22 | Giant minimal |
Rendering the same scene on `sm` and `lg` then screen-blending them creates natural texture interference. Fine detail shows through gaps in coarse characters. Most scenes use two or three grids.
## Character palettes (20+)
Each sorted dark-to-bright, each a different visual texture. Validated against the font at init so broken glyphs get dropped silently.
| Family | Examples | Feel |
|--------|----------|------|
| Density ramps | ` .:-=+#@█` | Classic ASCII art gradient |
| Block elements | ` ░▒▓█▄▀▐▌` | Chunky, digital |
| Braille | ` ⠁⠂⠃...⠿` | Fine-grained pointillism |
| Dots | ` ⋅∘∙●◉◎` | Smooth, organic |
| Stars | ` ·✧✦✩✨★✶` | Sparkle, celestial |
| Half-fills | ` ◔◑◕◐◒◓◖◗◙` | Directional fill progression |
| Crosshatch | ` ▣▤▥▦▧▨▩` | Hatched density ramp |
| Math | ` ·∘∙•°±×÷≈≠≡∞∫∑Ω` | Scientific, abstract |
| Box drawing | ` ─│┌┐└┘├┤┬┴┼` | Structural, circuit-like |
| Katakana | ` ·ヲァィゥェォャュ...` | Matrix rain |
| Greek | ` αβγδεζηθ...ω` | Classical, academic |
| Runes | ` ᚠᚢᚦᚱᚷᛁᛇᛒᛖᛚᛞᛟ` | Mystical, ancient |
| Alchemical | ` ☉☽♀♂♃♄♅♆♇` | Esoteric |
| Arrows | ` ←↑→↓↔↕↖↗↘↙` | Directional, kinetic |
| Music | ` ♪♫♬♩♭♮♯○●` | Musical |
| Project-specific | ` .·~=≈∞⚡☿✦★⊕◊◆▲▼●■` | Themed per project |
Custom palettes are built per project to match the content.
## Color strategies
| Strategy | How it maps hue | Good for |
|----------|----------------|----------|
| Angle-mapped | Position angle from center | Rainbow radial effects |
| Distance-mapped | Distance from center | Depth, tunnels |
| Frequency-mapped | Audio spectral centroid | Timbral shifting |
| Value-mapped | Brightness level | Heat maps, fire |
| Time-cycled | Slow rotation over time | Ambient, chill |
| Source-sampled | Original video pixel colors | Video-to-ASCII |
| Palette-indexed | Discrete lookup table | Retro, flat graphic |
| Temperature | Warm-to-cool blend | Emotional tone |
| Complementary | Hue + opposite | Bold, dramatic |
| Triadic | Three equidistant hues | Psychedelic, vibrant |
| Analogous | Neighboring hues | Harmonious, subtle |
| Monochrome | Fixed hue, vary S/V | Noir, focused |
Plus 10 discrete RGB palettes (neon, pastel, cyberpunk, vaporwave, earth, ice, blood, forest, mono-green, mono-amber).
## Effects
### Backgrounds
| Effect | Description | Parameters |
|--------|-------------|------------|
| Sine field | Layered sinusoidal interference | freq, speed, octave count |
| Smooth noise | Multi-octave Perlin approximation | octaves, scale |
| Cellular | Voronoi-like moving cells | n_centers, speed |
| Noise/static | Random per-cell flicker | density |
| Video source | Downsampled video frame | brightness |
### Primary effects
| Effect | Description |
|--------|-------------|
| Concentric rings | Bass-driven pulsing rings with wobble |
| Radial rays | Spoke pattern, beat-triggered |
| Spiral arms | Logarithmic spiral, configurable arm count/tightness |
| Tunnel | Infinite depth perspective |
| Vortex | Twisting radial distortion |
| Frequency waves | Per-band sine waves at different heights |
| Interference | Overlapping sine waves creating moire |
| Aurora | Horizontal flowing bands |
| Ripple | Point-source concentric waves |
| Fire columns | Rising flames with heat-color gradient |
| Spectrum bars | Mirrored frequency visualizer |
| Waveform | Oscilloscope-style trace |
### Particle systems
| Type | Behavior | Character sets |
|------|----------|---------------|
| Explosion | Beat-triggered radial burst | `*+#@⚡✦★█▓` |
| Sparks | Short-lived bright dots | `·•●★✶*+` |
| Embers | Rising from bottom with drift | `·•●★` |
| Snow | Falling with wind sway | `❄❅❆·•*○` |
| Rain | Fast vertical streaks | `│┃║/\` |
| Bubbles | Rising, expanding | `○◎◉●∘∙°` |
| Data | Falling hex/binary | `01{}[]<>/\` |
| Runes | Mystical floating symbols | `ᚠᚢᚦᚱᚷᛁ✦★` |
| Orbit | Circular/elliptical paths | `·•●` |
| Gravity well | Attracted to point sources | configurable |
| Dissolve | Spread across screen, fade | configurable |
| Starfield | 3D projected, approaching | configurable |
## Shader pipeline
38 composable shaders, applied to the pixel canvas after character rendering. Configurable per section.
| Category | Shaders |
|----------|---------|
| Geometry | CRT barrel, pixelate, wave distort, displacement map, kaleidoscope, mirror (h/v/quad/diag) |
| Channel | Chromatic aberration (beat-reactive), channel shift, channel swap, RGB split radial |
| Color | Invert, posterize, threshold, solarize, hue rotate, saturation, color grade, color wobble, color ramp |
| Glow/Blur | Bloom, edge glow, soft focus, radial blur |
| Noise | Film grain (beat-reactive), static noise |
| Lines/Patterns | Scanlines, halftone |
| Tone | Vignette, contrast, gamma, levels, brightness |
| Glitch/Data | Glitch bands (beat-reactive), block glitch, pixel sort, data bend |
12 color tint presets: warm, cool, matrix green, amber, sepia, neon pink, ice, blood, forest, void, sunset, neutral.
7 mood presets for common shader combos:
| Mood | Shaders |
|------|---------|
| Retro terminal | CRT + scanlines + grain + amber/green tint |
| Clean modern | Light bloom + subtle vignette |
| Glitch art | Heavy chromatic + glitch bands + color wobble |
| Cinematic | Bloom + vignette + grain + color grade |
| Dreamy | Heavy bloom + soft focus + color wobble |
| Harsh/industrial | High contrast + grain + scanlines, no bloom |
| Psychedelic | Color wobble + chromatic + kaleidoscope mirror |
## Blend modes and composition
20 pixel blend modes for layering canvases: normal, add, subtract, multiply, screen, overlay, softlight, hardlight, difference, exclusion, colordodge, colorburn, linearlight, vividlight, pin_light, hard_mix, lighten, darken, grain_extract, grain_merge.
Mirror modes: horizontal, vertical, quad, diagonal, kaleidoscope (6-fold radial). Beat-triggered.
Transitions: crossfade, directional wipe, radial wipe, dissolve, glitch cut.
## Hardware adaptation
Auto-detects CPU count, RAM, platform, ffmpeg. Adapts worker count, resolution, FPS.
| Profile | Resolution | FPS | When |
|---------|-----------|-----|------|
| `draft` | 960x540 | 12 | Check timing/layout |
| `preview` | 1280x720 | 15 | Review effects |
| `production` | 1920x1080 | 24 | Final output |
| `max` | 3840x2160 | 30 | Ultra-high |
| `auto` | Detected | 24 | Adapts to hardware + duration |
`auto` estimates render time and downgrades if it would take over an hour. Low-memory systems drop to 720p automatically.
### Render times (1080p 24fps, ~180ms/frame/worker)
| Duration | 4 workers | 8 workers | 16 workers |
|----------|-----------|-----------|------------|
| 30s | ~3 min | ~2 min | ~1 min |
| 2 min | ~13 min | ~7 min | ~4 min |
| 5 min | ~33 min | ~17 min | ~9 min |
| 10 min | ~65 min | ~33 min | ~17 min |
720p roughly halves these. 4K roughly quadruples them.
## Known pitfalls
**Brightness.** ASCII characters are small bright dots on black. Most frame pixels are background. Linear `* N` multipliers clip highlights and wash out. Use `tonemap()` with per-scene gamma instead. Default gamma 0.75, solarize scenes 0.55, posterize 0.50.
**Render bottleneck.** The per-cell Python loop compositing font bitmaps runs at ~100-150ms/frame. Unavoidable without Cython/C. Everything else must be vectorized numpy. Python for-loops over rows/cols in effect functions will tank performance.
**ffmpeg deadlock.** Never `stderr=subprocess.PIPE` on long-running encodes. Buffer fills at ~64KB, process hangs. Redirect stderr to a file.
**Font cell height.** Pillow's `textbbox()` returns wrong height on macOS. Use `font.getmetrics()` for `ascent + descent`.
**Font compatibility.** Not all Unicode renders in all fonts. Palettes validated at init, blank glyphs silently removed.
## Requirements
◆ Python 3.10+
◆ NumPy, Pillow, SciPy (audio modes)
◆ ffmpeg on PATH
◆ A monospace font (Menlo, Courier, Monaco, auto-detected)
◆ Optional: OpenCV, ElevenLabs API key (TTS mode)
## File structure
```
├── SKILL.md # Modes, workflow, creative direction
├── README.md # This file
└── references/
├── architecture.md # Grid system, fonts, palettes, color, _render_vf()
├── effects.md # Value fields, hue fields, backgrounds, particles
├── shaders.md # 38 shaders, ShaderChain, tint presets, transitions
├── composition.md # Blend modes, multi-grid, tonemap, FeedbackBuffer
├── scenes.md # Scene protocol, SCENES table, render_clip(), examples
├── design-patterns.md # Layer hierarchy, directional arcs, scene concepts
├── inputs.md # Audio analysis, video sampling, text, TTS
├── optimization.md # Hardware detection, vectorized patterns, parallelism
└── troubleshooting.md # Broadcasting traps, blend pitfalls, diagnostics
```
## Projects built with this
✦ 85-second highlight reel. 15 scenes (14×5s + 15s crescendo finale), randomized order, directional parameter arcs, layer hierarchy composition. Showcases the full effect vocabulary: fBM, voronoi fragmentation, reaction-diffusion, cellular automata, dual counter-rotating spirals, wave collision, domain warping, tunnel descent, kaleidoscope symmetry, boid flocking, fire simulation, glitch corruption, and a 7-layer crescendo buildup.
✦ Audio-reactive music visualizer. 3.5 min, 8 sections with distinct effects, beat-triggered particles and glitch, cycling palettes.
✦ TTS narrated testimonial video. 23 quotes, per-quote ElevenLabs voices, background music at 15% wide stereo, per-clip re-rendering for iterative editing.

View File

@@ -59,16 +59,20 @@ Every mode follows the same 6-stage pipeline. See `references/architecture.md` f
| Dimension | Options | Reference |
|-----------|---------|-----------|
| **Character palette** | Density ramps, block elements, symbols, scripts (katakana, Greek, runes, braille), dots, project-specific | `architecture.md` § Character Palettes |
| **Color strategy** | HSV (angle/distance/time/value mapped), discrete RGB palettes, monochrome, complementary, triadic, temperature | `architecture.md` § Color System |
| **Color strategy** | HSV (angle/distance/time/value mapped), OKLAB/OKLCH (perceptually uniform), discrete RGB palettes, auto-generated harmony (complementary/triadic/analogous/tetradic), monochrome, temperature | `architecture.md` § Color System |
| **Color tint** | Warm, cool, amber, matrix green, neon pink, sepia, ice, blood, void, sunset | `shaders.md` § Color Grade |
| **Background texture** | Sine fields, noise, smooth noise, cellular/voronoi, video source | `effects.md` § Background Fills |
| **Primary effects** | Rings, spirals, tunnel, vortex, waves, interference, aurora, ripple, fire | `effects.md` § Radial / Wave / Fire |
| **Particles** | Energy sparks, snow, rain, bubbles, runes, binary data, orbits, gravity wells | `effects.md` § Particle Systems |
| **Background texture** | Sine fields, fBM noise, domain warp, voronoi cells, reaction-diffusion, cellular automata, video source | `effects.md` § Background Fills, Noise-Based Fields, Simulation-Based Fields |
| **Primary effects** | Rings, spirals, tunnel, vortex, waves, interference, aurora, ripple, fire, strange attractors, SDFs (geometric shapes with smooth booleans) | `effects.md` § Radial / Wave / Fire / SDF-Based Fields |
| **Particles** | Energy sparks, snow, rain, bubbles, runes, binary data, orbits, gravity wells, flocking boids, flow-field followers, trail-drawing particles | `effects.md` § Particle Systems |
| **Shader mood** | Retro CRT, clean modern, glitch art, cinematic, dreamy, harsh industrial, psychedelic | `shaders.md` § Design Philosophy |
| **Grid density** | xs(8px) through xxl(40px), mixed per layer | `architecture.md` § Grid System |
| **Font** | Menlo, Monaco, Courier, SF Mono, JetBrains Mono, Fira Code, IBM Plex | `architecture.md` § Font Selection |
| **Coordinate space** | Cartesian, polar, tiled, rotated, skewed, fisheye, twisted, Möbius, domain-warped | `effects.md` § Coordinate Transforms |
| **Mirror mode** | None, horizontal, vertical, quad, diagonal, kaleidoscope | `shaders.md` § Mirror Effects |
| **Transition style** | Crossfade, wipe (directional/radial), dissolve, glitch cut | `shaders.md` § Transitions |
| **Masking** | Circle, rect, ring, gradient, text stencil, value-field-as-mask, animated iris/wipe/dissolve | `composition.md` § Masking |
| **Temporal motion** | Static, audio-reactive, eased keyframes, morphing between fields, temporal noise (smooth in-place evolution) | `effects.md` § Temporal Coherence |
| **Transition style** | Crossfade, wipe (directional/radial), dissolve, glitch cut, iris open/close, mask-based reveal | `shaders.md` § Transitions, `composition.md` § Animated Masks |
| **Aspect ratio** | Landscape (16:9), portrait (9:16), square (1:1), ultrawide (21:9) | `architecture.md` § Resolution Presets |
### Per-Section Variation
@@ -95,10 +99,11 @@ Establish with user:
- **Input source** — file path, format, duration
- **Mode** — which of the 6 modes above
- **Sections** — time-mapped style changes (timestamps → effect names)
- **Resolution** — default 1920x1080 @ 24fps; GIFs typically 640x360 @ 15fps
- **Resolution** — landscape 1920x1080 (default), portrait 1080x1920, square 1080x1080 @ 24fps; GIFs typically 640x360 @ 15fps
- **Style direction** — dense/sparse, bright/dark, chaotic/minimal, color palette
- **Text/branding** — easter eggs, overlays, credits, themed character sets
- **Output format** — MP4 (default), GIF, PNG sequence
- **Aspect ratio** — landscape (16:9), portrait (9:16 for TikTok/Reels/Stories), square (1:1 for IG feed)
### Step 2: Detect Hardware and Set Quality
@@ -240,11 +245,12 @@ Image.fromarray(canvas).save("test.png")
| File | Contents |
|------|----------|
| `references/architecture.md` | Grid system, font selection, character palettes (library of 20+), color system (HSV + discrete RGB), `_render_vf()` helper, compositing, v2 effect function contract |
| `references/architecture.md` | Grid system (landscape/portrait/square resolution presets), font selection, character palettes (library of 20+), color system (HSV + OKLAB/OKLCH + discrete RGB + color harmony generation + perceptual gradient interpolation), `_render_vf()` helper, compositing, v2 effect function contract |
| `references/inputs.md` | All input sources: audio analysis, video sampling, image conversion, text/lyrics, TTS integration (ElevenLabs, voice assignment, audio mixing) |
| `references/effects.md` | Effect building blocks: 12 value field generators (`vf_sinefield` through `vf_noise_static`), 8 hue field generators (`hf_fixed` through `hf_plasma`), radial/wave/fire effects, particles, composing guide |
| `references/effects.md` | Effect building blocks: 20+ value field generators (trig, noise/fBM, domain warp, voronoi, reaction-diffusion, cellular automata, strange attractors, SDFs), 8 hue field generators, coordinate transforms (rotate/tile/polar/Möbius), temporal coherence (easing, keyframes, morphing), radial/wave/fire effects, advanced particles (flocking, flow fields, trails), composing guide |
| `references/shaders.md` | 38 shader implementations (geometry, channel, color, glow, noise, pattern, tone, glitch, mirror), `ShaderChain` class, full `_apply_shader_step()` dispatch, audio-reactive scaling, transitions, tint presets |
| `references/composition.md` | **v2 core**: pixel blend modes (20 modes with implementations), multi-grid composition, `_render_vf()` helper, adaptive `tonemap()`, per-scene gamma, `FeedbackBuffer` with spatial transforms, `PixelBlendStack` |
| `references/scenes.md` | **v2 scene protocol**: scene function contract, `Renderer` class, `SCENES` table structure, `render_clip()` loop, beat-synced cutting, parallel rendering + pickling constraints, 4 complete scene examples, scene design checklist |
| `references/composition.md` | **v2 core**: pixel blend modes (20 modes with implementations), multi-grid composition, `_render_vf()` helper, adaptive `tonemap()`, per-scene gamma, `FeedbackBuffer` with spatial transforms, `PixelBlendStack`, masking/stencil system (shape masks, text stencils, animated masks, boolean ops) |
| `references/scenes.md` | **v2 scene protocol**: scene function contract (local time convention), `Renderer` class, `SCENES` table structure, `render_clip()` loop, beat-synced cutting, parallel rendering + pickling constraints, 4 complete scene examples, scene design checklist |
| `references/design-patterns.md` | **Scene composition patterns**: layer hierarchy (bg/content/accent), directional parameter arcs vs oscillation, scene concepts and visual metaphors, counter-rotating dual systems, wave collision, progressive fragmentation, entropy/consumption, staggered layer entry (crescendo), scene ordering |
| `references/troubleshooting.md` | NumPy broadcasting traps, blend mode pitfalls, multiprocessing/pickling issues, brightness diagnostics, ffmpeg deadlocks, font issues, performance bottlenecks, common mistakes |
| `references/optimization.md` | Hardware detection, adaptive quality profiles (draft/preview/production/max), CLI integration, vectorized effect patterns, parallel rendering, memory management |

View File

@@ -1,12 +1,43 @@
# Architecture Reference
**Cross-references:**
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
- Scene protocol, render_clip, SCENES table: `scenes.md`
- Shader pipeline, feedback buffer, output encoding: `shaders.md`
- Complete scene examples: `examples.md`
- Input sources (audio analysis, video, TTS): `inputs.md`
- Performance tuning, hardware detection: `optimization.md`
- Common bugs (broadcasting, font, encoding): `troubleshooting.md`
## Grid System
### Resolution Presets
```python
RESOLUTION_PRESETS = {
"landscape": (1920, 1080), # 16:9 — YouTube, default
"portrait": (1080, 1920), # 9:16 — TikTok, Reels, Stories
"square": (1080, 1080), # 1:1 — Instagram feed
"ultrawide": (2560, 1080), # 21:9 — cinematic
"landscape4k":(3840, 2160), # 16:9 — 4K
"portrait4k": (2160, 3840), # 9:16 — 4K portrait
}
def get_resolution(preset="landscape", custom=None):
"""Returns (VW, VH) tuple."""
if custom:
return custom
return RESOLUTION_PRESETS.get(preset, RESOLUTION_PRESETS["landscape"])
```
### Multi-Density Grids
Pre-initialize multiple grid sizes. Switch per section for visual variety.
Pre-initialize multiple grid sizes. Switch per section for visual variety. Grid dimensions auto-compute from resolution:
| Key | Font Size | Grid (1920x1080) | Use |
**Landscape (1920x1080):**
| Key | Font Size | Grid (cols x rows) | Use |
|-----|-----------|-------------------|-----|
| xs | 8 | 400x108 | Ultra-dense data fields |
| sm | 10 | 320x83 | Dense detail, rain, starfields |
@@ -15,7 +46,34 @@ Pre-initialize multiple grid sizes. Switch per section for visual variety.
| xl | 24 | 137x37 | Short quotes, large titles |
| xxl | 40 | 80x22 | Giant text, minimal |
**Grid sizing for text-heavy content**: When displaying readable text (quotes, lyrics, testimonials), use 20px (`lg`) as the primary grid. This gives 160 columns -- plenty for lines up to ~50 chars centered. For very short quotes (< 60 chars, <= 3 lines), 24px (`xl`) makes them more impactful. Only init the grids you actually use -- each grid pre-rasterizes all characters which costs ~0.3-0.5s.
**Portrait (1080x1920):**
| Key | Font Size | Grid (cols x rows) | Use |
|-----|-----------|-------------------|-----|
| xs | 8 | 225x192 | Ultra-dense, tall data columns |
| sm | 10 | 180x148 | Dense detail, vertical rain |
| md | 16 | 112x100 | Default balanced |
| lg | 20 | 90x80 | Readable text (~30 chars/line centered) |
| xl | 24 | 75x66 | Short quotes, stacked |
| xxl | 40 | 45x39 | Giant text, minimal |
**Square (1080x1080):**
| Key | Font Size | Grid (cols x rows) | Use |
|-----|-----------|-------------------|-----|
| sm | 10 | 180x83 | Dense detail |
| md | 16 | 112x56 | Default balanced |
| lg | 20 | 90x45 | Readable text |
**Key differences in portrait mode:**
- Fewer columns (90 at `lg` vs 160) — lines must be shorter or wrap
- Many more rows (80 at `lg` vs 45) — vertical stacking is natural
- Aspect ratio correction flips: `asp = cw / ch` still works but the visual emphasis is vertical
- Radial effects appear as tall ellipses unless corrected
- Vertical effects (rain, embers, fire columns) are naturally enhanced
- Horizontal effects (spectrum bars, waveforms) need rotation or compression
**Grid sizing for text in portrait**: Use `lg` (20px) for 2-3 word lines. Max comfortable line length is ~25-30 chars. For longer quotes, break aggressively into many short lines stacked vertically — portrait has vertical space to spare. `xl` (24px) works for single words or very short phrases.
Grid dimensions: `cols = VW // cell_width`, `rows = VH // cell_height`.
@@ -59,7 +117,23 @@ FONT_PREFS_LINUX = [
("Noto Sans Mono", "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf"),
("Ubuntu Mono", "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf"),
]
FONT_PREFS = FONT_PREFS_MACOS if platform.system() == "Darwin" else FONT_PREFS_LINUX
FONT_PREFS_WINDOWS = [
("Consolas", r"C:\Windows\Fonts\consola.ttf"),
("Courier New", r"C:\Windows\Fonts\cour.ttf"),
("Lucida Console", r"C:\Windows\Fonts\lucon.ttf"),
("Cascadia Code", os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts\CascadiaCode.ttf")),
("Cascadia Mono", os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts\CascadiaMono.ttf")),
]
def _get_font_prefs():
s = platform.system()
if s == "Darwin":
return FONT_PREFS_MACOS
elif s == "Windows":
return FONT_PREFS_WINDOWS
return FONT_PREFS_LINUX
FONT_PREFS = _get_font_prefs()
```
**Multi-font rendering**: use different fonts for different layers (e.g., monospace for background, a bolder variant for overlay text). Each GridLayer owns its own font:
@@ -77,8 +151,8 @@ Before initializing grids, gather all characters that need bitmap pre-rasterizat
all_chars = set()
for pal in [PAL_DEFAULT, PAL_DENSE, PAL_BLOCKS, PAL_RUNE, PAL_KATA,
PAL_GREEK, PAL_MATH, PAL_DOTS, PAL_BRAILLE, PAL_STARS,
PAL_BINARY, PAL_MUSIC, PAL_BOX, PAL_CIRCUIT, PAL_ARROWS,
PAL_HERMES]: # ... all palettes used in project
PAL_HALFFILL, PAL_HATCH, PAL_BINARY, PAL_MUSIC, PAL_BOX,
PAL_CIRCUIT, PAL_ARROWS, PAL_HERMES]: # ... all palettes used in project
all_chars.update(pal)
# Add any overlay text characters
all_chars.update("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,-:;!?/|")
@@ -87,21 +161,31 @@ all_chars.discard(" ") # space is never rendered
### GridLayer Initialization
Each grid pre-computes coordinate arrays for vectorized effect math:
Each grid pre-computes coordinate arrays for vectorized effect math. The grid automatically adapts to any resolution (landscape, portrait, square):
```python
class GridLayer:
def __init__(self, font_path, font_size):
def __init__(self, font_path, font_size, vw=None, vh=None):
"""Initialize grid for any resolution.
vw, vh: video width/height in pixels. Defaults to global VW, VH."""
vw = vw or VW; vh = vh or VH
self.vw = vw; self.vh = vh
self.font = ImageFont.truetype(font_path, font_size)
asc, desc = self.font.getmetrics()
bbox = self.font.getbbox("M")
self.cw = bbox[2] - bbox[0] # character cell width
self.ch = asc + desc # CRITICAL: not textbbox height
self.cols = VW // self.cw
self.rows = VH // self.ch
self.ox = (VW - self.cols * self.cw) // 2 # centering
self.oy = (VH - self.rows * self.ch) // 2
self.cols = vw // self.cw
self.rows = vh // self.ch
self.ox = (vw - self.cols * self.cw) // 2 # centering
self.oy = (vh - self.rows * self.ch) // 2
# Aspect ratio metadata
self.aspect = vw / vh # >1 = landscape, <1 = portrait, 1 = square
self.is_portrait = vw < vh
self.is_landscape = vw > vh
# Index arrays
self.rr = np.arange(self.rows, dtype=np.float32)[:, None]
@@ -219,9 +303,11 @@ PAL_ARABIC = " \u0627\u0628\u062a\u062b\u062c\u062d\u062e\u062f\u0630\u0631\u0
#### Dot / Point Progressions
```python
PAL_DOTS = " \u22c5\u2218\u2219\u25cf\u25c9\u25ce\u25c6\u2726\u2605" # dot size progression
PAL_BRAILLE = " \u2801\u2802\u2803\u2804\u2805\u2806\u2807\u2808\u2809\u280a\u280b\u280c\u280d\u280e\u280f\u2810\u2811\u2812\u2813\u2814\u2815\u2816\u2817\u2818\u2819\u281a\u281b\u281c\u281d\u281e\u281f\u283f" # braille patterns
PAL_STARS = " \u00b7\u2727\u2726\u2729\u2728\u2605\u2736\u2733\u2738" # star progression
PAL_DOTS = " ⋅∘∙●◉◎◆✦★" # dot size progression
PAL_BRAILLE = " ⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠿" # braille patterns
PAL_STARS = " ·✧✦✩✨★✶✳✸" # star progression
PAL_HALFFILL = " ◔◑◕◐◒◓◖◗◙" # directional half-fill progression
PAL_HATCH = " ▣▤▥▦▧▨▩" # crosshatch density ramp
```
#### Project-Specific (examples -- invent new ones per project)
@@ -353,6 +439,202 @@ def rgb_palette_map(val, mask, palette):
return R, G, B
```
### OKLAB Color Space (Perceptually Uniform)
HSV hue is perceptually non-uniform: green occupies far more visual range than blue. OKLAB / OKLCH provide perceptually even color steps — hue increments of 0.1 look equally different regardless of starting hue. Use OKLAB for:
- Gradient interpolation (no unwanted intermediate hues)
- Color harmony generation (perceptually balanced palettes)
- Smooth color transitions over time
```python
# --- sRGB <-> Linear sRGB ---
def srgb_to_linear(c):
"""Convert sRGB [0,1] to linear light. c: float32 array."""
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def linear_to_srgb(c):
"""Convert linear light to sRGB [0,1]."""
return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(np.maximum(c, 0), 1/2.4) - 0.055)
# --- Linear sRGB <-> OKLAB ---
def linear_rgb_to_oklab(r, g, b):
"""Linear sRGB to OKLAB. r,g,b: float32 arrays [0,1].
Returns (L, a, b) where L=[0,1], a,b=[-0.4, 0.4] approx."""
l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
l_c = np.cbrt(l_); m_c = np.cbrt(m_); s_c = np.cbrt(s_)
L = 0.2104542553 * l_c + 0.7936177850 * m_c - 0.0040720468 * s_c
a = 1.9779984951 * l_c - 2.4285922050 * m_c + 0.4505937099 * s_c
b_ = 0.0259040371 * l_c + 0.7827717662 * m_c - 0.8086757660 * s_c
return L, a, b_
def oklab_to_linear_rgb(L, a, b):
"""OKLAB to linear sRGB. Returns (r, g, b) float32 arrays [0,1]."""
l_ = L + 0.3963377774 * a + 0.2158037573 * b
m_ = L - 0.1055613458 * a - 0.0638541728 * b
s_ = L - 0.0894841775 * a - 1.2914855480 * b
l_c = l_ ** 3; m_c = m_ ** 3; s_c = s_ ** 3
r = +4.0767416621 * l_c - 3.3077115913 * m_c + 0.2309699292 * s_c
g = -1.2684380046 * l_c + 2.6097574011 * m_c - 0.3413193965 * s_c
b_ = -0.0041960863 * l_c - 0.7034186147 * m_c + 1.7076147010 * s_c
return np.clip(r, 0, 1), np.clip(g, 0, 1), np.clip(b_, 0, 1)
# --- Convenience: sRGB uint8 <-> OKLAB ---
def rgb_to_oklab(R, G, B):
"""sRGB uint8 arrays to OKLAB."""
r = srgb_to_linear(R.astype(np.float32) / 255.0)
g = srgb_to_linear(G.astype(np.float32) / 255.0)
b = srgb_to_linear(B.astype(np.float32) / 255.0)
return linear_rgb_to_oklab(r, g, b)
def oklab_to_rgb(L, a, b):
"""OKLAB to sRGB uint8 arrays."""
r, g, b_ = oklab_to_linear_rgb(L, a, b)
R = np.clip(linear_to_srgb(r) * 255, 0, 255).astype(np.uint8)
G = np.clip(linear_to_srgb(g) * 255, 0, 255).astype(np.uint8)
B = np.clip(linear_to_srgb(b_) * 255, 0, 255).astype(np.uint8)
return R, G, B
# --- OKLCH (cylindrical form of OKLAB) ---
def oklab_to_oklch(L, a, b):
"""OKLAB to OKLCH. Returns (L, C, H) where H is in [0, 1] (normalized)."""
C = np.sqrt(a**2 + b**2)
H = (np.arctan2(b, a) / (2 * np.pi)) % 1.0
return L, C, H
def oklch_to_oklab(L, C, H):
"""OKLCH to OKLAB. H in [0, 1]."""
angle = H * 2 * np.pi
a = C * np.cos(angle)
b = C * np.sin(angle)
return L, a, b
```
### Gradient Interpolation (OKLAB vs HSV)
Interpolating colors through OKLAB avoids the hue detours that HSV produces:
```python
def lerp_oklab(color_a, color_b, t_array):
"""Interpolate between two sRGB colors through OKLAB.
color_a, color_b: (R, G, B) tuples 0-255
t_array: float32 array [0,1] — interpolation parameter per pixel.
Returns (R, G, B) uint8 arrays."""
La, aa, ba = rgb_to_oklab(
np.full_like(t_array, color_a[0], dtype=np.uint8),
np.full_like(t_array, color_a[1], dtype=np.uint8),
np.full_like(t_array, color_a[2], dtype=np.uint8))
Lb, ab, bb = rgb_to_oklab(
np.full_like(t_array, color_b[0], dtype=np.uint8),
np.full_like(t_array, color_b[1], dtype=np.uint8),
np.full_like(t_array, color_b[2], dtype=np.uint8))
L = La + (Lb - La) * t_array
a = aa + (ab - aa) * t_array
b = ba + (bb - ba) * t_array
return oklab_to_rgb(L, a, b)
def lerp_oklch(color_a, color_b, t_array, short_path=True):
"""Interpolate through OKLCH (preserves chroma, smooth hue path).
short_path: take the shorter arc around the hue wheel."""
La, aa, ba = rgb_to_oklab(
np.full_like(t_array, color_a[0], dtype=np.uint8),
np.full_like(t_array, color_a[1], dtype=np.uint8),
np.full_like(t_array, color_a[2], dtype=np.uint8))
Lb, ab, bb = rgb_to_oklab(
np.full_like(t_array, color_b[0], dtype=np.uint8),
np.full_like(t_array, color_b[1], dtype=np.uint8),
np.full_like(t_array, color_b[2], dtype=np.uint8))
L1, C1, H1 = oklab_to_oklch(La, aa, ba)
L2, C2, H2 = oklab_to_oklch(Lb, ab, bb)
# Shortest hue path
if short_path:
dh = H2 - H1
dh = np.where(dh > 0.5, dh - 1.0, np.where(dh < -0.5, dh + 1.0, dh))
H = (H1 + dh * t_array) % 1.0
else:
H = H1 + (H2 - H1) * t_array
L = L1 + (L2 - L1) * t_array
C = C1 + (C2 - C1) * t_array
Lout, aout, bout = oklch_to_oklab(L, C, H)
return oklab_to_rgb(Lout, aout, bout)
```
### Color Harmony Generation
Auto-generate harmonious palettes from a seed color:
```python
def harmony_complementary(seed_rgb):
"""Two colors: seed + opposite hue."""
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
_, C, H = oklab_to_oklch(L, a, b)
return [seed_rgb, _oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.5) % 1.0)]
def harmony_triadic(seed_rgb):
"""Three colors: seed + two at 120-degree offsets."""
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
_, C, H = oklab_to_oklch(L, a, b)
return [seed_rgb,
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.333) % 1.0),
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.667) % 1.0)]
def harmony_analogous(seed_rgb, spread=0.08, n=5):
"""N colors spread evenly around seed hue."""
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
_, C, H = oklab_to_oklch(L, a, b)
offsets = np.linspace(-spread * (n-1)/2, spread * (n-1)/2, n)
return [_oklch_to_srgb_tuple(L[0], C[0], (H[0] + off) % 1.0) for off in offsets]
def harmony_split_complementary(seed_rgb, split=0.08):
"""Three colors: seed + two flanking the complement."""
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
_, C, H = oklab_to_oklch(L, a, b)
comp = (H[0] + 0.5) % 1.0
return [seed_rgb,
_oklch_to_srgb_tuple(L[0], C[0], (comp - split) % 1.0),
_oklch_to_srgb_tuple(L[0], C[0], (comp + split) % 1.0)]
def harmony_tetradic(seed_rgb):
"""Four colors: two complementary pairs at 90-degree offset."""
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
_, C, H = oklab_to_oklch(L, a, b)
return [seed_rgb,
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.25) % 1.0),
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.5) % 1.0),
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.75) % 1.0)]
def _oklch_to_srgb_tuple(L, C, H):
"""Helper: single OKLCH -> sRGB (R,G,B) int tuple."""
La = np.array([L]); Ca = np.array([C]); Ha = np.array([H])
Lo, ao, bo = oklch_to_oklab(La, Ca, Ha)
R, G, B = oklab_to_rgb(Lo, ao, bo)
return (int(R[0]), int(G[0]), int(B[0]))
```
### OKLAB Hue Fields
Drop-in replacements for `hf_*` generators that produce perceptually uniform hue variation:
```python
def hf_oklch_angle(offset=0.0, chroma=0.12, lightness=0.7):
"""OKLCH hue mapped to angle from center. Perceptually uniform rainbow.
Returns (R, G, B) uint8 color array instead of a float hue.
NOTE: Use with _render_vf_rgb() variant, not standard _render_vf()."""
def fn(g, f, t, S):
H = (g.angle / (2 * np.pi) + offset + t * 0.05) % 1.0
L = np.full_like(H, lightness)
C = np.full_like(H, chroma)
Lo, ao, bo = oklch_to_oklab(L, C, H)
R, G, B = oklab_to_rgb(Lo, ao, bo)
return mkc(R, G, B, g.rows, g.cols)
return fn
```
### Compositing Helpers
```python
@@ -458,7 +740,7 @@ subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_path,
### v2 Protocol (Current)
Every scene function: `(renderer, features_dict, time_float, state_dict) -> canvas_uint8`
Every scene function: `(r, f, t, S) -> canvas_uint8` — where `r` = Renderer, `f` = features dict, `t` = time float, `S` = persistent state dict
```python
def fx_example(r, f, t, S):

View File

@@ -1,6 +1,14 @@
# Composition & Brightness Reference
The composable system is the core of visual complexity. It operates at three levels: pixel-level blend modes, multi-grid composition, and adaptive brightness management. This document covers all three.
The composable system is the core of visual complexity. It operates at three levels: pixel-level blend modes, multi-grid composition, and adaptive brightness management. This document covers all three, plus the masking/stencil system for spatial control.
**Cross-references:**
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
- Effect building blocks (value fields, hue fields, particles): `effects.md`
- Scene protocol, render_clip, SCENES table: `scenes.md`
- Shader pipeline, feedback buffer: `shaders.md`
- Complete scene examples with blend/mask usage: `examples.md`
- Blend mode pitfalls (overlay crush, division by zero): `troubleshooting.md`
## Pixel-Level Blend Modes
@@ -102,6 +110,69 @@ result = blend_canvas(result, canvas_c, "difference", 0.6)
Order matters: `screen(A, B)` is commutative, but `difference(screen(A,B), C)` differs from `difference(A, screen(B,C))`.
### Linear-Light Blend Modes
Standard `blend_canvas()` operates in sRGB space — the raw byte values. This is fine for most uses, but sRGB is perceptually non-linear: blending in sRGB darkens midtones and shifts hues slightly. For physically accurate blending (matching how light actually combines), convert to linear light first.
Uses `srgb_to_linear()` / `linear_to_srgb()` from `architecture.md` § OKLAB Color System.
```python
def blend_canvas_linear(base, top, mode="normal", opacity=1.0):
"""Blend in linear light space for physically accurate results.
Identical API to blend_canvas(), but converts sRGB → linear before
blending and linear → sRGB after. More expensive (~2x) due to the
gamma conversions, but produces correct results for additive blending,
screen, and any mode where brightness matters.
"""
af = srgb_to_linear(base.astype(np.float32) / 255.0)
bf = srgb_to_linear(top.astype(np.float32) / 255.0)
fn = BLEND_MODES.get(mode, BLEND_MODES["normal"])
result = fn(af, bf)
if opacity < 1.0:
result = af * (1 - opacity) + result * opacity
result = linear_to_srgb(np.clip(result, 0, 1))
return np.clip(result * 255, 0, 255).astype(np.uint8)
```
**When to use `blend_canvas_linear()` vs `blend_canvas()`:**
| Scenario | Use | Why |
|----------|-----|-----|
| Screen-blending two bright layers | `linear` | sRGB screen over-brightens highlights |
| Add mode for glow/bloom effects | `linear` | Additive light follows linear physics |
| Blending text overlay at low opacity | `srgb` | Perceptual blending looks more natural for text |
| Multiply for shadow/darkening | `srgb` | Differences are minimal for darken ops |
| Color-critical work (matching reference) | `linear` | Avoids sRGB hue shifts in midtones |
| Performance-critical inner loop | `srgb` | ~2x faster, good enough for most ASCII art |
**Batch version** for compositing many layers (converts once, blends multiple, converts back):
```python
def blend_many_linear(layers, modes, opacities):
"""Blend a stack of layers in linear light space.
Args:
layers: list of uint8 (H,W,3) canvases
modes: list of blend mode strings (len = len(layers) - 1)
opacities: list of floats (len = len(layers) - 1)
Returns:
uint8 (H,W,3) canvas
"""
# Convert all to linear at once
linear = [srgb_to_linear(l.astype(np.float32) / 255.0) for l in layers]
result = linear[0]
for i in range(1, len(linear)):
fn = BLEND_MODES.get(modes[i-1], BLEND_MODES["normal"])
blended = fn(result, linear[i])
op = opacities[i-1]
if op < 1.0:
blended = result * (1 - op) + blended * op
result = np.clip(blended, 0, 1)
result = linear_to_srgb(result)
return np.clip(result * 255, 0, 255).astype(np.uint8)
```
---
## Multi-Grid Composition
@@ -219,19 +290,22 @@ def tonemap(canvas, target_mean=90, gamma=0.75, black_point=2, white_point=253):
"""Adaptive tone-mapping: normalizes + gamma-corrects so no frame is
fully dark or washed out.
1. Compute 1st and 99.5th percentile (ignores outlier pixels)
1. Compute 1st and 99.5th percentile on 4x subsample (16x fewer values,
negligible accuracy loss, major speedup at 1080p+)
2. Stretch that range to [0, 1]
3. Apply gamma curve (< 1 lifts shadows, > 1 darkens)
4. Rescale to [black_point, white_point]
"""
f = canvas.astype(np.float32)
lo = np.percentile(f, 1)
hi = np.percentile(f, 99.5)
sub = f[::4, ::4] # 4x subsample: ~390K values vs ~6.2M at 1080p
lo = np.percentile(sub, 1)
hi = np.percentile(sub, 99.5)
if hi - lo < 10:
hi = max(hi, lo + 10) # near-uniform frame fallback
f = np.clip((f - lo) / (hi - lo), 0.0, 1.0)
f = np.power(f, gamma)
f = f * (white_point - black_point) + black_point
np.power(f, gamma, out=f) # in-place: avoids allocation
np.multiply(f, (white_point - black_point), out=f)
np.add(f, black_point, out=f)
return np.clip(f, 0, 255).astype(np.uint8)
```
@@ -453,6 +527,208 @@ class FeedbackBuffer:
---
## Masking / Stencil System
Masks are float32 arrays `(rows, cols)` or `(VH, VW)` in range [0, 1]. They control where effects are visible: 1.0 = fully visible, 0.0 = fully hidden. Use masks to create figure/ground relationships, focal points, and shaped reveals.
### Shape Masks
```python
def mask_circle(g, cx_frac=0.5, cy_frac=0.5, radius=0.3, feather=0.05):
"""Circular mask centered at (cx_frac, cy_frac) in normalized coords.
feather: width of soft edge (0 = hard cutoff)."""
asp = g.cw / g.ch if hasattr(g, 'cw') else 1.0
dx = (g.cc / g.cols - cx_frac)
dy = (g.rr / g.rows - cy_frac) * asp
d = np.sqrt(dx**2 + dy**2)
if feather > 0:
return np.clip(1.0 - (d - radius) / feather, 0, 1)
return (d <= radius).astype(np.float32)
def mask_rect(g, x0=0.2, y0=0.2, x1=0.8, y1=0.8, feather=0.03):
"""Rectangular mask. Coordinates in [0,1] normalized."""
dx = np.maximum(x0 - g.cc / g.cols, g.cc / g.cols - x1)
dy = np.maximum(y0 - g.rr / g.rows, g.rr / g.rows - y1)
d = np.maximum(dx, dy)
if feather > 0:
return np.clip(1.0 - d / feather, 0, 1)
return (d <= 0).astype(np.float32)
def mask_ring(g, cx_frac=0.5, cy_frac=0.5, inner_r=0.15, outer_r=0.35,
feather=0.03):
"""Ring / annulus mask."""
inner = mask_circle(g, cx_frac, cy_frac, inner_r, feather)
outer = mask_circle(g, cx_frac, cy_frac, outer_r, feather)
return outer - inner
def mask_gradient_h(g, start=0.0, end=1.0):
"""Left-to-right gradient mask."""
return np.clip((g.cc / g.cols - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
def mask_gradient_v(g, start=0.0, end=1.0):
"""Top-to-bottom gradient mask."""
return np.clip((g.rr / g.rows - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
def mask_gradient_radial(g, cx_frac=0.5, cy_frac=0.5, inner=0.0, outer=0.5):
"""Radial gradient mask — bright at center, dark at edges."""
d = np.sqrt((g.cc / g.cols - cx_frac)**2 + (g.rr / g.rows - cy_frac)**2)
return np.clip(1.0 - (d - inner) / (outer - inner + 1e-10), 0, 1)
```
### Value Field as Mask
Use any `vf_*` function's output as a spatial mask:
```python
def mask_from_vf(vf_result, threshold=0.5, feather=0.1):
"""Convert a value field to a mask by thresholding.
feather: smooth edge width around threshold."""
if feather > 0:
return np.clip((vf_result - threshold + feather) / (2 * feather), 0, 1)
return (vf_result > threshold).astype(np.float32)
def mask_select(mask, vf_a, vf_b):
"""Spatial conditional: show vf_a where mask is 1, vf_b where mask is 0.
mask: float32 [0,1] array. Intermediate values blend."""
return vf_a * mask + vf_b * (1 - mask)
```
### Text Stencil
Render text to a mask. Effects are visible only through the letterforms:
```python
def mask_text(grid, text, row_frac=0.5, font=None, font_size=None):
"""Render text string as a float32 mask [0,1] at grid resolution.
Characters = 1.0, background = 0.0.
row_frac: vertical position as fraction of grid height.
font: PIL ImageFont (defaults to grid's font if None).
font_size: override font size for the mask text (for larger stencil text).
"""
from PIL import Image, ImageDraw, ImageFont
f = font or grid.font
if font_size and font != grid.font:
f = ImageFont.truetype(font.path, font_size)
# Render text to image at pixel resolution, then downsample to grid
img = Image.new("L", (grid.cols * grid.cw, grid.ch), 0)
draw = ImageDraw.Draw(img)
bbox = draw.textbbox((0, 0), text, font=f)
tw = bbox[2] - bbox[0]
x = (grid.cols * grid.cw - tw) // 2
draw.text((x, 0), text, fill=255, font=f)
row_mask = np.array(img, dtype=np.float32) / 255.0
# Place in full grid mask
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
target_row = int(grid.rows * row_frac)
# Downsample rendered text to grid cells
for c in range(grid.cols):
px = c * grid.cw
if px + grid.cw <= row_mask.shape[1]:
cell = row_mask[:, px:px + grid.cw]
if cell.mean() > 0.1:
mask[target_row, c] = cell.mean()
return mask
def mask_text_block(grid, lines, start_row_frac=0.3, font=None):
"""Multi-line text stencil. Returns full grid mask."""
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
for i, line in enumerate(lines):
row_frac = start_row_frac + i / grid.rows
line_mask = mask_text(grid, line, row_frac, font)
mask = np.maximum(mask, line_mask)
return mask
```
### Animated Masks
Masks that change over time for reveals, wipes, and morphing:
```python
def mask_iris(g, t, t_start, t_end, cx_frac=0.5, cy_frac=0.5,
max_radius=0.7, ease_fn=None):
"""Iris open/close: circle that grows from 0 to max_radius.
ease_fn: easing function (default: ease_in_out_cubic from effects.md)."""
if ease_fn is None:
ease_fn = lambda x: x * x * (3 - 2 * x) # smoothstep fallback
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
radius = ease_fn(progress) * max_radius
return mask_circle(g, cx_frac, cy_frac, radius, feather=0.03)
def mask_wipe_h(g, t, t_start, t_end, direction="right"):
"""Horizontal wipe reveal."""
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
if direction == "left":
progress = 1 - progress
return mask_gradient_h(g, start=progress - 0.05, end=progress + 0.05)
def mask_wipe_v(g, t, t_start, t_end, direction="down"):
"""Vertical wipe reveal."""
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
if direction == "up":
progress = 1 - progress
return mask_gradient_v(g, start=progress - 0.05, end=progress + 0.05)
def mask_dissolve(g, t, t_start, t_end, seed=42):
"""Random pixel dissolve — noise threshold sweeps from 0 to 1."""
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
rng = np.random.RandomState(seed)
noise = rng.random((g.rows, g.cols)).astype(np.float32)
return (noise < progress).astype(np.float32)
```
### Mask Boolean Operations
```python
def mask_union(a, b):
"""OR — visible where either mask is active."""
return np.maximum(a, b)
def mask_intersect(a, b):
"""AND — visible only where both masks are active."""
return np.minimum(a, b)
def mask_subtract(a, b):
"""A minus B — visible where A is active but B is not."""
return np.clip(a - b, 0, 1)
def mask_invert(m):
"""NOT — flip mask."""
return 1.0 - m
```
### Applying Masks to Canvases
```python
def apply_mask_canvas(canvas, mask, bg_canvas=None):
"""Apply a grid-resolution mask to a pixel canvas.
Expands mask from (rows, cols) to (VH, VW) via nearest-neighbor.
canvas: uint8 (VH, VW, 3)
mask: float32 (rows, cols) [0,1]
bg_canvas: what shows through where mask=0. None = black.
"""
# Expand mask to pixel resolution
mask_px = np.repeat(np.repeat(mask, canvas.shape[0] // mask.shape[0] + 1, axis=0),
canvas.shape[1] // mask.shape[1] + 1, axis=1)
mask_px = mask_px[:canvas.shape[0], :canvas.shape[1]]
if bg_canvas is not None:
return np.clip(canvas * mask_px[:, :, None] +
bg_canvas * (1 - mask_px[:, :, None]), 0, 255).astype(np.uint8)
return np.clip(canvas * mask_px[:, :, None], 0, 255).astype(np.uint8)
def apply_mask_vf(vf_a, vf_b, mask):
"""Apply mask at value-field level — blend two value fields spatially.
All arrays are (rows, cols) float32."""
return vf_a * mask + vf_b * (1 - mask)
```
---
## PixelBlendStack
Higher-level wrapper for multi-layer compositing:

View File

@@ -0,0 +1,193 @@
# Scene Design Patterns
**Cross-references:**
- Scene protocol, SCENES table: `scenes.md`
- Blend modes, multi-grid composition, tonemap: `composition.md`
- Effect building blocks (value fields, noise, SDFs): `effects.md`
- Shader pipeline, feedback buffer: `shaders.md`
- Complete scene examples: `examples.md`
Higher-order patterns for composing scenes that feel intentional rather than random. These patterns use the existing building blocks (value fields, blend modes, shaders, feedback) but organize them with compositional intent.
## Layer Hierarchy
Every scene should have clear visual layers with distinct roles:
| Layer | Grid | Brightness | Purpose |
|-------|------|-----------|---------|
| **Background** | xs or sm (dense) | 0.10.25 | Atmosphere, texture. Never competes with content. |
| **Content** | md (balanced) | 0.40.8 | The main visual idea. Carries the scene's concept. |
| **Accent** | lg or sm (sparse) | 0.51.0 (sparse coverage) | Highlights, punctuation, sparse bright points. |
The background sets mood. The content layer is what the scene *is about*. The accent adds visual interest without overwhelming.
```python
def fx_example(r, f, t, S):
local = t
progress = min(local / 5.0, 1.0)
g_bg = r.get_grid("sm")
g_main = r.get_grid("md")
g_accent = r.get_grid("lg")
# --- Background: dim atmosphere ---
bg_val = vf_smooth_noise(g_bg, f, t * 0.3, S, octaves=2, bri=0.15)
# ... render bg to canvas
# --- Content: the main visual idea ---
content_val = vf_spiral(g_main, f, t, S, n_arms=n_arms, tightness=tightness)
# ... render content on top of canvas
# --- Accent: sparse highlights ---
accent_val = vf_noise_static(g_accent, f, t, S, density=0.05)
# ... render accent on top
return canvas
```
## Directional Parameter Arcs
Parameters should *go somewhere* over the scene's duration — not oscillate aimlessly with `sin(t * N)`.
**Bad:** `twist = 3.0 + 2.0 * math.sin(t * 0.6)` — wobbles back and forth, feels aimless.
**Good:** `twist = 2.0 + progress * 5.0` — starts gentle, ends intense. The scene *builds*.
Use `progress = min(local / duration, 1.0)` (0→1 over the scene) to drive directional change:
| Pattern | Formula | Feel |
|---------|---------|------|
| Linear ramp | `progress * range` | Steady buildup |
| Ease-out | `1 - (1 - progress) ** 2` | Fast start, gentle finish |
| Ease-in | `progress ** 2` | Slow start, accelerating |
| Step reveal | `np.clip((progress - 0.5) / 0.25, 0, 1)` | Nothing until 50%, then fades in |
| Build + plateau | `min(1.0, progress * 1.5)` | Reaches full at 67%, holds |
Oscillation is fine for *secondary* parameters (saturation shimmer, hue drift). But the *defining* parameter of the scene should have a direction.
### Examples of Directional Arcs
| Scene concept | Parameter | Arc |
|--------------|-----------|-----|
| Emergence | Ring radius | 0 → max (ease-out) |
| Shatter | Voronoi cell count | 8 → 38 (linear) |
| Descent | Tunnel speed | 2.0 → 10.0 (linear) |
| Mandala | Shape complexity | ring → +polygon → +star → +rosette (step reveals) |
| Crescendo | Layer count | 1 → 7 (staggered entry) |
| Entropy | Geometry visibility | 1.0 → 0.0 (consumed) |
## Scene Concepts
Each scene should be built around a *visual idea*, not an effect name.
**Bad:** "fx_plasma_cascade" — named after the effect. No concept.
**Good:** "fx_emergence" — a point of light expands into a field. The name tells you *what happens*.
Good scene concepts have:
1. A **visual metaphor** (emergence, descent, collision, entropy)
2. A **directional arc** (things change from A to B, not oscillate)
3. **Motivated layer choices** (each layer serves the concept)
4. **Motivated feedback** (transform direction matches the metaphor)
| Concept | Metaphor | Feedback transform | Why |
|---------|----------|-------------------|-----|
| Emergence | Birth, expansion | zoom-out | Past frames expand outward |
| Descent | Falling, acceleration | zoom-in | Past frames rush toward center |
| Inferno | Rising fire | shift-up | Past frames rise with the flames |
| Entropy | Decay, dissolution | none | Clean, no persistence — things disappear |
| Crescendo | Accumulation | zoom + hue_shift | Everything compounds and shifts |
## Compositional Techniques
### Counter-Rotating Dual Systems
Two instances of the same effect rotating in opposite directions create visual interference:
```python
# Primary spiral (clockwise)
s1_val = vf_spiral(g_main, f, t * 1.5, S, n_arms=n_arms_1, tightness=tightness_1)
# Counter-rotating spiral (counter-clockwise via negative time)
s2_val = vf_spiral(g_accent, f, -t * 1.2, S, n_arms=n_arms_2, tightness=tightness_2)
# Screen blend creates bright interference at crossing points
canvas = blend_canvas(canvas_with_s1, c2, "screen", 0.7)
```
Works with spirals, vortexes, rings. The counter-rotation creates constantly shifting interference patterns.
### Wave Collision
Two wave fronts converging from opposite sides, meeting at a collision point:
```python
collision_phase = abs(progress - 0.5) * 2 # 1→0→1 (0 at collision)
# Wave A approaches from left
offset_a = (1 - progress) * g.cols * 0.4
wave_a = np.sin((g.cc + offset_a) * 0.08 + t * 2) * 0.5 + 0.5
# Wave B approaches from right
offset_b = -(1 - progress) * g.cols * 0.4
wave_b = np.sin((g.cc + offset_b) * 0.08 - t * 2) * 0.5 + 0.5
# Interference peaks at collision
combined = wave_a * 0.5 + wave_b * 0.5 + np.abs(wave_a - wave_b) * (1 - collision_phase) * 0.5
```
### Progressive Fragmentation
Voronoi with cell count increasing over time — visual shattering:
```python
n_pts = int(8 + progress * 30) # 8 cells → 38 cells
# Pre-generate enough points, slice to n_pts
px = base_x[:n_pts] + np.sin(t * 0.3 + np.arange(n_pts) * 0.7) * (3 + progress * 3)
```
The edge glow width can also increase with progress to emphasize the cracks.
### Entropy / Consumption
A clean geometric pattern being overtaken by an organic process:
```python
# Geometry fades out
geo_val = clean_pattern * max(0.05, 1.0 - progress * 0.9)
# Organic process grows in
rd_val = vf_reaction_diffusion(g, f, t, S) * min(1.0, progress * 1.5)
# Render geometry first, organic on top — organic consumes geometry
```
### Staggered Layer Entry (Crescendo)
Layers enter one at a time, building to overwhelming density:
```python
def layer_strength(enter_t, ramp=1.5):
"""0.0 until enter_t, ramps to 1.0 over ramp seconds."""
return max(0.0, min(1.0, (local - enter_t) / ramp))
# Layer 1: always present
s1 = layer_strength(0.0)
# Layer 2: enters at 2s
s2 = layer_strength(2.0)
# Layer 3: enters at 4s
s3 = layer_strength(4.0)
# ... etc
# Each layer uses a different effect, grid, palette, and blend mode
# Screen blend between layers so they accumulate light
```
For a 15-second crescendo, 7 layers entering every 2 seconds works well. Use different blend modes (screen for most, add for energy, colordodge for the final wash).
## Scene Ordering
For a multi-scene reel or video:
- **Vary mood between adjacent scenes** — don't put two calm scenes next to each other
- **Randomize order** rather than grouping by type — prevents "effect demo" feel
- **End on the strongest scene** — crescendo or something with a clear payoff
- **Open with energy** — grab attention in the first 2 seconds

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,416 @@
# Scene Examples
**Cross-references:**
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
- Scene protocol, render_clip, SCENES table: `scenes.md`
- Shader pipeline, feedback buffer, ShaderChain: `shaders.md`
- Input sources (audio features, video features): `inputs.md`
- Performance tuning: `optimization.md`
- Common bugs: `troubleshooting.md`
Copy-paste-ready scene functions at increasing complexity. Each is a complete, working v2 scene function that returns a pixel canvas. See `scenes.md` for the scene protocol and `composition.md` for blend modes and tonemap.
---
## Minimal — Single Grid, Single Effect
### Breathing Plasma
One grid, one value field, one hue field. The simplest possible scene.
```python
def fx_breathing_plasma(r, f, t, S):
"""Plasma field with time-cycling hue. Audio modulates brightness."""
canvas = _render_vf(r, "md",
lambda g, f, t, S: vf_plasma(g, f, t, S) * 1.3,
hf_time_cycle(0.08), PAL_DENSE, f, t, S, sat=0.8)
return canvas
```
### Reaction-Diffusion Coral
Single grid, simulation-based field. Evolves organically over time.
```python
def fx_coral(r, f, t, S):
"""Gray-Scott reaction-diffusion — coral branching pattern.
Slow-evolving, organic. Best for ambient/chill sections."""
canvas = _render_vf(r, "sm",
lambda g, f, t, S: vf_reaction_diffusion(g, f, t, S,
feed=0.037, kill=0.060, steps_per_frame=6, init_mode="center"),
hf_distance(0.55, 0.015), PAL_DOTS, f, t, S, sat=0.7)
return canvas
```
### SDF Geometry
Geometric shapes from SDFs. Clean, precise, graphic.
```python
def fx_sdf_rings(r, f, t, S):
"""Concentric SDF rings with smooth pulsing."""
def val_fn(g, f, t, S):
d1 = sdf_ring(g, radius=0.15 + f.get("bass", 0.3) * 0.05, thickness=0.015)
d2 = sdf_ring(g, radius=0.25 + f.get("mid", 0.3) * 0.05, thickness=0.012)
d3 = sdf_ring(g, radius=0.35 + f.get("hi", 0.3) * 0.04, thickness=0.010)
combined = sdf_smooth_union(sdf_smooth_union(d1, d2, 0.05), d3, 0.05)
return sdf_glow(combined, falloff=0.08) * (0.5 + f.get("rms", 0.3) * 0.8)
canvas = _render_vf(r, "md", val_fn, hf_angle(0.0), PAL_STARS, f, t, S, sat=0.85)
return canvas
```
---
## Standard — Two Grids + Blend
### Tunnel Through Noise
Two grids at different densities, screen blended. The fine noise texture shows through the coarser tunnel characters.
```python
def fx_tunnel_noise(r, f, t, S):
"""Tunnel depth on md grid + fBM noise on sm grid, screen blended."""
canvas_a = _render_vf(r, "md",
lambda g, f, t, S: vf_tunnel(g, f, t, S, speed=4.0, complexity=8) * 1.2,
hf_distance(0.5, 0.02), PAL_BLOCKS, f, t, S, sat=0.7)
canvas_b = _render_vf(r, "sm",
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=4, freq=0.05, speed=0.15) * 1.3,
hf_time_cycle(0.06), PAL_RUNE, f, t, S, sat=0.6)
return blend_canvas(canvas_a, canvas_b, "screen", 0.7)
```
### Voronoi Cells + Spiral Overlay
Voronoi cell edges with a spiral arm pattern overlaid.
```python
def fx_voronoi_spiral(r, f, t, S):
"""Voronoi edge detection on md + logarithmic spiral on lg."""
canvas_a = _render_vf(r, "md",
lambda g, f, t, S: vf_voronoi(g, f, t, S,
n_cells=15, mode="edge", edge_width=2.0, speed=0.4),
hf_angle(0.2), PAL_CIRCUIT, f, t, S, sat=0.75)
canvas_b = _render_vf(r, "lg",
lambda g, f, t, S: vf_spiral(g, f, t, S, n_arms=4, tightness=3.0) * 1.2,
hf_distance(0.1, 0.03), PAL_BLOCKS, f, t, S, sat=0.9)
return blend_canvas(canvas_a, canvas_b, "exclusion", 0.6)
```
### Domain-Warped fBM
Two layers of the same fBM, one domain-warped, difference-blended for psychedelic organic texture.
```python
def fx_organic_warp(r, f, t, S):
"""Clean fBM vs domain-warped fBM, difference blended."""
canvas_a = _render_vf(r, "sm",
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=5, freq=0.04, speed=0.1),
hf_plasma(0.2), PAL_DENSE, f, t, S, sat=0.6)
canvas_b = _render_vf(r, "md",
lambda g, f, t, S: vf_domain_warp(g, f, t, S,
warp_strength=20.0, freq=0.05, speed=0.15),
hf_time_cycle(0.05), PAL_BRAILLE, f, t, S, sat=0.7)
return blend_canvas(canvas_a, canvas_b, "difference", 0.7)
```
---
## Complex — Three Grids + Conditional + Feedback
### Psychedelic Cathedral
Three-grid composition with beat-triggered kaleidoscope and feedback zoom tunnel. The most visually complex pattern.
```python
def fx_cathedral(r, f, t, S):
"""Three-layer cathedral: interference + rings + noise, kaleidoscope on beat,
feedback zoom tunnel."""
# Layer 1: interference pattern on sm grid
canvas_a = _render_vf(r, "sm",
lambda g, f, t, S: vf_interference(g, f, t, S, n_waves=7) * 1.3,
hf_angle(0.0), PAL_MATH, f, t, S, sat=0.8)
# Layer 2: pulsing rings on md grid
canvas_b = _render_vf(r, "md",
lambda g, f, t, S: vf_rings(g, f, t, S, n_base=10, spacing_base=3) * 1.4,
hf_distance(0.3, 0.02), PAL_STARS, f, t, S, sat=0.9)
# Layer 3: temporal noise on lg grid (slow morph)
canvas_c = _render_vf(r, "lg",
lambda g, f, t, S: vf_temporal_noise(g, f, t, S,
freq=0.04, t_freq=0.2, octaves=3),
hf_time_cycle(0.12), PAL_BLOCKS, f, t, S, sat=0.7)
# Blend: A screen B, then difference with C
result = blend_canvas(canvas_a, canvas_b, "screen", 0.8)
result = blend_canvas(result, canvas_c, "difference", 0.5)
# Beat-triggered kaleidoscope
if f.get("bdecay", 0) > 0.3:
folds = 6 if f.get("sub_r", 0.3) > 0.4 else 8
result = sh_kaleidoscope(result.copy(), folds=folds)
return result
# Scene table entry with feedback:
# {"start": 30.0, "end": 50.0, "name": "cathedral", "fx": fx_cathedral,
# "gamma": 0.65, "shaders": [("bloom", {"thr": 110}), ("chromatic", {"amt": 4}),
# ("vignette", {"s": 0.2}), ("grain", {"amt": 8})],
# "feedback": {"decay": 0.75, "blend": "screen", "opacity": 0.35,
# "transform": "zoom", "transform_amt": 0.012, "hue_shift": 0.015}}
```
### Masked Reaction-Diffusion with Attractor Overlay
Reaction-diffusion visible only through an animated iris mask, with a strange attractor density field underneath.
```python
def fx_masked_life(r, f, t, S):
"""Attractor base + reaction-diffusion visible through iris mask + particles."""
g_sm = r.get_grid("sm")
g_md = r.get_grid("md")
# Layer 1: strange attractor density field (background)
canvas_bg = _render_vf(r, "sm",
lambda g, f, t, S: vf_strange_attractor(g, f, t, S,
attractor="clifford", n_points=30000),
hf_time_cycle(0.04), PAL_DOTS, f, t, S, sat=0.5)
# Layer 2: reaction-diffusion (foreground, will be masked)
canvas_rd = _render_vf(r, "md",
lambda g, f, t, S: vf_reaction_diffusion(g, f, t, S,
feed=0.046, kill=0.063, steps_per_frame=4, init_mode="ring"),
hf_angle(0.15), PAL_HALFFILL, f, t, S, sat=0.85)
# Animated iris mask — opens over first 5 seconds of scene
scene_start = S.get("_scene_start", t)
if "_scene_start" not in S:
S["_scene_start"] = t
mask = mask_iris(g_md, t, scene_start, scene_start + 5.0,
max_radius=0.6)
canvas_rd = apply_mask_canvas(canvas_rd, mask, bg_canvas=canvas_bg)
# Layer 3: flow-field particles following the R-D gradient
rd_field = vf_reaction_diffusion(g_sm, f, t, S,
feed=0.046, kill=0.063, steps_per_frame=0) # read without stepping
ch_p, co_p = update_flow_particles(S, g_sm, f, rd_field,
n=300, speed=0.8, char_set=list("·•◦∘°"))
canvas_p = g_sm.render(ch_p, co_p)
result = blend_canvas(canvas_rd, canvas_p, "add", 0.7)
return result
```
### Morphing Field Sequence with Eased Keyframes
Demonstrates temporal coherence: smooth morphing between effects with keyframed parameters.
```python
def fx_morphing_journey(r, f, t, S):
"""Morphs through 4 value fields over 20 seconds with eased transitions.
Parameters (twist, arm count) also keyframed."""
# Keyframed twist parameter
twist = keyframe(t, [(0, 1.0), (5, 5.0), (10, 2.0), (15, 8.0), (20, 1.0)],
ease_fn=ease_in_out_cubic, loop=True)
# Sequence of value fields with 2s crossfade
fields = [
lambda g, f, t, S: vf_plasma(g, f, t, S),
lambda g, f, t, S: vf_vortex(g, f, t, S, twist=twist),
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=5, freq=0.04),
lambda g, f, t, S: vf_domain_warp(g, f, t, S, warp_strength=15),
]
durations = [5.0, 5.0, 5.0, 5.0]
val_fn = lambda g, f, t, S: vf_sequence(g, f, t, S, fields, durations,
crossfade=2.0)
# Render with slowly rotating hue
canvas = _render_vf(r, "md", val_fn, hf_time_cycle(0.06),
PAL_DENSE, f, t, S, sat=0.8)
# Second layer: tiled version of same sequence at smaller grid
tiled_fn = lambda g, f, t, S: vf_sequence(
make_tgrid(g, *uv_tile(g, 3, 3, mirror=True)),
f, t, S, fields, durations, crossfade=2.0)
canvas_b = _render_vf(r, "sm", tiled_fn, hf_angle(0.1),
PAL_RUNE, f, t, S, sat=0.6)
return blend_canvas(canvas, canvas_b, "screen", 0.5)
```
---
## Specialized — Unique State Patterns
### Game of Life with Ghost Trails
Cellular automaton with analog fade trails. Beat injects random cells.
```python
def fx_life(r, f, t, S):
"""Conway's Game of Life with fading ghost trails.
Beat events inject random live cells for disruption."""
canvas = _render_vf(r, "sm",
lambda g, f, t, S: vf_game_of_life(g, f, t, S,
rule="life", steps_per_frame=1, fade=0.92, density=0.25),
hf_fixed(0.33), PAL_BLOCKS, f, t, S, sat=0.8)
# Overlay: coral automaton on lg grid for chunky texture
canvas_b = _render_vf(r, "lg",
lambda g, f, t, S: vf_game_of_life(g, f, t, S,
rule="coral", steps_per_frame=1, fade=0.85, density=0.15, seed=99),
hf_time_cycle(0.1), PAL_HATCH, f, t, S, sat=0.6)
return blend_canvas(canvas, canvas_b, "screen", 0.5)
```
### Boids Flock Over Voronoi
Emergent swarm movement over a cellular background.
```python
def fx_boid_swarm(r, f, t, S):
"""Flocking boids over animated voronoi cells."""
# Background: voronoi cells
canvas_bg = _render_vf(r, "md",
lambda g, f, t, S: vf_voronoi(g, f, t, S,
n_cells=20, mode="distance", speed=0.2),
hf_distance(0.4, 0.02), PAL_CIRCUIT, f, t, S, sat=0.5)
# Foreground: boids
g = r.get_grid("md")
ch_b, co_b = update_boids(S, g, f, n_boids=150, perception=6.0,
max_speed=1.5, char_set=list("▸▹►▻→⟶"))
canvas_boids = g.render(ch_b, co_b)
# Trails for the boids
# (boid positions are stored in S["boid_x"], S["boid_y"])
S["px"] = list(S.get("boid_x", []))
S["py"] = list(S.get("boid_y", []))
ch_t, co_t = draw_particle_trails(S, g, max_trail=6, fade=0.6)
canvas_trails = g.render(ch_t, co_t)
result = blend_canvas(canvas_bg, canvas_trails, "add", 0.3)
result = blend_canvas(result, canvas_boids, "add", 0.9)
return result
```
### Fire Rising Through SDF Text Stencil
Fire effect visible only through text letterforms.
```python
def fx_fire_text(r, f, t, S):
"""Fire columns visible through text stencil. Text acts as window."""
g = r.get_grid("lg")
# Full-screen fire (will be masked)
canvas_fire = _render_vf(r, "sm",
lambda g, f, t, S: np.clip(
vf_fbm(g, f, t, S, octaves=4, freq=0.08, speed=0.8) *
(1.0 - g.rr / g.rows) * # fade toward top
(0.6 + f.get("bass", 0.3) * 0.8), 0, 1),
hf_fixed(0.05), PAL_BLOCKS, f, t, S, sat=0.9) # fire hue
# Background: dark domain warp
canvas_bg = _render_vf(r, "md",
lambda g, f, t, S: vf_domain_warp(g, f, t, S,
warp_strength=8, freq=0.03, speed=0.05) * 0.3,
hf_fixed(0.6), PAL_DENSE, f, t, S, sat=0.4)
# Text stencil mask
mask = mask_text(g, "FIRE", row_frac=0.45)
# Expand vertically for multi-row coverage
for offset in range(-2, 3):
shifted = mask_text(g, "FIRE", row_frac=0.45 + offset / g.rows)
mask = mask_union(mask, shifted)
canvas_masked = apply_mask_canvas(canvas_fire, mask, bg_canvas=canvas_bg)
return canvas_masked
```
### Portrait Mode: Vertical Rain + Quote
Optimized for 9:16. Uses vertical space for long rain trails and stacked text.
```python
def fx_portrait_rain_quote(r, f, t, S):
"""Portrait-optimized: matrix rain (long vertical trails) with stacked quote.
Designed for 1080x1920 (9:16)."""
g = r.get_grid("md") # ~112x100 in portrait
# Matrix rain — long trails benefit from portrait's extra rows
ch, co, S = eff_matrix_rain(g, f, t, S,
hue=0.33, bri=0.6, pal=PAL_KATA, speed_base=0.4, speed_beat=2.5)
canvas_rain = g.render(ch, co)
# Tunnel depth underneath for texture
canvas_tunnel = _render_vf(r, "sm",
lambda g, f, t, S: vf_tunnel(g, f, t, S, speed=3.0, complexity=6) * 0.8,
hf_fixed(0.33), PAL_BLOCKS, f, t, S, sat=0.5)
result = blend_canvas(canvas_tunnel, canvas_rain, "screen", 0.8)
# Quote text — portrait layout: short lines, many of them
g_text = r.get_grid("lg") # ~90x80 in portrait
quote_lines = layout_text_portrait(
"The code is the art and the art is the code",
max_chars_per_line=20)
# Center vertically
block_start = (g_text.rows - len(quote_lines)) // 2
ch_t = np.full((g_text.rows, g_text.cols), " ", dtype="U1")
co_t = np.zeros((g_text.rows, g_text.cols, 3), dtype=np.uint8)
total_chars = sum(len(l) for l in quote_lines)
progress = min(1.0, (t - S.get("_scene_start", t)) / 3.0)
if "_scene_start" not in S: S["_scene_start"] = t
render_typewriter(ch_t, co_t, quote_lines, block_start, g_text.cols,
progress, total_chars, (200, 255, 220), t)
canvas_text = g_text.render(ch_t, co_t)
result = blend_canvas(result, canvas_text, "add", 0.9)
return result
```
---
## Scene Table Template
Wire scenes into a complete video:
```python
SCENES = [
{"start": 0.0, "end": 5.0, "name": "coral",
"fx": fx_coral, "grid": "sm", "gamma": 0.70,
"shaders": [("bloom", {"thr": 110}), ("vignette", {"s": 0.2})],
"feedback": {"decay": 0.8, "blend": "screen", "opacity": 0.3,
"transform": "zoom", "transform_amt": 0.01}},
{"start": 5.0, "end": 15.0, "name": "tunnel_noise",
"fx": fx_tunnel_noise, "grid": "md", "gamma": 0.75,
"shaders": [("chromatic", {"amt": 3}), ("bloom", {"thr": 120}),
("scanlines", {"intensity": 0.06}), ("grain", {"amt": 8})],
"feedback": None},
{"start": 15.0, "end": 35.0, "name": "cathedral",
"fx": fx_cathedral, "grid": "sm", "gamma": 0.65,
"shaders": [("bloom", {"thr": 100}), ("chromatic", {"amt": 5}),
("color_wobble", {"amt": 0.2}), ("vignette", {"s": 0.18})],
"feedback": {"decay": 0.75, "blend": "screen", "opacity": 0.35,
"transform": "zoom", "transform_amt": 0.012, "hue_shift": 0.015}},
{"start": 35.0, "end": 50.0, "name": "morphing",
"fx": fx_morphing_journey, "grid": "md", "gamma": 0.70,
"shaders": [("bloom", {"thr": 110}), ("grain", {"amt": 6})],
"feedback": {"decay": 0.7, "blend": "screen", "opacity": 0.25,
"transform": "rotate_cw", "transform_amt": 0.003}},
]
```

View File

@@ -1,5 +1,14 @@
# Input Sources
**Cross-references:**
- Grid system, resolution presets: `architecture.md`
- Effect building blocks (audio-reactive modulation): `effects.md`
- Scene protocol, SCENES table (feature routing): `scenes.md`
- Shader pipeline, output encoding: `shaders.md`
- Performance tuning (audio chunking, WAV caching): `optimization.md`
- Common bugs (sample rate, dtype, silence handling): `troubleshooting.md`
- Complete scene examples with feature usage: `examples.md`
## Audio Analysis
### Loading
@@ -294,23 +303,73 @@ For narrated videos (testimonials, quotes, storytelling), generate speech audio
### ElevenLabs Voice Generation
```python
import requests
import requests, time, os
def generate_tts(text, voice_id, api_key, output_path, model="eleven_multilingual_v2"):
"""Generate TTS audio via ElevenLabs API."""
"""Generate TTS audio via ElevenLabs API. Streams response to disk."""
# Skip if already generated (idempotent re-runs)
if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
return
url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
headers = {"xi-api-key": api_key, "Content-Type": "application/json"}
data = {"text": text, "model_id": model,
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75}}
resp = requests.post(url, json=data, headers=headers, timeout=30)
data = {
"text": text,
"model_id": model,
"voice_settings": {
"stability": 0.65,
"similarity_boost": 0.80,
"style": 0.15,
"use_speaker_boost": True,
},
}
resp = requests.post(url, json=data, headers=headers, stream=True)
resp.raise_for_status()
with open(output_path, "wb") as f:
f.write(resp.content)
for chunk in resp.iter_content(chunk_size=4096):
f.write(chunk)
time.sleep(0.3) # rate limit: avoid 429s on batch generation
```
Voice settings notes:
- `stability` 0.65 gives natural variation without drift. Lower (0.3-0.5) for more expressive reads, higher (0.7-0.9) for monotone/narration.
- `similarity_boost` 0.80 keeps it close to the voice profile. Lower for more generic sound.
- `style` 0.15 adds slight stylistic variation. Keep low (0-0.2) for straightforward reads.
- `use_speaker_boost` True improves clarity at the cost of slightly more processing time.
### Voice Pool
ElevenLabs has ~20 built-in voices. Use multiple voices for variety across quotes. Reference pool:
```python
VOICE_POOL = [
("JBFqnCBsd6RMkjVDRZzb", "George"),
("nPczCjzI2devNBz1zQrb", "Brian"),
("pqHfZKP75CvOlQylNhV4", "Bill"),
("CwhRBWXzGAHq8TQ4Fs17", "Roger"),
("cjVigY5qzO86Huf0OWal", "Eric"),
("onwK4e9ZLuTAKqWW03F9", "Daniel"),
("IKne3meq5aSn9XLyUdCD", "Charlie"),
("iP95p4xoKVk53GoZ742B", "Chris"),
("bIHbv24MWmeRgasZH58o", "Will"),
("TX3LPaxmHKxFdv7VOQHJ", "Liam"),
("SAz9YHcvj6GT2YYXdXww", "River"),
("EXAVITQu4vr4xnSDxMaL", "Sarah"),
("Xb7hH8MSUJpSbSDYk0k2", "Alice"),
("pFZP5JQG7iQjIQuC4Bku", "Lily"),
("XrExE9yKIg1WjnnlVkGX", "Matilda"),
("FGY2WhTYpPnrIDTdsKH5", "Laura"),
("SOYHLrjzK2X1ezoPC6cr", "Harry"),
("hpp4J3VqNfWAUOO0d1Us", "Bella"),
("N2lVS1w4EtoT3dr4eOWO", "Callum"),
("cgSgspJ2msm6clMCkdW9", "Jessica"),
("pNInz6obpgDQGcFmaJgB", "Adam"),
]
```
### Voice Assignment
Use multiple voices for variety. Shuffle deterministically so re-runs are consistent:
Shuffle deterministically so re-runs produce the same voice mapping:
```python
import random as _rng
@@ -318,83 +377,199 @@ import random as _rng
def assign_voices(n_quotes, voice_pool, seed=42):
"""Assign a different voice to each quote, cycling if needed."""
r = _rng.Random(seed)
shuffled = list(voice_pool)
r.shuffle(shuffled)
return [shuffled[i % len(shuffled)] for i in range(n_quotes)]
ids = [v[0] for v in voice_pool]
r.shuffle(ids)
return [ids[i % len(ids)] for i in range(n_quotes)]
```
### Pronunciation Control
TTS text should be separate from display text. Common fixes:
TTS text must be separate from display text. The display text has line breaks for visual layout; the TTS text is a flat sentence with phonetic fixes.
Common fixes:
- Brand names: spell phonetically ("Nous" -> "Noose", "nginx" -> "engine-x")
- Abbreviations: expand ("API" -> "A P I", "CLI" -> "C L I")
- Technical terms: add phonetic hints
- Punctuation for pacing: periods create pauses, commas create slight pauses
```python
QUOTES = [("Display text here", "Author")]
QUOTES_TTS = ["TTS text with phonetic spelling here"]
# Display text: line breaks control visual layout
QUOTES = [
("It can do far more than the Claws,\nand you don't need to buy a Mac Mini.\nNous Research has a winner here.", "Brian Roemmele"),
]
# TTS text: flat, phonetically corrected for speech
QUOTES_TTS = [
"It can do far more than the Claws, and you don't need to buy a Mac Mini. Noose Research has a winner here.",
]
# Keep both arrays in sync -- same indices
```
### Audio Pipeline
1. Generate individual TTS clips (MP3/WAV per quote)
2. Get duration of each clip
3. Calculate timing: speech start/end per quote with gaps
1. Generate individual TTS clips (MP3 per quote, skipping existing)
2. Convert each to WAV (mono, 22050 Hz) for duration measurement and concatenation
3. Calculate timing: intro pad + speech + gaps + outro pad = target duration
4. Concatenate into single TTS track with silence padding
5. Mix with background music
```python
def build_tts_track(tts_clips, target_duration, gap_seconds=2.0):
"""Concatenate TTS clips with gaps, pad to target duration."""
# Get durations
def build_tts_track(tts_clips, target_duration, intro_pad=5.0, outro_pad=4.0):
"""Concatenate TTS clips with calculated gaps, pad to target duration.
Returns:
timing: list of (start_time, end_time, quote_index) tuples
"""
sr = 22050
# Convert MP3s to WAV for duration and sample-level concatenation
durations = []
for clip in tts_clips:
wav = clip.replace(".mp3", ".wav")
subprocess.run(
["ffmpeg", "-y", "-i", clip, "-ac", "1", "-ar", str(sr),
"-sample_fmt", "s16", wav],
capture_output=True, check=True)
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", clip],
"-of", "csv=p=0", wav],
capture_output=True, text=True)
durations.append(float(result.stdout.strip()))
# Calculate timing
# Calculate gap to fill target duration
total_speech = sum(durations)
total_gaps = target_duration - total_speech
gap = max(0.5, total_gaps / (len(tts_clips) + 1))
timing = [] # (start, end, quote_index)
t = gap # start after initial gap
n_gaps = len(tts_clips) - 1
remaining = target_duration - total_speech - intro_pad - outro_pad
gap = max(1.0, remaining / max(1, n_gaps))
# Build timing and concatenate samples
timing = []
t = intro_pad
all_audio = [np.zeros(int(sr * intro_pad), dtype=np.int16)]
for i, dur in enumerate(durations):
wav = tts_clips[i].replace(".mp3", ".wav")
with wave.open(wav) as wf:
samples = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16)
timing.append((t, t + dur, i))
t += dur + gap
# Concatenate with ffmpeg
# ... silence padding + concat filter
all_audio.append(samples)
t += dur
if i < len(tts_clips) - 1:
all_audio.append(np.zeros(int(sr * gap), dtype=np.int16))
t += gap
all_audio.append(np.zeros(int(sr * outro_pad), dtype=np.int16))
# Pad or trim to exactly target_duration
full = np.concatenate(all_audio)
target_samples = int(sr * target_duration)
if len(full) < target_samples:
full = np.pad(full, (0, target_samples - len(full)))
else:
full = full[:target_samples]
# Write concatenated TTS track
with wave.open("tts_full.wav", "w") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
wf.writeframes(full.tobytes())
return timing
```
### Audio Mixing
Mix TTS (center) with background music (wide stereo, low volume):
Mix TTS (center) with background music (wide stereo, low volume). The filter chain:
1. TTS mono duplicated to both channels (centered)
2. BGM loudness-normalized, volume reduced to 15%, stereo widened with `extrastereo`
3. Mixed together with dropout transition for smooth endings
```python
def mix_audio(tts_path, bgm_path, output_path, bgm_volume=0.15):
"""Mix TTS centered with BGM panned wide stereo."""
filter_complex = (
# TTS: mono -> stereo center
"[0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=mono,"
"pan=stereo|c0=c0|c1=c0[tts];"
# BGM: normalize loudness, reduce volume, widen stereo
f"[1:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,"
f"loudnorm=I=-16:TP=-1.5:LRA=11,"
f"volume={bgm_volume},"
f"extrastereo=m=2.5[bgm];"
# Mix with smooth dropout at end
"[tts][bgm]amix=inputs=2:duration=longest:dropout_transition=3,"
"aformat=sample_fmts=s16:sample_rates=44100:channel_layouts=stereo[out]"
)
cmd = [
"ffmpeg", "-y",
"-i", tts_path, # mono TTS
"-i", bgm_path, # stereo BGM
"-filter_complex",
f"[0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=mono,"
f"pan=stereo|c0=c0|c1=c0[tts];" # TTS center
f"[1:a]loudnorm=I=-16:TP=-1.5:LRA=11,"
f"volume={bgm_volume},"
f"extrastereo=2.5[bgm];" # BGM wide stereo
f"[tts][bgm]amix=inputs=2:duration=longest[out]",
"-map", "[out]", "-c:a", "pcm_s16le", output_path
"-i", tts_path,
"-i", bgm_path,
"-filter_complex", filter_complex,
"-map", "[out]", output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
```
### Per-Quote Visual Style
Cycle through visual presets per quote for variety. Each preset defines a background effect, color scheme, and text color:
```python
QUOTE_STYLES = [
{"hue": 0.08, "accent": 0.7, "bg": "spiral", "text_rgb": (255, 220, 140)}, # warm gold
{"hue": 0.55, "accent": 0.6, "bg": "rings", "text_rgb": (180, 220, 255)}, # cool blue
{"hue": 0.75, "accent": 0.7, "bg": "wave", "text_rgb": (220, 180, 255)}, # purple
{"hue": 0.35, "accent": 0.6, "bg": "matrix", "text_rgb": (140, 255, 180)}, # green
{"hue": 0.95, "accent": 0.8, "bg": "fire", "text_rgb": (255, 180, 160)}, # red/coral
{"hue": 0.12, "accent": 0.5, "bg": "interference", "text_rgb": (255, 240, 200)}, # amber
{"hue": 0.60, "accent": 0.7, "bg": "tunnel", "text_rgb": (160, 210, 255)}, # cyan
{"hue": 0.45, "accent": 0.6, "bg": "aurora", "text_rgb": (180, 255, 220)}, # teal
]
style = QUOTE_STYLES[quote_index % len(QUOTE_STYLES)]
```
This guarantees no two adjacent quotes share the same look, even without randomness.
### Typewriter Text Rendering
Display quote text character-by-character synced to speech progress. Recently revealed characters are brighter, creating a "just typed" glow:
```python
def render_typewriter(ch, co, lines, block_start, cols, progress, total_chars, text_rgb, t):
"""Overlay typewriter text onto character/color grids.
progress: 0.0 (nothing visible) to 1.0 (all text visible)."""
chars_visible = int(total_chars * min(1.0, progress * 1.2)) # slight overshoot for snappy feel
tr, tg, tb = text_rgb
char_count = 0
for li, line in enumerate(lines):
row = block_start + li
col = (cols - len(line)) // 2
for ci, c in enumerate(line):
if char_count < chars_visible:
age = chars_visible - char_count
bri_factor = min(1.0, 0.5 + 0.5 / (1 + age * 0.015)) # newer = brighter
hue_shift = math.sin(char_count * 0.3 + t * 2) * 0.05
stamp(ch, co, c, row, col + ci,
(int(min(255, tr * bri_factor * (1.0 + hue_shift))),
int(min(255, tg * bri_factor)),
int(min(255, tb * bri_factor * (1.0 - hue_shift)))))
char_count += 1
# Blinking cursor at insertion point
if progress < 1.0 and int(t * 3) % 2 == 0:
# Find cursor position (char_count == chars_visible)
cc = 0
for li, line in enumerate(lines):
for ci, c in enumerate(line):
if cc == chars_visible:
stamp(ch, co, "\u258c", block_start + li,
(cols - len(line)) // 2 + ci, (255, 220, 100))
return
cc += 1
```
### Feature Analysis on Mixed Audio
Run the standard audio analysis (FFT, beat detection) on the final mixed track so visual effects react to both TTS and music:
@@ -404,4 +579,114 @@ Run the standard audio analysis (FFT, beat detection) on the final mixed track s
features = analyze_audio("mixed_final.wav", fps=24)
```
This means visuals will pulse with both the music beats and the speech energy -- creating natural synchronization.
Visuals pulse with both the music beats and the speech energy.
---
## Audio-Video Sync Verification
After rendering, verify that visual beat markers align with actual audio beats. Drift accumulates from frame timing errors, ffmpeg concat boundaries, and rounding in `fi / fps`.
### Beat Timestamp Extraction
```python
def extract_beat_timestamps(features, fps, threshold=0.5):
"""Extract timestamps where beat feature exceeds threshold."""
beat = features["beat"]
timestamps = []
for fi in range(len(beat)):
if beat[fi] > threshold:
timestamps.append(fi / fps)
return timestamps
def extract_visual_beat_timestamps(video_path, fps, brightness_jump=30):
"""Detect visual beats by brightness jumps between consecutive frames.
Returns timestamps where mean brightness increases by more than threshold."""
import subprocess
cmd = ["ffmpeg", "-i", video_path, "-f", "rawvideo", "-pix_fmt", "gray", "-"]
proc = subprocess.run(cmd, capture_output=True)
frames = np.frombuffer(proc.stdout, dtype=np.uint8)
# Infer frame dimensions from total byte count
n_pixels = len(frames)
# For 1080p: 1920*1080 pixels per frame
# Auto-detect from video metadata is more robust:
probe = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0", video_path],
capture_output=True, text=True)
w, h = map(int, probe.stdout.strip().split(","))
ppf = w * h # pixels per frame
n_frames = n_pixels // ppf
frames = frames[:n_frames * ppf].reshape(n_frames, ppf)
means = frames.mean(axis=1)
timestamps = []
for i in range(1, len(means)):
if means[i] - means[i-1] > brightness_jump:
timestamps.append(i / fps)
return timestamps
```
### Sync Report
```python
def sync_report(audio_beats, visual_beats, tolerance_ms=50):
"""Compare audio beat timestamps to visual beat timestamps.
Args:
audio_beats: list of timestamps (seconds) from audio analysis
visual_beats: list of timestamps (seconds) from video brightness analysis
tolerance_ms: max acceptable drift in milliseconds
Returns:
dict with matched/unmatched/drift statistics
"""
tolerance = tolerance_ms / 1000.0
matched = []
unmatched_audio = []
unmatched_visual = list(visual_beats)
for at in audio_beats:
best_match = None
best_delta = float("inf")
for vt in unmatched_visual:
delta = abs(at - vt)
if delta < best_delta:
best_delta = delta
best_match = vt
if best_match is not None and best_delta < tolerance:
matched.append({"audio": at, "visual": best_match, "drift_ms": best_delta * 1000})
unmatched_visual.remove(best_match)
else:
unmatched_audio.append(at)
drifts = [m["drift_ms"] for m in matched]
return {
"matched": len(matched),
"unmatched_audio": len(unmatched_audio),
"unmatched_visual": len(unmatched_visual),
"total_audio_beats": len(audio_beats),
"total_visual_beats": len(visual_beats),
"mean_drift_ms": np.mean(drifts) if drifts else 0,
"max_drift_ms": np.max(drifts) if drifts else 0,
"p95_drift_ms": np.percentile(drifts, 95) if len(drifts) > 1 else 0,
}
# Usage:
audio_beats = extract_beat_timestamps(features, fps=24)
visual_beats = extract_visual_beat_timestamps("output.mp4", fps=24)
report = sync_report(audio_beats, visual_beats)
print(f"Matched: {report['matched']}/{report['total_audio_beats']} beats")
print(f"Mean drift: {report['mean_drift_ms']:.1f}ms, Max: {report['max_drift_ms']:.1f}ms")
# Target: mean drift < 20ms, max drift < 42ms (1 frame at 24fps)
```
### Common Sync Issues
| Symptom | Cause | Fix |
|---------|-------|-----|
| Consistent late visual beats | ffmpeg concat adds frames at boundaries | Use `-vsync cfr` flag; pad segments to exact frame count |
| Drift increases over time | Floating-point accumulation in `t = fi / fps` | Use integer frame counter, compute `t` fresh each frame |
| Random missed beats | Beat threshold too high / feature smoothing too aggressive | Lower threshold; reduce EMA alpha for beat feature |
| Beats land on wrong frame | Off-by-one in frame indexing | Verify: frame 0 = t=0, frame 1 = t=1/fps (not t=0) |

View File

@@ -1,5 +1,15 @@
# Optimization Reference
**Cross-references:**
- Grid system, resolution presets, portrait GridLayer: `architecture.md`
- Effect building blocks (pre-computation strategies): `effects.md`
- `_render_vf()`, tonemap (subsampled percentile): `composition.md`
- Scene protocol, render_clip: `scenes.md`
- Shader pipeline, encoding (ffmpeg flags): `shaders.md`
- Input sources (audio chunking, WAV extraction): `inputs.md`
- Common bugs (memory, OOM, frame drops): `troubleshooting.md`
- Complete scene examples: `examples.md`
## Hardware Detection
Detect the user's hardware at script startup and adapt rendering parameters automatically. Never hardcode worker counts or resolution.
@@ -124,6 +134,8 @@ def apply_quality_profile(profile):
parser = argparse.ArgumentParser()
parser.add_argument("--quality", choices=["draft", "preview", "production", "max", "auto"],
default="auto", help="Render quality preset")
parser.add_argument("--aspect", choices=["landscape", "portrait", "square"],
default="landscape", help="Aspect ratio preset")
parser.add_argument("--workers", type=int, default=0, help="Override worker count (0=auto)")
parser.add_argument("--resolution", type=str, default="", help="Override resolution e.g. 1280x720")
args = parser.parse_args()
@@ -132,6 +144,16 @@ hw = detect_hardware()
if args.workers > 0:
hw["workers"] = args.workers
profile = quality_profile(hw, target_duration, args.quality)
# Apply aspect ratio preset (before manual resolution override)
ASPECT_PRESETS = {
"landscape": (1920, 1080),
"portrait": (1080, 1920),
"square": (1080, 1080),
}
if args.aspect != "landscape" and not args.resolution:
profile["vw"], profile["vh"] = ASPECT_PRESETS[args.aspect]
if args.resolution:
w, h = args.resolution.split("x")
profile["vw"], profile["vh"] = int(w), int(h)
@@ -142,6 +164,47 @@ log(f"Render: {profile['vw']}x{profile['vh']} @{profile['fps']}fps, "
f"CRF {profile['crf']}, {profile['workers']} workers")
```
### Portrait Mode Considerations
Portrait (1080x1920) has the same pixel count as landscape 1080p, so performance is equivalent. But composition patterns differ:
| Concern | Landscape | Portrait |
|---------|-----------|----------|
| Grid cols at `lg` | 160 | 90 |
| Grid rows at `lg` | 45 | 80 |
| Max text line chars | ~50 centered | ~25-30 centered |
| Vertical rain | Short travel | Long, dramatic travel |
| Horizontal spectrum | Full width | Needs rotation or compression |
| Radial effects | Natural circles | Tall ellipses (aspect correction handles this) |
| Particle explosions | Wide spread | Tall spread |
| Text stacking | 3-4 lines comfortable | 8-10 lines comfortable |
| Quote layout | 2-3 wide lines | 5-6 short lines |
**Portrait-optimized patterns:**
- Vertical rain/matrix effects are naturally enhanced — longer column travel
- Fire columns rise through more screen space
- Rising embers/particles have more vertical runway
- Text can be stacked more aggressively with more lines
- Radial effects work if aspect correction is applied (GridLayer handles this automatically)
- Spectrum bars can be rotated 90 degrees (vertical bars from bottom)
**Portrait text layout:**
```python
def layout_text_portrait(text, max_chars_per_line=25, grid=None):
"""Break text into short lines for portrait display."""
words = text.split()
lines = []; current = ""
for w in words:
if len(current) + len(w) + 1 > max_chars_per_line:
lines.append(current.strip())
current = w + " "
else:
current += w + " "
if current.strip():
lines.append(current.strip())
return lines
```
## Performance Budget
Target: 100-200ms per frame (5-10 fps single-threaded, 40-80 fps across 8 workers).
@@ -173,6 +236,74 @@ canvas[y:y+ch, x:x+cw] = np.maximum(canvas[y:y+ch, x:x+cw],
Collect all characters from all palettes + overlay text into the init set. Lazy-init for any missed characters.
## Pre-Rendered Background Textures
Alternative to `_render_vf()` for backgrounds where characters don't need to change every frame. Pre-bake a static ASCII texture once at init, then multiply by a per-cell color field each frame. One matrix multiply vs thousands of bitmap blits.
Use when: background layer uses a fixed character palette and only color/brightness varies per frame. NOT suitable for layers where character selection depends on a changing value field.
### Init: Bake the Texture
```python
# In GridLayer.__init__:
self._bg_row_idx = np.clip(
(np.arange(VH) - self.oy) // self.ch, 0, self.rows - 1
)
self._bg_col_idx = np.clip(
(np.arange(VW) - self.ox) // self.cw, 0, self.cols - 1
)
self._bg_textures = {}
def make_bg_texture(self, palette):
"""Pre-render a static ASCII texture (grayscale float32) once."""
if palette not in self._bg_textures:
texture = np.zeros((VH, VW), dtype=np.float32)
rng = random.Random(12345)
ch_list = [c for c in palette if c != " " and c in self.bm]
if not ch_list:
ch_list = list(self.bm.keys())[:5]
for row in range(self.rows):
y = self.oy + row * self.ch
if y + self.ch > VH:
break
for col in range(self.cols):
x = self.ox + col * self.cw
if x + self.cw > VW:
break
bm = self.bm[rng.choice(ch_list)]
texture[y:y+self.ch, x:x+self.cw] = bm
self._bg_textures[palette] = texture
return self._bg_textures[palette]
```
### Render: Color Field x Cached Texture
```python
def render_bg(self, color_field, palette=PAL_CIRCUIT):
"""Fast background: pre-rendered ASCII texture * per-cell color field.
color_field: (rows, cols, 3) uint8. Returns (VH, VW, 3) uint8."""
texture = self.make_bg_texture(palette)
# Expand cell colors to pixel coords via pre-computed index maps
color_px = color_field[
self._bg_row_idx[:, None], self._bg_col_idx[None, :]
].astype(np.float32)
return (texture[:, :, None] * color_px).astype(np.uint8)
```
### Usage in a Scene
```python
# Build per-cell color from effect fields (cheap — rows*cols, not VH*VW)
hue = ((t * 0.05 + val * 0.2) % 1.0).astype(np.float32)
R, G, B = hsv2rgb(hue, np.full_like(val, 0.5), val)
color_field = mkc(R, G, B, g.rows, g.cols) # (rows, cols, 3) uint8
# Render background — single matrix multiply, no per-cell loop
canvas_bg = g.render_bg(color_field, PAL_DENSE)
```
The texture init loop runs once and is cached per palette. Per-frame cost is one fancy-index lookup + one broadcast multiply — orders of magnitude faster than the per-cell bitmap blit loop in `render()` for dense backgrounds.
## Coordinate Array Caching
Pre-compute all grid-relative coordinate arrays at init, not per-frame:
@@ -215,8 +346,8 @@ all_rows = []
all_cols = []
all_fades = []
for c in range(cols):
head = int(state["ry"][c])
trail_len = state["rln"][c]
head = int(S["ry"][c])
trail_len = S["rln"][c]
for i in range(trail_len):
row = head - i
if 0 <= row < rows:
@@ -254,6 +385,57 @@ for fi in range(n_cols):
# Now map fire_val to chars and colors in one vectorized pass
```
## PIL String Rendering for Text-Heavy Scenes
Alternative to per-cell bitmap blitting when rendering many long text strings (scrolling tickers, typewriter sequences, idea floods). Uses PIL's native `ImageDraw.text()` which renders an entire string in one C call, vs one Python-loop bitmap blit per character.
Typical win: a scene with 56 ticker rows renders 56 PIL `text()` calls instead of ~10K individual bitmap blits.
Use when: scene renders many rows of readable text strings. NOT suitable for sparse or spatially-scattered single characters (use normal `render()` for those).
```python
from PIL import Image, ImageDraw
def render_text_layer(grid, rows_data, font):
"""Render dense text rows via PIL instead of per-cell bitmap blitting.
Args:
grid: GridLayer instance (for oy, ch, ox, font metrics)
rows_data: list of (row_index, text_string, rgb_tuple) — one per row
font: PIL ImageFont instance (grid.font)
Returns:
uint8 array (VH, VW, 3) — canvas with rendered text
"""
img = Image.new("RGB", (VW, VH), (0, 0, 0))
draw = ImageDraw.Draw(img)
for row_idx, text, color in rows_data:
y = grid.oy + row_idx * grid.ch
if y + grid.ch > VH:
break
draw.text((grid.ox, y), text, fill=color, font=font)
return np.array(img)
```
### Usage in a Ticker Scene
```python
# Build ticker data (text + color per row)
rows_data = []
for row in range(n_tickers):
text = build_ticker_text(row, t) # scrolling substring
color = hsv2rgb_scalar(hue, 0.85, bri) # (R, G, B) tuple
rows_data.append((row, text, color))
# One PIL pass instead of thousands of bitmap blits
canvas_tickers = render_text_layer(g_md, rows_data, g_md.font)
# Blend with other layers normally
result = blend_canvas(canvas_bg, canvas_tickers, "screen", 0.9)
```
This is purely a rendering optimization — same visual output, fewer draw calls. The grid's `render()` method is still needed for sparse character fields where characters are placed individually based on value fields.
## Bloom Optimization
**Do NOT use `scipy.ndimage.uniform_filter`** -- measured at 424ms/frame.
@@ -433,3 +615,82 @@ Scale with hardware. Baseline: 1080p, 24fps, ~180ms/frame/worker.
At 720p: multiply times by ~0.5. At 4K: multiply by ~4.
Heavier effects (many particles, dense grids, extra shader passes) add ~20-50%.
---
## Temp File Cleanup
Rendering generates intermediate files that accumulate across runs. Clean up after the final concat/mux step.
### Files to Clean
| File type | Source | Location |
|-----------|--------|----------|
| WAV extracts | `ffmpeg -i input.mp3 ... tmp.wav` | `tempfile.mktemp()` or project dir |
| Segment clips | `render_clip()` output | `segments/seg_00.mp4` etc. |
| Concat list | ffmpeg concat demuxer input | `segments/concat.txt` |
| ffmpeg stderr logs | piped to file for debugging | `*.log` in project dir |
| Feature cache | pickled numpy arrays | `*.pkl` or `*.npz` |
### Cleanup Function
```python
import glob
import tempfile
import shutil
def cleanup_render_artifacts(segments_dir="segments", keep_final=True):
"""Remove intermediate files after successful render.
Call this AFTER verifying the final output exists and plays correctly.
Args:
segments_dir: directory containing segment clips and concat list
keep_final: if True, only delete intermediates (not the final output)
"""
removed = []
# 1. Segment clips
if os.path.isdir(segments_dir):
shutil.rmtree(segments_dir)
removed.append(f"directory: {segments_dir}")
# 2. Temporary WAV files
for wav in glob.glob("*.wav"):
if wav.startswith("tmp") or wav.startswith("extracted_"):
os.remove(wav)
removed.append(wav)
# 3. ffmpeg stderr logs
for log in glob.glob("ffmpeg_*.log"):
os.remove(log)
removed.append(log)
# 4. Feature cache (optional — useful to keep for re-renders)
# for cache in glob.glob("features_*.npz"):
# os.remove(cache)
# removed.append(cache)
print(f"Cleaned {len(removed)} artifacts: {removed}")
return removed
```
### Integration with Render Pipeline
Call cleanup at the end of the main render script, after the final output is verified:
```python
# At end of main()
if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
cleanup_render_artifacts(segments_dir="segments")
print(f"Done. Output: {output_path}")
else:
print("WARNING: final output missing or empty — skipping cleanup")
```
### Temp File Best Practices
- Use `tempfile.mkdtemp()` for segment directories — avoids polluting the project dir
- Name WAV extracts with `tempfile.mktemp(suffix=".wav")` so they're in the OS temp dir
- For debugging, set `KEEP_INTERMEDIATES=1` env var to skip cleanup
- Feature caches (`.npz`) are cheap to store and expensive to recompute — default to keeping them

View File

@@ -1,5 +1,15 @@
# Scene System Reference
**Cross-references:**
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
- Shader pipeline, feedback buffer, ShaderChain: `shaders.md`
- Complete scene examples at every complexity level: `examples.md`
- Input sources (audio features, video features): `inputs.md`
- Performance tuning, portrait CLI: `optimization.md`
- Common bugs (state leaks, frame drops): `troubleshooting.md`
Scenes are the top-level creative unit. Each scene is a time-bounded segment with its own effect function, shader chain, feedback configuration, and tone-mapping gamma.
## Scene Protocol (v2)
@@ -12,7 +22,7 @@ def fx_scene_name(r, f, t, S) -> canvas:
Args:
r: Renderer instance — access multiple grids via r.get_grid("sm")
f: dict of audio/video features, all values normalized to [0, 1]
t: time in seconds (global, not local to scene)
t: time in seconds local to scene (0.0 at scene start)
S: dict for persistent state (particles, rain columns, etc.)
Returns:
@@ -20,6 +30,20 @@ def fx_scene_name(r, f, t, S) -> canvas:
"""
```
**Local time convention:** Scene functions receive `t` starting at 0.0 for the first frame of the scene, regardless of where the scene appears in the timeline. The render loop subtracts the scene's start time before calling the function:
```python
# In render_clip:
t_local = fi / FPS - scene_start
canvas = fx_fn(r, feat, t_local, S)
```
This makes scenes reorderable without modifying their code. Compute scene progress as:
```python
progress = min(t / scene_duration, 1.0) # 0→1 over the scene
```
This replaces the v1 protocol where scenes returned `(chars, colors)` tuples. The v2 protocol gives scenes full control over multi-grid rendering and pixel-level composition internally.
### The Renderer Class

View File

@@ -2,6 +2,15 @@
Post-processing effects applied to the pixel canvas (`numpy uint8 array, shape (H,W,3)`) after character rendering and before encoding. Also covers **pixel-level blend modes**, **feedback buffers**, and the **ShaderChain** compositor.
**Cross-references:**
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
- Effect building blocks (value fields, noise, SDFs): `effects.md`
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
- Scene protocol, render_clip, SCENES table: `scenes.md`
- Complete scene examples with shader usage: `examples.md`
- Performance tuning (frame budget, worker count): `optimization.md`
- Encoding pitfalls (ffmpeg flags, color space): `troubleshooting.md`
## Design Philosophy
The shader pipeline turns raw ASCII renders into cinematic output. The system is designed for **composability** — every shader, blend mode, and feedback transform is an independent building block. Combining them creates infinite visual variety from a small set of primitives.
@@ -1025,3 +1034,324 @@ cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
"-vf", f"fps={fps},scale={W}:{H}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
"-loop", "0", output_gif]
```
### PNG Sequence
For frame-accurate editing, compositing in external tools (After Effects, Nuke), or lossless archival:
```python
import os
def output_png_sequence(frames, output_dir, W, H, fps, prefix="frame"):
"""Write frames as numbered PNGs. frames = iterable of uint8 (H,W,3) arrays."""
os.makedirs(output_dir, exist_ok=True)
# Method 1: Direct PIL write (no ffmpeg dependency)
from PIL import Image
for i, frame in enumerate(frames):
img = Image.fromarray(frame)
img.save(os.path.join(output_dir, f"{prefix}_{i:06d}.png"))
# Method 2: ffmpeg pipe (faster for large sequences)
cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{W}x{H}", "-r", str(fps), "-i", "pipe:0",
os.path.join(output_dir, f"{prefix}_%06d.png")]
```
Reassemble PNG sequence to video:
```bash
ffmpeg -framerate 24 -i frame_%06d.png -c:v libx264 -crf 18 -pix_fmt yuv420p output.mp4
```
### Alpha Channel / Transparent Background (RGBA)
For compositing ASCII art over other video or images. Uses RGBA canvas (4 channels) instead of RGB (3 channels):
```python
def create_rgba_canvas(H, W):
"""Transparent canvas — alpha channel starts at 0 (fully transparent)."""
return np.zeros((H, W, 4), dtype=np.uint8)
def render_char_rgba(canvas, row, col, char_img, color_rgb, alpha=255):
"""Render a character with alpha. char_img = PIL glyph mask (grayscale).
Alpha comes from the glyph mask — background stays transparent."""
r, g, b = color_rgb
y0, x0 = row * cell_h, col * cell_w
mask = np.array(char_img) # grayscale 0-255
canvas[y0:y0+cell_h, x0:x0+cell_w, 0] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 0], (mask * r / 255).astype(np.uint8))
canvas[y0:y0+cell_h, x0:x0+cell_w, 1] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 1], (mask * g / 255).astype(np.uint8))
canvas[y0:y0+cell_h, x0:x0+cell_w, 2] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 2], (mask * b / 255).astype(np.uint8))
canvas[y0:y0+cell_h, x0:x0+cell_w, 3] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 3], mask)
def blend_onto_background(rgba_canvas, bg_rgb):
"""Composite RGBA canvas over a solid or image background."""
alpha = rgba_canvas[:, :, 3:4].astype(np.float32) / 255.0
fg = rgba_canvas[:, :, :3].astype(np.float32)
bg = bg_rgb.astype(np.float32)
result = fg * alpha + bg * (1.0 - alpha)
return result.astype(np.uint8)
```
RGBA output via ffmpeg (ProRes 4444 for editing, WebM VP9 for web):
```bash
# ProRes 4444 — preserves alpha, widely supported in NLEs
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le output.mov
# WebM VP9 — alpha support for web/browser compositing
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
-c:v libvpx-vp9 -pix_fmt yuva420p -crf 30 -b:v 0 output.webm
# PNG sequence with alpha (lossless)
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
frame_%06d.png
```
**Key constraint**: shaders that operate on `(H,W,3)` arrays need adaptation for RGBA. Either apply shaders to the RGB channels only and preserve alpha, or write RGBA-aware versions:
```python
def apply_shader_rgba(canvas_rgba, shader_fn, **kwargs):
"""Apply an RGB shader to the color channels of an RGBA canvas."""
rgb = canvas_rgba[:, :, :3]
alpha = canvas_rgba[:, :, 3:4]
rgb_out = shader_fn(rgb, **kwargs)
return np.concatenate([rgb_out, alpha], axis=2)
```
---
## Real-Time Terminal Rendering
Live ASCII display in the terminal using ANSI escape codes. Useful for previewing scenes during development, live performances, and interactive parameter tuning.
### ANSI Color Escape Codes
```python
def rgb_to_ansi(r, g, b):
"""24-bit true color ANSI escape (supported by most modern terminals)."""
return f"\033[38;2;{r};{g};{b}m"
ANSI_RESET = "\033[0m"
ANSI_CLEAR = "\033[2J\033[H" # clear screen + cursor home
ANSI_HIDE_CURSOR = "\033[?25l"
ANSI_SHOW_CURSOR = "\033[?25h"
```
### Frame-to-ANSI Conversion
```python
def frame_to_ansi(chars, colors):
"""Convert char+color arrays to a single ANSI string for terminal output.
Args:
chars: (rows, cols) array of single characters
colors: (rows, cols, 3) uint8 RGB array
Returns:
str: ANSI-encoded frame ready for sys.stdout.write()
"""
rows, cols = chars.shape
lines = []
for r in range(rows):
parts = []
prev_color = None
for c in range(cols):
rgb = tuple(colors[r, c])
ch = chars[r, c]
if ch == " " or rgb == (0, 0, 0):
parts.append(" ")
else:
if rgb != prev_color:
parts.append(rgb_to_ansi(*rgb))
prev_color = rgb
parts.append(ch)
parts.append(ANSI_RESET)
lines.append("".join(parts))
return "\n".join(lines)
```
### Optimized: Delta Updates
Only redraw characters that changed since the last frame. Eliminates redundant terminal writes for static regions:
```python
def frame_to_ansi_delta(chars, colors, prev_chars, prev_colors):
"""Emit ANSI escapes only for cells that changed."""
rows, cols = chars.shape
parts = []
for r in range(rows):
for c in range(cols):
if (chars[r, c] != prev_chars[r, c] or
not np.array_equal(colors[r, c], prev_colors[r, c])):
parts.append(f"\033[{r+1};{c+1}H") # move cursor
rgb = tuple(colors[r, c])
parts.append(rgb_to_ansi(*rgb))
parts.append(chars[r, c])
return "".join(parts)
```
### Live Render Loop
```python
import sys
import time
def render_live(scene_fn, r, fps=24, duration=None):
"""Render a scene function live in the terminal.
Args:
scene_fn: v2 scene function (r, f, t, S) -> canvas
OR v1-style function that populates a grid
r: Renderer instance
fps: target frame rate
duration: seconds to run (None = run until Ctrl+C)
"""
frame_time = 1.0 / fps
S = {}
f = {} # synthesize features or connect to live audio
sys.stdout.write(ANSI_HIDE_CURSOR + ANSI_CLEAR)
sys.stdout.flush()
t0 = time.monotonic()
frame_count = 0
try:
while True:
t = time.monotonic() - t0
if duration and t > duration:
break
# Synthesize features from time (or connect to live audio via pyaudio)
f = synthesize_features(t)
# Render scene — for terminal, use a small grid
g = r.get_grid("sm")
# Option A: v2 scene → extract chars/colors from canvas (reverse render)
# Option B: call effect functions directly for chars/colors
canvas = scene_fn(r, f, t, S)
# For terminal display, render chars+colors directly
# (bypassing the pixel canvas — terminal uses character cells)
chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g)
frame_str = ANSI_CLEAR + frame_to_ansi(chars, colors)
sys.stdout.write(frame_str)
sys.stdout.flush()
# Frame timing
elapsed = time.monotonic() - t0 - (frame_count * frame_time)
sleep_time = frame_time - elapsed
if sleep_time > 0:
time.sleep(sleep_time)
frame_count += 1
except KeyboardInterrupt:
pass
finally:
sys.stdout.write(ANSI_SHOW_CURSOR + ANSI_RESET + "\n")
sys.stdout.flush()
def scene_to_terminal(scene_fn, r, f, t, S, g):
"""Run effect functions and return (chars, colors) for terminal display.
For terminal mode, skip the pixel canvas and work with character arrays directly."""
# Effects that return (chars, colors) work directly
# For vf-based effects, render the value field + hue field to chars/colors:
val = vf_plasma(g, f, t, S)
hue = hf_time_cycle(0.08)(g, t)
mask = val > 0.03
chars = val2char(val, mask, PAL_DENSE)
R, G, B = hsv2rgb(hue, np.full_like(val, 0.8), val)
colors = mkc(R, G, B, g.rows, g.cols)
return chars, colors
```
### Curses-Based Rendering (More Robust)
For full-featured terminal UIs with proper resize handling and input:
```python
import curses
def render_curses(scene_fn, r, fps=24):
"""Curses-based live renderer with resize handling and key input."""
def _main(stdscr):
curses.start_color()
curses.use_default_colors()
curses.curs_set(0) # hide cursor
stdscr.nodelay(True) # non-blocking input
# Initialize color pairs (curses supports 256 colors)
# Map RGB to nearest curses color pair
color_cache = {}
next_pair = [1]
def get_color_pair(r, g, b):
key = (r >> 4, g >> 4, b >> 4) # quantize to reduce pairs
if key not in color_cache:
if next_pair[0] < curses.COLOR_PAIRS - 1:
ci = 16 + (r // 51) * 36 + (g // 51) * 6 + (b // 51) # 6x6x6 cube
curses.init_pair(next_pair[0], ci, -1)
color_cache[key] = next_pair[0]
next_pair[0] += 1
else:
return 0
return curses.color_pair(color_cache[key])
S = {}
f = {}
frame_time = 1.0 / fps
t0 = time.monotonic()
while True:
t = time.monotonic() - t0
f = synthesize_features(t)
# Adapt grid to terminal size
max_y, max_x = stdscr.getmaxyx()
g = r.get_grid_for_size(max_x, max_y) # dynamic grid sizing
chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g)
rows, cols = chars.shape
for row in range(min(rows, max_y - 1)):
for col in range(min(cols, max_x - 1)):
ch = chars[row, col]
rgb = tuple(colors[row, col])
try:
stdscr.addch(row, col, ch, get_color_pair(*rgb))
except curses.error:
pass # ignore writes outside terminal bounds
stdscr.refresh()
# Handle input
key = stdscr.getch()
if key == ord('q'):
break
time.sleep(max(0, frame_time - (time.monotonic() - t0 - t)))
curses.wrapper(_main)
```
### Terminal Rendering Constraints
| Constraint | Value | Notes |
|-----------|-------|-------|
| Max practical grid | ~200x60 | Depends on terminal size |
| Color support | 24-bit (modern), 256 (fallback), 16 (minimal) | Check `$COLORTERM` for truecolor |
| Frame rate ceiling | ~30 fps | Terminal I/O is the bottleneck |
| Delta updates | 2-5x faster | Only worth it when <30% of cells change per frame |
| SSH latency | Kills performance | Local terminals only for real-time |
**Detect color support:**
```python
import os
def get_terminal_color_depth():
ct = os.environ.get("COLORTERM", "")
if ct in ("truecolor", "24bit"):
return 24
term = os.environ.get("TERM", "")
if "256color" in term:
return 8 # 256 colors
return 4 # 16 colors basic ANSI
```

View File

@@ -1,5 +1,15 @@
# Troubleshooting Reference
**Cross-references:**
- Grid system, palettes, font selection: `architecture.md`
- Effect building blocks (value fields, noise, SDFs): `effects.md`
- `_render_vf()`, blend modes, tonemap: `composition.md`
- Scene protocol, render_clip, SCENES table: `scenes.md`
- Shader pipeline, feedback buffer, encoding: `shaders.md`
- Input sources (audio, video, TTS): `inputs.md`
- Performance tuning, hardware detection: `optimization.md`
- Complete scene examples: `examples.md`
Common bugs, gotchas, and platform-specific issues encountered during ASCII video development.
## NumPy Broadcasting

View File

@@ -0,0 +1,3 @@
---
description: Skills for data science workflows — interactive exploration, Jupyter notebooks, data analysis, and visualization.
---

View File

@@ -0,0 +1,171 @@
---
name: jupyter-live-kernel
description: >
Use a live Jupyter kernel for stateful, iterative Python execution via hamelnb.
Load this skill when the task involves exploration, iteration, or inspecting
intermediate results — data science, ML experimentation, API exploration, or
building up complex code step-by-step. Uses terminal to run CLI commands against
a live Jupyter kernel. No new tools required.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [jupyter, notebook, repl, data-science, exploration, iterative]
category: data-science
---
# Jupyter Live Kernel (hamelnb)
Gives you a **stateful Python REPL** via a live Jupyter kernel. Variables persist
across executions. Use this instead of `execute_code` when you need to build up
state incrementally, explore APIs, inspect DataFrames, or iterate on complex code.
## When to Use This vs Other Tools
| Tool | Use When |
|------|----------|
| **This skill** | Iterative exploration, state across steps, data science, ML, "let me try this and check" |
| `execute_code` | One-shot scripts needing hermes tool access (web_search, file ops). Stateless. |
| `terminal` | Shell commands, builds, installs, git, process management |
**Rule of thumb:** If you'd want a Jupyter notebook for the task, use this skill.
## Prerequisites
1. **uv** must be installed (check: `which uv`)
2. **JupyterLab** must be installed: `uv tool install jupyterlab`
3. A Jupyter server must be running (see Setup below)
## Setup
The hamelnb script location:
```
SCRIPT="$HOME/.agent-skills/hamelnb/skills/jupyter-live-kernel/scripts/jupyter_live_kernel.py"
```
If not cloned yet:
```
git clone https://github.com/hamelsmu/hamelnb.git ~/.agent-skills/hamelnb
```
### Starting JupyterLab
Check if a server is already running:
```
uv run "$SCRIPT" servers
```
If no servers found, start one:
```
jupyter-lab --no-browser --port=8888 --notebook-dir=$HOME/notebooks \
--IdentityProvider.token='' --ServerApp.password='' > /tmp/jupyter.log 2>&1 &
sleep 3
```
Note: Token/password disabled for local agent access. The server runs headless.
### Creating a Notebook for REPL Use
If you just need a REPL (no existing notebook), create a minimal notebook file:
```
mkdir -p ~/notebooks
```
Write a minimal .ipynb JSON file with one empty code cell, then start a kernel
session via the Jupyter REST API:
```
curl -s -X POST http://127.0.0.1:8888/api/sessions \
-H "Content-Type: application/json" \
-d '{"path":"scratch.ipynb","type":"notebook","name":"scratch.ipynb","kernel":{"name":"python3"}}'
```
## Core Workflow
All commands return structured JSON. Always use `--compact` to save tokens.
### 1. Discover servers and notebooks
```
uv run "$SCRIPT" servers --compact
uv run "$SCRIPT" notebooks --compact
```
### 2. Execute code (primary operation)
```
uv run "$SCRIPT" execute --path <notebook.ipynb> --code '<python code>' --compact
```
State persists across execute calls. Variables, imports, objects all survive.
Multi-line code works with $'...' quoting:
```
uv run "$SCRIPT" execute --path scratch.ipynb --code $'import os\nfiles = os.listdir(".")\nprint(f"Found {len(files)} files")' --compact
```
### 3. Inspect live variables
```
uv run "$SCRIPT" variables --path <notebook.ipynb> list --compact
uv run "$SCRIPT" variables --path <notebook.ipynb> preview --name <varname> --compact
```
### 4. Edit notebook cells
```
# View current cells
uv run "$SCRIPT" contents --path <notebook.ipynb> --compact
# Insert a new cell
uv run "$SCRIPT" edit --path <notebook.ipynb> insert \
--at-index <N> --cell-type code --source '<code>' --compact
# Replace cell source (use cell-id from contents output)
uv run "$SCRIPT" edit --path <notebook.ipynb> replace-source \
--cell-id <id> --source '<new code>' --compact
# Delete a cell
uv run "$SCRIPT" edit --path <notebook.ipynb> delete --cell-id <id> --compact
```
### 5. Verification (restart + run all)
Only use when the user asks for a clean verification or you need to confirm
the notebook runs top-to-bottom:
```
uv run "$SCRIPT" restart-run-all --path <notebook.ipynb> --save-outputs --compact
```
## Practical Tips from Experience
1. **First execution after server start may timeout** — the kernel needs a moment
to initialize. If you get a timeout, just retry.
2. **The kernel Python is JupyterLab's Python** — packages must be installed in
that environment. If you need additional packages, install them into the
JupyterLab tool environment first.
3. **--compact flag saves significant tokens** — always use it. JSON output can
be very verbose without it.
4. **For pure REPL use**, create a scratch.ipynb and don't bother with cell editing.
Just use `execute` repeatedly.
5. **Argument order matters** — subcommand flags like `--path` go BEFORE the
sub-subcommand. E.g.: `variables --path nb.ipynb list` not `variables list --path nb.ipynb`.
6. **If a session doesn't exist yet**, you need to start one via the REST API
(see Setup section). The tool can't execute without a live kernel session.
7. **Errors are returned as JSON** with traceback — read the `ename` and `evalue`
fields to understand what went wrong.
8. **Occasional websocket timeouts** — some operations may timeout on first try,
especially after a kernel restart. Retry once before escalating.
## Timeout Defaults
The script has a 30-second default timeout per execution. For long-running
operations, pass `--timeout 120`. Use generous timeouts (60+) for initial
setup or heavy computation.

View File

@@ -0,0 +1,297 @@
---
name: linear
description: Manage Linear issues, projects, and teams via the GraphQL API. Create, update, search, and organize issues. Uses API key auth (no OAuth needed). All operations via curl — no dependencies.
version: 1.0.0
author: Hermes Agent
license: MIT
prerequisites:
env_vars: [LINEAR_API_KEY]
commands: [curl]
metadata:
hermes:
tags: [Linear, Project Management, Issues, GraphQL, API, Productivity]
---
# Linear — Issue & Project Management
Manage Linear issues, projects, and teams directly via the GraphQL API using `curl`. No MCP server, no OAuth flow, no extra dependencies.
## Setup
1. Get a personal API key from **Linear Settings > API > Personal API keys**
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
## API Basics
- **Endpoint:** `https://api.linear.app/graphql` (POST)
- **Auth header:** `Authorization: $LINEAR_API_KEY` (no "Bearer" prefix for API keys)
- **All requests are POST** with `Content-Type: application/json`
- **Both UUIDs and short identifiers** (e.g., `ENG-123`) work for `issue(id:)`
Base curl pattern:
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
```
## Workflow States
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
| Type | Description |
|------|-------------|
| `triage` | Incoming issues needing review |
| `backlog` | Acknowledged but not yet planned |
| `unstarted` | Planned/ready but not started |
| `started` | Actively being worked on |
| `completed` | Done |
| `canceled` | Won't do |
Each team has its own named states (e.g., "In Progress" is type `started`). To change an issue's status, you need the `stateId` (UUID) of the target state — query workflow states first.
**Priority values:** 0 = None, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low
## Common Queries
### Get current user
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ viewer { id name email } }"}' | python3 -m json.tool
```
### List teams
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ teams { nodes { id name key } } }"}' | python3 -m json.tool
```
### List workflow states for a team
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ workflowStates(filter: { team: { key: { eq: \"ENG\" } } }) { nodes { id name type } } }"}' | python3 -m json.tool
```
### List issues (first 20)
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issues(first: 20) { nodes { identifier title priority state { name type } assignee { name } team { key } url } pageInfo { hasNextPage endCursor } } }"}' | python3 -m json.tool
```
### List my assigned issues
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ viewer { assignedIssues(first: 25) { nodes { identifier title state { name type } priority url } } } }"}' | python3 -m json.tool
```
### Get a single issue (by identifier like ENG-123)
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issue(id: \"ENG-123\") { id identifier title description priority state { id name type } assignee { id name } team { key } project { name } labels { nodes { name } } comments { nodes { body user { name } createdAt } } url } }"}' | python3 -m json.tool
```
### Search issues by text
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issueSearch(query: \"bug login\", first: 10) { nodes { identifier title state { name } assignee { name } url } } }"}' | python3 -m json.tool
```
### Filter issues by state type
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issues(filter: { state: { type: { in: [\"started\"] } } }, first: 20) { nodes { identifier title state { name } assignee { name } } } }"}' | python3 -m json.tool
```
### Filter by team and assignee
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issues(filter: { team: { key: { eq: \"ENG\" } }, assignee: { email: { eq: \"user@example.com\" } } }, first: 20) { nodes { identifier title state { name } priority } } }"}' | python3 -m json.tool
```
### List projects
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ projects(first: 20) { nodes { id name description progress lead { name } teams { nodes { key } } url } } }"}' | python3 -m json.tool
```
### List team members
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ users { nodes { id name email active } } }"}' | python3 -m json.tool
```
### List labels
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issueLabels { nodes { id name color } } }"}' | python3 -m json.tool
```
## Common Mutations
### Create an issue
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title url } } }",
"variables": {
"input": {
"teamId": "TEAM_UUID",
"title": "Fix login bug",
"description": "Users cannot login with SSO",
"priority": 2
}
}
}' | python3 -m json.tool
```
### Update issue status
First get the target state UUID from the workflow states query above, then:
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { stateId: \"STATE_UUID\" }) { success issue { identifier state { name type } } } }"}' | python3 -m json.tool
```
### Assign an issue
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { assigneeId: \"USER_UUID\" }) { success issue { identifier assignee { name } } } }"}' | python3 -m json.tool
```
### Set priority
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { priority: 1 }) { success issue { identifier priority } } }"}' | python3 -m json.tool
```
### Add a comment
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { commentCreate(input: { issueId: \"ISSUE_UUID\", body: \"Investigated. Root cause is X.\" }) { success comment { id body } } }"}' | python3 -m json.tool
```
### Set due date
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { dueDate: \"2026-04-01\" }) { success issue { identifier dueDate } } }"}' | python3 -m json.tool
```
### Add labels to an issue
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { labelIds: [\"LABEL_UUID_1\", \"LABEL_UUID_2\"] }) { success issue { identifier labels { nodes { name } } } } }"}' | python3 -m json.tool
```
### Add issue to a project
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { projectId: \"PROJECT_UUID\" }) { success issue { identifier project { name } } } }"}' | python3 -m json.tool
```
### Create a project
```bash
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "mutation($input: ProjectCreateInput!) { projectCreate(input: $input) { success project { id name url } } }",
"variables": {
"input": {
"name": "Q2 Auth Overhaul",
"description": "Replace legacy auth with OAuth2 and PKCE",
"teamIds": ["TEAM_UUID"]
}
}
}' | python3 -m json.tool
```
## Pagination
Linear uses Relay-style cursor pagination:
```bash
# First page
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issues(first: 20) { nodes { identifier title } pageInfo { hasNextPage endCursor } } }"}' | python3 -m json.tool
# Next page — use endCursor from previous response
curl -s -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ issues(first: 20, after: \"CURSOR_FROM_PREVIOUS\") { nodes { identifier title } pageInfo { hasNextPage endCursor } } }"}' | python3 -m json.tool
```
Default page size: 50. Max: 250. Always use `first: N` to limit results.
## Filtering Reference
Comparators: `eq`, `neq`, `in`, `nin`, `lt`, `lte`, `gt`, `gte`, `contains`, `startsWith`, `containsIgnoreCase`
Combine filters with `or: [...]` for OR logic (default is AND within a filter object).
## Typical Workflow
1. **Query teams** to get team IDs and keys
2. **Query workflow states** for target team to get state UUIDs
3. **List or search issues** to find what needs work
4. **Create issues** with team ID, title, description, priority
5. **Update status** by setting `stateId` to the target workflow state
6. **Add comments** to track progress
7. **Mark complete** by setting `stateId` to the team's "completed" type state
## Rate Limits
- 5,000 requests/hour per API key
- 3,000,000 complexity points/hour
- Use `first: N` to limit results and reduce complexity cost
- Monitor `X-RateLimit-Requests-Remaining` response header
## Important Notes
- Always use `terminal` tool with `curl` for API calls — do NOT use `web_extract` or `browser`
- Always check the `errors` array in GraphQL responses — HTTP 200 can still contain errors
- If `stateId` is omitted when creating issues, Linear defaults to the first backlog state
- The `description` field supports Markdown
- Use `python3 -m json.tool` or `jq` to format JSON responses for readability

View File

@@ -0,0 +1,390 @@
---
name: parallel-cli
description: Optional vendor skill for Parallel CLI — agent-native web search, extraction, deep research, enrichment, FindAll, and monitoring. Prefer JSON output and non-interactive flows.
version: 1.1.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Research, Web, Search, Deep-Research, Enrichment, CLI]
related_skills: [duckduckgo-search, mcporter]
---
# Parallel CLI
Use `parallel-cli` when the user explicitly wants Parallel, or when a terminal-native workflow would benefit from Parallel's vendor-specific stack for web search, extraction, deep research, enrichment, entity discovery, or monitoring.
This is an optional third-party workflow, not a Hermes core capability.
Important expectations:
- Parallel is a paid service with a free tier, not a fully free local tool.
- It overlaps with Hermes native `web_search` / `web_extract`, so do not prefer it by default for ordinary lookups.
- Prefer this skill when the user mentions Parallel specifically or needs capabilities like Parallel's enrichment, FindAll, or monitor workflows.
`parallel-cli` is designed for agents:
- JSON output via `--json`
- Non-interactive command execution
- Async long-running jobs with `--no-wait`, `status`, and `poll`
- Context chaining with `--previous-interaction-id`
- Search, extract, research, enrichment, entity discovery, and monitoring in one CLI
## When to use it
Prefer this skill when:
- The user explicitly mentions Parallel or `parallel-cli`
- The task needs richer workflows than a simple one-shot search/extract pass
- You need async deep research jobs that can be launched and polled later
- You need structured enrichment, FindAll entity discovery, or monitoring
Prefer Hermes native `web_search` / `web_extract` for quick one-off lookups when Parallel is not specifically requested.
## Installation
Try the least invasive install path available for the environment.
### Homebrew
```bash
brew install parallel-web/tap/parallel-cli
```
### npm
```bash
npm install -g parallel-web-cli
```
### Python package
```bash
pip install "parallel-web-tools[cli]"
```
### Standalone installer
```bash
curl -fsSL https://parallel.ai/install.sh | bash
```
If you want an isolated Python install, `pipx` can also work:
```bash
pipx install "parallel-web-tools[cli]"
pipx ensurepath
```
## Authentication
Interactive login:
```bash
parallel-cli login
```
Headless / SSH / CI:
```bash
parallel-cli login --device
```
API key environment variable:
```bash
export PARALLEL_API_KEY="***"
```
Verify current auth status:
```bash
parallel-cli auth
```
If auth requires browser interaction, run with `pty=true`.
## Core rule set
1. Always prefer `--json` when you need machine-readable output.
2. Prefer explicit arguments and non-interactive flows.
3. For long-running jobs, use `--no-wait` and then `status` / `poll`.
4. Cite only URLs returned by the CLI output.
5. Save large JSON outputs to a temp file when follow-up questions are likely.
6. Use background processes only for genuinely long-running workflows; otherwise run in foreground.
7. Prefer Hermes native tools unless the user wants Parallel specifically or needs Parallel-only workflows.
## Quick reference
```text
parallel-cli
├── auth
├── login
├── logout
├── search
├── extract / fetch
├── research run|status|poll|processors
├── enrich run|status|poll|plan|suggest|deploy
├── findall run|ingest|status|poll|result|enrich|extend|schema|cancel
└── monitor create|list|get|update|delete|events|event-group|simulate
```
## Common flags and patterns
Commonly useful flags:
- `--json` for structured output
- `--no-wait` for async jobs
- `--previous-interaction-id <id>` for follow-up tasks that reuse earlier context
- `--max-results <n>` for search result count
- `--mode one-shot|agentic` for search behavior
- `--include-domains domain1.com,domain2.com`
- `--exclude-domains domain1.com,domain2.com`
- `--after-date YYYY-MM-DD`
Read from stdin when convenient:
```bash
echo "What is the latest funding for Anthropic?" | parallel-cli search - --json
echo "Research question" | parallel-cli research run - --json
```
## Search
Use for current web lookups with structured results.
```bash
parallel-cli search "What is Anthropic's latest AI model?" --json
parallel-cli search "SEC filings for Apple" --include-domains sec.gov --json
parallel-cli search "bitcoin price" --after-date 2026-01-01 --max-results 10 --json
parallel-cli search "latest browser benchmarks" --mode one-shot --json
parallel-cli search "AI coding agent enterprise reviews" --mode agentic --json
```
Useful constraints:
- `--include-domains` to narrow trusted sources
- `--exclude-domains` to strip noisy domains
- `--after-date` for recency filtering
- `--max-results` when you need broader coverage
If you expect follow-up questions, save output:
```bash
parallel-cli search "latest React 19 changes" --json -o /tmp/react-19-search.json
```
When summarizing results:
- lead with the answer
- include dates, names, and concrete facts
- cite only returned sources
- avoid inventing URLs or source titles
## Extraction
Use to pull clean content or markdown from a URL.
```bash
parallel-cli extract https://example.com --json
parallel-cli extract https://company.com --objective "Find pricing info" --json
parallel-cli extract https://example.com --full-content --json
parallel-cli fetch https://example.com --json
```
Use `--objective` when the page is broad and you only need one slice of information.
## Deep research
Use for deeper multi-step research tasks that may take time.
Common processor tiers:
- `lite` / `base` for faster, cheaper passes
- `core` / `pro` for more thorough synthesis
- `ultra` for the heaviest research jobs
### Synchronous
```bash
parallel-cli research run \
"Compare the leading AI coding agents by pricing, model support, and enterprise controls" \
--processor core \
--json
```
### Async launch + poll
```bash
parallel-cli research run \
"Compare the leading AI coding agents by pricing, model support, and enterprise controls" \
--processor ultra \
--no-wait \
--json
parallel-cli research status trun_xxx --json
parallel-cli research poll trun_xxx --json
parallel-cli research processors --json
```
### Context chaining / follow-up
```bash
parallel-cli research run "What are the top AI coding agents?" --json
parallel-cli research run \
"What enterprise controls does the top-ranked one offer?" \
--previous-interaction-id trun_xxx \
--json
```
Recommended Hermes workflow:
1. launch with `--no-wait --json`
2. capture the returned run/task ID
3. if the user wants to continue other work, keep moving
4. later call `status` or `poll`
5. summarize the final report with citations from the returned sources
## Enrichment
Use when the user has CSV/JSON/tabular inputs and wants additional columns inferred from web research.
### Suggest columns
```bash
parallel-cli enrich suggest "Find the CEO and annual revenue" --json
```
### Plan a config
```bash
parallel-cli enrich plan -o config.yaml
```
### Inline data
```bash
parallel-cli enrich run \
--data '[{"company": "Anthropic"}, {"company": "Mistral"}]' \
--intent "Find headquarters and employee count" \
--json
```
### Non-interactive file run
```bash
parallel-cli enrich run \
--source-type csv \
--source companies.csv \
--target enriched.csv \
--source-columns '[{"name": "company", "description": "Company name"}]' \
--intent "Find the CEO and annual revenue"
```
### YAML config run
```bash
parallel-cli enrich run config.yaml
```
### Status / polling
```bash
parallel-cli enrich status <task_group_id> --json
parallel-cli enrich poll <task_group_id> --json
```
Use explicit JSON arrays for column definitions when operating non-interactively.
Validate the output file before reporting success.
## FindAll
Use for web-scale entity discovery when the user wants a discovered dataset rather than a short answer.
```bash
parallel-cli findall run "Find AI coding agent startups with enterprise offerings" --json
parallel-cli findall run "AI startups in healthcare" -n 25 --json
parallel-cli findall status <run_id> --json
parallel-cli findall poll <run_id> --json
parallel-cli findall result <run_id> --json
parallel-cli findall schema <run_id> --json
```
This is a better fit than ordinary search when the user wants a discovered set of entities that can be reviewed, filtered, or enriched later.
## Monitor
Use for ongoing change detection over time.
```bash
parallel-cli monitor list --json
parallel-cli monitor get <monitor_id> --json
parallel-cli monitor events <monitor_id> --json
parallel-cli monitor delete <monitor_id> --json
```
Creation is usually the sensitive part because cadence and delivery matter:
```bash
parallel-cli monitor create --help
```
Use this when the user wants recurring tracking of a page or source rather than a one-time fetch.
## Recommended Hermes usage patterns
### Fast answer with citations
1. Run `parallel-cli search ... --json`
2. Parse titles, URLs, dates, excerpts
3. Summarize with inline citations from the returned URLs only
### URL investigation
1. Run `parallel-cli extract URL --json`
2. If needed, rerun with `--objective` or `--full-content`
3. Quote or summarize the extracted markdown
### Long research workflow
1. Run `parallel-cli research run ... --no-wait --json`
2. Store the returned ID
3. Continue other work or periodically poll
4. Summarize the final report with citations
### Structured enrichment workflow
1. Inspect the input file and columns
2. Use `enrich suggest` or provide explicit enriched columns
3. Run `enrich run`
4. Poll for completion if needed
5. Validate the output file before reporting success
## Error handling and exit codes
The CLI documents these exit codes:
- `0` success
- `2` bad input
- `3` auth error
- `4` API error
- `5` timeout
If you hit auth errors:
1. check `parallel-cli auth`
2. confirm `PARALLEL_API_KEY` or run `parallel-cli login` / `parallel-cli login --device`
3. verify `parallel-cli` is on `PATH`
## Maintenance
Check current auth / install state:
```bash
parallel-cli auth
parallel-cli --help
```
Update commands:
```bash
parallel-cli update
pip install --upgrade parallel-web-tools
parallel-cli config auto-update-check off
```
## Pitfalls
- Do not omit `--json` unless the user explicitly wants human-formatted output.
- Do not cite sources not present in the CLI output.
- `login` may require PTY/browser interaction.
- Prefer foreground execution for short tasks; do not overuse background processes.
- For large result sets, save JSON to `/tmp/*.json` instead of stuffing everything into context.
- Do not silently choose Parallel when Hermes native tools are already sufficient.
- Remember this is a vendor workflow that usually requires account auth and paid usage beyond the free tier.

View File

@@ -0,0 +1,3 @@
---
description: Skills for interacting with social platforms and social-media workflows — posting, reading, monitoring, and account operations.
---

View File

@@ -0,0 +1,202 @@
---
name: xitter
description: Interact with X/Twitter via the x-cli terminal client using official X API credentials. Use for posting, reading timelines, searching tweets, liking, retweeting, bookmarks, mentions, and user lookups.
version: 1.0.0
author: Siddharth Balyan + Hermes Agent
license: MIT
platforms: [linux, macos]
prerequisites:
commands: [uv]
env_vars: [X_API_KEY, X_API_SECRET, X_BEARER_TOKEN, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET]
metadata:
hermes:
tags: [twitter, x, social-media, x-cli]
homepage: https://github.com/Infatoshi/x-cli
---
# Xitter — X/Twitter via x-cli
Use `x-cli` for official X/Twitter API interactions from the terminal.
This skill is for:
- posting tweets, replies, and quote tweets
- searching tweets and reading timelines
- looking up users, followers, and following
- liking and retweeting
- checking mentions and bookmarks
This skill intentionally does not vendor a separate CLI implementation into Hermes. Install and use upstream `x-cli` instead.
## Important Cost / Access Note
X API access is not meaningfully free for most real usage. Expect to need paid or prepaid X developer access. If commands fail with permissions or quota errors, check your X developer plan first.
## Install
Install upstream `x-cli` with `uv`:
```bash
uv tool install git+https://github.com/Infatoshi/x-cli.git
```
Upgrade later with:
```bash
uv tool upgrade x-cli
```
Verify:
```bash
x-cli --help
```
## Credentials
You need these five values from the X Developer Portal:
- `X_API_KEY`
- `X_API_SECRET`
- `X_BEARER_TOKEN`
- `X_ACCESS_TOKEN`
- `X_ACCESS_TOKEN_SECRET`
Get them from:
- https://developer.x.com/en/portal/dashboard
### Why does X need 5 secrets?
Unfortunately, the official X API splits auth across both app-level and user-level credentials:
- `X_API_KEY` + `X_API_SECRET` identify your app
- `X_BEARER_TOKEN` is used for app-level read access
- `X_ACCESS_TOKEN` + `X_ACCESS_TOKEN_SECRET` let the CLI act as your user account for writes and authenticated actions
So yes — it is a lot of secrets for one integration, but this is the stable official API path and is still preferable to cookie/session scraping.
Setup requirements in the portal:
1. Create or open your app
2. In user authentication settings, set permissions to `Read and write`
3. Generate or regenerate the access token + access token secret after enabling write permissions
4. Save all five values carefully — missing any one of them will usually produce confusing auth or permission errors
Note: upstream `x-cli` expects the full credential set to be present, so even if you mostly care about read-only commands, it is simplest to configure all five.
## Cost / Friction Reality Check
If this setup feels heavier than it should be, that is because it is. Xs official developer flow is high-friction and often paid. This skill chooses the official API path because it is more stable and maintainable than browser-cookie/session approaches.
If the user wants the least brittle long-term setup, use this skill. If they want a zero-setup or unofficial path, that is a different trade-off and not what this skill is for.
## Where to Store Credentials
`x-cli` looks for credentials in `~/.config/x-cli/.env`.
If you already keep your X credentials in `~/.hermes/.env`, the cleanest setup is:
```bash
mkdir -p ~/.config/x-cli
ln -sf ~/.hermes/.env ~/.config/x-cli/.env
```
Or create a dedicated file:
```bash
mkdir -p ~/.config/x-cli
cat > ~/.config/x-cli/.env <<'EOF'
X_API_KEY=your_consumer_key
X_API_SECRET=your_secret_key
X_BEARER_TOKEN=your_bearer_token
X_ACCESS_TOKEN=your_access_token
X_ACCESS_TOKEN_SECRET=your_access_token_secret
EOF
chmod 600 ~/.config/x-cli/.env
```
## Quick Verification
```bash
x-cli user get openai
x-cli tweet search "from:NousResearch" --max 3
x-cli me mentions --max 5
```
If reads work but writes fail, regenerate the access token after confirming `Read and write` permissions.
## Common Commands
### Tweets
```bash
x-cli tweet post "hello world"
x-cli tweet get https://x.com/user/status/1234567890
x-cli tweet delete 1234567890
x-cli tweet reply 1234567890 "nice post"
x-cli tweet quote 1234567890 "worth reading"
x-cli tweet search "AI agents" --max 20
x-cli tweet metrics 1234567890
```
### Users
```bash
x-cli user get openai
x-cli user timeline openai --max 10
x-cli user followers openai --max 50
x-cli user following openai --max 50
```
### Self / Authenticated User
```bash
x-cli me mentions --max 20
x-cli me bookmarks --max 20
x-cli me bookmark 1234567890
x-cli me unbookmark 1234567890
```
### Quick Actions
```bash
x-cli like 1234567890
x-cli retweet 1234567890
```
## Output Modes
Use structured output when the agent needs to inspect fields programmatically:
```bash
x-cli -j tweet search "AI agents" --max 5
x-cli -p user get openai
x-cli -md tweet get 1234567890
x-cli -v -j tweet get 1234567890
```
Recommended defaults:
- `-j` for machine-readable output
- `-v` when you need timestamps, metrics, or metadata
- plain/default mode for quick human inspection
## Agent Workflow
1. Confirm `x-cli` is installed
2. Confirm credentials are present
3. Start with a read command (`user get`, `tweet search`, `me mentions`)
4. Use `-j` when extracting fields for later steps
5. Only perform write actions after confirming the target tweet/user and the user's intent
## Pitfalls
- **Paid API access**: many failures are plan/permission problems, not code problems.
- **403 oauth1-permissions**: regenerate the access token after enabling `Read and write`.
- **Reply restrictions**: X restricts many programmatic replies. `tweet quote` is often more reliable than `tweet reply`.
- **Rate limits**: expect per-endpoint limits and cooldown windows.
- **Credential drift**: if you rotate tokens in `~/.hermes/.env`, make sure `~/.config/x-cli/.env` still points at the current file.
## Notes
- Prefer official API workflows over cookie/session scraping.
- Use tweet URLs or IDs interchangeably — `x-cli` accepts both.
- If bookmark behavior changes upstream, check the upstream README first:
https://github.com/Infatoshi/x-cli

0
tests/acp/__init__.py Normal file
View File

56
tests/acp/test_auth.py Normal file
View File

@@ -0,0 +1,56 @@
"""Tests for acp_adapter.auth — provider detection."""
from acp_adapter.auth import has_provider, detect_provider
class TestHasProvider:
def test_has_provider_with_resolved_runtime(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "openrouter", "api_key": "sk-or-test"},
)
assert has_provider() is True
def test_has_no_provider_when_runtime_has_no_key(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "openrouter", "api_key": ""},
)
assert has_provider() is False
def test_has_no_provider_when_runtime_resolution_fails(self, monkeypatch):
def _boom():
raise RuntimeError("no provider")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
assert has_provider() is False
class TestDetectProvider:
def test_detect_openrouter(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "openrouter", "api_key": "sk-or-test"},
)
assert detect_provider() == "openrouter"
def test_detect_anthropic(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "anthropic", "api_key": "sk-ant-test"},
)
assert detect_provider() == "anthropic"
def test_detect_none_when_no_key(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda: {"provider": "kimi-coding", "api_key": ""},
)
assert detect_provider() is None
def test_detect_none_on_resolution_error(self, monkeypatch):
def _boom():
raise RuntimeError("broken")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
assert detect_provider() is None

239
tests/acp/test_events.py Normal file
View File

@@ -0,0 +1,239 @@
"""Tests for acp_adapter.events — callback factories for ACP notifications."""
import asyncio
from concurrent.futures import Future
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import acp
from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk
from acp_adapter.events import (
make_message_cb,
make_step_cb,
make_thinking_cb,
make_tool_progress_cb,
)
@pytest.fixture()
def mock_conn():
"""Mock ACP Client connection."""
conn = MagicMock(spec=acp.Client)
conn.session_update = AsyncMock()
return conn
@pytest.fixture()
def event_loop_fixture():
"""Create a real event loop for testing threadsafe coroutine submission."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
# ---------------------------------------------------------------------------
# Tool progress callback
# ---------------------------------------------------------------------------
class TestToolProgressCallback:
def test_emits_tool_call_start(self, mock_conn, event_loop_fixture):
"""Tool progress should emit a ToolCallStart update."""
tool_call_ids = {}
loop = event_loop_fixture
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
# Run callback in the event loop context
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("terminal", "$ ls -la", {"command": "ls -la"})
# Should have tracked the tool call ID
assert "terminal" in tool_call_ids
# Should have called run_coroutine_threadsafe
mock_rcts.assert_called_once()
coro = mock_rcts.call_args[0][0]
# The coroutine should be conn.session_update
assert mock_conn.session_update.called or coro is not None
def test_handles_string_args(self, mock_conn, event_loop_fixture):
"""If args is a JSON string, it should be parsed."""
tool_call_ids = {}
loop = event_loop_fixture
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("read_file", "Reading /etc/hosts", '{"path": "/etc/hosts"}')
assert "read_file" in tool_call_ids
def test_handles_non_dict_args(self, mock_conn, event_loop_fixture):
"""If args is not a dict, it should be wrapped."""
tool_call_ids = {}
loop = event_loop_fixture
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("terminal", "$ echo hi", None)
assert "terminal" in tool_call_ids
def test_duplicate_same_name_tool_calls_use_fifo_ids(self, mock_conn, event_loop_fixture):
"""Multiple same-name tool calls should be tracked independently in order."""
tool_call_ids = {}
loop = event_loop_fixture
progress_cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
step_cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
progress_cb("terminal", "$ ls", {"command": "ls"})
progress_cb("terminal", "$ pwd", {"command": "pwd"})
assert len(tool_call_ids["terminal"]) == 2
step_cb(1, [{"name": "terminal", "result": "ok-1"}])
assert len(tool_call_ids["terminal"]) == 1
step_cb(2, [{"name": "terminal", "result": "ok-2"}])
assert "terminal" not in tool_call_ids
# ---------------------------------------------------------------------------
# Thinking callback
# ---------------------------------------------------------------------------
class TestThinkingCallback:
def test_emits_thought_chunk(self, mock_conn, event_loop_fixture):
"""Thinking callback should emit AgentThoughtChunk."""
loop = event_loop_fixture
cb = make_thinking_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("Analyzing the code...")
mock_rcts.assert_called_once()
def test_ignores_empty_text(self, mock_conn, event_loop_fixture):
"""Empty text should not emit any update."""
loop = event_loop_fixture
cb = make_thinking_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
cb("")
mock_rcts.assert_not_called()
# ---------------------------------------------------------------------------
# Step callback
# ---------------------------------------------------------------------------
class TestStepCallback:
def test_completes_tracked_tool_calls(self, mock_conn, event_loop_fixture):
"""Step callback should mark tracked tools as completed."""
tool_call_ids = {"terminal": "tc-abc123"}
loop = event_loop_fixture
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb(1, [{"name": "terminal", "result": "success"}])
# Tool should have been removed from tracking
assert "terminal" not in tool_call_ids
mock_rcts.assert_called_once()
def test_ignores_untracked_tools(self, mock_conn, event_loop_fixture):
"""Tools not in tool_call_ids should be silently ignored."""
tool_call_ids = {}
loop = event_loop_fixture
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
cb(1, [{"name": "unknown_tool", "result": "ok"}])
mock_rcts.assert_not_called()
def test_handles_string_tool_info(self, mock_conn, event_loop_fixture):
"""Tool info as a string (just the name) should work."""
tool_call_ids = {"read_file": "tc-def456"}
loop = event_loop_fixture
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb(2, ["read_file"])
assert "read_file" not in tool_call_ids
mock_rcts.assert_called_once()
# ---------------------------------------------------------------------------
# Message callback
# ---------------------------------------------------------------------------
class TestMessageCallback:
def test_emits_agent_message_chunk(self, mock_conn, event_loop_fixture):
"""Message callback should emit AgentMessageChunk."""
loop = event_loop_fixture
cb = make_message_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
future = MagicMock(spec=Future)
future.result.return_value = None
mock_rcts.return_value = future
cb("Here is your answer.")
mock_rcts.assert_called_once()
def test_ignores_empty_message(self, mock_conn, event_loop_fixture):
"""Empty text should not emit any update."""
loop = event_loop_fixture
cb = make_message_cb(mock_conn, "session-1", loop)
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
cb("")
mock_rcts.assert_not_called()

View File

@@ -0,0 +1,75 @@
"""Tests for acp_adapter.permissions — ACP approval bridging."""
import asyncio
from concurrent.futures import Future
from unittest.mock import MagicMock, patch
import pytest
from acp.schema import (
AllowedOutcome,
DeniedOutcome,
RequestPermissionResponse,
)
from acp_adapter.permissions import make_approval_callback
def _make_response(outcome):
"""Helper to build a RequestPermissionResponse with the given outcome."""
return RequestPermissionResponse(outcome=outcome)
def _setup_callback(outcome, timeout=60.0):
"""
Create a callback wired to a mock request_permission coroutine
that resolves to the given outcome.
Returns:
(callback, mock_request_permission_fn)
"""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
mock_rp = MagicMock(name="request_permission")
response = _make_response(outcome)
# Patch asyncio.run_coroutine_threadsafe so it returns a future
# that immediately yields the response.
future = MagicMock(spec=Future)
future.result.return_value = response
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future):
cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=timeout)
result = cb("rm -rf /", "dangerous command")
return result
class TestApprovalMapping:
def test_approval_allow_once_maps_correctly(self):
outcome = AllowedOutcome(option_id="allow_once", outcome="selected")
result = _setup_callback(outcome)
assert result == "once"
def test_approval_allow_always_maps_correctly(self):
outcome = AllowedOutcome(option_id="allow_always", outcome="selected")
result = _setup_callback(outcome)
assert result == "always"
def test_approval_deny_maps_correctly(self):
outcome = DeniedOutcome(outcome="cancelled")
result = _setup_callback(outcome)
assert result == "deny"
def test_approval_timeout_returns_deny(self):
"""When the future times out, the callback should return 'deny'."""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
mock_rp = MagicMock(name="request_permission")
future = MagicMock(spec=Future)
future.result.side_effect = TimeoutError("timed out")
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future):
cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=0.01)
result = cb("rm -rf /", "dangerous")
assert result == "deny"

297
tests/acp/test_server.py Normal file
View File

@@ -0,0 +1,297 @@
"""Tests for acp_adapter.server — HermesACPAgent ACP server."""
import asyncio
import os
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
import acp
from acp.schema import (
AgentCapabilities,
AuthenticateResponse,
Implementation,
InitializeResponse,
ListSessionsResponse,
LoadSessionResponse,
NewSessionResponse,
PromptResponse,
ResumeSessionResponse,
SessionInfo,
TextContentBlock,
Usage,
)
from acp_adapter.server import HermesACPAgent, HERMES_VERSION
from acp_adapter.session import SessionManager
@pytest.fixture()
def mock_manager():
"""SessionManager with a mock agent factory."""
return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent"))
@pytest.fixture()
def agent(mock_manager):
"""HermesACPAgent backed by a mock session manager."""
return HermesACPAgent(session_manager=mock_manager)
# ---------------------------------------------------------------------------
# initialize
# ---------------------------------------------------------------------------
class TestInitialize:
@pytest.mark.asyncio
async def test_initialize_returns_correct_protocol_version(self, agent):
resp = await agent.initialize(protocol_version=1)
assert isinstance(resp, InitializeResponse)
assert resp.protocol_version == acp.PROTOCOL_VERSION
@pytest.mark.asyncio
async def test_initialize_returns_agent_info(self, agent):
resp = await agent.initialize(protocol_version=1)
assert resp.agent_info is not None
assert isinstance(resp.agent_info, Implementation)
assert resp.agent_info.name == "hermes-agent"
assert resp.agent_info.version == HERMES_VERSION
@pytest.mark.asyncio
async def test_initialize_returns_capabilities(self, agent):
resp = await agent.initialize(protocol_version=1)
caps = resp.agent_capabilities
assert isinstance(caps, AgentCapabilities)
assert caps.session_capabilities is not None
assert caps.session_capabilities.fork is not None
assert caps.session_capabilities.list is not None
# ---------------------------------------------------------------------------
# authenticate
# ---------------------------------------------------------------------------
class TestAuthenticate:
@pytest.mark.asyncio
async def test_authenticate_with_provider_configured(self, agent, monkeypatch):
monkeypatch.setattr(
"acp_adapter.server.has_provider",
lambda: True,
)
resp = await agent.authenticate(method_id="openrouter")
assert isinstance(resp, AuthenticateResponse)
@pytest.mark.asyncio
async def test_authenticate_without_provider(self, agent, monkeypatch):
monkeypatch.setattr(
"acp_adapter.server.has_provider",
lambda: False,
)
resp = await agent.authenticate(method_id="openrouter")
assert resp is None
# ---------------------------------------------------------------------------
# new_session / cancel / load / resume
# ---------------------------------------------------------------------------
class TestSessionOps:
@pytest.mark.asyncio
async def test_new_session_creates_session(self, agent):
resp = await agent.new_session(cwd="/home/user/project")
assert isinstance(resp, NewSessionResponse)
assert resp.session_id
# Session should be retrievable from the manager
state = agent.session_manager.get_session(resp.session_id)
assert state is not None
assert state.cwd == "/home/user/project"
@pytest.mark.asyncio
async def test_cancel_sets_event(self, agent):
resp = await agent.new_session(cwd=".")
state = agent.session_manager.get_session(resp.session_id)
assert not state.cancel_event.is_set()
await agent.cancel(session_id=resp.session_id)
assert state.cancel_event.is_set()
@pytest.mark.asyncio
async def test_cancel_nonexistent_session_is_noop(self, agent):
# Should not raise
await agent.cancel(session_id="does-not-exist")
@pytest.mark.asyncio
async def test_load_session_returns_response(self, agent):
resp = await agent.new_session(cwd="/tmp")
load_resp = await agent.load_session(cwd="/tmp", session_id=resp.session_id)
assert isinstance(load_resp, LoadSessionResponse)
@pytest.mark.asyncio
async def test_load_session_not_found_returns_none(self, agent):
resp = await agent.load_session(cwd="/tmp", session_id="bogus")
assert resp is None
@pytest.mark.asyncio
async def test_resume_session_returns_response(self, agent):
resp = await agent.new_session(cwd="/tmp")
resume_resp = await agent.resume_session(cwd="/tmp", session_id=resp.session_id)
assert isinstance(resume_resp, ResumeSessionResponse)
@pytest.mark.asyncio
async def test_resume_session_creates_new_if_missing(self, agent):
resume_resp = await agent.resume_session(cwd="/tmp", session_id="nonexistent")
assert isinstance(resume_resp, ResumeSessionResponse)
# ---------------------------------------------------------------------------
# list / fork
# ---------------------------------------------------------------------------
class TestListAndFork:
@pytest.mark.asyncio
async def test_list_sessions(self, agent):
await agent.new_session(cwd="/a")
await agent.new_session(cwd="/b")
resp = await agent.list_sessions()
assert isinstance(resp, ListSessionsResponse)
assert len(resp.sessions) == 2
@pytest.mark.asyncio
async def test_fork_session(self, agent):
new_resp = await agent.new_session(cwd="/original")
fork_resp = await agent.fork_session(cwd="/forked", session_id=new_resp.session_id)
assert fork_resp.session_id
assert fork_resp.session_id != new_resp.session_id
# ---------------------------------------------------------------------------
# prompt
# ---------------------------------------------------------------------------
class TestPrompt:
@pytest.mark.asyncio
async def test_prompt_returns_refusal_for_unknown_session(self, agent):
prompt = [TextContentBlock(type="text", text="hello")]
resp = await agent.prompt(prompt=prompt, session_id="nonexistent")
assert isinstance(resp, PromptResponse)
assert resp.stop_reason == "refusal"
@pytest.mark.asyncio
async def test_prompt_returns_end_turn_for_empty_message(self, agent):
new_resp = await agent.new_session(cwd=".")
prompt = [TextContentBlock(type="text", text=" ")]
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
assert resp.stop_reason == "end_turn"
@pytest.mark.asyncio
async def test_prompt_runs_agent(self, agent):
"""The prompt method should call run_conversation on the agent."""
new_resp = await agent.new_session(cwd=".")
state = agent.session_manager.get_session(new_resp.session_id)
# Mock the agent's run_conversation
state.agent.run_conversation = MagicMock(return_value={
"final_response": "Hello! How can I help?",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hello! How can I help?"},
],
})
# Set up a mock connection
mock_conn = MagicMock(spec=acp.Client)
mock_conn.session_update = AsyncMock()
agent._conn = mock_conn
prompt = [TextContentBlock(type="text", text="hello")]
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
assert isinstance(resp, PromptResponse)
assert resp.stop_reason == "end_turn"
state.agent.run_conversation.assert_called_once()
@pytest.mark.asyncio
async def test_prompt_updates_history(self, agent):
"""After a prompt, session history should be updated."""
new_resp = await agent.new_session(cwd=".")
state = agent.session_manager.get_session(new_resp.session_id)
expected_history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hey"},
]
state.agent.run_conversation = MagicMock(return_value={
"final_response": "hey",
"messages": expected_history,
})
mock_conn = MagicMock(spec=acp.Client)
mock_conn.session_update = AsyncMock()
agent._conn = mock_conn
prompt = [TextContentBlock(type="text", text="hi")]
await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
assert state.history == expected_history
@pytest.mark.asyncio
async def test_prompt_sends_final_message_update(self, agent):
"""The final response should be sent as an AgentMessageChunk."""
new_resp = await agent.new_session(cwd=".")
state = agent.session_manager.get_session(new_resp.session_id)
state.agent.run_conversation = MagicMock(return_value={
"final_response": "I can help with that!",
"messages": [],
})
mock_conn = MagicMock(spec=acp.Client)
mock_conn.session_update = AsyncMock()
agent._conn = mock_conn
prompt = [TextContentBlock(type="text", text="help me")]
await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
# session_update should have been called with the final message
mock_conn.session_update.assert_called()
# Get the last call's update argument
last_call = mock_conn.session_update.call_args_list[-1]
update = last_call[1].get("update") or last_call[0][1]
assert update.session_update == "agent_message_chunk"
@pytest.mark.asyncio
async def test_prompt_cancelled_returns_cancelled_stop_reason(self, agent):
"""If cancel is called during prompt, stop_reason should be 'cancelled'."""
new_resp = await agent.new_session(cwd=".")
state = agent.session_manager.get_session(new_resp.session_id)
def mock_run(*args, **kwargs):
# Simulate cancel being set during execution
state.cancel_event.set()
return {"final_response": "interrupted", "messages": []}
state.agent.run_conversation = mock_run
mock_conn = MagicMock(spec=acp.Client)
mock_conn.session_update = AsyncMock()
agent._conn = mock_conn
prompt = [TextContentBlock(type="text", text="do something")]
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
assert resp.stop_reason == "cancelled"
# ---------------------------------------------------------------------------
# on_connect
# ---------------------------------------------------------------------------
class TestOnConnect:
def test_on_connect_stores_client(self, agent):
mock_conn = MagicMock(spec=acp.Client)
agent.on_connect(mock_conn)
assert agent._conn is mock_conn

112
tests/acp/test_session.py Normal file
View File

@@ -0,0 +1,112 @@
"""Tests for acp_adapter.session — SessionManager and SessionState."""
import pytest
from unittest.mock import MagicMock
from acp_adapter.session import SessionManager, SessionState
@pytest.fixture()
def manager():
"""SessionManager with a mock agent factory (avoids needing API keys)."""
return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent"))
# ---------------------------------------------------------------------------
# create / get
# ---------------------------------------------------------------------------
class TestCreateSession:
def test_create_session_returns_state(self, manager):
state = manager.create_session(cwd="/tmp/work")
assert isinstance(state, SessionState)
assert state.cwd == "/tmp/work"
assert state.session_id
assert state.history == []
assert state.agent is not None
def test_create_session_registers_task_cwd(self, manager, monkeypatch):
calls = []
monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda task_id, cwd: calls.append((task_id, cwd)))
state = manager.create_session(cwd="/tmp/work")
assert calls == [(state.session_id, "/tmp/work")]
def test_session_ids_are_unique(self, manager):
s1 = manager.create_session()
s2 = manager.create_session()
assert s1.session_id != s2.session_id
def test_get_session(self, manager):
state = manager.create_session()
fetched = manager.get_session(state.session_id)
assert fetched is state
def test_get_nonexistent_session_returns_none(self, manager):
assert manager.get_session("does-not-exist") is None
# ---------------------------------------------------------------------------
# fork
# ---------------------------------------------------------------------------
class TestForkSession:
def test_fork_session_deep_copies_history(self, manager):
original = manager.create_session()
original.history.append({"role": "user", "content": "hello"})
original.history.append({"role": "assistant", "content": "hi"})
forked = manager.fork_session(original.session_id, cwd="/new")
assert forked is not None
# History should be equal in content
assert len(forked.history) == 2
assert forked.history[0]["content"] == "hello"
# But a deep copy — mutating one doesn't affect the other
forked.history.append({"role": "user", "content": "extra"})
assert len(original.history) == 2
assert len(forked.history) == 3
def test_fork_session_has_new_id(self, manager):
original = manager.create_session()
forked = manager.fork_session(original.session_id)
assert forked is not None
assert forked.session_id != original.session_id
def test_fork_nonexistent_returns_none(self, manager):
assert manager.fork_session("bogus-id") is None
# ---------------------------------------------------------------------------
# list / cleanup / remove
# ---------------------------------------------------------------------------
class TestListAndCleanup:
def test_list_sessions_empty(self, manager):
assert manager.list_sessions() == []
def test_list_sessions_returns_created(self, manager):
s1 = manager.create_session(cwd="/a")
s2 = manager.create_session(cwd="/b")
listing = manager.list_sessions()
ids = {s["session_id"] for s in listing}
assert s1.session_id in ids
assert s2.session_id in ids
assert len(listing) == 2
def test_cleanup_clears_all(self, manager):
manager.create_session()
manager.create_session()
assert len(manager.list_sessions()) == 2
manager.cleanup()
assert manager.list_sessions() == []
def test_remove_session(self, manager):
state = manager.create_session()
assert manager.remove_session(state.session_id) is True
assert manager.get_session(state.session_id) is None
# Removing again returns False
assert manager.remove_session(state.session_id) is False

236
tests/acp/test_tools.py Normal file
View File

@@ -0,0 +1,236 @@
"""Tests for acp_adapter.tools — tool kind mapping and ACP content building."""
import pytest
from acp_adapter.tools import (
TOOL_KIND_MAP,
build_tool_complete,
build_tool_start,
build_tool_title,
extract_locations,
get_tool_kind,
make_tool_call_id,
)
from acp.schema import (
FileEditToolCallContent,
ContentToolCallContent,
ToolCallLocation,
ToolCallStart,
ToolCallProgress,
)
# ---------------------------------------------------------------------------
# TOOL_KIND_MAP coverage
# ---------------------------------------------------------------------------
COMMON_HERMES_TOOLS = ["read_file", "search_files", "terminal", "patch", "write_file", "process"]
class TestToolKindMap:
def test_all_hermes_tools_have_kind(self):
"""Every common hermes tool should appear in TOOL_KIND_MAP."""
for tool in COMMON_HERMES_TOOLS:
assert tool in TOOL_KIND_MAP, f"{tool} missing from TOOL_KIND_MAP"
def test_tool_kind_read_file(self):
assert get_tool_kind("read_file") == "read"
def test_tool_kind_terminal(self):
assert get_tool_kind("terminal") == "execute"
def test_tool_kind_patch(self):
assert get_tool_kind("patch") == "edit"
def test_tool_kind_write_file(self):
assert get_tool_kind("write_file") == "edit"
def test_tool_kind_web_search(self):
assert get_tool_kind("web_search") == "fetch"
def test_tool_kind_execute_code(self):
assert get_tool_kind("execute_code") == "execute"
def test_tool_kind_browser_navigate(self):
assert get_tool_kind("browser_navigate") == "fetch"
def test_unknown_tool_returns_other_kind(self):
assert get_tool_kind("nonexistent_tool_xyz") == "other"
# ---------------------------------------------------------------------------
# make_tool_call_id
# ---------------------------------------------------------------------------
class TestMakeToolCallId:
def test_returns_string(self):
tc_id = make_tool_call_id()
assert isinstance(tc_id, str)
def test_starts_with_tc_prefix(self):
tc_id = make_tool_call_id()
assert tc_id.startswith("tc-")
def test_ids_are_unique(self):
ids = {make_tool_call_id() for _ in range(100)}
assert len(ids) == 100
# ---------------------------------------------------------------------------
# build_tool_title
# ---------------------------------------------------------------------------
class TestBuildToolTitle:
def test_terminal_title_includes_command(self):
title = build_tool_title("terminal", {"command": "ls -la /tmp"})
assert "ls -la /tmp" in title
def test_terminal_title_truncates_long_command(self):
long_cmd = "x" * 200
title = build_tool_title("terminal", {"command": long_cmd})
assert len(title) < 120
assert "..." in title
def test_read_file_title(self):
title = build_tool_title("read_file", {"path": "/etc/hosts"})
assert "/etc/hosts" in title
def test_patch_title(self):
title = build_tool_title("patch", {"path": "main.py", "mode": "replace"})
assert "main.py" in title
def test_search_title(self):
title = build_tool_title("search_files", {"pattern": "TODO"})
assert "TODO" in title
def test_web_search_title(self):
title = build_tool_title("web_search", {"query": "python asyncio"})
assert "python asyncio" in title
def test_unknown_tool_uses_name(self):
title = build_tool_title("some_new_tool", {"foo": "bar"})
assert title == "some_new_tool"
# ---------------------------------------------------------------------------
# build_tool_start
# ---------------------------------------------------------------------------
class TestBuildToolStart:
def test_build_tool_start_for_patch(self):
"""patch should produce a FileEditToolCallContent (diff)."""
args = {
"path": "src/main.py",
"old_string": "print('hello')",
"new_string": "print('world')",
}
result = build_tool_start("tc-1", "patch", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "edit"
# The first content item should be a diff
assert len(result.content) >= 1
diff_item = result.content[0]
assert isinstance(diff_item, FileEditToolCallContent)
assert diff_item.path == "src/main.py"
assert diff_item.new_text == "print('world')"
assert diff_item.old_text == "print('hello')"
def test_build_tool_start_for_write_file(self):
"""write_file should produce a FileEditToolCallContent (diff)."""
args = {"path": "new_file.py", "content": "print('hello')"}
result = build_tool_start("tc-w1", "write_file", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "edit"
assert len(result.content) >= 1
diff_item = result.content[0]
assert isinstance(diff_item, FileEditToolCallContent)
assert diff_item.path == "new_file.py"
def test_build_tool_start_for_terminal(self):
"""terminal should produce text content with the command."""
args = {"command": "ls -la /tmp"}
result = build_tool_start("tc-2", "terminal", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "execute"
assert len(result.content) >= 1
content_item = result.content[0]
assert isinstance(content_item, ContentToolCallContent)
# The wrapped text block should contain the command
text = content_item.content.text
assert "ls -la /tmp" in text
def test_build_tool_start_for_read_file(self):
"""read_file should include the path in content."""
args = {"path": "/etc/hosts", "offset": 1, "limit": 50}
result = build_tool_start("tc-3", "read_file", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "read"
assert len(result.content) >= 1
content_item = result.content[0]
assert isinstance(content_item, ContentToolCallContent)
assert "/etc/hosts" in content_item.content.text
def test_build_tool_start_for_search(self):
"""search_files should include pattern in content."""
args = {"pattern": "TODO", "target": "content"}
result = build_tool_start("tc-4", "search_files", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "search"
assert "TODO" in result.content[0].content.text
def test_build_tool_start_generic_fallback(self):
"""Unknown tools should get a generic text representation."""
args = {"foo": "bar", "baz": 42}
result = build_tool_start("tc-5", "some_tool", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "other"
# ---------------------------------------------------------------------------
# build_tool_complete
# ---------------------------------------------------------------------------
class TestBuildToolComplete:
def test_build_tool_complete_for_terminal(self):
"""Completed terminal call should include output text."""
result = build_tool_complete("tc-2", "terminal", "total 42\ndrwxr-xr-x 2 root root 4096 ...")
assert isinstance(result, ToolCallProgress)
assert result.status == "completed"
assert len(result.content) >= 1
content_item = result.content[0]
assert isinstance(content_item, ContentToolCallContent)
assert "total 42" in content_item.content.text
def test_build_tool_complete_truncates_large_output(self):
"""Very large outputs should be truncated."""
big_output = "x" * 10000
result = build_tool_complete("tc-6", "read_file", big_output)
assert isinstance(result, ToolCallProgress)
display_text = result.content[0].content.text
assert len(display_text) < 6000
assert "truncated" in display_text
# ---------------------------------------------------------------------------
# extract_locations
# ---------------------------------------------------------------------------
class TestExtractLocations:
def test_extract_locations_with_path(self):
args = {"path": "src/app.py", "offset": 42}
locs = extract_locations(args)
assert len(locs) == 1
assert isinstance(locs[0], ToolCallLocation)
assert locs[0].path == "src/app.py"
assert locs[0].line == 42
def test_extract_locations_without_path(self):
args = {"command": "echo hi"}
locs = extract_locations(args)
assert locs == []

View File

@@ -129,6 +129,7 @@ class TestGetTextAuxiliaryClient:
def test_custom_endpoint_over_codex(self, monkeypatch, codex_auth_dir):
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
monkeypatch.setenv("OPENAI_API_KEY", "lm-studio-key")
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
# Override the autouse monkeypatch for codex
monkeypatch.setattr(
"agent.auxiliary_client._read_codex_access_token",
@@ -137,7 +138,7 @@ class TestGetTextAuxiliaryClient:
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client()
assert model == "gpt-4o-mini"
assert model == "my-local-model"
call_kwargs = mock_openai.call_args
assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1"
@@ -150,9 +151,13 @@ class TestGetTextAuxiliaryClient:
from agent.auxiliary_client import CodexAuxiliaryClient
assert isinstance(client, CodexAuxiliaryClient)
def test_returns_none_when_nothing_available(self):
def test_returns_none_when_nothing_available(self, monkeypatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
client, model = get_text_auxiliary_client()
assert client is None
assert model is None
@@ -209,17 +214,21 @@ class TestVisionClientFallback:
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_vision_auxiliary_client()
assert client is not None
assert model == "gpt-4o-mini"
assert model == "my-local-model"
def test_vision_forced_main_returns_none_without_creds(self, monkeypatch):
"""Forced main with no credentials still returns None."""
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
client, model = get_vision_auxiliary_client()
assert client is None
assert model is None
@@ -305,21 +314,23 @@ class TestResolveForcedProvider:
def test_forced_main_uses_custom(self, monkeypatch):
monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = _resolve_forced_provider("main")
assert model == "gpt-4o-mini"
assert model == "my-local-model"
def test_forced_main_skips_openrouter_nous(self, monkeypatch):
"""Even if OpenRouter key is set, 'main' skips it."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = _resolve_forced_provider("main")
# Should use custom endpoint, not OpenRouter
assert model == "gpt-4o-mini"
assert model == "my-local-model"
def test_forced_main_falls_to_codex(self, codex_auth_dir, monkeypatch):
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \

View File

@@ -3,7 +3,7 @@
import pytest
from unittest.mock import patch, MagicMock
from agent.context_compressor import ContextCompressor
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
@pytest.fixture()
@@ -138,7 +138,7 @@ class TestGenerateSummaryNoneContent:
with patch("agent.context_compressor.call_llm", return_value=mock_response):
summary = c._generate_summary(messages)
assert isinstance(summary, str)
assert "CONTEXT SUMMARY" in summary
assert summary.startswith(SUMMARY_PREFIX)
def test_none_content_in_system_message_compress(self):
"""System message with content=None should not crash during compress."""
@@ -153,6 +153,57 @@ class TestGenerateSummaryNoneContent:
assert len(result) < len(msgs)
class TestNonStringContent:
"""Regression: content as dict (e.g., llama.cpp tool calls) must not crash."""
def test_dict_content_coerced_to_string(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = {"text": "some summary"}
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
summary = c._generate_summary(messages)
assert isinstance(summary, str)
assert summary.startswith(SUMMARY_PREFIX)
def test_none_content_coerced_to_empty(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = None
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
messages = [
{"role": "user", "content": "do something"},
{"role": "assistant", "content": "ok"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
summary = c._generate_summary(messages)
# None content → empty string → standardized compaction handoff prefix added
assert summary is not None
assert summary == SUMMARY_PREFIX
class TestSummaryPrefixNormalization:
def test_legacy_prefix_is_replaced(self):
summary = ContextCompressor._with_summary_prefix("[CONTEXT SUMMARY]: did work")
assert summary == f"{SUMMARY_PREFIX}\ndid work"
def test_existing_new_prefix_is_not_duplicated(self):
summary = ContextCompressor._with_summary_prefix(f"{SUMMARY_PREFIX}\ndid work")
assert summary == f"{SUMMARY_PREFIX}\ndid work"
class TestCompressWithClient:
def test_summarization_path(self):
mock_client = MagicMock()
@@ -170,7 +221,7 @@ class TestCompressWithClient:
# Should have summary message in the middle
contents = [m.get("content", "") for m in result]
assert any("CONTEXT SUMMARY" in c for c in contents)
assert any(c.startswith(SUMMARY_PREFIX) for c in contents)
assert len(result) < len(msgs)
def test_summarization_does_not_split_tool_call_pairs(self):
@@ -242,7 +293,9 @@ class TestCompressWithClient:
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
summary_msg = [m for m in result if "CONTEXT SUMMARY" in (m.get("content") or "")]
summary_msg = [
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
]
assert len(summary_msg) == 1
assert summary_msg[0]["role"] == "user"
@@ -270,7 +323,9 @@ class TestCompressWithClient:
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
summary_msg = [m for m in result if "CONTEXT SUMMARY" in (m.get("content") or "")]
summary_msg = [
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
]
assert len(summary_msg) == 1
assert summary_msg[0]["role"] == "assistant"

View File

@@ -2,6 +2,7 @@
import builtins
import importlib
import logging
import sys
from agent.prompt_builder import (
@@ -144,6 +145,23 @@ class TestParseSkillFile:
assert frontmatter == {}
assert desc == ""
def test_logs_parse_failures_and_returns_defaults(self, tmp_path, monkeypatch, caplog):
skill_file = tmp_path / "SKILL.md"
skill_file.write_text("---\nname: broken\n---\n")
def boom(*args, **kwargs):
raise OSError("read exploded")
monkeypatch.setattr(type(skill_file), "read_text", boom)
with caplog.at_level(logging.DEBUG, logger="agent.prompt_builder"):
is_compat, frontmatter, desc = _parse_skill_file(skill_file)
assert is_compat is True
assert frontmatter == {}
assert desc == ""
assert "Failed to parse skill file" in caplog.text
assert str(skill_file) in caplog.text
def test_incompatible_platform_returns_false(self, tmp_path):
skill_file = tmp_path / "SKILL.md"
skill_file.write_text(
@@ -440,6 +458,21 @@ class TestReadSkillConditions:
conditions = _read_skill_conditions(tmp_path / "missing.md")
assert conditions == {}
def test_logs_condition_read_failures_and_returns_empty(self, tmp_path, monkeypatch, caplog):
skill_file = tmp_path / "SKILL.md"
skill_file.write_text("---\nname: broken\n---\n")
def boom(*args, **kwargs):
raise OSError("read exploded")
monkeypatch.setattr(type(skill_file), "read_text", boom)
with caplog.at_level(logging.DEBUG, logger="agent.prompt_builder"):
conditions = _read_skill_conditions(skill_file)
assert conditions == {}
assert "Failed to read skill conditions" in caplog.text
assert str(skill_file) in caplog.text
class TestSkillShouldShow:
def test_no_filter_info_always_shows(self):

View File

@@ -23,6 +23,14 @@ class TestApplyCacheMarker:
_apply_cache_marker(msg, MARKER)
assert msg["cache_control"] == MARKER
def test_empty_string_content_gets_top_level_marker(self):
"""Empty text blocks cannot have cache_control (Anthropic rejects them)."""
msg = {"role": "assistant", "content": ""}
_apply_cache_marker(msg, MARKER)
assert msg["cache_control"] == MARKER
# Must NOT wrap into [{"type": "text", "text": "", "cache_control": ...}]
assert msg["content"] == ""
def test_string_content_wrapped_in_list(self):
msg = {"role": "user", "content": "Hello"}
_apply_cache_marker(msg, MARKER)

View File

@@ -1,5 +1,6 @@
"""Shared fixtures for the hermes-agent test suite."""
import asyncio
import os
import signal
import sys
@@ -59,6 +60,39 @@ def mock_config():
def _timeout_handler(signum, frame):
raise TimeoutError("Test exceeded 30 second timeout")
@pytest.fixture(autouse=True)
def _ensure_current_event_loop(request):
"""Provide a default event loop for sync tests that call get_event_loop().
Python 3.11+ no longer guarantees a current loop for plain synchronous tests.
A number of gateway tests still use asyncio.get_event_loop().run_until_complete(...).
Ensure they always have a usable loop without interfering with pytest-asyncio's
own loop management for @pytest.mark.asyncio tests.
"""
if request.node.get_closest_marker("asyncio") is not None:
yield
return
try:
loop = asyncio.get_event_loop_policy().get_event_loop()
except RuntimeError:
loop = None
created = loop is None or loop.is_closed()
if created:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
yield
finally:
if created and loop is not None:
try:
loop.close()
finally:
asyncio.set_event_loop(None)
@pytest.fixture(autouse=True)
def _enforce_test_timeout():
"""Kill any individual test that takes longer than 30 seconds."""

View File

@@ -106,6 +106,47 @@ class TestDeliverResultMirrorLogging:
)
class TestRunJobSessionPersistence:
def test_run_job_passes_session_db_and_cron_platform(self, tmp_path):
job = {
"id": "test-job",
"name": "test",
"prompt": "hello",
}
fake_db = MagicMock()
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "test-key",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
},
), \
patch("run_agent.AIAgent") as mock_agent_cls:
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ok"}
mock_agent_cls.return_value = mock_agent
success, output, final_response, error = run_job(job)
assert success is True
assert error is None
assert final_response == "ok"
assert "ok" in output
kwargs = mock_agent_cls.call_args.kwargs
assert kwargs["session_db"] is fake_db
assert kwargs["platform"] == "cron"
assert kwargs["session_id"].startswith("cron_test-job_")
fake_db.close.assert_called_once()
class TestRunJobConfigLogging:
"""Verify that config.yaml parse failures are logged, not silently swallowed."""

View File

@@ -1,6 +1,7 @@
"""Tests for gateway/channel_directory.py — channel resolution and display."""
import json
import os
from pathlib import Path
from unittest.mock import patch
@@ -122,7 +123,7 @@ class TestResolveChannelName:
class TestBuildFromSessions:
def _write_sessions(self, tmp_path, sessions_data):
"""Write sessions.json at the path _build_from_sessions expects."""
sessions_path = tmp_path / ".hermes" / "sessions" / "sessions.json"
sessions_path = tmp_path / "sessions" / "sessions.json"
sessions_path.parent.mkdir(parents=True)
sessions_path.write_text(json.dumps(sessions_data))
@@ -152,7 +153,7 @@ class TestBuildFromSessions:
},
})
with patch.object(Path, "home", return_value=tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = _build_from_sessions("telegram")
assert len(entries) == 2
@@ -161,7 +162,7 @@ class TestBuildFromSessions:
assert "Bob" in names
def test_missing_sessions_file(self, tmp_path):
with patch.object(Path, "home", return_value=tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = _build_from_sessions("telegram")
assert entries == []
@@ -171,7 +172,7 @@ class TestBuildFromSessions:
"s2": {"origin": {"platform": "telegram", "chat_id": "123", "chat_name": "X"}},
})
with patch.object(Path, "home", return_value=tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = _build_from_sessions("telegram")
assert len(entries) == 1
@@ -202,7 +203,7 @@ class TestBuildFromSessions:
},
})
with patch.object(Path, "home", return_value=tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
entries = _build_from_sessions("telegram")
ids = {entry["id"] for entry in entries}

Some files were not shown because too many files have changed in this diff Show More