Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f3676598 | ||
|
|
be97aeb7ba | ||
|
|
1a9da1ae82 | ||
|
|
3e6b68252f | ||
|
|
091ef7d304 | ||
|
|
0c29cfd1a6 | ||
|
|
6d14a24b79 | ||
|
|
7450bee8bc | ||
|
|
a6b6afdff4 | ||
|
|
23c0578bd7 | ||
|
|
3eb6bd7f92 | ||
|
|
f58db77cd0 | ||
|
|
8977bf282e | ||
|
|
267e7fd395 | ||
|
|
d183f75ee0 | ||
|
|
4239230957 | ||
|
|
927fa7a980 | ||
|
|
afea650e16 | ||
|
|
195c4d2a98 | ||
|
|
5b71f7dd72 | ||
|
|
135c65093a | ||
|
|
de8bdf529d | ||
|
|
c10ccaaf51 | ||
|
|
5e55b35cc8 | ||
|
|
c6501c0f49 | ||
|
|
a2b8e430e8 | ||
|
|
d78d77e460 | ||
|
|
89db6c8534 | ||
|
|
787936d133 | ||
|
|
2c0d648397 | ||
|
|
134643a2fa | ||
|
|
3c1d066a8a | ||
|
|
15cb4e2279 | ||
|
|
0269eca7e1 | ||
|
|
81dd43a8eb | ||
|
|
272c2f30aa | ||
|
|
bd8e2ec1a6 | ||
|
|
40ae170647 | ||
|
|
1495f0cc38 | ||
|
|
a5aecf26fa | ||
|
|
c35ede789f | ||
|
|
a26a12ad07 | ||
|
|
043350dfd3 | ||
|
|
21f55af769 | ||
|
|
72e82f88c0 | ||
|
|
fa3b06b035 | ||
|
|
f768e75ecf | ||
|
|
34468ed0d4 | ||
|
|
fc995634cc | ||
|
|
f24b7ed9d9 | ||
|
|
59510d7b44 | ||
|
|
0cd5867bbb | ||
|
|
d4b533de4e | ||
|
|
64f7f36713 | ||
|
|
c914e4a371 | ||
|
|
fabca0bdd8 | ||
|
|
f7a3509b25 | ||
|
|
7d51cd7516 | ||
|
|
13a2350c8d | ||
|
|
f600352e43 | ||
|
|
8104b20269 | ||
|
|
eee32cdd52 | ||
|
|
899e8b9067 | ||
|
|
abe0e19c0a | ||
|
|
d967e74427 | ||
|
|
05022066ea | ||
|
|
e25b2a6e18 | ||
|
|
9cb7d40d8d | ||
|
|
85b65e29f0 | ||
|
|
ddc22866a3 | ||
|
|
1d9aacbd00 | ||
|
|
2f171743b7 | ||
|
|
162c7856ca | ||
|
|
1d7a1c00b4 | ||
|
|
e59b815c04 | ||
|
|
4f7fe9bcff | ||
|
|
3a8d643d37 | ||
|
|
765790a216 | ||
|
|
566669013f | ||
|
|
a1f76ba7e9 | ||
|
|
8bf498c21d | ||
|
|
92273e4f57 | ||
|
|
0fdab53ef0 | ||
|
|
53f598e7a2 | ||
|
|
7527e7aeac | ||
|
|
c45593ceae | ||
|
|
128da68823 | ||
|
|
ac0cce5f3f | ||
|
|
75f478750c | ||
|
|
eadfeef60e | ||
|
|
f90777a6b8 | ||
|
|
c79b80a8a5 | ||
|
|
16047655b5 | ||
|
|
2564760d7a | ||
|
|
4bc7296042 | ||
|
|
f1237aa95b | ||
|
|
32032e1e2d | ||
|
|
e946f49ab5 | ||
|
|
1ffa22ee6b | ||
|
|
b9646276fd | ||
|
|
a5371b3e68 | ||
|
|
038ed94a6c | ||
|
|
ef3a650f05 | ||
|
|
4e9d886d9d | ||
|
|
8f2931e3ee |
@@ -26,6 +26,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed so the arm64 job can push/pull its registry-backed build cache
|
||||
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
|
||||
# job for why registry cache replaced the gha cache on that arch.
|
||||
packages: write
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own image. PR runs reuse a PR-scoped group with
|
||||
@@ -196,11 +200,34 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for smoke testing. PR arm64
|
||||
# builds deliberately avoid the gha cache: cold-cache arm64 builds can
|
||||
# outlive GitHub's short-lived Azure cache SAS token, then fail while
|
||||
# reading or writing cache blobs before the smoke test can run.
|
||||
- name: Build image (arm64, smoke test, uncached PR)
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
# read (cache-from) on every event and written (cache-to) on
|
||||
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
|
||||
# the whole job — unlike the gha cache backend's short-lived Azure SAS
|
||||
# token, which expired mid-build on slow cold-cache arm64 runs and
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for smoke testing.
|
||||
#
|
||||
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
|
||||
# they pull warm layers pushed by the most recent main build but never
|
||||
# write, so rapid PR pushes don't race on cache writes or pollute the
|
||||
# cache ref. This restores warm-cache speed to arm64 PR builds (which
|
||||
# were running fully uncached and were ~45% slower than amd64, making
|
||||
# them the job most often cancelled on supersede).
|
||||
#
|
||||
# Registry cache (type=registry on ghcr.io) is used instead of the gha
|
||||
# cache that previously broke here: its credential is the job-lifetime
|
||||
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, smoke test, cache read-only PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
@@ -211,9 +238,11 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
|
||||
# Main/release builds still use the per-arch gha cache so the digest
|
||||
# push below can reuse layers from this smoke-test build.
|
||||
# Main/release builds read AND write the registry cache so the digest
|
||||
# push below reuses layers from this smoke-test build, and so the next
|
||||
# PR/main build starts warm.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
@@ -225,8 +254,8 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Smoke test image
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
@@ -253,8 +282,8 @@ jobs:
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
|
||||
@@ -49,8 +49,8 @@ hermes-agent/
|
||||
│ ├── hermes-achievements/ # Gamified achievement tracking
|
||||
│ ├── observability/ # Metrics / traces / logs plugin
|
||||
│ ├── image_gen/ # Image-generation providers
|
||||
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
|
||||
│ # spotify, strike-freedom-cockpit, ...
|
||||
│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
|
||||
│ # strike-freedom-cockpit, ...
|
||||
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
|
||||
├── skills/ # Built-in skills bundled with the repo
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
|
||||
@@ -308,11 +308,14 @@ def compress_context(
|
||||
# The check itself sets ``agent._compression_warning`` so the
|
||||
# status-callback replay machinery still emits the warning to the user
|
||||
# the first time it would matter.
|
||||
if not getattr(agent, "_compression_feasibility_checked", True):
|
||||
try:
|
||||
check_compression_model_feasibility(agent)
|
||||
finally:
|
||||
agent._compression_feasibility_checked = True
|
||||
if not getattr(agent, "_compression_feasibility_checked", False):
|
||||
# Mark as checked only after the probe completes. If the check
|
||||
# raises (e.g. a fatal aux-context ValueError that aborts the
|
||||
# session), leaving the flag unset is harmless; a non-fatal
|
||||
# transient failure is swallowed inside the function so the flag
|
||||
# is set normally on the next successful pass.
|
||||
check_compression_model_feasibility(agent)
|
||||
agent._compression_feasibility_checked = True
|
||||
|
||||
_pre_msg_count = len(messages)
|
||||
logger.info(
|
||||
|
||||
@@ -451,3 +451,190 @@ def get_cross_profile_warning(path: str) -> Optional[str]:
|
||||
f"``cross_profile=True``. (Defense-in-depth — not a security "
|
||||
f"boundary; the terminal tool can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox-mirror write guard (#32049)
|
||||
#
|
||||
# Non-local terminal backends (Docker, Daytona, etc.) bind a sandbox-local
|
||||
# directory to the container's ``$HOME``. The on-disk layout looks like
|
||||
#
|
||||
# <HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...
|
||||
#
|
||||
# When the agent (running host-side) speculates that authoritative profile
|
||||
# state lives at one of those sandbox-mirror paths, the write lands on the
|
||||
# mirror — never read by the host process — while the host file is left
|
||||
# untouched. The agent reports success, the user sees no change, and on
|
||||
# disk two divergent copies accumulate. See #32049 for evidence.
|
||||
#
|
||||
# This guard is path-shape-only: it detects the
|
||||
# ``…/sandboxes/<backend>/<task>/home/.hermes/…`` segment and warns
|
||||
# regardless of which Hermes profile is active. It does NOT cover the
|
||||
# inner-container case where the bind mount strips the ``sandboxes/`` prefix
|
||||
# (the agent's view inside the container is plain ``/root/.hermes/...``);
|
||||
# that case needs a separate dispatch-layer or host-side ``profile_state``
|
||||
# tool.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_sandbox_mirror_segments(parts: tuple) -> Optional[int]:
|
||||
"""Return the index of the inner ``.hermes`` part in a sandbox-mirror path.
|
||||
|
||||
Matches ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and returns the
|
||||
index where the inner Hermes-state portion starts. Returns ``None`` for
|
||||
paths that do not contain the sandbox-mirror shape.
|
||||
"""
|
||||
for i, part in enumerate(parts):
|
||||
if part != "sandboxes":
|
||||
continue
|
||||
# Need at least: sandboxes / <backend> / <task> / home / .hermes / <thing>
|
||||
if i + 5 >= len(parts):
|
||||
continue
|
||||
if parts[i + 3] == "home" and parts[i + 4] == ".hermes":
|
||||
return i + 4
|
||||
return None
|
||||
|
||||
|
||||
def classify_sandbox_mirror_target(path: str) -> Optional[dict]:
|
||||
"""Classify a write target as a sandbox-mirror of authoritative Hermes state.
|
||||
|
||||
Returns ``None`` when the path does not match the sandbox-mirror shape.
|
||||
Otherwise returns a dict with:
|
||||
|
||||
* ``target_path``: the resolved path string
|
||||
* ``mirror_root``: the ``…/sandboxes/<backend>/<task>/home/.hermes``
|
||||
prefix (so callers can show users which sandbox owns the mirror)
|
||||
* ``inner_path``: the portion under the mirror's ``.hermes`` (what the
|
||||
agent likely meant to address on the host)
|
||||
|
||||
Detection is path-shape-only — does not require any Hermes resolver to
|
||||
succeed, so it works correctly even when called from contexts where
|
||||
HERMES_HOME resolution would be ambiguous.
|
||||
"""
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
parts = target.parts
|
||||
inner_idx = _find_sandbox_mirror_segments(parts)
|
||||
if inner_idx is None:
|
||||
return None
|
||||
|
||||
mirror_root = str(Path(*parts[: inner_idx + 1]))
|
||||
inner_path = str(Path(*parts[inner_idx + 1 :])) if inner_idx + 1 < len(parts) else ""
|
||||
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": mirror_root,
|
||||
"inner_path": inner_path,
|
||||
}
|
||||
|
||||
|
||||
def get_sandbox_mirror_warning(path: str) -> Optional[str]:
|
||||
"""Return a model-facing warning when ``path`` lands in a sandbox mirror.
|
||||
|
||||
Returns ``None`` when the path is not a sandbox-mirror target. Caller
|
||||
is expected to surface the warning to the agent as a tool-result
|
||||
error. The bypass kwarg (``cross_profile=True``) is shared with the
|
||||
cross-profile guard: both are soft "I know what I'm doing" overrides
|
||||
a user can authorise.
|
||||
|
||||
Defense-in-depth, NOT a security boundary: the terminal tool runs as
|
||||
the same OS user and can write the mirror path directly. The guard
|
||||
exists to surface the misclassification before the silent-success +
|
||||
divergent-copy footgun in #32049 fires.
|
||||
"""
|
||||
info = classify_sandbox_mirror_target(path)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is a per-task mirror "
|
||||
f"created by a non-local terminal backend (docker/daytona/etc.). "
|
||||
f"Writes here land on a copy that the host Hermes process never "
|
||||
f"reads — the authoritative file is likely {info['inner_path']!r} "
|
||||
f"under the real HERMES_HOME. Use the host-side tool for "
|
||||
f"authoritative state (e.g. ``memory`` for memories), or address "
|
||||
f"the host path directly. To bypass this guard after explicit "
|
||||
f"user direction, retry the call with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container-context mirror guard (inner-container case — #32049 follow-up)
|
||||
#
|
||||
# Brian's shape-based detector (#32213) catches paths that still carry the
|
||||
# full ``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix on the host.
|
||||
# But when file tools execute *inside* the container the bind-mount strips
|
||||
# that prefix: the agent sees plain ``/root/.hermes/…``. The root:root
|
||||
# ownership on the divergent SOUL.md in #32049 confirms this is the primary
|
||||
# failure mode.
|
||||
#
|
||||
# Fix: file_tools passes the active Docker mirror prefix when the terminal
|
||||
# backend is docker + persistent. This catches the very first file-tool call,
|
||||
# before a DockerEnvironment object necessarily exists.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def classify_container_mirror_target(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""Classify a write target as a container-side sandbox mirror.
|
||||
|
||||
``mirror_prefix`` must be supplied by the caller after it has established
|
||||
that file tools are executing in a container whose home is a sandbox
|
||||
mirror. Returns ``None`` when no such context is active or the path is not
|
||||
under the mirror prefix. Otherwise returns:
|
||||
|
||||
* ``target_path``: resolved path string
|
||||
* ``mirror_root``: the declared container mirror prefix
|
||||
* ``inner_path``: portion under the mirror root (what the agent
|
||||
likely meant to address in the host HERMES_HOME)
|
||||
"""
|
||||
if not mirror_prefix:
|
||||
return None
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
mirror = Path(os.path.expanduser(mirror_prefix)).resolve()
|
||||
inner = target.relative_to(mirror)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": str(mirror),
|
||||
"inner_path": inner.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def get_container_mirror_warning(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""Return a model-facing warning when *path* lands in the container's
|
||||
sandbox mirror of authoritative Hermes state.
|
||||
|
||||
The caller supplies ``mirror_prefix`` only when the current file-tool
|
||||
backend is known to execute inside a Docker sandbox. Same contract as
|
||||
``get_cross_profile_warning``: soft guard, returns ``None`` for
|
||||
non-mirror paths, caller surfaces as a tool-result error. Bypass via
|
||||
``cross_profile=True`` after explicit user direction.
|
||||
"""
|
||||
info = classify_container_mirror_target(path, mirror_prefix)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is the container's "
|
||||
f"bind-mounted home — a per-task mirror that the host Hermes "
|
||||
f"process never reads. The authoritative file is "
|
||||
f"{info['inner_path']!r} under the real HERMES_HOME. Use the "
|
||||
f"host-side tool for authoritative state (e.g. ``memory`` for "
|
||||
f"memories), or address the host path directly. To bypass after "
|
||||
f"explicit user direction, retry with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
@@ -1128,6 +1128,18 @@ def _model_name_suggests_kimi(model: str) -> bool:
|
||||
return lower.startswith("kimi") or "moonshot" in lower
|
||||
|
||||
|
||||
def _model_name_suggests_minimax_m3(model: str) -> bool:
|
||||
"""Return True if the model name looks like MiniMax M3.
|
||||
|
||||
Catches ``MiniMax-M3``, ``minimax/minimax-m3``, and similar variants
|
||||
across surfaces (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
|
||||
Used as a guard against stale cache entries seeded by pre-catalog builds
|
||||
that resolved M3 via the generic ``minimax`` catch-all (204,800) before
|
||||
the ``minimax-m3`` (1M) entry existed in DEFAULT_CONTEXT_LENGTHS.
|
||||
"""
|
||||
return "minimax-m3" in model.lower()
|
||||
|
||||
|
||||
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Query a local server for the model's context length."""
|
||||
import httpx
|
||||
@@ -1539,6 +1551,19 @@ def get_model_context_length(
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Invalidate stale ≤204,800 cache entries for MiniMax-M3. Pre-catalog
|
||||
# builds resolved M3 via the generic ``minimax`` catch-all (204,800)
|
||||
# and persisted it before the ``minimax-m3`` (1M) entry existed; that
|
||||
# stale value would otherwise stick forever here at step 1. M3 is 1M,
|
||||
# so any sub-256K cached value for an M3 slug is a leftover — drop it
|
||||
# and fall through to the hardcoded default.
|
||||
elif cached <= 204_800 and _model_name_suggests_minimax_m3(model):
|
||||
logger.info(
|
||||
"Dropping stale MiniMax-M3 cache entry %s@%s -> %s (pre-catalog value); "
|
||||
"re-resolving via hardcoded defaults",
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Nous Portal: the portal /v1/models endpoint is authoritative.
|
||||
# Bypass the persistent cache so step 5b can always reconcile
|
||||
# against it — this corrects pre-fix entries seeded from the
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
|
||||
from typing import Optional
|
||||
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
from agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
@@ -802,7 +803,7 @@ def build_environment_hints() -> str:
|
||||
|
||||
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
|
||||
try:
|
||||
host_lines.append(f"Current working directory: {os.getcwd()}")
|
||||
host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Single source of truth for the agent working directory.
|
||||
|
||||
`TERMINAL_CWD` is the runtime carrier for the configured working directory
|
||||
(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
|
||||
gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
relies on the launch dir. Reading it in one place keeps the system prompt, the
|
||||
tool surfaces, and context-file discovery agreeing on where the agent lives.
|
||||
|
||||
The #29531 per-session extension point is this function: a future PR adds a
|
||||
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
|
||||
`set_session_vars` seam — by design, not a reopening hazard.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
|
||||
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
@@ -24,7 +24,6 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import (
|
||||
@@ -41,6 +40,7 @@ from agent.prompt_builder import (
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
|
||||
|
||||
def _ra():
|
||||
@@ -288,13 +288,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
context_parts.append(system_message)
|
||||
|
||||
if not agent.skip_context_files:
|
||||
# Use TERMINAL_CWD for context file discovery when set (gateway
|
||||
# mode). The gateway process runs from the hermes-agent install
|
||||
# dir, so os.getcwd() would pick up the repo's AGENTS.md and
|
||||
# other dev files — inflating token usage by ~10k for no benefit.
|
||||
_context_cwd = os.getenv("TERMINAL_CWD") or None
|
||||
# Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
|
||||
# CLI), None lets build_context_files_prompt fall back to the launch
|
||||
# dir — the user's real cwd there, but the install dir for the gateway
|
||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||
context_files_prompt = _r.build_context_files_prompt(
|
||||
cwd=_context_cwd, skip_soul=_soul_loaded)
|
||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
|
||||
if context_files_prompt:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
|
||||
@@ -8,18 +8,24 @@ fn main() {
|
||||
// `option_env!()` macro to default the install-script reference.
|
||||
// Precedence (matches install.ps1's own arg precedence): commit > branch.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
|
||||
// Useful for CI builds that want to pin to a tagged release SHA
|
||||
// rather than whatever the checkout's HEAD happens to be.
|
||||
// 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
|
||||
// the repo this build.rs lives in. Default for `cargo tauri build`
|
||||
// from a dev machine — pins the produced .exe to your current
|
||||
// checkout state.
|
||||
// 3. Last-resort fallback: hardcoded `main` branch, no commit. The
|
||||
// installer will fetch HEAD-of-main at runtime. Used when the
|
||||
// build is happening outside a git checkout (e.g. cargo install
|
||||
// from a packaged crate, unlikely for this binary but defensive).
|
||||
// The COMMIT pin is opt-in. By default a dev build pins ONLY the branch,
|
||||
// so the produced installer follows that branch's HEAD at install time
|
||||
// (tolerant of fast-forwards/new commits, and never references a SHA the
|
||||
// local checkout hasn't pushed). Set HERMES_BUILD_PIN_COMMIT to bake an
|
||||
// immutable commit pin for reproducible/release installers.
|
||||
//
|
||||
// Commit pin resolution:
|
||||
// - HERMES_BUILD_PIN_COMMIT, if set and non-empty. Accepts a SHA, tag,
|
||||
// or branch name; resolved to an immutable SHA via `git rev-parse`
|
||||
// when possible, else used verbatim if it already looks like a SHA.
|
||||
// - Otherwise: NO commit pin (branch-follow is the default).
|
||||
//
|
||||
// Branch pin resolution:
|
||||
// 1. HERMES_BUILD_PIN_BRANCH, if set and non-empty.
|
||||
// 2. `git rev-parse --abbrev-ref HEAD` of the checkout this build.rs
|
||||
// lives in — the current branch. (None on a detached HEAD.)
|
||||
// 3. Last-resort fallback handled below: if neither commit nor branch
|
||||
// resolves, warn — the binary needs a runtime arg or dev-repo env.
|
||||
//
|
||||
// Build script reruns on git HEAD change so a new commit triggers
|
||||
// a rebuild without `cargo clean`.
|
||||
@@ -30,11 +36,20 @@ fn main() {
|
||||
|
||||
if let Some(c) = &commit {
|
||||
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: pinning to commit {}",
|
||||
short(c)
|
||||
);
|
||||
}
|
||||
if let Some(b) = &branch {
|
||||
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
|
||||
match &commit {
|
||||
Some(_) => println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"),
|
||||
None => println!(
|
||||
"cargo:warning=hermes-bootstrap: following branch {b} HEAD (no commit pin; \
|
||||
set HERMES_BUILD_PIN_COMMIT for an immutable pin)"
|
||||
),
|
||||
}
|
||||
}
|
||||
if commit.is_none() && branch.is_none() {
|
||||
// Fail loudly rather than silently produce a binary that errors
|
||||
@@ -46,8 +61,11 @@ fn main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Rerun build.rs when HEAD moves so successive builds pick up new
|
||||
// commits without needing `cargo clean`. .git/HEAD changes on every
|
||||
// Rerun build.rs when HEAD moves. With branch-follow as the default the
|
||||
// baked commit no longer changes per-commit, but a branch *switch* changes
|
||||
// the detected branch name, so we still re-trigger. When an explicit
|
||||
// HERMES_BUILD_PIN_COMMIT resolves a moving ref (tag/branch) to a SHA, a
|
||||
// HEAD move can also change that resolution. .git/HEAD changes on every
|
||||
// commit / branch switch / rebase.
|
||||
let git_dir = locate_git_dir();
|
||||
if let Some(gd) = &git_dir {
|
||||
@@ -83,24 +101,46 @@ fn main() {
|
||||
}
|
||||
|
||||
fn resolve_commit_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
// Commit pinning is OPT-IN. Only bake a commit when the caller explicitly
|
||||
// asks for one via HERMES_BUILD_PIN_COMMIT. With no env var, we return
|
||||
// None and the installer follows the branch HEAD at install time.
|
||||
let requested = std::env::var("HERMES_BUILD_PIN_COMMIT").ok()?;
|
||||
let requested = requested.trim();
|
||||
if requested.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
// Resolve the request (which may be a SHA, tag, or branch name) to an
|
||||
// immutable commit SHA so the baked pin is reproducible. `^{commit}`
|
||||
// dereferences tags to the commit they point at.
|
||||
if let Ok(out) = Command::new("git")
|
||||
.args(["rev-parse", "--verify", &format!("{requested}^{{commit}}")])
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
if let Ok(s) = String::from_utf8(out.stdout) {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Couldn't resolve via git (e.g. building outside a checkout). Accept the
|
||||
// literal value only if it already looks like a SHA; otherwise fail loud
|
||||
// rather than bake an unresolvable ref into the binary.
|
||||
if is_sha(requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
panic!(
|
||||
"HERMES_BUILD_PIN_COMMIT={requested:?} could not be resolved to a commit \
|
||||
(git rev-parse failed and it is not a valid SHA)"
|
||||
);
|
||||
}
|
||||
|
||||
/// True if `s` looks like an abbreviated-or-full git SHA (7..=40 hex chars).
|
||||
fn is_sha(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn resolve_branch_pin() -> Option<String> {
|
||||
|
||||
@@ -482,6 +482,18 @@ async function runBootstrap(opts) {
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
||||
} = opts
|
||||
|
||||
// Bail before spawning anything if the user already cancelled — otherwise an
|
||||
// already-aborted signal would still fetch the manifest (a spawn) before the
|
||||
// in-loop abort check fires.
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
if (typeof onEvent === 'function') {
|
||||
try {
|
||||
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
} catch {}
|
||||
}
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
|
||||
|
||||
// Tee every event to the runLog AND the caller's onEvent. This gives us a
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
|
||||
test('runBootstrap bails immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const events = []
|
||||
const result = await runBootstrap({
|
||||
installStamp: null,
|
||||
activeRoot: '/tmp/hermes-runner-test',
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: '/tmp/hermes-runner-test',
|
||||
logRoot: '/tmp/hermes-runner-test',
|
||||
onEvent: ev => events.push(ev),
|
||||
abortSignal: controller.signal
|
||||
})
|
||||
|
||||
// Cancelled before any install script is spawned.
|
||||
assert.deepEqual(result, { ok: false, cancelled: true })
|
||||
assert.ok(
|
||||
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
|
||||
'should emit a cancelled failure event'
|
||||
)
|
||||
})
|
||||
@@ -8,6 +8,8 @@ const {
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
net: electronNet,
|
||||
protocol,
|
||||
safeStorage,
|
||||
session,
|
||||
shell,
|
||||
@@ -364,6 +366,66 @@ app.setAboutPanelOptions({
|
||||
copyright: 'Copyright © 2026 Nous Research'
|
||||
})
|
||||
|
||||
// Custom scheme for streaming local media (video/audio) into the renderer.
|
||||
// Reading large media through `readFileDataUrl` failed: it base64-loads the
|
||||
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
|
||||
// so any non-trivial video silently refused to load. Streaming via a protocol
|
||||
// handler removes the size cap and gives the <video> element seekable,
|
||||
// range-aware playback. Must be registered before the app is ready.
|
||||
const MEDIA_PROTOCOL = 'hermes-media'
|
||||
// Only audio/video may be streamed. Without this the handler would read any
|
||||
// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
|
||||
const STREAMABLE_MEDIA_EXTS = new Set([
|
||||
'.avi',
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.ogg',
|
||||
'.opus',
|
||||
'.wav',
|
||||
'.webm'
|
||||
])
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: MEDIA_PROTOCOL,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function registerMediaProtocol() {
|
||||
protocol.handle(MEDIA_PROTOCOL, async request => {
|
||||
let resolvedPath
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
|
||||
;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
|
||||
} catch {
|
||||
return new Response('Media not found', { status: 404 })
|
||||
}
|
||||
|
||||
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
|
||||
return new Response('Unsupported media type', { status: 415 })
|
||||
}
|
||||
|
||||
// Delegate to Electron's net stack on a file:// URL — it resolves the
|
||||
// content-type and honors Range requests so seeking works. Forward the
|
||||
// renderer's headers (notably Range) and skip custom-protocol re-entry.
|
||||
return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
headers: request.headers
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
@@ -373,6 +435,9 @@ let connectionPromise = null
|
||||
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
|
||||
// the renderer's "Reload and retry" path or by quitting the app.
|
||||
let bootstrapFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
let connectionConfigCache = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
@@ -1678,12 +1743,15 @@ async function ensureRuntime(backend) {
|
||||
})
|
||||
} catch {}
|
||||
|
||||
bootstrapAbortController = new AbortController()
|
||||
|
||||
const bootstrapResult = await runBootstrap({
|
||||
installStamp: backend.installStamp,
|
||||
activeRoot: backend.activeRoot,
|
||||
sourceRepoRoot: SOURCE_REPO_ROOT,
|
||||
hermesHome: HERMES_HOME,
|
||||
logRoot: path.join(HERMES_HOME, 'logs'),
|
||||
abortSignal: bootstrapAbortController.signal,
|
||||
onEvent: ev => {
|
||||
// Tee every bootstrap event to (a) the desktop log for forensics
|
||||
// and (b) the renderer for live progress UI. Either may be absent;
|
||||
@@ -1699,6 +1767,16 @@ async function ensureRuntime(backend) {
|
||||
writeMarker: writeBootstrapMarker
|
||||
})
|
||||
|
||||
bootstrapAbortController = null
|
||||
|
||||
if (bootstrapResult.cancelled) {
|
||||
const cancelledError = new Error('Hermes install was cancelled.')
|
||||
cancelledError.isBootstrapFailure = true
|
||||
cancelledError.bootstrapCancelled = true
|
||||
bootstrapFailure = cancelledError
|
||||
throw cancelledError
|
||||
}
|
||||
|
||||
if (!bootstrapResult.ok) {
|
||||
const bootstrapError = new Error(
|
||||
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
|
||||
@@ -3194,6 +3272,18 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
||||
// Renderer's Cancel button during first-launch install. Abort the running
|
||||
// install script (SIGTERM via the runner's abortSignal). runBootstrap
|
||||
// resolves with { cancelled: true }, which surfaces the recovery overlay.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
bootstrapAbortController.abort()
|
||||
} catch {}
|
||||
return { ok: true, cancelled: true }
|
||||
}
|
||||
return { ok: false, cancelled: false }
|
||||
})
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
@@ -3654,6 +3744,7 @@ app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null)
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
createWindow()
|
||||
|
||||
@@ -3663,6 +3754,13 @@ app.whenReady().then(() => {
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
bootstrapAbortController.abort()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (desktopLogFlushTimer) {
|
||||
clearTimeout(desktopLogFlushTimer)
|
||||
desktopLogFlushTimer = null
|
||||
|
||||
@@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
|
||||
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
|
||||
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
|
||||
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
|
||||
onBootstrapEvent: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:bootstrap:event', listener)
|
||||
|
||||
@@ -32,7 +32,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",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -97,7 +97,7 @@ function ChatHeader({
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
@@ -54,7 +54,8 @@ import {
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
$workingSessionIds
|
||||
$workingSessionIds,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
@@ -67,8 +68,18 @@ import { VirtualSessionList } from './virtual-session-list'
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
|
||||
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
icon: props => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Skills & Tools',
|
||||
icon: props => <Codicon name="symbol-misc" {...props} />,
|
||||
route: SKILLS_ROUTE
|
||||
},
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
]
|
||||
@@ -115,6 +126,31 @@ const baseName = (path: string) =>
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
|
||||
// FTS results cover sessions that aren't in the loaded page; synthesize a
|
||||
// minimal SessionInfo so they render in the same row component (resume works
|
||||
// by id; the snippet stands in for the preview).
|
||||
function searchResultToSession(result: SessionSearchResult): SessionInfo {
|
||||
const ts = result.session_started ?? Date.now() / 1000
|
||||
|
||||
return {
|
||||
archived: false,
|
||||
cwd: null,
|
||||
ended_at: null,
|
||||
id: result.session_id,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: ts,
|
||||
message_count: 0,
|
||||
model: result.model ?? null,
|
||||
output_tokens: 0,
|
||||
preview: result.snippet?.trim() || null,
|
||||
source: result.source ?? null,
|
||||
started_at: ts,
|
||||
title: null,
|
||||
tool_call_count: 0
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
@@ -128,6 +164,14 @@ function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
groups.set(id, group)
|
||||
}
|
||||
|
||||
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
|
||||
// input, so an active project floats up), but rows *within* a group sort by
|
||||
// creation time so they don't reshuffle every time a message lands — keeps
|
||||
// muscle memory intact.
|
||||
for (const group of groups.values()) {
|
||||
group.sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
@@ -149,6 +193,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onLoadMoreSessions: () => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
@@ -156,7 +202,9 @@ export function ChatSidebar({
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@@ -170,6 +218,9 @@ export function ChatSidebar({
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
|
||||
@@ -180,24 +231,99 @@ export function ChatSidebar({
|
||||
|
||||
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
|
||||
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
|
||||
const visiblePinnedIds = useMemo(
|
||||
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
|
||||
[pinnedSessionIds, sessionsById]
|
||||
)
|
||||
// Index sessions by both their live id and their lineage-root id so a pin
|
||||
// stored as the pre-compression root resolves to the live continuation tip.
|
||||
const sessionByAnyId = useMemo(() => {
|
||||
const map = new Map<string, SessionInfo>()
|
||||
|
||||
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
|
||||
for (const s of sessions) {
|
||||
map.set(s.id, s)
|
||||
|
||||
const pinnedSessions = useMemo(
|
||||
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
|
||||
[visiblePinnedIds, sessionsById]
|
||||
)
|
||||
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
|
||||
map.set(s._lineage_root_id, s)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}, [sessions])
|
||||
|
||||
const pinnedSessions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const out: SessionInfo[] = []
|
||||
|
||||
for (const pinId of pinnedSessionIds) {
|
||||
const session = sessionByAnyId.get(pinId)
|
||||
|
||||
if (session && !seen.has(session.id)) {
|
||||
seen.add(session.id)
|
||||
out.push(session)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}, [pinnedSessionIds, sessionByAnyId])
|
||||
|
||||
const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
|
||||
|
||||
// Full-text search across *all* sessions (not just the loaded page) so 699
|
||||
// sessions stay findable. Debounced; loaded sessions are matched instantly
|
||||
// client-side and merged ahead of the server hits.
|
||||
useEffect(() => {
|
||||
if (!trimmedQuery) {
|
||||
setServerMatches([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const id = window.setTimeout(() => {
|
||||
void searchSessions(trimmedQuery)
|
||||
.then(res => {
|
||||
if (!cancelled) {
|
||||
setServerMatches(res.results)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(id)
|
||||
}
|
||||
}, [trimmedQuery])
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!trimmedQuery) {
|
||||
return []
|
||||
}
|
||||
|
||||
const needle = trimmedQuery.toLowerCase()
|
||||
const out = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of sortedSessions) {
|
||||
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
|
||||
out.set(s.id, s)
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of serverMatches) {
|
||||
if (out.has(match.session_id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const loaded = sessionByAnyId.get(match.session_id)
|
||||
out.set(match.session_id, loaded ?? searchResultToSession(match))
|
||||
}
|
||||
|
||||
return [...out.values()]
|
||||
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
|
||||
|
||||
const unpinnedAgentSessions = useMemo(
|
||||
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
|
||||
[sortedSessions, visiblePinnedIdSet]
|
||||
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
|
||||
[sortedSessions, pinnedRealIdSet]
|
||||
)
|
||||
|
||||
const agentSessions = useMemo(
|
||||
@@ -227,7 +353,10 @@ export function ChatSidebar({
|
||||
return
|
||||
}
|
||||
|
||||
reorderPinnedSession(String(active.id), newIndex)
|
||||
// Sortable ids are live session ids; the pinned store is keyed by durable
|
||||
// (lineage-root) ids, so translate before reordering.
|
||||
const dragged = sessionByAnyId.get(String(active.id))
|
||||
reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex)
|
||||
}
|
||||
|
||||
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
@@ -322,12 +451,63 @@ export function ChatSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 pb-1 pt-1">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
|
||||
<input
|
||||
aria-label="Search sessions"
|
||||
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions…"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
emptyState={
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
No sessions match “{trimmedQuery}”.
|
||||
</div>
|
||||
}
|
||||
label="Results"
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => undefined}
|
||||
onTogglePin={pinSession}
|
||||
open
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={searchResults}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
@@ -342,7 +522,7 @@ export function ChatSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
@@ -361,9 +541,9 @@ export function ChatSidebar({
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
@@ -372,15 +552,17 @@ export function ChatSidebar({
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label="Agents"
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={onNewSessionInWorkspace}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
@@ -472,7 +654,9 @@ interface SidebarSessionsSectionProps {
|
||||
workingSessionIdSet: Set<string>
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
onNewSessionInWorkspace?: (path: null | string) => void
|
||||
pinned: boolean
|
||||
rootClassName?: string
|
||||
contentClassName?: string
|
||||
@@ -496,7 +680,9 @@ function SidebarSessionsSection({
|
||||
workingSessionIdSet,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onTogglePin,
|
||||
onNewSessionInWorkspace,
|
||||
pinned,
|
||||
rootClassName,
|
||||
contentClassName,
|
||||
@@ -518,8 +704,9 @@ function SidebarSessionsSection({
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onPin: () => onTogglePin(sessionPinId(session)),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
session
|
||||
}
|
||||
@@ -551,9 +738,19 @@ function SidebarSessionsSection({
|
||||
} else if (groups?.length) {
|
||||
const groupNodes = groups.map(group =>
|
||||
dndActive ? (
|
||||
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
<SortableSidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
) : (
|
||||
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -568,6 +765,7 @@ function SidebarSessionsSection({
|
||||
inner = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
@@ -610,6 +808,7 @@ function SidebarSessionsSection({
|
||||
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
@@ -618,6 +817,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
function SidebarWorkspaceGroup({
|
||||
group,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
@@ -634,18 +834,31 @@ function SidebarWorkspaceGroup({
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<button
|
||||
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
@@ -663,7 +876,7 @@ function SidebarWorkspaceGroup({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
@@ -687,6 +900,7 @@ function SidebarWorkspaceGroup({
|
||||
interface SortableWorkspaceProps {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
}
|
||||
|
||||
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
|
||||
@@ -702,6 +916,7 @@ interface SortableSessionRowProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
|
||||
@@ -26,6 +26,7 @@ interface SessionActions {
|
||||
title: string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
@@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }
|
||||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -45,6 +46,7 @@ export function SidebarSessionRow({
|
||||
isPinned,
|
||||
isSelected,
|
||||
isWorking,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume,
|
||||
@@ -61,7 +63,14 @@ export function SidebarSessionRow({
|
||||
const handleLabel = `Reorder ${title}`
|
||||
|
||||
return (
|
||||
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<SessionContextMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
||||
@@ -88,6 +97,15 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onArchive()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onResume()
|
||||
}}
|
||||
type="button"
|
||||
@@ -127,7 +145,14 @@ export function SidebarSessionRow({
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<SessionActionsMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type FC, useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { sessionPinId } from '@/store/session'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
|
||||
@@ -12,6 +13,7 @@ interface SessionRowCommonProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -20,6 +22,7 @@ interface SessionRowCommonProps {
|
||||
interface VirtualSessionListProps {
|
||||
activeSessionId: null | string
|
||||
className?: string
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
@@ -35,6 +38,7 @@ const OVERSCAN_ROWS = 12
|
||||
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
activeSessionId,
|
||||
className,
|
||||
onArchiveSession,
|
||||
onDeleteSession,
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
@@ -72,8 +76,9 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onPin: () => onTogglePin(sessionPinId(session)),
|
||||
onResume: () => onResumeSession(session.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,37 +3,29 @@ import {
|
||||
IconBookmark,
|
||||
IconBookmarkFilled,
|
||||
IconDownload,
|
||||
IconLoader2,
|
||||
IconRefresh,
|
||||
IconSparkles,
|
||||
IconTrash
|
||||
} from '@tabler/icons-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
getActionStatus,
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getLogs,
|
||||
getStatus,
|
||||
getUsageAnalytics,
|
||||
restartGateway,
|
||||
searchSessions,
|
||||
setModelAssignment,
|
||||
updateHermes
|
||||
} from '@/hermes'
|
||||
import type {
|
||||
ActionStatusResponse,
|
||||
AnalyticsResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
ModelOptionProvider,
|
||||
SessionInfo,
|
||||
SessionSearchResult as SessionSearchApiResult,
|
||||
StatusResponse
|
||||
} from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
@@ -47,30 +39,9 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
|
||||
|
||||
export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage'
|
||||
export type CommandCenterSection = 'sessions' | 'system' | 'usage'
|
||||
|
||||
const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[]
|
||||
|
||||
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
|
||||
// hints make the assignments panel readable; raw task keys (vision, mcp, …)
|
||||
// are opaque to most users.
|
||||
interface AuxTaskMeta {
|
||||
hint: string
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const AUX_TASKS: readonly AuxTaskMeta[] = [
|
||||
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
|
||||
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
|
||||
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
|
||||
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
|
||||
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
|
||||
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
|
||||
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
|
||||
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
|
||||
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
|
||||
]
|
||||
const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[]
|
||||
|
||||
const USAGE_PERIODS = [7, 30, 90] as const
|
||||
type UsagePeriod = (typeof USAGE_PERIODS)[number]
|
||||
@@ -79,7 +50,6 @@ interface CommandCenterViewProps {
|
||||
initialSection?: CommandCenterSection
|
||||
onClose: () => void
|
||||
onDeleteSession: (sessionId: string) => Promise<void>
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
onNavigateRoute: (path: string) => void
|
||||
onOpenSession: (sessionId: string) => void
|
||||
}
|
||||
@@ -87,14 +57,12 @@ interface CommandCenterViewProps {
|
||||
const SECTION_LABELS: Record<CommandCenterSection, string> = {
|
||||
sessions: 'Sessions',
|
||||
system: 'System',
|
||||
models: 'Models',
|
||||
usage: 'Usage'
|
||||
}
|
||||
|
||||
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
|
||||
sessions: 'Search and manage sessions',
|
||||
system: 'Status, logs, and system actions',
|
||||
models: 'Global and auxiliary model controls',
|
||||
usage: 'Token, cost, and skill activity over time'
|
||||
}
|
||||
|
||||
@@ -113,9 +81,9 @@ interface SectionSearchEntry {
|
||||
}
|
||||
|
||||
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
|
||||
{
|
||||
id: 'nav-messaging',
|
||||
route: MESSAGING_ROUTE,
|
||||
@@ -128,7 +96,6 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
|
||||
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
|
||||
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
|
||||
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' },
|
||||
{ id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' }
|
||||
]
|
||||
|
||||
@@ -216,7 +183,6 @@ export function CommandCenterView({
|
||||
initialSection,
|
||||
onClose,
|
||||
onDeleteSession,
|
||||
onMainModelChanged,
|
||||
onNavigateRoute,
|
||||
onOpenSession
|
||||
}: CommandCenterViewProps) {
|
||||
@@ -233,16 +199,6 @@ export function CommandCenterView({
|
||||
const [systemLoading, setSystemLoading] = useState(false)
|
||||
const [systemError, setSystemError] = useState('')
|
||||
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState('')
|
||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
const [applyingModel, setApplyingModel] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
|
||||
const [usage, setUsage] = useState<AnalyticsResponse | null>(null)
|
||||
const [usageLoading, setUsageLoading] = useState(false)
|
||||
@@ -265,11 +221,6 @@ export function CommandCenterView({
|
||||
[sessions]
|
||||
)
|
||||
|
||||
const selectedProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
|
||||
[providers, selectedProvider]
|
||||
)
|
||||
|
||||
const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -342,29 +293,6 @@ export function CommandCenterView({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshModels = useCallback(async () => {
|
||||
setModelsLoading(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
setProviders(modelOptions.providers || [])
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshUsage = useCallback(async (days: UsagePeriod) => {
|
||||
const requestId = usageRequestRef.current + 1
|
||||
usageRequestRef.current = requestId
|
||||
@@ -430,28 +358,12 @@ export function CommandCenterView({
|
||||
}
|
||||
}, [refreshSystem, section, status, systemLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (section === 'models' && !mainModel && !modelsLoading) {
|
||||
void refreshModels()
|
||||
}
|
||||
}, [mainModel, modelsLoading, refreshModels, section])
|
||||
|
||||
useEffect(() => {
|
||||
if (section === 'usage') {
|
||||
void refreshUsage(usagePeriod)
|
||||
}
|
||||
}, [refreshUsage, section, usagePeriod])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProviderModels.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedProviderModels.includes(selectedModel)) {
|
||||
setSelectedModel(selectedProviderModels[0])
|
||||
}
|
||||
}, [selectedModel, selectedProviderModels])
|
||||
|
||||
const showGlobalSearchResults = debouncedQuery.length > 0
|
||||
const hasGlobalSearchResults = searchGroups.length > 0
|
||||
const sessionListHasResults = filteredSessions.length > 0
|
||||
@@ -497,128 +409,6 @@ export function CommandCenterView({
|
||||
[refreshSystem]
|
||||
)
|
||||
|
||||
const applyMainModel = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
const result = await setModelAssignment({
|
||||
model: selectedModel,
|
||||
provider: selectedProvider,
|
||||
scope: 'main'
|
||||
})
|
||||
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
onMainModelChanged?.(provider, model)
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
}, [onMainModelChanged, refreshModels, selectedModel, selectedProvider])
|
||||
|
||||
const setAuxiliaryToMain = useCallback(
|
||||
async (task: string) => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task
|
||||
})
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
},
|
||||
[mainModel, refreshModels]
|
||||
)
|
||||
|
||||
const applyAuxiliaryDraft = useCallback(
|
||||
async (task: string) => {
|
||||
if (!auxDraft.provider || !auxDraft.model) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: auxDraft.model,
|
||||
provider: auxDraft.provider,
|
||||
scope: 'auxiliary',
|
||||
task
|
||||
})
|
||||
setEditingAuxTask(null)
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
},
|
||||
[auxDraft, refreshModels]
|
||||
)
|
||||
|
||||
const beginAuxiliaryEdit = useCallback(
|
||||
(task: string) => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === task)
|
||||
|
||||
const initialProvider =
|
||||
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
|
||||
|
||||
const initialModel = current?.model || mainModel?.model || ''
|
||||
setAuxDraft({ provider: initialProvider, model: initialModel })
|
||||
setEditingAuxTask(task)
|
||||
},
|
||||
[auxiliary, mainModel]
|
||||
)
|
||||
|
||||
const auxDraftProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
|
||||
[auxDraft.provider, providers]
|
||||
)
|
||||
|
||||
const resetAuxiliaryModels = useCallback(async () => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task: '__reset__'
|
||||
})
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
}, [mainModel, refreshModels])
|
||||
|
||||
const handleSearchSelect = useCallback(
|
||||
(result: CommandCenterSearchResult) => {
|
||||
if (result.kind === 'route') {
|
||||
@@ -658,7 +448,7 @@ export function CommandCenterView({
|
||||
{SECTIONS.map(value => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
|
||||
key={value}
|
||||
label={SECTION_LABELS[value]}
|
||||
onClick={() => setSection(value)}
|
||||
@@ -684,12 +474,6 @@ export function CommandCenterView({
|
||||
{usageLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
{section === 'models' && (
|
||||
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
|
||||
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
|
||||
{modelsLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showGlobalSearchResults ? (
|
||||
@@ -844,7 +628,7 @@ export function CommandCenterView({
|
||||
period={usagePeriod}
|
||||
usage={usage}
|
||||
/>
|
||||
) : section === 'system' ? (
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3 text-sm">
|
||||
{status ? (
|
||||
@@ -902,154 +686,6 @@ export function CommandCenterView({
|
||||
</pre>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3">
|
||||
{mainModel ? (
|
||||
<>
|
||||
<div className="text-sm font-medium text-foreground">Main model</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mainModel.provider} / {mainModel.model}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">Loading model state...</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedProvider(event.target.value)}
|
||||
value={selectedProvider}
|
||||
>
|
||||
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
|
||||
<option key={provider.slug || 'none'} value={provider.slug}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedModel(event.target.value)}
|
||||
value={selectedModel}
|
||||
>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
|
||||
<option key={model || 'none'} value={model}>
|
||||
{model || 'No models available'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OverlayActionButton
|
||||
disabled={!selectedProvider || !selectedModel || applyingModel}
|
||||
onClick={() => void applyMainModel()}
|
||||
>
|
||||
{applyingModel ? (
|
||||
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<IconSparkles className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
{applyingModel ? 'Applying...' : 'Apply'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="min-h-0 overflow-auto p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
tone="subtle"
|
||||
>
|
||||
Reset all
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{AUX_TASKS.map(meta => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
||||
const isAuto = !current || !current.provider || current.provider === 'auto'
|
||||
const isEditing = editingAuxTask === meta.key
|
||||
|
||||
return (
|
||||
<OverlayCard className="px-2 py-1.5" key={meta.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-foreground">{meta.label}</span>
|
||||
<span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-[0.62rem] text-muted-foreground">
|
||||
{isAuto
|
||||
? 'auto · use main model'
|
||||
: `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
tone="subtle"
|
||||
>
|
||||
Set to main
|
||||
</OverlayActionButton>
|
||||
<OverlayActionButton
|
||||
disabled={!providers.length || applyingModel}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
>
|
||||
Change
|
||||
</OverlayActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<select
|
||||
className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
|
||||
onChange={event =>
|
||||
setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' }))
|
||||
}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
|
||||
<option key={provider.slug || 'none'} value={provider.slug}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
|
||||
onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
{(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => (
|
||||
<option key={model || 'none'} value={model}>
|
||||
{model || 'No models available'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OverlayActionButton
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applyingModel}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
>
|
||||
{applyingModel ? 'Applying...' : 'Apply'}
|
||||
</OverlayActionButton>
|
||||
<OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle">
|
||||
Cancel
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
@@ -31,8 +32,12 @@ import {
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages,
|
||||
@@ -122,6 +127,7 @@ export function DesktopController() {
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
||||
|
||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||
@@ -192,7 +198,10 @@ export function DesktopController() {
|
||||
|
||||
try {
|
||||
const limit = $sessionsLimit.get()
|
||||
const result = await listSessions(limit)
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
@@ -217,10 +226,14 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
if ($pinnedSessionIds.get().includes(sessionId)) {
|
||||
unpinSession(sessionId)
|
||||
// Pin on the durable lineage-root id so the pin survives auto-compression.
|
||||
const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId)
|
||||
const pinId = session ? sessionPinId(session) : sessionId
|
||||
|
||||
if ($pinnedSessionIds.get().includes(pinId)) {
|
||||
unpinSession(pinId)
|
||||
} else {
|
||||
pinSession(sessionId)
|
||||
pinSession(pinId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -324,6 +337,7 @@ export function DesktopController() {
|
||||
})
|
||||
|
||||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
@@ -392,6 +406,29 @@ export function DesktopController() {
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
const target = path?.trim()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
// The next message creates the backend session in $currentCwd, so seed
|
||||
// it (and the branch) from the workspace the user clicked the + on.
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
setCurrentCwd(info.cwd || target)
|
||||
setCurrentBranch(info.branch || '')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
@@ -461,9 +498,11 @@ export function DesktopController() {
|
||||
const sidebar = (
|
||||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
)
|
||||
@@ -485,6 +524,7 @@ export function DesktopController() {
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
{settingsOpen && (
|
||||
@@ -497,6 +537,13 @@ export function DesktopController() {
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -507,13 +554,6 @@ export function DesktopController() {
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
@@ -575,10 +615,10 @@ export function DesktopController() {
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
disabled={terminalTakeoverActive}
|
||||
resizable
|
||||
side="left"
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
@@ -751,7 +751,39 @@ export function useSessionActions({
|
||||
]
|
||||
)
|
||||
|
||||
const archiveSession = useCallback(
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
notifyError(err, 'Archive failed')
|
||||
}
|
||||
},
|
||||
[selectedStoredSessionId, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
return {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
@@ -167,10 +168,12 @@ export function ConfigSettings({
|
||||
query,
|
||||
activeSectionId,
|
||||
onConfigSaved,
|
||||
onMainModelChanged,
|
||||
importInputRef
|
||||
}: SearchProps & {
|
||||
activeSectionId: string
|
||||
onConfigSaved?: () => void
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
importInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}) {
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
@@ -322,6 +325,11 @@ export function ConfigSettings({
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{activeSectionId === 'model' && !query.trim() && (
|
||||
<div className="mb-6">
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{query.trim() && (
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
{fields.length} result{fields.length === 1 ? '' : 's'}
|
||||
|
||||
@@ -141,13 +141,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
'delegation.max_iterations': 'Subagent Turn Limit',
|
||||
'delegation.max_concurrent_children': 'Parallel Subagents',
|
||||
'delegation.child_timeout_seconds': 'Subagent Timeout',
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
|
||||
'auxiliary.vision.provider': 'Vision Provider',
|
||||
'auxiliary.vision.model': 'Vision Model',
|
||||
'auxiliary.compression.provider': 'Compression Provider',
|
||||
'auxiliary.compression.model': 'Compression Model',
|
||||
'auxiliary.title_generation.provider': 'Title Provider',
|
||||
'auxiliary.title_generation.model': 'Title Model'
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
|
||||
}
|
||||
|
||||
export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -183,7 +177,7 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
icon: Sparkles,
|
||||
keys: ['model', 'model_context_length', 'fallback_providers']
|
||||
keys: ['model_context_length', 'fallback_providers']
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
@@ -287,13 +281,7 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
'delegation.max_iterations',
|
||||
'delegation.max_concurrent_children',
|
||||
'delegation.child_timeout_seconds',
|
||||
'delegation.reasoning_effort',
|
||||
'auxiliary.vision.provider',
|
||||
'auxiliary.vision.model',
|
||||
'auxiliary.compression.provider',
|
||||
'auxiliary.compression.model',
|
||||
'auxiliary.title_generation.provider',
|
||||
'auxiliary.title_generation.model'
|
||||
'delegation.reasoning_effort'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -311,11 +299,11 @@ export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
tools: 'Search skills and tools...'
|
||||
sessions: 'Search archived sessions...'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -19,7 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
@@ -27,11 +27,11 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'tools',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) {
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
|
||||
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
|
||||
@@ -40,7 +40,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
gateway: '',
|
||||
keys: '',
|
||||
mcp: '',
|
||||
tools: ''
|
||||
sessions: ''
|
||||
})
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -137,18 +137,18 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
label="API Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'tools'}
|
||||
icon={Package}
|
||||
label="Skills & Tools"
|
||||
onClick={() => setActiveView('tools')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
label="MCP"
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label="Archived Chats"
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
@@ -194,6 +194,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
activeSectionId={activeView.slice('config:'.length)}
|
||||
importInputRef={importInputRef}
|
||||
onConfigSaved={onConfigSaved}
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
query={queries.config}
|
||||
/>
|
||||
) : activeView === 'keys' ? (
|
||||
@@ -201,7 +202,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
<SessionsSettings query={queries.sessions} />
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getGlobalModelInfo = vi.fn()
|
||||
const getGlobalModelOptions = vi.fn()
|
||||
const getAuxiliaryModels = vi.fn()
|
||||
const setModelAssignment = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: () => getGlobalModelInfo(),
|
||||
getGlobalModelOptions: () => getGlobalModelOptions(),
|
||||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body)
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }]
|
||||
})
|
||||
getAuxiliaryModels.mockResolvedValue({
|
||||
main: { provider: 'nous', model: 'hermes-4' },
|
||||
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
|
||||
})
|
||||
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderModelSettings() {
|
||||
const { ModelSettings } = await import('./model-settings')
|
||||
|
||||
return render(<ModelSettings />)
|
||||
}
|
||||
|
||||
describe('ModelSettings', () => {
|
||||
it('loads and shows the current main model', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
|
||||
expect(screen.getByText('nous / hermes-4')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the auxiliary task rows', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
expect(await screen.findByText('Vision')).toBeTruthy()
|
||||
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
// One "Set to main" button per task slot; the first is Vision.
|
||||
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
|
||||
fireEvent.click(setToMainButtons[0])
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setModelAssignment).toHaveBeenCalledWith({
|
||||
model: 'hermes-4',
|
||||
provider: 'nous',
|
||||
scope: 'auxiliary',
|
||||
task: 'vision'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
|
||||
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
|
||||
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
|
||||
// hints make the assignments readable; raw task keys (vision, mcp, …) are
|
||||
// opaque to most users.
|
||||
interface AuxTaskMeta {
|
||||
hint: string
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const AUX_TASKS: readonly AuxTaskMeta[] = [
|
||||
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
|
||||
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
|
||||
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
|
||||
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
|
||||
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
|
||||
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
|
||||
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
|
||||
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
|
||||
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
|
||||
]
|
||||
|
||||
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
|
||||
|
||||
interface ModelSettingsProps {
|
||||
/** Notified after the main model is applied, so live UI stores can sync. */
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
}
|
||||
|
||||
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
setProviders(modelOptions.providers || [])
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const providerOptions = providers.length ? providers : NO_PROVIDERS
|
||||
|
||||
const selectedProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
|
||||
[providers, selectedProvider]
|
||||
)
|
||||
|
||||
const auxDraftProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
|
||||
[auxDraft.provider, providers]
|
||||
)
|
||||
|
||||
const applyMainModel = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' })
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
onMainModelChanged?.(provider, model)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}, [onMainModelChanged, refresh, selectedModel, selectedProvider])
|
||||
|
||||
const setAuxiliaryToMain = useCallback(
|
||||
async (task: string) => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task })
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
},
|
||||
[mainModel, refresh]
|
||||
)
|
||||
|
||||
const applyAuxiliaryDraft = useCallback(
|
||||
async (task: string) => {
|
||||
if (!auxDraft.provider || !auxDraft.model) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task })
|
||||
setEditingAuxTask(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
},
|
||||
[auxDraft, refresh]
|
||||
)
|
||||
|
||||
const beginAuxiliaryEdit = useCallback(
|
||||
(task: string) => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === task)
|
||||
|
||||
const initialProvider =
|
||||
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
|
||||
|
||||
const initialModel = current?.model || mainModel?.model || ''
|
||||
setAuxDraft({ provider: initialProvider, model: initialModel })
|
||||
setEditingAuxTask(task)
|
||||
},
|
||||
[auxiliary, mainModel]
|
||||
)
|
||||
|
||||
const resetAuxiliaryModels = useCallback(async () => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task: '__reset__'
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}, [mainModel, refresh])
|
||||
|
||||
if (loading && !mainModel) {
|
||||
return <LoadingState label="Loading model configuration..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<SectionHeading
|
||||
icon={Sparkles}
|
||||
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
|
||||
title="Main model"
|
||||
/>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
|
||||
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
|
||||
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<SectionHeading icon={Cpu} title="Auxiliary models" />
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Reset all to main
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
|
||||
</p>
|
||||
<div className="divide-y divide-border/40">
|
||||
{AUX_TASKS.map(meta => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
||||
const isAuto = !current || !current.provider || current.provider === 'auto'
|
||||
const isEditing = editingAuxTask === meta.key
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
!isEditing && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Set to main
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!providers.length || applying}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
below={
|
||||
isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applying}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
size="sm"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</span>
|
||||
}
|
||||
key={meta.key}
|
||||
title={
|
||||
<span className="flex items-baseline gap-2">
|
||||
{meta.label}
|
||||
<Pill>{meta.hint}</Pill>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
|
||||
function workspaceLabel(cwd: null | string | undefined): string {
|
||||
const path = cwd?.trim()
|
||||
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
path
|
||||
.replace(/[/\\]+$/, '')
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? path
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionsSettings({ query }: SearchProps) {
|
||||
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not load archived sessions')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const unarchive = useCallback(async (session: SessionInfo) => {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await setSessionArchived(session.id, false)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
triggerHaptic('selection')
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Unarchive failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remove = useCallback(async (session: SessionInfo) => {
|
||||
if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
triggerHaptic('warning')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Delete failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
return sessions.filter(session =>
|
||||
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
|
||||
)
|
||||
}, [query, sessions])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading archived sessions…" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
title="Archived sessions"
|
||||
/>
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to
|
||||
archive it.
|
||||
</p>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
|
||||
title="Nothing archived"
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{filtered.map(session => {
|
||||
const label = workspaceLabel(session.cwd)
|
||||
const busy = busyId === session.id
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
key={session.id}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { Brain, Wrench } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { asText, includesQuery, prettyName, toolNames } from './helpers'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { ToolsetConfigPanel } from './toolset-config-panel'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
export function ToolsSettings({ query }: SearchProps) {
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([getSkills(), getToolsets()])
|
||||
.then(([s, t]) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setSkills(s)
|
||||
setToolsets(t)
|
||||
})
|
||||
.catch(err => notifyError(err, 'Capabilities failed to load'))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return skills
|
||||
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
|
||||
.sort(
|
||||
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
|
||||
)
|
||||
}, [query, skills])
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
if (!toolsets) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return toolsets
|
||||
.filter(t => {
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
includesQuery(t.name, q) ||
|
||||
includesQuery(t.label, q) ||
|
||||
includesQuery(t.description, q) ||
|
||||
toolNames(t).some(n => includesQuery(n, q))
|
||||
)
|
||||
})
|
||||
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
|
||||
}, [query, toolsets])
|
||||
|
||||
const skillGroups = useMemo(() => {
|
||||
const groups = new Map<string, SkillInfo[]>()
|
||||
|
||||
for (const skill of filteredSkills) {
|
||||
const cat = asText(skill.category) || 'other'
|
||||
groups.set(cat, [...(groups.get(cat) ?? []), skill])
|
||||
}
|
||||
|
||||
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [filteredSkills])
|
||||
|
||||
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
|
||||
setSavingSkill(skill.name)
|
||||
|
||||
try {
|
||||
await toggleSkill(skill.name, enabled)
|
||||
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
||||
message: `${skill.name} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${skill.name}`)
|
||||
} finally {
|
||||
setSavingSkill(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
|
||||
setSavingToolset(toolset.name)
|
||||
|
||||
try {
|
||||
await toggleToolset(toolset.name, enabled)
|
||||
setToolsets(c => c?.map(t => (t.name === toolset.name ? { ...t, enabled, available: enabled } : t)) ?? c)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!skills || !toolsets) {
|
||||
return <LoadingState label="Loading skills and toolsets..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="mt-4 first:mt-0" key={category}>
|
||||
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{list.map(skill => (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={savingSkill === skill.name}
|
||||
onCheckedChange={c => void handleToggleSkill(skill, c)}
|
||||
/>
|
||||
}
|
||||
description={asText(skill.description)}
|
||||
key={asText(skill.name)}
|
||||
title={asText(skill.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Wrench}
|
||||
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
|
||||
title="Toolsets"
|
||||
/>
|
||||
<div className="divide-y divide-border/40">
|
||||
{filteredToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
const expanded = expandedToolset === toolset.name
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(c => (c === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</Pill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={c => void handleToggleToolset(toolset, c)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
below={
|
||||
<>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{tools.slice(0, 10).map(t => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{tools.length > 10 && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
|
||||
+{tools.length - 10} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && (
|
||||
<ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={asText(toolset.description)}
|
||||
key={asText(toolset.name) || label}
|
||||
title={label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
return {
|
||||
name: 'tts',
|
||||
has_category: true,
|
||||
active_provider: null,
|
||||
providers: [
|
||||
{
|
||||
name: 'Microsoft Edge TTS',
|
||||
@@ -33,7 +34,8 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
tag: 'No API key needed',
|
||||
env_vars: [],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false
|
||||
requires_nous_auth: false,
|
||||
is_active: false
|
||||
},
|
||||
{
|
||||
name: 'ElevenLabs',
|
||||
@@ -43,7 +45,8 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
{ key: 'ELEVENLABS_API_KEY', prompt: 'ElevenLabs API key', url: 'https://x', default: null, is_set: false }
|
||||
],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false
|
||||
requires_nous_auth: false,
|
||||
is_active: false
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
@@ -99,4 +102,54 @@ describe('ToolsetConfigPanel', () => {
|
||||
|
||||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('ELEVENLABS_API_KEY', 'sk-test-123'))
|
||||
})
|
||||
|
||||
it('expands the active provider on load, not just the first configured one', async () => {
|
||||
// ElevenLabs is the active provider per config, even though the keyless
|
||||
// Edge TTS provider sorts first and is also "configured". The panel must
|
||||
// honor is_active and expand ElevenLabs (so its API-key field renders)
|
||||
// rather than defaulting to the first keyless provider. Regression test
|
||||
// for the GUI showing the wrong provider selected after relaunch.
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
active_provider: 'ElevenLabs',
|
||||
providers: [
|
||||
{
|
||||
name: 'Microsoft Edge TTS',
|
||||
badge: 'free',
|
||||
tag: 'No API key needed',
|
||||
env_vars: [],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: false
|
||||
},
|
||||
{
|
||||
name: 'ElevenLabs',
|
||||
badge: 'paid',
|
||||
tag: 'Most natural voices',
|
||||
env_vars: [
|
||||
{
|
||||
key: 'ELEVENLABS_API_KEY',
|
||||
prompt: 'ElevenLabs API key',
|
||||
url: 'https://x',
|
||||
default: null,
|
||||
is_set: true
|
||||
}
|
||||
],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
// The active provider's env-var field only renders when it's the expanded
|
||||
// one — so finding it proves ElevenLabs (not Edge TTS) was auto-expanded.
|
||||
expect(await screen.findByText('ELEVENLABS_API_KEY')).toBeTruthy()
|
||||
// No provider selection was triggered — this is purely reflecting state.
|
||||
expect(selectToolsetProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -195,16 +195,23 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
|
||||
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
|
||||
|
||||
// Default the expanded provider to the first one that is fully configured,
|
||||
// else the first provider.
|
||||
// Default the expanded provider to the one actually active in config
|
||||
// (`is_active` / `cfg.active_provider`, mirroring the CLI picker), then the
|
||||
// first fully-configured provider, else the first provider. Without this the
|
||||
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
|
||||
// the user had already selected another (e.g. DuckDuckGo).
|
||||
useEffect(() => {
|
||||
if (activeProvider || providers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const configured = providers.find(p => providerConfigured(p, envState))
|
||||
setActiveProvider((configured ?? providers[0]).name)
|
||||
}, [activeProvider, providers, envState])
|
||||
const selected =
|
||||
providers.find(p => p.is_active) ??
|
||||
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
|
||||
providers.find(p => providerConfigured(p, envState)) ??
|
||||
providers[0]
|
||||
setActiveProvider(selected.name)
|
||||
}, [activeProvider, providers, envState, cfg])
|
||||
|
||||
async function handleSelect(provider: ToolProvider) {
|
||||
setActiveProvider(provider.name)
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
gateway?: HermesGateway | null
|
||||
onClose: () => void
|
||||
onConfigSaved?: () => void
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
}
|
||||
|
||||
export interface SearchProps {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { type CommandCenterSection } from '@/app/command-center'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
|
||||
const SECTIONS = ['models', 'sessions', 'system'] as const
|
||||
const SECTIONS = ['sessions', 'system', 'usage'] as const
|
||||
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
|
||||
|
||||
export function useOverlayRouting() {
|
||||
|
||||
+32
-6
@@ -1,16 +1,24 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const toggleSkill = vi.fn()
|
||||
const toggleToolset = vi.fn()
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getSkills: () => getSkills(),
|
||||
getToolsets: () => getToolsets(),
|
||||
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled)
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
|
||||
deleteEnvVar: vi.fn(),
|
||||
revealEnvVar: vi.fn(),
|
||||
setEnvVar: vi.fn()
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
@@ -32,10 +40,21 @@ function toolset(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkills() {
|
||||
return import('./index').then(({ SkillsView }) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -43,10 +62,9 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ToolsSettings toolset toggle', () => {
|
||||
describe('SkillsView toolset management', () => {
|
||||
it('renders a switch for each toolset and toggles it off', async () => {
|
||||
const { ToolsSettings } = await import('./tools-settings')
|
||||
render(<ToolsSettings query="" />)
|
||||
await renderSkills()
|
||||
|
||||
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(sw.getAttribute('aria-checked')).toBe('true')
|
||||
@@ -57,10 +75,18 @@ describe('ToolsSettings toolset toggle', () => {
|
||||
})
|
||||
|
||||
it('keeps the configured pill alongside the switch', async () => {
|
||||
const { ToolsSettings } = await import('./tools-settings')
|
||||
render(<ToolsSettings query="" />)
|
||||
await renderSkills()
|
||||
|
||||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(screen.getByText('Configured')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expands the provider config panel when the configured pill is clicked', async () => {
|
||||
await renderSkills()
|
||||
|
||||
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
|
||||
fireEvent.click(configureBtn)
|
||||
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
@@ -14,6 +14,7 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
const SKILLS_MODES = ['skills', 'toolsets'] as const
|
||||
@@ -73,6 +74,8 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
@@ -88,6 +91,12 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCapabilities()
|
||||
}, [refreshCapabilities])
|
||||
@@ -148,6 +157,26 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
|
||||
setSavingToolset(toolset.name)
|
||||
|
||||
try {
|
||||
await toggleToolset(toolset.name, enabled)
|
||||
setToolsets(current =>
|
||||
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
@@ -248,16 +277,30 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
const expanded = expandedToolset === toolset.name
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5" key={toolset.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -275,6 +318,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaMime,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
mediaPathFromMarkdownHref,
|
||||
mediaStreamUrl
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -40,24 +40,22 @@ import { cn } from '@/lib/utils'
|
||||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
|
||||
const blob = await fetch(dataUrl).then(response => response.blob())
|
||||
|
||||
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
|
||||
}
|
||||
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Stream audio/video through the custom protocol: data URLs are capped and
|
||||
// load the whole file into memory, which broke playback for larger videos.
|
||||
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
|
||||
return mediaStreamUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
|
||||
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
}
|
||||
|
||||
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
|
||||
@@ -278,10 +276,7 @@ const MarkdownTextImpl = () => {
|
||||
// render, which churns Streamdown's outer memo + propagates new prop
|
||||
// identities into every Block. The plugin set really only varies on
|
||||
// `isStreaming`, so memoize on that.
|
||||
const plugins = useMemo(
|
||||
() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),
|
||||
[isStreaming]
|
||||
)
|
||||
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -62,9 +62,7 @@ function formatStageName(name: string): string {
|
||||
if (name.length <= 3) return name
|
||||
return name
|
||||
.split('-')
|
||||
.map((word, i) =>
|
||||
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
|
||||
)
|
||||
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
@@ -116,17 +114,10 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||
state === 'failed' && 'bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-sm font-medium',
|
||||
state === 'pending' && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn('truncate text-sm font-medium', state === 'pending' && 'text-muted-foreground')}>
|
||||
{formatStageName(descriptor.name)}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
@@ -135,9 +126,7 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||
{state === 'failed' ? STATE_LABEL[state] : null}
|
||||
</span>
|
||||
</div>
|
||||
{reason && state !== 'pending' && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
|
||||
)}
|
||||
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@@ -180,7 +169,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
durationMs: ev.durationMs ?? null,
|
||||
// Stamp the start time on the running transition so the UI can show
|
||||
// a live elapsed timer; preserve it across repeated running events.
|
||||
startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null,
|
||||
startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null),
|
||||
json: ev.json ?? null,
|
||||
error: ev.error ?? null
|
||||
}
|
||||
@@ -217,6 +206,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const logEndRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -293,8 +283,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and
|
||||
run the command below, then relaunch this app. Subsequent launches will skip this step.
|
||||
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the
|
||||
command below, then relaunch this app. Subsequent launches will skip this step.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -328,11 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
|
||||
I{'\u2019'}ve run it -- retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -362,7 +348,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{failed
|
||||
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
|
||||
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
|
||||
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
|
||||
'Subsequent launches will skip this step.'}
|
||||
</p>
|
||||
@@ -382,10 +368,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300',
|
||||
failed ? 'bg-destructive' : 'bg-primary'
|
||||
)}
|
||||
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -431,14 +414,18 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
>
|
||||
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
|
||||
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
|
||||
<span className="ml-1 tabular-nums">
|
||||
({state.log.length} line{state.log.length === 1 ? '' : 's'})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{logOpen && (
|
||||
<div className={cn(
|
||||
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
|
||||
failed ? 'max-h-96' : 'max-h-64'
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
|
||||
failed ? 'max-h-96' : 'max-h-64'
|
||||
)}
|
||||
>
|
||||
{state.log.length === 0 ? (
|
||||
<div className="text-muted-foreground">No output yet.</div>
|
||||
) : (
|
||||
@@ -457,12 +444,38 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active footer: let the user actually cancel a running install. */}
|
||||
{state.active && !failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
disabled={cancelling}
|
||||
onClick={async () => {
|
||||
setCancelling(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop?.cancelBootstrap?.()
|
||||
} catch {
|
||||
// ignore -- the failed/cancelled event will surface the result
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{cancelling ? 'Cancelling...' : 'Cancel install'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer -- always visible, never scrolls; only renders on failure */}
|
||||
{failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
Full transcript saved to{' '}
|
||||
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
|
||||
// the render level means no timer logic (even a stale HMR one) can ever
|
||||
// scramble "CONN".
|
||||
const PREFIX = 'CONN'
|
||||
const TAIL = 'ECTING'
|
||||
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
|
||||
// nousnet-web download-button decode effect).
|
||||
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
|
||||
const TICK_MS = 45
|
||||
|
||||
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
|
||||
const TEXT_OUT_MS = 360
|
||||
const POST_TEXT_HOLD_MS = 300
|
||||
const OVERLAY_OUT_MS = 520
|
||||
// Preview-only: how long to "connect" for, and the pause before replaying.
|
||||
const PREVIEW_CONNECT_MS = 2600
|
||||
const PREVIEW_REPLAY_MS = 1100
|
||||
|
||||
type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'
|
||||
|
||||
// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay
|
||||
// only flashes. Load with `?connecting=1` to force a looping preview.
|
||||
function forcedPreview(): boolean {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('connecting') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function scrambledTail(resolvedCount: number): string {
|
||||
return Array.from(TAIL, (ch, i) =>
|
||||
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
|
||||
).join('')
|
||||
}
|
||||
|
||||
export function GatewayConnectingOverlay() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const boot = useStore($desktopBoot)
|
||||
const [previewing] = useState(forcedPreview)
|
||||
const [tail, setTail] = useState(TAIL)
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
|
||||
const connecting = gatewayState !== 'open' && !boot.error
|
||||
// Latches once we've actually shown the overlay, so the brief frame where
|
||||
// gatewayState flips to "open" (connecting -> false) before the exit phase
|
||||
// kicks in doesn't unmount us and cause a flash.
|
||||
const shownRef = useRef(false)
|
||||
|
||||
if (previewing || connecting) {
|
||||
shownRef.current = true
|
||||
}
|
||||
|
||||
// Decode loop — only while live (freeze the resolved word during the exit).
|
||||
useEffect(() => {
|
||||
if (phase !== 'live' || (!previewing && !connecting)) {
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = 0
|
||||
let hold = 0
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
if (resolved >= TAIL.length) {
|
||||
hold += 1
|
||||
|
||||
if (hold > 16) {
|
||||
resolved = 0
|
||||
hold = 0
|
||||
}
|
||||
|
||||
setTail(TAIL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolved += 0.5
|
||||
setTail(scrambledTail(Math.floor(resolved)))
|
||||
}, TICK_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [phase, previewing, connecting])
|
||||
|
||||
// Kick off the exit when connected: real connect, or a faked timer in preview.
|
||||
useEffect(() => {
|
||||
if (phase !== 'live') {
|
||||
return
|
||||
}
|
||||
|
||||
if (previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}, PREVIEW_CONNECT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (gatewayState === 'open' && shownRef.current) {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}
|
||||
}, [phase, previewing, gatewayState])
|
||||
|
||||
// Advance the exit choreography: text-out -> overlay-out -> gone.
|
||||
useEffect(() => {
|
||||
if (phase === 'text-out') {
|
||||
const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (phase === 'overlay-out') {
|
||||
const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
// Preview replays so we can keep watching the transition.
|
||||
if (phase === 'gone' && previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('live')
|
||||
}, PREVIEW_REPLAY_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
}, [phase, previewing])
|
||||
|
||||
// Boot failed — BootFailureOverlay owns the screen; don't linger behind it.
|
||||
if (boot.error && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Real connect: once the fade finishes, get out of the way for good.
|
||||
if (phase === 'gone' && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Never showed (e.g. gateway already up on a warm reload) — stay out.
|
||||
if (!previewing && !connecting && !shownRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const leaving = phase !== 'live'
|
||||
const overlayHidden = phase === 'overlay-out' || phase === 'gone'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
|
||||
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
{PREFIX}
|
||||
{tail}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
|
||||
style={{ animation: 'gco-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+2
-7
@@ -48,6 +48,7 @@ declare global {
|
||||
getBootstrapState: () => Promise<DesktopBootstrapState>
|
||||
resetBootstrap: () => Promise<{ ok: boolean }>
|
||||
repairBootstrap: () => Promise<{ ok: boolean }>
|
||||
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
|
||||
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
|
||||
getVersion: () => Promise<DesktopVersionInfo>
|
||||
updates: {
|
||||
@@ -194,12 +195,7 @@ export interface DesktopBootstrapStageDescriptor {
|
||||
needs_user_input?: boolean
|
||||
}
|
||||
|
||||
export type DesktopBootstrapStageState =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface DesktopBootstrapStageResult {
|
||||
state: DesktopBootstrapStageState
|
||||
@@ -248,7 +244,6 @@ export type DesktopBootstrapEvent =
|
||||
docsUrl: string
|
||||
}
|
||||
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
||||
@@ -111,9 +111,14 @@ export class HermesGateway extends JsonRpcGatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -123,6 +128,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
})
|
||||
}
|
||||
|
||||
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
return window.hermesDesktop.api<SessionSearchResponse>({
|
||||
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
||||
|
||||
@@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([
|
||||
|
||||
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
|
||||
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model'])
|
||||
|
||||
const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/browser',
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
IconActivity as Activity,
|
||||
IconAlertCircle as AlertCircle,
|
||||
IconAlertTriangle as AlertTriangle,
|
||||
IconArchive as Archive,
|
||||
IconArchiveOff as ArchiveOff,
|
||||
IconArrowUp as ArrowUp,
|
||||
IconArrowUpRight as ArrowUpRight,
|
||||
IconAt as AtSign,
|
||||
@@ -98,6 +100,8 @@ export {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
ArchiveOff,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
AtSign,
|
||||
|
||||
@@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
|
||||
// file with Range support. Used for audio/video so playback bypasses the data
|
||||
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
|
||||
export function mediaStreamUrl(path: string): string {
|
||||
return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
|
||||
}
|
||||
|
||||
export function mediaPathFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#media:')) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { sessionPinId } from './session'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
cwd: null,
|
||||
ended_at: null,
|
||||
id: 'live',
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 0,
|
||||
message_count: 0,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
source: null,
|
||||
started_at: 0,
|
||||
title: null,
|
||||
tool_call_count: 0,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('sessionPinId', () => {
|
||||
it('uses the live id when there is no compression lineage', () => {
|
||||
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
|
||||
})
|
||||
|
||||
it('uses the lineage root so a pin survives compression', () => {
|
||||
// After auto-compression the entry surfaces under a fresh tip id but keeps
|
||||
// the original root — pinning on the root keeps the pin stable.
|
||||
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,12 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
||||
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
|
||||
}
|
||||
|
||||
/** Durable id for pinning. Auto-compression rotates a conversation's session
|
||||
* id (root -> continuation tip), so pins keyed on the live id evaporate. The
|
||||
* lineage root is stable across every compression, so we pin on that. */
|
||||
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||||
session._lineage_root_id ?? session.id
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
vi.mock('@/lib/storage', () => ({
|
||||
persistString: (key: string, value: null | string) => {
|
||||
if (value === null) {
|
||||
storage.delete(key)
|
||||
} else {
|
||||
storage.set(key, value)
|
||||
}
|
||||
},
|
||||
storedString: (key: string) => storage.get(key) ?? null
|
||||
}))
|
||||
|
||||
const notifySpy = vi.fn()
|
||||
const dismissSpy = vi.fn()
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: (...args: unknown[]) => notifySpy(...args),
|
||||
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable } = await import('./updates')
|
||||
|
||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
supported: true,
|
||||
behind: 3,
|
||||
targetSha: 'sha-a',
|
||||
fetchedAt: 0,
|
||||
...over
|
||||
})
|
||||
|
||||
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
|
||||
|
||||
describe('maybeNotifyUpdateAvailable', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
notifySpy.mockClear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows when an update is available and not snoozed', () => {
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays quiet for new commits once the toast was closed', () => {
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
lastToast().onDismiss() // user closes it → cooldown starts
|
||||
notifySpy.mockClear()
|
||||
|
||||
// A different commit lands while still within the cooldown window.
|
||||
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b', behind: 9 }))
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-shows once the cooldown elapses', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
lastToast().onDismiss()
|
||||
notifySpy.mockClear()
|
||||
|
||||
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
|
||||
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b' }))
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does nothing when already up to date', () => {
|
||||
maybeNotifyUpdateAvailable(status({ behind: 0 }))
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -48,7 +48,22 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
|
||||
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
|
||||
|
||||
const UPDATE_TOAST_ID = 'desktop-update-available'
|
||||
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
|
||||
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
|
||||
// a day, so a "don't show this exact sha again" guard re-popped the toast on
|
||||
// every new commit. We instead suppress the toast for a cooldown window that
|
||||
// (re)starts whenever the user closes it.
|
||||
const UPDATE_TOAST_SNOOZE_KEY = 'hermes:update-toast-snooze-until'
|
||||
const UPDATE_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function snoozeUpdateToast(): void {
|
||||
persistString(UPDATE_TOAST_SNOOZE_KEY, String(Date.now() + UPDATE_TOAST_COOLDOWN_MS))
|
||||
}
|
||||
|
||||
function isUpdateToastSnoozed(): boolean {
|
||||
const until = Number(storedString(UPDATE_TOAST_SNOOZE_KEY) || 0)
|
||||
|
||||
return Number.isFinite(until) && Date.now() < until
|
||||
}
|
||||
|
||||
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
|
||||
// against. The backend reports its own value in session runtime info; a lower
|
||||
@@ -74,25 +89,18 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
message:
|
||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
title: 'Backend out of date'
|
||||
})
|
||||
}
|
||||
|
||||
function markToastDismissed(sha: string | undefined) {
|
||||
if (sha) {
|
||||
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a one-shot toast the first time we see a particular target commit so
|
||||
* users don't have to notice the status-bar version pill turning colors.
|
||||
* Dismissal is remembered per-target-sha so the toast doesn't keep popping
|
||||
* back for the same update across restarts.
|
||||
* Fire a toast when an update is available, at most once per cooldown window.
|
||||
* Closing the toast — dismissing it or opening the updates window from it —
|
||||
* (re)starts the cooldown, so a busy upstream branch doesn't re-spam the user
|
||||
* on every new commit. The snooze is persisted, so it survives relaunches too.
|
||||
*/
|
||||
function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
if (!status || status.supported === false || status.error || !status.targetSha) {
|
||||
return
|
||||
}
|
||||
@@ -101,7 +109,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) {
|
||||
if (isUpdateToastSnoozed()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,13 +118,12 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
}
|
||||
|
||||
const behind = status.behind ?? 0
|
||||
const targetSha = status.targetSha
|
||||
|
||||
notify({
|
||||
action: {
|
||||
label: "See what's new",
|
||||
onClick: () => {
|
||||
markToastDismissed(targetSha)
|
||||
snoozeUpdateToast()
|
||||
openUpdatesWindow()
|
||||
}
|
||||
},
|
||||
@@ -124,7 +131,7 @@ function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
id: UPDATE_TOAST_ID,
|
||||
kind: 'info',
|
||||
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
|
||||
onDismiss: () => markToastDismissed(targetSha),
|
||||
onDismiss: () => snoozeUpdateToast(),
|
||||
title: 'Update ready'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,9 +240,14 @@ export interface SessionCreateResponse {
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
archived?: boolean
|
||||
cwd?: null | string
|
||||
ended_at: null | number
|
||||
id: string
|
||||
/** Original root id of a compression chain, when this entry is a projected
|
||||
* continuation tip. Stable across compressions — used as the durable id for
|
||||
* pins so a pinned conversation survives auto-compression. */
|
||||
_lineage_root_id?: null | string
|
||||
input_tokens: number
|
||||
is_active: boolean
|
||||
last_active: number
|
||||
@@ -470,12 +475,17 @@ export interface ToolProvider {
|
||||
env_vars: ToolEnvVar[]
|
||||
post_setup: string | null
|
||||
requires_nous_auth: boolean
|
||||
/** True when this is the provider currently written to config (mirrors the
|
||||
* CLI `hermes tools` active-provider detection). */
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ToolsetConfig {
|
||||
name: string
|
||||
has_category: boolean
|
||||
providers: ToolProvider[]
|
||||
/** Name of the currently active provider, or null if none is configured. */
|
||||
active_provider: string | null
|
||||
}
|
||||
|
||||
export interface SessionSearchResult {
|
||||
|
||||
@@ -872,6 +872,17 @@ _cleanup_done = False
|
||||
# Weak reference to the active AIAgent for memory provider shutdown at exit
|
||||
_active_agent_ref = None
|
||||
_deferred_agent_startup_done = False
|
||||
# Set True once the TUI's prompt_toolkit app starts (which enables focus
|
||||
# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI
|
||||
# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit
|
||||
# escape codes for modes they never enabled (#36823).
|
||||
_tui_input_modes_active = False
|
||||
|
||||
|
||||
def _mark_tui_input_modes_active() -> None:
|
||||
"""Record that the TUI app started, so _run_cleanup resets input modes."""
|
||||
global _tui_input_modes_active
|
||||
_tui_input_modes_active = True
|
||||
|
||||
|
||||
def _prepare_deferred_agent_startup() -> None:
|
||||
@@ -927,6 +938,12 @@ def _run_cleanup():
|
||||
return
|
||||
_cleanup_done = True
|
||||
|
||||
# Reset terminal input modes first, before the slower resource teardown
|
||||
# below (MCP / browser / memory shutdown can take seconds). On Ctrl+C the
|
||||
# user's terminal becomes usable immediately, and a later step raising
|
||||
# can't skip the reset (#36823). No-op unless the TUI actually ran.
|
||||
_reset_terminal_input_modes_on_exit()
|
||||
|
||||
try:
|
||||
_cleanup_all_terminals()
|
||||
except Exception:
|
||||
@@ -972,6 +989,50 @@ def _run_cleanup():
|
||||
pass
|
||||
|
||||
|
||||
def _reset_terminal_input_modes_on_exit() -> None:
|
||||
"""Best-effort: disable focus reporting + mouse tracking on TUI exit so they
|
||||
don't leak into the next shell session sharing the tab.
|
||||
|
||||
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM /
|
||||
SIGHUP and crashes can bypass its unwind, leaving the modes enabled. The
|
||||
terminal then emits raw ``ESC[I`` / ``ESC[O`` focus events and fragmented
|
||||
SGR mouse reports as visible text in whatever runs next in the same tab
|
||||
(#36823). Called from ``_run_cleanup`` (atexit-registered + invoked on the
|
||||
normal / EOF / interrupt exit paths) this covers normal quit, Ctrl+C and
|
||||
SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
|
||||
``os._exit(0)`` path bypasses ``atexit``; neither runs this — but both are
|
||||
non-TTY / non-TUI, so there is nothing to reset there.
|
||||
|
||||
Gated on ``_tui_input_modes_active`` so one-shot non-TUI CLI runs (which
|
||||
share ``_run_cleanup`` via ``atexit``) never emit these codes. Writes to the
|
||||
controlling terminal directly: by exit, prompt_toolkit's own output is torn
|
||||
down, so ``sys.stdout`` is the real fd; falls back to ``/dev/tty`` when
|
||||
stdout is redirected away from the terminal.
|
||||
"""
|
||||
global _tui_input_modes_active
|
||||
if not _tui_input_modes_active:
|
||||
return
|
||||
# About to disable the modes — clear the flag so a re-armed _run_cleanup (or
|
||||
# a long-lived process that reuses it) doesn't re-emit them.
|
||||
_tui_input_modes_active = False
|
||||
# Prefer stdout when it's the terminal; otherwise the TUI may have driven
|
||||
# /dev/tty while stdout was redirected — reset there instead of nowhere.
|
||||
try:
|
||||
stream = sys.stdout
|
||||
if stream is not None and stream.isatty():
|
||||
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
stream.flush()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open("/dev/tty", "w", encoding="ascii") as tty:
|
||||
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
tty.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Git Worktree Isolation (#652)
|
||||
# =============================================================================
|
||||
@@ -2116,6 +2177,41 @@ def _cprint(text: str):
|
||||
pass
|
||||
|
||||
|
||||
def _prepend_note_to_message(message, note: str):
|
||||
"""Prepend a one-shot system-style note to a user message.
|
||||
|
||||
``message`` is normally a plain string, but when the user attaches an image
|
||||
to a vision-capable model it becomes a list of OpenAI-style content parts
|
||||
(text + ``image_url`` blocks). Naively doing ``note + "\\n\\n" + message``
|
||||
then raises ``TypeError: can only concatenate str (not "list") to str`` —
|
||||
e.g. running ``/model ...`` (which queues a model-switch note) and then
|
||||
sending a pasted image in the same turn.
|
||||
|
||||
Returns the message with ``note`` prepended:
|
||||
* ``str`` → ``f"{note}\\n\\n{message}"`` (just ``note`` when empty)
|
||||
* ``list`` → note folded into the first text part, or inserted as a new
|
||||
leading ``{"type": "text"}`` part when there is no text part.
|
||||
Unknown shapes are returned unchanged (fail-open).
|
||||
"""
|
||||
note = str(note or "").strip()
|
||||
if not note:
|
||||
return message
|
||||
if isinstance(message, str):
|
||||
return f"{note}\n\n{message}" if message else note
|
||||
if isinstance(message, list):
|
||||
parts = list(message)
|
||||
for i, part in enumerate(parts):
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
merged = dict(part)
|
||||
text = merged.get("text", "")
|
||||
merged["text"] = f"{note}\n\n{text}" if text else note
|
||||
parts[i] = merged
|
||||
return parts
|
||||
# No text part (image-only) — insert the note as a leading text block.
|
||||
return [{"type": "text", "text": note}, *parts]
|
||||
return message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-drop / local attachment detection — extracted as pure helpers for tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -12135,17 +12231,21 @@ class HermesCLI:
|
||||
reset_current_session_key = None # type: ignore[assignment]
|
||||
_approval_session_token = None
|
||||
agent_message = _voice_prefix + message if _voice_prefix else message
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
# Prepend pending notes via _prepend_note_to_message, which
|
||||
# handles both plain-string and multimodal content-parts list
|
||||
# messages. Naive ``note + "\n\n" + agent_message`` crashed with
|
||||
# TypeError when an image was attached (agent_message is a list)
|
||||
# and a /model or /reload-skills note was queued for the turn.
|
||||
_msn = getattr(self, '_pending_model_switch_note', None)
|
||||
if _msn:
|
||||
agent_message = _msn + "\n\n" + agent_message
|
||||
agent_message = _prepend_note_to_message(agent_message, _msn)
|
||||
self._pending_model_switch_note = None
|
||||
# Prepend pending /reload-skills note so the model sees which
|
||||
# skills were added/removed before handling this turn. Same
|
||||
# one-shot queue pattern as the model-switch note above.
|
||||
_srn = getattr(self, '_pending_skills_reload_note', None)
|
||||
if _srn:
|
||||
agent_message = _srn + "\n\n" + agent_message
|
||||
agent_message = _prepend_note_to_message(agent_message, _srn)
|
||||
self._pending_skills_reload_note = None
|
||||
try:
|
||||
result = self.agent.run_conversation(
|
||||
@@ -15096,6 +15196,9 @@ class HermesCLI:
|
||||
pass # No running loop -- nothing to patch
|
||||
except Exception:
|
||||
pass
|
||||
# The app enables focus reporting + mouse tracking; record that
|
||||
# so _run_cleanup resets them on exit (#36823).
|
||||
_mark_tui_input_modes_active()
|
||||
app.run()
|
||||
except (EOFError, KeyboardInterrupt, BrokenPipeError):
|
||||
pass
|
||||
|
||||
+27
-8
@@ -428,22 +428,18 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
ensure_dirs()
|
||||
if not JOBS_FILE.exists():
|
||||
return []
|
||||
|
||||
|
||||
_strict_retry = False # track whether we used the strict=False fallback
|
||||
|
||||
try:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get("jobs", [])
|
||||
except json.JSONDecodeError:
|
||||
# Retry with strict=False to handle bare control chars in string values
|
||||
_strict_retry = True
|
||||
try:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(f.read(), strict=False)
|
||||
jobs = data.get("jobs", [])
|
||||
if jobs:
|
||||
# Auto-repair: rewrite with proper escaping
|
||||
save_jobs(jobs)
|
||||
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
|
||||
return jobs
|
||||
except Exception as e:
|
||||
logger.error("Failed to auto-repair jobs.json: %s", e)
|
||||
raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e
|
||||
@@ -451,6 +447,29 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
logger.error("IOError reading jobs.json: %s", e)
|
||||
raise RuntimeError(f"Failed to read cron database: {e}") from e
|
||||
|
||||
# Validate the top-level JSON shape: accept a dict (expected) or a bare
|
||||
# list (auto-repair). Anything else (str/number/null) is corruption that
|
||||
# would otherwise raise an uncaught AttributeError on ``.get()`` and take
|
||||
# down the whole cron subsystem.
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
if _strict_retry and jobs:
|
||||
# Hit control-character corruption — rewrite with proper escaping.
|
||||
save_jobs(jobs)
|
||||
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
|
||||
return jobs
|
||||
if isinstance(data, list):
|
||||
# Bare array — likely saved/edited outside save_jobs(). Wrap it back
|
||||
# into the expected {"jobs": [...]} structure.
|
||||
if data:
|
||||
save_jobs(data)
|
||||
logger.warning("Auto-repaired jobs.json (bare list wrapped as dict)")
|
||||
return data
|
||||
|
||||
raise RuntimeError(
|
||||
f"Cron database corrupted: expected {{'jobs': [...]}}, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
|
||||
+13
-5
@@ -1182,14 +1182,22 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool =
|
||||
markdown — often security docs / runbooks that *describe* attack
|
||||
commands in prose. The LOOSER ``_scan_cron_skill_assembled``
|
||||
pattern set is used: only unambiguous prompt-injection directives
|
||||
and invisible unicode block, command-shape patterns are dropped
|
||||
to avoid false-positives. Skill bodies are vetted at install time
|
||||
by ``skills_guard.py``.
|
||||
block; command-shape patterns are dropped and invisible unicode is
|
||||
sanitized (stripped + logged) rather than blocked, to avoid
|
||||
false-positives that permanently kill a job. Skill bodies are
|
||||
vetted at install time by ``skills_guard.py``.
|
||||
"""
|
||||
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
|
||||
|
||||
scanner = _scan_cron_skill_assembled if has_skills else _scan_cron_prompt
|
||||
scan_error = scanner(assembled)
|
||||
if has_skills:
|
||||
# Skill content is install-time vetted by skills_guard.py. Invisible
|
||||
# unicode is sanitized (not blocked) so a stray zero-width space in a
|
||||
# skill code example can't permanently kill the job; the cleaned
|
||||
# prompt is what actually runs.
|
||||
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
|
||||
assembled = cleaned
|
||||
else:
|
||||
scan_error = _scan_cron_prompt(assembled)
|
||||
if scan_error:
|
||||
job_label = job.get("name") or job.get("id") or "<unknown>"
|
||||
logger.warning(
|
||||
|
||||
@@ -27,10 +27,20 @@ drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; }
|
||||
# don't try to write to /root.
|
||||
export HOME=/opt/data
|
||||
|
||||
# Save the Docker -w (or default) working directory before init
|
||||
# scripts cd to /opt/data, so the container starts in the
|
||||
# directory the user requested.
|
||||
_hermes_orig_cwd="${HERMES_ORIG_CWD:-$PWD}"
|
||||
|
||||
cd /opt/data
|
||||
# shellcheck disable=SC1091
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
# Restore the original working directory before handing off to
|
||||
# the user's command so `hermes chat` starts in the Docker -w
|
||||
# directory, not /opt/data.
|
||||
cd "$_hermes_orig_cwd"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
drop hermes
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Multi-gateway deployment
|
||||
|
||||
Hermes supports multiple gateway processes running concurrently — one per profile
|
||||
(default, writer, admin, coder, researcher). Each gateway opens its own connection
|
||||
to platform APIs and delivers messages for its profile's subscribers.
|
||||
|
||||
## Single-dispatcher posture
|
||||
|
||||
Only one gateway owns the kanban dispatcher. The owning gateway keeps
|
||||
`kanban.dispatch_in_gateway: true` (the default); every other gateway sets it
|
||||
to `false`.
|
||||
|
||||
**Why this matters:** a gateway with `dispatch_in_gateway: true` opens per-board
|
||||
SQLite connections for both the dispatcher and the notifier watcher. Multiple
|
||||
gateways doing this concurrently multiplies the open file descriptors on each
|
||||
`kanban.db` and amplifies WAL `-shm` reader contention. Gating both paths on the
|
||||
same flag means exactly one process touches the kanban DBs.
|
||||
|
||||
## Configuration
|
||||
|
||||
On the dispatch-owning gateway (typically the `default` profile), no change is
|
||||
needed. On every other profile gateway, add to `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
kanban:
|
||||
dispatch_in_gateway: false
|
||||
```
|
||||
|
||||
Or set the env var: `HERMES_KANBAN_DISPATCH_IN_GATEWAY=false`
|
||||
|
||||
## What each gateway does
|
||||
|
||||
| Gateway role | dispatch_in_gateway | Opens per-board DBs? | Runs dispatcher + notifier? |
|
||||
|---|---|---|---|
|
||||
| default (dispatch owner) | true (default) | yes | yes |
|
||||
| writer, admin, coder, etc. | false | no | no |
|
||||
|
||||
Non-dispatch gateways still deliver messages for their own platform adapters
|
||||
(Telegram, Discord, etc.) — they just don't poll kanban boards.
|
||||
+27
-4
@@ -361,10 +361,17 @@ class StreamingConfig:
|
||||
# fall back to edit-based when not.
|
||||
# "draft" — explicitly request native drafts; falls back to edit when
|
||||
# the platform/chat doesn't support them.
|
||||
# "edit" — progressive editMessageText only (legacy/default
|
||||
# behaviour).
|
||||
# "edit" — progressive editMessageText only (legacy behaviour).
|
||||
# "off" — disable streaming entirely.
|
||||
transport: str = "edit"
|
||||
#
|
||||
# Default is "auto": prefer native draft streaming on platforms that
|
||||
# support it (Telegram DMs via sendMessageDraft, Bot API 9.5+) and fall
|
||||
# back to edit-based streaming everywhere else. This is safe as a global
|
||||
# default because adapters without draft support (Discord, Slack, Matrix,
|
||||
# …) report supports_draft_streaming() == False and transparently use the
|
||||
# edit path — so "auto" never regresses non-Telegram platforms, it only
|
||||
# upgrades the chats that can render the smoother native preview.
|
||||
transport: str = "auto"
|
||||
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
|
||||
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
|
||||
cursor: str = DEFAULT_STREAMING_CURSOR
|
||||
@@ -393,7 +400,7 @@ class StreamingConfig:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=_coerce_bool(data.get("enabled"), False),
|
||||
transport=data.get("transport", "edit"),
|
||||
transport=data.get("transport", "auto"),
|
||||
edit_interval=_coerce_float(
|
||||
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
|
||||
),
|
||||
@@ -1722,6 +1729,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
"webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
|
||||
"send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"},
|
||||
})
|
||||
bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
if bluebubbles_require_mention is not None:
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = (
|
||||
bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"}
|
||||
)
|
||||
bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
if bluebubbles_mention_patterns:
|
||||
try:
|
||||
parsed_patterns = json.loads(bluebubbles_mention_patterns)
|
||||
except Exception:
|
||||
parsed_patterns = [
|
||||
part.strip()
|
||||
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",")
|
||||
if part.strip()
|
||||
]
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns
|
||||
bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL")
|
||||
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
|
||||
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
|
||||
|
||||
+238
-12
@@ -1265,6 +1265,107 @@ def cleanup_document_cache(max_age_hours: int = 24) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified media caching
|
||||
#
|
||||
# One entry point for "I have raw attachment bytes from a platform — cache them
|
||||
# and tell me what I got." Classifies by extension/MIME against the shared
|
||||
# registries above, routes to the right cache_*_from_bytes helper, and returns
|
||||
# a small result the caller can store and/or describe in a transcript. Used by
|
||||
# both the addressed-message path and the observed-group-context path, on any
|
||||
# platform — not Telegram-specific.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CachedMedia:
|
||||
"""Result of caching one attachment's bytes."""
|
||||
|
||||
path: str # absolute cache path, agent-visible (sandbox-translated)
|
||||
media_type: str # MIME type recorded on the MessageEvent
|
||||
kind: str # "image" | "video" | "audio" | "document"
|
||||
display_name: str # human-readable name for transcript notes
|
||||
|
||||
def context_note(self) -> str:
|
||||
"""One-line transcript annotation pointing the agent at the file."""
|
||||
return f"[{self.kind} '{self.display_name}' saved at: {self.path}]"
|
||||
|
||||
|
||||
def _resolve_media_ext(filename: str, mime_type: str) -> str:
|
||||
"""Best-effort file extension from filename, then MIME fallback."""
|
||||
if filename:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext:
|
||||
return ext
|
||||
mime = (mime_type or "").lower()
|
||||
if not mime:
|
||||
return ""
|
||||
for table in (
|
||||
SUPPORTED_IMAGE_DOCUMENT_TYPES,
|
||||
SUPPORTED_VIDEO_TYPES,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
):
|
||||
for ext, m in table.items():
|
||||
if m == mime:
|
||||
return ext
|
||||
return ""
|
||||
|
||||
|
||||
def cache_media_bytes(
|
||||
data: bytes,
|
||||
*,
|
||||
filename: str = "",
|
||||
mime_type: str = "",
|
||||
default_kind: Optional[str] = None,
|
||||
) -> Optional[CachedMedia]:
|
||||
"""Classify and cache raw attachment bytes; return a CachedMedia or None.
|
||||
|
||||
``default_kind`` ("image"/"video"/"audio"/"document") biases classification
|
||||
when the extension/MIME are ambiguous — e.g. a Telegram native photo whose
|
||||
file has no usable name. Unsupported document types return None so the
|
||||
caller can record an "unsupported" note. Images that fail validation
|
||||
(``cache_image_from_bytes`` raises ValueError) also return None.
|
||||
"""
|
||||
from tools.credential_files import to_agent_visible_cache_path
|
||||
|
||||
ext = _resolve_media_ext(filename, mime_type)
|
||||
mime = (mime_type or "").lower()
|
||||
display = re.sub(r"[^\w.\- ]", "_", filename) if filename else (ext.lstrip(".") or "file")
|
||||
|
||||
is_image = (
|
||||
mime.startswith("image/")
|
||||
or ext in SUPPORTED_IMAGE_DOCUMENT_TYPES
|
||||
or default_kind == "image"
|
||||
)
|
||||
is_video = mime.startswith("video/") or ext in SUPPORTED_VIDEO_TYPES or default_kind == "video"
|
||||
is_audio = mime.startswith("audio/") or default_kind == "audio"
|
||||
|
||||
if is_image:
|
||||
img_ext = ext if ext in SUPPORTED_IMAGE_DOCUMENT_TYPES else ".jpg"
|
||||
try:
|
||||
path = cache_image_from_bytes(data, ext=img_ext)
|
||||
except ValueError:
|
||||
return None
|
||||
out_mime = mime if mime.startswith("image/") else SUPPORTED_IMAGE_DOCUMENT_TYPES.get(img_ext, "image/jpeg")
|
||||
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "image", display)
|
||||
|
||||
if is_video:
|
||||
vid_ext = ext if ext in SUPPORTED_VIDEO_TYPES else ".mp4"
|
||||
path = cache_video_from_bytes(data, ext=vid_ext)
|
||||
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_VIDEO_TYPES.get(vid_ext, "video/mp4"), "video", display)
|
||||
|
||||
if is_audio:
|
||||
aud_ext = ext if ext in {".ogg", ".mp3", ".wav", ".m4a", ".opus", ".flac"} else ".ogg"
|
||||
path = cache_audio_from_bytes(data, ext=aud_ext)
|
||||
out_mime = mime if mime.startswith("audio/") else f"audio/{aud_ext.lstrip('.')}"
|
||||
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "audio", display)
|
||||
|
||||
if ext not in SUPPORTED_DOCUMENT_TYPES:
|
||||
return None
|
||||
|
||||
path = cache_document_from_bytes(data, filename or f"document{ext}")
|
||||
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_DOCUMENT_TYPES[ext], "document", display or f"document{ext}")
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Types of incoming messages."""
|
||||
TEXT = "text"
|
||||
@@ -1644,6 +1745,22 @@ def resolve_channel_skills(
|
||||
return None
|
||||
|
||||
|
||||
def _strip_media_directives(text: str) -> str:
|
||||
"""Strip internal delivery directives ([[audio_as_voice]], [[as_document]],
|
||||
MEDIA:<path>) so they never render as visible text.
|
||||
|
||||
Backstop only: run ``extract_media`` first. MEDIA cleanup uses the shared
|
||||
``MEDIA_TAG_CLEANUP_RE`` (only tags whose path has a known deliverable
|
||||
extension are removed; an unknown-extension tag is intentionally left so the
|
||||
bare-path detector downstream can still pick it up, per #34517). [[...]] is
|
||||
exact.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
text = text.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "")
|
||||
return MEDIA_TAG_CLEANUP_RE.sub("", text)
|
||||
|
||||
|
||||
class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
Base class for platform adapters.
|
||||
@@ -1734,8 +1851,8 @@ class BasePlatformAdapter(ABC):
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Whether this adapter gates inbound access before dispatch.
|
||||
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
|
||||
config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot, WhatsApp) implement a
|
||||
documented config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` — and
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
@@ -1799,6 +1916,84 @@ class BasePlatformAdapter(ABC):
|
||||
f"{type(self).__name__} does not implement send_draft"
|
||||
)
|
||||
|
||||
# ── Structured stream-event rendering ────────────────────────────────
|
||||
#
|
||||
# These methods let an adapter decide *how* to present each structured
|
||||
# streaming event (see gateway/stream_events.py). The default
|
||||
# implementations reproduce the historical behavior exactly: assistant
|
||||
# text/commentary/segment events delegate to the stream consumer, and
|
||||
# tool events render the same "emoji tool_name: preview" chrome the
|
||||
# gateway has always produced. Adapters override these to be more native
|
||||
# to their platform (e.g. Telegram streaming a MarkdownV2 ```bash``` block
|
||||
# as a draft; iMessage eating tool chrome it cannot format).
|
||||
#
|
||||
# The contract is presentation-only: nothing rendered here is persisted to
|
||||
# conversation history. History is owned by the agent; what an adapter
|
||||
# chooses to "eat" must never change the bytes the agent stored.
|
||||
|
||||
def render_message_event(self, event: Any, sink: Any) -> None:
|
||||
"""Render a MessageChunk / MessageStop / Commentary onto the sink.
|
||||
|
||||
Default: map onto the stream consumer's existing primitives, preserving
|
||||
today's behavior 1:1. ``sink`` is a GatewayStreamConsumer.
|
||||
"""
|
||||
from gateway.stream_events import MessageChunk, MessageStop, Commentary
|
||||
|
||||
if isinstance(event, MessageChunk):
|
||||
if event.text:
|
||||
sink.on_delta(event.text)
|
||||
elif isinstance(event, MessageStop):
|
||||
# An intermediate stop (text → tool → text) is a segment break;
|
||||
# the terminal stop is signalled by the gateway via finish(),
|
||||
# not here, so we only break segments on non-final stops.
|
||||
if not event.final:
|
||||
sink.on_segment_break()
|
||||
elif isinstance(event, Commentary):
|
||||
if event.text:
|
||||
sink.on_commentary(event.text)
|
||||
|
||||
def format_tool_event(self, event: Any, *, mode: str = "all",
|
||||
preview_max_len: int = 40) -> Optional[str]:
|
||||
"""Return the rendered chrome for a ToolCallChunk, or None to eat it.
|
||||
|
||||
Reproduces the gateway's historical tool-progress formatting: an emoji
|
||||
for the tool, the tool name, and a short argument preview (or the full
|
||||
args dict in ``verbose`` mode). Adapters that cannot render tool chrome
|
||||
(no message editing, plain-text only) should override to return None so
|
||||
the event is dropped rather than spamming separate bubbles.
|
||||
|
||||
``mode`` is the resolved tool-progress mode ("all" / "new" / "verbose");
|
||||
``preview_max_len`` mirrors the ``tool_preview_length`` config (0 means
|
||||
"no cap" in verbose mode).
|
||||
"""
|
||||
from gateway.stream_events import ToolCallChunk
|
||||
if not isinstance(event, ToolCallChunk):
|
||||
return None
|
||||
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(event.tool_name, default="⚙️")
|
||||
|
||||
if mode == "verbose":
|
||||
if event.args:
|
||||
import json
|
||||
args_str = json.dumps(event.args, ensure_ascii=False, default=str)
|
||||
if preview_max_len > 0 and len(args_str) > preview_max_len:
|
||||
args_str = args_str[:preview_max_len - 3] + "..."
|
||||
return f"{emoji} {event.tool_name}({list(event.args.keys())})\n{args_str}"
|
||||
if event.preview:
|
||||
return f"{emoji} {event.tool_name}: \"{event.preview}\""
|
||||
return f"{emoji} {event.tool_name}..."
|
||||
|
||||
# "all" / "new": short preview, capped (default 40 to keep gateway
|
||||
# progress bubbles compact — they persist as permanent messages).
|
||||
preview = event.preview
|
||||
if preview:
|
||||
cap = preview_max_len if preview_max_len > 0 else 40
|
||||
if len(preview) > cap:
|
||||
preview = preview[:cap - 3] + "..."
|
||||
return f"{emoji} {event.tool_name}: \"{preview}\""
|
||||
return f"{emoji} {event.tool_name}..."
|
||||
|
||||
@property
|
||||
def has_fatal_error(self) -> bool:
|
||||
return self._fatal_error_message is not None
|
||||
@@ -3884,21 +4079,20 @@ class BasePlatformAdapter(ABC):
|
||||
# where Telegram's sendPhoto recompression destroys legibility.
|
||||
force_document_attachments = "[[as_document]]" in response
|
||||
|
||||
# Pre-extract snapshot for the #29346 recovery/invariant below.
|
||||
_response_pre_extract = response
|
||||
|
||||
# Extract MEDIA:<path> tags (from TTS tool) before other processing
|
||||
media_files, response = self.extract_media(response)
|
||||
media_files = self.filter_media_delivery_paths(media_files)
|
||||
|
||||
# Extract image URLs and send them as native platform attachments
|
||||
images, text_content = self.extract_images(response)
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = text_content.replace("[[as_document]]", "").strip()
|
||||
# Strip only MEDIA: tags whose path has a deliverable extension
|
||||
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
|
||||
# extension is intentionally left in the body so extract_local_files
|
||||
# below can still pick up the bare path — otherwise the file would
|
||||
# be silently dropped (issue #34517).
|
||||
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
|
||||
# Strip any remaining internal directives from message body (fixes #1561).
|
||||
# _strip_media_directives shares MEDIA_TAG_CLEANUP_RE, so a MEDIA: tag
|
||||
# with an unknown extension is intentionally left in the body for
|
||||
# extract_local_files below to pick up rather than silently dropped (#34517).
|
||||
text_content = _strip_media_directives(text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
@@ -3912,7 +4106,25 @@ class BasePlatformAdapter(ABC):
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
|
||||
# A2 (#29346): extraction can reduce a non-empty response to
|
||||
# empty text with no attachment, and the `if text_content` guard
|
||||
# below then drops it silently. Recover on every platform (#33842
|
||||
# was Discord-only); the guard avoids duplicating an attachment.
|
||||
if not (text_content or images or local_files or media_files):
|
||||
# Recover from the post-extract_media `response`, not the raw
|
||||
# snapshot: extract_media already stripped MEDIA (incl. spaced
|
||||
# paths) with its full grammar, so no fragment can leak.
|
||||
_recovered = _strip_media_directives(response).strip()
|
||||
if _recovered:
|
||||
logger.warning(
|
||||
"[%s] response_delivery_recovered: extract pipeline "
|
||||
"reduced a non-empty response (%d chars) to empty with "
|
||||
"no attachment; delivering recovered original to %s",
|
||||
self.name, len(_response_pre_extract), event.source.chat_id,
|
||||
)
|
||||
text_content = _recovered
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
||||
@@ -4110,6 +4322,20 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
# A3 (#29346): if a non-empty response produced nothing
|
||||
# deliverable, fail loudly rather than dropping it in silence.
|
||||
_anything_delivered = (
|
||||
delivery_attempted or _tts_caption_delivered
|
||||
or images or local_files or media_files
|
||||
)
|
||||
if not _anything_delivered and _response_pre_extract.strip():
|
||||
logger.error(
|
||||
"[%s] response_delivery_dropped: non-empty response "
|
||||
"(%d chars) produced no delivered message or attachment "
|
||||
"for %s (empty after extract, recovery yielded nothing).",
|
||||
self.name, len(_response_pre_extract), event.source.chat_id,
|
||||
)
|
||||
|
||||
# Determine overall success for the processing hook
|
||||
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
|
||||
await self._run_processing_hook(
|
||||
|
||||
@@ -44,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645
|
||||
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
|
||||
MAX_TEXT_LENGTH = 4000
|
||||
|
||||
# BlueBubbles/iMessage does not expose a stable bot mention identity like
|
||||
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
|
||||
# group mention gating without custom aliases, use conservative Hermes wake
|
||||
# words so `require_mention: true` is a one-line enablement path.
|
||||
DEFAULT_MENTION_PATTERNS = [
|
||||
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
|
||||
r"(?<![\w@])@?hermes\b[,:\-]?",
|
||||
]
|
||||
|
||||
# Tapback reaction codes (BlueBubbles associatedMessageType values)
|
||||
_TAPBACK_ADDED = {
|
||||
2000: "love", 2001: "like", 2002: "dislike",
|
||||
@@ -127,6 +136,15 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not str(self.webhook_path).startswith("/"):
|
||||
self.webhook_path = f"/{self.webhook_path}"
|
||||
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
|
||||
_require_mention = extra.get("require_mention")
|
||||
if _require_mention is None:
|
||||
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
|
||||
self._mention_patterns = self._compile_mention_patterns(
|
||||
extra["mention_patterns"]
|
||||
if "mention_patterns" in extra
|
||||
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
)
|
||||
self.client: Optional[httpx.AsyncClient] = None
|
||||
self._runner = None
|
||||
self._private_api_enabled: Optional[bool] = None
|
||||
@@ -141,6 +159,62 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
sep = "&" if "?" in path else "?"
|
||||
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
|
||||
"""Compile group-mention wake words from config/env.
|
||||
|
||||
``raw`` is a list (from config or env JSON), a string (raw env var:
|
||||
JSON list, or comma/newline-separated), or None (use Hermes defaults).
|
||||
"""
|
||||
if raw is None:
|
||||
patterns = list(DEFAULT_MENTION_PATTERNS)
|
||||
elif isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
try:
|
||||
loaded = json.loads(text) if text else []
|
||||
except Exception:
|
||||
loaded = None
|
||||
patterns = loaded if isinstance(loaded, list) else [
|
||||
part.strip()
|
||||
for line in text.splitlines()
|
||||
for part in line.split(",")
|
||||
]
|
||||
elif isinstance(raw, list):
|
||||
patterns = raw
|
||||
else:
|
||||
patterns = [raw]
|
||||
|
||||
compiled: List["re.Pattern"] = []
|
||||
for pattern in patterns:
|
||||
text = str(pattern).strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
compiled.append(re.compile(text, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[bluebubbles] Invalid mention pattern %r: %s", text, exc)
|
||||
return compiled
|
||||
|
||||
def _message_matches_mention_patterns(self, text: str) -> bool:
|
||||
if not text or not self._mention_patterns:
|
||||
return False
|
||||
return any(pattern.search(text) for pattern in self._mention_patterns)
|
||||
|
||||
def _clean_mention_text(self, text: str) -> str:
|
||||
"""Strip a leading BlueBubbles wake word before dispatch.
|
||||
|
||||
Custom mention patterns are regular expressions, so stripping only a
|
||||
leading match avoids deleting ordinary words later in the prompt.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
for pattern in self._mention_patterns:
|
||||
match = pattern.match(text.lstrip())
|
||||
if match:
|
||||
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
|
||||
return cleaned or text
|
||||
return text
|
||||
|
||||
async def _api_get(self, path: str) -> Dict[str, Any]:
|
||||
assert self.client is not None
|
||||
res = await self.client.get(self._api_url(path))
|
||||
@@ -921,6 +995,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
session_chat_id = chat_guid or chat_identifier
|
||||
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
|
||||
if is_group and self.require_mention:
|
||||
if not self._message_matches_mention_patterns(text):
|
||||
logger.debug(
|
||||
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
text = self._clean_mention_text(text)
|
||||
source = self.build_source(
|
||||
chat_id=session_chat_id,
|
||||
chat_name=chat_identifier or sender,
|
||||
|
||||
+156
-55
@@ -2521,31 +2521,55 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
|
||||
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"draft_id": int(draft_id),
|
||||
"text": text,
|
||||
}
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
|
||||
try:
|
||||
ok = await self._bot.send_message_draft(**kwargs)
|
||||
if ok:
|
||||
# Drafts have no message_id; we report success without one
|
||||
# so the caller knows the animation frame landed.
|
||||
return SendResult(success=True, message_id=None)
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
except Exception as e:
|
||||
# Most likely: BadRequest because this bot/chat doesn't allow
|
||||
# drafts, or a transient server hiccup. The caller treats any
|
||||
# failure as "fall back to edit-based for this response".
|
||||
logger.debug(
|
||||
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
|
||||
self.name, chat_id, draft_id, e,
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
# Apply the same MarkdownV2 conversion the regular ``send`` path uses
|
||||
# so the animated draft preview renders with identical formatting to
|
||||
# the final message. Without this, the draft streams as raw text and
|
||||
# the final ``sendMessage`` (which DOES use MarkdownV2) snaps into
|
||||
# formatted output, producing a jarring visual shift at the end of the
|
||||
# response. We try MarkdownV2 first and fall back to plain text if a
|
||||
# malformed escape would be rejected — mirroring the (True, False)
|
||||
# retry the streaming send loop uses — so a single bad token never
|
||||
# kills draft streaming for the whole response.
|
||||
for use_markdown in (True, False):
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"draft_id": int(draft_id),
|
||||
"text": self.format_message(text) if use_markdown else text,
|
||||
}
|
||||
if use_markdown:
|
||||
kwargs["parse_mode"] = ParseMode.MARKDOWN_V2
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
|
||||
try:
|
||||
ok = await self._bot.send_message_draft(**kwargs)
|
||||
if ok:
|
||||
# Drafts have no message_id; we report success without one
|
||||
# so the caller knows the animation frame landed.
|
||||
return SendResult(success=True, message_id=None)
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
except Exception as e:
|
||||
# A MarkdownV2 parse failure (BadRequest "can't parse entities")
|
||||
# is recoverable: retry once as plain text. Any other failure
|
||||
# (chat doesn't allow drafts, transient hiccup) — or a failure
|
||||
# on the plain-text attempt — propagates to the caller, which
|
||||
# treats it as "fall back to edit-based for this response".
|
||||
if use_markdown and self._is_bad_request_error(e):
|
||||
logger.debug(
|
||||
"[%s] sendMessageDraft MarkdownV2 rejected, retrying "
|
||||
"as plain text (chat=%s draft_id=%s): %s",
|
||||
self.name, chat_id, draft_id, e,
|
||||
)
|
||||
continue
|
||||
logger.debug(
|
||||
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
|
||||
self.name, chat_id, draft_id, e,
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
|
||||
async def _send_message_with_thread_fallback(self, **kwargs):
|
||||
"""Send a Telegram message, retrying once without message_thread_id
|
||||
@@ -4918,13 +4942,109 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
channel_prompt=channel_prompt,
|
||||
)
|
||||
|
||||
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
|
||||
def _media_message_type(self, msg: Message) -> MessageType:
|
||||
"""Classify a Telegram media message into a MessageType."""
|
||||
if msg.sticker:
|
||||
return MessageType.STICKER
|
||||
if msg.photo:
|
||||
return MessageType.PHOTO
|
||||
if msg.video:
|
||||
return MessageType.VIDEO
|
||||
if msg.audio:
|
||||
return MessageType.AUDIO
|
||||
if msg.voice:
|
||||
return MessageType.VOICE
|
||||
return MessageType.DOCUMENT
|
||||
|
||||
async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None:
|
||||
"""Cache an unmentioned group attachment and annotate the observed text.
|
||||
|
||||
Passive group traffic, so downloads are bounded by the same
|
||||
``_max_doc_bytes`` limit as the addressed document path. Oversized or
|
||||
unsupported attachments are noted in the transcript without downloading.
|
||||
"""
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
|
||||
source, filename, mime, kind = self._observed_media_source(msg)
|
||||
if source is None:
|
||||
return
|
||||
|
||||
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if not (0 < size <= max_bytes):
|
||||
limit_mb = max_bytes // (1024 * 1024)
|
||||
event.text = self._append_observed_note(
|
||||
event.text,
|
||||
f"[Observed Telegram attachment too large or unverifiable. Maximum: {limit_mb} MB.]",
|
||||
)
|
||||
logger.info("[Telegram] Observed group attachment skipped (size=%s)", file_size)
|
||||
return
|
||||
|
||||
try:
|
||||
file_obj = await source.get_file()
|
||||
data = bytes(await file_obj.download_as_bytearray())
|
||||
if not filename:
|
||||
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
|
||||
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
|
||||
except Exception as exc:
|
||||
logger.warning("[Telegram] Failed to cache observed group media: %s", exc, exc_info=True)
|
||||
return
|
||||
|
||||
if cached is None:
|
||||
event.text = self._append_observed_note(
|
||||
event.text, "[Observed Telegram attachment: unsupported type, not cached.]"
|
||||
)
|
||||
return
|
||||
|
||||
event.media_urls = [cached.path]
|
||||
event.media_types = [cached.media_type]
|
||||
if cached.kind == "image":
|
||||
event.message_type = MessageType.PHOTO
|
||||
elif cached.kind == "video":
|
||||
event.message_type = MessageType.VIDEO
|
||||
event.text = self._append_observed_note(event.text, cached.context_note())
|
||||
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
|
||||
|
||||
def _observed_media_source(self, msg: Message):
|
||||
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
|
||||
if msg.photo:
|
||||
return msg.photo[-1], "", "", "image"
|
||||
if msg.video:
|
||||
return msg.video, "", "video/mp4", "video"
|
||||
if msg.voice:
|
||||
return msg.voice, "voice.ogg", "audio/ogg", "audio"
|
||||
if msg.audio:
|
||||
return msg.audio, getattr(msg.audio, "file_name", "") or "", "", "audio"
|
||||
if msg.document:
|
||||
doc = msg.document
|
||||
return doc, doc.file_name or "", (doc.mime_type or "").lower(), None
|
||||
return None, "", "", None
|
||||
|
||||
@staticmethod
|
||||
def _append_observed_note(existing: Optional[str], note: str) -> str:
|
||||
if not note:
|
||||
return existing or ""
|
||||
if not existing:
|
||||
return note
|
||||
return f"{existing}\n\n{note}"
|
||||
|
||||
def _observe_unmentioned_group_message(
|
||||
self,
|
||||
message: Message,
|
||||
msg_type: MessageType,
|
||||
update_id: Optional[int] = None,
|
||||
event: Optional[MessageEvent] = None,
|
||||
) -> None:
|
||||
"""Append skipped group chatter to the target session without dispatching."""
|
||||
store = getattr(self, "_session_store", None)
|
||||
if not store:
|
||||
return
|
||||
try:
|
||||
event = self._build_message_event(message, msg_type, update_id=update_id)
|
||||
event = event or self._build_message_event(message, msg_type, update_id=update_id)
|
||||
shared_source = self._telegram_group_observe_shared_source(event.source)
|
||||
session_entry = store.get_or_create_session(shared_source)
|
||||
entry = {
|
||||
@@ -5285,39 +5405,20 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not self._should_process_message(update.message):
|
||||
if self._should_observe_unmentioned_group_message(update.message):
|
||||
_m = update.message
|
||||
if _m.sticker:
|
||||
_observe_type = MessageType.STICKER
|
||||
elif _m.photo:
|
||||
_observe_type = MessageType.PHOTO
|
||||
elif _m.video:
|
||||
_observe_type = MessageType.VIDEO
|
||||
elif _m.audio:
|
||||
_observe_type = MessageType.AUDIO
|
||||
elif _m.voice:
|
||||
_observe_type = MessageType.VOICE
|
||||
else:
|
||||
_observe_type = MessageType.DOCUMENT
|
||||
self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id)
|
||||
_observe_type = self._media_message_type(_m)
|
||||
_event = self._build_message_event(_m, _observe_type, update_id=update.update_id)
|
||||
if _m.caption:
|
||||
_event.text = self._clean_bot_trigger_text(_m.caption)
|
||||
await self._cache_observed_media(_m, _event)
|
||||
self._observe_unmentioned_group_message(
|
||||
_m, _event.message_type, update_id=update.update_id, event=_event
|
||||
)
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
|
||||
# Determine media type
|
||||
if msg.sticker:
|
||||
msg_type = MessageType.STICKER
|
||||
elif msg.photo:
|
||||
msg_type = MessageType.PHOTO
|
||||
elif msg.video:
|
||||
msg_type = MessageType.VIDEO
|
||||
elif msg.audio:
|
||||
msg_type = MessageType.AUDIO
|
||||
elif msg.voice:
|
||||
msg_type = MessageType.VOICE
|
||||
elif msg.document:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
else:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
|
||||
|
||||
msg_type = self._media_message_type(msg)
|
||||
|
||||
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
|
||||
|
||||
# Add caption as text
|
||||
|
||||
@@ -364,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"error": f"Unknown route: {route_name}"}, status=404
|
||||
)
|
||||
|
||||
# Disabled routes are kept in the subscriptions file (so the dashboard
|
||||
# can re-enable them) but reject incoming events. Default-enabled:
|
||||
# only an explicit ``enabled: false`` turns a route off, matching the
|
||||
# mcp_servers ``enabled`` semantics.
|
||||
if route_config.get("enabled", True) is False:
|
||||
return web.json_response(
|
||||
{"error": f"Route disabled: {route_name}"}, status=403
|
||||
)
|
||||
|
||||
# ── Auth-before-body ─────────────────────────────────────
|
||||
# Check Content-Length before reading the full payload.
|
||||
content_length = request.content_length or 0
|
||||
|
||||
@@ -161,7 +161,15 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
).strip() or DEFAULT_WS_URL
|
||||
|
||||
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
|
||||
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
|
||||
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
|
||||
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
|
||||
# (dm_policy=allowlist via env, no config extra) runs with an empty
|
||||
# allowlist and drops every authorized DM at intake.
|
||||
self._allow_from = _coerce_list(
|
||||
extra.get("allow_from")
|
||||
or extra.get("allowFrom")
|
||||
or os.getenv("WECOM_ALLOWED_USERS", "")
|
||||
)
|
||||
|
||||
self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
|
||||
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
|
||||
|
||||
+20
-12
@@ -378,12 +378,16 @@ async def _api_post(
|
||||
) -> Dict[str, Any]:
|
||||
body = _json_dumps({**payload, "base_info": _base_info()})
|
||||
url = f"{base_url.rstrip('/')}/{endpoint}"
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
|
||||
async def _do() -> Dict[str, Any]:
|
||||
async with session.post(url, data=body, headers=_headers(token, body)) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
|
||||
|
||||
async def _api_get(
|
||||
@@ -398,12 +402,16 @@ async def _api_get(
|
||||
"iLink-App-Id": ILINK_APP_ID,
|
||||
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.get(url, headers=headers, timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
|
||||
async def _do() -> Dict[str, Any]:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
|
||||
|
||||
async def _get_updates(
|
||||
|
||||
@@ -379,6 +379,11 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
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":
|
||||
|
||||
+26
-2
@@ -5121,6 +5121,30 @@ class GatewayRunner:
|
||||
cross boards, so delivery semantics are unchanged — this is
|
||||
purely a fan-out of the single-DB poll.
|
||||
"""
|
||||
# Gate: only the dispatch-owning gateway opens kanban DBs for notifier polling.
|
||||
# Non-dispatch gateways have no subscriptions to deliver — all kanban state lives
|
||||
# in the dispatch owner's per-board DBs. This prevents N-gateway -shm contention.
|
||||
# TODO: gate per-board when per-board dispatcher_owner tracking lands.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
except Exception:
|
||||
logger.warning("kanban notifier: config loader unavailable; disabled")
|
||||
return
|
||||
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
|
||||
if env_override in {"0", "false", "no", "off"}:
|
||||
logger.info("kanban notifier: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
|
||||
return
|
||||
try:
|
||||
cfg = _load_config()
|
||||
except Exception as exc:
|
||||
logger.warning("kanban notifier: cannot load config (%s); disabled", exc)
|
||||
return
|
||||
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
|
||||
if not kanban_cfg.get("dispatch_in_gateway", True):
|
||||
logger.info(
|
||||
"kanban notifier: disabled via config kanban.dispatch_in_gateway=false"
|
||||
)
|
||||
return
|
||||
from gateway.config import Platform as _Platform
|
||||
try:
|
||||
from hermes_cli import kanban_db as _kb
|
||||
@@ -6820,8 +6844,8 @@ class GatewayRunner:
|
||||
"""Whether the adapter for *platform* gates access at intake itself.
|
||||
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented
|
||||
``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
|
||||
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
|
||||
@@ -261,6 +261,12 @@ class GatewayStreamConsumer:
|
||||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
# #29346: a tool/segment boundary means what we delivered was an interim
|
||||
# preamble, not the final answer — clear the flags so a premature setter
|
||||
# can't fool the gateway. Safe: got_done returns before any reset, and
|
||||
# run.py reads these only after the consumer task exits.
|
||||
self._final_response_sent = False
|
||||
self._final_content_delivered = False
|
||||
# Native draft streaming: bump the draft_id so the next text segment
|
||||
# animates as a fresh preview below the tool-progress bubbles, not
|
||||
# over the prior segment's already-finalized draft. This is how
|
||||
@@ -549,6 +555,9 @@ class GatewayStreamConsumer:
|
||||
current_update_visible = await self._send_or_edit(
|
||||
display_text,
|
||||
finalize=(got_done or got_segment_break),
|
||||
# A segment-break finalize closes a preamble, not the
|
||||
# turn-final answer — only got_done marks delivered (#29346).
|
||||
is_turn_final=got_done,
|
||||
)
|
||||
self._last_edit_time = time.monotonic()
|
||||
|
||||
@@ -1058,12 +1067,17 @@ class GatewayStreamConsumer:
|
||||
age = time.monotonic() - self._message_created_ts
|
||||
return age >= threshold
|
||||
|
||||
async def _try_fresh_final(self, text: str) -> bool:
|
||||
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
|
||||
"""Send ``text`` as a brand-new message (best-effort delete the old
|
||||
preview) so the platform's visible timestamp reflects completion
|
||||
time. Returns True on successful delivery, False on any failure so
|
||||
the caller falls back to the normal edit path.
|
||||
|
||||
``is_turn_final`` is False when finalizing an interim segment at a tool
|
||||
boundary (a preamble) rather than the turn-final answer; the
|
||||
final-delivery flag is then left unset so the gateway still delivers the
|
||||
real answer from the next API call (#29346).
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
@@ -1108,10 +1122,13 @@ class GatewayStreamConsumer:
|
||||
self._message_created_ts = None
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
self._final_response_sent = True
|
||||
if is_turn_final:
|
||||
self._final_response_sent = True
|
||||
return True
|
||||
|
||||
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
|
||||
async def _send_or_edit(
|
||||
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
|
||||
) -> bool:
|
||||
"""Send or edit the streaming message.
|
||||
|
||||
Returns True if the text was successfully delivered (sent or edited),
|
||||
@@ -1205,7 +1222,9 @@ class GatewayStreamConsumer:
|
||||
if (
|
||||
finalize
|
||||
and self._should_send_fresh_final()
|
||||
and await self._try_fresh_final(text)
|
||||
and await self._try_fresh_final(
|
||||
text, is_turn_final=is_turn_final,
|
||||
)
|
||||
):
|
||||
return True
|
||||
# Edit existing message
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Adapter-driven dispatch of structured stream events to a delivery sink.
|
||||
|
||||
``GatewayEventDispatcher`` is the seam Tobi asked for: the agent emits typed
|
||||
events (gateway/stream_events.py), and the *adapter* decides how each one is
|
||||
delivered. The dispatcher holds an adapter + the stream consumer (sink) + the
|
||||
resolved per-channel presentation settings (tool-progress mode, preview length)
|
||||
and routes each event through the adapter's render hooks.
|
||||
|
||||
Message/commentary/segment events flow into the consumer (native draft on
|
||||
Telegram DMs, edit-in-place elsewhere). Tool events are formatted by the
|
||||
adapter — which may return None to *eat* the event on platforms that can't
|
||||
render tool chrome — and the rendered line is enqueued onto the same tool
|
||||
progress queue the gateway already drains, so the two no longer race through
|
||||
independent code paths.
|
||||
|
||||
This module deliberately has no platform knowledge and no asyncio: it is a thin
|
||||
synchronous router callable from the agent's worker thread, exactly like the
|
||||
callbacks it replaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from gateway.stream_events import (
|
||||
Commentary,
|
||||
GatewayNotice,
|
||||
LongToolHint,
|
||||
MessageChunk,
|
||||
MessageStop,
|
||||
StreamEvent,
|
||||
ToolCallChunk,
|
||||
ToolCallFinished,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("gateway.stream_events")
|
||||
|
||||
|
||||
class GatewayEventDispatcher:
|
||||
"""Route typed stream events through an adapter onto a delivery sink.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
adapter:
|
||||
The platform adapter. Provides ``render_message_event`` and
|
||||
``format_tool_event`` (BasePlatformAdapter defaults reproduce today's
|
||||
behavior; adapters may override for native rendering).
|
||||
sink:
|
||||
The GatewayStreamConsumer for assistant-text delivery. May be None
|
||||
when streaming is disabled, in which case message events are dropped
|
||||
(the final response still goes out via the normal send path).
|
||||
enqueue_tool_line:
|
||||
Callback that places a rendered tool-progress line onto the gateway's
|
||||
progress queue (the same queue ``send_progress_messages`` drains). May
|
||||
be None when tool progress is disabled for this channel.
|
||||
tool_mode:
|
||||
Resolved tool-progress mode for this channel ("all" / "new" / "verbose"
|
||||
/ "off").
|
||||
preview_max_len:
|
||||
Resolved ``tool_preview_length`` (0 = no cap in verbose mode).
|
||||
on_long_tool / on_notice:
|
||||
Optional hooks for LongToolHint / GatewayNotice events, letting the
|
||||
gateway own the "should I surface this here?" decision.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: Any,
|
||||
sink: Any = None,
|
||||
*,
|
||||
enqueue_tool_line: Optional[Callable[[Any], None]] = None,
|
||||
tool_mode: str = "all",
|
||||
preview_max_len: int = 40,
|
||||
on_long_tool: Optional[Callable[[LongToolHint], None]] = None,
|
||||
on_notice: Optional[Callable[[GatewayNotice], None]] = None,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.sink = sink
|
||||
self._enqueue_tool_line = enqueue_tool_line
|
||||
self.tool_mode = tool_mode or "all"
|
||||
self.preview_max_len = preview_max_len
|
||||
self._on_long_tool = on_long_tool
|
||||
self._on_notice = on_notice
|
||||
# "new" mode dedup — only report when the tool changes.
|
||||
self._last_tool: Optional[str] = None
|
||||
|
||||
def dispatch(self, event: StreamEvent) -> None:
|
||||
"""Route a single event. Never raises into the agent's worker thread."""
|
||||
try:
|
||||
self._dispatch(event)
|
||||
except Exception: # presentation must never break the agent loop
|
||||
logger.debug("stream-event dispatch error", exc_info=True)
|
||||
|
||||
def _dispatch(self, event: StreamEvent) -> None:
|
||||
if isinstance(event, (MessageChunk, MessageStop, Commentary)):
|
||||
if self.sink is not None:
|
||||
self.adapter.render_message_event(event, self.sink)
|
||||
return
|
||||
|
||||
if isinstance(event, ToolCallChunk):
|
||||
if self.tool_mode == "off" or self._enqueue_tool_line is None:
|
||||
return
|
||||
# "new" mode: only emit when the tool changes.
|
||||
if self.tool_mode == "new" and event.tool_name == self._last_tool:
|
||||
return
|
||||
self._last_tool = event.tool_name
|
||||
line = self.adapter.format_tool_event(
|
||||
event, mode=self.tool_mode, preview_max_len=self.preview_max_len,
|
||||
)
|
||||
# None == adapter chose to eat this event (can't render tool chrome).
|
||||
if line:
|
||||
self._enqueue_tool_line(line)
|
||||
return
|
||||
|
||||
if isinstance(event, ToolCallFinished):
|
||||
# Default: no chrome on completion (matches today — the gateway only
|
||||
# rendered "started" events). Completion drives onboarding hints.
|
||||
return
|
||||
|
||||
if isinstance(event, LongToolHint):
|
||||
if self._on_long_tool is not None:
|
||||
self._on_long_tool(event)
|
||||
return
|
||||
|
||||
if isinstance(event, GatewayNotice):
|
||||
if self._on_notice is not None:
|
||||
self._on_notice(event)
|
||||
return
|
||||
|
||||
|
||||
__all__ = ["GatewayEventDispatcher"]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Structured streaming events — the agent→gateway delivery contract.
|
||||
|
||||
Historically the agent drove gateway delivery through a fan of loosely-typed
|
||||
callbacks (``stream_delta_callback(text)``, ``tool_progress_callback(event_type,
|
||||
tool_name, preview, args)``, ``interim_assistant_callback(text)`` …) and each
|
||||
gateway callback decided *both* what to render and how to send it. That
|
||||
coupling is why tool-progress bubbles and the streaming draft raced each other
|
||||
on Telegram, and why tool-call formatting lived agent-side even though only the
|
||||
gateway knows what a given platform can render.
|
||||
|
||||
This module defines a small, typed event vocabulary that names *what happened*
|
||||
without prescribing *how it is delivered*. The gateway's stream consumer
|
||||
(``GatewayStreamConsumer``) is the single sink; the platform adapter decides how
|
||||
to render each event (Telegram can stream a MarkdownV2 ```bash``` block as a
|
||||
native draft; iMessage has no rich formatting and may collapse or drop tool
|
||||
chrome). Separation of concerns: smart agent emits structured data, smart
|
||||
gateway decides delivery.
|
||||
|
||||
These are intentionally plain frozen dataclasses — no behavior, no platform
|
||||
knowledge, no I/O. They are cheap to construct on the agent's worker thread and
|
||||
safe to hand across the thread/async boundary into the consumer queue.
|
||||
|
||||
Design constraints (see hermes-agent-dev skill — message-flow + cache
|
||||
invariants):
|
||||
* Events describe *transport*, never *context*. Nothing here is persisted to
|
||||
conversation history; what the gateway chooses to "eat" (e.g. tool chrome on
|
||||
a platform that can't render it) must never diverge from the bytes stored in
|
||||
the agent's message history. History is owned by the agent; these events are
|
||||
a presentation-layer stream only.
|
||||
* Backward compatible by construction. The gateway adapts its existing
|
||||
callbacks into these events at the boundary; adapters that don't opt into
|
||||
event-native rendering get identical behavior via the base-class default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
|
||||
# ── Message (assistant text) events ──────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageChunk:
|
||||
"""A delta of streamed assistant text.
|
||||
|
||||
``text`` is the incremental content as it arrives from the model. The
|
||||
consumer accumulates chunks and progressively renders them (native draft on
|
||||
Telegram DMs, edit-in-place elsewhere). Reasoning/think-block content is
|
||||
filtered upstream and never arrives as a MessageChunk.
|
||||
"""
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageStop:
|
||||
"""The current assistant message segment is complete.
|
||||
|
||||
Emitted when a contiguous run of assistant text ends — either the whole
|
||||
response finished, or a tool boundary interrupts the text so the next
|
||||
segment should render as a fresh message *below* any tool chrome.
|
||||
|
||||
``final`` is True only for the terminal stop of the whole turn; an
|
||||
intermediate stop (text → tool call → more text) carries ``final=False`` so
|
||||
the consumer finalizes the current bubble and prepares a new segment without
|
||||
treating the turn as done.
|
||||
"""
|
||||
final: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Commentary:
|
||||
"""A complete interim assistant message emitted between tool iterations.
|
||||
|
||||
Example: the model says "I'll inspect the repo first." before issuing a tool
|
||||
call. Unlike a MessageChunk this is already-complete text (not a delta); the
|
||||
consumer renders it as its own message so it reads as a distinct beat.
|
||||
"""
|
||||
text: str
|
||||
|
||||
|
||||
# ── Tool-call events ─────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallChunk:
|
||||
"""A tool invocation has started (or its in-progress state changed).
|
||||
|
||||
Carries the raw facts about the call — name, a short argument ``preview``,
|
||||
and the full ``args`` dict — and lets the *gateway* decide presentation
|
||||
(emoji, truncation, verbose vs compact, or eat it entirely on platforms that
|
||||
don't show tool chrome). Previously the agent's gateway callback baked the
|
||||
emoji + preview formatting in; that decision now belongs to the adapter.
|
||||
"""
|
||||
tool_name: str
|
||||
preview: Optional[str] = None
|
||||
args: Optional[Dict[str, Any]] = None
|
||||
# Monotonic per-turn index, so the consumer can correlate a finish with its
|
||||
# start and so "new"-mode dedup (only report when the tool changes) works
|
||||
# without the consumer tracking call order itself.
|
||||
index: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallFinished:
|
||||
"""A tool invocation completed.
|
||||
|
||||
``duration`` is wall-clock seconds. ``ok`` reflects whether the tool
|
||||
returned without raising. The gateway uses this to clear/settle a progress
|
||||
bubble and to drive one-time onboarding hints (e.g. suggest /verbose after a
|
||||
long tool run). No tool *output* travels here — output is the agent's
|
||||
concern and is persisted to history, not streamed as presentation.
|
||||
"""
|
||||
tool_name: str
|
||||
duration: float = 0.0
|
||||
ok: bool = True
|
||||
index: int = 0
|
||||
|
||||
|
||||
# ── Gateway control / lifecycle events ───────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LongToolHint:
|
||||
"""One-shot onboarding nudge when a tool runs longer than the threshold.
|
||||
|
||||
The gateway gates this on platform capability (the /verbose command must be
|
||||
usable) and on the user not having seen the hint before. Modeled as an
|
||||
event so the *gateway* owns the "should I surface this here?" decision rather
|
||||
than the agent.
|
||||
"""
|
||||
tool_name: str = ""
|
||||
duration: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayNotice:
|
||||
"""A gateway-originated control message (restart, online, long-run notice).
|
||||
|
||||
``kind`` is a stable string the adapter can switch on
|
||||
(``"restart"`` / ``"online"`` / ``"long_run"`` / …). ``text`` is the
|
||||
human-readable default the base class renders when an adapter has no
|
||||
platform-specific treatment.
|
||||
"""
|
||||
kind: str
|
||||
text: str = ""
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Union of every event the consumer's dispatcher accepts. Kept explicit (rather
|
||||
# than a marker base class) so a missing ``case`` in an exhaustive match is a
|
||||
# visible type error rather than a silent fall-through.
|
||||
StreamEvent = Union[
|
||||
MessageChunk,
|
||||
MessageStop,
|
||||
Commentary,
|
||||
ToolCallChunk,
|
||||
ToolCallFinished,
|
||||
LongToolHint,
|
||||
GatewayNotice,
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MessageChunk",
|
||||
"MessageStop",
|
||||
"Commentary",
|
||||
"ToolCallChunk",
|
||||
"ToolCallFinished",
|
||||
"LongToolHint",
|
||||
"GatewayNotice",
|
||||
"StreamEvent",
|
||||
]
|
||||
@@ -6165,6 +6165,7 @@ def _prompt_model_selection(
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
description=description,
|
||||
searchable=True,
|
||||
)
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
|
||||
"conflicts (Telegram, Discord, and Slack only allow one active "
|
||||
"session per token)."
|
||||
)
|
||||
print_info("Recommendation: stop the gateway first with 'hermes stop'.")
|
||||
print_info("Recommendation: stop the gateway first with 'hermes gateway stop'.")
|
||||
print()
|
||||
if not auto_yes and not prompt_yes_no("Continue anyway?", default=False):
|
||||
print_info("Migration cancelled. Stop the gateway and try again.")
|
||||
|
||||
@@ -124,7 +124,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model for this session", "Configuration",
|
||||
aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"),
|
||||
args_hint="[model] [--provider name] [--global] [--refresh]"),
|
||||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||||
"Configuration", aliases=("codex_runtime",),
|
||||
args_hint="[auto|codex_app_server]"),
|
||||
|
||||
@@ -105,7 +105,9 @@ _hermes_profiles() {{
|
||||
local profiles_dir="$HOME/.hermes/profiles"
|
||||
local profiles="default"
|
||||
if [ -d "$profiles_dir" ]; then
|
||||
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
|
||||
for f in "$profiles_dir"/*/; do
|
||||
[ -d "$f" ] && profiles="$profiles $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
echo "$profiles"
|
||||
}}
|
||||
@@ -206,7 +208,7 @@ _hermes_profiles() {{
|
||||
local -a profiles
|
||||
profiles=(default)
|
||||
if [[ -d "$HOME/.hermes/profiles" ]]; then
|
||||
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
|
||||
profiles+=($HOME/.hermes/profiles/*(N/:t))
|
||||
fi
|
||||
_describe 'profile' profiles
|
||||
}}
|
||||
@@ -260,7 +262,9 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
|
||||
"function __hermes_profiles",
|
||||
" echo default",
|
||||
" if test -d $HOME/.hermes/profiles",
|
||||
" ls $HOME/.hermes/profiles 2>/dev/null",
|
||||
" for d in $HOME/.hermes/profiles/*/",
|
||||
" basename $d",
|
||||
" end",
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
|
||||
+61
-2
@@ -1345,7 +1345,27 @@ DEFAULT_CONFIG = {
|
||||
# responses and content messages are never touched. Default 0
|
||||
# (disabled) preserves prior behavior.
|
||||
"ephemeral_system_ttl": 0,
|
||||
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
|
||||
# Per-platform display/streaming overrides. Each key is a gateway
|
||||
# platform ("telegram", "discord", "slack", …) mapping to a dict of
|
||||
# display settings that override the global value for that platform
|
||||
# only. A setting left unset here falls through to the global default.
|
||||
#
|
||||
# Shipped defaults encode the streaming experience that works best
|
||||
# per platform:
|
||||
# - Telegram has native animated draft streaming (sendMessageDraft),
|
||||
# which is smooth, so streaming is on by default there.
|
||||
# - Discord/Slack/etc. only have edit-based streaming (repeated
|
||||
# editMessage), which flickers and is noticeably jankier, so
|
||||
# streaming is off by default there.
|
||||
# These are gap-fillers: a user who explicitly sets, e.g.,
|
||||
# display.platforms.discord.streaming: true keeps their value
|
||||
# (config deep-merge has user values win over defaults). The global
|
||||
# streaming.enabled master switch still gates everything — these
|
||||
# per-platform flags only take effect once streaming is enabled.
|
||||
"platforms": {
|
||||
"telegram": {"streaming": True},
|
||||
"discord": {"streaming": False},
|
||||
},
|
||||
# Gateway runtime-metadata footer appended to the FINAL message of a turn
|
||||
# (disabled by default to keep replies minimal). When enabled, renders
|
||||
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
|
||||
@@ -2037,6 +2057,45 @@ DEFAULT_CONFIG = {
|
||||
"trust_recent_files_seconds": 600,
|
||||
},
|
||||
|
||||
# Real-time token streaming to messaging platforms (Telegram, Discord,
|
||||
# Slack, etc.). Read at the top level by the gateway; absent this block the
|
||||
# gateway falls back to these same defaults, so adding it here only makes
|
||||
# the feature discoverable in config.yaml — it does not change behavior.
|
||||
#
|
||||
# Disabled by default: streaming costs extra edit/draft API calls per
|
||||
# response. Set ``enabled: true`` and restart the gateway to turn it on.
|
||||
"streaming": {
|
||||
# Master switch. When false, each response is delivered as a single
|
||||
# final message (no progressive updates).
|
||||
"enabled": False,
|
||||
# Transport selection:
|
||||
# "auto" — prefer native draft streaming where the platform
|
||||
# supports it (Telegram DMs via sendMessageDraft,
|
||||
# Bot API 9.5+) and fall back to edit-based elsewhere.
|
||||
# Safe global default: platforms without draft support
|
||||
# (Discord, Slack, Matrix, Telegram groups) transparently
|
||||
# use the edit path, so "auto" only upgrades chats that
|
||||
# can render the smoother native preview.
|
||||
# "draft" — explicitly request native drafts; falls back to edit
|
||||
# when the platform/chat doesn't support them.
|
||||
# "edit" — progressive editMessageText only (legacy behavior).
|
||||
# "off" — disable streaming entirely (same as enabled: false).
|
||||
"transport": "auto",
|
||||
# Minimum seconds between progressive edits — tuned for Telegram's
|
||||
# ~1 edit/s flood envelope.
|
||||
"edit_interval": 0.8,
|
||||
# Flush the buffer to the platform once this many characters have
|
||||
# accumulated, so short replies feel near-instant.
|
||||
"buffer_threshold": 24,
|
||||
# Cursor glyph appended to the in-progress message while streaming.
|
||||
"cursor": " \u2589",
|
||||
# When >0, the final edit for a long-running streamed response is
|
||||
# delivered as a fresh message if the preview has been visible at
|
||||
# least this many seconds, so the platform timestamp reflects
|
||||
# completion time. Telegram only; other platforms ignore it.
|
||||
"fresh_final_after_seconds": 60.0,
|
||||
},
|
||||
|
||||
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
|
||||
# state.db accumulates every session, message, tool call, and FTS5 index
|
||||
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
|
||||
@@ -3756,7 +3815,7 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions",
|
||||
"sessions", "streaming",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
|
||||
@@ -473,66 +473,6 @@ def _cmd_list_archived(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_usage(args) -> int:
|
||||
"""Show usage telemetry for ALL skills, with provenance.
|
||||
|
||||
Unlike `status` (curator-scoped to agent-created candidates), this lists
|
||||
every skill on disk — bundled built-ins and hub-installed included — so you
|
||||
can see how often each is actually used regardless of curation.
|
||||
"""
|
||||
import json as _json
|
||||
from tools import skill_usage
|
||||
|
||||
rows = skill_usage.usage_report()
|
||||
|
||||
prov_filter = getattr(args, "provenance", None)
|
||||
if prov_filter:
|
||||
rows = [r for r in rows if r.get("provenance") == prov_filter]
|
||||
|
||||
sort_key = getattr(args, "sort", "activity")
|
||||
if sort_key == "name":
|
||||
rows.sort(key=lambda r: r["name"])
|
||||
elif sort_key == "recent":
|
||||
# Most-recently-active first; never-active sinks to the bottom.
|
||||
rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True)
|
||||
else: # "activity" (default): most-used first
|
||||
rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(_json.dumps(rows, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
if not rows:
|
||||
print("curator: no skills found")
|
||||
return 0
|
||||
|
||||
# Provenance tallies for a quick header.
|
||||
counts = {"agent": 0, "bundled": 0, "hub": 0}
|
||||
for r in rows:
|
||||
counts[r.get("provenance", "agent")] = counts.get(r.get("provenance", "agent"), 0) + 1
|
||||
print(
|
||||
f"skills: {len(rows)} total "
|
||||
f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})"
|
||||
)
|
||||
print()
|
||||
print(
|
||||
f" {'skill':40s} {'origin':8s} "
|
||||
f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity"
|
||||
)
|
||||
for r in rows:
|
||||
last = _fmt_ts(r.get("last_activity_at"))
|
||||
print(
|
||||
f" {r['name'][:40]:40s} "
|
||||
f"{r.get('provenance', 'agent'):8s} "
|
||||
f"{r.get('use_count', 0):>4d} "
|
||||
f"{r.get('view_count', 0):>4d} "
|
||||
f"{r.get('patch_count', 0):>5d} "
|
||||
f"{r.get('activity_count', 0):>4d} "
|
||||
f"{last}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring (called from hermes_cli.main)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -549,25 +489,6 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_status = subs.add_parser("status", help="Show curator status and skill stats")
|
||||
p_status.set_defaults(func=_cmd_status)
|
||||
|
||||
p_usage = subs.add_parser(
|
||||
"usage",
|
||||
help="Show usage telemetry for ALL skills (built-in, hub, agent) with provenance",
|
||||
)
|
||||
p_usage.add_argument(
|
||||
"--sort", choices=("activity", "recent", "name"), default="activity",
|
||||
help="Sort order: activity (most-used first, default), recent "
|
||||
"(most-recently-active first), or name (alphabetical)",
|
||||
)
|
||||
p_usage.add_argument(
|
||||
"--provenance", choices=("agent", "bundled", "hub"), default=None,
|
||||
help="Only show skills of this origin",
|
||||
)
|
||||
p_usage.add_argument(
|
||||
"--json", action="store_true",
|
||||
help="Emit the full report as JSON instead of a table",
|
||||
)
|
||||
p_usage.set_defaults(func=_cmd_usage)
|
||||
|
||||
p_run = subs.add_parser("run", help="Trigger a curator review now")
|
||||
p_run.add_argument(
|
||||
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
||||
|
||||
+349
-27
@@ -5,11 +5,242 @@ Provides a curses multi-select with keyboard navigation, plus a
|
||||
text-based numbered fallback for terminals without curses support.
|
||||
"""
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional, Set
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
def _query_matches(label: str, query: str) -> bool:
|
||||
"""Return True when every query token is a case-insensitive subsequence."""
|
||||
normalized = label.lower()
|
||||
tokens = query.lower().split()
|
||||
|
||||
if not tokens:
|
||||
return True
|
||||
|
||||
for token in tokens:
|
||||
pos = 0
|
||||
|
||||
for ch in token:
|
||||
pos = normalized.find(ch, pos)
|
||||
|
||||
if pos < 0:
|
||||
return False
|
||||
|
||||
pos += 1
|
||||
|
||||
return True
|
||||
|
||||
|
||||
_WORD_BOUNDARY = frozenset("-_/. ")
|
||||
|
||||
|
||||
def _is_boundary(target: str, index: int) -> bool:
|
||||
"""True if position ``index`` in ``target`` starts a word.
|
||||
|
||||
Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a
|
||||
separator char, or a lower->upper camelCase transition.
|
||||
"""
|
||||
if index == 0:
|
||||
return True
|
||||
|
||||
prev = target[index - 1]
|
||||
|
||||
if prev in _WORD_BOUNDARY:
|
||||
return True
|
||||
|
||||
# camelCase / lower->upper transition (e.g. the `O` in `gptO`).
|
||||
cur = target[index]
|
||||
|
||||
return prev == prev.lower() and cur != cur.lower() and cur == cur.upper()
|
||||
|
||||
|
||||
def _token_score(orig: str, lower: str, token: str) -> float | None:
|
||||
"""Score one token against a target. None if the token isn't a subsequence.
|
||||
|
||||
A faithful port of ``fuzzyScore`` in ui-tui/src/lib/fuzzy.ts and
|
||||
web/src/lib/fuzzy.ts so all three surfaces rank model ids identically:
|
||||
contiguous runs, word-boundary / first-char starts, prefix matches, and
|
||||
exact matches all score higher than scattered subsequence hits.
|
||||
|
||||
``lower`` is ``orig`` lowercased; matching is done against ``lower`` while
|
||||
boundary detection uses ``orig`` (so the camelCase rule works), exactly as
|
||||
in the TS scorer.
|
||||
"""
|
||||
score = 0.0
|
||||
prev = -1
|
||||
search_from = 0
|
||||
positions: list[int] = []
|
||||
|
||||
for ch in token:
|
||||
idx = lower.find(ch, search_from)
|
||||
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
positions.append(idx)
|
||||
score += 1
|
||||
|
||||
if prev >= 0 and idx == prev + 1:
|
||||
score += 5
|
||||
elif prev >= 0:
|
||||
score -= min(idx - prev - 1, 3)
|
||||
|
||||
if _is_boundary(orig, idx):
|
||||
score += 3
|
||||
|
||||
if idx == 0:
|
||||
score += 5
|
||||
|
||||
prev = idx
|
||||
search_from = idx + 1
|
||||
|
||||
# Prefix bonus: the token matched a contiguous prefix of the target.
|
||||
if positions and positions[0] == 0 and positions[-1] == len(positions) - 1:
|
||||
score += 8
|
||||
|
||||
# Exact full match dominates everything else.
|
||||
if lower == token:
|
||||
score += 20
|
||||
|
||||
# Slightly prefer shorter targets when scores are otherwise close.
|
||||
score -= len(lower) * 0.01
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def _fuzzy_score(label: str, query: str) -> float | None:
|
||||
"""Aggregate score for a multi-token query (AND). None if any token fails.
|
||||
|
||||
Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated
|
||||
token must match; per-token scores are summed.
|
||||
"""
|
||||
lower = label.lower()
|
||||
tokens = query.lower().split()
|
||||
|
||||
if not tokens:
|
||||
return 0.0
|
||||
|
||||
total = 0.0
|
||||
|
||||
for token in tokens:
|
||||
token_score = _token_score(label, lower, token)
|
||||
|
||||
if token_score is None:
|
||||
return None
|
||||
|
||||
total += token_score
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _filter_indices(items: List[str], query: str) -> List[int]:
|
||||
"""Return item indices matching *query*, ranked best-first.
|
||||
|
||||
An empty query keeps every item in original order. Otherwise items are
|
||||
filtered to fuzzy matches and sorted by score descending, ties broken by
|
||||
original index so equal-scoring rows keep their catalog order.
|
||||
"""
|
||||
q = query.strip()
|
||||
|
||||
if not q:
|
||||
return list(range(len(items)))
|
||||
|
||||
scored = []
|
||||
|
||||
for i, label in enumerate(items):
|
||||
score = _fuzzy_score(label, q)
|
||||
|
||||
if score is not None:
|
||||
scored.append((i, score))
|
||||
|
||||
scored.sort(key=lambda pair: (-pair[1], pair[0]))
|
||||
|
||||
return [i for i, _ in scored]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SearchState:
|
||||
"""Mutable search state shared by curses picker loops."""
|
||||
|
||||
active: bool = False
|
||||
query: str = ""
|
||||
|
||||
|
||||
def _reconcile_cursor(filtered: List[int], cursor: int) -> tuple[int, int]:
|
||||
"""Return ``(cursor, cursor_pos)`` inside the filtered index list."""
|
||||
if not filtered:
|
||||
return cursor, 0
|
||||
|
||||
if cursor not in filtered:
|
||||
cursor = filtered[0]
|
||||
|
||||
return cursor, filtered.index(cursor)
|
||||
|
||||
|
||||
def _move_filtered_cursor(
|
||||
filtered: List[int], cursor: int, cursor_pos: int, delta: int
|
||||
) -> int:
|
||||
"""Move through the filtered index list, wrapping like the legacy menus."""
|
||||
if not filtered:
|
||||
return cursor
|
||||
|
||||
return filtered[(cursor_pos + delta) % len(filtered)]
|
||||
|
||||
|
||||
def _scroll_for_cursor(
|
||||
scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int
|
||||
) -> int:
|
||||
"""Clamp scroll offset so the cursor remains visible."""
|
||||
visible_rows = max(1, visible_rows)
|
||||
|
||||
if cursor_pos < scroll_offset:
|
||||
scroll_offset = cursor_pos
|
||||
elif cursor_pos >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor_pos - visible_rows + 1
|
||||
|
||||
return max(0, min(scroll_offset, max(0, total_rows - visible_rows)))
|
||||
|
||||
|
||||
def _handle_active_search_key(
|
||||
curses_mod, key: int, search: _SearchState
|
||||
) -> tuple[bool, bool, bool]:
|
||||
"""Handle a key while the search prompt is active.
|
||||
|
||||
Returns ``(handled, confirm, changed)``. Active search consumes query
|
||||
editing keys, but leaves navigation keys for the menu loop to handle.
|
||||
"""
|
||||
if not search.active:
|
||||
return False, False, False
|
||||
|
||||
if key == 27:
|
||||
# Esc stops search AND clears the query, restoring the full list (so a
|
||||
# no-match filter can't strand the user on an empty list). Signals
|
||||
# `changed` when there was a query so the driver resets scroll/cursor.
|
||||
had_query = bool(search.query)
|
||||
search.active = False
|
||||
search.query = ""
|
||||
return True, False, had_query
|
||||
|
||||
if key in (curses_mod.KEY_BACKSPACE, 127, 8):
|
||||
search.query = search.query[:-1]
|
||||
return True, False, True
|
||||
|
||||
if key == 21: # Ctrl+U
|
||||
search.query = ""
|
||||
return True, False, True
|
||||
|
||||
if key in (curses_mod.KEY_ENTER, 10, 13):
|
||||
return True, True, False
|
||||
|
||||
if 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255
|
||||
search.query += chr(key)
|
||||
return True, False, True
|
||||
|
||||
return False, False, False
|
||||
|
||||
|
||||
def flush_stdin() -> None:
|
||||
"""Flush any stray bytes from the stdin input buffer.
|
||||
|
||||
@@ -58,9 +289,16 @@ def read_menu_key(stdscr) -> str:
|
||||
the escape path; ``q`` also cancels. Unknown sequences map to
|
||||
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
|
||||
"""
|
||||
import curses
|
||||
return _decode_menu_key(stdscr, stdscr.getch())
|
||||
|
||||
key = stdscr.getch()
|
||||
|
||||
def _decode_menu_key(stdscr, key: int) -> str:
|
||||
"""Normalize an already-read keypress to a menu action.
|
||||
|
||||
Split out from ``read_menu_key`` so search-aware loops can peek the raw
|
||||
key (e.g. to catch ``/``) before falling back to nav decoding.
|
||||
"""
|
||||
import curses
|
||||
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
return NAV_UP
|
||||
@@ -121,6 +359,8 @@ def _run_curses_menu(
|
||||
extra_color_pairs=False,
|
||||
fallback,
|
||||
cancel_value,
|
||||
searchable=False,
|
||||
search_labels=None,
|
||||
):
|
||||
"""Shared curses single-/multi-select event loop.
|
||||
|
||||
@@ -135,9 +375,12 @@ def _run_curses_menu(
|
||||
Callbacks / params:
|
||||
draw_header(stdscr, max_y, max_x) -> int
|
||||
Draw the title/hint/description rows. Returns the first screen row
|
||||
index where the scrollable item list should start.
|
||||
index where the scrollable item list should start. When search is
|
||||
active it receives the live ``_SearchState`` via the optional
|
||||
``search`` keyword (drawn by the menu so the hint line can show it).
|
||||
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
|
||||
Draw one item row.
|
||||
Draw one item row. ``idx`` is always the ORIGINAL item index, so
|
||||
per-menu rendering is unchanged whether or not a filter is active.
|
||||
on_action(action, cursor) -> value
|
||||
Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the
|
||||
loop; return anything else to resolve the menu with that value.
|
||||
@@ -151,6 +394,10 @@ def _run_curses_menu(
|
||||
fallback() -> value
|
||||
Called when curses errors out on a real TTY (curses unavailable).
|
||||
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
|
||||
searchable: when true, ``/`` opens a type-to-filter prompt over
|
||||
``search_labels``. Returned values are always ORIGINAL item indices.
|
||||
search_labels: per-item text used for filtering (required when
|
||||
``searchable`` is true; length must equal ``item_count``).
|
||||
"""
|
||||
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
|
||||
# so return the cancel value directly — matching the pre-refactor guard in
|
||||
@@ -158,6 +405,8 @@ def _run_curses_menu(
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_value
|
||||
|
||||
use_search = searchable and search_labels is not None and len(search_labels) == item_count
|
||||
|
||||
try:
|
||||
import curses
|
||||
result_holder = [_KEEP]
|
||||
@@ -175,22 +424,46 @@ def _run_curses_menu(
|
||||
)
|
||||
cursor = initial_cursor
|
||||
scroll_offset = 0
|
||||
search = _SearchState()
|
||||
# Non-None labels for filtering; empty when search is disabled so
|
||||
# _filter_indices stays a cheap identity range.
|
||||
labels: List[str] = (
|
||||
search_labels if (use_search and search_labels is not None) else []
|
||||
)
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
items_start = draw_header(stdscr, max_y, max_x)
|
||||
filtered = (
|
||||
_filter_indices(labels, search.query)
|
||||
if use_search
|
||||
else list(range(item_count))
|
||||
)
|
||||
cursor, cursor_pos = _reconcile_cursor(filtered, cursor)
|
||||
|
||||
visible_rows = max_y - items_start - reserve_bottom
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
# draw_header accepts an optional `search` kwarg when the menu
|
||||
# wants to render the live filter; tolerate headers that don't.
|
||||
try:
|
||||
items_start = draw_header(stdscr, max_y, max_x, search=search)
|
||||
except TypeError:
|
||||
items_start = draw_header(stdscr, max_y, max_x)
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
|
||||
visible_rows = max(1, max_y - items_start - reserve_bottom)
|
||||
scroll_offset = _scroll_for_cursor(
|
||||
scroll_offset, cursor_pos, visible_rows, len(filtered)
|
||||
)
|
||||
|
||||
if use_search and search.query and not filtered:
|
||||
try:
|
||||
stdscr.addnstr(items_start, 0, " No matches", max_x - 1, curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
for draw_i, filtered_pos in enumerate(
|
||||
range(scroll_offset, min(len(filtered), scroll_offset + visible_rows))
|
||||
):
|
||||
i = filtered[filtered_pos]
|
||||
y = draw_i + items_start
|
||||
if y >= max_y - reserve_bottom:
|
||||
break
|
||||
@@ -200,13 +473,46 @@ def _run_curses_menu(
|
||||
draw_footer(stdscr, max_y, max_x)
|
||||
|
||||
stdscr.refresh()
|
||||
action = read_menu_key(stdscr)
|
||||
|
||||
if use_search:
|
||||
key = stdscr.getch()
|
||||
|
||||
if search.active:
|
||||
# Active search consumes query-editing keys; nav keys
|
||||
# fall through to be decoded below.
|
||||
handled, confirm, changed = _handle_active_search_key(
|
||||
curses, key, search
|
||||
)
|
||||
if changed:
|
||||
scroll_offset = 0
|
||||
cursor, cursor_pos = _reconcile_cursor(
|
||||
_filter_indices(search_labels, search.query), cursor
|
||||
)
|
||||
if confirm:
|
||||
if filtered:
|
||||
outcome = on_action(NAV_SELECT, cursor)
|
||||
if outcome is not _KEEP:
|
||||
result_holder[0] = outcome
|
||||
return
|
||||
continue
|
||||
if handled:
|
||||
continue
|
||||
action = _decode_menu_key(stdscr, key)
|
||||
elif key == ord("/"):
|
||||
search.active = True
|
||||
continue
|
||||
else:
|
||||
action = _decode_menu_key(stdscr, key)
|
||||
else:
|
||||
action = read_menu_key(stdscr)
|
||||
|
||||
if action == NAV_UP:
|
||||
cursor = (cursor - 1) % item_count
|
||||
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1)
|
||||
elif action == NAV_DOWN:
|
||||
cursor = (cursor + 1) % item_count
|
||||
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1)
|
||||
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
|
||||
if action == NAV_SELECT and use_search and not filtered:
|
||||
continue
|
||||
outcome = on_action(action, cursor)
|
||||
if outcome is not _KEEP:
|
||||
result_holder[0] = outcome
|
||||
@@ -320,6 +626,7 @@ def curses_radiolist(
|
||||
*,
|
||||
cancel_returns: int | None = None,
|
||||
description: str | None = None,
|
||||
searchable: bool = False,
|
||||
) -> int:
|
||||
"""Curses single-select radio list. Returns the selected index.
|
||||
|
||||
@@ -331,6 +638,9 @@ def curses_radiolist(
|
||||
description: Optional multi-line text shown between the title and
|
||||
the item list. Useful for context that should survive the
|
||||
curses screen clear.
|
||||
searchable: When true, ``/`` opens a type-to-filter prompt. The
|
||||
returned value is always the original item index, not a filtered
|
||||
row position.
|
||||
"""
|
||||
if cancel_returns is None:
|
||||
cancel_returns = selected
|
||||
@@ -339,7 +649,7 @@ def curses_radiolist(
|
||||
if description:
|
||||
desc_lines = description.splitlines()
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
def _draw_header(stdscr, max_y, max_x, search=None):
|
||||
import curses
|
||||
row = 0
|
||||
try:
|
||||
@@ -356,11 +666,13 @@ def curses_radiolist(
|
||||
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
|
||||
row += 1
|
||||
|
||||
stdscr.addnstr(
|
||||
row, 0,
|
||||
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
if searchable and search is not None and search.active:
|
||||
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
|
||||
elif searchable:
|
||||
hint = " \u2191\u2193 navigate ENTER/SPACE select / search ESC cancel"
|
||||
else:
|
||||
hint = " \u2191\u2193 navigate ENTER/SPACE select ESC cancel"
|
||||
stdscr.addnstr(row, 0, hint, max_x - 1, curses.A_DIM)
|
||||
row += 1
|
||||
except curses.error:
|
||||
pass
|
||||
@@ -396,6 +708,8 @@ def curses_radiolist(
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
|
||||
cancel_value=cancel_returns,
|
||||
searchable=searchable,
|
||||
search_labels=list(items) if searchable else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -431,27 +745,33 @@ def curses_single_select(
|
||||
default_index: int = 0,
|
||||
*,
|
||||
cancel_label: str = "Cancel",
|
||||
searchable: bool = False,
|
||||
) -> int | None:
|
||||
"""Curses single-select menu. Returns selected index or None on cancel.
|
||||
|
||||
Works inside prompt_toolkit because curses.wrapper() restores the terminal
|
||||
safely, unlike simple_term_menu which conflicts with /dev/tty.
|
||||
|
||||
When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the
|
||||
returned value is always the original item index (or None for cancel).
|
||||
"""
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
def _draw_header(stdscr, max_y, max_x, search=None):
|
||||
import curses
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
if searchable and search is not None and search.active:
|
||||
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
|
||||
elif searchable:
|
||||
hint = " ↑↓ navigate ENTER confirm / search ESC/q cancel"
|
||||
else:
|
||||
hint = " ↑↓ navigate ENTER confirm ESC/q cancel"
|
||||
stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
return 3
|
||||
@@ -488,6 +808,8 @@ def curses_single_select(
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
|
||||
cancel_value=None,
|
||||
searchable=searchable,
|
||||
search_labels=list(all_items) if searchable else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
|
||||
from hermes_cli.dashboard_auth import list_providers
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import ProviderError
|
||||
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
|
||||
from hermes_cli.dashboard_auth.cookies import read_session_cookies
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
|
||||
@@ -185,44 +185,94 @@ async def gated_auth_middleware(
|
||||
return await call_next(request)
|
||||
|
||||
at, _rt = read_session_cookies(request)
|
||||
if not at:
|
||||
if not at and not _rt:
|
||||
# Neither token present — no session at all. Nothing to verify or
|
||||
# refresh; force login.
|
||||
return _unauth_response(request, reason="no_cookie")
|
||||
|
||||
# Try every registered provider's verify_session in turn. Providers
|
||||
# MUST return None for tokens they don't recognise (not raise). This
|
||||
# lets multiple providers stack — the first one that recognises a
|
||||
# token wins.
|
||||
#
|
||||
# When the access-token cookie is absent but a refresh-token cookie is
|
||||
# present, skip verification and go straight to the refresh path below.
|
||||
# This is the COMMON expiry case, not an edge case: the access-token
|
||||
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
|
||||
# the browser EVICTS it the moment the token lapses, while the
|
||||
# refresh-token cookie lives for 30 days. From that point the browser
|
||||
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
|
||||
# bounce the user to /login on every expiry despite holding a perfectly
|
||||
# good refresh token — defeating the whole transparent-refresh feature.
|
||||
session = None
|
||||
for provider in list_providers():
|
||||
try:
|
||||
session = provider.verify_session(access_token=at)
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during verify: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return JSONResponse(
|
||||
{"detail": f"Auth provider {provider.name!r} unreachable"},
|
||||
status_code=503,
|
||||
)
|
||||
if session is not None:
|
||||
break
|
||||
if at:
|
||||
for provider in list_providers():
|
||||
try:
|
||||
session = provider.verify_session(access_token=at)
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during verify: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return JSONResponse(
|
||||
{"detail": f"Auth provider {provider.name!r} unreachable"},
|
||||
status_code=503,
|
||||
)
|
||||
if session is not None:
|
||||
break
|
||||
|
||||
if session is None:
|
||||
# Access token is expired/invalid. Before forcing re-login, try to
|
||||
# rotate it using the refresh token (if the session cookie carries
|
||||
# one). On success we re-set the rotated cookies on the response and
|
||||
# serve the request transparently; on RefreshExpiredError (RT dead /
|
||||
# revoked / reuse-detected) we fall through to clear-and-relogin.
|
||||
refreshed = _attempt_refresh(request, refresh_token=_rt)
|
||||
if refreshed is not None:
|
||||
new_session, refreshing_provider = refreshed
|
||||
request.state.session = new_session
|
||||
response = await call_next(request)
|
||||
# Persist the ROTATED tokens. Portal rotates the refresh token on
|
||||
# every refresh and runs reuse-detection, so writing the new RT
|
||||
# back is mandatory: a stale RT cookie would replay a rotated
|
||||
# token on the next refresh and (outside Portal's grace) revoke
|
||||
# the whole session. Bind cookie Secure/Path to the request shape.
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
detect_https,
|
||||
set_session_cookies,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
|
||||
set_session_cookies(
|
||||
response,
|
||||
access_token=new_session.access_token,
|
||||
refresh_token=new_session.refresh_token,
|
||||
access_token_expires_in=_expires_in_seconds(new_session),
|
||||
use_https=detect_https(request),
|
||||
prefix=prefix_from_request(request),
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_SUCCESS,
|
||||
provider=refreshing_provider,
|
||||
user_id=new_session.user_id,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return response
|
||||
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
reason="no_provider_recognises",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
response = _unauth_response(request, reason="invalid_or_expired_session")
|
||||
# Clear the dead cookie so the browser doesn't keep sending it.
|
||||
# Contract v1: no refresh token to retry with, so the only correct
|
||||
# Clear the dead cookies so the browser doesn't keep sending them.
|
||||
# Refresh already failed (or there was no RT), so the only correct
|
||||
# next step is full re-auth via /login. Importing locally avoids a
|
||||
# cycle with cookies → middleware at module load. Pass the active
|
||||
# prefix so the deletion's Path matches the set-Path (otherwise
|
||||
@@ -234,3 +284,61 @@ async def gated_auth_middleware(
|
||||
|
||||
request.state.session = session
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _expires_in_seconds(session) -> int:
|
||||
"""Seconds until the access token's ``exp``, floored at 60.
|
||||
|
||||
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
|
||||
cookie's Max-Age tracks the token lifetime even on a slightly skewed
|
||||
clock. ``time`` imported locally to keep the module's import surface
|
||||
minimal.
|
||||
"""
|
||||
import time
|
||||
|
||||
return max(60, int(session.expires_at) - int(time.time()))
|
||||
|
||||
|
||||
def _attempt_refresh(request: Request, *, refresh_token):
|
||||
"""Try to rotate an expired session via the refresh token.
|
||||
|
||||
Returns ``(new_session, provider_name)`` on success, or ``None`` if
|
||||
there's no RT or every provider's ``refresh_session`` failed with
|
||||
``RefreshExpiredError`` (dead/revoked/reuse-detected RT → force re-login).
|
||||
|
||||
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
|
||||
here — re-raising would 500 the request; instead we log and return None so
|
||||
the caller forces a clean re-login, which is the safer UX than a hard
|
||||
error on a transient network blip during the narrow refresh window.
|
||||
"""
|
||||
if not refresh_token:
|
||||
return None
|
||||
for provider in list_providers():
|
||||
try:
|
||||
new_session = provider.refresh_session(refresh_token=refresh_token)
|
||||
except RefreshExpiredError:
|
||||
# This provider owns the RT but it's dead — stop trying others
|
||||
# (an RT belongs to exactly one provider) and force re-login.
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="refresh_expired",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during refresh: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
if new_session is not None:
|
||||
return new_session, provider.name
|
||||
return None
|
||||
|
||||
|
||||
+54
-12
@@ -453,11 +453,8 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
||||
if pid == my_pid or pid in exclude_pids:
|
||||
continue
|
||||
try:
|
||||
cmdline = (
|
||||
open(f"/proc/{pid}/cmdline", "rb")
|
||||
.read()
|
||||
.decode("utf-8", errors="replace")
|
||||
)
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as _f:
|
||||
cmdline = _f.read().decode("utf-8", errors="replace")
|
||||
cmdline = cmdline.replace("\x00", " ")
|
||||
cmdline_lc = cmdline.lower()
|
||||
if any(p in cmdline_lc for p in patterns) and (
|
||||
@@ -5877,15 +5874,60 @@ def _maybe_redirect_run_to_s6_supervision(args) -> bool:
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
# Block until the container is signalled. The supervised gateway's
|
||||
# lifetime is independent of this process — s6-supervise restarts
|
||||
# it on crash, and we don't want the container to exit when the
|
||||
# gateway flaps. `sleep infinity` matches the static main-hermes
|
||||
# service's pattern (see docker/s6-rc.d/main-hermes/run): the CMD
|
||||
# process is a no-op heartbeat that keeps /init alive until
|
||||
# Keep the CMD process alive as a no-op heartbeat. The supervised
|
||||
# gateway's lifetime is independent of this process — s6-supervise
|
||||
# restarts it on crash, and we don't want the container to exit when
|
||||
# the gateway flaps. The CMD process keeps /init alive until
|
||||
# `docker stop` sends SIGTERM, at which point /init runs stage 3
|
||||
# shutdown (which tears down the supervised gateway cleanly).
|
||||
os.execvp("sleep", ["sleep", "infinity"])
|
||||
#
|
||||
# Prefer `sleep infinity` (matches the static main-hermes service's
|
||||
# pattern in docker/s6-rc.d/main-hermes/run, and frees the Python
|
||||
# interpreter — the heartbeat is a tiny `sleep` process, not a
|
||||
# resident interpreter). But `os.execvp` does a PATH lookup for the
|
||||
# `sleep` binary and historically crashed the whole container with
|
||||
# FileNotFoundError when PATH was empty/truncated/clobbered at this
|
||||
# point — e.g. after user customizations rewrote PATH, or on minimal
|
||||
# images without `sleep` on PATH (issue #36208). Fall back to an
|
||||
# in-process block (no external binary, can't fail on PATH) so the
|
||||
# container keeps running instead of dying during boot.
|
||||
try:
|
||||
os.execvp("sleep", ["sleep", "infinity"])
|
||||
except OSError:
|
||||
# execvp only returns by raising; on success it replaces this
|
||||
# process. ENOENT (no `sleep` on PATH) and any other exec error
|
||||
# land here.
|
||||
print(
|
||||
"→ `sleep` is unavailable; keeping the s6 CMD process alive "
|
||||
"in-process until the container is stopped.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
_block_until_terminated()
|
||||
return True # unreachable on the execvp success path
|
||||
|
||||
|
||||
def _block_until_terminated() -> None:
|
||||
"""Keep the s6 CMD process alive until the container is stopped.
|
||||
|
||||
Fallback heartbeat for when ``os.execvp("sleep", ...)`` can't run
|
||||
(``sleep`` missing from PATH — issue #36208). Installs a SIGTERM
|
||||
handler that exits with the conventional 128+signum code so
|
||||
``docker stop`` produces a clean, expected exit, then blocks on
|
||||
``signal.pause()``. Falls back to ``threading.Event().wait()`` on
|
||||
platforms without ``signal.pause()`` (e.g. Windows) — although this
|
||||
path only runs inside the s6 Linux container image, the fallback
|
||||
keeps the helper safe to import and unit-test anywhere.
|
||||
"""
|
||||
signal.signal(signal.SIGTERM, lambda signum, _frame: sys.exit(128 + signum))
|
||||
pause = getattr(signal, "pause", None)
|
||||
if pause is not None:
|
||||
while True:
|
||||
pause()
|
||||
else: # pragma: no cover - non-Unix fallback, not exercised in the s6 image
|
||||
import threading
|
||||
|
||||
threading.Event().wait()
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
|
||||
+25
-3
@@ -4353,13 +4353,21 @@ def decompose_triage_task(
|
||||
child_ids: list[str] = []
|
||||
with write_txn(conn):
|
||||
root_row = conn.execute(
|
||||
"SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,)
|
||||
"SELECT id, status, tenant, workspace_kind, workspace_path "
|
||||
"FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if root_row is None:
|
||||
return None
|
||||
if root_row["status"] != "triage":
|
||||
return None
|
||||
tenant = root_row["tenant"]
|
||||
# Children inherit the root's workspace by default so a fan-out
|
||||
# of a code-gen task lands in the parent's project dir/worktree
|
||||
# rather than throwaway scratch tmp dirs. A child dict can still
|
||||
# override with its own 'workspace_kind' / 'workspace_path'.
|
||||
root_ws_kind = root_row["workspace_kind"] or "scratch"
|
||||
root_ws_path = root_row["workspace_path"]
|
||||
|
||||
# Create children. Status is 'todo' regardless of parents — we
|
||||
# link them under the root AFTER creation so the dispatcher
|
||||
@@ -4370,16 +4378,30 @@ def decompose_triage_task(
|
||||
title = child["title"].strip()
|
||||
body = child.get("body")
|
||||
assignee = _canonical_assignee(child.get("assignee"))
|
||||
# Per-child override wins; otherwise inherit the root's
|
||||
# workspace. A child that sets workspace_kind without a path
|
||||
# falls back to the root path only when kinds match (so a
|
||||
# child can't accidentally point a 'dir' at the root's
|
||||
# worktree path or vice versa).
|
||||
child_ws_kind = child.get("workspace_kind") or root_ws_kind
|
||||
if child.get("workspace_path"):
|
||||
child_ws_path = child.get("workspace_path")
|
||||
elif child_ws_kind == root_ws_kind:
|
||||
child_ws_path = root_ws_path
|
||||
else:
|
||||
child_ws_path = None
|
||||
conn.execute(
|
||||
"INSERT INTO tasks "
|
||||
"(id, title, body, assignee, status, workspace_kind, "
|
||||
" tenant, created_at, created_by) "
|
||||
"VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)",
|
||||
" workspace_path, tenant, created_at, created_by) "
|
||||
"VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)",
|
||||
(
|
||||
new_id,
|
||||
title,
|
||||
body if isinstance(body, str) else None,
|
||||
assignee,
|
||||
child_ws_kind,
|
||||
child_ws_path,
|
||||
tenant,
|
||||
now,
|
||||
(author or "decomposer"),
|
||||
|
||||
+57
-9
@@ -4575,6 +4575,7 @@ def _model_flow_named_custom(config, provider_info):
|
||||
menu_items,
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
searchable=True,
|
||||
)
|
||||
print()
|
||||
if idx < 0 or idx >= len(models):
|
||||
@@ -7478,8 +7479,23 @@ def _update_via_zip(args):
|
||||
# individually so update does not silently strip working capabilities.
|
||||
print("→ Updating Python dependencies...")
|
||||
|
||||
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
|
||||
|
||||
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin, fresh_bootstrap = ensure_uv()
|
||||
# First-time managed uv install on an existing checkout: the old venv
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
|
||||
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
|
||||
sys.exit(1)
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
@@ -8579,16 +8595,27 @@ def _install_psutil_android_compat(
|
||||
|
||||
|
||||
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
"""Best-effort uv bootstrap on Termux for faster update installs."""
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin or not _is_termux_env():
|
||||
return uv_bin
|
||||
"""Best-effort uv bootstrap on Termux for faster update installs.
|
||||
|
||||
The normal path (``ensure_uv()`` in managed_uv) installs the managed
|
||||
standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official
|
||||
installer may not work (glibc vs bionic). Fall back to ``pip install uv``
|
||||
which gets a Termux-compatible binary.
|
||||
"""
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return existing
|
||||
if not _is_termux_env():
|
||||
return None
|
||||
try:
|
||||
print(" → Termux detected: trying to install uv for faster dependency updates...")
|
||||
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
|
||||
except Exception:
|
||||
pass
|
||||
return shutil.which("uv")
|
||||
# After pip install, check managed path first, then PATH
|
||||
return resolve_uv() or shutil.which("uv")
|
||||
|
||||
|
||||
def _update_node_dependencies() -> None:
|
||||
@@ -9236,7 +9263,12 @@ def _cmd_update_pip(args):
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
uv = shutil.which("uv")
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
# Keep managed uv current before using it.
|
||||
update_managed_uv()
|
||||
|
||||
uv, _fresh_bootstrap = ensure_uv()
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
@@ -9251,7 +9283,8 @@ def _cmd_update_pip(args):
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
|
||||
print("✗ Detected a uv-tool install but managed uv install failed.")
|
||||
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
elif pipx_managed and pipx:
|
||||
@@ -9647,8 +9680,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# breaks on this machine, keep base deps and reinstall the remaining extras
|
||||
# individually so update does not silently strip working capabilities.
|
||||
print("→ Updating Python dependencies...")
|
||||
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
|
||||
|
||||
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin, fresh_bootstrap = ensure_uv()
|
||||
# First-time managed uv install on an existing checkout: the old venv
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
|
||||
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
|
||||
sys.exit(1)
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
install_group = "all"
|
||||
|
||||
if uv_bin:
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Managed uv — one path, no guessing.
|
||||
|
||||
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
|
||||
Windows). Every code path that needs uv resolves it from that single location.
|
||||
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
|
||||
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
|
||||
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
|
||||
probing, no conda guards, no multi-location resolution chains.
|
||||
|
||||
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
|
||||
managed uv before), it returns ``(path, True)`` instead of just ``path``.
|
||||
Callers in the update path use that signal to nuke and recreate the venv
|
||||
with the now-current managed uv, guaranteeing a Python with FTS5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def managed_uv_path() -> Path:
|
||||
"""Return the path where Hermes keeps *its* uv binary.
|
||||
|
||||
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
|
||||
Windows. The directory may not exist yet — callers should use
|
||||
``ensure_uv()`` to bootstrap it.
|
||||
"""
|
||||
home = get_hermes_home()
|
||||
if platform.system() == "Windows":
|
||||
return home / "bin" / "uv.exe"
|
||||
return home / "bin" / "uv"
|
||||
|
||||
|
||||
def resolve_uv() -> Optional[str]:
|
||||
"""Return the managed uv path if it exists, else ``None``.
|
||||
|
||||
No side effects — pure lookup.
|
||||
"""
|
||||
p = managed_uv_path()
|
||||
if p.is_file() and os.access(p, os.X_OK):
|
||||
return str(p)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_uv() -> Tuple[Optional[str], bool]:
|
||||
"""Return the managed uv path, installing it first if necessary.
|
||||
|
||||
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
|
||||
``True`` when we just installed managed uv for the first time (there was
|
||||
no managed uv before this call). Callers can use that signal to rebuild
|
||||
the venv so Python is guaranteed to have FTS5.
|
||||
|
||||
On failure returns ``(None, False)`` (never raises) so callers can fall
|
||||
back to pip gracefully.
|
||||
"""
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return (existing, False)
|
||||
|
||||
target = managed_uv_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" → Installing managed uv into {target.parent} ...")
|
||||
|
||||
try:
|
||||
_install_uv(target)
|
||||
except Exception as exc:
|
||||
logger.warning("Managed uv install failed: %s", exc)
|
||||
print(f" ✗ Failed to install managed uv: {exc}")
|
||||
return (None, False)
|
||||
|
||||
# Verify
|
||||
result = resolve_uv()
|
||||
if result:
|
||||
version = subprocess.run(
|
||||
[result, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ Managed uv installed ({version})")
|
||||
else:
|
||||
print(" ✗ Managed uv install appeared to succeed but binary not found")
|
||||
return (result, result is not None)
|
||||
|
||||
|
||||
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
|
||||
"""Nuke and recreate the venv with managed uv.
|
||||
|
||||
Called when managed uv is first bootstrapped on an existing install — the
|
||||
old venv may point to a Python without FTS5, so we rebuild it with a
|
||||
fresh interpreter from the current managed uv. Returns ``True`` on
|
||||
success.
|
||||
"""
|
||||
if venv_dir.exists():
|
||||
print(f" → Rebuilding venv (old Python may lack FTS5)...")
|
||||
shutil.rmtree(venv_dir, ignore_errors=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[uv_bin, "venv", str(venv_dir), "--python", python_version],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
|
||||
py_ver = subprocess.run(
|
||||
[str(venv_python), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ venv rebuilt ({py_ver})")
|
||||
return True
|
||||
else:
|
||||
logger.warning("venv rebuild failed: %s", result.stderr)
|
||||
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def update_managed_uv() -> Optional[str]:
|
||||
"""Run ``uv self update`` on the managed uv binary.
|
||||
|
||||
Call this during ``hermes update`` so the managed copy stays current.
|
||||
Returns the managed path if a managed uv is present (always — even when
|
||||
``uv self update`` fails, the old binary still works). Returns ``None``
|
||||
only when no managed uv exists yet (``ensure_uv()`` handles that case).
|
||||
"""
|
||||
existing = resolve_uv()
|
||||
if not existing:
|
||||
# Not installed yet — ensure_uv() will handle that elsewhere.
|
||||
return None
|
||||
|
||||
result = subprocess.run(
|
||||
[existing, "self", "update"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version = subprocess.run(
|
||||
[existing, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ Managed uv updated ({version})")
|
||||
else:
|
||||
# Non-fatal — old uv still works fine.
|
||||
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
|
||||
return existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installer internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_uv(target: Path) -> None:
|
||||
"""Bootstrap uv into *target* using the official standalone installer.
|
||||
|
||||
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
|
||||
so the astral installer writes the binary directly into
|
||||
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
|
||||
"""
|
||||
system = platform.system()
|
||||
env = {
|
||||
**os.environ,
|
||||
# Tell the astral installer to drop the binary in our dir, not
|
||||
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
|
||||
# uses UV_INSTALL_DIR.
|
||||
"UV_UNMANAGED_INSTALL": str(target.parent),
|
||||
"UV_INSTALL_DIR": str(target.parent),
|
||||
}
|
||||
|
||||
if system == "Windows":
|
||||
_install_uv_windows(env)
|
||||
else:
|
||||
_install_uv_posix(env)
|
||||
|
||||
|
||||
def _install_uv_posix(env: dict[str, str]) -> None:
|
||||
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
|
||||
installer_path = f.name
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["sh", installer_path],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(installer_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _install_uv_windows(env: dict[str, str]) -> None:
|
||||
"""Invoke the PowerShell installer."""
|
||||
cmd = (
|
||||
'irm https://astral.sh/uv/install.ps1 | iex'
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
+115
-19
@@ -700,6 +700,48 @@ def switch_model(
|
||||
|
||||
target_provider = pdef.id
|
||||
|
||||
# Guard against silent aggregator hops. A vendor name like bare
|
||||
# "openai" is an alias that resolves to an aggregator ("openrouter").
|
||||
# If the user explicitly asked for that vendor but the aggregator it
|
||||
# routes to has no credentials, do NOT silently switch them onto an
|
||||
# unauthed endpoint (the classic HTTP 401 "Missing Authentication
|
||||
# header"). Point them at the real direct provider instead.
|
||||
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
|
||||
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
|
||||
_explicit_norm = explicit_provider.strip().lower()
|
||||
_alias_target = _PROVIDER_ALIAS_TABLE.get(_explicit_norm)
|
||||
if (
|
||||
_alias_target
|
||||
and _alias_target == target_provider
|
||||
and target_provider != _explicit_norm
|
||||
and target_provider in _AGG_PROVIDERS
|
||||
):
|
||||
_authed = get_authenticated_provider_slugs(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
)
|
||||
if target_provider not in _authed:
|
||||
_suggestions = [
|
||||
s for s in _authed
|
||||
if s.startswith(_explicit_norm) and s != _explicit_norm
|
||||
]
|
||||
_hint = (
|
||||
f" Did you mean: {', '.join(_suggestions)}?"
|
||||
if _suggestions else ""
|
||||
)
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=pdef.name,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Provider '{_explicit_norm}' is an alias that routes "
|
||||
f"through {get_label(target_provider)}, which "
|
||||
f"has no credentials configured.{_hint}"
|
||||
),
|
||||
)
|
||||
|
||||
# If no model specified, try auto-detect from endpoint
|
||||
if not new_model:
|
||||
if pdef.base_url:
|
||||
@@ -854,25 +896,62 @@ def switch_model(
|
||||
api_mode = ""
|
||||
|
||||
if provider_changed or explicit_provider:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception as e:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Could not resolve credentials for provider "
|
||||
f"'{provider_label}': {e}"
|
||||
),
|
||||
)
|
||||
import os
|
||||
# User-config providers (providers.<name> in config.yaml) carry their
|
||||
# own base_url + transport + key reference. resolve_runtime_provider()
|
||||
# resolves by provider NAME and doesn't know user-config slugs (e.g. a
|
||||
# block named "openai"), so it would re-resolve from scratch and fail
|
||||
# or hop to an aggregator. Use the pdef's endpoint directly instead.
|
||||
_user_pdef = None
|
||||
if explicit_provider and user_providers:
|
||||
from hermes_cli.providers import resolve_user_provider as _ruser
|
||||
_user_pdef = _ruser(explicit_provider.strip().lower(), user_providers)
|
||||
if _user_pdef is None:
|
||||
_user_pdef = _ruser(target_provider, user_providers)
|
||||
if _user_pdef is not None and _user_pdef.base_url:
|
||||
_ucfg = (user_providers or {}).get(explicit_provider.strip().lower()) \
|
||||
or (user_providers or {}).get(target_provider) or {}
|
||||
_ukey = str(_ucfg.get("api_key", "") or "").strip()
|
||||
if _ukey.startswith("${") and _ukey.endswith("}"):
|
||||
_ukey = os.environ.get(_ukey[2:-1], "").strip()
|
||||
if not _ukey:
|
||||
_kenv = str(_ucfg.get("key_env", "") or "").strip()
|
||||
if _kenv:
|
||||
_ukey = os.environ.get(_kenv, "").strip()
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
explicit_api_key=_ukey or None,
|
||||
explicit_base_url=_user_pdef.base_url,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "") or _ukey
|
||||
base_url = runtime.get("base_url", "") or _user_pdef.base_url
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception:
|
||||
api_key = _ukey
|
||||
base_url = _user_pdef.base_url
|
||||
api_mode = ""
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception as e:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Could not resolve credentials for provider "
|
||||
f"'{provider_label}': {e}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
@@ -1195,7 +1274,24 @@ def list_authenticated_providers(
|
||||
curated["lmstudio"] = live
|
||||
|
||||
# --- 1. Check Hermes-mapped providers ---
|
||||
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
|
||||
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
# Skip vendor names that are merely aliases routing through an
|
||||
# aggregator (e.g. bare "openai" → "openrouter"). These are NOT
|
||||
# directly-routable providers: emitting them as their own picker
|
||||
# row produces a phantom entry that, when selected, resolves via
|
||||
# resolve_provider_full() to the aggregator (OpenRouter) — silently
|
||||
# switching a user off their real provider onto an endpoint they
|
||||
# may have no key for (HTTP 401). The user's real provider (e.g.
|
||||
# openai-api, or a providers.openai config row) covers this vendor.
|
||||
_alias_target = _PROVIDER_ALIAS_TABLE.get(hermes_id)
|
||||
if (
|
||||
_alias_target
|
||||
and _alias_target != hermes_id
|
||||
and _alias_target in _AGG_PROVIDERS
|
||||
):
|
||||
continue
|
||||
# Skip aliases that map to the same models.dev provider (e.g.
|
||||
# kimi-coding and kimi-coding-cn both → kimi-for-coding).
|
||||
# The first one with valid credentials wins (#10526).
|
||||
|
||||
+25
-2
@@ -235,13 +235,13 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"gemini": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
],
|
||||
"google-gemini-cli": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
],
|
||||
"zai": [
|
||||
"glm-5.1",
|
||||
@@ -2106,9 +2106,32 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
if api_key:
|
||||
base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
|
||||
base = base_raw or "https://api.openai.com/v1"
|
||||
# Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted)
|
||||
# may serve a small curated catalog — use the live list verbatim so
|
||||
# discovery works. But the canonical api.openai.com /v1/models dump
|
||||
# is 120+ entries of embeddings, whisper, tts, dall-e, moderation and
|
||||
# legacy chat models — none of which belong in the agent model picker.
|
||||
# For the default endpoint, intersect the live list with our curated
|
||||
# agentic catalog so ``/model`` matches what ``hermes model`` shows.
|
||||
is_default_openai = base.rstrip("/") in (
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.openai.com",
|
||||
)
|
||||
try:
|
||||
live = fetch_api_models(api_key, base)
|
||||
if live:
|
||||
if is_default_openai:
|
||||
live_lower = {m.lower() for m in live}
|
||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
# Keep curated order; only surface curated models the
|
||||
# account actually has access to.
|
||||
filtered = [m for m in curated if m.lower() in live_lower]
|
||||
if filtered:
|
||||
return filtered
|
||||
# Account serves none of the curated models (rare —
|
||||
# e.g. org without GPT-5 access). Fall back to curated
|
||||
# so the picker still offers sane defaults.
|
||||
return curated or live
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+15
-2
@@ -47,7 +47,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
"openrouter": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
extra_env_vars=("OPENAI_API_KEY",),
|
||||
base_url_env_var="OPENROUTER_BASE_URL",
|
||||
),
|
||||
"nous": HermesOverlay(
|
||||
@@ -677,6 +676,20 @@ def resolve_provider_full(
|
||||
ProviderDef if found, else None.
|
||||
"""
|
||||
canonical = normalize_provider(name)
|
||||
raw = name.strip().lower()
|
||||
|
||||
# 0. User-defined config providers win over the built-in alias table.
|
||||
# A user who declares ``providers.<name>`` in config.yaml has stated
|
||||
# explicit intent for that name — it must not be hijacked by a legacy
|
||||
# vendor alias (e.g. bare "openai" → "openrouter"). Resolve the raw
|
||||
# name against user config FIRST so a configured ``providers.openai``
|
||||
# (pointing at api.openai.com) beats the alias that would otherwise
|
||||
# silently route to OpenRouter. Only the raw (pre-alias) name is tried
|
||||
# here; canonical/alias resolution still happens below.
|
||||
if user_providers:
|
||||
user_pdef = resolve_user_provider(raw, user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
# 1. Built-in (models.dev + overlays)
|
||||
pdef = get_provider(canonical)
|
||||
@@ -690,7 +703,7 @@ def resolve_provider_full(
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
# Try original name (in case alias didn't match)
|
||||
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
|
||||
user_pdef = resolve_user_provider(raw, user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
|
||||
+35
-18
@@ -335,7 +335,14 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
# Collect results from all (or filtered) sources in parallel.
|
||||
# Per-source limits are generous — parallelism + 30s timeout cap prevents hangs.
|
||||
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||
# NOTE: when the centralized index is available, parallel_search_sources
|
||||
# skips the external API sources and serves everything from "hermes-index".
|
||||
# That source MUST therefore carry a high limit, or browse silently caps
|
||||
# the entire hub at the default (50) — it shipped that way and surfaced
|
||||
# ~136 of 88k skills. The external-source limits below only apply when the
|
||||
# index is unavailable (offline / first run before the cache populates).
|
||||
_PER_SOURCE_LIMIT = {
|
||||
"hermes-index": 5000,
|
||||
"official": 200, "skills-sh": 200, "well-known": 50,
|
||||
"github": 200, "clawhub": 500, "claude-marketplace": 100,
|
||||
"lobehub": 500, "browse-sh": 500,
|
||||
@@ -396,18 +403,22 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
# Build table
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("#", style="dim", width=4, justify="right")
|
||||
table.add_column("Name", style="bold cyan", max_width=25)
|
||||
table.add_column("Description", max_width=50)
|
||||
table.add_column("Name", style="bold cyan", max_width=22)
|
||||
table.add_column("Description", max_width=44)
|
||||
table.add_column("Source", style="dim", width=12)
|
||||
table.add_column("Trust", width=10)
|
||||
# The identifier is what you pass to `hermes skills install`. Browse used
|
||||
# to omit it entirely, so users couldn't act on what they saw without a
|
||||
# second `search`. overflow="fold" keeps long slugs copy-pasteable.
|
||||
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)
|
||||
|
||||
for i, r in enumerate(page_items, start=start + 1):
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green",
|
||||
"community": "yellow"}.get(r.trust_level, "dim")
|
||||
trust_label = "★ official" if r.source == "official" else r.trust_level
|
||||
|
||||
desc = r.description[:50]
|
||||
if len(r.description) > 50:
|
||||
desc = r.description[:44]
|
||||
if len(r.description) > 44:
|
||||
desc += "..."
|
||||
|
||||
table.add_row(
|
||||
@@ -416,6 +427,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
desc,
|
||||
r.source,
|
||||
f"[{trust_style}]{trust_label}[/]",
|
||||
r.identifier,
|
||||
)
|
||||
|
||||
c.print(table)
|
||||
@@ -439,7 +451,9 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
c.print(f" [yellow]⚡ Slow sources skipped: {', '.join(timed_out)} "
|
||||
f"— run again for cached results[/]")
|
||||
|
||||
c.print("[dim]Tip: 'hermes skills search <query>' searches deeper across all registries[/]\n")
|
||||
c.print("[dim]Tip: 'hermes skills inspect <identifier>' to preview, "
|
||||
"'hermes skills install <identifier>' to install, "
|
||||
"'hermes skills search <query>' to search deeper[/]\n")
|
||||
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
@@ -725,24 +739,27 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
|
||||
|
||||
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
|
||||
"""
|
||||
from tools.skills_hub import GitHubAuth, create_source_router
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, parallel_search_sources,
|
||||
)
|
||||
|
||||
page_size = max(1, min(page_size, 100))
|
||||
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
|
||||
# "hermes-index" must carry a high limit: when the index is available the
|
||||
# router skips external API sources and serves everything from it, so a
|
||||
# low cap here silently truncates the whole hub (see do_browse note).
|
||||
_PER_SOURCE_LIMIT = {"hermes-index": 5000, "official": 100, "skills-sh": 100,
|
||||
"well-known": 25, "github": 100, "clawhub": 50,
|
||||
"claude-marketplace": 50, "lobehub": 50, "browse-sh": 500}
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
all_results: list = []
|
||||
for src in sources:
|
||||
sid = src.source_id()
|
||||
if source != "all" and sid != source and sid != "official":
|
||||
continue
|
||||
try:
|
||||
limit = _PER_SOURCE_LIMIT.get(sid, 50)
|
||||
all_results.extend(src.search("", limit=limit))
|
||||
except Exception:
|
||||
continue
|
||||
# Delegate to the shared parallel walker so this inherits the index-aware
|
||||
# source-skip logic — querying hermes-index AND the external APIs at once
|
||||
# would double-count every skill.
|
||||
all_results, _counts, _timed_out = parallel_search_sources(
|
||||
sources, query="", per_source_limits=_PER_SOURCE_LIMIT,
|
||||
source_filter=source, overall_timeout=30,
|
||||
)
|
||||
if not all_results:
|
||||
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
|
||||
seen: dict = {}
|
||||
@@ -759,7 +776,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
|
||||
page_items = deduped[start : min(start + page_size, total)]
|
||||
return {
|
||||
"items": [{"name": r.name, "description": r.description, "source": r.source,
|
||||
"trust": r.trust_level} for r in page_items],
|
||||
"trust": r.trust_level, "identifier": r.identifier} for r in page_items],
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"total": total,
|
||||
|
||||
+836
-39
File diff suppressed because it is too large
Load Diff
+208
-7
@@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
handoff_platform TEXT,
|
||||
handoff_error TEXT,
|
||||
rewind_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
@@ -451,12 +452,8 @@ class SessionDB:
|
||||
self._fts_unavailable_warned = True
|
||||
logger.warning(
|
||||
"SQLite FTS5 unavailable for %s; full-text session search "
|
||||
"disabled. This usually means Hermes is running on an "
|
||||
"unsupported install (e.g. a pip-installed or pip-managed "
|
||||
"Python whose bundled SQLite lacks FTS5) rather than a "
|
||||
"mainline install. Some features may be missing or behave "
|
||||
"differently. Install the supported way: "
|
||||
"https://hermes-agent.nousresearch.com (underlying error: %s)",
|
||||
"disabled. Run `hermes update` to rebuild the venv with a "
|
||||
"current Python. (underlying error: %s)",
|
||||
self.db_path,
|
||||
exc,
|
||||
)
|
||||
@@ -1430,6 +1427,22 @@ class SessionDB:
|
||||
row = cursor.fetchone()
|
||||
return row["title"] if row else None
|
||||
|
||||
def set_session_archived(self, session_id: str, archived: bool) -> bool:
|
||||
"""Archive or unarchive a session.
|
||||
|
||||
Archived sessions are hidden from the default session list but keep all
|
||||
their messages — this is a soft hide, not a delete. Returns True when a
|
||||
row was updated.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"UPDATE sessions SET archived = ? WHERE id = ?",
|
||||
(1 if archived else 0, session_id),
|
||||
)
|
||||
return cursor.rowcount
|
||||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a session by exact title. Returns session dict or None."""
|
||||
with self._lock:
|
||||
@@ -1549,6 +1562,8 @@ class SessionDB:
|
||||
min_message_count: int = 0,
|
||||
project_compression_tips: bool = True,
|
||||
order_by_last_active: bool = False,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
|
||||
@@ -1604,6 +1619,10 @@ class SessionDB:
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("s.message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
if archived_only:
|
||||
where_clauses.append("s.archived = 1")
|
||||
elif not include_archived:
|
||||
where_clauses.append("s.archived = 0")
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
if order_by_last_active:
|
||||
@@ -3027,7 +3046,13 @@ class SessionDB:
|
||||
# Utility
|
||||
# =========================================================================
|
||||
|
||||
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
|
||||
def session_count(
|
||||
self,
|
||||
source: str = None,
|
||||
min_message_count: int = 0,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
) -> int:
|
||||
"""Count sessions, optionally filtered by source."""
|
||||
where_clauses = []
|
||||
params = []
|
||||
@@ -3038,6 +3063,10 @@ class SessionDB:
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
if archived_only:
|
||||
where_clauses.append("archived = 1")
|
||||
elif not include_archived:
|
||||
where_clauses.append("archived = 0")
|
||||
|
||||
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
|
||||
@@ -3153,6 +3182,178 @@ class SessionDB:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
return deleted
|
||||
|
||||
def delete_sessions(
|
||||
self,
|
||||
session_ids: List[str],
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> int:
|
||||
"""Delete every session in *session_ids* in a single transaction.
|
||||
|
||||
Backs the dashboard's bulk-select-then-delete flow on the
|
||||
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
|
||||
single-session :meth:`delete_session` contract per row:
|
||||
|
||||
* Unknown IDs are silently skipped (no 404) — selection state
|
||||
in the UI can race against another tab's delete, and we'd
|
||||
rather succeed-on-the-rest than fail-the-whole-batch.
|
||||
* Children of every deleted ID are orphaned
|
||||
(``parent_session_id → NULL``), never cascade-deleted, so a
|
||||
branch / subagent transcript survives an inadvertent parent
|
||||
delete.
|
||||
* Messages and the session row both go in one
|
||||
``_execute_write`` call so a partial failure can't leave the
|
||||
DB in a "messages gone but session row still there" state.
|
||||
* On-disk transcript / ``request_dump_*`` files are cleaned up
|
||||
outside the DB transaction when *sessions_dir* is provided,
|
||||
matching :meth:`prune_sessions` and
|
||||
:meth:`delete_empty_sessions`.
|
||||
|
||||
Returns the count of sessions that actually existed and were
|
||||
deleted (may be less than ``len(session_ids)`` if some IDs were
|
||||
already gone).
|
||||
"""
|
||||
if not session_ids:
|
||||
return 0
|
||||
# Dedup + drop any non-string entries up-front. Avoids
|
||||
# double-counting in the WHERE-IN list and protects against
|
||||
# callers that pass a list with stray ``None`` values.
|
||||
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
|
||||
if not unique_ids:
|
||||
return 0
|
||||
|
||||
removed_ids: list[str] = []
|
||||
|
||||
def _do(conn):
|
||||
placeholders = ",".join("?" * len(unique_ids))
|
||||
# First, filter to IDs that actually exist — we want to
|
||||
# return the real deleted count, not the input length.
|
||||
cursor = conn.execute(
|
||||
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
|
||||
unique_ids,
|
||||
)
|
||||
existing = [row["id"] for row in cursor.fetchall()]
|
||||
if not existing:
|
||||
return 0
|
||||
|
||||
existing_placeholders = ",".join("?" * len(existing))
|
||||
# Orphan children whose parent is in the kill list so the
|
||||
# FK constraint stays satisfied. Pin children whose parent
|
||||
# is itself in the kill list rather than NULL-ing parents
|
||||
# of survivors — the IN list on ``parent_session_id`` does
|
||||
# exactly this.
|
||||
conn.execute(
|
||||
f"UPDATE sessions SET parent_session_id = NULL "
|
||||
f"WHERE parent_session_id IN ({existing_placeholders})",
|
||||
existing,
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
|
||||
existing,
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
|
||||
existing,
|
||||
)
|
||||
removed_ids.extend(existing)
|
||||
return len(existing)
|
||||
|
||||
count = self._execute_write(_do)
|
||||
for sid in removed_ids:
|
||||
self._remove_session_files(sessions_dir, sid)
|
||||
return count
|
||||
|
||||
def count_empty_sessions(self) -> int:
|
||||
"""Return the count of empty, non-active, non-archived sessions.
|
||||
|
||||
"Empty" = ``message_count = 0`` AND the session has ended
|
||||
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
|
||||
guard matches the safety contract used by :meth:`prune_sessions`:
|
||||
only ended sessions are candidates for bulk deletion, so a freshly
|
||||
spawned session whose first message hasn't landed yet — or one
|
||||
held open by the live agent — is never sniped out from under
|
||||
the runtime.
|
||||
|
||||
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
|
||||
web dashboard hide its "Delete empty" button when there's nothing
|
||||
to clean up, and pre-populate the confirm dialog with the actual
|
||||
count.
|
||||
"""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions "
|
||||
"WHERE message_count = 0 "
|
||||
"AND ended_at IS NOT NULL "
|
||||
"AND archived = 0"
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def delete_empty_sessions(
|
||||
self,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> int:
|
||||
"""Delete every empty, ended, non-archived session.
|
||||
|
||||
Mirrors :meth:`prune_sessions`' transactional shape:
|
||||
|
||||
* Selects candidate IDs first (``message_count = 0`` AND
|
||||
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
|
||||
touch a live session or one the user deliberately archived.
|
||||
* Orphans any child whose parent is in the kill list — children
|
||||
of an empty parent are kept and re-parented to ``NULL`` rather
|
||||
than cascade-deleted, matching ``delete_session`` /
|
||||
``prune_sessions`` semantics so branch/subagent transcripts
|
||||
survive an inadvertent parent cleanup.
|
||||
* Deletes the rows in a single ``_execute_write`` callback so
|
||||
the operation is atomic — a partial failure (e.g. SIGKILL
|
||||
mid-loop) doesn't leave the DB in a "messages-deleted but
|
||||
session-row-still-there" half-state.
|
||||
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
|
||||
``request_dump_*``) outside the DB transaction when
|
||||
``sessions_dir`` is provided. Empty sessions don't typically
|
||||
have transcript files, but the gateway can leave a stub
|
||||
``request_dump_*`` if it crashed before the first reply —
|
||||
so we still sweep, matching ``prune_sessions``.
|
||||
|
||||
Returns the number of sessions deleted.
|
||||
"""
|
||||
removed_ids: list[str] = []
|
||||
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM sessions "
|
||||
"WHERE message_count = 0 "
|
||||
"AND ended_at IS NOT NULL "
|
||||
"AND archived = 0"
|
||||
)
|
||||
session_ids = {row["id"] for row in cursor.fetchall()}
|
||||
|
||||
if not session_ids:
|
||||
return 0
|
||||
|
||||
placeholders = ",".join("?" * len(session_ids))
|
||||
conn.execute(
|
||||
f"UPDATE sessions SET parent_session_id = NULL "
|
||||
f"WHERE parent_session_id IN ({placeholders})",
|
||||
list(session_ids),
|
||||
)
|
||||
|
||||
for sid in session_ids:
|
||||
# DELETE FROM messages is paranoia — by construction
|
||||
# these rows have ``message_count = 0`` — but if a
|
||||
# bookkeeping bug ever lets the counter drift below the
|
||||
# real row count, we still leave a clean FK state.
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?", (sid,)
|
||||
)
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
removed_ids.append(sid)
|
||||
return len(session_ids)
|
||||
|
||||
count = self._execute_write(_do)
|
||||
for sid in removed_ids:
|
||||
self._remove_session_files(sessions_dir, sid)
|
||||
return count
|
||||
|
||||
def prune_sessions(
|
||||
self,
|
||||
older_than_days: int = 90,
|
||||
|
||||
@@ -36,8 +36,13 @@ Key contract points encoded here:
|
||||
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
|
||||
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
|
||||
JWKS is cached for 5 minutes.
|
||||
- V1 has NO refresh tokens — ``refresh_session`` always raises
|
||||
``RefreshExpiredError`` so the middleware redirects to ``/auth/login``.
|
||||
- the dashboard auth-code grant issues a 24h rotating refresh token
|
||||
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
|
||||
to rotate the access token; ``complete_login`` and ``refresh_session``
|
||||
both populate ``Session.refresh_token`` with the (rotating) value the
|
||||
middleware persists back to the HttpOnly cookie. On a dead/expired/
|
||||
reuse-detected refresh token Portal returns 400 → ``RefreshExpiredError``
|
||||
→ middleware redirects to ``/auth/login``.
|
||||
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
|
||||
- tolerant ``oauth_contract_version`` check: missing → warn + proceed;
|
||||
present and ``!= 1`` → refuse.
|
||||
@@ -49,11 +54,11 @@ of cookie names; this provider just hands back ``{"code_verifier": …,
|
||||
"state": …}`` and the route serializes those into the ``hermes_session_pkce``
|
||||
cookie.
|
||||
|
||||
Forward compatibility: if a future Portal contract starts issuing refresh
|
||||
tokens, ``complete_login`` already captures the value forward-compatibly
|
||||
(populates ``Session.refresh_token``). Wiring the RT cookie back into the
|
||||
middleware's near-expiry refresh path lives in the host application, not
|
||||
here.
|
||||
Refresh-token rotation: Portal rotates the refresh token on every
|
||||
successful refresh and runs reuse-detection (replaying a rotated token
|
||||
outside Portal's 60s grace revokes the whole session). The host
|
||||
middleware therefore MUST persist the rotated ``Session.refresh_token``
|
||||
back to the cookie on every refresh.
|
||||
|
||||
Skip reasons:
|
||||
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
|
||||
@@ -229,12 +234,94 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
|
||||
|
||||
# The dashboard auth-code grant now issues a rotating refresh token
|
||||
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
|
||||
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=InvalidCodeError
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
"""Rotate the access token using the refresh token.
|
||||
|
||||
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
|
||||
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
|
||||
so it never lands in Portal's request-body access logs — mirroring the
|
||||
device-flow CLI convention; Portal reconciles header vs. body and
|
||||
rejects conflicts.
|
||||
|
||||
Portal rotates the refresh token on every successful refresh, so the
|
||||
returned ``Session.refresh_token`` is a NEW value the caller MUST
|
||||
persist (replacing the old cookie). Failing to persist it means the
|
||||
next refresh replays a rotated token and — outside Portal's 60s grace
|
||||
— trips reuse-detection and revokes the whole session.
|
||||
|
||||
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
|
||||
detected), so the middleware clears cookies and forces re-login.
|
||||
Raises ``ProviderError`` if Portal is unreachable.
|
||||
"""
|
||||
if not refresh_token:
|
||||
# No RT to present — treat as a dead session so middleware
|
||||
# forces a clean re-login rather than emitting a malformed POST.
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
self._token_url,
|
||||
# The refresh token goes in BOTH the body and the
|
||||
# ``x-nous-refresh-token`` header. Portal's token endpoint
|
||||
# requires ``refresh_token`` in the body (its request schema
|
||||
# rejects a header-only request as ``invalid_request``), and
|
||||
# additionally reconciles the header against the body — sending
|
||||
# both lets Portal keep the value out of body-access-logs while
|
||||
# still satisfying the schema. The header name must match
|
||||
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
|
||||
# token``); any other name is silently ignored. (Verified
|
||||
# against the NAS #293 preview deploy: header-only → 400
|
||||
# invalid_request; body → accepted.)
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"x-nous-refresh-token": refresh_token,
|
||||
},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint unreachable: {exc}"
|
||||
) from exc
|
||||
|
||||
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
|
||||
# surface as RefreshExpiredError so middleware forces re-login.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=RefreshExpiredError
|
||||
)
|
||||
|
||||
def _token_response_to_session(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
) -> Session:
|
||||
"""Translate a Portal ``/api/oauth/token`` response into a Session.
|
||||
|
||||
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
|
||||
(refresh grant). ``bad_request_exc`` is the exception type raised on a
|
||||
400 — ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
||||
for the refresh path — so the middleware's distinct handling
|
||||
(400-on-callback vs. force-relogin) is preserved.
|
||||
"""
|
||||
if response.status_code == 400:
|
||||
# Contract: invalid_code, invalid_grant, redirect_uri_mismatch all
|
||||
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
|
||||
# (auth-code) and expired / revoked / reuse-detected (refresh) all
|
||||
# surface as 400 with an OAuth-shaped JSON error envelope.
|
||||
body = self._parse_json_body(response)
|
||||
error_code = body.get("error", "invalid_request")
|
||||
raise InvalidCodeError(f"Portal rejected code: {error_code}")
|
||||
raise bad_request_exc(f"Portal rejected token request: {error_code}")
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint returned {response.status_code}: "
|
||||
@@ -251,21 +338,14 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
raise ProviderError(f"unexpected token_type={token_type!r}")
|
||||
|
||||
claims = self._verify_jwt(access_token)
|
||||
# Contract V1: no refresh token expected. If a future Portal ever
|
||||
# adds one, capture it forward-compatibly.
|
||||
# The dashboard grant issues a rotating refresh token; capture it so
|
||||
# the caller can persist it. Empty string if Portal omitted it (the
|
||||
# session then behaves as access-token-only until expiry).
|
||||
refresh_token = payload.get("refresh_token") or ""
|
||||
if not isinstance(refresh_token, str):
|
||||
refresh_token = ""
|
||||
return self._session_from_claims(access_token, refresh_token, claims)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
# Contract V1 has no refresh tokens — always force re-auth. If a
|
||||
# future Portal contract starts issuing them, this method needs to
|
||||
# be re-implemented; until then it's an unconditional refusal.
|
||||
raise RefreshExpiredError(
|
||||
"Nous Portal does not issue refresh tokens in OAuth contract v1; "
|
||||
"user must re-authenticate via /auth/login."
|
||||
)
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# Contract: returns None on expiry/invalidity (middleware then
|
||||
@@ -284,9 +364,16 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
return self._session_from_claims(access_token, "", claims)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Contract V1: no refresh tokens to revoke, and no Portal revocation
|
||||
# endpoint documented for dashboard tokens. Logout is purely
|
||||
# client-side cookie clearing; this is a best-effort no-op.
|
||||
# Portal exposes no public refresh-token revocation grant on its token
|
||||
# endpoint (revocation is driven from the authenticated /sessions UI,
|
||||
# keyed by sessionId + userId, not by the RT value). So logout is
|
||||
# client-side cookie clearing; the server-side refresh session simply
|
||||
# expires within its 24h TTL. Best-effort no-op, must not raise.
|
||||
#
|
||||
# If Portal later adds a token-endpoint revoke grant (e.g.
|
||||
# grant_type=... + X-Refresh-Token), implement it here so logout
|
||||
# invalidates the RT server-side immediately rather than waiting out
|
||||
# the TTL.
|
||||
_ = refresh_token
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Example dashboard plugin — backend API routes.
|
||||
|
||||
Mounted at /api/plugins/example/ by the dashboard plugin system.
|
||||
|
||||
This minimal plugin exists so the test suite has a stable, side-effect-free
|
||||
GET endpoint to verify that plugin API routes work with auth.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/hello")
|
||||
async def hello():
|
||||
"""Simple greeting endpoint to demonstrate plugin API routes."""
|
||||
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
|
||||
@@ -228,6 +228,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._session_initialized = False
|
||||
self._lazy_init_kwargs: Optional[dict] = None
|
||||
self._lazy_init_session_id: Optional[str] = None
|
||||
self._init_thread: Optional[threading.Thread] = None
|
||||
self._init_lock = threading.Lock()
|
||||
self._init_error = ""
|
||||
|
||||
# Port #4053: cron guard — when True, plugin is fully inactive
|
||||
self._cron_skipped = False
|
||||
@@ -326,22 +329,24 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
# aiPeer comes from honcho.json (host block or root) only.
|
||||
# SOUL.md is persona content, not identity config.
|
||||
|
||||
# ----- Port #1957: lazy session init for tools-only mode -----
|
||||
self._lazy_init_kwargs = dict(kwargs)
|
||||
self._lazy_init_session_id = session_id
|
||||
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
|
||||
|
||||
# Network-backed session creation can block on Honcho service or DB
|
||||
# outages. Startup must fail open for context/hybrid modes, where
|
||||
# Honcho is initialized only to enrich prompts. Tools-only mode has
|
||||
# an explicit contract: init_on_session_start=False stays lazy until
|
||||
# the first tool call, while init_on_session_start=True remains an
|
||||
# eager, ready-on-return initialization path.
|
||||
if self._recall_mode == "tools":
|
||||
if cfg.init_on_session_start:
|
||||
# Eager init even in tools mode (opt-in)
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
self._ensure_session()
|
||||
return
|
||||
# Defer actual session creation until first tool call
|
||||
self._lazy_init_kwargs = kwargs
|
||||
self._lazy_init_session_id = session_id
|
||||
# Still need a client reference for _ensure_session
|
||||
self._config = cfg
|
||||
logger.debug("Honcho tools-only mode — deferring session init until first tool call")
|
||||
return
|
||||
|
||||
# ----- Eager init (context or hybrid mode) -----
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
self._start_session_init_background(wait_timeout=0.1)
|
||||
|
||||
except ImportError:
|
||||
logger.debug("honcho-ai package not installed — plugin inactive")
|
||||
@@ -349,6 +354,66 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
logger.warning("Honcho init failed: %s", e)
|
||||
self._manager = None
|
||||
|
||||
def _resolve_session_key(self, cfg, session_id: str, **kwargs) -> str:
|
||||
"""Resolve the Honcho session key without touching the network."""
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
return (
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
|
||||
def _start_session_init_background(self, *, wait_timeout: float = 0.0) -> None:
|
||||
"""Start Honcho session initialization in a daemon thread.
|
||||
|
||||
This keeps Hermes CLI/gateway startup responsive when Honcho is down,
|
||||
slow, or its database is unhealthy. The thread may still take the SDK
|
||||
timeout path, but it cannot block agent construction or first prompt
|
||||
assembly. ``wait_timeout`` lets fast/mock initializations finish before
|
||||
returning while still failing open for slow backends.
|
||||
"""
|
||||
if self._cron_skipped or self._session_initialized:
|
||||
return
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return
|
||||
|
||||
with self._init_lock:
|
||||
if self._cron_skipped or self._session_initialized:
|
||||
return
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return
|
||||
|
||||
cfg = self._config
|
||||
init_kwargs = dict(self._lazy_init_kwargs)
|
||||
init_session_id = self._lazy_init_session_id or "hermes-default"
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
self._do_session_init(cfg, init_session_id, **init_kwargs)
|
||||
self._lazy_init_kwargs = None
|
||||
self._lazy_init_session_id = None
|
||||
self._init_error = ""
|
||||
except Exception as e:
|
||||
self._init_error = str(e)
|
||||
self._manager = None
|
||||
logger.warning("Honcho background session init failed: %s", e)
|
||||
|
||||
self._init_thread = threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name="honcho-session-init",
|
||||
)
|
||||
self._init_thread.start()
|
||||
if wait_timeout > 0:
|
||||
self._init_thread.join(timeout=wait_timeout)
|
||||
|
||||
def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
|
||||
"""Shared session initialization logic for both eager and lazy paths."""
|
||||
from plugins.memory.honcho.client import get_honcho_client
|
||||
@@ -364,22 +429,15 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
)
|
||||
|
||||
# ----- B3: resolve_session_name -----
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
self._session_key = (
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
|
||||
logger.debug("Honcho session key resolved: %s", self._session_key)
|
||||
|
||||
# Create session eagerly
|
||||
# Create the remote session before running startup-only migration and
|
||||
# prewarm work. Do not mark the provider ready until this method's
|
||||
# synchronous setup has finished; background startup sets _manager before
|
||||
# get_or_create()/migration/prewarm are complete, and lifecycle hooks must
|
||||
# not treat that partially initialized state as usable.
|
||||
session = self._manager.get_or_create(self._session_key)
|
||||
self._session_initialized = True
|
||||
|
||||
# ----- B6: Memory file migration (one-time, for new sessions) -----
|
||||
# Skip under per-session strategy: every Hermes run creates a fresh
|
||||
@@ -434,12 +492,15 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
prewarm_thread = threading.Thread(
|
||||
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
prewarm_thread.start()
|
||||
self._prefetch_thread = prewarm_thread
|
||||
logger.debug("Honcho pre-warm started for session: %s", self._session_key)
|
||||
|
||||
self._session_initialized = True
|
||||
|
||||
def _ensure_session(self) -> bool:
|
||||
"""Lazily initialize the Honcho session (for tools-only mode).
|
||||
|
||||
@@ -449,7 +510,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
return True
|
||||
if self._cron_skipped:
|
||||
return False
|
||||
if not self._config or not self._lazy_init_kwargs:
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return False
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -463,9 +526,26 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._lazy_init_session_id = None
|
||||
return self._manager is not None
|
||||
except Exception as e:
|
||||
self._manager = None
|
||||
self._session_initialized = False
|
||||
logger.warning("Honcho lazy session init failed: %s", e)
|
||||
return False
|
||||
|
||||
def _session_ready(self) -> bool:
|
||||
"""Return whether a manager/session key can be used safely.
|
||||
|
||||
Background initialization sets ``_manager`` before the blocking
|
||||
get-or-create call completes, so ``_session_initialized`` guards real
|
||||
async startup. Tests and legacy direct construction may inject a ready
|
||||
manager/session key without setting that flag; allow that only when no
|
||||
init thread is currently in flight.
|
||||
"""
|
||||
if not self._manager or not self._session_key:
|
||||
return False
|
||||
if self._session_initialized:
|
||||
return True
|
||||
return not (self._init_thread and self._init_thread.is_alive())
|
||||
|
||||
def _format_first_turn_context(self, ctx: dict) -> str:
|
||||
"""Format the prefetch context dict into a readable system prompt block."""
|
||||
parts = []
|
||||
@@ -505,14 +585,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if self._cron_skipped:
|
||||
return ""
|
||||
if not self._manager or not self._session_key:
|
||||
# tools-only mode without session yet still returns a minimal block
|
||||
if self._recall_mode == "tools" and self._config:
|
||||
return (
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile, honcho_search, "
|
||||
"honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory."
|
||||
)
|
||||
return ""
|
||||
if not self._config:
|
||||
return ""
|
||||
|
||||
# ----- B1: adapt text based on recall_mode -----
|
||||
if self._recall_mode == "context":
|
||||
@@ -563,6 +637,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if self._recall_mode == "tools":
|
||||
return ""
|
||||
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return ""
|
||||
|
||||
# B5: injection_frequency — if "first-turn" and past first turn, return empty.
|
||||
# _turn_count is 1-indexed (first user message = 1), so > 1 means "past first".
|
||||
if self._injection_frequency == "first-turn" and self._turn_count > 1:
|
||||
@@ -575,18 +653,17 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
parts = []
|
||||
|
||||
# ----- Layer 1: Base context (representation + card) -----
|
||||
# On first call, fetch synchronously so turn 1 isn't empty.
|
||||
# After that, serve from cache and refresh in background on cadence.
|
||||
# First fetch is asynchronous: a slow Honcho backend must not block the
|
||||
# first response. Serve empty context now and consume the background
|
||||
# result on a later turn.
|
||||
with self._base_context_lock:
|
||||
if self._base_context_cache is None:
|
||||
# First call — synchronous fetch
|
||||
self._base_context_cache = ""
|
||||
self._last_context_turn = self._turn_count
|
||||
try:
|
||||
ctx = self._manager.get_prefetch_context(self._session_key)
|
||||
self._base_context_cache = self._format_first_turn_context(ctx) if ctx else ""
|
||||
self._last_context_turn = self._turn_count
|
||||
self._manager.prefetch_context(self._session_key, query or None)
|
||||
except Exception as e:
|
||||
logger.debug("Honcho base context fetch failed: %s", e)
|
||||
self._base_context_cache = ""
|
||||
logger.debug("Honcho base context prefetch failed: %s", e)
|
||||
base_context = self._base_context_cache
|
||||
|
||||
# Check if background context prefetch has a fresher result
|
||||
@@ -641,10 +718,11 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
first_turn_thread = threading.Thread(
|
||||
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
first_turn_thread.start()
|
||||
self._prefetch_thread = first_turn_thread
|
||||
self._prefetch_thread.join(timeout=_first_turn_timeout)
|
||||
if self._prefetch_thread.is_alive():
|
||||
logger.debug(
|
||||
@@ -709,13 +787,14 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
"""
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key or not query:
|
||||
return
|
||||
|
||||
# B1: tools-only mode — no prefetch
|
||||
if self._recall_mode == "tools":
|
||||
return
|
||||
|
||||
if not self._session_ready() or not query:
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
# Trivial prompts don't warrant either a context refresh or a dialectic call.
|
||||
if self._is_trivial_prompt(query):
|
||||
return
|
||||
@@ -769,10 +848,11 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
prefetch_thread = threading.Thread(
|
||||
target=_run, daemon=True, name="honcho-prefetch"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
prefetch_thread.start()
|
||||
self._prefetch_thread = prefetch_thread
|
||||
|
||||
# ----- Dialectic depth: multi-pass .chat() with cold/warm prompts -----
|
||||
|
||||
@@ -1126,7 +1206,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
"""
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key:
|
||||
if self._recall_mode == "tools" and not self._session_ready():
|
||||
return
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
msg_limit = self._config.message_max_chars if self._config else 25000
|
||||
@@ -1169,7 +1252,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
return
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key:
|
||||
if self._recall_mode == "tools" and not self._session_ready():
|
||||
return
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
def _write():
|
||||
@@ -1187,6 +1273,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
return
|
||||
if not self._manager:
|
||||
return
|
||||
if not self._session_initialized and self._init_thread and self._init_thread.is_alive():
|
||||
return
|
||||
# Wait for pending sync
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=10.0)
|
||||
@@ -1213,6 +1301,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
|
||||
# Port #1957: ensure session is initialized for tools-only mode
|
||||
if not self._session_initialized:
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return tool_error("Honcho session is still initializing; try again shortly.")
|
||||
if not self._ensure_session():
|
||||
return tool_error("Honcho session could not be initialized.")
|
||||
|
||||
@@ -1313,7 +1403,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
# Flush any remaining messages
|
||||
if self._manager:
|
||||
if self._manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
|
||||
try:
|
||||
self._manager.flush_all()
|
||||
except Exception:
|
||||
|
||||
@@ -56,7 +56,7 @@ gemini = GeminiProfile(
|
||||
env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
auth_type="api_key",
|
||||
default_aux_model="gemini-3-flash-preview",
|
||||
default_aux_model="gemini-3.5-flash",
|
||||
)
|
||||
|
||||
google_gemini_cli = GeminiProfile(
|
||||
|
||||
@@ -269,7 +269,13 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _health_monitor(self) -> None:
|
||||
"""Force reconnect if the WebSocket has been idle too long."""
|
||||
"""Observe WebSocket idleness without reconnecting healthy quiet links.
|
||||
|
||||
simplex-chat can legitimately stay application-silent for long periods
|
||||
when no messages arrive. The websockets client already sends protocol
|
||||
pings (see _ws_listener ping_interval/ping_timeout), so treating lack of
|
||||
chat events as a stale connection causes needless reconnect churn.
|
||||
"""
|
||||
while self._running:
|
||||
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
|
||||
if not self._running:
|
||||
@@ -277,15 +283,7 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
|
||||
elapsed = time.time() - self._last_ws_activity
|
||||
if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
|
||||
logger.warning(
|
||||
"SimpleX: WS idle for %.0fs, forcing reconnect", elapsed
|
||||
)
|
||||
self._last_ws_activity = time.time()
|
||||
if self._ws:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("SimpleX: WS application-idle for %.0fs", elapsed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inbound event handling
|
||||
@@ -293,7 +291,12 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _handle_event(self, event: dict) -> None:
|
||||
"""Dispatch a daemon event to the appropriate handler."""
|
||||
resp_type = event.get("type") or event.get("resp", {}).get("type", "")
|
||||
# simplex-chat WebSocket messages are usually shaped as:
|
||||
# {"corrId": "...", "resp": {"type": "newChatItems", ...}}
|
||||
# Older/examples may put the response fields at top-level. Normalize
|
||||
# both forms before dispatching, otherwise inbound chatItems are lost.
|
||||
resp = event.get("resp") if isinstance(event.get("resp"), dict) else event
|
||||
resp_type = event.get("type") or resp.get("type", "")
|
||||
|
||||
# Filter responses to our own commands (echoes)
|
||||
corr_id = event.get("corrId", "")
|
||||
@@ -302,10 +305,10 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
return
|
||||
|
||||
if resp_type == "newChatItem":
|
||||
await self._handle_new_chat_item(event)
|
||||
await self._handle_new_chat_item(resp)
|
||||
elif resp_type == "newChatItems":
|
||||
# Batch variant — process each item
|
||||
items = event.get("chatItems") or []
|
||||
items = resp.get("chatItems") or []
|
||||
for item_wrapper in items:
|
||||
await self._handle_new_chat_item(item_wrapper)
|
||||
# Ignore all other event types (delivery receipts, contact updates, etc.)
|
||||
@@ -347,7 +350,9 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
or contact_info.get("localDisplayName")
|
||||
or contact_id
|
||||
)
|
||||
chat_id = contact_id
|
||||
# Replies must be routed by SimpleX CLI display name, while
|
||||
# authorization should still use the stable numeric contactId.
|
||||
chat_id = contact_name or contact_id
|
||||
chat_name = contact_name
|
||||
|
||||
if not chat_id:
|
||||
@@ -364,7 +369,7 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
or sender_id
|
||||
)
|
||||
else:
|
||||
sender_id = chat_id
|
||||
sender_id = contact_id if not is_group else chat_id
|
||||
sender_name = chat_name
|
||||
|
||||
# Extract text
|
||||
@@ -508,7 +513,11 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
group_id = chat_id[6:]
|
||||
cmd_str = f"#[{group_id}] {content}"
|
||||
else:
|
||||
cmd_str = f"@[{chat_id}] {content}"
|
||||
# SimpleX CLI addresses direct contacts by display name, e.g.
|
||||
# `@Alice hello`. `@[Alice]` is interpreted literally as a contact
|
||||
# named "[Alice]" and `@[4]` as "[4]", so do not wrap direct
|
||||
# chat IDs / display names in brackets.
|
||||
cmd_str = f"@{chat_id} {content}"
|
||||
|
||||
payload = {
|
||||
"corrId": corr_id,
|
||||
@@ -643,7 +652,8 @@ async def _standalone_send(
|
||||
group_id = chat_id[6:]
|
||||
cmd_str = f"#[{group_id}] {message}"
|
||||
else:
|
||||
cmd_str = f"@[{chat_id}] {message}"
|
||||
# Direct contacts are addressed by display name without brackets.
|
||||
cmd_str = f"@{chat_id} {message}"
|
||||
|
||||
payload = {
|
||||
"corrId": f"hermes-snd-{int(time.time() * 1000)}",
|
||||
|
||||
@@ -21,9 +21,12 @@ delivers it.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
@@ -42,7 +45,9 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_MODEL = "grok-imagine-video"
|
||||
DEFAULT_TEXT_TO_VIDEO_MODEL = "grok-imagine-video"
|
||||
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5-preview"
|
||||
DEFAULT_MODEL = DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
DEFAULT_DURATION = 8
|
||||
DEFAULT_ASPECT_RATIO = "16:9"
|
||||
DEFAULT_RESOLUTION = "720p"
|
||||
@@ -58,10 +63,18 @@ _MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"grok-imagine-video": {
|
||||
"display": "Grok Imagine Video",
|
||||
"speed": "~60-240s",
|
||||
"strengths": "Text-to-video + image-to-video; up to 7 reference images for style/character.",
|
||||
"price": "see https://docs.x.ai/docs/models",
|
||||
"strengths": "Text-to-video; legacy image-to-video fallback.",
|
||||
"price": "see https://docs.x.ai/developers/models/grok-imagine-video",
|
||||
"modalities": ["text", "image"],
|
||||
},
|
||||
"grok-imagine-video-1.5-preview": {
|
||||
"display": "Grok Imagine Video 1.5 Preview",
|
||||
"speed": "~60-240s",
|
||||
"strengths": "Latest xAI image-to-video model.",
|
||||
"price": "see https://docs.x.ai/developers/models/grok-imagine-video-1.5-preview",
|
||||
"modalities": ["image"],
|
||||
"aliases": ["grok-imagine-video-1.5-2026-05-30"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -111,10 +124,31 @@ def _xai_headers(api_key: str) -> Dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _image_ref_to_xai_url(value: str) -> str:
|
||||
"""Return a URL/data URI accepted by xAI for image inputs."""
|
||||
ref = (value or "").strip()
|
||||
if not ref:
|
||||
return ""
|
||||
lower = ref.lower()
|
||||
if lower.startswith(("http://", "https://", "data:image/")):
|
||||
return ref
|
||||
|
||||
path = Path(ref).expanduser()
|
||||
if not path.is_file():
|
||||
return ref
|
||||
|
||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
if not mime.startswith("image/"):
|
||||
return ref
|
||||
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def _normalize_reference_images(reference_image_urls: Optional[List[str]]):
|
||||
refs = []
|
||||
for url in reference_image_urls or []:
|
||||
normalized = (url or "").strip()
|
||||
normalized = _image_ref_to_xai_url(url)
|
||||
if normalized:
|
||||
refs.append({"url": normalized})
|
||||
return refs or None
|
||||
@@ -131,6 +165,28 @@ def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_model_for_modality(
|
||||
model: Optional[str],
|
||||
*,
|
||||
modality: str,
|
||||
explicit_model: bool,
|
||||
) -> str:
|
||||
"""Select xAI's text/video model without treating config as a prompt override.
|
||||
|
||||
``grok-imagine-video-1.5-preview`` currently rejects text-only video
|
||||
generation, but it is the desired image-to-video backend. Explicit tool
|
||||
``model=`` still wins for users who intentionally request another model.
|
||||
"""
|
||||
requested = (model or "").strip()
|
||||
if explicit_model and requested:
|
||||
return requested
|
||||
if modality == "image":
|
||||
return DEFAULT_IMAGE_TO_VIDEO_MODEL
|
||||
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL:
|
||||
return DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
return requested or DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
|
||||
|
||||
async def _submit(
|
||||
client: httpx.AsyncClient,
|
||||
payload: Dict[str, Any],
|
||||
@@ -192,7 +248,7 @@ async def _poll(
|
||||
|
||||
|
||||
class XAIVideoGenProvider(VideoGenProvider):
|
||||
"""xAI grok-imagine-video backend (text-to-video + image-to-video)."""
|
||||
"""xAI Grok Imagine video backend (text-to-video + image-to-video)."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -222,7 +278,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
return {
|
||||
"name": "xAI Grok Imagine",
|
||||
"badge": "paid",
|
||||
"tag": "grok-imagine-video — text-to-video & image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
|
||||
"tag": "grok-imagine-video for text-to-video; grok-imagine-video-1.5-preview for image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
}
|
||||
@@ -260,6 +316,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
return loop.run_until_complete(self._generate_async(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
explicit_model=bool(kwargs.get("_model_override_explicit")),
|
||||
image_url=image_url,
|
||||
reference_image_urls=reference_image_urls,
|
||||
duration=duration,
|
||||
@@ -284,6 +341,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
*,
|
||||
prompt: str,
|
||||
model: Optional[str],
|
||||
explicit_model: bool,
|
||||
image_url: Optional[str],
|
||||
reference_image_urls: Optional[List[str]],
|
||||
duration: Optional[int],
|
||||
@@ -303,10 +361,15 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
prompt = (prompt or "").strip()
|
||||
image_url_norm = (image_url or "").strip() or None
|
||||
image_url_norm = _image_ref_to_xai_url(image_url or "") or None
|
||||
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
|
||||
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
|
||||
modality_used = "image" if image_url_norm else "text"
|
||||
resolved_model = _resolve_model_for_modality(
|
||||
model,
|
||||
modality=modality_used,
|
||||
explicit_model=explicit_model,
|
||||
)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
@@ -340,7 +403,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
normalized_resolution = DEFAULT_RESOLUTION
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model or DEFAULT_MODEL,
|
||||
"model": resolved_model,
|
||||
"prompt": prompt,
|
||||
"duration": clamped_duration,
|
||||
"aspect_ratio": normalized_aspect_ratio,
|
||||
@@ -366,7 +429,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
|
||||
error_type="api_error",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
@@ -388,7 +451,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error="xAI video generation completed without a video URL",
|
||||
error_type="empty_response",
|
||||
provider="xai",
|
||||
model=body.get("model") or model or DEFAULT_MODEL,
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
extra: Dict[str, Any] = {
|
||||
@@ -399,7 +462,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
extra["usage"] = body["usage"]
|
||||
return success_response(
|
||||
video=url,
|
||||
model=body.get("model") or model or DEFAULT_MODEL,
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
modality=modality_used,
|
||||
aspect_ratio=normalized_aspect_ratio,
|
||||
@@ -413,7 +476,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s",
|
||||
error_type="timeout",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
@@ -426,7 +489,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error=message,
|
||||
error_type=f"xai_{status}",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: xai
|
||||
version: 1.0.0
|
||||
description: "xAI Grok-Imagine video generation backend. Supports text-to-video, image-to-video, reference-image-guided generation, video edit, and video extend via the xAI async videos API."
|
||||
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, and reference-image-guided generation via the xAI async videos API."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
|
||||
+50
-77
@@ -289,79 +289,57 @@ function Install-AgentBrowser {
|
||||
# ============================================================================
|
||||
|
||||
function Install-Uv {
|
||||
Write-Info "Checking for uv package manager..."
|
||||
|
||||
# Check if uv is already available
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$version = uv --version
|
||||
$script:UvCmd = "uv"
|
||||
Write-Success "uv found ($version)"
|
||||
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
|
||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
|
||||
# place, so install.ps1 and `hermes update` stay in sync.
|
||||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||||
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
$version = & $managedUv --version
|
||||
Write-Success "Managed uv found ($version)"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Check common install locations
|
||||
$uvPaths = @(
|
||||
"$env:USERPROFILE\.local\bin\uv.exe",
|
||||
"$env:USERPROFILE\.cargo\bin\uv.exe"
|
||||
)
|
||||
foreach ($uvPath in $uvPaths) {
|
||||
if (Test-Path $uvPath) {
|
||||
$script:UvCmd = $uvPath
|
||||
$version = & $uvPath --version
|
||||
Write-Success "uv found at $uvPath ($version)"
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# Install uv
|
||||
Write-Info "Installing uv (fast Python package manager)..."
|
||||
# Capture EAP outside the try block so the catch's restore call always
|
||||
# has a meaningful value -- if the assignment lived inside try and the
|
||||
# try body threw before reaching it, the catch would see $prevEAP
|
||||
# unset and leave EAP at whatever the previous protected call set.
|
||||
|
||||
Write-Info "Installing managed uv into $HermesHome\bin ..."
|
||||
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
|
||||
|
||||
# UV_INSTALL_DIR tells the astral installer to place the binary
|
||||
# directly into $HermesHome\bin instead of ~/.local/bin.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$prevUVInstallDir = $env:UV_INSTALL_DIR
|
||||
try {
|
||||
# Relax ErrorActionPreference around the nested astral installer.
|
||||
# The astral installer (a separate `powershell -c "irm ... | iex"`)
|
||||
# writes download progress to stderr. With $ErrorActionPreference
|
||||
# = "Stop" set at the top of this script, PowerShell wraps stderr
|
||||
# lines from native commands (which `powershell -c` is, from our
|
||||
# perspective) as ErrorRecord objects when captured via 2>&1, then
|
||||
# throws a terminating exception on the first one -- even though
|
||||
# uv installs successfully and the child exits 0. Same fix
|
||||
# pattern Test-Python uses for `uv python install`; verify success
|
||||
# via Test-Path on the expected binary afterwards, which is more
|
||||
# reliable than exit-code/stderr signal anyway.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
||||
$ErrorActionPreference = $prevEAP
|
||||
|
||||
# Find the installed binary
|
||||
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
|
||||
if (-not (Test-Path $uvExe)) {
|
||||
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
|
||||
# Restore UV_INSTALL_DIR — don't leak it into subsequent stages.
|
||||
if ($null -eq $prevUVInstallDir) {
|
||||
Remove-Item Env:UV_INSTALL_DIR -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UV_INSTALL_DIR = $prevUVInstallDir
|
||||
}
|
||||
if (-not (Test-Path $uvExe)) {
|
||||
# Refresh PATH and try again
|
||||
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$uvExe = (Get-Command uv).Source
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $uvExe) {
|
||||
$script:UvCmd = $uvExe
|
||||
$version = & $uvExe --version
|
||||
Write-Success "uv installed ($version)"
|
||||
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
$version = & $managedUv --version
|
||||
Write-Success "Managed uv installed ($version)"
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Err "uv installed but not found on PATH"
|
||||
Write-Info "Try restarting your terminal and re-running"
|
||||
|
||||
Write-Err "uv installed but not found at $managedUv"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
return $false
|
||||
} catch {
|
||||
# Restore EAP in case the try block threw before the assignment
|
||||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||||
# Restore UV_INSTALL_DIR on error too.
|
||||
if ($null -eq $prevUVInstallDir) {
|
||||
Remove-Item Env:UV_INSTALL_DIR -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UV_INSTALL_DIR = $prevUVInstallDir
|
||||
}
|
||||
Write-Err "Failed to install uv: $_"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
return $false
|
||||
@@ -385,11 +363,9 @@ function Sync-EnvPath {
|
||||
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
|
||||
# prior process is not visible here. Later stages (Test-Python,
|
||||
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
|
||||
# at the top to populate $script:UvCmd from PATH or known install paths.
|
||||
# Throws if uv is not findable -- the caller's stage then surfaces a
|
||||
# clean error via the stage-driver's try/catch. Fast path is a single
|
||||
# Get-Command call when uv is on PATH (the common case after Stage-Uv
|
||||
# ran path-modifying installs in a sibling process).
|
||||
# at the top to populate $script:UvCmd from the managed location.
|
||||
# Throws if uv is not findable — the caller's stage then surfaces a
|
||||
# clean error via the stage-driver's try/catch.
|
||||
function Resolve-UvCmd {
|
||||
# Already resolved (default invocation path: Install-Uv ran earlier
|
||||
# in the same process and set $script:UvCmd).
|
||||
@@ -404,9 +380,15 @@ function Resolve-UvCmd {
|
||||
# Stale; fall through to re-discover.
|
||||
}
|
||||
|
||||
# Try PATH first (covers `winget install astral.uv`, manual installs,
|
||||
# and the post-Install-Uv state where uv.exe lives in
|
||||
# %USERPROFILE%\.local\bin which the installer added to PATH).
|
||||
# Check the managed location first — this is where Install-Uv puts it.
|
||||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
return
|
||||
}
|
||||
|
||||
# Fall back to PATH (covers edge cases where the installer ran in a
|
||||
# sibling process and HERMES_HOME wasn't propagated).
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$script:UvCmd = "uv"
|
||||
return
|
||||
@@ -420,16 +402,7 @@ function Resolve-UvCmd {
|
||||
return
|
||||
}
|
||||
|
||||
# Check the well-known install locations the astral.sh installer drops
|
||||
# uv into. Mirrors the probe order Install-Uv uses.
|
||||
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
|
||||
if (Test-Path $uvPath) {
|
||||
$script:UvCmd = $uvPath
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
|
||||
throw "uv is not installed. Run install.ps1 -Stage uv first."
|
||||
}
|
||||
|
||||
function Test-Python {
|
||||
|
||||
+17
-138
@@ -475,39 +475,22 @@ install_uv() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Checking for uv package manager..."
|
||||
# Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there —
|
||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
|
||||
# place, so install.sh and `hermes update` stay in sync.
|
||||
local _managed_uv="$HERMES_HOME/bin/uv"
|
||||
|
||||
# Check common locations for uv
|
||||
if command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
if [ -x "$_managed_uv" ]; then
|
||||
UV_CMD="$_managed_uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found ($UV_VERSION)"
|
||||
log_success "Managed uv found ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check ~/.local/bin (default uv install location) even if not on PATH yet
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found at ~/.local/bin ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
log_info "Installing managed uv into $HERMES_HOME/bin ..."
|
||||
mkdir -p "$HERMES_HOME/bin"
|
||||
|
||||
# Check ~/.cargo/bin (alternative uv install location)
|
||||
if [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found at ~/.cargo/bin ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Install uv
|
||||
log_info "Installing uv (fast Python package manager)..."
|
||||
# Capture installer output so a failure shows the user WHY (network,
|
||||
# glibc mismatch on old distros, missing curl, ~/.local/bin not
|
||||
# writable, disk full, corp proxy / TLS interception, etc.) instead
|
||||
# of the previous "✗ Failed to install uv" with zero diagnostic.
|
||||
#
|
||||
# Two-stage: download the installer, then run it. Piping
|
||||
# `curl | sh` masks curl failures (sh exits 0 on empty stdin)
|
||||
# and conflates network errors with installer errors.
|
||||
@@ -522,26 +505,22 @@ install_uv() {
|
||||
rm -f "$_uv_install_log" "$_uv_installer"
|
||||
exit 1
|
||||
fi
|
||||
if sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
|
||||
# UV_UNMANAGED_INSTALL tells the astral installer to place the binary
|
||||
# directly into $HERMES_HOME/bin instead of ~/.local/bin.
|
||||
if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
|
||||
rm -f "$_uv_installer"
|
||||
# uv installs to ~/.local/bin by default
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
elif command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
if [ -x "$_managed_uv" ]; then
|
||||
UV_CMD="$_managed_uv"
|
||||
else
|
||||
log_error "uv installer reported success but binary not found on PATH"
|
||||
log_error "uv installer reported success but binary not found at $_managed_uv"
|
||||
log_info "Installer output:"
|
||||
sed 's/^/ /' "$_uv_install_log" >&2
|
||||
log_info "Try adding ~/.local/bin to your PATH and re-running"
|
||||
rm -f "$_uv_install_log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$_uv_install_log"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv installed ($UV_VERSION)"
|
||||
log_success "Managed uv installed ($UV_VERSION)"
|
||||
else
|
||||
log_error "Failed to install uv"
|
||||
log_info "Installer output:"
|
||||
@@ -579,7 +558,6 @@ check_python() {
|
||||
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python found: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -589,7 +567,6 @@ check_python() {
|
||||
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python installed: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
else
|
||||
log_error "Failed to install Python $PYTHON_VERSION"
|
||||
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
|
||||
@@ -597,104 +574,6 @@ check_python() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Probe whether $1 (a python executable) links a SQLite with the FTS5
|
||||
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
|
||||
# virtual tables for full-text session search; a SQLite without FTS5 makes
|
||||
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
|
||||
_python_has_fts5() {
|
||||
"$1" - <<'PY' 2>/dev/null
|
||||
import sqlite3, sys
|
||||
try:
|
||||
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
|
||||
# Returns 0 if the resulting interpreter ships FTS5.
|
||||
_reinstall_python_with_fts5() {
|
||||
local uv_bin="$1"
|
||||
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
|
||||
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
|
||||
}
|
||||
|
||||
_warn_no_fts5() {
|
||||
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
|
||||
# Install proceeds — Hermes degrades gracefully and disables only full-text
|
||||
# session search — but warn so it isn't a silent gap.
|
||||
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
|
||||
log_warn "full-text session search will be disabled until FTS5 is present."
|
||||
}
|
||||
|
||||
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
|
||||
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
|
||||
# but WHICH builds a given uv can install is baked into the uv binary's
|
||||
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
|
||||
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
|
||||
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
|
||||
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
|
||||
#
|
||||
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
|
||||
# an already-current uv)
|
||||
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
|
||||
# this is what fixes a stale standalone uv
|
||||
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
|
||||
# fresh standalone uv via the official installer into a temp dir and use
|
||||
# THAT to reinstall — this fixes package-manager-managed stale uv
|
||||
#
|
||||
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
|
||||
# stale interpreter in place and the installer's later `uv python find` resolves
|
||||
# to it. Keeps session search working without bundling a second SQLite or asking
|
||||
# the user to do anything.
|
||||
ensure_fts5() {
|
||||
[ -n "${PYTHON_PATH:-}" ] || return 0
|
||||
if _python_has_fts5 "$PYTHON_PATH"; then
|
||||
return 0
|
||||
fi
|
||||
# Termux / non-uv installs have nothing to escalate.
|
||||
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
|
||||
|
||||
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
|
||||
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
|
||||
# Python builds. Try to update uv in place.
|
||||
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
|
||||
if "$UV_CMD" self update >/dev/null 2>&1; then
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
|
||||
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
|
||||
# a fresh standalone uv into a temp dir and use it just for the reinstall.
|
||||
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
|
||||
local _tmp_uv_dir _fresh_uv
|
||||
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
|
||||
mkdir -p "$_tmp_uv_dir"
|
||||
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
|
||||
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
|
||||
_fresh_uv="$_tmp_uv_dir/uv"
|
||||
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
|
||||
_warn_no_fts5
|
||||
}
|
||||
|
||||
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
|
||||
# (which downloads PortableGit on Windows). git is required to clone the repo,
|
||||
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
|
||||
|
||||
@@ -45,9 +45,13 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"ben.bartholomew@vectorize.io": "benfrank241",
|
||||
"74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev",
|
||||
"subw3@mail2.sysu.edu.cn": "Subway2023",
|
||||
"trevin@trevinchow.com": "tmchow",
|
||||
"zhipengli@thebrainly.ai": "a1245582339",
|
||||
"mathijs.vd.hurk@gmail.com": "mathijsvandenhurk",
|
||||
"david.gutowsky@gmail.com": "davidgut1982",
|
||||
"drpelagik@gmail.com": "SeaXen",
|
||||
"lengr@users.noreply.github.com": "LengR",
|
||||
"17255546+CharZhou@users.noreply.github.com": "CharZhou",
|
||||
@@ -61,6 +65,7 @@ AUTHOR_MAP = {
|
||||
"524706+Twanislas@users.noreply.github.com": "Twanislas",
|
||||
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
|
||||
"kchuang1015@users.noreply.github.com": "kchuang1015",
|
||||
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
|
||||
"45688690+fujinice@users.noreply.github.com": "fujinice",
|
||||
"276689385+carltonawong@users.noreply.github.com": "carltonawong",
|
||||
"195255660+EvilHumphrey@users.noreply.github.com": "EvilHumphrey",
|
||||
@@ -83,6 +88,7 @@ AUTHOR_MAP = {
|
||||
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
|
||||
"tillfalko@gmail.com": "tillfalko",
|
||||
"hi@fesalfayed.com": "fesalfayed",
|
||||
"marek.les@seznam.cz": "maxcz79",
|
||||
# teknium (multiple emails)
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"kenyon1977@gmail.com": "kenyonxu",
|
||||
@@ -1198,6 +1204,7 @@ AUTHOR_MAP = {
|
||||
"zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events)
|
||||
"agentsmithlaor@gmail.com": "oferlaor", # PR #22356 salvage (cron origin sender identity)
|
||||
"jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback)
|
||||
"caojiguang@gmail.com": "caojiguang", # PR #35117 carries #31853 (weixin _api_post/_api_get wait_for)
|
||||
# pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan
|
||||
"ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix)
|
||||
# Kanban bug-fix batch salvage (May 2026)
|
||||
@@ -1416,6 +1423,7 @@ AUTHOR_MAP = {
|
||||
"me@simontaggart.com": "SiTaggart", # PR #35583 (docker_forward_env empty-secret .env fallback)
|
||||
"2663402852@qq.com": "x1am1", # PR #35098 (chown root-owned top-level HERMES_HOME state files)
|
||||
"nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser)
|
||||
"wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for the container-context sandbox-mirror guard (#32049 follow-up).
|
||||
|
||||
Brian's shape-based guard (#32213) catches paths that carry the full
|
||||
``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix. This covers the
|
||||
complementary inner-container case: when file tools execute inside Docker,
|
||||
the bind-mount strips that prefix and the guard sees plain ``/root/.hermes/…``.
|
||||
The root:root ownership on the divergent SOUL.md in #32049 confirms this
|
||||
is the primary failure mode.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestClassifyContainerMirrorTarget:
|
||||
def test_returns_none_without_context(self):
|
||||
"""No Docker context — /root/.hermes/… must not be flagged."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
assert classify_container_mirror_target("/root/.hermes/profiles/group1/SOUL.md") is None
|
||||
|
||||
def test_catches_soul_md_with_context(self):
|
||||
"""Primary failure mode from #32049: agent writes SOUL.md via container path."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
result = classify_container_mirror_target(
|
||||
"/root/.hermes/profiles/group1/SOUL.md",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
assert result is not None
|
||||
assert result["mirror_root"].replace("\\", "/").endswith("root/.hermes")
|
||||
assert result["inner_path"] == "profiles/group1/SOUL.md"
|
||||
|
||||
@pytest.mark.parametrize("inner", [
|
||||
"SOUL.md",
|
||||
"memories/MEMORY.md",
|
||||
])
|
||||
def test_catches_authoritative_profile_files(self, inner):
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
result = classify_container_mirror_target(
|
||||
f"/root/.hermes/{inner}",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
assert result is not None
|
||||
assert result["inner_path"] == inner
|
||||
|
||||
def test_non_hermes_path_not_flagged(self):
|
||||
"""/root/workspace/… is not .hermes state and must not be blocked."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
assert (
|
||||
classify_container_mirror_target(
|
||||
"/root/workspace/main.py",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestGetContainerMirrorWarning:
|
||||
def test_warning_names_inner_path_and_bypass(self):
|
||||
from agent.file_safety import get_container_mirror_warning
|
||||
|
||||
warn = get_container_mirror_warning(
|
||||
"/root/.hermes/profiles/group1/SOUL.md",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
assert warn is not None
|
||||
assert "profiles/group1/SOUL.md" in warn
|
||||
assert "cross_profile=True" in warn
|
||||
|
||||
|
||||
class TestOrthogonality:
|
||||
"""Container-context guard catches what the shape-based guard (#32213) misses."""
|
||||
|
||||
def test_inner_container_path_caught_by_context_guard(self):
|
||||
"""No sandboxes/ segment — shape guard passes, context guard blocks."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
path = "/root/.hermes/profiles/group1/SOUL.md"
|
||||
|
||||
assert classify_container_mirror_target(path) is None # no context
|
||||
assert classify_container_mirror_target(path, mirror_prefix="/root/.hermes") is not None
|
||||
|
||||
|
||||
class TestFileToolIntegration:
|
||||
"""file_tools must catch the mirror path before creating DockerEnvironment."""
|
||||
|
||||
def test_guard_uses_current_docker_config_before_env_exists(self, monkeypatch):
|
||||
import tools.file_tools as file_tools
|
||||
|
||||
monkeypatch.setattr(
|
||||
file_tools,
|
||||
"_get_container_mirror_prefix_for_task",
|
||||
lambda task_id: "/root/.hermes",
|
||||
)
|
||||
|
||||
warning = file_tools._check_cross_profile_path(
|
||||
"/root/.hermes/profiles/group1/SOUL.md",
|
||||
task_id="new-task",
|
||||
)
|
||||
|
||||
assert warning is not None
|
||||
assert "Sandbox-mirror write blocked" in warning
|
||||
assert "profiles/group1/SOUL.md" in warning
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests for the sandbox-mirror write guard in agent/file_safety.
|
||||
|
||||
The guard fires when a tool tries to write into the per-task mirror
|
||||
directory created by a non-local terminal backend (Docker, Daytona, etc.).
|
||||
Those paths look like ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and
|
||||
they accumulate divergent copies of authoritative profile state (SOUL.md,
|
||||
config.yaml, memories/*.md) because the host Hermes process never reads
|
||||
them. Soft guard — defense in depth, NOT a security boundary.
|
||||
|
||||
Reference: #32049 — under ``terminal.backend: docker``, the agent's
|
||||
``write_file`` / ``patch`` calls landed on the sandbox mirror of SOUL.md
|
||||
while the host process kept loading the untouched authoritative file.
|
||||
The agent reported success; the rule never took effect.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classify_sandbox_mirror_target — pure path-shape detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClassifySandboxMirrorTarget:
|
||||
def test_docker_mirror_soul_md_classified(self, tmp_path):
|
||||
"""The exact path shape reported in #32049."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# mirror copy\n")
|
||||
|
||||
result = classify_sandbox_mirror_target(str(target))
|
||||
assert result is not None
|
||||
assert result["target_path"] == str(target.resolve())
|
||||
assert result["mirror_root"].endswith(
|
||||
"sandboxes/docker/default/home/.hermes"
|
||||
)
|
||||
assert result["inner_path"] == "profiles/group1/SOUL.md"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backend,inner",
|
||||
[
|
||||
("docker", "profiles/coder/memories/MEMORY.md"),
|
||||
("daytona", "profiles/default/cron/jobs.json"),
|
||||
("podman", ".env"),
|
||||
],
|
||||
)
|
||||
def test_other_backends_and_inner_files_match(self, tmp_path, backend, inner):
|
||||
"""The detector is backend-agnostic — sandbox-mirror shape is what matters."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / backend / "task-42" / "home" / ".hermes"
|
||||
/ Path(inner)
|
||||
)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("x")
|
||||
|
||||
result = classify_sandbox_mirror_target(str(target))
|
||||
assert result is not None
|
||||
assert result["inner_path"] == inner
|
||||
assert backend in result["mirror_root"]
|
||||
|
||||
def test_path_outside_sandbox_returns_none(self, tmp_path):
|
||||
"""A plain Hermes path is not a mirror."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# real SOUL\n")
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_sandboxes_segment_without_home_hermes_returns_none(self, tmp_path):
|
||||
"""A ``sandboxes/`` directory unrelated to Hermes-state mirroring (e.g.
|
||||
the sandbox workspace itself) is not flagged."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / "docker" / "task-42" / "workspace" / "main.py"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("print('hi')\n")
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_sandboxes_segment_with_home_but_no_hermes_returns_none(self, tmp_path):
|
||||
"""``sandboxes/<backend>/<task>/home/anything-not-hermes`` is not a mirror."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / "docker" / "task-42" / "home" / ".bashrc"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("alias ll='ls -la'\n")
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_truncated_sandbox_path_returns_none(self, tmp_path):
|
||||
"""``…/sandboxes/<backend>/<task>`` without ``home/.hermes/<thing>`` is not a mirror."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = tmp_path / "sandboxes" / "docker" / "task-42"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_non_existent_path_still_classifies_by_shape(self, tmp_path):
|
||||
"""Detection is path-shape only — it must not require the file to exist
|
||||
(the agent is about to CREATE the mirror file, that's the bug)."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
# Parent directory exists so .resolve() doesn't strip the tail
|
||||
# under strict mode, but the file itself does NOT exist.
|
||||
target.parent.mkdir(parents=True)
|
||||
assert not target.exists()
|
||||
|
||||
result = classify_sandbox_mirror_target(str(target))
|
||||
assert result is not None
|
||||
assert result["inner_path"] == "profiles/group1/SOUL.md"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_sandbox_mirror_warning — the model-facing string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSandboxMirrorWarning:
|
||||
def test_non_mirror_returns_none(self, tmp_path):
|
||||
from agent.file_safety import get_sandbox_mirror_warning
|
||||
|
||||
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# real SOUL\n")
|
||||
|
||||
assert get_sandbox_mirror_warning(str(target)) is None
|
||||
|
||||
def test_mirror_warning_names_mirror_root_and_inner_path(self, tmp_path):
|
||||
from agent.file_safety import get_sandbox_mirror_warning
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# mirror copy\n")
|
||||
|
||||
warn = get_sandbox_mirror_warning(str(target))
|
||||
assert warn is not None
|
||||
# Must name the mirror root so the user can locate the sandbox.
|
||||
assert "sandboxes/docker/default/home/.hermes" in warn
|
||||
# Must hint at what the agent likely meant.
|
||||
assert "profiles/group1/SOUL.md" in warn
|
||||
# Must name the bypass kwarg shared with the cross-profile guard.
|
||||
assert "cross_profile=True" in warn
|
||||
|
||||
def test_warning_is_defense_in_depth_not_boundary(self, tmp_path):
|
||||
from agent.file_safety import get_sandbox_mirror_warning
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / "docker" / "t" / "home" / ".hermes"
|
||||
/ "profiles" / "g" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("x")
|
||||
|
||||
warn = get_sandbox_mirror_warning(str(target))
|
||||
# Must self-document as defense-in-depth so future reviewers
|
||||
# don't promote it to a hard block (matches the existing
|
||||
# cross-profile guard's contract).
|
||||
assert "not a security boundary" in warn.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Independence from cross-profile classifier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSandboxMirrorIsOrthogonalToCrossProfile:
|
||||
"""The sandbox-mirror guard must fire even when the inner path is
|
||||
in-profile from the host's view — the bug is the mirror, not the
|
||||
profile mismatch."""
|
||||
|
||||
def test_same_profile_mirror_still_flagged(self, tmp_path, monkeypatch):
|
||||
import agent.file_safety as fs
|
||||
monkeypatch.setattr(fs, "_hermes_root_path", lambda: tmp_path)
|
||||
monkeypatch.setattr(fs, "_hermes_home_path", lambda: tmp_path / "profiles" / "group1")
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("x")
|
||||
|
||||
# cross-profile classifier: active profile == target's inner-mirror
|
||||
# profile name; on the existing detector the path's parts[2] is
|
||||
# ``sandboxes``, not a scoped area, so it returns None.
|
||||
assert fs.classify_cross_profile_target(str(target)) is None
|
||||
# sandbox-mirror classifier: fires unconditionally on the shape.
|
||||
assert fs.classify_sandbox_mirror_target(str(target)) is not None
|
||||
@@ -4,8 +4,9 @@ from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMinimaxContextLengths:
|
||||
"""Verify context length entries match official docs (204,800 for all models).
|
||||
"""Verify context length entries match official docs.
|
||||
|
||||
M2.x series is 204,800; M3 is 1M (max output 512K).
|
||||
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
||||
"""
|
||||
|
||||
@@ -15,11 +16,80 @@ class TestMinimaxContextLengths:
|
||||
|
||||
def test_minimax_models_resolve_via_prefix(self):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
# All MiniMax models should resolve to 204,800 via the "minimax" prefix
|
||||
# M2.x models resolve to 204,800 via the "minimax" catch-all
|
||||
for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
|
||||
ctx = get_model_context_length(model, "")
|
||||
assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
|
||||
|
||||
def test_minimax_m3_resolves_to_1m(self):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
# M3 must beat the generic "minimax" catch-all (204,800) and resolve to
|
||||
# a 1M-class context. The exact value depends on the source: our
|
||||
# hardcoded catalog says 1,000,000; the OpenRouter catalog reports
|
||||
# 1,048,576 (1024²). Either is correct — assert "≥ 1M, not 204,800".
|
||||
for model in ("MiniMax-M3", "minimax/minimax-m3", "minimax-m3"):
|
||||
ctx = get_model_context_length(model, "")
|
||||
assert ctx >= 1_000_000, f"{model} expected 1M-class, got {ctx}"
|
||||
|
||||
|
||||
class TestMinimaxM3StaleCacheGuard:
|
||||
"""Pre-catalog builds resolved M3 via the generic 'minimax' catch-all
|
||||
(204,800) and persisted it before the 'minimax-m3' (1M) catalog entry
|
||||
existed. The step-1 cache guard must drop that stale value and re-resolve
|
||||
to 1M, while leaving correct M2.x entries (204,800) untouched.
|
||||
"""
|
||||
|
||||
def test_suggests_minimax_m3(self):
|
||||
from agent.model_metadata import _model_name_suggests_minimax_m3
|
||||
assert _model_name_suggests_minimax_m3("MiniMax-M3")
|
||||
assert _model_name_suggests_minimax_m3("minimax/minimax-m3")
|
||||
assert not _model_name_suggests_minimax_m3("MiniMax-M2.7")
|
||||
assert not _model_name_suggests_minimax_m3("MiniMax-M2.5")
|
||||
|
||||
def test_stale_m3_cache_dropped_and_reresolves(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
import agent.model_metadata as mm
|
||||
importlib.reload(mm)
|
||||
base = "https://api.minimaxi.com/anthropic"
|
||||
mm.save_context_length("MiniMax-M3", base, 204_800)
|
||||
ctx = mm.get_model_context_length(
|
||||
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
|
||||
)
|
||||
# Invariant: the stale 204,800 catch-all value must be DROPPED and
|
||||
# re-resolved to M3's real, larger context. The exact value depends on
|
||||
# the resolution source (hardcoded catalog = 1,000,000; the models.dev
|
||||
# registry currently reports 512,000) — both are large-context values
|
||||
# well above the generic "minimax" catch-all. Assert the contract
|
||||
# ("> 204,800, stale value gone"), not a brittle literal.
|
||||
assert ctx > 204_800, f"stale M3 cache not dropped/re-resolved, got {ctx}"
|
||||
|
||||
def test_correct_m3_cache_preserved(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
import agent.model_metadata as mm
|
||||
importlib.reload(mm)
|
||||
base = "https://api.minimaxi.com/anthropic"
|
||||
mm.save_context_length("MiniMax-M3", base, 1_000_000)
|
||||
ctx = mm.get_model_context_length(
|
||||
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
|
||||
)
|
||||
assert ctx == 1_000_000
|
||||
|
||||
def test_m2_cache_not_clobbered(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
import agent.model_metadata as mm
|
||||
importlib.reload(mm)
|
||||
base = "https://api.minimaxi.com/anthropic"
|
||||
# 204,800 is the CORRECT value for M2.x — guard must not touch it.
|
||||
for slug in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1"):
|
||||
mm.save_context_length(slug, base, 204_800)
|
||||
ctx = mm.get_model_context_length(
|
||||
slug, base_url=base, api_key="", provider="minimax-cn"
|
||||
)
|
||||
assert ctx == 204_800, f"{slug} should stay 204800, got {ctx}"
|
||||
|
||||
|
||||
|
||||
class TestMinimaxThinkingSupport:
|
||||
|
||||
@@ -927,6 +927,29 @@ class TestEnvironmentHints:
|
||||
assert "Terminal backend: docker" in result
|
||||
assert "inside" in result.lower()
|
||||
|
||||
def test_build_environment_hints_uses_terminal_cwd_over_launch_dir(self, monkeypatch, tmp_path):
|
||||
"""THE BUG: gateway/cron set TERMINAL_CWD but the prompt emitted os.getcwd()
|
||||
(the daemon launch dir). Regression for #24882/#24969/#27383/#29265."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
configured = tmp_path / "workspace"
|
||||
configured.mkdir()
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(configured))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_pb._clear_backend_probe_cache()
|
||||
assert f"Current working directory: {configured}" in _pb.build_environment_hints()
|
||||
|
||||
def test_build_environment_hints_falls_back_to_launch_dir(self, monkeypatch, tmp_path):
|
||||
"""The #19242 local-CLI contract: no TERMINAL_CWD → the launch dir."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_pb._clear_backend_probe_cache()
|
||||
assert f"Current working directory: {tmp_path}" in _pb.build_environment_hints()
|
||||
|
||||
def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch):
|
||||
"""When the probe succeeds, its output must appear in the hint block."""
|
||||
import agent.prompt_builder as _pb
|
||||
@@ -1247,4 +1270,3 @@ class TestOpenAIModelExecutionGuidance:
|
||||
# =========================================================================
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.runtime_cwd as rt
|
||||
from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
|
||||
|
||||
|
||||
def _raise_oserror(*args, **kwargs):
|
||||
raise OSError("cwd gone")
|
||||
|
||||
|
||||
class TestResolveAgentCwd:
|
||||
def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
monkeypatch.chdir(os.path.expanduser("~"))
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path):
|
||||
# The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins.
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone"))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_expands_leading_tilde(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "~")
|
||||
assert resolve_agent_cwd() == Path(os.path.expanduser("~"))
|
||||
|
||||
def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path):
|
||||
# " ".strip() → "" → falsy, so the launch dir wins (not a " " path).
|
||||
monkeypatch.setenv("TERMINAL_CWD", " ")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_propagates_oserror_from_getcwd(self, monkeypatch):
|
||||
# The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd).
|
||||
# The resolver must NOT swallow it — build_environment_hints owns the
|
||||
# try/except OSError guard at the call site (prompt_builder.py:805).
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.setattr(rt.os, "getcwd", _raise_oserror)
|
||||
with pytest.raises(OSError):
|
||||
resolve_agent_cwd()
|
||||
|
||||
|
||||
class TestResolveContextCwd:
|
||||
def test_returns_dir_when_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
assert resolve_context_cwd() == tmp_path
|
||||
|
||||
def test_returns_none_when_unset(self, monkeypatch):
|
||||
# Unset → None; the caller (build_context_files_prompt) then getcwds —
|
||||
# the local-CLI #19242 contract. Discovery still runs; it is NOT skipped.
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
assert resolve_context_cwd() is None
|
||||
|
||||
def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path):
|
||||
# Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir
|
||||
# guard, so a missing dir is returned (not None) — discovery just finds nothing.
|
||||
missing = tmp_path / "gone"
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(missing))
|
||||
assert resolve_context_cwd() == missing
|
||||
|
||||
def test_expands_leading_tilde(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "~")
|
||||
assert resolve_context_cwd() == Path(os.path.expanduser("~"))
|
||||
|
||||
def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch):
|
||||
# " ".strip() → "" → None, so the caller getcwds for discovery rather
|
||||
# than building Path(" ") and resolving garbage under the launch dir.
|
||||
monkeypatch.setenv("TERMINAL_CWD", " ")
|
||||
assert resolve_context_cwd() is None
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for agent/system_prompt.py — context-file cwd wiring."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.system_prompt import build_system_prompt_parts
|
||||
|
||||
|
||||
def _make_agent(**overrides):
|
||||
base = dict(
|
||||
load_soul_identity=False,
|
||||
skip_context_files=False,
|
||||
valid_tool_names=[],
|
||||
_task_completion_guidance=False,
|
||||
_tool_use_enforcement=False,
|
||||
_environment_probe=False,
|
||||
_kanban_worker_guidance="",
|
||||
_memory_store=None,
|
||||
_memory_manager=None,
|
||||
model="",
|
||||
provider="",
|
||||
platform="",
|
||||
pass_session_id=False,
|
||||
session_id="",
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _captured_context_cwd(agent):
|
||||
"""The cwd build_system_prompt_parts hands to build_context_files_prompt."""
|
||||
captured = {}
|
||||
|
||||
def fake_context_files(cwd=None, skip_soul=False):
|
||||
captured["cwd"] = cwd
|
||||
return ""
|
||||
|
||||
with (
|
||||
patch("run_agent.load_soul_md", return_value=""),
|
||||
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
||||
patch("run_agent.build_environment_hints", return_value=""),
|
||||
patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
|
||||
):
|
||||
build_system_prompt_parts(agent)
|
||||
return captured["cwd"]
|
||||
|
||||
|
||||
class TestContextFileCwd:
|
||||
def test_none_when_terminal_cwd_unset(self, monkeypatch):
|
||||
# Unset → None, so discovery falls back to the launch dir inside
|
||||
# build_context_files_prompt (the local-CLI #19242 contract).
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
assert _captured_context_cwd(_make_agent()) is None
|
||||
|
||||
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
assert _captured_context_cwd(_make_agent()) == tmp_path
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user