Compare commits

..
Author SHA1 Message Date
ethernet 4312a9cc4e test ci 2026-06-12 15:02:05 -04:00
198 changed files with 4942 additions and 15218 deletions
+49
View File
@@ -0,0 +1,49 @@
name: E2E CLI Tests
on:
push:
branches:
- "**"
permissions:
contents: read
jobs:
e2e-tui-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: cd e2e && CI=true npm run test
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
- name: Bundle TUI traces into self-contained replay HTML
if: always()
run: node e2e/scripts/bundle-replay-html.mjs
- name: Upload TUI replay viewer
uses: actions/upload-artifact@v4
if: always()
with:
name: tui-replay-viewer
path: tui-replay-viewer/
retention-days: 7
- name: Upload raw TUI test traces
uses: actions/upload-artifact@v4
if: always()
with:
name: tui-test-traces
path: e2e/tui-traces/
retention-days: 7
+2
View File
@@ -19,6 +19,8 @@ __pycache__/
.notebooklm-playwright/
.pip-cache/
.uv-cache/
.tui-test/
tui-traces/
compose.hermes.local.yml
export*
__pycache__/model_tools.cpython-310.pyc
+2 -90
View File
@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
account info to show (fail-open: caller just shows nothing).
"""
try:
from hermes_cli.nous_account import nous_portal_topup_url
from hermes_cli.nous_account import nous_portal_billing_url
if account_info is None or not getattr(account_info, "logged_in", False):
return None
@@ -213,8 +213,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
if not windows and not details:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -338,93 +337,6 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
return None
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
and callers degrade gracefully.
"""
logged_in: bool
balance_lines: tuple[str, ...] = ()
identity_line: Optional[str] = None
topup_url: Optional[str] = None
depleted: bool = False
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return not_logged_in
except Exception:
return not_logged_in
try:
import concurrent.futures
from hermes_cli.nous_account import (
get_nous_portal_account_info,
nous_portal_topup_url,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
return not_logged_in
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
balance_lines = [
line
for line in rendered
if not line.lstrip().startswith("Top up:")
and not line.lstrip().startswith("(or run")
]
# Identity line — shown before any open (roadmap §4.4).
email = getattr(account, "email", None)
org_name = getattr(account, "org_name", None)
who: list[str] = []
if email:
who.append(str(email))
if org_name:
who.append(f"org {org_name}")
identity_line = ("Topping up as " + " / ".join(who)) if who else None
return CreditsView(
logged_in=True,
balance_lines=tuple(balance_lines),
identity_line=identity_line,
topup_url=nous_portal_topup_url(account),
depleted=getattr(account, "paid_service_access", None) is False,
)
def _resolve_codex_usage_url(base_url: str) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
+7 -14
View File
@@ -127,21 +127,14 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
return converted
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
"""Flatten message content to a plain-text summary.
def _summarize_user_message_for_log(content: Any) -> str:
"""Return a short text summary of a user message for logging/trajectory.
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
parts from the API server. Several consumers want a plain string:
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
- External memory providers, which feed the text to regexes
(``sanitize_context``) and text APIs — a raw list crashes the sync with
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
so the turn isn't recorded as if the attachment never existed. Returns an
empty string for empty lists and ``str(content)`` for unexpected scalar
types.
parts from the API server. Logging, spinner previews, and trajectory
files all want a plain string — this helper extracts the first chunk of
text and notes any attached images. Returns an empty string for empty
lists and ``str(content)`` for unexpected scalar types.
"""
if content is None:
return ""
@@ -164,7 +157,7 @@ def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
text_bits.append(text)
elif ptype in {"image_url", "input_image"}:
image_count += 1
summary = sep.join(text_bits).strip()
summary = " ".join(text_bits).strip()
if image_count:
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
summary = f"{note} {summary}" if summary else note
+2 -9
View File
@@ -190,10 +190,6 @@ CODING_AGENT_GUIDANCE = (
"Verify, and know when to stop:\n"
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
"tests/linter/build and confirm they pass before claiming the work is done.\n"
"- Terminal state persists across calls: current directory and exported "
"environment variables carry forward. Activate a virtualenv or export setup "
"vars once, then reuse that state instead of re-sourcing it before every "
"test command.\n"
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
"paths for the same flaw and fix the class, not just the reported site.\n"
"- When fixing linter/type errors on a file, stop after about three "
@@ -715,13 +711,10 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
lines.append("- Branch: (detached HEAD)")
# Linked worktree: the per-worktree git dir differs from the shared common dir.
# We surface the fact that it's a worktree (so the model knows branches/stashes
# are shared state) but deliberately do NOT expose the primary tree path —
# giving the model a second absolute path causes it to sometimes run commands
# in the wrong directory.
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
lines.append("- Worktree: linked (git state shared with primary tree)")
main_tree = Path(common_dir).resolve().parent
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
dirty = [f"{n} {label}" for label, n in (
("staged", counts["staged"]), ("modified", counts["modified"]),
+2 -45
View File
@@ -143,18 +143,10 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
# become another unbounded transcript copy after the LLM summarizer failed.
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
_FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
# MEDIA delivery directives must not reach the summarizer — if one leaks into
# the summary, the downstream model may re-emit it as an active directive on
# the next turn, triggering bogus attachment sends (#14665).
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
value = value.strip()
@@ -1015,7 +1007,6 @@ class ContextCompressor(ContextEngine):
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@@ -1463,7 +1454,7 @@ Use this exact structure:
prompt += f"""
FOCUS TOPIC: "{focus_topic}"
This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
try:
call_kwargs = {
@@ -1632,39 +1623,6 @@ This compaction should PRIORITISE preserving all information related to the focu
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@classmethod
def _derive_auto_focus_topic(
cls,
messages: List[Dict[str, Any]],
) -> Optional[str]:
"""Infer a compact focus hint from the most recent real user turns."""
candidates: list[str] = []
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if msg.get("role") != "user":
continue
content = msg.get("content")
if cls._is_context_summary_content(content):
continue
text = redact_sensitive_text(_content_text_for_contains(content).strip())
if not text:
continue
text = " ".join(text.split())
if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + ""
candidates.append(text)
if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
break
if not candidates:
return None
candidates.reverse()
focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
if len(focus) > _AUTO_FOCUS_MAX_CHARS:
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + ""
return focus
@classmethod
def _find_latest_context_summary(
cls,
@@ -2112,8 +2070,7 @@ This compaction should PRIORITISE preserving all information related to the focu
)
# Phase 3: Generate structured summary
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
# If summary generation failed, behavior splits on
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
+1 -11
View File
@@ -286,16 +286,6 @@ def evaluate_credits_notices(
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
if uf >= band[0]:
current_band = band
# Top-up suppression: when the account holds purchased (top-up) credits,
# the subscription-cap gauge is the wrong denominator — warning "90% used"
# at a user sitting on $50 of top-up is noise (and it previously stuck
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
# entirely; the cap-reached case is covered by the grant_spent info notice
# below, which already names the remaining top-up balance. A top-up landing
# mid-session flips current_band → None and the clear path below removes
# any showing band line.
if state.purchased_micros > 0:
current_band = None
grant_cond = (
state.denominator_kind == "subscription_cap"
and uf is not None
@@ -355,7 +345,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /credits to top up",
text="✕ Credit access paused · run /usage for balance",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",
+2 -19
View File
@@ -489,23 +489,6 @@ PLATFORM_HINTS = {
"files arrive as downloadable documents. You can also include image "
"URLs in markdown format ![alt](url) and they will be sent as photos."
),
"whatsapp_cloud": (
"You are on a text messaging communication platform, WhatsApp "
"(via Meta's official Business Cloud API). Standard markdown "
"(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
"to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
"to write in markdown. Tables are NOT supported — prefer bullet "
"lists or labeled key:value pairs. "
"You can send media files natively: include MEDIA:/absolute/path/to/file "
"in your response. Images (.jpg, .png) become photo attachments, "
"videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
"messages, other files arrive as documents. Image URLs in markdown "
"format ![alt](url) also work. "
"IMPORTANT: this platform has a 24-hour conversation window — if the "
"user hasn't messaged in 24h, free-form replies are refused by Meta "
"(error 131047). This rarely matters for live chat, but is worth "
"knowing if you're scheduling a delayed message."
),
"telegram": (
"You are on a text messaging communication platform, Telegram. "
"Standard markdown is automatically converted to Telegram format. "
@@ -1435,13 +1418,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
lines = [
"# Nous Subscription",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
lines.extend(
[
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
-101
View File
@@ -1,101 +0,0 @@
const path = require('node:path')
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin'
])
function delimiterForPlatform(platform = process.platform) {
return platform === 'win32' ? ';' : ':'
}
function pathModuleForPlatform(platform = process.platform) {
return platform === 'win32' ? path.win32 : path.posix
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') return 'PATH'
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const seen = new Set()
const ordered = []
for (const entry of entries) {
if (!entry) continue
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) continue
seen.add(part)
ordered.push(part)
}
}
return ordered.join(delimiter)
}
function buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
return appendUniquePathEntries(
[hermesNodeBin, venvBin, currentPath, saneEntries],
{ delimiter }
)
}
function buildDesktopBackendEnv({
hermesHome,
pythonPathEntries = [],
venvRoot,
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
return {
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
[key]: buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath: currentPathValue(currentEnv, platform),
platform,
pathModule
})
}
}
module.exports = {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
pathEnvKey
}
@@ -1,95 +0,0 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
pathEnvKey
} = require('./backend-env.cjs')
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
for (const expected of POSIX_SANE_PATH_ENTRIES) {
assert.ok(entries.includes(expected), `${expected} should be present`)
}
})
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
assert.ok(
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
'existing Homebrew bin keeps its precedence over appended missing sane entries'
)
})
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
const env = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
pythonPathEntries: ['/repo/hermes-agent'],
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentEnv: {
PATH: '/usr/bin:/bin',
PYTHONPATH: '/existing/pythonpath'
},
platform: 'darwin',
pathModule: path.posix
})
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
const env = buildDesktopBackendEnv({
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
pythonPathEntries: ['C:\\repo\\hermes-agent'],
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
currentEnv: {
Path: 'C:\\Windows\\System32;C:\\Windows',
PYTHONPATH: 'C:\\existing\\pythonpath'
},
platform: 'win32',
pathModule: path.win32
})
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
assert.equal(env.PATH, undefined)
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
assert.ok(env.Path.includes('\\venv\\Scripts;'))
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
'/a:/b:/c'
)
})
-99
View File
@@ -1,99 +0,0 @@
/**
* Helpers for local dashboard session-token discovery.
*
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
* spawns the local dashboard, but the dashboard is the source of truth for the
* token it actually serves to the renderer. If those drift, HTTP readiness
* probes still pass while /api/ws rejects the renderer's token.
*/
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
return null
}
}
function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
}
return servedToken || fallbackToken
}
/**
* A served token that differs from our spawn token while our child is DEAD
* came from a process we did not spawn (orphan/port squatter that satisfied
* the public /api/status readiness probe). With a live child the mismatch is
* benign: our own backend regenerated the token because the env pin did not
* survive the spawn.
*/
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
}
/**
* Resolve the token the backend actually serves, adopting benign drift and
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
* sampled after the fetch, not before.
*/
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
throw new Error(
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
)
}
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
}
@@ -1,142 +0,0 @@
/**
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
assert.equal(extractInjectedDashboardToken(html), 'served-token')
})
test('extractInjectedDashboardToken handles escaped token strings', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
})
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
})
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
})
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'served-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /served a different session token/)
})
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => '<html></html>',
rememberLog: () => {
throw new Error('should not log when no served token is present')
}
})
assert.equal(token, 'spawn-token')
})
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
rememberLog: () => {
throw new Error('should not log when token already matches')
}
})
assert.equal(token, 'same-token')
})
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
await assert.rejects(
() =>
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => {
throw new Error('boom')
}
}),
/boom/
)
})
test('fetchPublicText rejects unsupported protocols', async () => {
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
})
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
const cases = [
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
// Live child + drift = our backend regenerated the token (env pin lost).
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
}
})
test('adoptServedDashboardToken adopts drift from a live child', async () => {
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
})
assert.equal(token, 'served-token')
})
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
await assert.rejects(
() =>
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => false,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
label: 'Hermes backend for profile "work"'
}),
/profile "work".*process we did not spawn/
)
})
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {
throw new Error('boom')
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'spawn-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
})
+17 -64
View File
@@ -29,11 +29,8 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { PortPool } = require('./port-pool.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const {
@@ -96,7 +93,6 @@ try {
nodePty = require(nodePtyDir)
}
} catch {
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
nodePty = null
nodePtyDir = null
}
@@ -111,10 +107,6 @@ if (USER_DATA_OVERRIDE) {
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
// In-process port reservations that close the pickPort() TOCTOU window where
// two concurrent backend spawns could be handed the same port. See
// port-pool.cjs for the full rationale.
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -2135,11 +2127,9 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
label,
command: python,
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [root],
venvRoot: path.join(root, 'venv')
}),
env: {
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
root,
bootstrap: Boolean(options.bootstrap),
shell: false
@@ -2158,11 +2148,9 @@ function createActiveBackend(dashboardArgs) {
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
command: fileExists(venvPython) ? venvPython : findSystemPython(),
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
env: buildDesktopBackendEnv({
hermesHome: HERMES_HOME,
pythonPathEntries: [ACTIVE_HERMES_ROOT],
venvRoot: VENV_ROOT
}),
env: {
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
},
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
@@ -2464,11 +2452,10 @@ function isPortAvailable(port) {
}
async function pickPort() {
const port = await portPool.reserve(isPortAvailable)
if (port === null) {
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
if (await isPortAvailable(port)) return port
}
return port
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
function fetchJson(url, token, options = {}) {
@@ -4552,20 +4539,9 @@ async function spawnPoolBackend(profile, entry) {
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
let backend
let hermesCwd
let webDist
try {
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
hermesCwd = resolveHermesCwd()
webDist = resolveWebDist()
} catch (error) {
// These run before the child exists / its exit handler is attached, so a
// throw here would otherwise leak the reservation and slowly exhaust the
// 9120-9199 range across switch cycles in one app session.
portPool.release(port)
throw error
}
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -4603,13 +4579,11 @@ async function spawnPoolBackend(profile, entry) {
child.once('error', error => {
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
backendPool.delete(profile)
portPool.release(port)
rejectStart?.(error)
})
child.once('exit', (code, signal) => {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
portPool.release(port)
if (!ready) {
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
@@ -4620,21 +4594,15 @@ async function spawnPoolBackend(profile, entry) {
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
childAlive: () => child.exitCode === null && !child.killed,
label: `Hermes backend for profile "${profile}"`,
rememberLog
})
entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token: authToken,
token,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4644,7 +4612,6 @@ function stopPoolBackend(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.port) portPool.release(entry.port)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
@@ -4730,11 +4697,6 @@ async function startHermes() {
}
if (connectionPromise) return connectionPromise
// Hoisted so the outer .catch can release a port reserved by pickPort() when
// a throw (e.g. ensureRuntime failing) happens before the child's exit
// handler is attached. Stays null on the remote path (no port picked).
let reservedPort = null
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
@@ -4764,7 +4726,6 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
reservedPort = port
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
// Pin the desktop's chosen profile via the global --profile flag. This is
@@ -4829,7 +4790,6 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
@@ -4837,7 +4797,6 @@ async function startHermes() {
rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
@@ -4862,11 +4821,6 @@ async function startHermes() {
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
rememberLog
})
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -4880,8 +4834,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4897,7 +4851,6 @@ async function startHermes() {
{ allowDecrease: true }
)
connectionPromise = null
portPool.release(reservedPort)
throw error
})
@@ -5172,8 +5125,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
connectionPromise = null
bootstrapState = {
active: false,
manifest: null,
-73
View File
@@ -1,73 +0,0 @@
'use strict'
/**
* In-process port reservation pool for the desktop backend launcher.
*
* pickPort() probes a localhost port with a throwaway server and closes it
* before the real bind happens in a separate Python child. Between that probe
* and the child's bind there is a TOCTOU window: a second concurrent spawn
* (the primary backend racing a pool backend) can be handed the SAME port, and
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
* destroyed" boot loop). Reserving the chosen port in THIS process until the
* child exits closes that window.
*
* The OS bind remains the source of truth; this only deconflicts racers inside
* this process it can't stop a foreign squatter, which the probe + the
* EADDRINUSE self-heal still cover.
*
* The pool is dependency-injected (the availability probe is passed in) and
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
* (see port-pool.test.cjs).
*/
class PortPool {
/**
* @param {number} floor inclusive lowest port to hand out
* @param {number} ceiling inclusive highest port to hand out
*/
constructor(floor, ceiling) {
this.floor = floor
this.ceiling = ceiling
this._reserved = new Set()
}
/** @returns {boolean} whether `port` is currently reserved in-process. */
has(port) {
return this._reserved.has(port)
}
/** Release a previously reserved port. No-op if it was not reserved. */
release(port) {
this._reserved.delete(port)
}
/** Drop all reservations. */
clear() {
this._reserved.clear()
}
/** @returns {number} count of currently reserved ports. */
get size() {
return this._reserved.size
}
/**
* Reserve and return the lowest port in [floor, ceiling] that is neither
* already reserved in-process nor rejected by `isAvailable(port)`, or null
* if every port is taken. `isAvailable` may be sync (boolean) or async
* (Promise<boolean>); it is awaited either way.
*
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
* @returns {Promise<number|null>}
*/
async reserve(isAvailable) {
for (let port = this.floor; port <= this.ceiling; port += 1) {
if (this._reserved.has(port)) continue
if (!(await isAvailable(port))) continue
this._reserved.add(port)
return port
}
return null
}
}
module.exports = { PortPool }
-77
View File
@@ -1,77 +0,0 @@
/**
* Tests for electron/port-pool.cjs.
*
* Run with: node --test electron/port-pool.test.cjs
*
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
* window. These cover selection order, skipping reserved/unavailable ports,
* release/reuse, exhaustion, and async probes without real sockets.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { PortPool } = require('./port-pool.cjs')
const allFree = () => true
test('reserve returns the lowest free port and reserves it', async () => {
const pool = new PortPool(9120, 9199)
const port = await pool.reserve(allFree)
assert.equal(port, 9120)
assert.ok(pool.has(9120))
assert.equal(pool.size, 1)
})
test('reserve skips ports already reserved in-process', async () => {
const pool = new PortPool(9120, 9199)
const first = await pool.reserve(allFree)
const second = await pool.reserve(allFree)
assert.equal(first, 9120)
assert.equal(second, 9121)
})
test('reserve skips ports the probe rejects', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120, 9121])
const port = await pool.reserve(p => !busy.has(p))
assert.equal(port, 9122)
})
test('reserve returns null when every port is taken', async () => {
const pool = new PortPool(9120, 9121)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(await pool.reserve(allFree), null)
})
test('release frees a reserved port for reuse', async () => {
const pool = new PortPool(9120, 9120)
assert.equal(await pool.reserve(allFree), 9120)
assert.equal(await pool.reserve(allFree), null) // exhausted
pool.release(9120)
assert.ok(!pool.has(9120))
assert.equal(await pool.reserve(allFree), 9120) // reusable
})
test('release is a no-op for an unreserved port', () => {
const pool = new PortPool(9120, 9199)
pool.release(9120)
assert.equal(pool.size, 0)
})
test('reserve awaits an async probe', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120])
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
assert.equal(port, 9121)
})
test('clear drops all reservations', async () => {
const pool = new PortPool(9120, 9199)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(pool.size, 2)
pool.clear()
assert.equal(pool.size, 0)
})
@@ -8,7 +8,7 @@ const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {
+2 -3
View File
@@ -18,8 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -36,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+3 -3
View File
@@ -18,7 +18,7 @@ import {
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { getSessionMessages, listSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setRefreshing(true)
try {
const sessions = (await listAllProfileSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {
@@ -287,7 +287,7 @@ const MARKDOWN_COMPONENTS = {
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
@@ -383,10 +383,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
</div>
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
{selection && (
<div
aria-hidden
@@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { profile, title })
void exportSession(sessionId, { title })
}
},
{
@@ -8,7 +8,7 @@ import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/ap
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@@ -119,7 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
@@ -218,13 +218,13 @@ export function CommandPalette() {
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryFn: () => listSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listAllProfileSessions(200, 0, 'only'),
queryFn: () => listSessions(200, 0, 'only'),
enabled: open
})
+1 -3
View File
@@ -547,9 +547,7 @@ export function DesktopController() {
return
}
const storedProfile = $sessions
.get()
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
@@ -315,11 +315,8 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
allowTransparency: true,
convertEol: true,
cursorBlink: true,
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontSize: 11,
fontWeight: '400',
fontWeightBold: '700',
letterSpacing: 0,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
@@ -601,13 +598,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
startSession()
}
// fonts.ready settles only already-requested faces; bold/italic aren't asked
// for until styled output paints (past atlas init), so warm them up front.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
void warm.then(mount, mount)
if (fonts?.ready) {
void fonts.ready.then(mount, mount)
} else {
mount()
}
return () => {
disposed = true
@@ -933,8 +933,6 @@ export function useMessageStream({
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -209,46 +209,6 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
const lineage = session._lineage_root_id ?? session.id
setSessions(prev => [
session,
...prev.filter(existing => {
if (sessionMatchesStoredId(existing, storedSessionId)) {
return false
}
return (existing._lineage_root_id ?? existing.id) !== lineage
})
])
}
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (cached) {
return cached
}
try {
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
if (resolved) {
upsertResolvedSession(resolved, storedSessionId)
}
return resolved
} catch {
return undefined
}
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
@@ -520,13 +480,8 @@ export function useSessionActions({
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
const storedForProfile = await resolveStoredSession(storedSessionId)
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
const sessionProfile = storedForProfile?.profile
if (resumeRequestRef.current !== requestId) {
return
}
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@@ -594,7 +549,7 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const stored = $sessions.get().find(session => session.id === storedSessionId)
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
@@ -844,7 +799,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const removed = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
@@ -853,7 +808,7 @@ export function useSessionActions({
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -888,7 +843,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
@@ -927,7 +882,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const archived = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
@@ -935,7 +890,7 @@ export function useSessionActions({
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -952,12 +907,12 @@ export function useSessionActions({
// in flight and briefly reinsert the still-unarchived backend row. Win
// that race after the mutation succeeds so right-click → Archive does
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
setSessionsTotal(prev => prev + 1)
}
@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import { startManualProviderOAuth } from '@/store/onboarding'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
@@ -224,23 +224,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}, [apiKeyDraft, selectedProviderRow])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in. The
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
// local-endpoint form (URL + optional API key) instead of being dead-ended
// on the OAuth picker (the original "booted back to the first screen" loop).
// to the shared onboarding flow scoped to this provider's real sign-in.
const startProviderSetup = useCallback(() => {
const slug = selectedProviderRow?.slug
if (!slug) {
return
}
const lower = slug.toLowerCase()
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
startManualLocalEndpoint()
} else {
startManualProviderOAuth(slug)
if (selectedProviderRow?.slug) {
startManualProviderOAuth(selectedProviderRow.slug)
}
}, [selectedProviderRow])
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -43,14 +43,14 @@ export function SessionsSettings() {
setLoading(true)
try {
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
}, [s.failedLoad])
}, [])
useEffect(() => {
void load()
@@ -1,80 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MessageRenderBoundary } from './message-render-boundary'
afterEach(cleanup)
function Boom({ error }: { error: Error | null }): null {
if (error) {
throw error
}
return null
}
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
describe('MessageRenderBoundary', () => {
it('renders children when nothing throws', () => {
render(
<MessageRenderBoundary resetKey="a">
<div>content</div>
</MessageRenderBoundary>
)
expect(screen.getByText('content')).toBeTruthy()
})
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { container } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
expect(container.innerHTML).toBe('')
spy.mockRestore()
})
it('recovers on the next consistent snapshot when resetKey changes', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { rerender } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<Boom error={null} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<div>recovered</div>
</MessageRenderBoundary>
)
expect(screen.getByText('recovered')).toBeTruthy()
spy.mockRestore()
})
it('re-throws unrelated errors so real bugs still surface', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(
<MessageRenderBoundary resetKey="a">
<Boom error={new Error('genuine render bug')} />
</MessageRenderBoundary>
)
).toThrow('genuine render bug')
spy.mockRestore()
})
})
@@ -1,48 +0,0 @@
import { Component, type ReactNode } from 'react'
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
// throws — rather than returning undefined — when a subscriber reads an index
// that the message/parts list no longer has. This races during high-frequency
// store replacement (session switch mid-stream, gateway reconnect replay): a
// subscriber from the previous, longer list is still in React's notification
// queue and reads one slot past the new, shorter array before it can unmount.
// The throw is transient and self-heals on the next consistent snapshot, but
// without a local boundary it unwinds to the root and blanks the whole app.
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught
// error so the next consistent render recovers silently.
resetKey: string
children: ReactNode
}
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) {
this.setState({ error: null })
}
}
render() {
if (this.state.error) {
// Only swallow the transient store race; re-throw anything else so real
// bugs still reach the root error boundary.
if (!isTransientLookupError(this.state.error)) {
throw this.state.error
}
return null
}
return this.props.children
}
}
@@ -16,8 +16,6 @@ import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
@@ -182,20 +180,18 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
key={virtualItem.key}
ref={virtualizer.measureElement}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</div>
)
})}
@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
@@ -9,30 +9,13 @@ import { $activeSessionId } from '@/store/session'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
// doesn't implement; stub them so the menu can open in tests.
beforeAll(() => {
const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>
const stubs: Record<string, () => unknown> = {
hasPointerCapture: () => false,
releasePointerCapture: () => undefined,
scrollIntoView: () => undefined,
setPointerCapture: () => undefined
}
for (const [name, fn] of Object.entries(stubs)) {
proto[name] ??= fn
}
})
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
function setRequest(command = 'rm -rf /tmp/x') {
$activeSessionId.set('sess-1')
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
@@ -95,26 +78,4 @@ describe('PendingToolApproval', () => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
it('offers "Always allow" in the options menu by default', async () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
})
it('hides "Always allow" when the backend disallows a permanent allow', async () => {
// tirith content-security warning present → allowPermanent=false.
setRequest('curl https://bit.ly/abc | bash', false)
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
// The session + reject options still render, but never the permanent allow.
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
})
})
@@ -61,8 +61,6 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -146,18 +144,16 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>
@@ -279,14 +279,11 @@ function ToolEntry({ part }: ToolEntryProps) {
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
// The header trailing slot only carries the live duration timer while the
// tool is running. The copy control used to live here too, but an
// `opacity-0` (yet still clickable) button straddling the caret/duration made
// the disclosure caret hard to hit. Copy now lives in the expanded body's
// top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? (
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
) : !isPending && copyAction.text ? (
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
) : undefined
return (
@@ -325,18 +322,7 @@ function ToolEntry({ part }: ToolEntryProps) {
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="relative grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
stopPropagation
text={copyAction.text}
/>
)}
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
@@ -127,9 +127,7 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
// UAX#9 paragraph so it resolves direction independently.
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
<span className="wrap-anywhere block whitespace-pre-line">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
@@ -26,8 +26,7 @@ function setProviders(providers: OAuthProvider[]) {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
manual: false
} satisfies DesktopOnboardingState)
}
@@ -50,8 +49,7 @@ afterEach(() => {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
manual: false
})
})
@@ -430,24 +430,19 @@ const persistShowAll = (value: boolean) => {
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { t } = useI18n()
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
const { manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()
// localEndpoint forces the key form regardless of `mode` (which a manual
// provider refresh may flip back to 'oauth'); it preselects the local option
// and hides the "back to sign in" link since the user came specifically to
// configure a custom endpoint.
if (localEndpoint || mode === 'apikey' || !hasOauth) {
if (mode === 'apikey' || !hasOauth) {
return (
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth && !localEndpoint}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
options={apiKeyOptions}
/>
{manual ? null : (
@@ -635,7 +630,6 @@ export function ProviderRow({
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
initialEnvKey,
isSet,
onBack,
onClear,
@@ -644,31 +638,16 @@ export function ApiKeyForm({
redactedValue
}: {
canGoBack: boolean
/** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
* the local / custom endpoint form). Falls back to the first option. */
initialEnvKey?: string
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (
envKey: string,
value: string,
name: string,
apiKey?: string
) => Promise<{ message?: string; ok: boolean }>
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
const [option, setOption] = useState<ApiKeyOption>(
() => options.find(o => o.envKey === initialEnvKey) ?? options[0]
)
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [value, setValue] = useState('')
// Optional endpoint API key, only used by the local / custom endpoint option
// (whose `value` is the base URL). Cleared whenever the option changes.
const [localKey, setLocalKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
@@ -678,7 +657,6 @@ export function ApiKeyForm({
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
setOption(options[0])
setValue('')
setLocalKey('')
setError(null)
}
}, [option.envKey, options])
@@ -690,7 +668,6 @@ export function ApiKeyForm({
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setLocalKey('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -716,11 +693,10 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
const result = await onSave(option.envKey, value, option.name)
if (result.ok) {
setValue('')
setLocalKey('')
} else {
setError(result.message ?? t.onboarding.couldNotSave)
}
@@ -783,17 +759,6 @@ export function ApiKeyForm({
type={isLocal ? 'text' : 'password'}
value={value}
/>
{isLocal ? (
<Input
autoComplete="off"
className="font-mono"
onChange={e => setLocalKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={t.onboarding.localApiKeyPlaceholder}
type="password"
value={localKey}
/>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
@@ -41,8 +41,7 @@ function resetStores() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
manual: false
})
}
@@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useEffect, useMemo, useState } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { listAllProfileSessions } from '@/hermes'
import { listSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Check, MessageCircle } from '@/lib/icons'
@@ -35,7 +35,7 @@ export function SessionPickerDialog({
const sessionsQuery = useQuery({
enabled: open,
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryFn: () => listSessions(200, 1, 'exclude'),
queryKey: ['session-picker', 'sessions']
})
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -12
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
import { listAllProfileSessions, listSessions } from './hermes'
const emptySessionsResponse = {
limit: 0,
@@ -46,15 +46,4 @@ describe('Hermes REST session helpers', () => {
})
)
})
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
await getSessionMessages('session-1', 'xiaoxuxu')
expect(api).toHaveBeenCalledWith({
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
profile: 'xiaoxuxu'
})
})
})
+3 -5
View File
@@ -54,10 +54,10 @@ export type {
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
BackendUpdateCheckResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
@@ -218,7 +218,6 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionMessagesResponse>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
@@ -344,14 +343,13 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
export function validateProviderCredential(
key: string,
value: string,
apiKey?: string
value: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value, api_key: apiKey ?? '' }
body: { key, value }
})
}
-1
View File
@@ -1372,7 +1372,6 @@ export const en: Translations = {
getKey: 'Get a key',
replaceCurrent: 'Replace current value',
pasteApiKey: 'Paste API key',
localApiKeyPlaceholder: 'API key (optional — only if your endpoint requires one)',
couldNotSave: 'Could not save credential.',
connecting: 'Connecting',
update: 'Update',
-1
View File
@@ -1041,7 +1041,6 @@ export interface Translations {
getKey: string
replaceCurrent: string
pasteApiKey: string
localApiKeyPlaceholder: string
couldNotSave: string
connecting: string
update: string
-1
View File
@@ -1554,7 +1554,6 @@ export const zh: Translations = {
getKey: '获取密钥',
replaceCurrent: '替换当前值',
pasteApiKey: '粘贴 API 密钥',
localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
couldNotSave: '无法保存凭据。',
connecting: '连接中',
update: '更新',
-2
View File
@@ -58,8 +58,6 @@ export type GatewayEventPayload = {
// approval.request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
// False when a tirith content-security warning forbids a permanent allow.
allow_permanent?: boolean
// secret.request (skill credential capture)
env_var?: string
prompt?: string
+1 -3
View File
@@ -5,7 +5,6 @@ import { notify, notifyError } from '@/store/notifications'
interface ExportSessionParams {
sessionId: string
profile?: string | null
title?: string | null
session?: SessionInfo
}
@@ -32,8 +31,7 @@ export async function exportSession(sessionId: string, params: Omit<ExportSessio
}
try {
const profile = params.profile ?? params.session?.profile
const { messages } = await getSessionMessages(sessionId, profile)
const { messages } = await getSessionMessages(sessionId)
const payload = {
exported_at: new Date().toISOString(),
+3 -46
View File
@@ -33,7 +33,6 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false,
...overrides
}
}
@@ -234,12 +233,10 @@ describe('OAuth onboarding', () => {
const state = $desktopOnboarding.get()
expect(state.reason).toBeNull()
expect(state.flow.status).toBe('confirming_model')
if (state.flow.status === 'confirming_model') {
expect(state.flow.label).toBe('Nous Portal')
expect(state.flow.currentModel).toBe(model)
}
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
})
})
@@ -286,7 +283,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected api path: ${path}`)
})
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
requestGateway: readyGateway()
})
@@ -316,7 +313,7 @@ describe('saveOnboardingLocalEndpoint', () => {
installApiMock(api)
const onCompleted = vi.fn()
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
onCompleted,
requestGateway: readyGateway()
})
@@ -335,46 +332,6 @@ describe('saveOnboardingLocalEndpoint', () => {
expect($desktopOnboarding.get().configured).toBe(true)
})
it('forwards the API key to the probe and persists it for auth-gated endpoints', async () => {
const calls: { body?: unknown; path: string }[] = []
const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => {
calls.push({ body, path })
if (path === '/api/providers/validate') {
return { ok: true, reachable: true, message: '', models: ['gpt-oss-120b'] }
}
if (path === '/api/model/set') {
return { ok: true, provider: 'custom', model: 'gpt-oss-120b', base_url: 'https://text.example.com/v1' }
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
const result = await saveOnboardingLocalEndpoint('https://text.example.com/v1', 'sk-secret', {
requestGateway: readyGateway()
})
expect(result.ok).toBe(true)
// The probe must receive the key so an auth-gated /v1/models enumerates.
const probe = calls.find(c => c.path === '/api/providers/validate')
expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
// And the key must be persisted alongside the endpoint for runtime auth.
const assign = calls.find(c => c.path === '/api/model/set')
expect(assign?.body).toMatchObject({
scope: 'main',
provider: 'custom',
model: 'gpt-oss-120b',
base_url: 'https://text.example.com/v1',
api_key: 'sk-secret'
})
})
it('reports the runtime reason when resolution still fails after saving', async () => {
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/validate') {
@@ -404,7 +361,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected gateway method: ${method}`)
}
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
requestGateway: failingGateway
})
+13 -52
View File
@@ -72,11 +72,6 @@ export interface DesktopOnboardingState {
* picker's "Add provider" button). Forces the overlay to show the picker
* even when configured === true, and adds a close affordance. */
manual: boolean
/** True when the overlay was opened specifically to configure a local /
* custom OpenAI-compatible endpoint (e.g. from Settings Model's "Set up
* custom endpoint"). Forces the API-key form with the local option
* preselected instead of the OAuth picker. */
localEndpoint: boolean
}
export interface OnboardingContext {
@@ -155,8 +150,7 @@ const INITIAL: DesktopOnboardingState = {
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false,
localEndpoint: false
manual: false
}
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
@@ -398,7 +392,6 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
patch({
manual: true,
requested: true,
localEndpoint: false,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
@@ -407,24 +400,6 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
void refreshProviders()
}
// Open the onboarding overlay directly on the local / custom endpoint form
// (URL + optional API key), bypassing the OAuth picker. Used by Settings →
// Model's "Set up custom endpoint" so it lands on a form that can actually
// configure the endpoint instead of dead-ending on the OAuth provider list
// (`custom` is not an OAuth provider, so the generic manual flow would just
// re-show the picker — the original "booted back to the first screen" loop).
export function startManualLocalEndpoint(reason: null | string = null) {
pendingProviderOAuthId = null
patch({
manual: true,
requested: true,
localEndpoint: true,
mode: 'apikey',
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
@@ -456,7 +431,7 @@ export function clearPendingProviderOAuth() {
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
patch({ manual: false, requested: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@@ -473,8 +448,7 @@ export function completeDesktopOnboarding() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false
manual: false
})
}
@@ -487,7 +461,7 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
@@ -727,28 +701,18 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
)
}
export async function saveOnboardingApiKey(
envKey: string,
value: string,
label: string,
ctx: OnboardingContext,
// Optional endpoint key — only meaningful for the "Local / custom endpoint"
// option, whose primary `value` is the base URL. Ignored for plain API-key
// providers (their key IS `value`).
endpointApiKey?: string
) {
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
const trimmed = value.trim()
if (!trimmed) {
return { ok: false, message: 'Enter a value first.' }
}
// The "Local / custom endpoint" option carries a base URL (in `value`) plus
// an optional API key. It must be wired into config (provider=custom +
// base_url + model + api_key), not dropped into .env — runtime resolution
// ignores OPENAI_BASE_URL.
// The "Local / custom endpoint" option carries a base URL, not an API key.
// It must be wired into config (provider=custom + base_url + model), not
// dropped into .env — runtime resolution ignores OPENAI_BASE_URL.
if (envKey === 'OPENAI_BASE_URL') {
return saveOnboardingLocalEndpoint(trimmed, endpointApiKey?.trim() ?? '', ctx)
return saveOnboardingLocalEndpoint(trimmed, ctx)
}
// No key validation here on purpose: we previously live-probed the key and
@@ -784,17 +748,14 @@ export async function saveOnboardingApiKey(
// env var that resolution never consults.
//
// The model is auto-discovered from the endpoint's /v1/models (surfaced by the
// validate probe). The optional API key is forwarded to the probe (so hosted
// endpoints that gate /v1/models behind auth still enumerate models) and
// persisted to model.api_key so the runtime can authenticate.
// validate probe) so the user only has to paste a URL — no extra UI field.
//
// We deliberately don't route through completeWithModelConfirm: that path
// re-assigns the model from /api/model/options WITHOUT a base_url, which would
// wipe the base_url we just wrote. We have a concrete model already, so we
// verify the runtime directly and finish.
export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: string, ctx: OnboardingContext) {
export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: OnboardingContext) {
const url = baseUrl.trim()
const key = apiKey.trim()
if (!url) {
return { ok: false, message: 'Enter the endpoint URL first.' }
@@ -806,7 +767,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: strin
let model = ''
try {
const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key)
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
@@ -829,7 +790,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: strin
}
try {
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)
-6
View File
@@ -53,12 +53,6 @@ describe('approval prompt store', () => {
expect($approvalRequest.get()).toBeNull()
})
it('carries allowPermanent so the bar can hide "Always allow"', () => {
setApprovalRequest({ allowPermanent: false, command: 'curl x | bash', description: 'content-security', sessionId: 's1' })
expect($approvalRequest.get()?.allowPermanent).toBe(false)
})
})
describe('sudo prompt store', () => {
-2
View File
@@ -68,8 +68,6 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
// resolved via approval.respond {choice, session_id}). It carries no request_id,
// unlike sudo/secret which are _block()-style request/response.
export interface ApprovalRequest extends KeyedPrompt {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
command: string
description: string
}
-55
View File
@@ -17,30 +17,6 @@
src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2');
}
/* JetBrains Mono bundled terminal font (Apache-2.0) so bold/italic share the
regular face's metrics instead of squeezing against a system fallback. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./fonts/JetBrainsMono-Italic.woff2') format('woff2');
}
@theme inline {
--color-background: var(--dt-background);
--color-foreground: var(--dt-foreground);
@@ -847,37 +823,6 @@ canvas {
content's --message-text-indent). No extra prose indent a single gutter
reads cleaner than a ragged tool-vs-reply column. */
/* RTL/bidi chat text (#44150): each block resolves its own base direction from
its first strong char (UAX#9 plaintext). text-align:start makes that resolved
direction drive alignment too load-bearing, since the user bubble pins
text-left. direction is never set, so chrome/layout/list-indent stay LTR (the
issue asks not to flip the whole UI). Covers assistant prose, user lines, and
both composers (main + edit share composer-rich-input). */
[data-slot='aui_assistant-message-content'] .aui-md :where(p, h1, h2, h3, h4, h5, h6, li, blockquote),
[data-slot='aui_user-inline-text'],
[data-slot='composer-rich-input'] {
unicode-bidi: plaintext;
text-align: start;
}
/* Inline code/KaTeX don't vote on direction and keep their own order: isolate
makes bidi treat each as one neutral, so a block that *starts* with `./run.sh`
then Arabic still resolves RTL, and the command's neutrals (dots/slashes)
aren't reordered by the surrounding RTL run. */
[data-slot='aui_assistant-message-content'] .aui-md :where(:not(pre) > code),
[data-slot='aui_user-inline-code'],
[data-slot='aui_assistant-message-content'] .aui-md .katex {
direction: ltr;
unicode-bidi: isolate;
}
/* Fenced code stays LTR even inside an RTL list item/blockquote — never mirrors. */
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'],
[data-slot='aui_user-fence'] {
direction: ltr;
text-align: left;
}
[data-slot='aui_user-message-root'] {
top: var(--sticky-human-top);
}
-4
View File
@@ -638,10 +638,6 @@ export interface AuxiliaryModelsResponse {
}
export interface ModelAssignmentRequest {
/** Optional API key for a custom/local endpoint. Persisted to model.api_key
* (where the runtime reads it) for self-hosted endpoints that require auth.
* Only honored for custom/local providers on the main slot. */
api_key?: string
/** OpenAI-compatible endpoint URL. Only honored for custom/local providers
* on the main slot wires a self-hosted endpoint into runtime resolution. */
base_url?: string
+2 -181
View File
@@ -456,9 +456,6 @@ def load_cli_config() -> Dict[str, Any]:
"busy_input_mode": "interrupt",
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the decision survives the repaint.
"persist_prompts": True,
"skin": "default",
},
@@ -3188,13 +3185,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
# show_reasoning: display model thinking/reasoning before the response
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
# Live web_extract summarization box (reasoning-style). Registered
# process-wide; no-op for runs that never summarize a web page.
try:
from tools.summary_display import set_summary_stream_callback
set_summary_stream_callback(self._on_summary_stream)
except Exception:
logger.debug("Failed to register summary stream callback", exc_info=True)
_configure_output_history(
enabled=CLI_CONFIG["display"].get("persistent_output", True),
max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200),
@@ -4670,55 +4660,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._deferred_content = ""
self._emit_stream_text(deferred)
# ── Web-extract summary streaming (reasoning-style box) ─────────────
def _on_summary_stream(self, event: str, **kwargs) -> None:
"""Render web_extract summarization tokens in a dim live box.
Mirrors the reasoning-box UX: opens a dim 'Summarizing' box when the
summarizer LLM starts streaming, streams its tokens line-by-line,
and closes the box with a char count when done. Registered with
tools.summary_display; called from the summarizer's event loop
thread, so output goes through _cprint (patch_stdout-safe).
"""
try:
if event == "start":
url = kwargs.get("url", "")
w = self._scrollback_box_width()
label = " Summarizing "
if url:
short = url if len(url) <= w - 20 else url[: w - 23] + "..."
label = f" Summarizing · {short} "
fill = w - 2 - len(label)
_cprint(f"\n{_DIM}┌─{label}{'' * max(fill - 1, 0)}{_RST}")
self._summary_box_opened = True
self._summary_buf = ""
elif event == "delta":
if not getattr(self, "_summary_box_opened", False):
return
self._summary_buf = getattr(self, "_summary_buf", "") + kwargs.get("text", "")
while "\n" in self._summary_buf:
line, self._summary_buf = self._summary_buf.split("\n", 1)
_cprint(f"{_DIM}{line}{_RST}")
if len(self._summary_buf) > 80:
_cprint(f"{_DIM}{self._summary_buf}{_RST}")
self._summary_buf = ""
elif event == "end":
if not getattr(self, "_summary_box_opened", False):
return
buf = getattr(self, "_summary_buf", "")
if buf:
_cprint(f"{_DIM}{buf}{_RST}")
self._summary_buf = ""
w = self._scrollback_box_width()
chars = kwargs.get("char_count", 0)
tail = f" {chars:,} chars " if kwargs.get("ok") else " summarization fell back "
fill = w - 2 - len(tail)
_cprint(f"{_DIM}{'' * max(fill - 1, 0)}{tail}{_RST}")
self._summary_box_opened = False
except Exception:
logger.debug("Summary stream render failed", exc_info=True)
def _stream_delta(self, text) -> None:
"""Line-buffered streaming callback for real-time token rendering.
@@ -7507,8 +7448,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
elif canonical == "credits":
self._show_credits()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
@@ -8410,86 +8349,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
print(f" {line}")
return True
def _show_credits(self):
"""`/credits` — focused Nous credit balance + top-up handoff.
Interactive CLI: balance block + identity line + a 3-button panel
(Open top-up / Copy link / Cancel). Non-interactive contexts the TUI
slash-worker subprocess and any place without a live prompt_toolkit app
(``self._app is None``) render a text variant (balance + tappable
top-up URL), because the modal would try to read the RPC stdin and crash
the worker. The terminal never confirms or polls payment (billing phase
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
message, never a crash.
"""
from agent.account_usage import build_credits_view
view = build_credits_view()
if not view.logged_in:
print()
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
print(" Run `hermes portal` to log in, then /credits.")
return
print()
print(" 💳 Nous credits")
print(f" {'' * 41}")
for line in view.balance_lines:
# Drop the helper's own "📈 Nous credits" header — we print our own.
if line.lstrip().startswith("📈"):
continue
print(f" {line}")
print(f" {'' * 41}")
if view.identity_line:
print(f" {view.identity_line}")
if not view.topup_url:
return
# Non-interactive (TUI slash-worker, piped, no live app): the
# prompt_toolkit modal can't run here — it would read the worker's
# JSON-RPC stdin and crash the command. Render the text variant: the
# tappable URL IS the affordance, same as the messaging surfaces.
if not getattr(self, "_app", None):
print()
print(f" Top up: {view.topup_url}")
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
return
choices = [
("open", "Open top-up in browser", "launch the portal billing page"),
("copy", "Copy link", "copy the top-up URL to your clipboard"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Add credits?",
detail=f"Top-up page:\n{view.topup_url}",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(view.topup_url)
except Exception:
opened = False
if not opened:
print(f" Open this URL to top up: {view.topup_url}")
print()
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
elif choice == "copy":
try:
self._write_osc52_clipboard(view.topup_url)
print(f" 📋 Copied: {view.topup_url}")
except Exception:
print(f" Top-up URL: {view.topup_url}")
else:
print(" 🟡 Cancelled. No credits added.")
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag
@@ -9502,25 +9361,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
for line in reqs["details"].split("\n"):
_cprint(f" {line}")
def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
"""Print a one-line scrollback summary of a resolved modal prompt.
Modal panels (approval / clarify) live in the prompt_toolkit layout and
vanish on the next repaint, so the question and the decision leave no
trace in the terminal scrollback. When display.persist_prompts is on
(default), emit a dim single line after the prompt resolves so the
decision survives in chat history.
"""
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
return
detail = " ".join(detail.split())
if len(detail) > 120:
detail = detail[:119] + ""
outcome = " ".join(outcome.split())
if len(outcome) > 120:
outcome = outcome[:119] + ""
_cprint(f"\n{_DIM}{icon} {label}: {detail}{outcome}{_RST}")
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
@@ -9560,7 +9400,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = 0
self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
remaining = self._clarify_deadline - _time.monotonic()
@@ -9674,16 +9513,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
_outcome_labels = {
"once": "allowed once",
"session": "allowed for session",
"always": "added to allowlist",
"deny": "denied",
}
self._persist_prompt_summary(
"", "Approval", command,
_outcome_labels.get(result, str(result)),
)
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
@@ -9800,7 +9629,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
show_full = state.get("show_full", False)
title = "⚠️ Dangerous Command"
cmd_display = command
cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
choice_labels = {
"once": "Allow once",
"session": "Allow for this session",
@@ -9824,11 +9653,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Pre-wrap the mandatory content — command + choices must always render.
cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width)
if not show_full and "view" in choices and len(cmd_wrapped) > 4:
cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text(
"… (choose Show full command)",
inner_text_width,
)
# (choice_index, wrapped_line) so we can re-apply selected styling below
choice_wrapped: list[tuple[int, str]] = []
@@ -9878,10 +9702,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))
if len(cmd_wrapped) > max_cmd_rows:
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(
"… (command truncated — use /logs or /debug for full text)",
inner_text_width,
)
cmd_wrapped = cmd_wrapped[:keep] + ["… (command truncated — use /logs or /debug for full text)"]
# Allocate any remaining rows to description. The extra -1 in full mode
# accounts for the blank separator between choices and description.
-1
View File
@@ -138,7 +138,6 @@ _HOME_TARGET_ENV_VARS = {
"bluebubbles": "BLUEBUBBLES_HOME_CHANNEL",
"qqbot": "QQBOT_HOME_CHANNEL",
"whatsapp": "WHATSAPP_HOME_CHANNEL",
"whatsapp_cloud": "WHATSAPP_CLOUD_HOME_CHANNEL",
}
# Legacy env var names kept for back-compat. Each entry is the current
+14
View File
@@ -0,0 +1,14 @@
{
"name": "hermes-agent-e2e",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "npm exec @microsoft/tui-test -t",
"replay": "npm exec @microsoft/tui-test show-trace"
},
"devDependencies": {
"@microsoft/tui-test": "^0.0.4",
"tui-replay": "^0.4.3"
}
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* Bundle tui-replay traces into a single self-contained HTML file.
*
* Run from the repo root after e2e tests complete:
* node e2e/scripts/bundle-replay-html.mjs
*
* Input: e2e/tui-traces/ (default @microsoft/tui-test output dir)
* Output: tui-replay-viewer/replay.html (uploaded as a GHA artifact)
*/
import { createReplayDataSource } from 'tui-replay';
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '../..');
// tui-replay/dist/ — resolved via ESM so package exports are honoured
const tuiReplayDist = dirname(fileURLToPath(import.meta.resolve('tui-replay')));
const tracesDir = resolve(repoRoot, 'e2e/tui-traces');
const outputDir = resolve(repoRoot, 'tui-replay-viewer');
const outputFile = join(outputDir, 'replay.html');
// ── exact strings to patch in client.js ────────────────────────────────────
const SELECTORS_IMPORT =
'import { annotationsForFrame, frameIndexAtTime, timelineItems } from "../preview/selectors.js";';
// Lines 166-172 of dist/viewer/client.js (0.4.x)
const FETCH_ORIGINAL = `async function fetchPreviewModel() {
const response = await fetch("/api/traces");
if (!response.ok) {
throw new Error(\`Unable to load traces: \${response.status}\`);
}
return (await response.json());
}`;
const FETCH_PATCHED = `async function fetchPreviewModel() {
return __INLINE_MODEL__;
}`;
// Lines 140-149 of dist/viewer/client.js (0.4.x)
const CONNECT_ORIGINAL = `function connectLiveUpdates() {
if (!("EventSource" in window)) {
startPollingLiveUpdates();
return;
}
const events = new EventSource("/api/events");
events.addEventListener("model", (event) => {
applyModelUpdate(JSON.parse(event.data));
});
}`;
const CONNECT_PATCHED = `function connectLiveUpdates() {
/* static mode: no live updates */
}`;
// ───────────────────────────────────────────────────────────────────────────
async function main() {
// Gracefully skip when traces haven't been written yet (e.g. tests skipped)
try {
await access(tracesDir);
} catch {
console.log(`tui-traces dir not found at ${tracesDir} — skipping HTML bundle.`);
process.exit(0);
}
console.log(`Loading traces from ${tracesDir}`);
const dataSource = createReplayDataSource({
inputs: [tracesDir],
projectRoot: repoRoot,
});
const model = await dataSource.load();
if (model.traces.length === 0) {
console.log('No traces found — skipping HTML bundle.');
process.exit(0);
}
console.log(`Found ${model.traces.length} trace(s).`);
// ── Load tui-replay dist assets ──────────────────────────────────────────
// renderIndexHtml is internal (not in the public index.js export) so we
// import it directly from the dist path.
const { renderIndexHtml } = await import(
pathToFileURL(join(tuiReplayDist, 'server/html.js')).href
);
const [rawClientJs, rawSelectorsJs] = await Promise.all([
readFile(join(tuiReplayDist, 'viewer/client.js'), 'utf8'),
readFile(join(tuiReplayDist, 'preview/selectors.js'), 'utf8'),
]);
// ── Patch client.js for static/embedded use ──────────────────────────────
let clientJs = rawClientJs;
// 1. Remove the ES module import (selectors will be inlined above it)
if (!clientJs.includes(SELECTORS_IMPORT)) {
throw new Error(
'Could not find selectors import in client.js — tui-replay may have updated. ' +
'Please update the SELECTORS_IMPORT constant in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(SELECTORS_IMPORT + '\n', '');
// 2. Replace the live fetch with a return of the inlined model
if (!clientJs.includes(FETCH_ORIGINAL)) {
throw new Error(
'Could not find fetchPreviewModel body in client.js — tui-replay may have updated. ' +
'Please update FETCH_ORIGINAL in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(FETCH_ORIGINAL, FETCH_PATCHED);
// 3. Disable live-reload SSE/polling (no server in static mode)
if (!clientJs.includes(CONNECT_ORIGINAL)) {
throw new Error(
'Could not find connectLiveUpdates body in client.js — tui-replay may have updated. ' +
'Please update CONNECT_ORIGINAL in bundle-replay-html.mjs.'
);
}
clientJs = clientJs.replace(CONNECT_ORIGINAL, CONNECT_PATCHED);
// Strip sourcemap comment (optional — keeps file clean in artifact viewer)
clientJs = clientJs.replace(/\n\/\/#\s*sourceMappingURL=client\.js\.map\s*$/, '');
// ── Prepare selectors for inline use ─────────────────────────────────────
// Remove `export` keyword so the functions are available in the same
// module scope as client.js (they're no longer imported — they're just
// declared above client.js in the same <script type="module"> block).
const selectorsInline = rawSelectorsJs
.replace(/^export function /gm, 'function ')
.replace(/\n\/\/#\s*sourceMappingURL=selectors\.js\.map\s*$/, '');
// ── Embed model JSON ──────────────────────────────────────────────────────
// JSON.stringify is safe inside a JS string but escape </script> sequences
// just in case trace content contains them.
const modelJsonString = JSON.stringify(model).replace(/<\/script>/gi, '<\\/script>');
// ── Assemble HTML ─────────────────────────────────────────────────────────
const htmlTemplate = renderIndexHtml();
const SCRIPT_TAG = '<script type="module" src="/assets/client.js"></script>';
if (!htmlTemplate.includes(SCRIPT_TAG)) {
throw new Error(
'Could not find the client script tag in the HTML template — ' +
'tui-replay may have updated. Please update SCRIPT_TAG in bundle-replay-html.mjs.'
);
}
const inlinedHtml = htmlTemplate.replace(
SCRIPT_TAG,
`<script type="module">
/* tui-replay selectors (inlined) */
${selectorsInline}
/* trace model (embedded at bundle time) */
const __INLINE_MODEL__ = JSON.parse(${JSON.stringify(modelJsonString)});
/* tui-replay client (patched for static mode) */
${clientJs}
</script>`
);
// ── Write output ──────────────────────────────────────────────────────────
await mkdir(outputDir, { recursive: true });
await writeFile(outputFile, inlinedHtml, 'utf8');
const sizeKb = (Buffer.byteLength(inlinedHtml, 'utf8') / 1024).toFixed(1);
console.log(`✓ Wrote ${outputFile} (${sizeKb} KB, ${model.traces.length} trace(s))`);
}
main().catch((err) => {
console.error('bundle-replay-html failed:', err.message ?? err);
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
// import { test, expect } from "@microsoft/tui-test";
// import {mkdtempSync, rmSync} from "fs"
// const CTRL_C = "\x03";
// test.describe("Hermes CLI basics", () => {
// const HERMES_HOME = mkdtempSync("hermes-home")
// test.use({
// env: {HERMES_HOME},
// })
// test("hermes command is available and shows version", async ({ terminal }) => {
// terminal.write("hermes --version\n");
// // Wait for the version output to appear
// await expect(terminal.getByText(/hermes/gi, { full: false })).toBeVisible({ timeout: 15000 });
// });
// test("hermes setup wizard starts interactively", async ({ terminal }) => {
// terminal.write("hermes setup\n");
// // Wait for the wizard to start (e.g., looking for "Configure Hermes Agent" or similar)
// await expect(terminal.getByText(/configure|setup|wizard|api key/gi)).toBeVisible({ timeout: 15000 });
// // Wait for the abort/exit message (KeyboardInterrupt is what python emits on ctrl+c)
// await expect(terminal.getByText(/abort|cancel|exit|terminated|keyboardinterrupt/gi)).toBeVisible({ timeout: 5000 });
// });
// test.afterAll(() => {
// rmSync(HERMES_HOME, { force: true,recursive: true})
// })
// });
+24
View File
@@ -0,0 +1,24 @@
import { test, expect, Shell } from "@microsoft/tui-test";
import {mkdtempSync, rmSync} from "fs"
if(process.env.CI === "true") {
test.describe("install hermes", () => {
const HERMES_HOME = mkdtempSync("hermes-home")
test.use({
shell: Shell.Bash,
env: {HERMES_HOME},
})
test("hermes installer works", async ({ terminal }) => {
// simulate curl | bash for installer script
terminal.write("cat $GITHUB_WORKSPACE/scripts/install.sh | bash\n");
// Wait for the version output to appear
await expect(terminal.getByText(/asdfasdfasdf/gi, { full: false })).toBeVisible({ timeout: 150000 });
});
test.afterAll(() => {
rmSync(HERMES_HOME, { force: true,recursive: true})
})
});
}
-3
View File
@@ -142,7 +142,6 @@ class GatewayAuthorizationMixin:
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
@@ -169,7 +168,6 @@ class GatewayAuthorizationMixin:
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS",
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
@@ -403,7 +401,6 @@ class GatewayAuthorizationMixin:
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
-59
View File
@@ -145,7 +145,6 @@ class Platform(Enum):
TELEGRAM = "telegram"
DISCORD = "discord"
WHATSAPP = "whatsapp"
WHATSAPP_CLOUD = "whatsapp_cloud"
SLACK = "slack"
SIGNAL = "signal"
MATTERMOST = "mattermost"
@@ -463,9 +462,6 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
),
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
Platform.WHATSAPP_CLOUD: lambda cfg: bool(
cfg.extra.get("phone_number_id") and cfg.extra.get("access_token")
),
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
@@ -1433,61 +1429,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
thread_id=os.getenv("WHATSAPP_HOME_CHANNEL_THREAD_ID") or None,
)
# WhatsApp Cloud API (official Business Platform via Meta).
# Distinct from the Baileys bridge: pure HTTP graph.facebook.com calls
# outbound, public webhook inbound. Both adapters can run in parallel
# against different phone numbers.
whatsapp_cloud_phone_id = os.getenv("WHATSAPP_CLOUD_PHONE_NUMBER_ID")
whatsapp_cloud_token = os.getenv("WHATSAPP_CLOUD_ACCESS_TOKEN")
if whatsapp_cloud_phone_id and whatsapp_cloud_token:
if Platform.WHATSAPP_CLOUD not in config.platforms:
config.platforms[Platform.WHATSAPP_CLOUD] = PlatformConfig()
config.platforms[Platform.WHATSAPP_CLOUD].enabled = True
config.platforms[Platform.WHATSAPP_CLOUD].extra.update({
"phone_number_id": whatsapp_cloud_phone_id,
"access_token": whatsapp_cloud_token,
})
# Optional: app_id / app_secret (signature verification)
wa_cloud_app_id = os.getenv("WHATSAPP_CLOUD_APP_ID")
if wa_cloud_app_id:
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_id"] = wa_cloud_app_id
wa_cloud_app_secret = os.getenv("WHATSAPP_CLOUD_APP_SECRET")
if wa_cloud_app_secret:
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = wa_cloud_app_secret
# Optional: WABA id (analytics, future use)
wa_cloud_waba_id = os.getenv("WHATSAPP_CLOUD_WABA_ID")
if wa_cloud_waba_id:
config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = wa_cloud_waba_id
# Webhook verify token — Meta hub.verify_token shared secret
wa_cloud_verify_token = os.getenv("WHATSAPP_CLOUD_VERIFY_TOKEN")
if wa_cloud_verify_token:
config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = wa_cloud_verify_token
# Webhook server bind config (defaults baked into the adapter)
wa_cloud_host = os.getenv("WHATSAPP_CLOUD_WEBHOOK_HOST")
if wa_cloud_host:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = wa_cloud_host
wa_cloud_port = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PORT")
if wa_cloud_port:
try:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(wa_cloud_port)
except ValueError:
pass
wa_cloud_path = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PATH")
if wa_cloud_path:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = wa_cloud_path
# Graph API version override (rarely needed)
wa_cloud_api_version = os.getenv("WHATSAPP_CLOUD_API_VERSION")
if wa_cloud_api_version:
config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = wa_cloud_api_version
whatsapp_cloud_home = os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL")
if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms:
config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel(
platform=Platform.WHATSAPP_CLOUD,
chat_id=whatsapp_cloud_home,
name=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_NAME", "Home"),
thread_id=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_THREAD_ID") or None,
)
# Slack
slack_token = os.getenv("SLACK_BOT_TOKEN")
if slack_token:
-6
View File
@@ -123,12 +123,6 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
# Tier 3 — no edit support, progress messages are permanent
"signal": _TIER_LOW,
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
# WhatsApp Cloud API: Meta added message editing in 2023 but the
# Hermes Cloud adapter doesn't implement edit_message yet, so we
# stay on TIER_LOW (tool_progress off) to avoid spamming each
# status update as a separate message. Promote to TIER_MEDIUM once
# Cloud's edit_message lands.
"whatsapp_cloud": _TIER_LOW,
"bluebubbles": _TIER_LOW,
"weixin": _TIER_LOW,
"wecom": _TIER_LOW,
-29
View File
@@ -52,22 +52,6 @@ for the full pattern (Template Buttons postback at 45s, `RequestCache`
state machine, `interrupt_session_activity` override for `/stop`
orphans) and the developer-guide page for the prose walkthrough.
**Sibling adapters that share behavior.** When a single platform has
two transport modes the user picks between — unofficial vs official
APIs, polling vs websocket, library A vs library B — the right
structure is two adapters that share a behavior mixin. WhatsApp does
this: `gateway/platforms/whatsapp.py` (Baileys bridge) and
`gateway/platforms/whatsapp_cloud.py` (Meta Cloud API) both inherit
from `WhatsAppBehaviorMixin` in `gateway/platforms/whatsapp_common.py`.
The mixin owns gating, allow-lists, mention parsing, broadcast
filters, and the WhatsApp-flavored markdown conversion — everything
that's platform-protocol-agnostic. Each adapter owns its transport.
Both register distinct `Platform.*` enum values so the gateway can run
both simultaneously against different phone numbers. The mixin must
come **first** in the bases list — `class WhatsAppAdapter(Mixin,
BasePlatformAdapter)` — so the mixin's `format_message` overrides
`BasePlatformAdapter`'s generic default.
See `plugins/platforms/irc/`, `plugins/platforms/teams/`, and
`plugins/platforms/google_chat/` for complete working examples, and
`website/docs/developer-guide/adding-platform-adapters.md` for the full
@@ -110,19 +94,6 @@ The adapter is a subclass of `BasePlatformAdapter` from `gateway/platforms/base.
| `send_animation(chat_id, path, caption)` | Send a GIF/animation |
| `send_image_file(chat_id, path, caption)` | Send image from local file |
### Interactive UX (recommended if your platform supports tappable buttons)
If your platform supports interactive button/menu messages, implement these for a more polished agent experience. They all degrade gracefully to plain text when not overridden:
| Method | Purpose |
|--------|---------|
| `send_clarify(chat_id, question, choices, clarify_id, session_key, ...)` | Render the `clarify` tool's multi-choice question as tappable buttons. Pair with inbound dispatch that routes button taps to `tools.clarify_gateway.resolve_gateway_clarify`. |
| `send_exec_approval(chat_id, command, session_key, description, ...)` | Render dangerous-command approval as Approve/Deny buttons. Inbound dispatch routes to `tools.approval.resolve_gateway_approval`. |
| `send_slash_confirm(chat_id, title, message, session_key, confirm_id, ...)` | Render slash-command confirmations (e.g. `/reload-mcp`) as Once/Always/Cancel buttons. Inbound dispatch routes to `tools.slash_confirm.resolve`. |
| `send_model_picker(...)` | Interactive `/model` picker. Used by Telegram and Discord. |
See `gateway/platforms/telegram.py`, `discord.py`, and `whatsapp_cloud.py` for reference implementations. The button-callback id convention (`cl:<id>:<idx>`, `appr:<id>:<choice>`, `sc:<choice>:<id>`) is shared across adapters — match it so the gateway-side resolvers work without modification.
### Required function
```python
+1 -8
View File
@@ -470,15 +470,8 @@ class EmailAdapter(BasePlatformAdapter):
for att in attachments:
media_urls.append(att["path"])
media_types.append(att["media_type"])
if att["type"] == "image" and msg_type == MessageType.TEXT:
if att["type"] == "image":
msg_type = MessageType.PHOTO
elif att["type"] == "document":
# Document wins over PHOTO for mixed attachments: run.py's
# image handling keys off the per-path image/* mime type
# regardless of message_type, but document-context injection
# gates strictly on MessageType.DOCUMENT — so DOCUMENT is the
# only classification that surfaces both.
msg_type = MessageType.DOCUMENT
# Store thread context for reply threading
self._thread_context[sender_addr] = {
-8
View File
@@ -602,14 +602,6 @@ class SignalAdapter(BasePlatformAdapter):
msg_type = MessageType.VOICE
elif any(mt.startswith("image/") for mt in media_types):
msg_type = MessageType.PHOTO
elif any(mt.startswith("video/") for mt in media_types):
msg_type = MessageType.VIDEO
else:
# Catch-all: application/*, text/*, and unknown MIME types are
# treated as documents so run.py's document-context injection
# surfaces the cached file path to the agent (same pattern as
# WhatsApp/Slack/BlueBubbles/Mattermost).
msg_type = MessageType.DOCUMENT
# Parse timestamp from envelope data (milliseconds since epoch)
ts_ms = envelope_data.get("timestamp", 0)
-65
View File
@@ -890,18 +890,6 @@ class SlackAdapter(BasePlatformAdapter):
async def handle_file_change(event, say):
pass
# Reactions are useful lightweight acknowledgements in Slack, but
# Hermes does not currently need to route them into the agent loop.
# Ack the events explicitly so high-traffic channels do not fill
# gateway.error.log with Slack Bolt "Unhandled request" warnings.
@self._app.event("reaction_added")
async def handle_reaction_added(event, say):
pass
@self._app.event("reaction_removed")
async def handle_reaction_removed(event, say):
pass
@self._app.event("assistant_thread_started")
async def handle_assistant_thread_started(event, say):
await self._handle_assistant_thread_lifecycle_event(event)
@@ -961,59 +949,6 @@ class SlackAdapter(BasePlatformAdapter):
):
self._app.action(_action_id)(self._handle_slash_confirm_action)
# Register plugin-provided Block Kit action handlers.
#
# Plugins call ``ctx.register_slack_action_handler(action_id, cb)``
# at register() time; the manager queues them and the adapter
# wires them into AsyncApp here so slack_bolt's matcher knows
# about them before Socket Mode starts dispatching events.
#
# Each callback is wrapped so a misbehaving plugin can't take
# down the gateway: any exception inside the plugin handler is
# caught and logged, and slack_bolt still sees a clean ack.
try:
from hermes_cli.plugins import get_plugin_manager
_plugin_handlers = get_plugin_manager().get_slack_action_handlers()
except Exception as e: # pragma: no cover - defensive
logger.warning(
"[Slack] Could not load plugin action handlers: %s", e,
)
_plugin_handlers = []
# Closure factory — keeps the wrapper's signature limited to
# ``(ack, body, action)``. slack_bolt inspects listener
# signatures via ``inspect.signature`` and passes ``None`` for
# any parameter name it doesn't recognise, so capturing loop
# vars as default args (``_cb=_cb`` etc.) silently clobbers
# them at dispatch time.
def _make_wrapper(cb, plugin_name):
async def _wrapped(ack, body, action):
try:
await cb(ack, body, action)
except Exception as exc: # pragma: no cover - defensive
logger.error(
"[Slack] Plugin '%s' action handler raised: %s",
plugin_name, exc, exc_info=True,
)
# Best-effort ack so Slack doesn't retry the click.
try:
await ack()
except Exception:
pass
return _wrapped
for _action_id, _cb, _plugin_name in _plugin_handlers:
self._app.action(_action_id)(_make_wrapper(_cb, _plugin_name))
logger.debug(
"[Slack] Registered plugin action handler %s (from %s)",
_action_id, _plugin_name,
)
if _plugin_handlers:
logger.info(
"[Slack] Wired %d plugin action handler(s)",
len(_plugin_handlers),
)
# Bring up the handler and watchdog atomically. ``_running`` only
# flips to True after the handler is alive so the watchdog loop
# observes the live task immediately; on any failure here we tear
+279 -7
View File
@@ -16,9 +16,11 @@ with different backends via a bridge pattern.
"""
import asyncio
import json
import logging
import os
import platform
import re
import shutil
import signal
import subprocess
@@ -178,7 +180,6 @@ import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
@@ -230,7 +231,7 @@ def check_whatsapp_requirements() -> bool:
return False
class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
class WhatsAppAdapter(BasePlatformAdapter):
"""
WhatsApp adapter.
@@ -252,12 +253,14 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
- allow_from: List of sender IDs allowed in DMs (when dm_policy="allowlist")
- group_policy: "open" | "allowlist" | "disabled" which groups are processed (default: "open")
- group_allow_from: List of group JIDs allowed (when group_policy="allowlist")
Behavior (gating, mention parsing, markdown conversion, chunking) is
provided by ``WhatsAppBehaviorMixin`` so the Cloud API adapter can
share it. Only transport-specific code lives here.
"""
# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH = 4096
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
DEFAULT_REPLY_PREFIX = "⚕ *Hermes Agent*\n────────────\n"
# Default bridge location relative to the hermes-agent install
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
@@ -329,6 +332,218 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
return float(default)
return parsed
def _effective_reply_prefix(self) -> str:
"""Return the prefix the Node bridge will add in self-chat mode."""
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
if whatsapp_mode != "self-chat":
return ""
if self._reply_prefix is not None:
return self._reply_prefix.replace("\\n", "\n")
env_prefix = os.getenv("WHATSAPP_REPLY_PREFIX")
if env_prefix is not None:
return env_prefix.replace("\\n", "\n")
return self.DEFAULT_REPLY_PREFIX
def _outgoing_chunk_limit(self) -> int:
"""Reserve room for the bridge-side prefix so final WhatsApp text fits."""
prefix_len = len(self._effective_reply_prefix())
# Keep enough space for truncate_message's pagination indicator and
# code-fence repair even if a user configures a very long prefix.
return max(1024, self.MAX_MESSAGE_LENGTH - prefix_len)
def _whatsapp_require_mention(self) -> bool:
configured = self.config.extra.get("require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}
def _whatsapp_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@staticmethod
def _coerce_allow_list(raw) -> set[str]:
"""Parse allow_from / group_allow_from from config or env var."""
if raw is None:
return set()
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@staticmethod
def _is_broadcast_chat(chat_id: str) -> bool:
"""True for WhatsApp pseudo-chats that aren't real conversations.
Covers Status updates (Stories) and Channel/Newsletter broadcasts.
These show up as inbound messages on Baileys but the agent should
never reply answering a Story update spams the contact's status
feed, and Channel posts aren't addressable in the first place.
"""
if not chat_id:
return False
cid = chat_id.strip().lower()
if cid == "status@broadcast":
return True
# @broadcast suffix covers status@broadcast plus any future
# broadcast-list variants. @newsletter is the Channel JID suffix.
if cid.endswith("@broadcast") or cid.endswith("@newsletter"):
return True
return False
@property
def enforces_own_access_policy(self) -> bool:
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
return True
def _is_dm_allowed(self, sender_id: str) -> bool:
"""Check whether a DM from the given sender should be processed."""
if self._dm_policy == "disabled":
return False
if self._dm_policy == "allowlist":
return sender_id in self._allow_from
# "open" — all DMs allowed
return True
def _is_group_allowed(self, chat_id: str) -> bool:
"""Check whether a group chat should be processed."""
if self._group_policy == "disabled":
return False
if self._group_policy == "allowlist":
return chat_id in self._group_allow_from
# "open" — all groups allowed
return True
def _compile_mention_patterns(self):
patterns = self.config.extra.get("mention_patterns")
if patterns is None:
raw = os.getenv("WHATSAPP_MENTION_PATTERNS", "").strip()
if raw:
try:
patterns = json.loads(raw)
except Exception:
patterns = [part.strip() for part in raw.splitlines() if part.strip()]
if not patterns:
patterns = [part.strip() for part in raw.split(",") if part.strip()]
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
logger.warning("[%s] whatsapp mention_patterns must be a list or string; got %s", self.name, type(patterns).__name__)
return []
compiled = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning("[%s] Invalid WhatsApp mention pattern %r: %s", self.name, pattern, exc)
if compiled:
logger.info("[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled))
return compiled
@staticmethod
def _normalize_whatsapp_id(value: Optional[str]) -> str:
if not value:
return ""
normalized = str(value).strip()
if ":" in normalized and "@" in normalized:
normalized = normalized.replace(":", "@", 1)
return normalized
def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]:
bot_ids = set()
for candidate in data.get("botIds") or []:
normalized = self._normalize_whatsapp_id(candidate)
if normalized:
bot_ids.add(normalized)
return bot_ids
def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool:
quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant"))
if not quoted_participant:
return False
return quoted_participant in self._bot_ids_from_message(data)
def _message_mentions_bot(self, data: Dict[str, Any]) -> bool:
bot_ids = self._bot_ids_from_message(data)
if not bot_ids:
return False
mentioned_ids = {
nid
for candidate in (data.get("mentionedIds") or [])
if (nid := self._normalize_whatsapp_id(candidate))
}
if mentioned_ids & bot_ids:
return True
body = str(data.get("body") or "")
lower_body = body.lower()
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0].lower()
if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body):
return True
return False
def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool:
if not self._mention_patterns:
return False
body = str(data.get("body") or "")
return any(pattern.search(body) for pattern in self._mention_patterns)
def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
if not text:
return text
bot_ids = self._bot_ids_from_message(data)
cleaned = text
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0]
if bare_id:
cleaned = re.sub(rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned)
return cleaned.strip() or text
def _should_process_message(self, data: Dict[str, Any]) -> bool:
chat_id_raw = str(data.get("chatId") or "")
# WhatsApp uses pseudo-chats for Status updates (Stories) and
# Channel/Newsletter broadcasts. These are not real conversations
# and the agent should never reply to them — even in self-chat mode
# where the bridge may surface them as "fromMe" events.
if self._is_broadcast_chat(chat_id_raw):
return False
is_group = data.get("isGroup", False)
if is_group:
chat_id = chat_id_raw
if not self._is_group_allowed(chat_id):
return False
else:
sender_id = str(data.get("senderId") or data.get("from") or "")
if not self._is_dm_allowed(sender_id):
return False
# DMs that pass the policy gate are always processed
return True
# Group messages: check mention / free-response settings
chat_id = str(data.get("chatId") or "")
if chat_id in self._whatsapp_free_response_chats():
return True
if not self._whatsapp_require_mention():
return True
body = str(data.get("body") or "").strip()
if body.startswith("/"):
return True
if self._message_is_reply_to_bot(data):
return True
if self._message_mentions_bot(data):
return True
return self._message_matches_mention_patterns(data)
async def connect(self) -> bool:
"""
Start the WhatsApp bridge.
@@ -697,6 +912,63 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
self._close_bridge_log()
print(f"[{self.name}] Disconnected")
def format_message(self, content: str) -> str:
"""Convert standard markdown to WhatsApp-compatible formatting.
WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
and monospaced `inline`. Standard markdown uses different syntax
for bold/italic/strikethrough, so we convert here.
Code blocks (``` fenced) and inline code (`) are protected from
conversion via placeholder substitution.
"""
if not content:
return content
# --- 1. Protect fenced code blocks from formatting changes ---
_FENCE_PH = "\x00FENCE"
fences: list[str] = []
def _save_fence(m: re.Match) -> str:
fences.append(m.group(0))
return f"{_FENCE_PH}{len(fences) - 1}\x00"
result = re.sub(r"```[\s\S]*?```", _save_fence, content)
# --- 2. Protect inline code ---
_CODE_PH = "\x00CODE"
codes: list[str] = []
def _save_code(m: re.Match) -> str:
codes.append(m.group(0))
return f"{_CODE_PH}{len(codes) - 1}\x00"
result = re.sub(r"`[^`\n]+`", _save_code, result)
# --- 3. Convert markdown formatting to WhatsApp syntax ---
# Bold: **text** or __text__ → *text*
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
result = re.sub(r"__(.+?)__", r"*\1*", result)
# Strikethrough: ~~text~~ → ~text~
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
# Italic: *text* is already WhatsApp italic — leave as-is
# _text_ is already WhatsApp italic — leave as-is
# --- 4. Convert markdown headers to bold text ---
# # Header → *Header*
result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE)
# --- 5. Convert markdown links: [text](url) → text (url) ---
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
# --- 6. Restore protected sections ---
for i, fence in enumerate(fences):
result = result.replace(f"{_FENCE_PH}{i}\x00", fence)
for i, code in enumerate(codes):
result = result.replace(f"{_CODE_PH}{i}\x00", code)
return result
async def send(
self,
chat_id: str,
File diff suppressed because it is too large Load Diff
-367
View File
@@ -1,367 +0,0 @@
"""
Transport-agnostic WhatsApp behavior shared by the Baileys bridge adapter
and the official WhatsApp Cloud API adapter.
The mixin provides:
- Allow-list / DM / group gating
- Mention detection (explicit @-mentions + configurable regex patterns)
- Quoted-reply-to-bot detection
- Broadcast / Channel / Newsletter filtering
- WhatsApp-flavored markdown conversion
- Outgoing chunk length budgeting
It is the *behavior layer*. Transport-specific concerns (subprocess management,
HTTP webhooks, Graph API calls, media upload protocols) live in each adapter.
Mixin contract the adapter must set these on ``self`` before any of the
mixin's methods are called (typically in ``__init__``):
self.config # gateway.config.PlatformConfig
self.name # str — adapter name (used in log lines)
self._dm_policy # str: "open" | "allowlist" | "disabled"
self._allow_from # set[str]
self._group_policy # str: "open" | "allowlist" | "disabled"
self._group_allow_from # set[str]
self._mention_patterns # list[re.Pattern]
self._reply_prefix # Optional[str]
Class attributes ``MAX_MESSAGE_LENGTH`` and ``DEFAULT_REPLY_PREFIX`` are
defined on the mixin and may be overridden per-adapter if needed.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class WhatsAppBehaviorMixin:
"""Shared behavior for all WhatsApp adapters (Baileys + Cloud API).
See module docstring for the attribute contract the host adapter must
satisfy. This mixin owns no state of its own every value it touches
is either a class attribute or set by the adapter's ``__init__``.
"""
# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH: int = 4096
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
DEFAULT_REPLY_PREFIX: str = "⚕ *Hermes Agent*\n────────────\n"
@property
def enforces_own_access_policy(self) -> bool:
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------ config
def _effective_reply_prefix(self) -> str:
"""Return the prefix to add to outgoing replies in self-chat mode.
Subclasses that don't have a self-chat concept (the Cloud API
adapter) can override this to always return ``""`` or apply a
different policy.
"""
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
if whatsapp_mode != "self-chat":
return ""
if self._reply_prefix is not None:
return self._reply_prefix.replace("\\n", "\n")
env_prefix = os.getenv("WHATSAPP_REPLY_PREFIX")
if env_prefix is not None:
return env_prefix.replace("\\n", "\n")
return self.DEFAULT_REPLY_PREFIX
def _outgoing_chunk_limit(self) -> int:
"""Reserve room for the reply prefix so the final message fits."""
prefix_len = len(self._effective_reply_prefix())
# Keep enough space for truncate_message's pagination indicator and
# code-fence repair even if a user configures a very long prefix.
return max(1024, self.MAX_MESSAGE_LENGTH - prefix_len)
def _whatsapp_require_mention(self) -> bool:
configured = self.config.extra.get("require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in {
"true",
"1",
"yes",
"on",
}
def _whatsapp_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@staticmethod
def _coerce_allow_list(raw) -> set[str]:
"""Parse allow_from / group_allow_from from config or env var."""
if raw is None:
return set()
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
# ------------------------------------------------------------------ JID helpers
@staticmethod
def _normalize_whatsapp_id(value: Optional[str]) -> str:
if not value:
return ""
normalized = str(value).strip()
if ":" in normalized and "@" in normalized:
normalized = normalized.replace(":", "@", 1)
return normalized
@staticmethod
def _is_broadcast_chat(chat_id: str) -> bool:
"""True for WhatsApp pseudo-chats that aren't real conversations.
Covers Status updates (Stories) and Channel/Newsletter broadcasts.
These show up as inbound messages on Baileys but the agent should
never reply answering a Story update spams the contact's status
feed, and Channel posts aren't addressable in the first place.
"""
if not chat_id:
return False
cid = chat_id.strip().lower()
if cid == "status@broadcast":
return True
# @broadcast suffix covers status@broadcast plus any future
# broadcast-list variants. @newsletter is the Channel JID suffix.
if cid.endswith("@broadcast") or cid.endswith("@newsletter"):
return True
return False
# ------------------------------------------------------------------ gating
def _is_dm_allowed(self, sender_id: str) -> bool:
"""Check whether a DM from the given sender should be processed."""
if self._dm_policy == "disabled":
return False
if self._dm_policy == "allowlist":
return sender_id in self._allow_from
# "open" — all DMs allowed
return True
def _is_group_allowed(self, chat_id: str) -> bool:
"""Check whether a group chat should be processed."""
if self._group_policy == "disabled":
return False
if self._group_policy == "allowlist":
return chat_id in self._group_allow_from
# "open" — all groups allowed
return True
def _compile_mention_patterns(self):
patterns = self.config.extra.get("mention_patterns")
if patterns is None:
raw = os.getenv("WHATSAPP_MENTION_PATTERNS", "").strip()
if raw:
try:
patterns = json.loads(raw)
except Exception:
patterns = [
part.strip() for part in raw.splitlines() if part.strip()
]
if not patterns:
patterns = [
part.strip() for part in raw.split(",") if part.strip()
]
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
logger.warning(
"[%s] whatsapp mention_patterns must be a list or string; got %s",
self.name,
type(patterns).__name__,
)
return []
compiled = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning(
"[%s] Invalid WhatsApp mention pattern %r: %s",
self.name,
pattern,
exc,
)
if compiled:
logger.info(
"[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled)
)
return compiled
def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]:
bot_ids = set()
for candidate in data.get("botIds") or []:
normalized = self._normalize_whatsapp_id(candidate)
if normalized:
bot_ids.add(normalized)
return bot_ids
def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool:
quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant"))
if not quoted_participant:
return False
return quoted_participant in self._bot_ids_from_message(data)
def _message_mentions_bot(self, data: Dict[str, Any]) -> bool:
bot_ids = self._bot_ids_from_message(data)
if not bot_ids:
return False
mentioned_ids = {
nid
for candidate in (data.get("mentionedIds") or [])
if (nid := self._normalize_whatsapp_id(candidate))
}
if mentioned_ids & bot_ids:
return True
body = str(data.get("body") or "")
lower_body = body.lower()
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0].lower()
if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body):
return True
return False
def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool:
if not self._mention_patterns:
return False
body = str(data.get("body") or "")
return any(pattern.search(body) for pattern in self._mention_patterns)
def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
if not text:
return text
bot_ids = self._bot_ids_from_message(data)
cleaned = text
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0]
if bare_id:
cleaned = re.sub(
rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned
)
return cleaned.strip() or text
def _should_process_message(self, data: Dict[str, Any]) -> bool:
chat_id_raw = str(data.get("chatId") or "")
# WhatsApp uses pseudo-chats for Status updates (Stories) and
# Channel/Newsletter broadcasts. These are not real conversations
# and the agent should never reply to them — even in self-chat mode
# where the bridge may surface them as "fromMe" events.
if self._is_broadcast_chat(chat_id_raw):
return False
is_group = data.get("isGroup", False)
if is_group:
chat_id = chat_id_raw
if not self._is_group_allowed(chat_id):
return False
else:
sender_id = str(data.get("senderId") or data.get("from") or "")
if not self._is_dm_allowed(sender_id):
return False
# DMs that pass the policy gate are always processed
return True
# Group messages: check mention / free-response settings
chat_id = str(data.get("chatId") or "")
if chat_id in self._whatsapp_free_response_chats():
return True
if not self._whatsapp_require_mention():
return True
body = str(data.get("body") or "").strip()
if body.startswith("/"):
return True
if self._message_is_reply_to_bot(data):
return True
if self._message_mentions_bot(data):
return True
return self._message_matches_mention_patterns(data)
# ------------------------------------------------------------------ formatting
def format_message(self, content: str) -> str:
"""Convert standard markdown to WhatsApp-compatible formatting.
WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
and monospaced `inline`. Standard markdown uses different syntax
for bold/italic/strikethrough, so we convert here.
Code blocks (``` fenced) and inline code (`) are protected from
conversion via placeholder substitution.
"""
if not content:
return content
# --- 1. Protect fenced code blocks from formatting changes ---
_FENCE_PH = "\x00FENCE"
fences: list[str] = []
def _save_fence(m: re.Match) -> str:
fences.append(m.group(0))
return f"{_FENCE_PH}{len(fences) - 1}\x00"
result = re.sub(r"```[\s\S]*?```", _save_fence, content)
# --- 2. Protect inline code ---
_CODE_PH = "\x00CODE"
codes: list[str] = []
def _save_code(m: re.Match) -> str:
codes.append(m.group(0))
return f"{_CODE_PH}{len(codes) - 1}\x00"
result = re.sub(r"`[^`\n]+`", _save_code, result)
# --- 3. Convert markdown formatting to WhatsApp syntax ---
# Bold: **text** or __text__ → *text*
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
result = re.sub(r"__(.+?)__", r"*\1*", result)
# Strikethrough: ~~text~~ → ~text~
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
# Italic: *text* is already WhatsApp italic — leave as-is
# _text_ is already WhatsApp italic — leave as-is
# --- 4. Convert markdown headers to bold text ---
# # Header → *Header*. Strip any *...* wrapping already produced
# by step 3 (e.g. "# **Title**" → "*Title*", not "**Title**",
# which WhatsApp renders with literal asterisks).
def _header_to_bold(m: re.Match) -> str:
inner = m.group(1).strip()
while len(inner) > 1 and inner.startswith("*") and inner.endswith("*"):
inner = inner[1:-1].strip()
return f"*{inner}*"
result = re.sub(
r"^#{1,6}\s+(.+)$", _header_to_bold, result, flags=re.MULTILINE
)
# --- 5. Convert markdown links: [text](url) → text (url) ---
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
# --- 6. Restore protected sections ---
for i, fence in enumerate(fences):
result = result.replace(f"{_FENCE_PH}{i}\x00", fence)
for i, code in enumerate(codes):
result = result.replace(f"{_CODE_PH}{i}\x00", code)
return result
+18 -319
View File
@@ -18,8 +18,6 @@ Configuration in config.yaml (or via env vars):
from __future__ import annotations
import asyncio
import base64
import binascii
import collections
import dataclasses
import hashlib
@@ -33,10 +31,9 @@ import time
import urllib.parse
import uuid
from datetime import datetime, timezone, timedelta
from enum import Enum
from pathlib import Path
from abc import ABC, abstractmethod
from typing import Any, Callable, ClassVar, Dict, Iterator, List, Optional, Tuple
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
import sys
@@ -58,7 +55,6 @@ from gateway.platforms.base import (
SendResult,
cache_document_from_bytes,
cache_image_from_bytes,
cache_video_from_bytes,
)
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.yuanbao_media import (
@@ -81,7 +77,6 @@ from gateway.platforms.yuanbao_proto import (
HERMES_INSTANCE_ID,
decode_conn_msg,
decode_inbound_push,
decode_forward_msg_data,
decode_query_group_info_rsp,
decode_get_group_member_list_rsp,
encode_auth_bind,
@@ -169,7 +164,7 @@ _YB_RES_REF_RE = re.compile(
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
# Media kinds that can be resolved and injected into the model context
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file", "video"})
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
# Strip page indicators like (1/3) appended by BasePlatformAdapter
_INDICATOR_RE = re.compile(r'\s*\(\d+/\d+\)$')
@@ -937,10 +932,6 @@ class InboundContext:
raw_text: str = ""
media_refs: list = dc_field(default_factory=list)
# Populated by ExtractContentMiddleware for elem_type 1009 (WeChat forward).
# Contains the parsed ForwardMsgData dict (sub_type / nick_name / msg list).
forwarded_records: Optional[dict] = None
# Owner command detection
owner_command: Optional[str] = None
@@ -948,7 +939,7 @@ class InboundContext:
source: Optional[Any] = None # SessionSource
# Populated by ClassifyMessageTypeMiddleware
msg_type: Optional[Any] = None # MessageType | YuanbaoMessageType
msg_type: Optional[Any] = None # MessageType
# Populated by QuoteContextMiddleware
reply_to_message_id: Optional[str] = None
@@ -1770,9 +1761,6 @@ class ExtractContentMiddleware(InboundMiddleware):
parts.append(text)
else:
parts.append("[unsupported message type]")
elif ctype == 1009:
# WeChat forwarded chat record: use the truncated summary text.
parts.append(custom.get("text", "[chat record]"))
else:
parts.append("[unsupported message type]")
except (json.JSONDecodeError, TypeError):
@@ -1884,70 +1872,10 @@ class ExtractContentMiddleware(InboundMiddleware):
pass
return urls
@staticmethod
def _extract_forwarded_records(msg_body: list, user_id: str = "") -> Optional[dict]:
"""Extract ForwardMsgData from ext_map for elem_type 1009 (WeChat forward).
The detailed chat-record payload lives in ``msg_content.ext_map``
(protobuf field 999, ``map<string, string>``):
- key format: ``wexin_forward_msg_[forward_msg_id]_[userid]``
- value: a **base64-encoded protobuf** ``ForwardMsgData`` (NOT JSON).
Decode with base64 then ``decode_forward_msg_data`` to recover the
``sub_type`` / ``nick_name`` / ``msg`` structure.
Matching strategy: take the first ``wexin_forward_msg_`` entry whose
decoded payload is a valid ``ForwardMsgData`` (``sub_type == 1``).
Returns the parsed ``ForwardMsgData`` dict or ``None``.
"""
for elem in msg_body or []:
if not isinstance(elem, dict) or elem.get("msg_type") != "TIMCustomElem":
continue
content = elem.get("msg_content", {}) or {}
if not isinstance(content, dict):
continue
data_str = content.get("data", "")
if not data_str:
continue
try:
custom = json.loads(data_str)
except (json.JSONDecodeError, TypeError):
continue
if not (isinstance(custom, dict) and custom.get("elem_type") == 1009):
continue
ext_map = content.get("ext_map") or {}
if not isinstance(ext_map, dict) or not ext_map:
return None
def _parse_value(value):
# ext_map values are base64-encoded ForwardMsgData protobuf.
if not isinstance(value, str) or not value:
return None
try:
pb = base64.b64decode(value)
except (binascii.Error, ValueError):
return None
data = decode_forward_msg_data(pb)
if isinstance(data, dict) and data.get("sub_type") == 1:
return data
return None
# Take the first valid wexin_forward_msg_ entry.
for key, value in ext_map.items():
if not key.startswith("wexin_forward_msg_"):
continue
parsed = _parse_value(value)
if parsed is not None:
return parsed
return None
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.raw_text = self._rewrite_slash_command(self._extract_text(ctx.msg_body))
ctx.media_refs = self._extract_inbound_media_refs(ctx.msg_body)
ctx.link_urls = self._extract_link_urls(ctx.msg_body)
ctx.forwarded_records = self._extract_forwarded_records(ctx.msg_body, ctx.from_account)
await next_fn()
class PlaceholderFilterMiddleware(InboundMiddleware):
@@ -2157,14 +2085,10 @@ class GroupAtGuardMiddleware(InboundMiddleware):
"and answer it directly."
)
@classmethod
@staticmethod
def _observe_group_message(
cls,
adapter, source, sender_display: str, text: str,
*,
ctx: InboundContext,
msg_id: Optional[str] = None,
forwarded_records: Optional[dict] = None,
*, msg_id: Optional[str] = None,
) -> None:
"""Write a group message into the session transcript without triggering the agent.
@@ -2179,14 +2103,7 @@ class GroupAtGuardMiddleware(InboundMiddleware):
try:
session_entry = store.get_or_create_session(source)
user_id = source.user_id or "unknown"
body_text = text
if forwarded_records:
summary = ForwardedRecordsParseMiddleware.build_forward_text(
forwarded_records, ctx=ctx, is_dispatch=False,
)
if summary:
body_text = f"{text}\n{summary}" if text else summary
attributed = f"[{sender_display}|{user_id}]\n{body_text}"
attributed = f"[{sender_display}|{user_id}]\n{text}"
entry: dict = {
"role": "user",
"content": attributed,
@@ -2208,8 +2125,6 @@ class GroupAtGuardMiddleware(InboundMiddleware):
self._observe_group_message(
adapter, ctx.source, ctx.sender_nickname or ctx.from_account, ctx.raw_text,
msg_id=ctx.msg_id or None,
forwarded_records=ctx.forwarded_records,
ctx=ctx,
)
logger.info(
"[%s] Group message observed (no @bot): chat=%s from=%s",
@@ -2250,26 +2165,14 @@ class GroupAttributionMiddleware(InboundMiddleware):
await next_fn()
class YuanbaoMessageType(Enum):
"""Yuanbao-local message subtypes; coerced back to :class:`MessageType`
before leaving the adapter (see :class:`DispatchMiddleware`)."""
# WeChat forwarded chat records (TIMCustomElem, elem_type 1009).
CHAT_RECORD = "chat_record"
class ClassifyMessageTypeMiddleware(InboundMiddleware):
"""Determine MessageType from text content and msg_body elements."""
name = "classify-msg-type"
@staticmethod
def _classify(text: str, msg_body: list):
"""Classify message type based on text and msg_body.
Returns a base :class:`MessageType`, or a yuanbao-local
:class:`YuanbaoMessageType` for platform-specific subtypes.
"""
def _classify(text: str, msg_body: list) -> MessageType:
"""Classify message type based on text and msg_body."""
if text.startswith("/"):
return MessageType.COMMAND
for elem in msg_body:
@@ -2282,14 +2185,6 @@ class ClassifyMessageTypeMiddleware(InboundMiddleware):
return MessageType.VIDEO
if etype == "TIMFileElem":
return MessageType.DOCUMENT
if etype == "TIMCustomElem":
data_str = (elem.get("msg_content") or {}).get("data", "")
try:
custom = json.loads(data_str)
except (json.JSONDecodeError, TypeError):
custom = None
if isinstance(custom, dict) and custom.get("elem_type") == 1009:
return YuanbaoMessageType.CHAT_RECORD
return MessageType.TEXT
async def handle(self, ctx: InboundContext, next_fn) -> None:
@@ -2371,180 +2266,6 @@ class QuoteContextMiddleware(InboundMiddleware):
await next_fn()
class ForwardedRecordsParseMiddleware(InboundMiddleware):
"""Deep-parse WeChat forwarded chat records (elem_type 1009) for dispatch.
Activates when a full ``ForwardMsgData`` dict is available on the current
turn, carried by the current message (``ctx.forwarded_records``).
Resolves media to ``[kind|ybres:RID]``
placeholders, appends downloadable refs to ``ctx.media_refs`` (for
:class:`MediaResolveMiddleware`), and rewrites ``ctx.raw_text``.
Group @bot turns *without* a forward on the current message rely on the
eagerly-rendered summaries that :class:`GroupAtGuardMiddleware` writes to
the transcript at observe time there is no run-time summary fallback
here.
On any failure the middleware leaves ``ctx.raw_text`` untouched
(graceful degradation, design §2.8).
"""
name = "forwarded-records-parse"
async def handle(self, ctx: InboundContext, next_fn) -> None:
try:
if ctx.forwarded_records:
self._send_loading_heartbeat(ctx)
ctx.raw_text = self.build_forward_text(ctx.forwarded_records, ctx=ctx, is_dispatch=True)
except Exception as exc:
# Degrade gracefully: leave ctx.raw_text as-is.
logger.warning(
"[%s] forwarded-records deep parse failed: %s",
getattr(ctx.adapter, "name", "yuanbao"), exc,
)
await next_fn()
# -- Heartbeat ---------------------------------------------------------
@staticmethod
async def _send_loading_heartbeat(ctx: InboundContext) -> None:
"""Best-effort RUNNING heartbeat so the user sees a loading bubble."""
try:
await ctx.adapter._outbound.heartbeat.send_heartbeat_once(
ctx.chat_id, WS_HEARTBEAT_RUNNING,
)
except Exception:
pass
# -- Record rendering helpers -----------------------------------------
@classmethod
def _media_marker(
cls, media: dict, plain_text: str = "",
) -> Tuple[str, Optional[Dict[str, str]]]:
"""Render one ``msgContent.multimedia`` entry as a textual marker.
Returns ``(marker, ref)``. Downloadable media emits a
``[kind|ybres:RID]`` marker and a ``ctx.media_refs`` ref dict when a
usable RID/URL is present; otherwise a plain ``[kind] name`` marker
and ``ref=None``.
"""
media_type = (media.get("type", "") or media.get("doc_type", "")).strip().lower()
url = str(media.get("url") or "").strip()
media_id = str(media.get("media_id") or "").strip()
file_name = str(media.get("file_name") or "").strip()
# media_id is directly usable as a ybres RID (design §2.10.9);
# fall back to parsing the resourceId out of the URL.
rid = media_id or ExtractContentMiddleware._parse_resource_id(url)
if media_type == "image":
if url and rid:
return f"[image|ybres:{rid}] {file_name}".rstrip(), {"kind": "image", "url": url}
return f"[image] {file_name or plain_text}".rstrip(), None
if media_type in ("file", "document", "code"):
if url and rid:
ref: Dict[str, str] = {"kind": "file", "url": url}
if file_name:
ref["name"] = file_name
return f"[file|ybres:{rid}] {file_name}".rstrip(), ref
return f"[file] {file_name}".rstrip(), None
if media_type == "url":
# Link share (e.g. WeChat article) — keep URL for the agent.
link_title = file_name or str(media.get("title") or "")
return f"[link] {link_title} {url}".rstrip(), None
if media_type == "video":
if url and rid:
return f"[video|ybres:{rid}] {file_name}".rstrip(), {"kind": "video", "url": url}
return f"[video] {file_name or url}".rstrip(), None
return f"[{media_type or 'media'}] {url or file_name}".rstrip(), None
# Per-record combined-text cap; record count is NOT capped (design §2.10.3).
FORWARD_MSG_TEXT_MAX_CHARS = 1000
@classmethod
def _walk_forward_msgs(
cls,
forward_data: dict,
) -> Iterator[Tuple[str, str, List[Dict[str, str]]]]:
"""Walk ``ForwardMsgData['msg']`` and yield ``(sender, body, refs)``.
Per-record dispatch over ``msgContent`` (text / multimedia / nested
forward / fallback); ``body`` is capped at
:attr:`FORWARD_MSG_TEXT_MAX_CHARS`. Media goes through
:meth:`_media_marker`, always building full ``[kind|ybres:RID]``
markers; ``refs`` holds that record's downloadable ``ctx.media_refs``
entries in textual order the order PatchAnchorsMiddleware relies on
(design §2.10.6). Headers / footers are the caller's job.
"""
for msg in (forward_data.get("msg") if isinstance(forward_data, dict) else None) or []:
if not isinstance(msg, dict):
continue
sender = msg.get("sender", "")
plain_text = msg.get("plainText", "")
msg_contents = msg.get("msgContent", []) or []
refs: List[Dict[str, str]] = []
if not msg_contents:
rendered = plain_text
else:
parts: List[str] = []
for mc in msg_contents:
if not isinstance(mc, dict):
continue
mc_type = mc.get("type", 0) # EnumMsgContentType
if mc_type == 1: # TEXT
parts.append(mc.get("text", ""))
elif mc_type == 2: # MULTIMEDIA
for media in mc.get("multimedia", []) or []:
if isinstance(media, dict):
marker, ref = cls._media_marker(
media, plain_text,
)
parts.append(marker)
if ref is not None:
refs.append(ref)
elif mc_type == 3: # nested FORWARD_MSG (design §2.10.10)
parts.append("[嵌套聊天记录]")
else:
if plain_text:
parts.append(plain_text)
rendered = " ".join(p for p in parts if p) or plain_text
if len(rendered) > cls.FORWARD_MSG_TEXT_MAX_CHARS:
rendered = rendered[: cls.FORWARD_MSG_TEXT_MAX_CHARS] + "…(已截断)"
yield sender, rendered, refs
# -- Prompt builders ---------------------------------------------------
@classmethod
def build_forward_text(
cls, forward_data: dict, *, ctx: InboundContext, is_dispatch: bool,
) -> str:
"""Render ``ForwardMsgData`` into forward text.
Body lines are ``发送人正文`` with full ``[kind|ybres:RID]`` media
markers preserved. When ``is_dispatch`` is true, refs are appended to
``ctx.media_refs`` for downstream resolution and a ``用户附言
{ctx.raw_text}`` footer is added; observed callers skip both since
no later middleware runs.
"""
nickname = ctx.sender_nickname or "用户"
lines = [f"当前用户的昵称为{nickname}", "以下为用户的聊天记录"]
for sender, body, refs in cls._walk_forward_msgs(forward_data):
lines.append(f"{sender}{body}")
if is_dispatch:
ctx.media_refs.extend(refs)
text = "\n".join(lines)
if is_dispatch and ctx.raw_text.strip():
text += f"\n\n用户附言:{ctx.raw_text.strip()}"
return text
class MediaResolveMiddleware(InboundMiddleware):
"""Resolve inbound media references to downloadable URLs."""
@@ -2552,6 +2273,9 @@ class MediaResolveMiddleware(InboundMiddleware):
# --- Resource download cache (keyed by resourceId) ---
# Avoids redundant downloads of the same resource within the TTL window.
# The same resourceId can be referenced multiple times in a session (own
# attachment, then quoted again, then observed in a group backfill); each
# reference otherwise triggers a fresh token exchange + download.
_resource_cache: ClassVar[Dict[str, Tuple[str, str, float]]] = {} # rid -> (local_path, mime, ts)
_RESOURCE_CACHE_TTL_S: ClassVar[int] = 24 * 60 * 60 # 24 hours
_RESOURCE_CACHE_MAX_SIZE: ClassVar[int] = 256
@@ -2727,15 +2451,6 @@ class MediaResolveMiddleware(InboundMiddleware):
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
if kind == "video":
# Yuanbao video resources carry no reliable extension; default to mp4.
local_path = cache_video_from_bytes(file_bytes)
mime = guess_mime_type(local_path) or (
content_type if content_type.startswith("video/") else "video/mp4"
)
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
# kind == "file"
if not file_name:
parsed = urllib.parse.urlparse(fetch_url)
@@ -2857,22 +2572,14 @@ class MediaResolveMiddleware(InboundMiddleware):
if not history:
return [], []
# Walk the most recent LOOKBACK messages newest→oldest so that when we
# hit the per-turn resolve cap we keep the *latest* media references,
# not the oldest ones in the window. Within a single message, also
# iterate matches in reverse so the last-added image wins on ties.
# Final ``order`` is reversed back to chronological (old→new) before
# handing off to ``_resolve_ybres_refs`` so downstream prompt insertion
# preserves natural reading order.
window = history[-OBSERVED_MEDIA_BACKFILL_LOOKBACK:]
start = max(0, len(history) - OBSERVED_MEDIA_BACKFILL_LOOKBACK)
order: List[Tuple[str, str, str]] = [] # (rid, kind, filename)
seen: set = set()
for msg in reversed(window):
for msg in history[start:]:
content = msg.get("content")
if not isinstance(content, str) or "|ybres:" not in content:
continue
matches = list(_YB_RES_REF_RE.finditer(content))
for m in reversed(matches):
for m in _YB_RES_REF_RE.finditer(content):
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
rid = m.group(2)
kind, _, filename = head.partition(":")
@@ -2888,9 +2595,6 @@ class MediaResolveMiddleware(InboundMiddleware):
if len(order) >= OBSERVED_MEDIA_BACKFILL_MAX_RESOLVE_PER_TURN:
break
# Restore chronological order (oldest→newest) for downstream resolution.
order.reverse()
if not order:
return [], []
@@ -2936,7 +2640,9 @@ class MediaResolveMiddleware(InboundMiddleware):
if not isinstance(text, str) or not text:
return paths, mimes
# Already-local media paths written by PatchAnchorsMiddleware.
# Already-local media paths written by PatchAnchorsMiddleware. The
# generic anchor regex covers every kind _patch emits (image/file today,
# video/audio if they later become resolvable) without per-kind upkeep.
seen: set = set()
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
kind = (m.group(1) or "").strip().lower()
@@ -3050,8 +2756,6 @@ class PatchAnchorsMiddleware(InboundMiddleware):
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
elif kind == "video":
replacement = f"[video: {u}]"
else:
continue
patched = (
@@ -3086,11 +2790,7 @@ class DispatchMiddleware(InboundMiddleware):
message_type=(
MessageType.DOCUMENT
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
# Coerce yuanbao-local subtypes (e.g. CHAT_RECORD) back to a
# base MessageType: chat records are deep-parsed into a text
# prompt, so TEXT is the right kind for downstream routing.
else ctx.msg_type if isinstance(ctx.msg_type, MessageType)
else MessageType.TEXT
else ctx.msg_type
),
source=ctx.source,
message_id=ctx.msg_id or None,
@@ -3189,7 +2889,6 @@ class InboundPipelineBuilder:
GroupAttributionMiddleware,
ClassifyMessageTypeMiddleware,
QuoteContextMiddleware,
ForwardedRecordsParseMiddleware,
MediaResolveMiddleware,
PatchAnchorsMiddleware,
DispatchMiddleware,
+23 -232
View File
@@ -492,29 +492,6 @@ def decode_biz_msg(data: bytes) -> dict:
# field 10: url (string)
# field 11: file_size (uint32)
# field 12: file_name (string)
# field 999: ext_map (map<string, string>) ← extension info for WeChat chat-history forwarding
# protobuf map is wire-encoded as a repeated message entry; each entry has:
# field 1: key (string)
# field 2: value (string)
# key format: wexin_forward_msg_[forward_msg_id]_[userid]
# value: base64(ForwardMsgData protobuf) ← NOT JSON; it is base64-encoded
# protobuf bytes that must be parsed with decode_forward_msg_data().
def _encode_map_entry(key: str, value: str) -> bytes:
"""Encode a single entry of a protobuf map<string, string> (field 1 key, field 2 value)."""
buf = b""
if key:
buf += _encode_field(1, WT_LEN, _encode_string(str(key)))
if value:
buf += _encode_field(2, WT_LEN, _encode_string(str(value)))
return buf
def _decode_map_entry(data: bytes) -> tuple[str, str]:
"""Decode a single entry of a protobuf map<string, string>, returning (key, value)."""
fdict = _fields_to_dict(_parse_fields(data))
return _get_string(fdict, 1), _get_string(fdict, 2)
def _encode_msg_content(content: dict) -> bytes:
@@ -541,12 +518,6 @@ def _encode_msg_content(content: dict) -> bytes:
if url:
img_buf += _encode_field(5, WT_LEN, _encode_string(url))
buf += _encode_field(8, WT_LEN, _encode_message(img_buf))
# ext_map (map<string, string>, field 999) — repeated message entries
ext_map = content.get("ext_map")
if isinstance(ext_map, dict):
for k, v in ext_map.items():
entry_bytes = _encode_map_entry(str(k), str(v))
buf += _encode_field(999, WT_LEN, _encode_message(entry_bytes))
return buf
@@ -579,14 +550,6 @@ def _decode_msg_content(data: bytes) -> dict:
imgs.append(img)
if imgs:
content["image_info_array"] = imgs
# ext_map (field 999) — decode repeated map entries into a plain dict
ext_map: dict[str, str] = {}
for entry_bytes in _get_repeated_bytes(fdict, 999):
k, v = _decode_map_entry(entry_bytes)
if k:
ext_map[k] = v
if ext_map:
content["ext_map"] = ext_map
return content
@@ -747,178 +710,9 @@ def decode_inbound_push(data: bytes) -> Optional[dict]:
# ============================================================
# WeChat forwarded chat-history parsing (ForwardMsgData)
# 出站消息编码
# ============================================================
#
# The value of ext_map["wexin_forward_msg_<id>_<userid>"] is a base64-encoded
# ForwardMsgData protobuf (NOT JSON). Structure (verified against live captures):
#
# message ForwardMsgData {
# uint32 sub_type = 1; // 1 = WeChat chat-history forward
# uint32 begin_time = 2;
# uint32 end_time = 3;
# string nick_name = 4; // forwarder's WeChat nickname
# repeated ForwardMsg msg = 5;
# }
# message ForwardMsg {
# string sender = 1;
# uint32 time = 2;
# string plainText = 3;
# repeated MsgContent msgContent = 4;
# }
# message MsgContent {
# uint32 type = 1; // 1=TEXT, 2=MULTIMEDIA, 3=nested forward
# string text = 2; // type==1
# repeated Multimedia multimedia = 3; // type==2
# }
# message Multimedia {
# string type = 1; // image / file / document / url / video
# string url = 2;
# string file_name = 4;
# uint32 file_size = 5;
# uint32 width = 6;
# uint32 height = 7;
# string media_id = 15; // can be used directly as a ybres RID
# string res_type = 24;
# }
def _decode_forward_multimedia(data: bytes) -> dict:
"""Decode a single Multimedia sub-message into the dict shape expected by _format_multimedia."""
fdict = _fields_to_dict(_parse_fields(data))
media: dict = {}
mtype = _get_string(fdict, 1)
if mtype:
media["type"] = mtype
url = _get_string(fdict, 2)
if url:
media["url"] = url
file_name = _get_string(fdict, 4)
if file_name:
media["file_name"] = file_name
file_size = _get_varint(fdict, 5)
if file_size:
media["file_size"] = file_size
media_id = _get_string(fdict, 15)
if media_id:
media["media_id"] = media_id
return media
def _decode_forward_msg_content(data: bytes) -> dict:
"""Decode a single MsgContent sub-message into {type, text?, multimedia?}."""
fdict = _fields_to_dict(_parse_fields(data))
content: dict = {"type": _get_varint(fdict, 1)}
text = _get_string(fdict, 2)
if text:
content["text"] = text
multimedia = [
_decode_forward_multimedia(b) for b in _get_repeated_bytes(fdict, 3)
]
if multimedia:
content["multimedia"] = multimedia
return content
def _decode_forward_msg(data: bytes) -> dict:
"""Decode a single ForwardMsg sub-message into {sender, plainText, msgContent}."""
fdict = _fields_to_dict(_parse_fields(data))
return {
"sender": _get_string(fdict, 1),
"time": _get_varint(fdict, 2),
"plainText": _get_string(fdict, 3),
"msgContent": [
_decode_forward_msg_content(b) for b in _get_repeated_bytes(fdict, 4)
],
}
def decode_forward_msg_data(data: bytes) -> Optional[dict]:
"""Parse ForwardMsgData protobuf bytes (the base64-decoded ext_map value).
Args:
data: ForwardMsgData protobuf bytes, after base64 decoding.
Returns:
A dict matching the structure consumed by
``ForwardedRecordsParseMiddleware.build_forward_text``
(``sub_type`` / ``nick_name`` / ``msg`` list); ``None`` on parse failure.
"""
try:
fdict = _fields_to_dict(_parse_fields(data))
return {
"sub_type": _get_varint(fdict, 1),
"begin_time": _get_varint(fdict, 2),
"end_time": _get_varint(fdict, 3),
"nick_name": _get_string(fdict, 4),
"msg": [_decode_forward_msg(b) for b in _get_repeated_bytes(fdict, 5)],
}
except Exception as e:
if DEBUG_MODE:
logger.debug("[yuanbao_proto] decode_forward_msg_data failed: %s", e)
return None
def _encode_forward_multimedia(media: dict) -> bytes:
buf = b""
for fn, key in [(1, "type"), (2, "url"), (4, "file_name"), (15, "media_id")]:
v = media.get(key, "")
if v:
buf += _encode_field(fn, WT_LEN, _encode_string(str(v)))
for fn, key in [(5, "file_size"), (6, "width"), (7, "height")]:
v = media.get(key, 0)
if v:
buf += _encode_field(fn, WT_VARINT, _encode_varint(int(v)))
return buf
def _encode_forward_msg_content(content: dict) -> bytes:
buf = _encode_field(1, WT_VARINT, _encode_varint(int(content.get("type", 0))))
text = content.get("text", "")
if text:
buf += _encode_field(2, WT_LEN, _encode_string(str(text)))
for media in content.get("multimedia") or []:
buf += _encode_field(3, WT_LEN, _encode_message(_encode_forward_multimedia(media)))
return buf
def _encode_forward_msg(msg: dict) -> bytes:
buf = b""
sender = msg.get("sender", "")
if sender:
buf += _encode_field(1, WT_LEN, _encode_string(str(sender)))
time_val = msg.get("time", 0)
if time_val:
buf += _encode_field(2, WT_VARINT, _encode_varint(int(time_val)))
plain = msg.get("plainText", "")
if plain:
buf += _encode_field(3, WT_LEN, _encode_string(str(plain)))
for mc in msg.get("msgContent") or []:
buf += _encode_field(4, WT_LEN, _encode_message(_encode_forward_msg_content(mc)))
return buf
def encode_forward_msg_data(data: dict) -> bytes:
"""Encode ForwardMsgData protobuf bytes (inverse of ``decode_forward_msg_data``).
Mainly used to build mock / test data; production code never needs to encode this.
"""
buf = _encode_field(1, WT_VARINT, _encode_varint(int(data.get("sub_type", 0))))
for fn, key in [(2, "begin_time"), (3, "end_time")]:
v = data.get(key, 0)
if v:
buf += _encode_field(fn, WT_VARINT, _encode_varint(int(v)))
nick = data.get("nick_name", "")
if nick:
buf += _encode_field(4, WT_LEN, _encode_string(str(nick)))
for msg in data.get("msg") or []:
buf += _encode_field(5, WT_LEN, _encode_message(_encode_forward_msg(msg)))
return buf
# ============================================================
# Outbound message encoding
# ============================================================
def _encode_send_c2c_req(
to_account: str,
from_account: str,
@@ -930,7 +724,7 @@ def _encode_send_c2c_req(
trace_id: str = "",
) -> bytes:
"""
Encode a SendC2CMessageReq biz payload.
编码 SendC2CMessageReq biz payload
SendC2CMessageReq fields:
1: msg_id (string)
@@ -975,7 +769,7 @@ def _encode_send_group_req(
trace_id: str = "",
) -> bytes:
"""
Encode a SendGroupMessageReq biz payload.
编码 SendGroupMessageReq biz payload
SendGroupMessageReq fields:
1: msg_id (string)
@@ -1022,20 +816,18 @@ def encode_send_c2c_message(
trace_id: str = "",
) -> bytes:
"""
Encode a C2C send-message request and return the full ConnMsg bytes
(ready to be sent over WebSocket).
编码 C2C 发消息请求返回完整 ConnMsg bytes可直接发送到 WebSocket
Args:
to_account: recipient account
msg_body: list of message-body elements; each item is
{"msg_type": str, "msg_content": dict}.
Example: [{"msg_type": "TIMTextElem", "msg_content": {"text": "hello"}}]
from_account: sender account (the bot account)
msg_id: unique message ID (req_id is used when empty)
msg_random: random number for de-duplication
msg_seq: message sequence number (optional)
group_code: filled in for the "private chat originating from a group" case
trace_id: trace ID for request tracing
to_account: 收件人账号
msg_body: 消息体列表每个元素: {"msg_type": str, "msg_content": dict}
例如: [{"msg_type": "TIMTextElem", "msg_content": {"text": "hello"}}]
from_account: 发件人账号机器人账号
msg_id: 消息唯一 ID空时使用 req_id
msg_random: 随机数防重
msg_seq: 消息序列号可选
group_code: 来自群聊的私聊场景时填写
trace_id: 链路追踪 ID
Returns:
ConnMsg bytes
@@ -1074,19 +866,18 @@ def encode_send_group_message(
trace_id: str = "",
) -> bytes:
"""
Encode a group send-message request and return the full ConnMsg bytes
(ready to be sent over WebSocket).
编码群消息发送请求返回完整 ConnMsg bytes可直接发送到 WebSocket
Args:
group_code: group ID
msg_body: list of message-body elements
from_account: sender account (the bot account)
msg_id: unique message ID
to_account: targeted recipient (usually empty)
random: random string for de-duplication
msg_seq: message sequence number
ref_msg_id: ID of the referenced (quoted) message
trace_id: trace ID for request tracing
group_code: 群号
msg_body: 消息体列表
from_account: 发件人账号机器人账号
msg_id: 消息唯一 ID
to_account: 指定接收者一般为空
random: 去重随机字符串
msg_seq: 消息序列号
ref_msg_id: 引用消息 ID
trace_id: 链路追踪 ID
Returns:
ConnMsg bytes
+2 -24
View File
@@ -4684,8 +4684,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Warn if no user allowlists are configured and open access is not opted in
_builtin_allowed_vars = (
"TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS",
"WHATSAPP_ALLOWED_USERS", "WHATSAPP_CLOUD_ALLOWED_USERS",
"SLACK_ALLOWED_USERS",
"WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS",
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
@@ -4703,8 +4702,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
_builtin_allow_all_vars = (
"TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS",
"WHATSAPP_ALLOW_ALL_USERS", "WHATSAPP_CLOUD_ALLOW_ALL_USERS",
"SLACK_ALLOW_ALL_USERS",
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
"SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS",
"MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS",
@@ -6189,18 +6187,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.warning("WhatsApp: Node.js not installed or bridge not configured")
return None
return WhatsAppAdapter(config)
elif platform == Platform.WHATSAPP_CLOUD:
from gateway.platforms.whatsapp_cloud import (
WhatsAppCloudAdapter,
check_whatsapp_cloud_requirements,
)
if not check_whatsapp_cloud_requirements():
logger.warning(
"WhatsApp Cloud: aiohttp/httpx missing — reinstall hermes-agent"
)
return None
return WhatsAppCloudAdapter(config)
elif platform == Platform.SLACK:
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
@@ -7278,9 +7264,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "usage":
return await self._handle_usage_command(event)
if canonical == "credits":
return await self._handle_credits_command(event)
if canonical == "insights":
return await self._handle_insights_command(event)
@@ -12565,11 +12548,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if interrupt_depth == 0:
agent._last_activity_ts = time.time()
agent._last_activity_desc = "starting new turn (cached)"
# Reset the SessionDB flush cursor so the new turn's messages are
# fully persisted — a stale value from the previous turn would
# cause `_flush_messages_to_session_db` to skip new rows (#44327).
if hasattr(agent, "_last_flushed_db_idx"):
agent._last_flushed_db_idx = 0
agent._api_call_count = 0
def _release_evicted_agent_soft(self, agent: Any) -> None:
-34
View File
@@ -2942,40 +2942,6 @@ class GatewaySlashCommandsMixin:
key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many"
return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)
async def _handle_credits_command(self, event: MessageEvent) -> str:
"""Handle /credits -- show Nous credit balance and the top-up handoff.
Renders the balance block + identity line + a tappable top-up URL that
opens the portal billing page with the modal open. The terminal does NOT
confirm, poll, or track payment (billing phase 2a) checkout happens in
the browser and the next /credits shows the new balance. The tappable URL
is the affordance: it works on every platform (button-capable or plain
text like SMS/email). Fetched off the event loop; fail-open.
"""
from agent.account_usage import build_credits_view
try:
view = await asyncio.to_thread(build_credits_view, markdown=True)
except Exception:
view = None
if view is None or not view.logged_in:
return t("gateway.credits.not_logged_in")
lines: list[str] = ["💳 **Nous credits**"]
for line in view.balance_lines:
if line.lstrip().startswith("📈"):
continue # drop the helper's header; we print our own
lines.append(line)
if view.identity_line:
lines.append("")
lines.append(view.identity_line)
if view.topup_url:
lines.append("")
lines.append(f"Top up: {view.topup_url}")
lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
return "\n".join(lines)
async def _handle_usage_command(self, event: MessageEvent) -> str:
"""Handle /usage command -- show token usage for the current session.
-15
View File
@@ -214,7 +214,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
gateway_only=True),
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
@@ -1044,17 +1043,6 @@ _SLACK_RESERVED_COMMANDS = frozenset({
# native slot, the alias spelling stays reachable via /hermes reset).
_SLACK_PRIORITY_ALIASES = ("btw", "bg")
# Canonical commands intentionally NOT given a native Slack slash slot. Slack
# caps apps at 50 slash commands and the registry is at that ceiling; rather
# than let the clamp silently drop whichever command sorts last (and break
# Telegram parity), we explicitly route a few low-frequency commands through
# ``/hermes <command>`` on Slack only. They remain native on every other
# surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional —
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
def _sanitize_slack_name(raw: str) -> str:
"""Convert a command name to a valid Slack slash command name.
@@ -1103,9 +1091,6 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
return
if slack_name in _SLACK_RESERVED_COMMANDS:
return
if slack_name in _SLACK_VIA_HERMES_ONLY:
# Intentionally Slack-via-/hermes only (see _SLACK_VIA_HERMES_ONLY).
return
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
return
# Slack description cap is 2000 chars; keep it short.
-18
View File
@@ -1017,15 +1017,6 @@ DEFAULT_CONFIG = {
"backend": "", # shared fallback — applies to both search and extract
"search_backend": "", # per-capability override for web_search (e.g. "searxng")
"extract_backend": "", # per-capability override for web_extract (e.g. "native")
# Grounded citations on web results:
# "auto" — results carry stable source ids; the model is instructed to
# cite inline only for research/report-style requests
# "always" — model is instructed to cite inline whenever it uses results
# "off" — no source ids, no citation guidance (pre-feature behavior)
"citations": "auto",
# Stream web_extract page summarization live into a reasoning-style
# box in the CLI (no effect on gateway/cron).
"summary_stream": True,
},
"browser": {
@@ -1438,10 +1429,6 @@ DEFAULT_CONFIG = {
# behaves badly with replayed scrollback.
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the question and decision survive the
# panel repaint. Set false to keep scrollback untouched.
"persist_prompts": True,
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
# File-mutation verifier footer. When true (default), the agent
# appends a one-line advisory to its final response whenever a
@@ -1451,11 +1438,6 @@ DEFAULT_CONFIG = {
# class of over-claim that otherwise forces users to run
# `git status` to verify edits landed. Set false to suppress.
"file_mutation_verifier": True,
# Nous credits status-bar notices (usage bands, grant-spent, depleted /
# restored). When false, no credits notices are emitted — balance data
# is still captured and /usage keeps working. Off switch for sub +
# top-up users who find the gauge noisy.
"credits_notices": True,
# Turn-completion explainer. When true (default), the agent appends a
# one-line explanation to its final response whenever a turn ends
# abnormally with no usable reply — empty content after retries, a
+2 -68
View File
@@ -2514,25 +2514,6 @@ def cmd_whatsapp(args):
print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.")
def cmd_whatsapp_cloud(args):
"""Set up WhatsApp Business Cloud API (official Meta integration).
Walks the user through the Meta-side credentials (Phone Number ID,
Access Token, App Secret, optional App/WABA IDs) plus webhook
configuration. Includes field-shape validators that catch the most
common setup mistakes (e.g. pasting a phone number into the Phone
Number ID field).
Distinct from ``hermes whatsapp`` (the Baileys bridge wizard) the
two adapters are complementary, not alternatives. See
``hermes_cli/setup_whatsapp_cloud.py``.
"""
_require_tty("whatsapp-cloud")
from hermes_cli.setup_whatsapp_cloud import run_whatsapp_cloud_setup
return run_whatsapp_cloud_setup()
def cmd_setup(args):
"""Interactive setup wizard."""
from hermes_cli.setup import run_setup_wizard
@@ -9559,7 +9540,6 @@ def _coalesce_session_name_args(argv: list) -> list:
"gateway",
"setup",
"whatsapp",
"whatsapp-cloud",
"login",
"logout",
"auth",
@@ -10352,8 +10332,6 @@ def cmd_dashboard(args):
_launch_profile not in ("default", "custom")
and not getattr(args, "isolated", False)
and not getattr(args, "open_profile", "")
# Desktop pool backends are intentionally per-profile.
and os.environ.get("HERMES_DESKTOP") != "1"
):
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
if _dashboard_listening(args.host, args.port):
@@ -10388,16 +10366,7 @@ def cmd_dashboard(args):
env = os.environ.copy()
# Drop the profile HERMES_HOME so the child binds the machine root.
env.pop("HERMES_HOME", None)
# On Windows, os.execvpe() does not truly replace the process — it
# spawns via CreateProcess then the parent exits. Under Python 3.14+
# this can crash with STATUS_ACCESS_VIOLATION (0xC0000005) when
# re-executing the dashboard for a non-default profile. Use
# subprocess.Popen + sys.exit() on Windows to avoid the crash.
if sys.platform == "win32":
proc = subprocess.Popen(reexec_argv, env=env)
sys.exit(proc.wait())
else:
os.execvpe(sys.executable, reexec_argv, env)
os.execvpe(sys.executable, reexec_argv, env)
# Attach gui.log early so dashboard startup/build failures are captured in
# the same logs directory as every other Hermes surface.
@@ -10462,26 +10431,6 @@ def cmd_dashboard(args):
# the missing-provider state if it matters.
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)
# Desktop chat uses the dashboard's in-process /api/ws gateway, which builds
# agents via tui_gateway.server._make_agent. That path only snapshots the
# tool registry — it never starts MCP discovery (the stdio TUI does that in
# tui_gateway/entry.py, which the dashboard process doesn't run). Without
# this, a profile's configured MCP servers never connect, so desktop
# sessions show no MCP tools. Spawn discovery in the background here so a
# slow/dead server can't block dashboard startup.
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger,
thread_name="dashboard-mcp-discovery",
)
except Exception:
logger.debug(
"Background MCP tool discovery failed at dashboard startup",
exc_info=True,
)
from hermes_cli.web_server import start_server
# The in-browser Chat tab (the embedded TUI over PTY/WebSocket) is always
@@ -10562,7 +10511,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"prompt-size",
"send", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "whatsapp-cloud", "chat", "secrets", "security",
"version", "webhook", "whatsapp", "chat", "secrets", "security",
# Help-ish invocations — plugin commands not being listed in
# top-level --help is an acceptable trade-off for skipping an
# expensive eager import of every bundled plugin module.
@@ -11223,21 +11172,6 @@ def main():
# =========================================================================
build_whatsapp_parser(subparsers, cmd_whatsapp=cmd_whatsapp)
# =========================================================================
# whatsapp-cloud command (official Meta Cloud API; complement to Baileys)
# =========================================================================
whatsapp_cloud_parser = subparsers.add_parser(
"whatsapp-cloud",
help="Set up WhatsApp Business Cloud API integration",
description=(
"Configure the official Meta WhatsApp Business Cloud API "
"adapter (Business account required, public webhook URL "
"required). Distinct from `hermes whatsapp` which sets up "
"the Baileys bridge for personal accounts."
),
)
whatsapp_cloud_parser.set_defaults(func=cmd_whatsapp_cloud)
# =========================================================================
# slack command (parser built in hermes_cli/subcommands/slack.py)
# =========================================================================
+6 -31
View File
@@ -80,8 +80,6 @@ class NousPortalAccountInfo:
fresh: bool
user_id: Optional[str] = None
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
client_id: Optional[str] = None
product_id: Optional[str] = None
nous_client: Optional[str] = None
@@ -142,29 +140,6 @@ def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None
return f"{base.rstrip('/')}/billing"
def nous_portal_topup_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
"""Return the portal top-up URL that auto-opens the top-up modal.
Prefers the org-pinned page ``{base}/orgs/{slug}/billing?topup=open`` (skips
the legacy shim's re-resolution + multi-org disambiguation). Falls back to the
legacy ``{base}/billing?topup=open`` when the account has no ``org_slug`` (the
portal's ``slug`` is nullable; the legacy page forwards the param through to
the org-pinned page). Never builds ``/orgs/None/billing``.
The ``?topup=open`` query is the NAS enabler that lands the user in the
top-up flow rather than just on the billing page.
"""
base_billing = nous_portal_billing_url(account_info) # {base}/billing
base = base_billing[: -len("/billing")] # strip the trailing /billing
slug = getattr(account_info, "org_slug", None) if account_info is not None else None
if isinstance(slug, str) and slug.strip():
from urllib.parse import quote
return f"{base}/orgs/{quote(slug.strip(), safe='')}/billing?topup=open"
return f"{base}/billing?topup=open"
def format_nous_portal_entitlement_message(
account_info: Optional[NousPortalAccountInfo],
*,
@@ -632,10 +607,12 @@ def _info_from_account_payload(
state: dict[str, Any],
portal_base_url: Optional[str],
) -> NousPortalAccountInfo:
raw_user = payload.get("user")
user: dict[str, Any] = raw_user if isinstance(raw_user, dict) else {}
raw_org = payload.get("organisation")
organisation: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
organisation = (
payload.get("organisation")
if isinstance(payload.get("organisation"), dict)
else {}
)
subscription = _subscription_from_payload(payload.get("subscription"))
access = _paid_service_access_from_payload(payload.get("paid_service_access"))
paid_access = access.allowed if access else None
@@ -647,8 +624,6 @@ def _info_from_account_payload(
source="account_api",
fresh=True,
org_id=_coerce_str(organisation.get("id")) or (access.organisation_id if access else None),
org_slug=_coerce_str(organisation.get("slug")),
org_name=_coerce_str(organisation.get("name")),
client_id=_coerce_str(state.get("client_id")),
portal_base_url=portal_base_url,
inference_base_url=_coerce_str(state.get("inference_base_url")),
+2 -160
View File
@@ -39,9 +39,6 @@ MANAGED_FEATURE_COVERAGE_CATEGORY: Dict[str, str] = {
"image_gen": "fal",
"video_gen": "fal-video",
"tts": "openai-audio",
# STT shares the TTS coverage category: both ride the managed
# "openai-audio" gateway endpoint (speech + transcriptions).
"stt": "openai-audio",
"browser": "browser-use",
"modal": "modal",
}
@@ -88,10 +85,6 @@ class NousSubscriptionFeatures:
def tts(self) -> NousFeatureState:
return self.features["tts"]
@property
def stt(self) -> NousFeatureState:
return self.features["stt"]
@property
def browser(self) -> NousFeatureState:
return self.features["browser"]
@@ -105,7 +98,7 @@ class NousSubscriptionFeatures:
return self.features["modal"]
def items(self) -> Iterable[NousFeatureState]:
ordered = ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal")
for key in ordered:
yield self.features[key]
@@ -216,34 +209,6 @@ def _tts_label(current_provider: str) -> str:
return mapping.get(current_provider or "edge", current_provider or "Edge TTS")
def _stt_label(current_provider: str) -> str:
mapping = {
"openai": "OpenAI Whisper",
"groq": "Groq Whisper",
"mistral": "Mistral Voxtral Transcribe",
"local": "Local faster-whisper",
}
return mapping.get(current_provider or "local", current_provider or "Local faster-whisper")
def _local_stt_backend_available() -> bool:
"""Whether a local STT backend could serve transcription right now.
True when faster-whisper is importable or a custom local STT command
is configured. Used both for feature detection and to stop
``apply_nous_managed_defaults`` from flipping a working local setup
to the managed gateway.
"""
if get_env_value("HERMES_LOCAL_STT_COMMAND"):
return True
try:
from tools.transcription_tools import _HAS_FASTER_WHISPER
return bool(_HAS_FASTER_WHISPER)
except Exception:
return False
def _resolve_browser_feature_state(
*,
browser_tool_enabled: bool,
@@ -362,7 +327,6 @@ def get_nous_subscription_features(
web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {}
tts_cfg = config.get("tts") if isinstance(config.get("tts"), dict) else {}
stt_cfg = config.get("stt") if isinstance(config.get("stt"), dict) else {}
browser_cfg = config.get("browser") if isinstance(config.get("browser"), dict) else {}
terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {}
@@ -372,11 +336,6 @@ def get_nous_subscription_features(
web_search_backend = str(web_cfg.get("search_backend") or "").strip().lower()
web_extract_backend = str(web_cfg.get("extract_backend") or "").strip().lower()
tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower()
# STT default is "local" (faster-whisper) per DEFAULT_CONFIG, which
# requires `pip install faster-whisper`. For Nous subscribers we'd
# rather route through the managed OpenAI audio gateway — see
# apply_nous_managed_defaults below.
stt_provider = str(stt_cfg.get("provider") or "local").strip().lower()
browser_provider_explicit = "cloud_provider" in browser_cfg
browser_provider = normalize_browser_cloud_provider(
browser_cfg.get("cloud_provider") if browser_provider_explicit else None
@@ -393,7 +352,6 @@ def get_nous_subscription_features(
# prevent gateway routing.
web_use_gateway = _uses_gateway(web_cfg)
tts_use_gateway = _uses_gateway(tts_cfg)
stt_use_gateway = _uses_gateway(stt_cfg)
browser_use_gateway = _uses_gateway(browser_cfg)
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
image_use_gateway = _uses_gateway(image_gen_cfg)
@@ -414,22 +372,6 @@ def get_nous_subscription_features(
direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
direct_modal = has_direct_modal_credentials()
# STT direct providers. OpenAI Whisper reuses the same audio key as
# OpenAI TTS — resolve_openai_audio_api_key() reads VOICE_TOOLS_OPENAI_KEY
# and falls back to OPENAI_API_KEY. The local provider's "direct"
# signal is whether faster-whisper is importable; we lazy-import so
# this module stays cheap on the happy path.
direct_openai_stt = bool(resolve_openai_audio_api_key())
direct_groq_stt = bool(get_env_value("GROQ_API_KEY"))
direct_mistral_stt = bool(get_env_value("MISTRAL_API_KEY"))
try:
from tools.transcription_tools import _HAS_FASTER_WHISPER
local_stt_available = bool(_HAS_FASTER_WHISPER) or bool(
get_env_value("HERMES_LOCAL_STT_COMMAND")
)
except Exception:
local_stt_available = bool(get_env_value("HERMES_LOCAL_STT_COMMAND"))
# When use_gateway is set, suppress direct credentials for managed detection
if web_use_gateway:
direct_firecrawl = False
@@ -443,11 +385,6 @@ def get_nous_subscription_features(
if tts_use_gateway:
direct_openai_tts = False
direct_elevenlabs = False
if stt_use_gateway:
direct_openai_stt = False
direct_groq_stt = False
direct_mistral_stt = False
local_stt_available = False
if browser_use_gateway:
direct_browser_use = False
direct_browserbase = False
@@ -479,10 +416,6 @@ def get_nous_subscription_features(
and is_managed_tool_gateway_ready("openai-audio")
and _entitled_for("openai-audio")
)
# STT and TTS share the same managed gateway endpoint ("openai-audio")
# because the OpenAI audio API covers both /audio/speech (TTS) and
# /audio/transcriptions (STT). One probe (and one entitlement), used by both.
managed_stt_available = managed_tts_available
managed_browser_available = (
managed_tools_flag
and nous_auth_present
@@ -548,24 +481,6 @@ def get_nous_subscription_features(
)
tts_active = bool(tts_tool_enabled and tts_available)
# STT availability per provider. Unlike TTS, STT isn't a model-callable
# tool — the gateway voice middleware calls it on every inbound voice
# message — so toolset_enabled is N/A and we treat stt as always
# "enabled" if a usable provider is configured.
stt_current_provider = stt_provider or "local"
stt_managed = (
stt_current_provider == "openai"
and managed_stt_available
and not direct_openai_stt
)
stt_available = bool(
(stt_current_provider == "local" and local_stt_available)
or (stt_current_provider == "openai" and (managed_stt_available or direct_openai_stt))
or (stt_current_provider == "groq" and direct_groq_stt)
or (stt_current_provider == "mistral" and direct_mistral_stt)
)
stt_active = stt_available
browser_local_available = _has_agent_browser()
browser_local_runnable = _local_browser_runnable()
(
@@ -622,13 +537,6 @@ def get_nous_subscription_features(
if isinstance(raw_tts_cfg, dict) and "provider" in raw_tts_cfg:
tts_explicit_configured = tts_provider not in {"", "edge"}
# STT considers any non-default provider explicit. "local" is the
# DEFAULT_CONFIG seed, so seeing it doesn't mean the user picked it.
stt_explicit_configured = False
raw_stt_cfg = config.get("stt")
if isinstance(raw_stt_cfg, dict) and "provider" in raw_stt_cfg:
stt_explicit_configured = stt_provider not in {"", "local"}
features = {
"web": NousFeatureState(
key="web",
@@ -678,21 +586,6 @@ def get_nous_subscription_features(
current_provider=_tts_label(tts_current_provider),
explicit_configured=tts_explicit_configured,
),
"stt": NousFeatureState(
key="stt",
label="Speech-to-text",
included_by_default=True,
available=stt_available,
active=stt_active,
managed_by_nous=stt_managed,
direct_override=stt_active and not stt_managed,
# STT isn't toolset-gated (gateway middleware calls it
# unconditionally on inbound voice), so report True so the
# status display doesn't flag it as "tool disabled".
toolset_enabled=True,
current_provider=_stt_label(stt_current_provider),
explicit_configured=stt_explicit_configured,
),
"browser": NousFeatureState(
key="browser",
label="Browser automation",
@@ -760,11 +653,6 @@ def apply_nous_managed_defaults(
tts_cfg = {}
config["tts"] = tts_cfg
stt_cfg = config.get("stt")
if not isinstance(stt_cfg, dict):
stt_cfg = {}
config["stt"] = stt_cfg
browser_cfg = config.get("browser")
if not isinstance(browser_cfg, dict):
browser_cfg = {}
@@ -786,30 +674,6 @@ def apply_nous_managed_defaults(
tts_cfg["provider"] = "openai"
changed.add("tts")
# STT: same pattern as TTS. The DEFAULT_CONFIG seed is "local"
# (requires `pip install faster-whisper`); for Nous subscribers we
# flip it to "openai" so the managed audio gateway handles transcription
# via the same auth as TTS. Skipped when the user has explicitly
# configured STT, has direct credentials for a non-managed provider,
# has a working local backend (faster-whisper installed or a custom
# local command — strong intent signal that "local" was a choice, not
# just the DEFAULT_CONFIG seed), or isn't entitled to the managed
# "openai-audio" category (flipping would point at a gateway that
# refuses them, silently breaking voice transcription).
if (
not features.stt.explicit_configured
and not _local_stt_backend_available()
and not (
resolve_openai_audio_api_key()
or get_env_value("GROQ_API_KEY")
or get_env_value("MISTRAL_API_KEY")
)
and features.account_info is not None
and features.account_info.tool_gateway_entitled_for("openai-audio")
):
stt_cfg["provider"] = "openai"
changed.add("stt")
if "browser" in selected_toolsets and not features.browser.explicit_configured and not (
get_env_value("BROWSER_USE_API_KEY")
or get_env_value("BROWSERBASE_API_KEY")
@@ -852,7 +716,6 @@ _GATEWAY_TOOL_LABELS = {
"image_gen": "Image generation (FAL)",
"video_gen": "Video generation (FAL)",
"tts": "Text-to-speech (OpenAI TTS)",
"stt": "Speech-to-text (OpenAI Whisper)",
"browser": "Browser automation (Browser Use)",
}
@@ -874,15 +737,6 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
),
# STT direct credentials. OpenAI Whisper shares the audio key
# with TTS via resolve_openai_audio_api_key() — counting it here
# too is intentional: if the user has an OpenAI audio key they
# don't need the gateway for either.
"stt": bool(
resolve_openai_audio_api_key()
or get_env_value("GROQ_API_KEY")
or get_env_value("MISTRAL_API_KEY")
),
"browser": bool(
get_env_value("BROWSER_USE_API_KEY")
or (get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID"))
@@ -895,11 +749,10 @@ _GATEWAY_DIRECT_LABELS = {
"image_gen": "FAL key",
"video_gen": "FAL key",
"tts": "OpenAI/ElevenLabs key",
"stt": "OpenAI/Groq/Mistral key",
"browser": "Browser Use/Browserbase key",
}
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "stt", "browser")
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser")
def get_gateway_eligible_tools(
@@ -945,7 +798,6 @@ def get_gateway_eligible_tools(
"image_gen": _uses_gateway(config.get("image_gen")),
"video_gen": _uses_gateway(config.get("video_gen")),
"tts": _uses_gateway(config.get("tts")),
"stt": _uses_gateway(config.get("stt")),
"browser": _uses_gateway(config.get("browser")),
}
@@ -992,11 +844,6 @@ def apply_gateway_defaults(
tts_cfg = {}
config["tts"] = tts_cfg
stt_cfg = config.get("stt")
if not isinstance(stt_cfg, dict):
stt_cfg = {}
config["stt"] = stt_cfg
browser_cfg = config.get("browser")
if not isinstance(browser_cfg, dict):
browser_cfg = {}
@@ -1012,11 +859,6 @@ def apply_gateway_defaults(
tts_cfg["use_gateway"] = True
changed.add("tts")
if "stt" in tool_keys:
stt_cfg["provider"] = "openai"
stt_cfg["use_gateway"] = True
changed.add("stt")
if "browser" in tool_keys:
browser_cfg["cloud_provider"] = "browser-use"
browser_cfg["use_gateway"] = True
-1
View File
@@ -24,7 +24,6 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
("discord", PlatformInfo(label="💬 Discord", default_toolset="hermes-discord")),
("slack", PlatformInfo(label="💼 Slack", default_toolset="hermes-slack")),
("whatsapp", PlatformInfo(label="📱 WhatsApp", default_toolset="hermes-whatsapp")),
("whatsapp_cloud", PlatformInfo(label="📱 WhatsApp Business (Cloud)", default_toolset="hermes-whatsapp")),
("signal", PlatformInfo(label="📡 Signal", default_toolset="hermes-signal")),
("bluebubbles", PlatformInfo(label="💙 BlueBubbles", default_toolset="hermes-bluebubbles")),
("email", PlatformInfo(label="📧 Email", default_toolset="hermes-email")),
+29 -108
View File
@@ -821,64 +821,6 @@ class PluginContext:
name,
)
# -- slack action handler registration ----------------------------------
def register_slack_action_handler(
self,
action_id: Any,
callback: Callable,
) -> None:
"""Register a Slack Block Kit action handler from a plugin.
Hermes' Slack adapter wires registered handlers into its
``slack_bolt.AsyncApp`` at connect time. The callback is invoked
when a user clicks a button (or interacts with another Block Kit
action element) whose ``action_id`` matches.
Callback signature follows the slack_bolt convention::
async def handler(ack, body, action) -> None:
await ack() # required, within 3 seconds
...
Args:
action_id: Whatever ``slack_bolt.App.action()`` accepts
a literal ``action_id`` string, a compiled ``re.Pattern``
for matching multiple ids, or a constraint dict
(e.g. ``{"action_id": "...", "block_id": "..."}``).
callback: Async callable receiving ``(ack, body, action)``.
Raises:
ValueError: if ``callback`` is not callable, or ``action_id``
is empty/None.
Example::
async def _on_approve(ack, body, action):
await ack()
# apply some workflow keyed on action["value"]
ctx.register_slack_action_handler("inbox_sweep_approve", _on_approve)
"""
if not callable(callback):
raise ValueError(
f"Plugin '{self.manifest.name}' tried to register a Slack "
f"action handler with a non-callable callback."
)
if action_id is None or (isinstance(action_id, str) and not action_id.strip()):
raise ValueError(
f"Plugin '{self.manifest.name}' tried to register a Slack "
f"action handler with an empty action_id."
)
self._manager._slack_action_handlers.append(
(action_id, callback, self.manifest.name)
)
logger.debug(
"Plugin %s registered Slack action handler: %s",
self.manifest.name,
action_id,
)
# -- hook registration --------------------------------------------------
# -- auxiliary task registration ---------------------------------------
@@ -1103,13 +1045,6 @@ class PluginManager:
# Plugin-registered auxiliary tasks: key → {key, display_name,
# description, defaults, plugin}. See PluginContext.register_auxiliary_task.
self._aux_tasks: Dict[str, Dict[str, Any]] = {}
# Slack Block Kit action handlers registered by plugins. Each entry
# is (matcher, callback, plugin_name); the Slack adapter wires them
# into its slack_bolt App at connect() time. ``matcher`` is whatever
# ``app.action()`` accepts (a literal action_id string, a compiled
# ``re.Pattern``, or a constraint dict); ``callback`` is an async
# function with the slack_bolt signature ``(ack, body, action)``.
self._slack_action_handlers: List[tuple] = []
# -----------------------------------------------------------------------
# Public
@@ -1129,12 +1064,10 @@ class PluginManager:
self._hooks.clear()
self._middleware.clear()
self._plugin_tool_names.clear()
self._plugin_platform_names.clear()
self._cli_commands.clear()
self._plugin_commands.clear()
self._plugin_skills.clear()
self._aux_tasks.clear()
self._slack_action_handlers.clear()
self._context_engine = None
# Set the flag up front as a re-entrancy guard (a plugin's register()
# can transitively trigger discovery again), but reset it if the sweep
@@ -1532,35 +1465,39 @@ class PluginManager:
logger.warning("Plugin '%s' has no register() function", manifest.name)
else:
ctx = PluginContext(manifest, self)
# Snapshot registry state BEFORE register() so each registry's
# attribution counts only what THIS plugin actually added.
# The previous approach diffed names against all already-loaded
# plugins, which mis-credited a plugin that registered a hook /
# middleware / tool name an earlier plugin had already used:
# the shared name was attributed to the first plugin only, so
# later plugins under-reported in `hermes plugins list`.
_tools_before = set(self._plugin_tool_names)
_hook_counts_before = {
h: len(cbs) for h, cbs in self._hooks.items()
}
_mw_counts_before = {
kind: len(cbs) for kind, cbs in self._middleware.items()
}
register_fn(ctx)
loaded.tools_registered = [
t for t in self._plugin_tool_names
if t not in _tools_before
]
loaded.hooks_registered = [
h
for h, cbs in self._hooks.items()
if len(cbs) > _hook_counts_before.get(h, 0)
]
loaded.middleware_registered = [
kind
for kind, cbs in self._middleware.items()
if len(cbs) > _mw_counts_before.get(kind, 0)
if t not in {
n
for name, p in self._plugins.items()
for n in p.tools_registered
}
]
loaded.hooks_registered = list(
{
h
for h, cbs in self._hooks.items()
if cbs # non-empty
}
- {
h
for name, p in self._plugins.items()
for h in p.hooks_registered
}
)
loaded.middleware_registered = list(
{
kind
for kind, cbs in self._middleware.items()
if cbs
}
- {
kind
for name, p in self._plugins.items()
for kind in p.middleware_registered
}
)
loaded.commands_registered = [
c for c in self._plugin_commands
if self._plugin_commands[c].get("plugin") == manifest.name
@@ -1715,22 +1652,6 @@ class PluginManager:
)
return results
# -----------------------------------------------------------------------
# Slack action handler accessor
# -----------------------------------------------------------------------
def get_slack_action_handlers(self) -> List[tuple]:
"""Return the list of plugin-registered Slack action handlers.
Each entry is a ``(action_id, callback, plugin_name)`` tuple.
Consumed by the Slack adapter at connect time to wire callbacks
into its ``slack_bolt.AsyncApp``.
Plugins register handlers via
:meth:`PluginContext.register_slack_action_handler`.
"""
return list(self._slack_action_handlers)
# -----------------------------------------------------------------------
# Introspection
# -----------------------------------------------------------------------
-19
View File
@@ -835,25 +835,6 @@ def create_profile(
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
# Seed an empty .env so the profile has its own credentials file from
# day one. Without it, profile-scoped env writes (dashboard Channels /
# Keys pages, `hermes -p <name> auth add`) had no file until first
# write, and the profile silently inherited API keys from the shell
# environment — users reasonably read that as "the new profile reads
# the root .env". Skipped when --clone/--clone-all already copied one.
env_path = profile_dir / ".env"
if not env_path.exists():
try:
env_path.write_text(
"# Per-profile secrets for this Hermes profile.\n"
"# API keys and tokens set here override the shell environment.\n"
"# Behavioral settings belong in config.yaml, not here.\n",
encoding="utf-8",
)
os.chmod(str(env_path), 0o600)
except OSError:
pass # best-effort — save_env_value creates the file on demand
# Seed a default SOUL.md so the user has a file to customize immediately.
# Skipped when the profile already has one (from --clone / --clone-all).
soul_path = profile_dir / "SOUL.md"
-541
View File
@@ -1,541 +0,0 @@
"""
Interactive setup wizard for the WhatsApp Cloud API adapter.
Entry point: ``hermes whatsapp-cloud`` (dispatched from
``cmd_whatsapp_cloud`` in ``hermes_cli/main.py``).
Walks the user through the 6 credentials Meta requires + recipient
allowlist, auto-generates the verify token, and prints exact follow-up
instructions for the parts that can't happen inside the wizard process
(starting cloudflared, starting the gateway, configuring Meta's
webhook dashboard, adding their phone to the recipient list).
Heavy emphasis on field-shape validation to catch the most common
configuration mistakes:
- Putting the actual phone number in ``WHATSAPP_CLOUD_PHONE_NUMBER_ID``
(the field expects Meta's 15-17 digit internal ID, not a phone number).
This is the #1 trap — caught us during Phase 3 live testing.
- Pasting tokens with trailing whitespace.
- Pasting an OpenAI / Slack / GitHub key by mistake.
- Confusing App ID with WABA ID with Phone Number ID.
Each prompt has contextual help showing exactly where to find the value
in Meta's App Dashboard, with a one-line description and the field's
expected shape ("starts with EAA", "15-17 digits", "32 hex chars", etc.).
The wizard intentionally does NOT smoke-test the webhook itself the
Hermes gateway and the cloudflared tunnel both run in separate
processes the user starts AFTER this wizard exits, so any in-wizard
probe would fail by design. Instead the final SETUP COMPLETE block
prints the exact curl command the user can run from a third terminal
to verify the loop end-to-end once everything's running.
"""
from __future__ import annotations
import re
import secrets
import sys
from typing import Optional
# ---------------------------------------------------------------------------
# Field-shape validators
# ---------------------------------------------------------------------------
#
# Each validator returns (ok, reason_if_not_ok). The wizard uses them to
# reject obviously-malformed input before saving — saves users a round
# trip with Meta's 401 / 400 errors.
def _validate_phone_number_id(value: str) -> tuple[bool, Optional[str]]:
"""Phone Number ID is a 15-17 digit numeric ID assigned by Meta.
It's NOT a phone number. The #1 setup mistake is pasting the actual
phone number (e.g. ``15556422442``) into this field that's only
10-11 digits and gets rejected by Graph as "Object with ID does
not exist."
"""
if not value:
return False, "Phone Number ID is required"
s = value.strip()
if not s.isdigit():
return False, "Phone Number ID must be numeric (no '+', spaces, or dashes)"
# Real phone numbers are 10-11 digits (US/CA country code + area code
# + 7 digits). Meta's internal IDs are 15-17 digits. If we see a
# phone-number-sized value, the user almost certainly pasted the
# phone number by mistake.
if 10 <= len(s) <= 12:
return False, (
"That looks like a phone number — but this field needs the "
"Phone Number ID (Meta's internal ID, 15-17 digits, e.g. "
"'7794189252778687'). Look just BELOW the 'From' dropdown in "
"API Setup → it's labelled 'Phone number ID'."
)
if len(s) < 13:
return False, "Phone Number ID looks too short (expected 13-18 digits)"
if len(s) > 20:
return False, "Phone Number ID looks too long (expected 13-18 digits)"
return True, None
def _validate_waba_id(value: str) -> tuple[bool, Optional[str]]:
"""WABA ID is numeric, similar length range as Phone Number ID."""
if not value:
return False, "WABA ID is required"
s = value.strip()
if not s.isdigit():
return False, "WABA ID must be numeric"
if len(s) < 10 or len(s) > 25:
return False, "WABA ID looks wrong (expected 10-25 digits)"
return True, None
def _validate_app_id(value: str) -> tuple[bool, Optional[str]]:
"""Meta App ID is numeric, typically 15-16 digits."""
if not value:
return False, "App ID is required"
s = value.strip()
if not s.isdigit():
return False, "App ID must be numeric"
if len(s) < 13 or len(s) > 20:
return False, "App ID looks wrong (expected 15-16 digits)"
return True, None
def _validate_app_secret(value: str) -> tuple[bool, Optional[str]]:
"""App Secret is a 32-character lowercase hex string."""
if not value:
return False, "App Secret is required"
s = value.strip()
if not re.fullmatch(r"[0-9a-f]+", s.lower()):
return False, (
"App Secret should be a hex string (only digits 0-9 and "
"letters a-f). Make sure you copied the 'App secret' from "
"Settings → Basic, not some other token."
)
if len(s) != 32:
return False, f"App Secret should be exactly 32 hex characters (got {len(s)})"
return True, None
def _validate_access_token(value: str) -> tuple[bool, Optional[str]]:
"""Meta access tokens start with ``EAA`` and are 100-300+ characters.
Both temp tokens (24h) and System User permanent tokens share this
prefix. We don't try to distinguish them.
"""
if not value:
return False, "Access token is required"
s = value.strip()
if not s.startswith("EAA"):
# Diagnose common paste mistakes
if s.startswith("sk-"):
return False, (
"That's an OpenAI key (starts with 'sk-'), not a Meta "
"WhatsApp access token. Meta tokens start with 'EAA'."
)
if s.startswith("xoxb-") or s.startswith("xoxp-"):
return False, (
"That's a Slack token, not a Meta WhatsApp access token. "
"Meta tokens start with 'EAA'."
)
if s.startswith("ghp_") or s.startswith("gho_"):
return False, (
"That's a GitHub token, not a Meta WhatsApp access "
"token. Meta tokens start with 'EAA'."
)
return False, (
"Meta WhatsApp access tokens start with 'EAA'. Check that "
"you're copying from the right place (API Setup → 'Generate "
"access token', or Business Settings → System Users → "
"'Generate token' for a permanent one)."
)
if len(s) < 100:
return False, f"Access token looks too short ({len(s)} chars, expected 100+)"
return True, None
# ---------------------------------------------------------------------------
# Prompt helpers
# ---------------------------------------------------------------------------
def _prompt(message: str, default: Optional[str] = None, secret: bool = False) -> str:
"""Read one line of input. Returns "" on EOF / Ctrl+C / empty input.
The ``default`` parameter is shown to the user but NOT auto-applied
on empty input callers handle the "user kept existing" case
explicitly so they can distinguish between a real value and a
display preview (e.g. ``"abc12345..."`` for masked secrets).
``secret=True`` reads via ``getpass`` so credentials are not echoed
to the terminal (or left in scrollback).
"""
try:
suffix = f" [{default}]" if default else ""
if secret and sys.stdin.isatty():
import getpass
raw = getpass.getpass(f"{message}{suffix} (input hidden): ").strip()
else:
raw = input(f"{message}{suffix}: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return ""
return raw
def _prompt_validated(
message: str,
validator,
*,
current: Optional[str] = None,
help_text: Optional[str] = None,
secret: bool = False,
) -> Optional[str]:
"""Repeat the prompt until the user enters a valid value or aborts.
Returns the validated value, or None if the user gave up (empty
response after an error, or Ctrl+C). ``current`` is shown as a
default for re-runs of the wizard with existing config.
"""
if help_text:
for line in help_text.strip().splitlines():
print(f" {line}")
attempts = 0
while True:
attempts += 1
value = _prompt(f"{message}", default=current, secret=secret)
if not value:
return None
ok, reason = validator(value)
if ok:
return value.strip()
print(f"{reason}")
if attempts >= 3:
try:
cont = input(" Try again, or press Enter to skip: ").strip()
except (EOFError, KeyboardInterrupt):
return None
if not cont:
return None
attempts = 0
# ---------------------------------------------------------------------------
# Wizard
# ---------------------------------------------------------------------------
def run_whatsapp_cloud_setup() -> int:
"""Interactive wizard for the WhatsApp Cloud API adapter.
Returns 0 on full success, 1 on user abort, 2 on partial completion
(some fields written but the user bailed before finishing).
"""
from hermes_cli.config import get_env_value, save_env_value
print()
print("⚕ WhatsApp Business Cloud API Setup")
print("=" * 50)
print()
print("This wizard configures Hermes to talk to WhatsApp via Meta's")
print("official Cloud API. It's the production-grade path:")
print()
print(" • No QR codes, no Node.js bridge subprocess")
print(" • Stable connection — no account-ban risk")
print(" • Business account required (not personal WhatsApp)")
print(" • Public webhook URL required (Cloudflare Tunnel, ngrok,")
print(" or your own reverse proxy with TLS)")
print()
print("If you don't have a Meta app set up yet, follow these steps")
print("FIRST, then come back and re-run this wizard:")
print()
print(" 1. https://developers.facebook.com/apps → Create App")
print("'Connect with customers through WhatsApp'")
print(" 2. App Dashboard → WhatsApp → API Setup")
print(" 3. Click 'Generate access token' (temp 24h token is fine to")
print(" start; switch to a System User permanent token later)")
print()
try:
proceed = input("Press Enter to continue, or Ctrl+C to abort... ").strip()
except (EOFError, KeyboardInterrupt):
print("\nSetup cancelled.")
return 1
print()
print("" * 50)
print("STEP 1 — Phone Number ID")
print("" * 50)
current_phone_id = get_env_value("WHATSAPP_CLOUD_PHONE_NUMBER_ID") or None
phone_id = _prompt_validated(
"Phone Number ID",
_validate_phone_number_id,
current=current_phone_id,
help_text=(
"Found in: App Dashboard → WhatsApp → API Setup, in the\n"
"'Send and receive messages' section.\n"
"Look BELOW the 'From' dropdown — there's a 'Phone number ID'\n"
"line with the value (15-17 digits, e.g. '7794189252778687').\n"
"It is NOT the phone number itself (+1 555-...). That's the\n"
"single most common setup mistake."
),
)
if not phone_id:
if current_phone_id:
phone_id = current_phone_id
print(f" ✓ Keeping existing: {phone_id}")
else:
print("\n✗ Phone Number ID is required. Aborting.")
return 1
else:
save_env_value("WHATSAPP_CLOUD_PHONE_NUMBER_ID", phone_id)
print(f" ✓ Saved: {phone_id}")
print()
print("" * 50)
print("STEP 2 — Access Token")
print("" * 50)
current_token = get_env_value("WHATSAPP_CLOUD_ACCESS_TOKEN") or None
current_display = (current_token[:15] + "...") if current_token else None
token = _prompt_validated(
"Access Token",
_validate_access_token,
current=current_display,
secret=True,
help_text=(
"Two options for getting one:\n\n"
" (a) TEMP — App Dashboard → WhatsApp → API Setup →\n"
" 'Generate access token' button. Lasts 24 hours.\n"
" Fine for testing today; you'll have to regenerate\n"
" tomorrow.\n\n"
" (b) PERMANENT (production) — System User token. One-time\n"
" setup, never expires:\n"
" • business.facebook.com → Settings → System users →\n"
" Add → Admin role\n"
" • Assign Assets → your app (Manage app), your\n"
" WhatsApp account (Manage WABAs)\n"
" • Generate token → expiration: Never → permissions:\n"
" business_management, whatsapp_business_messaging,\n"
" whatsapp_business_management\n\n"
"Tokens start with 'EAA'."
),
)
# If they had a current token and just hit Enter, keep it.
if not token:
if current_token:
token = current_token
print(" ✓ Keeping existing token")
else:
print("\n✗ Access Token is required. Aborting.")
return 1
else:
save_env_value("WHATSAPP_CLOUD_ACCESS_TOKEN", token)
print(" ✓ Saved (token hidden)")
print()
print("" * 50)
print("STEP 3 — App Secret (required for webhook signature verification)")
print("" * 50)
current_secret = get_env_value("WHATSAPP_CLOUD_APP_SECRET") or None
current_secret_display = (current_secret[:8] + "...") if current_secret else None
app_secret = _prompt_validated(
"App Secret",
_validate_app_secret,
current=current_secret_display,
secret=True,
help_text=(
"Found in: App Dashboard → Settings → Basic →\n"
"'App secret' field (click 'Show', enter your Facebook password).\n\n"
"If 'Show' doesn't appear, you may need Admin role on the app.\n"
"It's a 32-character lowercase hex string.\n\n"
"Without the App Secret, inbound webhook POSTs are refused\n"
"with HTTP 503 (we can't verify they actually came from Meta)."
),
)
if not app_secret:
if current_secret:
app_secret = current_secret
print(" ✓ Keeping existing App Secret")
else:
print("\n⚠ Skipping App Secret — inbound webhooks will be refused")
print(" until you set WHATSAPP_CLOUD_APP_SECRET manually.")
else:
save_env_value("WHATSAPP_CLOUD_APP_SECRET", app_secret)
print(" ✓ Saved (secret hidden)")
print()
print("" * 50)
print("STEP 4 — App ID & WABA ID (optional, for analytics)")
print("" * 50)
current_app_id = get_env_value("WHATSAPP_CLOUD_APP_ID") or None
app_id = _prompt_validated(
"App ID (optional, press Enter to skip)",
lambda v: (True, None) if not v else _validate_app_id(v),
current=current_app_id,
help_text=(
"Found in: App Dashboard → Settings → Basic → 'App ID' at the\n"
"top of the page. Numeric, ~15-16 digits.\n"
"Not required for messaging — useful only for analytics later."
),
)
if app_id:
save_env_value("WHATSAPP_CLOUD_APP_ID", app_id)
print(f" ✓ Saved: {app_id}")
elif current_app_id:
print(f" ✓ Keeping existing: {current_app_id}")
current_waba_id = get_env_value("WHATSAPP_CLOUD_WABA_ID") or None
waba_id = _prompt_validated(
"WABA ID (optional, press Enter to skip)",
lambda v: (True, None) if not v else _validate_waba_id(v),
current=current_waba_id,
help_text=(
"WhatsApp Business Account ID. Found in: App Dashboard →\n"
"WhatsApp → API Setup, near the top — 'WhatsApp Business\n"
"Account ID'. Numeric, ~15+ digits.\n"
"Not required for messaging — useful for analytics."
),
)
if waba_id:
save_env_value("WHATSAPP_CLOUD_WABA_ID", waba_id)
print(f" ✓ Saved: {waba_id}")
elif current_waba_id:
print(f" ✓ Keeping existing: {current_waba_id}")
print()
print("" * 50)
print("STEP 5 — Verify Token (auto-generated)")
print("" * 50)
current_verify = get_env_value("WHATSAPP_CLOUD_VERIFY_TOKEN") or None
if current_verify:
print(f" An existing verify token is already set ({current_verify[:8]}...).")
try:
regen = input(" Generate a new one? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
regen = "n"
if regen in {"y", "yes"}:
verify_token = secrets.token_urlsafe(32)
save_env_value("WHATSAPP_CLOUD_VERIFY_TOKEN", verify_token)
print(f" ✓ New verify token: {verify_token}")
else:
verify_token = current_verify
print(" ✓ Keeping existing verify token")
else:
verify_token = secrets.token_urlsafe(32)
save_env_value("WHATSAPP_CLOUD_VERIFY_TOKEN", verify_token)
print(f" ✓ Generated: {verify_token}")
print()
print(" → COPY THIS TOKEN NOW. You'll paste it into Meta's webhook")
print(" configuration dialog (next step).")
print()
print("" * 50)
print("STEP 6 — Recipient Allowlist")
print("" * 50)
print()
print(" Who is allowed to message the bot? (Comma-separated phone")
print(" numbers with country code, no '+' / spaces / dashes. Use '*'")
print(" to allow anyone — only safe if you've also configured Meta's")
print(" recipient whitelist for app-development mode.)")
print()
current_allow = get_env_value("WHATSAPP_CLOUD_ALLOWED_USERS") or None
allow_default = current_allow if current_allow else None
try:
allowed = input(
f" → Allowed users{' [' + allow_default + ']' if allow_default else ''}: "
).strip() or (allow_default or "")
except (EOFError, KeyboardInterrupt):
allowed = ""
if allowed:
# Light normalization — strip spaces and dashes from each entry.
allowed = ",".join(
re.sub(r"[\s\-+]", "", part) for part in allowed.split(",") if part.strip()
)
save_env_value("WHATSAPP_CLOUD_ALLOWED_USERS", allowed)
print(f" ✓ Saved: {allowed}")
else:
print(" ⚠ No allowlist — every inbound message will be denied.")
print(" Re-run this wizard or set WHATSAPP_CLOUD_ALLOWED_USERS manually.")
print()
print("" * 50)
print("SETUP COMPLETE — Next steps")
print("" * 50)
print()
print(" Hermes needs a public HTTPS URL to receive WhatsApp messages.")
print(" The recommended path is Cloudflare Tunnel (free, no port")
print(" forwarding, no DNS setup).")
print()
print(" 1. Install cloudflared (one-time, if you don't have it):")
print(" Windows: winget install Cloudflare.cloudflared")
print(" macOS: brew install cloudflared")
print(" Linux: https://github.com/cloudflare/cloudflared/releases")
print()
print(" Alternatives: ngrok, or your own domain + reverse proxy")
print(" with TLS.")
print()
print(" 2. Start the tunnel in a separate terminal:")
print(" cloudflared tunnel --url http://localhost:8090")
print(" Note the printed https://<random>.trycloudflare.com URL.")
print()
print(" 3. Start the Hermes gateway in another terminal:")
print(" hermes gateway")
print()
print(" 4. Verify your local config is reachable. From a third")
print(" terminal, with the tunnel URL substituted:")
print()
print(" curl 'https://YOUR-TUNNEL.trycloudflare.com/whatsapp/webhook?\\")
print(f" hub.mode=subscribe&hub.verify_token={verify_token}&\\")
print(" hub.challenge=hello'")
print()
print(" Expected: HTTP 200 with body 'hello'.")
print(" Also try: curl https://YOUR-TUNNEL.trycloudflare.com/health")
print(" (should return JSON with verify_token_configured: true).")
print()
print(" 5. Configure Meta to point at your tunnel:")
print(" App Dashboard → WhatsApp → Configuration → Edit webhook")
print(" Callback URL: <tunnel-url>/whatsapp/webhook")
print(f" Verify Token: {verify_token}")
print(" → Click 'Verify and save'")
print(" → Then 'Manage' webhook fields → subscribe to 'messages'")
print()
print(" 6. Add your phone to Meta's recipient list:")
print(" App Dashboard → WhatsApp → API Setup → 'To'")
print(" 'Manage phone number list'")
print()
print(" 7. DM the bot's test number from your phone.")
print()
print("" * 50)
print("Optional: polish your bot's WhatsApp profile")
print("" * 50)
print()
print(" WhatsApp shows a display name and profile picture for your bot")
print(" in every chat header and contact list. These are set in Meta's")
print(" Business Manager, not via this wizard — but here's where to do")
print(" it once you're up and running:")
print()
effective_waba = waba_id or current_waba_id
if effective_waba:
print(" • Display name + profile picture:")
print(" https://business.facebook.com/wa/manage/phone-numbers/"
f"?waba_id={effective_waba}")
else:
print(" • Display name + profile picture:")
print(" https://business.facebook.com/wa/manage/phone-numbers/")
print(" (select your WhatsApp Business Account on that page)")
print(" Display-name changes go through a ~24-48h Meta review.")
print()
print(" • About, description, website, hours, business category:")
print(" Same page → click your phone number → 'Edit profile'.")
print()
print(" • Verified badge (the green check):")
print(" Requires Meta's business verification process —")
print(" Business Manager → Security Center → Start Verification.")
print()
print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/")
print(" messaging/whatsapp-cloud")
print()
return 0
+1 -1
View File
@@ -344,7 +344,7 @@ def show_status(args):
print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD))
message = format_nous_portal_entitlement_message(
nous_account_info,
capability="managed web, image, TTS, STT, browser, and Modal tools",
capability="managed web, image, TTS, browser, and Modal tools",
)
if message:
for line in message.splitlines():
+62 -197
View File
@@ -632,12 +632,6 @@ class EnvVarUpdate(BaseModel):
key: str
value: str
profile: Optional[str] = None
# Optional bearer key for the connectivity probe of a custom/local endpoint
# (``key == "OPENAI_BASE_URL"``). Self-hosted endpoints that gate
# ``/v1/models`` behind auth otherwise look "reachable but empty"; sending
# the key lets the probe enumerate the served models. Ignored for the
# regular PUT /api/env path (which only reads key/value).
api_key: str = ""
class EnvVarDelete(BaseModel):
@@ -654,9 +648,6 @@ class MessagingPlatformUpdate(BaseModel):
enabled: Optional[bool] = None
env: Dict[str, str] = {}
clear_env: List[str] = []
# Explicit body profile beats the query param injected by the global
# dashboard profile switcher (same precedence as other scoped writes).
profile: Optional[str] = None
class TelegramOnboardingStart(BaseModel):
@@ -728,12 +719,6 @@ class ModelAssignment(BaseModel):
# reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is
# the path that actually wires a local endpoint into resolution.
base_url: str = ""
# Optional API key for a custom/local endpoint. Persisted to
# ``model.api_key`` (where the runtime resolver reads it) so a self-hosted
# endpoint that requires auth works from the GUI — mirrors the key the
# ``hermes model`` custom flow collects. Honored only on the main slot for
# custom/local providers.
api_key: str = ""
confirm_expensive_model: bool = False
profile: Optional[str] = None
@@ -806,7 +791,7 @@ def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, st
def _apply_main_model_assignment(
model_cfg: "Any", provider: str, model: str, base_url: str = "", api_key: str = ""
model_cfg: "Any", provider: str, model: str, base_url: str = ""
) -> dict:
"""Apply a main-slot model assignment to a ``model`` config dict in place.
@@ -846,14 +831,6 @@ def _apply_main_model_assignment(
# it so the new provider's default endpoint is used. Same-provider
# re-assignment keeps the user's configured base_url intact.
model_cfg["base_url"] = ""
# The endpoint key follows the same lifecycle as base_url: an explicit key
# is always persisted; an existing key is dropped only when switching to a
# different provider (it belonged to the old endpoint), and preserved on a
# same-provider re-pick so re-selecting a model doesn't wipe the key.
if api_key.strip():
model_cfg["api_key"] = api_key.strip()
elif model_cfg.get("api_key") and new_provider != prev_provider:
model_cfg["api_key"] = ""
model_cfg.pop("context_length", None)
return model_cfg
@@ -1661,49 +1638,6 @@ async def get_status():
}
_WINDOWS_11_MIN_BUILD = 22000
def _windows_build_number(version: str, platform_label: str) -> Optional[int]:
"""Extract the Windows NT build number from stdlib platform strings."""
for value in (version or "", platform_label or ""):
match = re.search(r"(?:^|[^\d])10\.0\.(\d{5,})(?:[^\d]|$)", value)
if not match:
continue
try:
return int(match.group(1))
except ValueError:
continue
return None
def _display_system_platform(
*,
system: str,
release: str,
version: str,
platform_label: str,
) -> Dict[str, str]:
"""Return host OS fields for display while preserving stdlib detail."""
if system == "Windows" and release == "10":
build = _windows_build_number(version, platform_label)
if build is not None and build >= _WINDOWS_11_MIN_BUILD:
platform_label = re.sub(
r"^Windows-10(?=-)",
"Windows-11",
platform_label,
count=1,
)
release = "11"
return {
"os": system,
"os_release": release,
"os_version": version,
"platform": platform_label,
}
@app.get("/api/system/stats")
async def get_system_stats():
"""Host + process system stats for the System page.
@@ -1715,12 +1649,10 @@ async def get_system_stats():
import platform as _platform
info: Dict[str, Any] = {
**_display_system_platform(
system=_platform.system(),
release=_platform.release(),
version=_platform.version(),
platform_label=_platform.platform(),
),
"os": _platform.system(),
"os_release": _platform.release(),
"os_version": _platform.version(),
"platform": _platform.platform(),
"arch": _platform.machine(),
"hostname": _platform.node(),
"python_version": _platform.python_version(),
@@ -3219,7 +3151,6 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
model = (body.model or "").strip()
task = (body.task or "").strip().lower()
base_url = (body.base_url or "").strip()
api_key = (body.api_key or "").strip()
if scope not in {"main", "auxiliary"}:
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
@@ -3256,7 +3187,7 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
def _apply_assignment():
with _profile_scope(body.profile or profile):
return _apply_model_assignment_sync(
scope, provider, model, task, base_url, api_key
scope, provider, model, task, base_url
)
return await asyncio.to_thread(_apply_assignment)
@@ -3268,7 +3199,7 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
def _apply_model_assignment_sync(
scope: str, provider: str, model: str, task: str, base_url: str, api_key: str = ""
scope: str, provider: str, model: str, task: str, base_url: str
):
"""Synchronous body of POST /api/model/set.
@@ -3283,7 +3214,7 @@ def _apply_model_assignment_sync(
raise HTTPException(status_code=400, detail="provider and model required for main")
provider, model = _normalize_main_model_assignment(provider, model)
model_cfg = _apply_main_model_assignment(
cfg.get("model", {}), provider, model, base_url, api_key
cfg.get("model", {}), provider, model, base_url
)
cfg["model"] = model_cfg
@@ -3318,27 +3249,6 @@ def _apply_model_assignment_sync(
save_config(cfg)
# Register a named ``custom_providers`` entry for a custom/local
# endpoint, mirroring the ``hermes model`` custom flow
# (_save_custom_provider). Without this the endpoint only lives in
# ``model.*`` and the picker has no proper ready row for it — the
# GUI then surfaces a "needs setup" dead-end on the bare ``custom``
# provider. Dedups by base_url, so re-saving is idempotent.
if provider.strip().lower() in {"custom", "local"} and base_url:
try:
from hermes_cli.main import _auto_provider_name, _save_custom_provider
_save_custom_provider(
base_url,
api_key,
model,
name=_auto_provider_name(base_url),
)
except Exception:
# Never block the assignment on the bookkeeping write —
# model.* is already persisted and routable.
_log.debug("custom_providers registration skipped", exc_info=True)
# Surface auxiliary slots still pinned to a *different* provider than
# the new main one. Switching the main model does NOT touch aux pins
# (they're independent, sticky per-task overrides — see
@@ -3593,14 +3503,9 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
# auto-pick a default without asking the user to type a model name.
if key == "OPENAI_BASE_URL":
url = value.rstrip("/") + "/models"
# Send the optional API key so endpoints that require auth on
# ``/v1/models`` (many hosted OpenAI-compatible servers) still enumerate
# their models instead of returning an empty list behind a 401.
api_key = (body.api_key or "").strip()
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
try:
with httpx.Client(timeout=httpx.Timeout(8.0)) as client:
resp = client.get(url, headers=headers)
resp = client.get(url)
return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)}
except Exception:
return {"ok": False, "reachable": False, "message": f"Could not reach {url}."}
@@ -4212,10 +4117,7 @@ def _gateway_platform_config(platform_id: str):
def _messaging_platform_payload(
entry: dict[str, Any],
env_on_disk: dict[str, str],
runtime: dict | None,
scoped: bool = False,
entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None
) -> dict[str, Any]:
platform_id = entry["id"]
gateway_running = get_running_pid() is not None
@@ -4228,11 +4130,7 @@ def _messaging_platform_payload(
env_vars = []
for key in entry["env_vars"]:
# When profile-scoped, judge only the profile's own .env — the
# dashboard process's os.environ carries the ROOT install's .env
# (loaded at startup) and would falsely report the root credentials
# as the profile's.
value = env_on_disk.get(key) or ("" if scoped else os.getenv(key, ""))
value = env_on_disk.get(key) or os.getenv(key, "")
env_vars.append(
{
"key": key,
@@ -4243,46 +4141,26 @@ def _messaging_platform_payload(
}
)
if scoped:
# Profile-scoped view: derive enablement/configuration from the
# profile's config.yaml + .env only. load_gateway_config()'s
# env-override layer reads os.environ and would leak the root
# install's tokens into the profile's reported state.
try:
cfg = load_config()
platforms_cfg = cfg.get("platforms") or {}
plat_cfg = platforms_cfg.get(platform_id)
if not isinstance(plat_cfg, dict):
plat_cfg = {}
enabled = bool(plat_cfg.get("enabled"))
hc = plat_cfg.get("home_channel")
home_channel = hc if isinstance(hc, dict) else None
except Exception:
enabled = False
home_channel = None
configured = all(env_on_disk.get(key) for key in entry["required_env"])
else:
try:
gateway_config, platform, platform_config = _gateway_platform_config(
platform_id
)
enabled = bool(platform_config and platform_config.enabled)
configured = bool(
platform_config
and gateway_config._is_platform_connected(platform, platform_config)
)
home_channel = (
platform_config.home_channel.to_dict()
if platform_config and platform_config.home_channel
else None
)
except Exception:
enabled = False
configured = all(
env_on_disk.get(key) or os.getenv(key, "")
for key in entry["required_env"]
)
home_channel = None
try:
gateway_config, platform, platform_config = _gateway_platform_config(
platform_id
)
enabled = bool(platform_config and platform_config.enabled)
configured = bool(
platform_config
and gateway_config._is_platform_connected(platform, platform_config)
)
home_channel = (
platform_config.home_channel.to_dict()
if platform_config and platform_config.home_channel
else None
)
except Exception:
enabled = False
configured = all(
env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"]
)
home_channel = None
state = (
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
@@ -4705,28 +4583,19 @@ async def cancel_telegram_onboarding(pairing_id: str):
@app.get("/api/messaging/platforms")
async def get_messaging_platforms(profile: Optional[str] = None):
# Profile-scoped so the dashboard's global profile switcher shows the
# TARGET profile's channel credentials/state, not the root install's.
# Inside _profile_scope, load_env()/read_runtime_status()/get_running_pid()
# all resolve against the requested profile's HERMES_HOME.
with _profile_scope(profile) as scoped_dir:
env_on_disk = load_env()
runtime = read_runtime_status()
return {
"platforms": [
_messaging_platform_payload(
entry, env_on_disk, runtime, scoped=scoped_dir is not None
)
for entry in _messaging_platform_catalog()
]
}
async def get_messaging_platforms():
env_on_disk = load_env()
runtime = read_runtime_status()
return {
"platforms": [
_messaging_platform_payload(entry, env_on_disk, runtime)
for entry in _messaging_platform_catalog()
]
}
@app.put("/api/messaging/platforms/{platform_id}")
async def update_messaging_platform(
platform_id: str, body: MessagingPlatformUpdate, profile: Optional[str] = None
):
async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate):
entry = _catalog_lookup(platform_id)
if not entry:
raise HTTPException(
@@ -4735,27 +4604,26 @@ async def update_messaging_platform(
allowed_env = set(entry["env_vars"])
try:
with _profile_scope(body.profile or profile):
for key in body.clear_env:
if key not in allowed_env:
raise HTTPException(
status_code=400,
detail=f"{key} is not configurable for {entry['name']}",
)
remove_env_value(key)
for key in body.clear_env:
if key not in allowed_env:
raise HTTPException(
status_code=400,
detail=f"{key} is not configurable for {entry['name']}",
)
remove_env_value(key)
for key, value in body.env.items():
if key not in allowed_env:
raise HTTPException(
status_code=400,
detail=f"{key} is not configurable for {entry['name']}",
)
trimmed = value.strip()
if trimmed:
save_env_value(key, trimmed)
for key, value in body.env.items():
if key not in allowed_env:
raise HTTPException(
status_code=400,
detail=f"{key} is not configurable for {entry['name']}",
)
trimmed = value.strip()
if trimmed:
save_env_value(key, trimmed)
if body.enabled is not None:
_write_platform_enabled(platform_id, body.enabled)
if body.enabled is not None:
_write_platform_enabled(platform_id, body.enabled)
return {"ok": True, "platform": platform_id}
except HTTPException:
@@ -4766,18 +4634,15 @@ async def update_messaging_platform(
@app.post("/api/messaging/platforms/{platform_id}/test")
async def test_messaging_platform(platform_id: str, profile: Optional[str] = None):
async def test_messaging_platform(platform_id: str):
entry = _catalog_lookup(platform_id)
if not entry:
raise HTTPException(
status_code=404, detail=f"Unknown messaging platform: {platform_id}"
)
with _profile_scope(profile) as scoped_dir:
env_on_disk = load_env()
payload = _messaging_platform_payload(
entry, env_on_disk, read_runtime_status(), scoped=scoped_dir is not None
)
env_on_disk = load_env()
payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status())
if not payload["enabled"]:
message = f"{entry['name']} is disabled. Enable it, then restart the gateway."
return {"ok": False, "state": payload["state"], "message": message}
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Gedetailleerde gebruik beskikbaar na die eerste agent-antwoord)_"
no_data: "Geen gebruiksdata beskikbaar vir hierdie sessie nie."
credits:
not_logged_in: "Nie by Nous Portal aangemeld nie. Meld aan om jou kredietsaldo te sien en op te laai."
verbose:
not_enabled: "Die `/verbose`-opdrag is nie vir boodskapplatforms geaktiveer nie.\n\nAktiveer dit in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Gereedskap-vordering: **AF** — geen gereedskap-aktiwiteit word vertoon nie."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Detaillierte Nutzung nach der ersten Agentenantwort verfügbar)_"
no_data: "Keine Nutzungsdaten für diese Sitzung verfügbar."
credits:
not_logged_in: "Nicht bei Nous Portal angemeldet. Melde dich an, um dein Guthaben zu sehen und aufzuladen."
verbose:
not_enabled: "Der Befehl `/verbose` ist für Messaging-Plattformen nicht aktiviert.\n\nIn `config.yaml` aktivieren:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Tool-Fortschritt: **OFF** — keine Tool-Aktivität angezeigt."
-3
View File
@@ -346,9 +346,6 @@ gateway:
detailed_after_first: "_(Detailed usage available after the first agent response)_"
no_data: "No usage data available for this session."
credits:
not_logged_in: "Not logged into Nous Portal. Log in to see your credit balance and top up."
verbose:
not_enabled: "The `/verbose` command is not enabled for messaging platforms.\n\nEnable it in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Tool progress: **OFF** — no tool activity shown."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Uso detallado disponible tras la primera respuesta del agente)_"
no_data: "No hay datos de uso disponibles para esta sesión."
credits:
not_logged_in: "No has iniciado sesión en Nous Portal. Inicia sesión para ver tu saldo de créditos y recargar."
verbose:
not_enabled: "El comando `/verbose` no está habilitado para plataformas de mensajería.\n\nHabilítalo en `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progreso de herramientas: **OFF** — no se muestra actividad de herramientas."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Utilisation détaillée disponible après la première réponse de l'agent)_"
no_data: "Aucune donnée d'utilisation disponible pour cette session."
credits:
not_logged_in: "Non connecté à Nous Portal. Connecte-toi pour voir ton solde de crédits et recharger."
verbose:
not_enabled: "La commande `/verbose` n'est pas activée pour les plateformes de messagerie.\n\nActivez-la dans `config.yaml` :\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progression des outils : **OFF** — aucune activité d'outil affichée."
-3
View File
@@ -338,9 +338,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Úsáid mhionsonraithe ar fáil tar éis chéad fhreagra an ghníomhaire)_"
no_data: "Níl aon sonraí úsáide ar fáil don seisiún seo."
credits:
not_logged_in: "Níl tú logáilte isteach i Nous Portal. Logáil isteach chun d'iarmhéid creidmheasa a fheiceáil agus breis a chur leis."
verbose:
not_enabled: "Níl an t-ordú `/verbose` cumasaithe d'ardáin teachtaireachtaí.\n\nCumasaigh in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Dul chun cinn uirlise: **AS** — gan aon ghníomhaíocht uirlise á thaispeáint."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(A részletes használat az első ügynökválasz után érhető el)_"
no_data: "Ehhez a munkamenethez nincsenek elérhető használati adatok."
credits:
not_logged_in: "Nincs bejelentkezve a Nous Portalra. Jelentkezz be a kreditegyenleg megtekintéséhez és feltöltéséhez."
verbose:
not_enabled: "A `/verbose` parancs nincs engedélyezve az üzenetküldő platformokon.\n\nEngedélyezd a `config.yaml` fájlban:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Eszközfolyamat: **OFF** — nem jelenik meg eszközaktivitás."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(L'uso dettagliato sarà disponibile dopo la prima risposta dell'agente)_"
no_data: "Nessun dato di utilizzo disponibile per questa sessione."
credits:
not_logged_in: "Non hai effettuato l'accesso a Nous Portal. Accedi per vedere il saldo dei crediti e ricaricare."
verbose:
not_enabled: "Il comando `/verbose` non è abilitato per le piattaforme di messaggistica.\n\nAbilitalo in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progresso strumenti: **OFF** — nessuna attività degli strumenti mostrata."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(詳細な使用状況は最初のエージェント応答後に利用可能)_"
no_data: "このセッションの使用データはありません。"
credits:
not_logged_in: "Nous Portal にログインしていません。ログインすると残高の確認とチャージができます。"
verbose:
not_enabled: "`/verbose` コマンドはメッセージングプラットフォームで有効になっていません。\n\n`config.yaml` で有効にしてください:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ ツール進捗: **OFF** — ツールの動作は表示されません。"
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(자세한 사용량은 첫 에이전트 응답 이후 확인할 수 있습니다)_"
no_data: "이 세션에 사용 가능한 사용량 데이터가 없습니다."
credits:
not_logged_in: "Nous Portal에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다."
verbose:
not_enabled: "`/verbose` 명령은 메시징 플랫폼에서 활성화되어 있지 않습니다.\n\n`config.yaml`에서 활성화하세요:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ 도구 진행 상황: **OFF** — 도구 활동이 표시되지 않습니다."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Utilização detalhada disponível após a primeira resposta do agente)_"
no_data: "Não há dados de utilização disponíveis para esta sessão."
credits:
not_logged_in: "Você não está conectado ao Nous Portal. Faça login para ver seu saldo de créditos e recarregar."
verbose:
not_enabled: "O comando `/verbose` não está ativado para plataformas de mensagens.\n\nAtiva-o em `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Progresso de ferramentas: **OFF** — não é mostrada qualquer atividade de ferramentas."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Подробное использование доступно после первого ответа агента)_"
no_data: "Данные об использовании для этого сеанса отсутствуют."
credits:
not_logged_in: "Вы не вошли в Nous Portal. Войдите, чтобы увидеть баланс кредитов и пополнить его."
verbose:
not_enabled: "Команда `/verbose` не включена для платформ обмена сообщениями.\n\nВключите в `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Прогресс инструментов: **OFF** — активность инструментов не показывается."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Ayrıntılı kullanım, ilk ajan yanıtından sonra kullanılabilir)_"
no_data: "Bu oturum için kullanım verisi yok."
credits:
not_logged_in: "Nous Portal'a giriş yapılmadı. Bakiyenizi görmek ve yükleme yapmak için giriş yapın."
verbose:
not_enabled: "`/verbose` komutu mesajlaşma platformlarında etkin değil.\n\n`config.yaml` içinde etkinleştirin:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Araç ilerlemesi: **OFF** — araç etkinliği gösterilmez."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(Детальне використання доступне після першої відповіді агента)_"
no_data: "Дані про використання для цього сеансу відсутні."
credits:
not_logged_in: "Ви не ввійшли в Nous Portal. Увійдіть, щоб переглянути баланс кредитів і поповнити його."
verbose:
not_enabled: "Команду `/verbose` не ввімкнено для платформ обміну повідомленнями.\n\nУвімкніть у `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ Прогрес інструментів: **OFF** — активність інструментів не показується."
-3
View File
@@ -334,9 +334,6 @@ Future messages in this room will use that transcript until `/reset` or another
detailed_after_first: "_(首次代理回應後可檢視詳細使用情況)_"
no_data: "此工作階段沒有可用的使用資料。"
credits:
not_logged_in: "未登入 Nous Portal。登入後即可查看額度餘額並儲值。"
verbose:
not_enabled: "`/verbose` 指令未在訊息平台上啟用。\n\n請在 `config.yaml` 中啟用:\n```yaml\ndisplay:\n tool_progress_command: true\n```"
mode_off: "⚙️ 工具進度:**OFF** — 不顯示任何工具活動。"

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