Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc3c01604 | ||
|
|
42dd2b562d | ||
|
|
6b4ff53315 | ||
|
|
ce84d1bafa | ||
|
|
afa540a222 | ||
|
|
711bb5a6c9 | ||
|
|
c677893105 | ||
|
|
eca6f5efbd | ||
|
|
068836cf6b | ||
|
|
09325f1bdf | ||
|
|
1003fa410c | ||
|
|
a2ae953620 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -6,6 +6,31 @@
|
||||
---
|
||||
|
||||
|
||||
## [v0.48.2] Provider/model mismatch warning (PR #283, fixes #266)
|
||||
|
||||
- **Provider mismatch warning** (PR #283): WebUI now warns when you select a model from a provider different from the one Hermes is configured for, instead of silently failing with a 401 error.
|
||||
- `api/streaming.py`: 401/auth errors classified as `type='auth_mismatch'` with an actionable hint ("Run `hermes model` in your terminal to switch providers")
|
||||
- `static/ui.js`: `populateModelDropdown()` stores `active_provider` from `/api/models` as `window._activeProvider`; new `_checkProviderMismatch()` helper compares selected model's provider prefix against the configured provider
|
||||
- `static/boot.js`: `modelSelect.onchange` calls `_checkProviderMismatch()` and shows a toast warning immediately on selection
|
||||
- `static/messages.js`: `apperror` handler shows "Provider mismatch" label (via i18n) instead of "Error" for auth errors
|
||||
- `static/i18n.js`: `provider_mismatch_warning` and `provider_mismatch_label` keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant)
|
||||
- Check skipped for `openrouter` and `custom` providers to avoid false positives
|
||||
- 21 new tests in `tests/test_provider_mismatch.py`; 679 tests total (up from 658)
|
||||
## [v0.48.1] Markdown table inline formatting (PR #278)
|
||||
|
||||
- **Inline formatting in table cells** (PR #278, @nesquena): Table header and data cells now render `**bold**`, `*italic*`, `` `code` ``, and `[links](url)` correctly. Previously `esc()` was used, which displayed raw HTML tags as text. Changed to `inlineMd()` consistent with list items and blockquotes. XSS-safe: `inlineMd()` escapes all interpolated values. Two-line change in `static/ui.js`. Fixes #273.
|
||||
## [v0.48.0] Real-time gateway session sync (PR #274)
|
||||
|
||||
- **Real-time gateway session sync** (PR #274, @bergeouss): Gateway sessions from Telegram, Discord, Slack, and other messaging platforms now appear in the WebUI sidebar and update in real time as new messages arrive. Enable via the "Show agent sessions" checkbox (renamed from "Show CLI sessions").
|
||||
- `api/gateway_watcher.py`: background daemon thread polling `state.db` every 5s using MD5 hash-based change detection
|
||||
- New SSE endpoint `/api/sessions/gateway/stream` for real-time push to browser
|
||||
- Dynamic source badges: telegram (blue), discord (purple), slack (dark purple), cli (green)
|
||||
- Zero changes to hermes-agent — WebUI reads the shared `state.db` that both components access
|
||||
- 10 new tests in `test_gateway_sync.py` covering metadata, filtering, SSE, and watcher lifecycle
|
||||
- 658 tests (up from 648)
|
||||
## [v0.47.1] Spanish locale (PR #275)
|
||||
|
||||
- **Spanish (es) locale** (PR #275, @gabogabucho): Full Spanish translation for all 175 UI strings. Exposed automatically in the language selector via existing `LOCALES` wiring. Includes regression tests verifying locale presence, representative translations, and key-parity with English. 648 tests (up from 645).
|
||||
## [v0.47.0] — 2026-04-11
|
||||
|
||||
### Features
|
||||
@@ -21,6 +46,8 @@
|
||||
### Tests
|
||||
- **Mobile layout regression suite** (PR #254): 14 static tests in `tests/test_mobile_layout.py` that run on every QA pass. Covers: CSS breakpoints at 900px/640px, right panel slide-over, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, `100dvh`, 44px touch targets, 16px textarea font. All pass against current and future master.
|
||||
|
||||
**CSS hotfix (commit a2ae953, post-tag):** session action menu — icon now displays inline-left of text. The `.ws-opt` base class (`flex-direction:column`) was causing SVG icons to stack above the label. Fixed with 3 CSS rule overrides on `.session-action-opt`.
|
||||
|
||||
**645 tests (up from 624 on v0.46.0 — +21 new tests)**
|
||||
|
||||
---
|
||||
|
||||
552
HERMES.md
552
HERMES.md
@@ -1,165 +1,176 @@
|
||||
# Why Hermes
|
||||
|
||||
Hermes is a persistent, autonomous AI agent that lives on your server. It remembers everything,
|
||||
schedules work while you sleep, and gets more capable the longer it runs. This document explains
|
||||
the mental model, why that matters, and how Hermes compares to every major AI tool available today.
|
||||
Hermes is a persistent, autonomous AI agent that runs on your server. It has layered memory that
|
||||
accumulates across sessions, a cron scheduler that fires jobs while you're offline, and a
|
||||
self-improving skills system that saves reusable procedures automatically. You reach it from a
|
||||
terminal, a browser, or a messaging app — and it's the same agent with the same history every time.
|
||||
|
||||
This document explains the mental model, how Hermes compares to other tools honestly, and where
|
||||
it is and is not the right choice.
|
||||
|
||||
---
|
||||
|
||||
## The Core Idea: Assistants Forget. Agents Don't.
|
||||
## The real problem: most tools are excellent in the moment and weak over time
|
||||
|
||||
Every time you open Claude Code, Codex, or a chat window, the tool starts from zero. It does not
|
||||
know who you are, what you worked on yesterday, how your repo is structured, or what bugs you
|
||||
already fixed. You re-explain yourself every single session. The tool is powerful in the moment
|
||||
and useless the next day.
|
||||
Memory is no longer a differentiator on its own. ChatGPT, Claude, Cursor, and GitHub Copilot all
|
||||
have some form of memory now. Anthropic, OpenAI, and Microsoft are all shipping scheduling and
|
||||
agent features. The category boundaries that existed twelve months ago are blurring fast.
|
||||
|
||||
Hermes fills that gap. It runs on your server, retains context across every session, and acts
|
||||
on your behalf whether or not you are at a keyboard.
|
||||
Hermes is not the only tool with memory or automation. It is the tool that makes those
|
||||
capabilities durable, self-hosted, cross-surface, and cumulative on your own server. The
|
||||
distinction that matters is not "has memory" vs. "has no memory" — it's whether context persists
|
||||
across sessions automatically, whether execution happens on hardware you control, whether you can
|
||||
reach the same agent identity from any device, and whether the system gets meaningfully better at
|
||||
your specific workflow over time without manual configuration.
|
||||
|
||||
```
|
||||
Assistant model: You -> [Tool] -> Answer -> Done
|
||||
(tool forgets everything when the window closes)
|
||||
Session-scoped: You -> [Tool] -> Answer -> Done
|
||||
(some tools now carry memory, but the execution is stateless)
|
||||
|
||||
Agent model: You <-> [Hermes] <-> (memory, skills, schedule, tools)
|
||||
(persistent, learns your stack, acts on your behalf, runs while you're offline)
|
||||
Persistent agent: You <-> [Hermes] <-> (memory, skills, schedule, tools, surfaces)
|
||||
(runs on your server, accumulates context, acts on your behalf offline)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Three Pillars
|
||||
## A note on convergence
|
||||
|
||||
### 1. Memory That Compounds
|
||||
The market is converging. Chat assistants are adding task scheduling and file connectors. IDE
|
||||
tools are launching cloud agent modes. CLI tools are adding skills systems and mobile surfaces.
|
||||
The lines between "assistant," "editor," and "agent" are dissolving.
|
||||
|
||||
Hermes has layered memory that survives every session, every reboot, every model swap:
|
||||
This makes comparisons harder but also makes the question sharper: what actually matters when
|
||||
every tool is claiming some version of every feature? For Hermes, the answer is synthesis. Any
|
||||
single feature — memory, scheduling, messaging — is available somewhere else. The value is
|
||||
having all of them in one self-hosted system, running continuously, with a persistent identity
|
||||
that accumulates real knowledge of your stack over time.
|
||||
|
||||
- **User profile** -- who you are, your preferences, your communication style, things you've
|
||||
corrected Hermes on
|
||||
- **Agent memory** -- facts about your environment, your toolchain, your project conventions
|
||||
- **Skills** -- reusable procedures Hermes discovers and saves; it never has to relearn how to
|
||||
deploy your app, run your tests, or review a PR
|
||||
- **Session history** -- every past conversation is searchable; Hermes can recall what you
|
||||
worked on last Tuesday
|
||||
---
|
||||
|
||||
## The three pillars
|
||||
|
||||
### 1. Memory that compounds
|
||||
|
||||
Hermes has layered memory that survives every session, every reboot, and every model swap:
|
||||
|
||||
- User profile — who you are, your preferences, your communication style, things you've corrected Hermes on
|
||||
- Agent memory — facts about your environment, your toolchain, your project conventions
|
||||
- Skills — reusable procedures Hermes discovers and saves automatically; it never has to relearn how to deploy your app, run your tests, or review a PR
|
||||
- Session history — every past conversation is searchable; Hermes can recall what you worked on last Tuesday
|
||||
|
||||
When you correct Hermes, it remembers. When it solves a tricky problem, it saves the approach.
|
||||
When it learns your stack, that knowledge carries into every future session.
|
||||
When it learns your stack, that knowledge carries into every future session. You never configure
|
||||
this manually — it happens in the background as a side effect of normal use.
|
||||
|
||||
### 2. Autonomous Scheduling
|
||||
### 2. Autonomous scheduling
|
||||
|
||||
Hermes can run jobs without you present -- every hour, every morning, on any cron schedule.
|
||||
It fires up a fresh session, runs the task, and delivers the result to wherever you want it:
|
||||
Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.
|
||||
Hermes can run jobs without you present — every hour, every morning, on any cron schedule. It
|
||||
fires up a fresh session with full access to your memory and skills, runs the task, and delivers
|
||||
the result wherever you want it: Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.
|
||||
|
||||
Things Hermes can do while you sleep:
|
||||
|
||||
- Review new pull requests on your GitHub repo and post a full verdict comment
|
||||
- Send you a morning briefing of news, markets, or anything else you care about
|
||||
- Send a morning briefing of news, markets, or anything else you track
|
||||
- Run your test suite and alert you if something breaks
|
||||
- Watch a competitor's blog for new posts and summarize them
|
||||
- Monitor a datasource and notify you when a threshold is crossed
|
||||
|
||||
### 3. Reach It From Anywhere
|
||||
The difference from cloud-scheduled alternatives is that the job runs on your server, with your
|
||||
memory and skills, and your data never leaves your hardware.
|
||||
|
||||
### 3. Reach it from anywhere
|
||||
|
||||
Hermes runs on your server and is reachable from every surface: terminal over SSH, the web UI
|
||||
(this project), and messaging apps including Telegram, Discord, Slack, WhatsApp, Signal, and
|
||||
Matrix. Start a task from your phone, check it from the browser on your laptop, continue it in
|
||||
a terminal on a remote server. The same agent, memory, and history follow you everywhere.
|
||||
a terminal on a remote server. The same agent, memory, and history follow you across all of them.
|
||||
|
||||
---
|
||||
|
||||
## A Framework for AI Tools
|
||||
## How AI tools are layered today
|
||||
|
||||
There are four distinct categories of AI tool. Understanding the category tells you what a tool
|
||||
can and cannot do.
|
||||
The old four-category model — chat, editor, CLI, agent — is too clean. These layers are actively
|
||||
collapsing into each other. Here is a more honest picture:
|
||||
|
||||
### Category 1: Chat Assistants
|
||||
*Claude.ai, ChatGPT, Gemini*
|
||||
Chat assistants (Claude.ai, ChatGPT) now have persistent memory, task scheduling, 50+ service
|
||||
connectors, and in some cases full agent modes with computer use. They are no longer "just chat."
|
||||
|
||||
You open a window, ask something, get an answer. No persistent memory beyond the conversation,
|
||||
no ability to run code or touch files, no way to act on your behalf. Excellent for Q&A,
|
||||
drafting, and brainstorming. You re-explain your context every session.
|
||||
IDE tools (Cursor, Windsurf, Copilot) have shipped or are shipping cross-session memory,
|
||||
cloud-based background agents, and in Cursor's case a full Automations platform with Slack
|
||||
integration. Cursor v3.0 (April 2026) is explicitly agent-first.
|
||||
|
||||
### Category 2: IDE Integrations
|
||||
*GitHub Copilot, Cursor, Windsurf, Zed AI*
|
||||
CLI tools (Claude Code, Codex, OpenCode) have added hooks, skills, desktop app automations,
|
||||
and multi-surface reach. Claude Code now spans terminal, IDE, desktop, and browser. Codex has
|
||||
become a product family: CLI, IDE extension, desktop app, and Codex Cloud.
|
||||
|
||||
Deep inside your editor. Autocomplete, inline diffs, refactors -- all excellent. Windsurf was
|
||||
earliest with workspace-scoped memory (Cascade Memories); Copilot has been shipping repo-level
|
||||
memory since late 2025 and is catching up. Cursor has no native memory as of early 2026. None
|
||||
have scheduling or messaging access. Tied to one machine and one editor.
|
||||
Persistent self-hosted agents (Hermes, OpenClaw) sit at the intersection: they combine the
|
||||
tool-use power of CLI agents, the memory of chat assistants, the scheduling of automation
|
||||
platforms, and the cross-surface reach of messaging integrations — running continuously on
|
||||
hardware you own.
|
||||
|
||||
### Category 3: Agentic CLI Tools
|
||||
*Claude Code, Codex CLI, OpenCode, Aider*
|
||||
|
||||
The current frontier for most developers. Can use real tools -- run shell commands, read and
|
||||
write files, search the web, call APIs. Great for deep, multi-step tasks in a single terminal
|
||||
session. All are adding memory and scheduling features to varying degrees (see comparisons below),
|
||||
but the core model is still session-scoped: you invoke it, it works, it stops.
|
||||
|
||||
### Category 4: Persistent Autonomous Agents
|
||||
*Hermes, OpenClaw (as of early 2026)*
|
||||
|
||||
All the tool use of Category 3, plus memory that accumulates across sessions, plus always-on
|
||||
scheduling, plus multi-modal access from any device or messaging app. Gets more useful over time
|
||||
rather than resetting to zero. Hermes and OpenClaw are the two primary open-source, self-hosted
|
||||
tools in this category. OpenClaw is a gateway-centric automation platform; Hermes is a
|
||||
self-improving agent that writes and reuses its own procedures from experience.
|
||||
The question is not which category a tool belongs to. The question is which combination of
|
||||
capabilities you actually need, where that execution lives, and whether the system gets better
|
||||
at your specific context over time.
|
||||
|
||||
---
|
||||
|
||||
## How Hermes Compares
|
||||
## How Hermes compares
|
||||
|
||||
### vs. OpenClaw
|
||||
|
||||
OpenClaw is the most direct comparison to Hermes and the question most people ask first.
|
||||
Both are open-source, self-hosted, always-on agents with persistent memory, cron scheduling,
|
||||
and messaging app integration. If you're evaluating Hermes, you should evaluate OpenClaw too.
|
||||
OpenClaw is the most direct comparison and the question most people ask first. Both are
|
||||
open-source, self-hosted, always-on agents with persistent memory, cron scheduling, and messaging
|
||||
app integration. If you're evaluating Hermes, evaluate OpenClaw too.
|
||||
|
||||
OpenClaw (MIT, ~347k GitHub stars) is built around a **Gateway** control plane written in
|
||||
Node.js/TypeScript. It excels at broad personal automation: native Chrome/Chromium control for
|
||||
browser automation, the widest messaging platform support in the space (WhatsApp, Telegram,
|
||||
Signal, iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, and more), voice wake words,
|
||||
and a ClawHub skill marketplace where users share pre-built automations. The community is large
|
||||
and the ecosystem is growing fast.
|
||||
OpenClaw (MIT) is built around a Gateway control plane written in Node.js/TypeScript. It has the
|
||||
widest messaging coverage in the space — 24+ channels including WhatsApp, Telegram, Signal,
|
||||
iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, Google Chat, Feishu, Mattermost, IRC,
|
||||
Nextcloud Talk, and more. It has native Chrome/Chromium control via CDP, voice wake words on
|
||||
macOS and iOS, and a ClawHub marketplace with 10,700+ skills. The community is large (350k+
|
||||
GitHub stars, 16,900+ commits) and growing.
|
||||
|
||||
Hermes takes a different approach. It is built in Python and centers on a **self-improving
|
||||
agent loop** rather than a gateway control plane. The core difference is in how skills work:
|
||||
OpenClaw skills are primarily human-authored plugins installed from a marketplace; Hermes
|
||||
**writes and saves its own skills automatically** as part of every session. When Hermes solves
|
||||
a problem a new way, it saves the procedure and reuses it going forward without any user effort.
|
||||
Hermes is built in Python and centers on a self-improving agent loop rather than a gateway
|
||||
control plane. The core architectural difference is in skills: OpenClaw skills are primarily
|
||||
human-authored plugins installed from a marketplace. Hermes writes and saves its own skills
|
||||
automatically as part of every session. When Hermes solves a problem a new way, it saves the
|
||||
procedure and reuses it without any user effort. That's not a subtle distinction — it's the
|
||||
reason Hermes gets meaningfully better at your workflow without you maintaining a plugin library.
|
||||
|
||||
Beyond the skills architecture, there are two other practical differences worth knowing:
|
||||
Two practical differences worth knowing directly:
|
||||
|
||||
**Stability.** OpenClaw's community forums and GitHub issues document a recurring pattern of
|
||||
update-breaking regressions -- for example, Telegram integration was broken across multiple
|
||||
releases in early 2026. The unofficial WhatsApp Web protocol OpenClaw uses is known to
|
||||
disconnect and requires periodic re-pairing (this is documented in OpenClaw's own FAQ).
|
||||
Hermes has had no equivalent release breakages.
|
||||
Stability. OpenClaw's GitHub issues and community forums document recurring update-breaking
|
||||
regressions. Telegram integration was broken across multiple releases from early 2026 through
|
||||
at least April 2026. The unofficial WhatsApp Web protocol OpenClaw relies on disconnects and
|
||||
requires periodic re-pairing — this is in OpenClaw's own FAQ.
|
||||
|
||||
**Security.** ClawHub's open publishing model has been exploited repeatedly. A community audit
|
||||
identified over a thousand malicious skills in the marketplace including prompt injections and
|
||||
tool-poisoning payloads; the community-maintained awesome-openclaw-skills list tracks confirmed
|
||||
removals and flags known bad actors. Hermes has no third-party marketplace and a correspondingly
|
||||
smaller attack surface.
|
||||
Security. ClawHub's open publishing model has been exploited at scale. Three separate audits in
|
||||
early 2026 found serious problems: Koi Security (January 2026) linked 335 skills to a campaign
|
||||
called "ClawHavoc" that delivered Atomic Stealer malware on macOS; Bitdefender found roughly
|
||||
900 malicious packages representing about 20% of the ecosystem at the time; Snyk's "ToxicSkills"
|
||||
report (February 2026) found malicious skills across roughly 4,000 scanned packages. China's
|
||||
CNCERT issued a national warning about ClawHub. Hermes has no third-party marketplace and a
|
||||
correspondingly smaller attack surface.
|
||||
|
||||
**OpenClaw's genuine strengths** are worth stating plainly: it has broader messaging coverage
|
||||
(iMessage, LINE, WeChat, Teams -- platforms Hermes does not support), native browser and
|
||||
computer control via Chrome CDP, voice wake words on macOS and iOS, a larger community, and
|
||||
more third-party integrations than Hermes. If those capabilities matter most to you, OpenClaw
|
||||
is worth a serious look.
|
||||
OpenClaw's genuine strengths are worth stating plainly: broader messaging coverage (iMessage,
|
||||
LINE, WeChat, Teams, Google Chat — platforms Hermes does not support), native browser and
|
||||
computer control via Chrome CDP, voice wake words, a larger community, and more third-party
|
||||
integrations than Hermes. If those capabilities matter most, OpenClaw is worth a serious look.
|
||||
|
||||
Where Hermes is the better fit: you want an agent that self-improves from experience without
|
||||
manual plugin authoring, you work in Python and want access to the ML/data science ecosystem,
|
||||
you want a stable deployment that does not break between updates, or you want a full web chat
|
||||
UI rather than a monitoring dashboard.
|
||||
Where Hermes fits better: you want an agent that self-improves from experience without managing
|
||||
a plugin library, you work in Python and want the ML/data science ecosystem, you want a stable
|
||||
deployment that doesn't break between updates, or you want a full web chat UI rather than a
|
||||
control dashboard.
|
||||
|
||||
| | OpenClaw | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Yes | Yes |
|
||||
| Scheduled jobs (cron) | Yes | Yes |
|
||||
| Messaging app access | Yes (15+ platforms, incl. iMessage/WeChat) | Yes (10+ platforms) |
|
||||
| Web UI | Gateway dashboard (monitoring only) | Full three-panel chat UI |
|
||||
| Messaging app access | Yes (24+ platforms, incl. iMessage/WeChat/LINE) | Yes (many platforms) |
|
||||
| Web UI | Chat UI + control dashboard | Full three-panel chat UI |
|
||||
| Self-hosted | Yes | Yes |
|
||||
| Open source | Yes (MIT) | Yes |
|
||||
| Self-improving skills | Partial (AI can generate skills; not the default loop) | Yes (automatic, first-class) |
|
||||
| Self-improving skills | Partial (AI can generate; not the default loop) | Yes (automatic, first-class) |
|
||||
| Browser / computer control | Yes (native Chrome CDP) | Via shell / tools |
|
||||
| Voice wake words | Yes (macOS/iOS) | No |
|
||||
| Python / ML ecosystem | No (Node.js) | Yes |
|
||||
@@ -167,209 +178,312 @@ UI rather than a monitoring dashboard.
|
||||
| Multi-profile support | Via binding-rule routing | Yes (first-class named profiles) |
|
||||
| Provider-agnostic | Yes | Yes |
|
||||
| Update reliability | Moderate (documented regressions) | High |
|
||||
| Memory inspectability | Limited | Yes (markdown files, editable) |
|
||||
| Self-hosted autonomous execution | Yes | Yes |
|
||||
|
||||
### vs. Claude Code (Anthropic)
|
||||
|
||||
Claude Code is Anthropic's official agentic CLI and one of the best tools in Category 3.
|
||||
In a single focused session it is capable -- deep code understanding, shell access, file
|
||||
editing, multi-step reasoning.
|
||||
Claude Code is Anthropic's official agentic tool and one of the strongest options for focused
|
||||
coding sessions. It has deep code understanding, shell access, file editing, and multi-step
|
||||
reasoning. It has been expanding rapidly — it now spans terminal, IDE plugin, desktop app, and
|
||||
browser surfaces — and the gap is closing in several areas.
|
||||
|
||||
Claude Code has been adding features rapidly and the gap is narrowing:
|
||||
What Claude Code has that's worth knowing:
|
||||
|
||||
- **Hooks system** -- 13 event types (SessionStart, PreToolUse, PostToolUse, Stop, etc.) with
|
||||
4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); deterministic
|
||||
- Hooks system — 26 event types (SessionStart, PreToolUse, PostToolUse, Stop, and more) with
|
||||
4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); gives deterministic
|
||||
non-LLM control over the agent lifecycle
|
||||
- **Plugins / Skills** -- installable via `/plugin install`, hot-reloaded from `~/.claude/skills`,
|
||||
with a marketplace; skills and slash commands unified as of v2.1.0
|
||||
- **Scheduling** -- `/loop` (session-scoped), cloud-managed cron via `claude.ai/code/scheduled`
|
||||
(Anthropic infrastructure, minimum interval applies), and desktop app automations
|
||||
- **Messaging channels** -- Telegram, Discord, iMessage, and webhooks via the Channels feature
|
||||
(research preview, v2.1.80+); deep Slack integration that triggers cloud sessions and creates PRs
|
||||
- **Claude Cowork** -- a separate product for knowledge workers; connects to 38+
|
||||
services via MCP including Slack, Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more
|
||||
- **Memory** -- CLAUDE.md and MEMORY.md for project-level context; auto-memory rolling out
|
||||
- Plugins / Skills — installable via `/plugin install`, hot-reloaded from `~/.claude/skills`,
|
||||
with a marketplace; includes the official ralph-wiggum plugin (`/ralph-loop`) for
|
||||
autonomous iteration toward a completion goal (distinct from `/loop`)
|
||||
- `/loop` — a native bundled skill, available in every session without any plugin, that runs
|
||||
a prompt on a repeating schedule within an active CLI session (polling/monitoring use case);
|
||||
session-scoped, dies when the terminal closes
|
||||
- Scheduling — cloud-managed cron (Anthropic infrastructure, minimum 1-hour interval) and
|
||||
desktop app scheduled tasks (run locally while the app is open, minimum 1-minute interval,
|
||||
full local file access); no self-hosted cron
|
||||
- Messaging channels — Telegram, Discord, and iMessage via the Channels feature (research
|
||||
preview, requires Bun runtime); Slack is the most-requested addition and has not yet shipped
|
||||
- Memory — CLAUDE.md and MEMORY.md for project-level context; auto-memory since v2.1.59+
|
||||
- Claude Cowork — a separate knowledge-worker product connecting 38+ services via MCP
|
||||
including Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more
|
||||
|
||||
These are real features. The key differences that remain:
|
||||
Claude Code's source was briefly and accidentally made public in March 2026 before being taken
|
||||
down. The CLI ships as minified/bundled TypeScript compiled with Bun — it is not open source.
|
||||
|
||||
- Claude Code's scheduling runs on **Anthropic's cloud** (or requires the desktop app open),
|
||||
not a self-hosted server; cloud jobs have a minimum interval and your data leaves your hardware
|
||||
- Memory is **project-file-based** (CLAUDE.md / MEMORY.md), not a knowledge graph that
|
||||
accumulates automatically across all your work; auto-memory is still rolling out
|
||||
- **Not provider-agnostic** -- routes through Bedrock or Vertex but always hits a Claude model;
|
||||
you cannot switch to GPT, Gemini, or a local model
|
||||
- **Not open source** -- proprietary; the CLI ships obfuscated JavaScript
|
||||
- Messaging channels are a **research preview** requiring Bun runtime; not yet production-grade
|
||||
Key differences that remain:
|
||||
|
||||
- Scheduling requires cloud (Anthropic infrastructure, data off your hardware, 1-hour minimum)
|
||||
or the desktop app (runs locally, but the app must stay open — not a headless server process);
|
||||
neither runs as a server daemon the way Hermes cron does
|
||||
- Memory is project-file-based (CLAUDE.md / MEMORY.md plus rolling auto-memory); it doesn't
|
||||
automatically accumulate a cross-project knowledge graph the way Hermes does
|
||||
- Not provider-agnostic — routes through Anthropic, Bedrock, Vertex, or Foundry, but always
|
||||
a Claude model; you can't switch to GPT, Gemini, or a local model
|
||||
- Messaging channels are still a research preview, not production
|
||||
|
||||
Hermes can use Claude Code as a sub-agent. For large implementation tasks, Hermes can spawn
|
||||
Claude Code to handle the heavy lifting and fold the result back into its own memory and history.
|
||||
|
||||
| | Claude Code | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory (automatic) | Partial (CLAUDE.md / MEMORY.md, rolling out) | Yes |
|
||||
| Skills / hooks system | Yes (Hooks + Plugin/Skills marketplace) | Yes (auto-generated from experience) |
|
||||
| Persistent memory (automatic) | Partial (CLAUDE.md / MEMORY.md + auto-memory v2.1.59+) | Yes |
|
||||
| Skills / hooks system | Yes (26-event Hooks + Plugin/Skills marketplace) | Yes (auto-generated from experience) |
|
||||
| Scheduled jobs (self-hosted) | No (cloud or desktop-app only) | Yes |
|
||||
| Messaging access | Partial (Telegram/Discord/iMessage via research preview; Slack native) | Yes (10+ platforms, production) |
|
||||
| Messaging access | Partial (Telegram/Discord/iMessage research preview; Slack not yet) | Yes (many platforms, production) |
|
||||
| Cowork connectors (Slack, Gmail, etc.) | Yes (via Claude Cowork, separate product) | Via agent tool use |
|
||||
| Web UI | Yes (claude.ai/code, Anthropic-hosted) | Yes (self-hosted) |
|
||||
| Provider-agnostic | No (Claude models only, via Bedrock/Vertex) | Yes (any provider) |
|
||||
| Provider-agnostic | No (Claude models only) | Yes (any provider) |
|
||||
| Self-hosted scheduling | No | Yes |
|
||||
| Open source | No | Yes |
|
||||
| Background/cloud agent mode | Yes (cloud-scheduled) | Yes (self-hosted cron) |
|
||||
| Runs as sub-agent of Hermes | Yes | N/A |
|
||||
| Memory inspectability | Partial (CLAUDE.md readable; auto-memory less so) | Yes (markdown files) |
|
||||
|
||||
### vs. Codex CLI (OpenAI)
|
||||
|
||||
Codex CLI is OpenAI's open-source agentic terminal tool (Apache 2.0, ~73k GitHub stars). It
|
||||
supports 10+ providers including Anthropic, Google, Mistral, Groq, and local models via Ollama.
|
||||
It added persistent session memory in v0.100.0 with `codex resume`. The desktop app has an
|
||||
Automations feature for scheduled local tasks.
|
||||
Codex CLI (Apache 2.0, ~60k GitHub stars) started as a straightforward terminal tool and has
|
||||
expanded into a product family. It was rewritten from TypeScript to Rust. It now includes an IDE
|
||||
extension, a desktop app with an Automations feature, and Codex Cloud for remote execution. A
|
||||
Skills system is shared across surfaces. It supports 12+ built-in providers: OpenAI, Anthropic,
|
||||
Google/Gemini, Mistral, Groq, Ollama, OpenRouter, LM Studio, Together AI, DeepSeek, xAI,
|
||||
Azure OpenAI, and custom endpoints.
|
||||
|
||||
The CLI itself has no native scheduling (open feature request as of early 2026). Memory is
|
||||
session-history-based rather than a living knowledge graph. No messaging app access. A strong
|
||||
tool for single-session coding; Hermes adds the always-on layer on top.
|
||||
The CLI itself has no native scheduling (open feature request). Session continuity is available
|
||||
via `codex resume`. Memory is session-history-based plus AGENTS.md project context — not a
|
||||
living knowledge graph that accumulates across all your projects. No first-party messaging
|
||||
integration. The Automations feature in the desktop app covers scheduled local tasks but doesn't
|
||||
reach the cross-session, cross-surface continuity Hermes has.
|
||||
|
||||
| | Codex CLI | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
|
||||
| Scheduled jobs | Partial (desktop app only; CLI has none) | Yes |
|
||||
| Scheduled jobs | Partial (desktop app Automations; CLI has none) | Yes |
|
||||
| Messaging app access | No | Yes |
|
||||
| Web UI | No | Yes (self-hosted) |
|
||||
| Provider-agnostic | Yes (10+ providers) | Yes (10+ providers) |
|
||||
| Web UI | No (CLI + desktop app) | Yes (self-hosted) |
|
||||
| Provider-agnostic | Yes (12+ providers) | Yes |
|
||||
| Self-hosted | Yes | Yes |
|
||||
| Open source | Yes (Apache 2.0) | Yes |
|
||||
| Background/cloud agent mode | Yes (Codex Cloud) | Yes (self-hosted cron) |
|
||||
| Self-improving skills | No | Yes |
|
||||
|
||||
### vs. OpenCode
|
||||
|
||||
OpenCode is an open-source TUI agentic coding assistant, provider-agnostic across 75+ providers.
|
||||
It has a WebUI embedded in its binary and an official desktop app. It uses SQLite for session
|
||||
history and AGENTS.md for project context.
|
||||
OpenCode is an open-source TUI agentic coding assistant supporting 75+ providers. It has a WebUI
|
||||
embedded in its binary, an official desktop app, SQLite session history, and AGENTS.md project
|
||||
context. It supports CLAUDE.md as a fallback for users migrating from Claude Code. There are 30+
|
||||
community plugins, and community messaging integrations exist for Telegram, Slack, Discord, and
|
||||
Microsoft Teams — though none are first-party and all require manual setup.
|
||||
|
||||
No native scheduled jobs (a community background plugin exists), no first-party messaging
|
||||
integration (community Telegram bots exist but require manual setup), and no automatic
|
||||
cross-session semantic memory. Good for interactive terminal coding sessions.
|
||||
OpenCode Go ($10/month) and OpenCode Zen (curated model service) are subscription tiers. The
|
||||
GitHub Copilot official integration launched January 2026. There is no native scheduling; a
|
||||
community background plugin exists. No automatic cross-session semantic memory.
|
||||
|
||||
| | OpenCode | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
|
||||
| Scheduled jobs | No (community plugin only) | Yes |
|
||||
| Messaging app access | No (community Telegram bot only) | Yes (first-party, 10+ platforms) |
|
||||
| Messaging app access | Community integrations only (Telegram/Slack/Discord/Teams) | Yes (first-party, many platforms) |
|
||||
| Web UI | Yes (embedded + desktop app) | Yes (self-hosted) |
|
||||
| Mobile access | No | Yes |
|
||||
| Skills system | No | Yes |
|
||||
| Skills / plugins | Yes (30+ community plugins) | Yes (auto-generated, first-party) |
|
||||
| Provider-agnostic | Yes (75+ providers) | Yes |
|
||||
| Open source | Yes | Yes |
|
||||
| Self-hosted autonomous execution | No | Yes |
|
||||
|
||||
### vs. Cursor / Windsurf / Copilot
|
||||
### vs. Cursor
|
||||
|
||||
Category 2 tools -- exceptional at in-editor autocomplete, inline diffs, and code review.
|
||||
Not competing for the same job as Hermes, and they work well alongside it.
|
||||
Cursor has changed substantially. The "no memory, no scheduling, no messaging" description was
|
||||
accurate in 2024 and is wrong now.
|
||||
|
||||
Windsurf was earliest with workspace-scoped memory (Cascade Memories); Copilot has been
|
||||
shipping repo-level memory since late 2025. Cursor has no native cross-session memory as of
|
||||
early 2026. None have scheduling or messaging access.
|
||||
Memories (per-project cross-session knowledge base) shipped in beta with v1.0 in June 2025.
|
||||
Automations launched March 5, 2026 — time-based, event-based (GitHub/Linear/PagerDuty), and
|
||||
communication-based (Slack) triggers that fire background agents on cloud VMs. The web app,
|
||||
mobile agent, and Slack bot give it multi-surface reach. Cursor v3.0 (April 2, 2026) is
|
||||
explicitly agent-first with Design Mode and 30+ marketplace plugins. Cursor acquired Supermaven
|
||||
for autocomplete. As of early 2026 it's valued at $29.3B with $2B ARR. It is not a narrow editor
|
||||
tool anymore.
|
||||
|
||||
Hermes still has a different profile: it's self-hosted and server-resident, the same persistent
|
||||
identity follows you across every surface without cloud intermediation, and it works with any
|
||||
model family rather than being cloud-VM-based. For workflows that require data sovereignty,
|
||||
self-hosted scheduling, or deep Python/ML tooling on your own hardware, Cursor's cloud-agent
|
||||
architecture is a fundamental mismatch. For teams that want editor-native agents with strong
|
||||
IDE integration, Cursor's recent evolution is significant.
|
||||
|
||||
| | Cursor | Windsurf | Copilot | Hermes |
|
||||
|---|---|---|---|---|
|
||||
| In-editor autocomplete | Excellent | Excellent | Excellent | No |
|
||||
| In-editor autocomplete | Excellent (Supermaven) | Excellent (Cascade) | Excellent | No |
|
||||
| Inline diff / refactor | Yes | Yes | Yes | Via shell |
|
||||
| Cross-session memory | No | Yes (workspace) | Partial (repo, early access) | Yes |
|
||||
| Scheduled background jobs | No | No | No | Yes |
|
||||
| Messaging app / mobile | No | No | No | Yes |
|
||||
| Cross-session memory | Yes (Memories, per-project) | Yes (Cascade Memories, workspace) | Yes (Agentic Memory, repo-scoped, 28-day expiry) | Yes (automatic, persistent) |
|
||||
| Scheduled background jobs | Yes (Automations, cloud VM) | No | Via Coding Agent (issue-driven) | Yes (self-hosted cron) |
|
||||
| Messaging app / multi-surface | Yes (Slack bot, web app, mobile) | No | Via Copilot CLI / fleet | Yes (many platforms) |
|
||||
| Background/cloud agent mode | Yes (Automations on cloud VMs) | No | Yes (Coding Agent, GA Mar 2026) | Yes (self-hosted) |
|
||||
| Terminal tool use | Limited | Limited | Limited | Full |
|
||||
| Self-hosted | No | No | No | Yes |
|
||||
| Provider-agnostic | Partial | Partial | No | Yes |
|
||||
| Self-hosted autonomous execution | No | No | No | Yes |
|
||||
| Provider-agnostic | Partial | Partial | No (GitHub models) | Yes |
|
||||
| Open source | No | No | No | Yes |
|
||||
| Memory inspectability | Partial | Yes (stored locally) | Limited | Yes (markdown files) |
|
||||
|
||||
### vs. Claude.ai / ChatGPT
|
||||
### vs. Claude.ai and ChatGPT
|
||||
|
||||
Category 1. For drafting, Q&A, and brainstorming in the moment, both are excellent.
|
||||
These are no longer simple chat tools. The description of "no memory, no scheduling, no
|
||||
messaging" is inaccurate for both.
|
||||
|
||||
Claude.ai memory has been improving -- it now generates memory from chat history, not just
|
||||
user-curated entries. Claude.ai can also execute code and read/write files in a sandboxed
|
||||
environment via Artifacts. These are real capabilities, just not the same as direct filesystem
|
||||
or shell access on your own server.
|
||||
Claude Cowork (in Claude Desktop) launched scheduled tasks on February 25, 2026 — hourly,
|
||||
daily, weekly, weekdays, and on-demand. It runs in an isolated VM with file and shell access.
|
||||
Claude has 50+ service connectors as of February 2026 including Slack (launched January 26,
|
||||
2026), Gmail, Google Calendar, Google Drive, Microsoft 365, Notion, Asana, Linear, and Jira.
|
||||
Memory auto-generates from chat history, not just user-curated entries. Code execution and
|
||||
file access in Artifacts is sandboxed, not the same as shell access on your own server.
|
||||
|
||||
| | Claude.ai / ChatGPT | Hermes |
|
||||
|---|---|---|
|
||||
| Memory across conversations | Yes (improving; auto-generated from history) | Yes (deep, automatic) |
|
||||
| Runs shell commands | No | Yes |
|
||||
| Code execution | Sandboxed (Artifacts) | Yes (full shell) |
|
||||
| Reads / writes files | Sandboxed (Artifacts) | Yes (full filesystem) |
|
||||
| Schedules background jobs | No | Yes |
|
||||
| Web UI | Yes | Yes |
|
||||
| Messaging apps | No | Yes |
|
||||
| Self-hosted | No | Yes |
|
||||
| Provider-agnostic | No | Yes |
|
||||
| Open source | No | Yes |
|
||||
ChatGPT has Agent Mode (launched July 17, 2025), Scheduled Tasks (January 2025, recurring
|
||||
automated prompts), a computer-using agent, Projects, 50+ connectors including Gmail, GitHub,
|
||||
and Google Drive, dual-mode memory (auto + manual), and ChatGPT Pulse for Pro users (daily
|
||||
research briefings). It is not a passive Q&A interface.
|
||||
|
||||
Where Claude.ai and ChatGPT differ from Hermes: neither is self-hosted, neither is
|
||||
provider-agnostic, and neither gives you execution on your own hardware. Connectors and
|
||||
scheduling exist, but they run on Anthropic's or OpenAI's infrastructure. Your memory, session
|
||||
history, and agent execution live on their servers, not yours. For many use cases that's fine
|
||||
— they are capable and well-supported. For privacy-conscious users, regulated environments, or
|
||||
workflows that require persistent server-side execution on controlled hardware, it's a
|
||||
disqualifying constraint.
|
||||
|
||||
| | Claude.ai | ChatGPT | Hermes |
|
||||
|---|---|---|---|
|
||||
| Memory across conversations | Yes (auto-generated from history) | Yes (dual-mode: auto + manual) | Yes (deep, automatic) |
|
||||
| Scheduled tasks | Yes (Cowork: hourly/daily/weekly) | Yes (since Jan 2025) | Yes (any cron, self-hosted) |
|
||||
| Service connectors / messaging | Yes (50+ via Cowork) | Yes (50+ connectors) | Yes (many platforms, direct) |
|
||||
| Runs shell commands | Sandboxed (Cowork VM) | Sandboxed | Yes (full shell) |
|
||||
| Code execution | Sandboxed | Sandboxed | Yes (full shell) |
|
||||
| Reads / writes files | Sandboxed | Sandboxed | Yes (full filesystem) |
|
||||
| Web UI | Yes (Anthropic-hosted) | Yes (OpenAI-hosted) | Yes (self-hosted) |
|
||||
| Self-hosted | No | No | Yes |
|
||||
| Provider-agnostic | No | No | Yes |
|
||||
| Open source | No | No | Yes |
|
||||
| Self-hosted autonomous execution | No | No | Yes |
|
||||
| Memory inspectability | Limited | Limited | Yes (markdown files) |
|
||||
|
||||
---
|
||||
|
||||
## The Compounding Advantage
|
||||
## The compounding advantage
|
||||
|
||||
What matters most about Hermes is that it improves over time. That is the point.
|
||||
What distinguishes Hermes from most of the tools above is that it gets meaningfully better at
|
||||
your specific workflow over time without manual configuration.
|
||||
|
||||
Every time Hermes encounters a new environment, it saves facts to memory. Every time it solves
|
||||
a problem a new way, it saves the approach as a skill. Every time you correct it, it updates its
|
||||
profile of you. Every session, every scheduled job, every tool call, the agent gets more
|
||||
calibrated to you and your workflow.
|
||||
profile of you. Every session, every scheduled job, every tool call adds to a body of knowledge
|
||||
that is specific to you, stored on your hardware, and available to every future interaction.
|
||||
|
||||
A Claude Code session on day one and day one hundred are identical. A Hermes agent on day one
|
||||
and day one hundred is smarter about you -- it knows your stack, your conventions, your
|
||||
preferences, and the solutions that have worked before.
|
||||
A Claude Code session on day one and day one hundred are identical — it starts fresh. A Hermes
|
||||
agent on day one and day one hundred knows your stack, your conventions, your preferences, and
|
||||
the solutions that have worked before. That's the actual compounding.
|
||||
|
||||
---
|
||||
|
||||
## Who Hermes Is For
|
||||
## Who Hermes is for
|
||||
|
||||
**Solo developers and power users** who don't want to re-explain their stack every session and
|
||||
want an AI that actually knows their environment.
|
||||
Solo developers and power users who don't want to re-explain their stack every session and want
|
||||
an AI that actually knows their environment.
|
||||
|
||||
**Teams on a shared server** where multiple people want Claude-quality AI access without each
|
||||
paying for a separate subscription or running local tooling.
|
||||
Teams on a shared server where multiple people want capable AI access without each paying for
|
||||
a separate subscription or running separate local tooling.
|
||||
|
||||
**Automation-heavy workflows** where you want an AI running tasks on a schedule, delivering
|
||||
results to your phone, without babysitting it.
|
||||
Automation-heavy workflows where you want an AI running tasks on a schedule, delivering results
|
||||
to your phone, without babysitting it.
|
||||
|
||||
**Privacy-conscious users** who want their conversations, memory, and files on their own
|
||||
hardware.
|
||||
Privacy-conscious users who want their conversations, memory, and files on their own hardware.
|
||||
|
||||
**Multi-model users** who want to switch between OpenAI, Anthropic, Google, DeepSeek, and
|
||||
others based on cost, capability, or rate limits, without rebuilding their workflow each time.
|
||||
Multi-model users who want to switch between OpenAI, Anthropic, Google, DeepSeek, and others
|
||||
based on cost, capability, or rate limits, without rebuilding their workflow each time.
|
||||
|
||||
---
|
||||
|
||||
## Scope and Limits
|
||||
## What Hermes is not
|
||||
|
||||
**Hermes lives in the terminal, browser, and messaging apps.** For in-editor autocomplete and
|
||||
inline diffs, use Cursor or Windsurf alongside it -- they do that job better.
|
||||
Hermes is not the best in-editor autocomplete tool. Cursor and Windsurf do that job better.
|
||||
Use one alongside Hermes.
|
||||
|
||||
**You run Hermes on your own server.** That means initial setup, but your data stays on your
|
||||
It is not zero-setup. You are running a server. That means initial configuration, and it means
|
||||
you're responsible for uptime, upgrades, and backups. The tradeoff is data sovereignty and
|
||||
control; that only makes sense if you actually want it.
|
||||
|
||||
It does not make weaker models magical. Memory and skills help, but the underlying model still
|
||||
determines reasoning quality. Hermes with a weak model is a well-organized weak model.
|
||||
|
||||
It still needs guardrails, approvals, and observability for high-stakes automations. Autonomous
|
||||
execution on a schedule with shell access is powerful and requires judgment about what to
|
||||
approve. Terminal commands can require confirmation before running; use that for anything
|
||||
consequential.
|
||||
|
||||
If you need the absolute lowest-friction path to a one-off answer or a quick edit, a chat
|
||||
interface or an in-editor tool is the right call. Hermes is for continuity and autonomy, not
|
||||
minimum-friction one-shots.
|
||||
|
||||
---
|
||||
|
||||
## Scope and limits
|
||||
|
||||
Hermes lives in the terminal, browser, and messaging apps. For in-editor autocomplete and inline
|
||||
diffs, use Cursor or Windsurf — they do that job better and work well alongside Hermes.
|
||||
|
||||
You run Hermes on your own server. That means initial setup, but your data stays on your
|
||||
hardware and you control the schedule, the models, and the costs.
|
||||
|
||||
**Hermes is an orchestration and memory layer.** It makes whatever model you point it at more
|
||||
useful over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
|
||||
Hermes is an orchestration and memory layer. It makes whatever model you point at it more useful
|
||||
over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
|
||||
something durable.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
## Security and control
|
||||
|
||||
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Cursor | Claude.ai | Hermes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | No | Yes (improving) | **Yes** |
|
||||
| Scheduled / background jobs | Yes | Partial‡ | Partial§ | No | No | No | **Yes (self-hosted)** |
|
||||
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview; Slack native) | No | No | No | No | **Yes (10+ platforms)** |
|
||||
| Web UI | Dashboard only | Yes (Anthropic cloud) | No | Yes | No | Yes | **Yes (self-hosted)** |
|
||||
| Skills system | Yes (marketplace) | Yes (Hooks + Plugins) | No | No | No | No | **Yes** |
|
||||
| Self-improving skills | Partial | No | No | No | No | No | **Yes** |
|
||||
| Browser / computer control | Yes (Chrome CDP) | No | No | No | No | No | Via shell |
|
||||
| Python / ML ecosystem | No (Node.js) | No | No | No | No | No | **Yes** |
|
||||
| In-editor autocomplete | No | No | No | No | Yes | No | No |
|
||||
| Orchestrates other agents | No | No | No | No | No | No | **Yes** |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Partial | No | **Yes** |
|
||||
| Self-hosted | Yes | No | Yes | Yes | No | No | **Yes** |
|
||||
| Open source | Yes (MIT) | No | Yes | Yes | No | No | **Yes** |
|
||||
| Always-on / autonomous | Yes | No | No | No | No | No | **Yes** |
|
||||
Memory is stored locally on your server as readable, editable files: user profile, agent memory,
|
||||
and skills are all markdown. Session history is in SQLite on your machine. You can inspect,
|
||||
edit, or delete any of it directly.
|
||||
|
||||
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
|
||||
‡ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
|
||||
§ Codex scheduling: desktop app Automations only; CLI has no native scheduling
|
||||
If you want external memory providers, eight are supported: Mem0, Honcho, Hindsight, RetainDB,
|
||||
ByteRover, Supermemory, Holographic, and others. These are optional and configurable.
|
||||
|
||||
Execution runs in configurable backends: local shell, Docker, SSH, Daytona, Singularity, or
|
||||
Modal. You choose what execution environment Hermes operates in and what it can reach.
|
||||
|
||||
Terminal commands can require confirmation before running. For any automation that touches
|
||||
production systems or makes external calls, enable approval controls.
|
||||
|
||||
Secrets stay on your hardware. Hermes does not phone home; it calls whatever model APIs you
|
||||
configure directly.
|
||||
|
||||
Multiple profiles give isolation between users or projects. A shared server can have separate
|
||||
profiles with separate memory, separate skills, and separate history.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| | OpenClaw | Claude Code | Codex | OpenCode | Cursor | Copilot | Claude.ai | ChatGPT | Hermes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | Yes (per-project) | Yes (repo-scoped‡) | Yes | Yes | Yes |
|
||||
| Scheduled / background jobs | Yes | Partial§ | Partial¶ | No | Yes (Automations) | Via Coding Agent | Yes (Cowork) | Yes | Yes (self-hosted) |
|
||||
| Messaging / multi-surface | Yes (24+ platforms) | Partial (preview) | No | Community only | Yes (Slack/web/mobile) | Via CLI/fleet | Yes (50+ connectors) | Yes (50+ connectors) | Yes (many platforms) |
|
||||
| Web UI | Chat UI + control dashboard | Anthropic-hosted | No | Yes | Yes + mobile | github.com | Yes (Claude Desktop) | Yes | Yes (self-hosted) |
|
||||
| Skills system | Yes (ClawHub marketplace) | Yes (Hooks + Plugins) | Partial (Skills) | Community plugins | Yes (marketplace) | No | No | No | Yes (auto-generated) |
|
||||
| Self-improving skills | Partial | No | No | No | No | No | No | No | Yes |
|
||||
| Browser / computer control | Yes (Chrome CDP) | No | No | No | No | No | No | Yes (CUA) | Via shell |
|
||||
| In-editor autocomplete | No | No | Via extension | No | Excellent | Excellent | No | No | No |
|
||||
| Orchestrates other agents | No | No | No | No | No | No | No | No | Yes |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Partial | No | No | No | Yes |
|
||||
| Self-hosted | Yes | No | Yes (CLI) | Yes | No | No | No | No | Yes |
|
||||
| Self-hosted autonomous execution | Yes | No | No | No | No | No | No | No | Yes |
|
||||
| Background/cloud agent mode | Yes | Yes (cloud) | Yes (Codex Cloud) | No | Yes (cloud VMs) | Yes (Coding Agent) | Yes (Cowork VM) | Yes (Agent Mode) | Yes (self-hosted) |
|
||||
| Memory inspectability | Limited | Partial | Partial | Partial | Partial | Limited | Limited | Limited | Yes (markdown files) |
|
||||
| Open source | Yes (MIT) | No | Yes (Apache 2.0) | Yes | No | No | No | No | Yes |
|
||||
| Always-on autonomous execution | Yes | No | No | No | No | No | No | No | Yes |
|
||||
|
||||
† Claude Code: CLAUDE.md / MEMORY.md project context plus auto-memory since v2.1.59+; no automatic cross-project accumulation
|
||||
‡ Copilot Agentic Memory: public preview Jan 15, 2026; enabled by default Mar 4, 2026; repo-scoped, auto-expires after 28 days
|
||||
§ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
|
||||
¶ Codex scheduling: desktop app Automations only; CLI has no native scheduling
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: v0.47.0 (April 11, 2026) — 645 tests, 645 passing
|
||||
> Last updated: v0.48.2 (April 12, 2026) — 679 tests, 679 passing
|
||||
> Tests: 604 total (604 passing, 0 failures)
|
||||
> Source: <repo>/
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
| v0.45.0 | Custom endpoint in new profile form | Base URL + API key fields; server-side URL validation; config.yaml merge; 9 new tests (PR #233, fixes #170) | 604 |
|
||||
| v0.46.0 | Security, Docker UID/GID, model discovery, i18n, cancel fix | Credential redaction in API responses (PR #243); Docker UID/GID matching (PR #237); custom model API key discovery (PR #238); HTML entity decode + zh/zh-Hant i18n (PR #239); cancel interrupts agent (PR #244); +20 tests | 624 |
|
||||
| v0.47.0 | Dialogs, session menu, skills command, mobile fixes, mobile QA | Shared app dialogs (#251); session ⋯ menu (#252); mobile QA suite (#254); custom provider slash routing fix (#255); Android Chrome mobile fixes (#256); /skills command (#257); +21 tests | 645 |
|
||||
| v0.47.1 | Spanish locale | Full Spanish (es) locale, 175 keys, key-parity tests (#275 @gabogabucho); +3 tests | 648 |
|
||||
| v0.48.0 | Gateway session sync | Real-time Telegram/Discord/Slack sessions in sidebar via SSE + DB polling (#274 @bergeouss); +10 tests | 658 |
|
||||
| v0.48.1 | Table inline formatting | `inlineMd()` in table cells — **bold**, *italic*, `code`, links render correctly (PR #278); 0 new tests | 658 |
|
||||
| v0.48.2 | Provider mismatch warning | Toast warning + auth_mismatch error type for provider/model mismatches (#283, fixes #266); +21 tests | 679 |
|
||||
| v0.32 | Auto-compaction handling | Compression detection, /compact command, real context window indicator | 424 |
|
||||
| v0.33 | /insights sync | Opt-in state.db sync so `hermes /insights` includes WebUI sessions | 424 |
|
||||
| v0.34 | Sprint 26 — Pluggable themes | Dark, Light, Slate, Solarized, Monokai, Nord; settings unsaved-changes guard; /theme command | 433 |
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 645 total (645 passing, 0 skipped, 0 known failures)
|
||||
> Automated tests: 679 total (679 passing, 0 skipped, 0 known failures)
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
|
||||
225
api/gateway_watcher.py
Normal file
225
api/gateway_watcher.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Hermes Web UI -- Gateway session watcher.
|
||||
|
||||
Background daemon thread that polls state.db every 5 seconds for changes
|
||||
to gateway sessions (telegram, discord, slack, etc.). When changes are
|
||||
detected, it pushes notifications to all subscribed SSE clients.
|
||||
|
||||
This enables real-time session list updates in the sidebar without
|
||||
requiring any changes to hermes-agent.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import HOME
|
||||
|
||||
|
||||
# ── State hash tracking ─────────────────────────────────────────────────────
|
||||
|
||||
def _snapshot_hash(sessions: list) -> str:
|
||||
"""Create a lightweight hash of session IDs and timestamps for change detection."""
|
||||
key = '|'.join(
|
||||
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
|
||||
for s in sorted(sessions, key=lambda x: x['session_id'])
|
||||
)
|
||||
return hashlib.md5(key.encode()).hexdigest()
|
||||
|
||||
|
||||
# ── DB resolution (shared pattern with state_sync.py) ──────────────────────
|
||||
|
||||
def _get_state_db_path() -> Path:
|
||||
"""Resolve state.db path for the active profile."""
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||||
except Exception:
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
||||
return hermes_home / 'state.db'
|
||||
|
||||
|
||||
def _get_agent_sessions_from_db() -> list:
|
||||
"""Read all non-webui sessions from state.db.
|
||||
Returns list of session dicts, or empty list on any error.
|
||||
"""
|
||||
db_path = _get_state_db_path()
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IS NOT NULL AND s.source != 'webui'
|
||||
GROUP BY s.id
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT 200
|
||||
""")
|
||||
sessions = []
|
||||
for row in cur.fetchall():
|
||||
sessions.append({
|
||||
'session_id': row['id'],
|
||||
'title': row['title'] or 'Agent Session',
|
||||
'model': row['model'] or 'unknown',
|
||||
'message_count': row['message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': row['last_activity'] or row['started_at'],
|
||||
'source': row['source'] or 'cli',
|
||||
})
|
||||
return sessions
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── GatewayWatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
class GatewayWatcher:
|
||||
"""Background thread that polls state.db for agent session changes.
|
||||
|
||||
Usage:
|
||||
watcher = GatewayWatcher()
|
||||
watcher.start()
|
||||
q = watcher.subscribe()
|
||||
# ... receive change events via q.get() ...
|
||||
watcher.unsubscribe(q)
|
||||
watcher.stop()
|
||||
"""
|
||||
|
||||
POLL_INTERVAL = 5 # seconds between polls
|
||||
SUBSCRIBER_TIMEOUT = 30 # seconds before sending keepalive comment
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: list[queue.Queue] = []
|
||||
self._sub_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._last_hash: str = ''
|
||||
self._last_sessions: list = []
|
||||
|
||||
def start(self):
|
||||
"""Start the watcher daemon thread."""
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the watcher thread."""
|
||||
self._stop_event.set()
|
||||
# Wake up any subscribers
|
||||
with self._sub_lock:
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put(None) # sentinel
|
||||
except Exception:
|
||||
pass
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3)
|
||||
self._thread = None
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
"""Subscribe to change events. Returns a queue.Queue.
|
||||
Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
|
||||
A None sentinel means the watcher is stopping.
|
||||
"""
|
||||
q = queue.Queue(maxsize=10)
|
||||
with self._sub_lock:
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue):
|
||||
"""Remove a subscriber queue."""
|
||||
with self._sub_lock:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _notify_subscribers(self, sessions: list):
|
||||
"""Push change event to all subscribers."""
|
||||
event = {
|
||||
'type': 'sessions_changed',
|
||||
'sessions': sessions,
|
||||
}
|
||||
with self._sub_lock:
|
||||
dead = []
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except queue.Full:
|
||||
dead.append(q) # remove slow consumers
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
# Send a None sentinel so the SSE handler unblocks, closes,
|
||||
# and lets the browser's EventSource auto-reconnect.
|
||||
try:
|
||||
q.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop. Runs in a daemon thread."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
sessions = _get_agent_sessions_from_db()
|
||||
current_hash = _snapshot_hash(sessions)
|
||||
|
||||
if current_hash != self._last_hash:
|
||||
self._last_hash = current_hash
|
||||
self._last_sessions = sessions
|
||||
self._notify_subscribers(sessions)
|
||||
except Exception:
|
||||
pass # never crash the watcher
|
||||
|
||||
# Sleep in small increments so we can stop promptly
|
||||
for _ in range(self.POLL_INTERVAL * 10):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
# ── Module-level singleton ─────────────────────────────────────────────────
|
||||
|
||||
_watcher: GatewayWatcher | None = None
|
||||
_watcher_lock = threading.Lock()
|
||||
|
||||
|
||||
def start_watcher():
|
||||
"""Start the global gateway watcher (idempotent)."""
|
||||
global _watcher
|
||||
with _watcher_lock:
|
||||
if _watcher is None:
|
||||
_watcher = GatewayWatcher()
|
||||
_watcher.start()
|
||||
|
||||
|
||||
def stop_watcher():
|
||||
"""Stop the global gateway watcher."""
|
||||
global _watcher
|
||||
with _watcher_lock:
|
||||
if _watcher is not None:
|
||||
_watcher.stop()
|
||||
_watcher = None
|
||||
|
||||
|
||||
def get_watcher() -> GatewayWatcher | None:
|
||||
"""Get the global watcher instance (or None if not started)."""
|
||||
with _watcher_lock:
|
||||
return _watcher
|
||||
@@ -269,6 +269,7 @@ def get_cli_sessions() -> list:
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
WHERE s.source IS NOT NULL AND s.source != 'webui'
|
||||
GROUP BY s.id
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT 200
|
||||
@@ -280,9 +281,11 @@ def get_cli_sessions() -> list:
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
|
||||
_source = row['source'] or 'cli'
|
||||
_display_title = row['title'] or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': row['title'] or 'CLI Session',
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or 'unknown',
|
||||
'message_count': row['message_count'] or 0,
|
||||
@@ -292,7 +295,7 @@ def get_cli_sessions() -> list:
|
||||
'archived': False,
|
||||
'project_id': None,
|
||||
'profile': profile,
|
||||
'source_tag': 'cli',
|
||||
'source_tag': _source,
|
||||
'is_cli_session': True,
|
||||
})
|
||||
except Exception:
|
||||
|
||||
@@ -327,6 +327,9 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == '/api/chat/stream':
|
||||
return _handle_sse_stream(handler, parsed)
|
||||
|
||||
if parsed.path == '/api/sessions/gateway/stream':
|
||||
return _handle_gateway_sse_stream(handler)
|
||||
|
||||
if parsed.path == '/api/file/raw':
|
||||
return _handle_file_raw(handler, parsed)
|
||||
|
||||
@@ -914,6 +917,52 @@ def _handle_sse_stream(handler, parsed):
|
||||
return True
|
||||
|
||||
|
||||
def _handle_gateway_sse_stream(handler):
|
||||
"""SSE endpoint for real-time gateway session updates.
|
||||
Streams change events from the gateway watcher background thread.
|
||||
Only active when show_cli_sessions (show_agent_sessions) setting is enabled.
|
||||
"""
|
||||
# Check if the feature is enabled
|
||||
settings = load_settings()
|
||||
if not settings.get('show_cli_sessions'):
|
||||
return j(handler, {'error': 'agent sessions not enabled'}, status=404)
|
||||
|
||||
from api.gateway_watcher import get_watcher
|
||||
watcher = get_watcher()
|
||||
if watcher is None:
|
||||
return j(handler, {'error': 'watcher not started'}, status=503)
|
||||
|
||||
handler.send_response(200)
|
||||
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||
handler.send_header('Cache-Control', 'no-cache')
|
||||
handler.send_header('X-Accel-Buffering', 'no')
|
||||
handler.send_header('Connection', 'keep-alive')
|
||||
handler.end_headers()
|
||||
|
||||
q = watcher.subscribe()
|
||||
try:
|
||||
# Send initial snapshot immediately
|
||||
from api.models import get_cli_sessions
|
||||
initial = get_cli_sessions()
|
||||
_sse(handler, 'sessions_changed', {'sessions': initial})
|
||||
|
||||
while True:
|
||||
try:
|
||||
event_data = q.get(timeout=30)
|
||||
except queue.Empty:
|
||||
handler.wfile.write(b': keepalive\n\n')
|
||||
handler.wfile.flush()
|
||||
continue
|
||||
if event_data is None:
|
||||
break # watcher is stopping
|
||||
_sse(handler, event_data.get('type', 'sessions_changed'), event_data)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
pass
|
||||
finally:
|
||||
watcher.unsubscribe(q)
|
||||
return True
|
||||
|
||||
|
||||
def _handle_file_raw(handler, parsed):
|
||||
qs = parse_qs(parsed.query)
|
||||
sid = qs.get('session_id', [''])[0]
|
||||
|
||||
@@ -463,12 +463,29 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
# Detect rate limit errors specifically so the client can show a helpful card
|
||||
# rather than the generic "Connection lost" message
|
||||
is_rate_limit = 'rate limit' in err_str.lower() or '429' in err_str or 'RateLimitError' in type(e).__name__
|
||||
is_auth_error = (
|
||||
'401' in err_str
|
||||
or 'AuthenticationError' in type(e).__name__
|
||||
or 'authentication' in err_str.lower()
|
||||
or 'unauthorized' in err_str.lower()
|
||||
or 'invalid api key' in err_str.lower()
|
||||
or 'no cookie auth credentials' in err_str.lower()
|
||||
)
|
||||
if is_rate_limit:
|
||||
put('apperror', {
|
||||
'message': err_str,
|
||||
'type': 'rate_limit',
|
||||
'hint': 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.',
|
||||
})
|
||||
elif is_auth_error:
|
||||
put('apperror', {
|
||||
'message': err_str,
|
||||
'type': 'auth_mismatch',
|
||||
'hint': (
|
||||
'The selected model may not be supported by your configured provider. '
|
||||
'Run `hermes model` in your terminal to switch providers, then restart the WebUI.'
|
||||
),
|
||||
})
|
||||
else:
|
||||
put('apperror', {'message': err_str, 'type': 'error'})
|
||||
finally:
|
||||
|
||||
18
server.py
18
server.py
@@ -110,6 +110,14 @@ def main() -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Start the gateway session watcher for real-time SSE updates
|
||||
try:
|
||||
from api.gateway_watcher import start_watcher
|
||||
start_watcher()
|
||||
except Exception as e:
|
||||
print(f'[!!] WARNING: Gateway watcher failed to start: {e}', flush=True)
|
||||
|
||||
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
|
||||
# ── TLS/HTTPS setup (optional) ─────────────────────────────────────────
|
||||
@@ -132,7 +140,15 @@ def main() -> None:
|
||||
print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} <user>@<your-server>', flush=True)
|
||||
print(f' Then open: {scheme}://localhost:{PORT}', flush=True)
|
||||
print('', flush=True)
|
||||
httpd.serve_forever()
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
finally:
|
||||
# Stop the gateway watcher on shutdown
|
||||
try:
|
||||
from api.gateway_watcher import stop_watcher
|
||||
stop_watcher()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -209,6 +209,11 @@ $('modelSelect').onchange=async()=>{
|
||||
localStorage.setItem('hermes-webui-model', selectedModel);
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;syncTopbar();
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
const warn=_checkProviderMismatch(selectedModel);
|
||||
if(warn&&typeof showToast==='function') showToast(warn,4000);
|
||||
}
|
||||
};
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
@@ -385,11 +390,13 @@ function applyBotName(){
|
||||
_initResizePanels();
|
||||
const saved=localStorage.getItem('hermes-webui-session');
|
||||
if(saved){
|
||||
try{await loadSession(saved);await renderSessionList();await checkInflightOnBoot(saved);return;}
|
||||
try{await loadSession(saved);await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
|
||||
catch(e){localStorage.removeItem('hermes-webui-session');}
|
||||
}
|
||||
// no saved session - show empty state, wait for user to hit +
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();
|
||||
// Start real-time gateway session sync if setting is enabled
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
})();
|
||||
|
||||
|
||||
203
static/i18n.js
203
static/i18n.js
@@ -48,6 +48,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} messages`,
|
||||
model_unavailable: ' (unavailable)',
|
||||
model_unavailable_title: 'This model is no longer in your current provider list',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
|
||||
provider_mismatch_label: 'Provider mismatch',
|
||||
// commands.js
|
||||
cmd_help: 'List available commands',
|
||||
cmd_clear: 'Clear conversation messages',
|
||||
@@ -121,7 +123,7 @@ const LOCALES = {
|
||||
settings_label_theme: 'Theme',
|
||||
settings_label_language: 'Language',
|
||||
settings_label_token_usage: 'Show token usage',
|
||||
settings_label_cli_sessions: 'Show CLI sessions',
|
||||
settings_label_cli_sessions: 'Show agent sessions',
|
||||
settings_label_sync_insights: 'Sync to insights',
|
||||
settings_label_check_updates: 'Check for updates',
|
||||
settings_label_bot_name: 'Assistant Name',
|
||||
@@ -193,6 +195,197 @@ const LOCALES = {
|
||||
suggest_plan: 'Help me plan a small project.',
|
||||
},
|
||||
|
||||
es: {
|
||||
_lang: 'es',
|
||||
_label: 'Español',
|
||||
_speech: 'es-ES',
|
||||
// boot.js
|
||||
cancelling: 'Cancelando…',
|
||||
cancel_failed: 'Error al cancelar: ',
|
||||
mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.',
|
||||
mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.',
|
||||
mic_network: 'El reconocimiento de voz no está disponible.',
|
||||
mic_error: 'Error de entrada por voz: ',
|
||||
session_imported: 'Sesión importada',
|
||||
import_failed: 'Error al importar: ',
|
||||
import_invalid_json: 'JSON inválido',
|
||||
image_pasted: 'Imagen pegada: ',
|
||||
// messages.js
|
||||
edit_message: 'Editar mensaje',
|
||||
regenerate: 'Regenerar respuesta',
|
||||
copy: 'Copiar',
|
||||
copied: '¡Copiado!',
|
||||
you: 'Tú',
|
||||
thinking: 'Pensando',
|
||||
expand_all: 'Expandir todo',
|
||||
collapse_all: 'Contraer todo',
|
||||
edit_failed: 'Error al editar: ',
|
||||
regen_failed: 'Error al regenerar: ',
|
||||
reconnect_active: 'Todavía se está generando una respuesta. ¿Recargar cuando termine?',
|
||||
reconnect_finished: 'Había una respuesta en curso cuando te fuiste. Puede que los mensajes se hayan actualizado.',
|
||||
// approval card
|
||||
approval_heading: 'Se requiere aprobación',
|
||||
approval_desc_prefix: 'Se detectó un comando peligroso',
|
||||
approval_btn_once: 'Permitir una vez',
|
||||
approval_btn_once_title: 'Permitir solo este comando (Enter)',
|
||||
approval_btn_session: 'Permitir en la sesión',
|
||||
approval_btn_session_title: 'Permitir durante esta sesión de conversación',
|
||||
approval_btn_always: 'Permitir siempre',
|
||||
approval_btn_always_title: 'Permitir siempre este patrón de comando',
|
||||
approval_btn_deny: 'Denegar',
|
||||
approval_btn_deny_title: 'Denegar — no ejecutar este comando',
|
||||
approval_responding: 'Respondiendo…',
|
||||
untitled: 'Sin título',
|
||||
n_messages: (n) => `${n} mensajes`,
|
||||
model_unavailable: ' (no disponible)',
|
||||
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
|
||||
provider_mismatch_label: 'Proveedor incompatible',
|
||||
// commands.js
|
||||
cmd_help: 'Listar los comandos disponibles',
|
||||
cmd_clear: 'Borrar los mensajes de la conversación',
|
||||
cmd_compact: 'Comprimir el contexto de la conversación',
|
||||
cmd_model: 'Cambiar de modelo (p. ej. /model gpt-4o)',
|
||||
cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
|
||||
cmd_new: 'Iniciar una nueva sesión de chat',
|
||||
cmd_usage: 'Activar o desactivar el uso de tokens',
|
||||
cmd_theme: 'Cambiar tema (dark/light/slate/solarized/monokai/nord/oled)',
|
||||
cmd_personality: 'Cambiar la personalidad del agente',
|
||||
cmd_skills: 'Listar las skills de Hermes disponibles',
|
||||
available_commands: 'Comandos disponibles:',
|
||||
type_slash: 'Escribe / para ver los comandos',
|
||||
conversation_cleared: 'Conversación borrada',
|
||||
model_usage: 'Uso: /model <name>',
|
||||
no_model_match: 'No hay ningún modelo que coincida con "',
|
||||
switched_to: 'Se cambió a ',
|
||||
workspace_usage: 'Uso: /workspace <name>',
|
||||
no_workspace_match: 'No hay ningún espacio de trabajo que coincida con "',
|
||||
switched_workspace: 'Se cambió al espacio de trabajo: ',
|
||||
workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ',
|
||||
new_session: 'Nueva sesión creada',
|
||||
compressing: 'Solicitando compresión del contexto...',
|
||||
token_usage_on: 'Uso de tokens activado',
|
||||
token_usage_off: 'Uso de tokens desactivado',
|
||||
theme_usage: 'Uso: /theme ',
|
||||
theme_set: 'Tema: ',
|
||||
no_active_session: 'No hay ninguna sesión activa',
|
||||
no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)',
|
||||
available_personalities: 'Personalidades disponibles:',
|
||||
personality_switch_hint: '\n\nUsa `/personality <name>` para cambiar, o `/personality none` para limpiar.',
|
||||
personalities_load_failed: 'No se pudieron cargar las personalidades',
|
||||
personality_cleared: 'Personalidad borrada',
|
||||
personality_set: 'Personalidad: ',
|
||||
failed_colon: 'Error: ',
|
||||
// ui.js
|
||||
no_workspace: 'Sin espacio de trabajo',
|
||||
// workspace.js
|
||||
unsaved_confirm: 'Tienes cambios sin guardar en la vista previa. ¿Descartar y navegar?',
|
||||
save: 'Guardar',
|
||||
edit: 'Editar',
|
||||
save_title: 'Guardar cambios',
|
||||
edit_title: 'Editar este archivo',
|
||||
saved: 'Guardado',
|
||||
save_failed: 'Error al guardar: ',
|
||||
image_load_failed: 'No se pudo cargar la imagen',
|
||||
file_open_failed: 'No se pudo abrir el archivo',
|
||||
downloading: (name) => `Descargando ${name}…`,
|
||||
double_click_rename: 'Haz doble clic para renombrar',
|
||||
renamed_to: 'Renombrado a ',
|
||||
rename_failed: 'Error al renombrar: ',
|
||||
delete_title: 'Eliminar',
|
||||
delete_confirm: (name) => `¿Eliminar ${name}?`,
|
||||
deleted: 'Eliminado ',
|
||||
delete_failed: 'Error al eliminar: ',
|
||||
new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):',
|
||||
created: 'Creado ',
|
||||
create_failed: 'Error al crear: ',
|
||||
new_folder_prompt: 'Nombre de la carpeta nueva:',
|
||||
folder_created: 'Carpeta creada ',
|
||||
folder_create_failed: 'Error al crear la carpeta: ',
|
||||
remove_title: 'Quitar',
|
||||
empty_dir: '(vacío)',
|
||||
upload_failed: 'Error al subir: ',
|
||||
all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`,
|
||||
// settings panel
|
||||
settings_title: 'Configuración',
|
||||
settings_save_btn: 'Guardar configuración',
|
||||
settings_label_model: 'Modelo predeterminado',
|
||||
settings_label_send_key: 'Tecla de envío',
|
||||
settings_label_theme: 'Tema',
|
||||
settings_label_language: 'Idioma',
|
||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
|
||||
settings_label_sync_insights: 'Sincronizar con insights',
|
||||
settings_label_check_updates: 'Buscar actualizaciones',
|
||||
settings_label_bot_name: 'Nombre del asistente',
|
||||
settings_label_password: 'Contraseña de acceso',
|
||||
settings_saved: 'Configuración guardada',
|
||||
settings_save_failed: 'Error al guardar: ',
|
||||
settings_load_failed: 'Error al cargar la configuración: ',
|
||||
settings_saved_pw: 'Configuración guardada (contraseña establecida — ahora se requiere iniciar sesión)',
|
||||
// login page (used server-side via /api/i18n/login endpoint)
|
||||
login_title: 'Iniciar sesión',
|
||||
login_subtitle: 'Introduce tu contraseña para continuar',
|
||||
login_placeholder: 'Contraseña',
|
||||
login_btn: 'Entrar',
|
||||
login_invalid_pw: 'Contraseña inválida',
|
||||
login_conn_failed: 'Error de conexión',
|
||||
dialog_confirm_title: 'Confirmar acción',
|
||||
dialog_prompt_title: 'Introduce un valor',
|
||||
dialog_confirm_btn: 'Confirmar',
|
||||
discard: 'Descartar',
|
||||
clear: 'Borrar',
|
||||
create: 'Crear',
|
||||
remove: 'Quitar',
|
||||
project_name_prompt: 'Nombre del proyecto:',
|
||||
// Sidebar & Tabs
|
||||
tab_chat: 'Chat',
|
||||
tab_tasks: 'Tareas',
|
||||
tab_skills: 'Habilidades',
|
||||
tab_memory: 'Memoria',
|
||||
tab_workspaces: 'Espacios',
|
||||
tab_profiles: 'Perfiles',
|
||||
tab_todos: 'Todos',
|
||||
new_conversation: 'Nueva conversación',
|
||||
filter_conversations: 'Filtrar conversaciones...',
|
||||
scheduled_jobs: 'Tareas programadas',
|
||||
new_job: 'Nueva tarea',
|
||||
loading: 'Cargando...',
|
||||
search_skills: 'Buscar skills...',
|
||||
new_skill: 'Nueva skill',
|
||||
personal_memory: 'Memoria personal',
|
||||
current_task_list: 'Lista de tareas actual',
|
||||
workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.',
|
||||
new_profile: 'Nuevo perfil',
|
||||
transcript: 'Transcripción',
|
||||
download_transcript: 'Descargar como Markdown',
|
||||
import: 'Importar',
|
||||
// Settings detail
|
||||
settings_label_sound: 'Sonido de notificación',
|
||||
settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.',
|
||||
settings_label_notifications: 'Notificaciones del navegador',
|
||||
settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.',
|
||||
settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.',
|
||||
settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.',
|
||||
settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.',
|
||||
settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.',
|
||||
settings_desc_bot_name: 'Nombre visible del asistente en toda la UI. Por defecto es Hermes.',
|
||||
settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.',
|
||||
password_placeholder: 'Introduce una contraseña nueva…',
|
||||
disable_auth: 'Desactivar autenticación',
|
||||
sign_out: 'Cerrar sesión',
|
||||
cancel: 'Cancelar',
|
||||
create_job: 'Crear tarea',
|
||||
save_skill: 'Guardar skill',
|
||||
editing: 'Editando',
|
||||
// Empty state
|
||||
empty_title: '¿En qué puedo ayudarte?',
|
||||
empty_subtitle: 'Pregunta lo que quieras, ejecuta comandos, explora archivos o gestiona tus tareas programadas.',
|
||||
suggest_files: '¿Qué archivos hay en este espacio de trabajo?',
|
||||
suggest_schedule: '¿Qué tengo hoy en mi agenda?',
|
||||
suggest_plan: 'Ayúdame a planificar un proyecto pequeño.',
|
||||
},
|
||||
|
||||
de: {
|
||||
_lang: 'de',
|
||||
_label: 'Deutsch',
|
||||
@@ -237,6 +430,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} Nachrichten`,
|
||||
model_unavailable: ' (nicht verfügbar)',
|
||||
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
|
||||
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
|
||||
provider_mismatch_label: 'Provider-Konflikt',
|
||||
// commands.js
|
||||
cmd_help: 'Verfügbare Befehle auflisten',
|
||||
cmd_clear: 'Konversationsverlauf löschen',
|
||||
@@ -310,7 +505,7 @@ const LOCALES = {
|
||||
settings_label_theme: 'Theme',
|
||||
settings_label_language: 'Sprache',
|
||||
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
||||
settings_label_cli_sessions: 'CLI-Sitzungen anzeigen',
|
||||
settings_label_cli_sessions: 'Agent-Sitzungen anzeigen',
|
||||
settings_label_sync_insights: 'Mit Insights synchronisieren',
|
||||
settings_label_check_updates: 'Nach Updates suchen',
|
||||
settings_label_bot_name: 'Assistenten-Name',
|
||||
@@ -426,6 +621,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u8fd9\u4e2a\u6a21\u578b\u5df2\u7ecf\u4e0d\u5728\u5f53\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u65e0\u6cd5\u5728\u5f53\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u5546 (${p}) \u4e0b\u5de5\u4f5c\u3002\u76f4\u63a5\u53d1\u9001\uff0c\u6216\u5728\u7ec8\u7aef\u8fd0\u884c \`hermes model\` \u5207\u6362\u3002`,
|
||||
provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d',
|
||||
// commands.js
|
||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||
cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f',
|
||||
@@ -613,6 +810,8 @@ const LOCALES = {
|
||||
n_messages: (n) => `${n} \u689d\u8a0a\u606f`,
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
|
||||
provider_mismatch_label: '\u63d0\u4f9b\u8005\u4e0d\u76f8\u7b26',
|
||||
// commands.js
|
||||
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
|
||||
cmd_clear: '\u6e05\u7a7a\u7576\u524d\u5c0d\u8a71\u8a0a\u606f',
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.47.0</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.48.2</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
||||
@@ -328,7 +328,7 @@
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button class="panel-icon-btn mobile-close-btn" onclick="closeMobileFiles()" title="Close">×</button>
|
||||
<button class="panel-icon-btn mobile-close-btn" onclick="closeMobileFiles()" title="Close" aria-label="Close workspace panel">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
@@ -405,9 +405,9 @@
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_cli_sessions">Show CLI sessions in sidebar</span>
|
||||
<span data-i18n="settings_label_cli_sessions">Show agent sessions in sidebar</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_cli_sessions">Merges sessions from Hermes agent platforms (CLI, Telegram, Discord, Slack, etc.) into the session list. Agent sessions are view-only.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
|
||||
@@ -234,7 +234,8 @@ async function send(){
|
||||
try{
|
||||
const d=JSON.parse(e.data);
|
||||
const isRateLimit=d.type==='rate_limit';
|
||||
const label=isRateLimit?'Rate limit reached':'Error';
|
||||
const isAuthMismatch=d.type==='auth_mismatch';
|
||||
const label=isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):'Error';
|
||||
const hint=d.hint?`\n\n*${d.hint}*`:'';
|
||||
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
|
||||
}catch(_){
|
||||
|
||||
@@ -1113,6 +1113,8 @@ async function saveSettings(andClose){
|
||||
if(typeof applyBotName==='function') applyBotName();
|
||||
if(typeof setLocale==='function') setLocale(language);
|
||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||||
// Restart gateway SSE when agent session setting changes
|
||||
if(typeof startGatewaySSE==='function'){if(showCliSessions)startGatewaySSE();else if(typeof stopGatewaySSE==='function')stopGatewaySSE();}
|
||||
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
||||
renderMessages();
|
||||
|
||||
@@ -248,6 +248,35 @@ async function renderSessionList(){
|
||||
}catch(e){console.warn('renderSessionList',e);}
|
||||
}
|
||||
|
||||
// ── Gateway session SSE (real-time sync for agent sessions) ──
|
||||
let _gatewaySSE = null;
|
||||
|
||||
function startGatewaySSE(){
|
||||
stopGatewaySSE();
|
||||
if(!window._showCliSessions) return;
|
||||
try{
|
||||
_gatewaySSE = new EventSource('/api/sessions/gateway/stream');
|
||||
_gatewaySSE.addEventListener('sessions_changed', (ev) => {
|
||||
try{
|
||||
const data = JSON.parse(ev.data);
|
||||
if(data.sessions){
|
||||
renderSessionList(); // re-fetch and re-render
|
||||
}
|
||||
}catch(e){ /* ignore parse errors */ }
|
||||
});
|
||||
_gatewaySSE.onerror = () => {
|
||||
// EventSource auto-reconnects; no action needed
|
||||
};
|
||||
}catch(e){ /* SSE not available */ }
|
||||
}
|
||||
|
||||
function stopGatewaySSE(){
|
||||
if(_gatewaySSE){
|
||||
_gatewaySSE.close();
|
||||
_gatewaySSE = null;
|
||||
}
|
||||
}
|
||||
|
||||
let _searchDebounceTimer = null;
|
||||
let _contentSearchResults = []; // results from /api/sessions/search content scan
|
||||
|
||||
@@ -409,6 +438,7 @@ function renderSessionListFromCache(){
|
||||
const el=document.createElement('div');
|
||||
const isActive=S.session&&s.session_id===S.session.session_id;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(s.is_cli_session?' cli-session':'');
|
||||
if(s.source_tag) el.dataset.source=s.source_tag;
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const rawTitle=s.title||'Untitled';
|
||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
||||
|
||||
@@ -138,9 +138,9 @@
|
||||
.session-actions-trigger svg{display:block;}
|
||||
.session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.session-action-menu.open{display:block;}
|
||||
.session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);}
|
||||
.session-action-opt .ws-opt-action{width:100%;align-items:flex-start;}
|
||||
.session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;}
|
||||
.session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);flex-direction:row!important;gap:0!important;padding:0!important;}
|
||||
.session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:10px 14px;}
|
||||
.session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;flex-shrink:0;display:flex;align-items:center;width:16px;}
|
||||
.session-action-opt:hover .ws-opt-icon{color:var(--text);opacity:1;}
|
||||
.session-action-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
||||
.session-action-meta{font-size:11px;color:var(--muted);line-height:1.3;white-space:normal;opacity:.72;}
|
||||
@@ -862,13 +862,13 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
|
||||
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}
|
||||
|
||||
/* ── CLI session items in sidebar ── */
|
||||
/* ── CLI / Agent session items in sidebar ── */
|
||||
.session-item.cli-session {
|
||||
border-left-color: var(--gold);
|
||||
padding-right: 40px; /* make room for the session actions trigger */
|
||||
}
|
||||
.session-item.cli-session::after {
|
||||
content: 'cli';
|
||||
content: attr(data-source);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -882,3 +882,10 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.session-item.cli-session:hover::after {
|
||||
display: none; /* hide badge on hover so session-actions icons are fully reachable */
|
||||
}
|
||||
/* Source-specific colors for gateway sessions */
|
||||
.session-item.cli-session[data-source="telegram"] { border-left-color: #0088cc; }
|
||||
.session-item.cli-session[data-source="telegram"]::after { color: #0088cc; }
|
||||
.session-item.cli-session[data-source="discord"] { border-left-color: #5865F2; }
|
||||
.session-item.cli-session[data-source="discord"]::after { color: #5865F2; }
|
||||
.session-item.cli-session[data-source="slack"] { border-left-color: #4A154B; }
|
||||
.session-item.cli-session[data-source="slack"]::after { color: #4A154B; }
|
||||
|
||||
32
static/ui.js
32
static/ui.js
@@ -45,6 +45,8 @@ async function populateModelDropdown(){
|
||||
try{
|
||||
const data=await fetch(new URL('/api/models',location.origin).href,{credentials:'include'}).then(r=>r.json());
|
||||
if(!data.groups||!data.groups.length) return; // keep HTML defaults
|
||||
// Store active provider globally so the send path can warn on mismatch
|
||||
window._activeProvider=data.active_provider||null;
|
||||
// Clear existing options
|
||||
sel.innerHTML='';
|
||||
_dynamicModelLabels={};
|
||||
@@ -70,6 +72,32 @@ async function populateModelDropdown(){
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given model ID belongs to a different provider than the one
|
||||
* currently configured in Hermes. Returns a warning string if mismatched,
|
||||
* or null if the selection looks compatible.
|
||||
*
|
||||
* Provider detection is intentionally loose — we compare the model's slash
|
||||
* prefix (e.g. "openai/" from "openai/gpt-4o") against the active provider
|
||||
* name. Custom/local endpoints report active_provider='custom' or the
|
||||
* base_url hostname and we skip the check to avoid false positives.
|
||||
*/
|
||||
function _checkProviderMismatch(modelId){
|
||||
const ap=(window._activeProvider||'').toLowerCase();
|
||||
if(!ap||ap==='custom'||ap==='openrouter') return null; // can't reliably check
|
||||
const slash=modelId.indexOf('/');
|
||||
if(slash<0) return null; // bare model name, no provider prefix
|
||||
const modelProvider=modelId.substring(0,slash).toLowerCase();
|
||||
// Normalise common aliases
|
||||
const aliases={'claude':'anthropic','gpt':'openai','gemini':'google'};
|
||||
const norm=p=>aliases[p]||p;
|
||||
if(norm(modelProvider)!==norm(ap)){
|
||||
return (window.t?window.t('provider_mismatch_warning',modelId,ap):
|
||||
`"${modelId}" may not work with your configured provider (${ap}). Send anyway or run \`hermes model\` to switch.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||||
// Once the user scrolls back to within 80px of the bottom, re-pin.
|
||||
@@ -211,8 +239,8 @@ function renderMd(raw){
|
||||
if(rows.length<2)return block;
|
||||
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
|
||||
if(!isSep(rows[1]))return block;
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${esc(c.trim())}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${esc(c.trim())}</th>`).join('');
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${inlineMd(c.trim())}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${inlineMd(c.trim())}</th>`).join('');
|
||||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||||
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
||||
|
||||
@@ -238,6 +238,13 @@ def test_server():
|
||||
# Isolated cron state
|
||||
(TEST_STATE_DIR / 'cron').mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Expose TEST_STATE_DIR to the test process itself so that tests which write
|
||||
# directly to state.db (e.g. test_gateway_sync.py) always use the same path
|
||||
# as the server. Other test files (test_auth_sessions.py) may override
|
||||
# HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR
|
||||
# is reserved for this mapping and is never overridden by individual test files.
|
||||
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"HERMES_WEBUI_PORT": str(TEST_PORT),
|
||||
|
||||
364
tests/test_gateway_sync.py
Normal file
364
tests/test_gateway_sync.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Tests for Phase 1: Real-time Gateway Session Sync.
|
||||
|
||||
Tests are ordered TDD-style:
|
||||
1. Gateway sessions appear in /api/sessions when setting enabled
|
||||
2. Gateway sessions excluded when setting disabled
|
||||
3. Gateway sessions have correct metadata (source_tag, is_cli_session)
|
||||
4. SSE stream endpoint opens and receives events
|
||||
5. Watcher detects new sessions inserted into state.db
|
||||
6. Settings UI has renamed label
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return json.loads(e.read()), e.code
|
||||
except Exception:
|
||||
return {}, e.code
|
||||
|
||||
|
||||
def _get_test_state_dir():
|
||||
"""Return the test state directory (matches conftest.py TEST_STATE_DIR).
|
||||
|
||||
conftest.py sets HERMES_WEBUI_TEST_STATE_DIR in the test-process environment
|
||||
(via os.environ.setdefault) so that tests writing directly to state.db always
|
||||
use the same path the test server was started with. If the env var is not
|
||||
set (e.g. when running this file standalone), fall back to the conftest
|
||||
formula: HERMES_HOME/webui-mvp-test.
|
||||
"""
|
||||
explicit = os.getenv('HERMES_WEBUI_TEST_STATE_DIR')
|
||||
if explicit:
|
||||
return pathlib.Path(explicit)
|
||||
hermes_home = pathlib.Path(os.getenv('HERMES_HOME', str(pathlib.Path.home() / '.hermes')))
|
||||
return hermes_home / 'webui-mvp-test' # matches conftest.py TEST_STATE_DIR formula
|
||||
|
||||
|
||||
def _get_state_db_path():
|
||||
"""Return path to the test state.db."""
|
||||
return _get_test_state_dir() / 'state.db'
|
||||
|
||||
|
||||
def _ensure_state_db():
|
||||
"""Create state.db with sessions and messages tables if it doesn't exist.
|
||||
Returns a connection. Does NOT delete existing data (safe for parallel tests).
|
||||
"""
|
||||
db_path = _get_state_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
title TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _insert_gateway_session(conn, session_id='20260401_120000_abcdefgh', source='telegram',
|
||||
title='Telegram Chat', model='anthropic/claude-sonnet-4-5',
|
||||
started_at=None, message_count=2):
|
||||
"""Insert a gateway session into state.db."""
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(session_id, source, title, model, started_at or time.time(), message_count)
|
||||
)
|
||||
# Delete any existing messages for this session (idempotent re-insert)
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
||||
# Insert some messages
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'user', ?, ?)",
|
||||
(session_id, 'Hello from Telegram', started_at or time.time())
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'assistant', ?, ?)",
|
||||
(session_id, 'Hi there!', (started_at or time.time()) + 1)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _remove_test_sessions(conn, *session_ids):
|
||||
"""Remove specific test sessions from state.db (parallel-safe cleanup)."""
|
||||
for sid in session_ids:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _cleanup_state_db():
|
||||
"""Remove state.db if it exists (only used for tests that need a blank slate)."""
|
||||
db_path = _get_state_db_path()
|
||||
for p in [db_path, db_path.parent / 'state.db-wal', db_path.parent / 'state.db-shm']:
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gateway_sessions_appear_when_enabled():
|
||||
"""Gateway sessions from state.db appear in /api/sessions when show_cli_sessions is on."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_test_tg_001', source='telegram', title='TG Test Chat')
|
||||
|
||||
# Enable the setting
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_tg_001']
|
||||
assert len(gw_ids) == 1, f"Expected gateway session gw_test_tg_001, got {[s['session_id'] for s in sessions]}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_test_tg_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sessions_excluded_when_disabled():
|
||||
"""Gateway sessions are NOT returned when show_cli_sessions is off."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_test_dc_001', source='discord', title='DC Test Chat')
|
||||
|
||||
# Ensure setting is off
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_dc_001']
|
||||
assert len(gw_ids) == 0, "Gateway session should not appear when setting is off"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_test_dc_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_gateway_session_has_correct_metadata():
|
||||
"""Gateway sessions include source_tag and is_cli_session fields."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_meta_001', source='telegram', title='Meta Test')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw = next((s for s in sessions if s['session_id'] == 'gw_meta_001'), None)
|
||||
assert gw is not None, "Gateway session not found"
|
||||
assert gw.get('source_tag') == 'telegram', f"Expected source_tag=telegram, got {gw.get('source_tag')}"
|
||||
assert gw.get('is_cli_session') is True, "is_cli_session should be True for agent sessions"
|
||||
assert gw.get('title') == 'Meta Test'
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_meta_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_session_has_message_count():
|
||||
"""Gateway sessions report correct message_count from state.db."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_msg_001', source='discord', title='Msg Count Test', message_count=5)
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw = next((s for s in sessions if s['session_id'] == 'gw_msg_001'), None)
|
||||
assert gw is not None
|
||||
assert gw.get('message_count') == 5, f"Expected message_count=5, got {gw.get('message_count')}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_msg_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sessions_multiple_sources():
|
||||
"""Sessions from multiple gateway sources (telegram, discord, slack) all appear."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_multi_tg', source='telegram', title='TG Chat')
|
||||
_insert_gateway_session(conn, session_id='gw_multi_dc', source='discord', title='DC Chat')
|
||||
_insert_gateway_session(conn, session_id='gw_multi_sl', source='slack', title='SL Chat')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
gw_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')}
|
||||
assert len(gw_ids) == 3, f"Expected 3 gateway sessions, got {len(gw_ids)}: {gw_ids}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_session_messages_readable():
|
||||
"""Gateway session messages can be loaded via /api/session."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='gw_read_001', source='telegram', title='Readable')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get(f'/api/session?session_id=gw_read_001')
|
||||
assert status == 200
|
||||
msgs = data.get('session', {}).get('messages', [])
|
||||
assert len(msgs) >= 2, f"Expected at least 2 messages, got {len(msgs)}"
|
||||
assert msgs[0].get('role') == 'user'
|
||||
assert msgs[0].get('content') == 'Hello from Telegram'
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'gw_read_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sse_stream_endpoint_exists():
|
||||
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
|
||||
# The SSE endpoint requires show_cli_sessions to be enabled
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
try:
|
||||
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream')
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
assert r.status in (200, 204), f"Expected 200/204, got {r.status}"
|
||||
# SSE should have content-type text/event-stream
|
||||
ctype = r.headers.get('Content-Type', '')
|
||||
assert 'text/event-stream' in ctype, f"Expected text/event-stream, got {ctype}"
|
||||
except Exception as e:
|
||||
# Timeout is acceptable — means the connection is held open (SSE behavior)
|
||||
if 'timed out' in str(e).lower() or 'timeout' in str(e).lower():
|
||||
pass # Good: SSE keeps the connection open
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_webui_sessions_not_duplicated():
|
||||
"""If a session_id exists both in WebUI store and state.db, it's not duplicated."""
|
||||
# Create a WebUI session with a known ID
|
||||
body = {}
|
||||
d, _ = post('/api/session/new', body)
|
||||
webui_sid = d['session']['session_id']
|
||||
|
||||
try:
|
||||
# Insert the same session_id into state.db as a gateway session
|
||||
conn = _ensure_state_db()
|
||||
_insert_gateway_session(conn, session_id=webui_sid, source='telegram', title='Dup Test')
|
||||
conn.close()
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
matching = [s for s in sessions if s['session_id'] == webui_sid]
|
||||
assert len(matching) == 1, f"Expected 1 entry for {webui_sid}, got {len(matching)}"
|
||||
finally:
|
||||
try:
|
||||
conn2 = sqlite3.connect(str(_get_state_db_path()))
|
||||
_remove_test_sessions(conn2, webui_sid)
|
||||
conn2.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/session/delete', {'session_id': webui_sid})
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_gateway_sessions_no_state_db():
|
||||
"""When state.db doesn't exist, /api/sessions works fine (no gateway sessions)."""
|
||||
_cleanup_state_db()
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
try:
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
# Should succeed with just webui sessions (or empty)
|
||||
assert 'sessions' in data
|
||||
finally:
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_cli_sessions_still_work():
|
||||
"""CLI sessions (source='cli') still appear alongside gateway sessions."""
|
||||
conn = _ensure_state_db()
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id='cli_legacy_001', source='cli', title='CLI Legacy')
|
||||
_insert_gateway_session(conn, session_id='gw_new_001', source='telegram', title='GW New')
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
sessions = data.get('sessions', [])
|
||||
agent_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('cli_legacy_001', 'gw_new_001')}
|
||||
assert len(agent_ids) == 2, f"Expected 2 agent sessions (cli + gateway), got {len(agent_ids)}"
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, 'cli_legacy_001', 'gw_new_001')
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
266
tests/test_provider_mismatch.py
Normal file
266
tests/test_provider_mismatch.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Tests for issue #266 — provider/model mismatch warning.
|
||||
|
||||
Covers:
|
||||
1. streaming.py: auth errors detected and classified as 'auth_mismatch'
|
||||
2. static/ui.js: _checkProviderMismatch() helper exists and logic is correct
|
||||
3. static/messages.js: apperror handler has auth_mismatch branch
|
||||
4. static/i18n.js: provider_mismatch_warning and provider_mismatch_label keys
|
||||
present in all 5 locales (en, es, de, zh, zh-Hant)
|
||||
5. static/boot.js: modelSelect.onchange calls _checkProviderMismatch
|
||||
6. /api/models: response includes active_provider field
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def _read(rel_path: str) -> str:
|
||||
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── 1. streaming.py: auth error detection ───────────────────────────────────
|
||||
|
||||
class TestStreamingAuthErrorDetection:
|
||||
"""streaming.py must classify auth/401 errors as auth_mismatch."""
|
||||
|
||||
def test_auth_mismatch_type_defined_in_streaming(self):
|
||||
"""'auth_mismatch' type must be emitted for auth errors."""
|
||||
src = _read("api/streaming.py")
|
||||
assert "auth_mismatch" in src, (
|
||||
"auth_mismatch type not found in streaming.py — "
|
||||
"401/auth errors will not be surfaced with a helpful message"
|
||||
)
|
||||
|
||||
def test_is_auth_error_flag_defined(self):
|
||||
"""is_auth_error variable must exist in the error handler."""
|
||||
src = _read("api/streaming.py")
|
||||
assert "is_auth_error" in src, (
|
||||
"is_auth_error flag not found in streaming.py"
|
||||
)
|
||||
|
||||
def test_auth_error_detects_401(self):
|
||||
"""'401' must be part of the auth error detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
# Find the is_auth_error block
|
||||
idx = src.find("is_auth_error")
|
||||
assert idx != -1
|
||||
block = src[idx:idx + 400]
|
||||
assert "'401'" in block or '"401"' in block, (
|
||||
"'401' not in is_auth_error detection block"
|
||||
)
|
||||
|
||||
def test_auth_error_detects_unauthorized(self):
|
||||
"""'unauthorized' must be part of the auth error detection logic."""
|
||||
src = _read("api/streaming.py")
|
||||
idx = src.find("is_auth_error")
|
||||
block = src[idx:idx + 400]
|
||||
assert "unauthorized" in block.lower(), (
|
||||
"'unauthorized' not in is_auth_error detection block"
|
||||
)
|
||||
|
||||
def test_auth_error_hint_mentions_hermes_model(self):
|
||||
"""The auth_mismatch hint must mention 'hermes model' command."""
|
||||
src = _read("api/streaming.py")
|
||||
# Find the auth_mismatch apperror block
|
||||
idx = src.find("auth_mismatch")
|
||||
block = src[idx:idx + 500]
|
||||
assert "hermes model" in block, (
|
||||
"auth_mismatch hint must mention 'hermes model' command "
|
||||
"so users know how to fix provider mismatch"
|
||||
)
|
||||
|
||||
def test_auth_error_does_not_catch_rate_limit(self):
|
||||
"""Rate limit errors must not be reclassified as auth_mismatch."""
|
||||
src = _read("api/streaming.py")
|
||||
# is_rate_limit must come before is_auth_error in the elif chain
|
||||
rl_idx = src.find("is_rate_limit")
|
||||
ae_idx = src.find("is_auth_error")
|
||||
assert rl_idx < ae_idx, (
|
||||
"is_rate_limit check should precede is_auth_error — "
|
||||
"rate limit errors must not be mistaken for auth errors"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. static/ui.js: _checkProviderMismatch() ───────────────────────────────
|
||||
|
||||
class TestCheckProviderMismatch:
|
||||
"""ui.js must expose _checkProviderMismatch() helper."""
|
||||
|
||||
def test_function_defined(self):
|
||||
"""_checkProviderMismatch function must be defined in ui.js."""
|
||||
src = _read("static/ui.js")
|
||||
assert "function _checkProviderMismatch" in src, (
|
||||
"_checkProviderMismatch not defined in ui.js"
|
||||
)
|
||||
|
||||
def test_uses_window_active_provider(self):
|
||||
"""Function must read window._activeProvider."""
|
||||
src = _read("static/ui.js")
|
||||
idx = src.find("function _checkProviderMismatch")
|
||||
block = src[idx:idx + 800]
|
||||
assert "_activeProvider" in block, (
|
||||
"_checkProviderMismatch must read window._activeProvider"
|
||||
)
|
||||
|
||||
def test_skips_check_for_openrouter(self):
|
||||
"""OpenRouter can route to any provider — skip the warning."""
|
||||
src = _read("static/ui.js")
|
||||
idx = src.find("function _checkProviderMismatch")
|
||||
block = src[idx:idx + 800]
|
||||
assert "openrouter" in block.lower(), (
|
||||
"_checkProviderMismatch must skip the check for openrouter"
|
||||
)
|
||||
|
||||
def test_skips_check_for_custom(self):
|
||||
"""Custom endpoints can serve any model — skip the warning."""
|
||||
src = _read("static/ui.js")
|
||||
idx = src.find("function _checkProviderMismatch")
|
||||
block = src[idx:idx + 800]
|
||||
assert "custom" in block.lower(), (
|
||||
"_checkProviderMismatch must skip the check for custom provider"
|
||||
)
|
||||
|
||||
def test_active_provider_stored_on_model_load(self):
|
||||
"""populateModelDropdown must store active_provider from /api/models."""
|
||||
src = _read("static/ui.js")
|
||||
# Find the function definition (skip the comment that also mentions the name)
|
||||
idx = src.find("async function populateModelDropdown")
|
||||
assert idx != -1, "async function populateModelDropdown not found"
|
||||
block = src[idx:idx + 800]
|
||||
assert "_activeProvider" in block, (
|
||||
"populateModelDropdown must set window._activeProvider "
|
||||
"from the /api/models response"
|
||||
)
|
||||
|
||||
|
||||
# ── 3. static/messages.js: apperror handler ─────────────────────────────────
|
||||
|
||||
class TestApperrorHandler:
|
||||
"""messages.js apperror handler must handle auth_mismatch type."""
|
||||
|
||||
def test_auth_mismatch_type_handled(self):
|
||||
"""apperror handler must check for type='auth_mismatch'."""
|
||||
src = _read("static/messages.js")
|
||||
assert "auth_mismatch" in src, (
|
||||
"auth_mismatch type not handled in messages.js apperror handler"
|
||||
)
|
||||
|
||||
def test_provider_mismatch_label(self):
|
||||
"""'Provider mismatch' label must appear in the error handling."""
|
||||
src = _read("static/messages.js")
|
||||
assert "Provider mismatch" in src, (
|
||||
"'Provider mismatch' label not found in messages.js"
|
||||
)
|
||||
|
||||
def test_is_auth_mismatch_variable(self):
|
||||
"""isAuthMismatch variable must be defined."""
|
||||
src = _read("static/messages.js")
|
||||
assert "isAuthMismatch" in src, (
|
||||
"isAuthMismatch variable not found in messages.js apperror handler"
|
||||
)
|
||||
|
||||
|
||||
# ── 4. static/i18n.js: all 5 locales ────────────────────────────────────────
|
||||
|
||||
class TestI18nProviderMismatch:
|
||||
"""All 5 locales must have provider_mismatch_warning and provider_mismatch_label."""
|
||||
|
||||
REQUIRED_KEYS = ["provider_mismatch_warning", "provider_mismatch_label"]
|
||||
|
||||
def _count_key(self, src: str, key: str) -> int:
|
||||
return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
|
||||
|
||||
def test_all_locales_have_warning_key(self):
|
||||
"""provider_mismatch_warning must appear in all 5 locales."""
|
||||
src = _read("static/i18n.js")
|
||||
count = self._count_key(src, "provider_mismatch_warning")
|
||||
assert count >= 5, (
|
||||
f"provider_mismatch_warning found {count} times, expected >= 5 "
|
||||
f"(one per locale: en, es, de, zh, zh-Hant)"
|
||||
)
|
||||
|
||||
def test_all_locales_have_label_key(self):
|
||||
"""provider_mismatch_label must appear in all 5 locales."""
|
||||
src = _read("static/i18n.js")
|
||||
count = self._count_key(src, "provider_mismatch_label")
|
||||
assert count >= 5, (
|
||||
f"provider_mismatch_label found {count} times, expected >= 5"
|
||||
)
|
||||
|
||||
def test_warning_is_function_in_en(self):
|
||||
"""English provider_mismatch_warning must be a function (m, p) => ..."""
|
||||
src = _read("static/i18n.js")
|
||||
# Find the en block
|
||||
en_start = src.find("\n en: {")
|
||||
es_start = src.find("\n es: {")
|
||||
en_block = src[en_start:es_start]
|
||||
assert "provider_mismatch_warning" in en_block, "Key not in en block"
|
||||
idx = en_block.find("provider_mismatch_warning")
|
||||
line = en_block[idx:idx + 200]
|
||||
# Must be a function, not a plain string
|
||||
assert "=>" in line, (
|
||||
"provider_mismatch_warning in en locale must be an arrow function "
|
||||
"that takes (m, p) parameters for model and provider interpolation"
|
||||
)
|
||||
|
||||
def test_spanish_locale_key_coverage(self):
|
||||
"""Spanish locale must have the new keys (parity with English)."""
|
||||
src = _read("static/i18n.js")
|
||||
es_start = src.find("\n es: {")
|
||||
de_start = src.find("\n de: {")
|
||||
es_block = src[es_start:de_start]
|
||||
for key in self.REQUIRED_KEYS:
|
||||
assert key in es_block, f"Key '{key}' missing from Spanish locale"
|
||||
|
||||
|
||||
# ── 5. static/boot.js: dropdown change handler ──────────────────────────────
|
||||
|
||||
class TestBootModelSelectChange:
|
||||
"""boot.js modelSelect.onchange must call _checkProviderMismatch."""
|
||||
|
||||
def test_onchange_calls_check_function(self):
|
||||
"""modelSelect.onchange must invoke _checkProviderMismatch."""
|
||||
src = _read("static/boot.js")
|
||||
assert "_checkProviderMismatch" in src, (
|
||||
"boot.js modelSelect.onchange must call _checkProviderMismatch "
|
||||
"to warn users about provider/model mismatches"
|
||||
)
|
||||
# Verify it's called from the onchange handler (near modelSelect.onchange)
|
||||
idx = src.find("'modelSelect').onchange") or src.find('"modelSelect").onchange')
|
||||
if idx == -1:
|
||||
# Try alternate patterns
|
||||
idx = src.find("modelSelect")
|
||||
block_start = src.rfind("\n", 0, src.find("_checkProviderMismatch")) or 0
|
||||
surrounding = src[max(0, block_start - 200):block_start + 400]
|
||||
assert "modelSelect" in surrounding or "selectedModel" in surrounding, (
|
||||
"_checkProviderMismatch must be called in the context of model selection"
|
||||
)
|
||||
|
||||
def test_onchange_shows_toast_on_mismatch(self):
|
||||
"""The warning must be shown via showToast, not alert()."""
|
||||
src = _read("static/boot.js")
|
||||
# Both _checkProviderMismatch call and showToast must be near each other
|
||||
idx = src.find("_checkProviderMismatch")
|
||||
assert idx != -1, "_checkProviderMismatch not found in boot.js"
|
||||
block = src[idx:idx + 300]
|
||||
assert "showToast" in block, (
|
||||
"Provider mismatch warning must be shown via showToast(), not alert()"
|
||||
)
|
||||
|
||||
|
||||
# ── 6. /api/models: active_provider in response ──────────────────────────────
|
||||
|
||||
def test_api_models_includes_active_provider():
|
||||
"""/api/models must include 'active_provider' key in response."""
|
||||
with urllib.request.urlopen(BASE + "/api/models", timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
# active_provider can be None/null but the key must exist
|
||||
assert "active_provider" in data, (
|
||||
"/api/models response missing 'active_provider' field — "
|
||||
"frontend needs this to detect provider mismatches"
|
||||
)
|
||||
45
tests/test_spanish_locale.py
Normal file
45
tests/test_spanish_locale.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_spanish_locale_block_exists():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
assert "\n es: {" in src
|
||||
assert "_label: 'Español'" in src
|
||||
assert "_speech: 'es-ES'" in src
|
||||
|
||||
|
||||
def test_spanish_locale_includes_representative_translations():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
expected = [
|
||||
"settings_title: 'Configuración'",
|
||||
"login_title: 'Iniciar sesión'",
|
||||
"approval_heading: 'Se requiere aprobación'",
|
||||
"tab_tasks: 'Tareas'",
|
||||
"tab_skills: 'Habilidades'",
|
||||
"tab_memory: 'Memoria'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in src
|
||||
|
||||
|
||||
def test_spanish_locale_covers_english_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
en_match = re.search(r"\n en: \{([\s\S]*?)\n \},\n\n es: \{", src)
|
||||
es_match = re.search(r"\n es: \{([\s\S]*?)\n \},\n\n de: \{", src)
|
||||
assert en_match, "English locale block not found"
|
||||
assert es_match, "Spanish locale block not found"
|
||||
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
en_keys = set(key_pattern.findall(en_match.group(1)))
|
||||
es_keys = set(key_pattern.findall(es_match.group(1)))
|
||||
|
||||
missing = sorted(en_keys - es_keys)
|
||||
assert not missing, f"Spanish locale missing keys: {missing}"
|
||||
Reference in New Issue
Block a user