Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52db880e65 | ||
|
|
3379028d36 | ||
|
|
40e5198b13 | ||
|
|
3ce5fb1d91 | ||
|
|
23d15dcbde | ||
|
|
5500dc8b3b | ||
|
|
a2536e9e11 | ||
|
|
4d3b2e5253 | ||
|
|
fd7fb36692 | ||
|
|
f3eb5beff8 | ||
|
|
5ad0c884b2 | ||
|
|
3d6deeeb5c | ||
|
|
027f313397 | ||
|
|
f51b0631bd | ||
|
|
a4e1244b7e | ||
|
|
de9d7902a6 | ||
|
|
48afd0613b | ||
|
|
faa4b8509a | ||
|
|
4ca8d99bf2 | ||
|
|
5cb5cb0d20 | ||
|
|
7bf3fdb9c1 | ||
|
|
0ca32ef9b4 | ||
|
|
1668b9dc69 | ||
|
|
94ff339dd8 | ||
|
|
7a7129b1c9 | ||
|
|
d33a5d72da | ||
|
|
68c56e6a2f | ||
|
|
55c8b52afb | ||
|
|
cfb48eaab4 | ||
|
|
87315afb19 | ||
|
|
5533340853 | ||
|
|
e4a98049f8 | ||
|
|
0b0671a3e3 | ||
|
|
2ff91f7287 | ||
|
|
ca3179c842 | ||
|
|
7dd1857a09 | ||
|
|
5712c6489f | ||
|
|
9939d6bcbd | ||
|
|
1c89f5304b | ||
|
|
273819a975 | ||
|
|
69ec8c0ca4 | ||
|
|
59a5fd36c4 | ||
|
|
d598577c7f | ||
|
|
1fc82b950f | ||
|
|
0096b01152 | ||
|
|
5afd38cb92 | ||
|
|
3138ac3e62 | ||
|
|
12a33d1058 | ||
|
|
159c0b7e57 | ||
|
|
4f63e7791f | ||
|
|
a68276fefc | ||
|
|
73449a62d3 | ||
|
|
34b06ddfba | ||
|
|
36a1fc5e8a | ||
|
|
4c27272959 | ||
|
|
7250bc2871 | ||
|
|
9b51c2560d | ||
|
|
238ff30c53 | ||
|
|
11d6904fe3 | ||
|
|
7a2968d602 | ||
|
|
737cbb2739 | ||
|
|
0205ef810a | ||
|
|
9f0dd52fd6 | ||
|
|
c2cdea1070 | ||
|
|
9ae86915d7 | ||
|
|
423bedbfc3 | ||
|
|
eabdab434e | ||
|
|
7f71d4b177 | ||
|
|
8b61356112 | ||
|
|
b6ccbe29fb |
@@ -1,8 +0,0 @@
|
||||
.git
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
tests/
|
||||
.env*
|
||||
/AGENTS.md
|
||||
@@ -1,135 +0,0 @@
|
||||
# Hermes Web UI — Docker Compose configuration template
|
||||
#
|
||||
# Copy this file to `.env` next to your docker-compose.yml.
|
||||
# All variables are optional — Docker Compose substitutes defaults if unset.
|
||||
#
|
||||
# cp .env.docker.example .env
|
||||
# # edit values you care about, then:
|
||||
# docker compose up -d
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# UID / GID — host user mapping
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Critical when bind-mounting an EXISTING host directory (e.g. ~/.hermes).
|
||||
# The container runs as UID/GID and must match your host file ownership,
|
||||
# otherwise the container can't read your config.yaml or write sessions.
|
||||
#
|
||||
# Find yours: id -u (UID) | id -g (GID)
|
||||
#
|
||||
# On macOS, UIDs start at 501 (not 1000), so you MUST set these.
|
||||
# On Linux, the default of 1000 usually matches the first interactive user.
|
||||
#
|
||||
# REPLACE THESE WITH YOUR ACTUAL VALUES (run `id -u` and `id -g`):
|
||||
UID=1000
|
||||
GID=1000
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Hermes home directory — single-container compose only
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Where on the host your config, sessions, skills, and state live.
|
||||
# Default: ~/.hermes (works for everyone with a standard install)
|
||||
# Override if your .hermes is elsewhere:
|
||||
# HERMES_HOME=/opt/hermes-data
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Workspace directory — single-container compose
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Path to your code/project directory. The WebUI's file browser shows
|
||||
# this at /workspace inside the container.
|
||||
# Default: ~/workspace
|
||||
# HERMES_WORKSPACE=/home/me/dev
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Password — protect remote access
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# REQUIRED if you expose the container on anything other than 127.0.0.1.
|
||||
# Without a password, anyone who can reach the port can run commands as
|
||||
# the agent.
|
||||
#
|
||||
# All three compose files forward this into the WebUI container, so
|
||||
# uncommenting it here is sufficient — no compose edit needed:
|
||||
# HERMES_WEBUI_PASSWORD=change-me-to-something-strong
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Additional security / networking / limit knobs — advanced
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# The WebUI process reads more HERMES_WEBUI_* variables than the compose
|
||||
# files forward by default. To use any of these in Docker, uncomment it here
|
||||
# AND add a matching passthrough line under `environment:` in your compose
|
||||
# file (e.g. `- HERMES_WEBUI_SESSION_TTL=${HERMES_WEBUI_SESSION_TTL:-}`).
|
||||
# Full descriptions live in .env.example.
|
||||
#
|
||||
# Auth cookie lifetime in seconds (default 2592000 = 30 days):
|
||||
# HERMES_WEBUI_SESSION_TTL=2592000
|
||||
#
|
||||
# Force the Secure cookie flag when serving HTTPS via a reverse proxy:
|
||||
# HERMES_WEBUI_SECURE=1
|
||||
#
|
||||
# Allowed public origins (scheme required) when behind a reverse proxy:
|
||||
# HERMES_WEBUI_ALLOWED_ORIGINS=https://myapp.example.com
|
||||
#
|
||||
# Trust reverse-proxy forwarded headers (only enable behind a proxy YOU run):
|
||||
# HERMES_WEBUI_TRUST_FORWARDED_HOST=1
|
||||
# HERMES_WEBUI_TRUST_FORWARDED_FOR=1
|
||||
# HERMES_WEBUI_TRUST_FORWARDED_PROTO=1
|
||||
#
|
||||
# Enable the passkey/WebAuthn login surface (default off):
|
||||
# HERMES_WEBUI_PASSKEY=1
|
||||
#
|
||||
# Archive extraction cap (MB) and "download folder as ZIP" caps:
|
||||
# HERMES_WEBUI_MAX_EXTRACTED_MB=2048
|
||||
# HERMES_WEBUI_FOLDER_ZIP_MAX_MB=1024
|
||||
# HERMES_WEBUI_FOLDER_ZIP_MAX_FILES=50000
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Permission handling for bind-mounted .hermes — advanced
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# By default, the WebUI's startup credential-permission fixer enforces
|
||||
# 0600 mode on .env, auth.json, and similar credential files in HERMES_HOME.
|
||||
# This is the right behavior on a clean install, but it can clash with:
|
||||
#
|
||||
# - Bind-mounting an EXISTING ~/.hermes whose .env is intentionally 0640
|
||||
# (e.g. group-readable for a Docker user group)
|
||||
# - HERMES_HOME_MODE configured at the agent level for a multi-user setup
|
||||
#
|
||||
# To bypass the WebUI's fixer entirely:
|
||||
# HERMES_SKIP_CHMOD=1
|
||||
#
|
||||
# OR to allow group bits while still stripping world-readable:
|
||||
# HERMES_HOME_MODE=0640
|
||||
#
|
||||
# ⚠️ MULTI-CONTAINER WARNING: HERMES_HOME_MODE has DIFFERENT semantics in
|
||||
# the WebUI vs. the agent image:
|
||||
# - WebUI: credential FILE mode threshold (0640 = allow group bits)
|
||||
# - Agent: HERMES_HOME *directory* mode (default 0700)
|
||||
# 0640 on a directory has NO execute bit, so the agent can't enter its own
|
||||
# home → broken. If you set HERMES_HOME_MODE for a multi-container setup,
|
||||
# use 0750 (group-traversable) or 0701 (x-only for non-owner traversal).
|
||||
# The compose files document both correctly per-service.
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Multi-container only — used by docker-compose.two-container.yml and
|
||||
# docker-compose.three-container.yml
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# These compose files use named Docker volumes by default (recommended).
|
||||
# Set the variables above to your host UID/GID — both the agent container
|
||||
# (HERMES_UID/HERMES_GID) and the webui container (WANTED_UID/WANTED_GID)
|
||||
# are derived from $UID/$GID so files written by one are readable by the
|
||||
# other.
|
||||
#
|
||||
# If you switch to bind mounts (replacing `hermes-home: {}` with a `device:`
|
||||
# bind), ALL THREE containers must mount the SAME host path and run as the
|
||||
# SAME UID/GID. Mismatched UIDs → "Permission denied" → the WebUI crashes
|
||||
# on every HTTP request because it can't read its own auth signing key.
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Gateway API server — multi-container only
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# The two/three-container compose files forward this into the hermes-agent
|
||||
# container. The agent only starts the gateway API listener (port 8642) when
|
||||
# a usable API_SERVER_KEY is present (>=16 chars) — API_SERVER_ENABLED alone
|
||||
# does nothing. Without a key, the gateway daemon still runs (messaging +
|
||||
# cron) but the WebUI's Tasks/System pill shows "Gateway endpoint not
|
||||
# reachable". Set a long random string here to enable it; the same value is
|
||||
# forwarded to the WebUI as HERMES_WEBUI_GATEWAY_API_KEY automatically:
|
||||
# API_SERVER_KEY=replace-with-a-long-random-string
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
# Hermes Web UI -- local machine config template
|
||||
# Copy this to .env and fill in your values.
|
||||
# start.sh sources .env automatically if present.
|
||||
# All values are optional -- auto-discovery will fill in anything left blank.
|
||||
|
||||
# Path to your hermes-agent checkout (the repo that contains run_agent.py)
|
||||
# HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
|
||||
|
||||
# Python executable to use (defaults to the agent venv if found)
|
||||
# HERMES_WEBUI_PYTHON=/path/to/python
|
||||
|
||||
# Bind address (default: 127.0.0.1 -- loopback only, safe default)
|
||||
# HERMES_WEBUI_HOST=127.0.0.1
|
||||
|
||||
# Port to listen on (default: 8787)
|
||||
# HERMES_WEBUI_PORT=8787
|
||||
|
||||
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)
|
||||
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui
|
||||
|
||||
# Default workspace directory shown on first launch
|
||||
# HERMES_WEBUI_DEFAULT_WORKSPACE=~/workspace
|
||||
|
||||
# Optional model override. Leave unset to use the active Hermes provider default.
|
||||
# HERMES_WEBUI_DEFAULT_MODEL=
|
||||
|
||||
# Base directory for all Hermes state (affects all paths above if set)
|
||||
# HERMES_HOME=~/.hermes
|
||||
|
||||
# Path to your Hermes config.yaml (for toolsets and model config)
|
||||
# HERMES_CONFIG_PATH=~/.hermes/config.yaml
|
||||
|
||||
# Display name for the assistant in the UI (default: Hermes)
|
||||
# HERMES_WEBUI_BOT_NAME=Hermes
|
||||
|
||||
# ── Security & remote access ─────────────────────────────────────────────────
|
||||
|
||||
# Password for the login page. REQUIRED if you bind to anything other than
|
||||
# 127.0.0.1 — without it, anyone who can reach the port can run commands.
|
||||
# HERMES_WEBUI_PASSWORD=change-me-to-something-strong
|
||||
|
||||
# Enable the passkey/WebAuthn login surface (default: off).
|
||||
# Alternative: set `webui_passkey_enabled: true` in the profile's config.yaml.
|
||||
# HERMES_WEBUI_PASSKEY=1
|
||||
|
||||
# Auth cookie lifetime in seconds (default: 2592000 = 30 days).
|
||||
# Clamped to [60, 31536000]. Also settable as session_ttl_seconds in settings.json.
|
||||
# HERMES_WEBUI_SESSION_TTL=2592000
|
||||
|
||||
# Force the Secure flag on auth cookies: 1/true or 0/false.
|
||||
# Unset = auto-detect (direct TLS socket, or a trusted X-Forwarded-Proto).
|
||||
# HERMES_WEBUI_SECURE=1
|
||||
|
||||
# Override the auth cookie name (default: derived internally).
|
||||
# HERMES_WEBUI_COOKIE_NAME=hermes_webui_session
|
||||
|
||||
# Extra allowed origins for CSRF/origin checks when serving behind a reverse
|
||||
# proxy or on a public hostname. Comma-separated; each entry MUST include the
|
||||
# scheme. Entries without a scheme are ignored with a warning.
|
||||
# HERMES_WEBUI_ALLOWED_ORIGINS=https://myapp.example.com:8000
|
||||
|
||||
# Serve HTTPS directly (both must be set; otherwise plain HTTP).
|
||||
# HERMES_WEBUI_TLS_CERT=/path/to/fullchain.pem
|
||||
# HERMES_WEBUI_TLS_KEY=/path/to/privkey.pem
|
||||
|
||||
# Extra sources appended to the Content-Security-Policy (space-separated).
|
||||
# HERMES_WEBUI_CSP_CONNECT_EXTRA=https://api.example.com wss://api.example.com
|
||||
# HERMES_WEBUI_CSP_FRAME_EXTRA=https://embed.example.com
|
||||
|
||||
# ── Reverse-proxy header trust (opt-in; only enable behind a proxy YOU run) ───
|
||||
# By default these forwarded headers are NOT trusted, because any direct client
|
||||
# could forge them. Enable (1/true/yes/on) only when a reverse proxy you control
|
||||
# sets them. TRUST_FORWARDED_HOST: honor X-Forwarded-Host/X-Real-Host in origin
|
||||
# checks. TRUST_FORWARDED_FOR: honor X-Forwarded-For for the client IP.
|
||||
# TRUST_FORWARDED_PROTO: honor X-Forwarded-Proto for HTTPS detection.
|
||||
# HERMES_WEBUI_TRUST_FORWARDED_HOST=1
|
||||
# HERMES_WEBUI_TRUST_FORWARDED_FOR=1
|
||||
# HERMES_WEBUI_TRUST_FORWARDED_PROTO=1
|
||||
|
||||
# ── Trusted-header auth / reverse-proxy SSO (opt-in; OFF by default) ──────────
|
||||
# Delegate authentication to a reverse proxy you run (Authelia, oauth2-proxy,
|
||||
# corporate SSO, …) that authenticates the user and forwards their identity in a
|
||||
# header. When HERMES_WEBUI_TRUSTED_AUTH_HEADER is set, the WebUI treats a request
|
||||
# as that identity — but ONLY when the request's un-spoofable raw socket peer is
|
||||
# an allowlisted proxy (HERMES_WEBUI_TRUSTED_PROXY_CIDRS below). With no header
|
||||
# set, this path is disabled and normal password/passkey auth applies.
|
||||
#
|
||||
# ⚠️ SECURITY: HERMES_WEBUI_TRUSTED_PROXY_CIDRS MUST list the EXACT IP(s) of your
|
||||
# proxy — e.g. 10.0.0.5/32 or 127.0.0.1/32. NEVER use 0.0.0.0/0 or a broad range:
|
||||
# any client whose peer IP falls in the range could forge the identity header and
|
||||
# log in as anyone. If the allowlist is empty/malformed, the header is ignored
|
||||
# (fails closed). Loopback is trusted by default. Set the CIDR to your proxy only.
|
||||
# HERMES_WEBUI_TRUSTED_AUTH_HEADER=Remote-User
|
||||
# HERMES_WEBUI_TRUSTED_PROXY_CIDRS=10.0.0.5/32
|
||||
# Optional: map a forwarded groups header to WebUI profiles (JSON object), and a
|
||||
# logout URL to redirect to after clearing the local session (SSO single-logout).
|
||||
# HERMES_WEBUI_TRUSTED_GROUPS_HEADER=Remote-Groups
|
||||
# HERMES_WEBUI_GROUP_PROFILE_MAP={"admins":"default","team-a":"projecta"}
|
||||
# HERMES_WEBUI_TRUSTED_AUTH_LOGOUT_URL=https://sso.example.com/logout
|
||||
|
||||
# ── Uploads & limits ─────────────────────────────────────────────────────────
|
||||
|
||||
# Where chat attachments are stored (default: <state dir>/attachments).
|
||||
# Attachments are transient agent context, kept out of the workspace by default.
|
||||
# HERMES_WEBUI_ATTACHMENT_DIR=~/.hermes/webui/attachments
|
||||
|
||||
# Cap on total bytes an uploaded archive may expand to (zip-bomb guard).
|
||||
# Default derives from the upload size limit; override in MB.
|
||||
# HERMES_WEBUI_MAX_EXTRACTED_MB=2048
|
||||
|
||||
# Caps for the "download folder as ZIP" feature in the workspace browser.
|
||||
# HERMES_WEBUI_FOLDER_ZIP_MAX_MB=1024
|
||||
# HERMES_WEBUI_FOLDER_ZIP_MAX_FILES=50000
|
||||
|
||||
# Requests slower than this many seconds get a stack-dump diagnostic in the log
|
||||
# (default: 5.0; set 0 to log every request's timing).
|
||||
# HERMES_WEBUI_SLOW_REQUEST_SECONDS=5.0
|
||||
|
||||
# ── Onboarding ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Skip the first-run onboarding wizard entirely.
|
||||
# HERMES_WEBUI_SKIP_ONBOARDING=1
|
||||
|
||||
# ── Advanced integrations ────────────────────────────────────────────────────
|
||||
|
||||
# Script whose stdout prefills new-session context (legacy generic hook; the
|
||||
# WebUI dynamic-recall hook in config.yaml takes precedence when configured).
|
||||
# HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT=/path/to/script
|
||||
# HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5
|
||||
# HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS=12000
|
||||
|
||||
# Extra sources for the notes drawer (see docs for the expected format).
|
||||
# HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=
|
||||
|
||||
# Directory scanned for WebUI plugins (default: ~/.hermes/plugins).
|
||||
# HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins
|
||||
|
||||
# API key used when polling the Hermes gateway health endpoint.
|
||||
# HERMES_WEBUI_GATEWAY_API_KEY=
|
||||
|
||||
# Override the Claude Code projects directory scanned by the CLI session
|
||||
# bridge (default: auto-discovered ~/.claude/projects).
|
||||
# HERMES_WEBUI_CLAUDE_PROJECTS_DIR=~/.claude/projects
|
||||
|
||||
# Override the Codex CLI home scanned by the CLI session bridge and model
|
||||
# catalog merge (default: ~/.codex).
|
||||
# CODEX_HOME=~/.codex
|
||||
|
||||
# Path to the LLM wiki root shown in the wiki panel (default: resolved from
|
||||
# HERMES_HOME's wiki env file).
|
||||
# WIKI_PATH=/path/to/wiki
|
||||
@@ -1,10 +0,0 @@
|
||||
# Normalize line endings to LF in the repository.
|
||||
* text=auto
|
||||
|
||||
# Shell scripts must stay LF on checkout too. A CRLF shebang (`#!/bin/bash\r`)
|
||||
# breaks the Docker image: the kernel can't find interpreter `/bin/bash\r`, so
|
||||
# local `docker compose up -d --build` on Windows machines with
|
||||
# `core.autocrlf=true` fails with `exec /hermeswebui_init.bash: no such file
|
||||
# or directory`.
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: nesquena
|
||||
@@ -1,122 +0,0 @@
|
||||
name: Browser smoke
|
||||
|
||||
# Headless page-load smoke: boots the real server.py (agent-free) and loads the
|
||||
# key pages in Chromium, failing on any console error or uncaught JS exception.
|
||||
# This catches the runtime-JS brick class (const-reassign, function/window
|
||||
# collision) that `node --check`, ESLint, and the mocked pytest suite cannot
|
||||
# see — they only manifest when a real browser executes the page.
|
||||
#
|
||||
# No secrets, no credentials: the server boots agent-free and the smoke script
|
||||
# strips every *_API_KEY from the environment before launch.
|
||||
#
|
||||
# Docs-only fast path: `browser-smoke` is a REQUIRED status check in the master
|
||||
# ruleset, and a skipped required check reports as pending forever (auto-merge
|
||||
# would hang). So on a docs/CHANGELOG/README-only change we keep the job running
|
||||
# and reporting green but short-circuit the expensive browser install + smoke.
|
||||
# Detection FAILS SAFE: any uncertainty or any non-doc path -> full smoke runs.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.detect.outputs.docs_only }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect docs-only change set
|
||||
id: detect
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# Fail-safe: ALWAYS exits 0 and ALWAYS emits docs_only (default 'false' =
|
||||
# run full smoke), so a detection hiccup can never wedge the required
|
||||
# browser-smoke context into perpetual pending.
|
||||
docs_only="false"
|
||||
emit() { echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"; }
|
||||
trap emit EXIT
|
||||
set -uo pipefail
|
||||
# STRICT ALLOWLIST — docs iff doc extension (*.md/.markdown/.rst) or an
|
||||
# extensionless doc basename. NOT docs: *.txt (requirements.txt!), anything
|
||||
# under docs/ that isn't a doc extension, and code files merely NAMED
|
||||
# README/CHANGELOG. See tests.yml for the full rationale.
|
||||
is_docs() {
|
||||
f="$1"; low="$(printf '%s' "$f" | tr '[:upper:]' '[:lower:]')"; base="${f##*/}"
|
||||
case "$low" in
|
||||
*.md|*.markdown|*.rst) return 0 ;;
|
||||
esac
|
||||
case "$base" in
|
||||
LICENSE|README|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin master || true
|
||||
RANGE="$BASE_SHA...$HEAD_SHA"
|
||||
else
|
||||
RANGE="${{ github.event.before }}...${{ github.sha }}"
|
||||
fi
|
||||
# --no-renames so a rename reveals BOTH sides (a renamed-away code file is visible).
|
||||
if ! FILES="$(git diff --name-only --no-renames "$RANGE" 2>/dev/null)"; then
|
||||
echo "Could not diff $RANGE — running full smoke (fail-safe)."; exit 0
|
||||
fi
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "Empty diff — running full smoke (fail-safe)."; exit 0
|
||||
fi
|
||||
echo "Changed files:"; echo "$FILES"
|
||||
result="true"
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
if ! is_docs "$f"; then
|
||||
echo "Non-docs path -> full smoke required: $f"
|
||||
result="false"
|
||||
break
|
||||
fi
|
||||
done <<< "$FILES"
|
||||
docs_only="$result"
|
||||
echo "docs_only=$docs_only"
|
||||
|
||||
browser-smoke:
|
||||
needs: changes
|
||||
# Belt-and-suspenders (see tests.yml): run even if `changes` errored/was skipped
|
||||
# so this required context can never wedge; step guards degrade to full smoke.
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Docs-only short-circuit
|
||||
if: needs.changes.outputs.docs_only == 'true'
|
||||
run: echo "Docs-only change set — skipping browser smoke (no page-executable change)."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
|
||||
- name: Set up Python
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: |
|
||||
**/requirements*.txt
|
||||
**/pyproject.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "pyyaml>=6.0" playwright
|
||||
# Install only the Chromium browser + its system deps.
|
||||
python -m playwright install --with-deps chromium
|
||||
|
||||
- name: Run browser smoke
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: python tests/browser_smoke.py
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Conversation lifecycle (informational)
|
||||
|
||||
# Scoped to the code this browser gate actually exercises — the chat
|
||||
# render/streaming surface — so it doesn't spin up playwright+chromium on
|
||||
# docs-only or unrelated changes. If none of these paths change, the run is
|
||||
# skipped entirely (GitHub treats a path-filtered no-match as "not triggered").
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'static/**'
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'static/**'
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
|
||||
jobs:
|
||||
live-to-final:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: normal
|
||||
script: tests/browser_conversation_lifecycle.py
|
||||
scenario: normal
|
||||
- name: terminal-error
|
||||
script: tests/browser_conversation_lifecycle.py
|
||||
scenario: terminal-error
|
||||
- name: historical-transcript-hydration
|
||||
script: tests/browser_historical_transcript_hydration.py
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: |
|
||||
**/requirements*.txt
|
||||
**/pyproject.toml
|
||||
.github/workflows/conversation-lifecycle.yml
|
||||
|
||||
- name: Install browser gate dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt playwright
|
||||
python -m playwright install --with-deps chromium
|
||||
|
||||
- name: Run conversation lifecycle gate
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
|
||||
run: python ${{ matrix.script }}
|
||||
|
||||
- name: Run settlement frame proof
|
||||
if: matrix.scenario == 'normal'
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: normal
|
||||
LIFECYCLE_TEST_BITE: settle-worklog-frame-proof
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Upload failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conversation-lifecycle-failure-${{ matrix.name }}
|
||||
path: lifecycle-artifacts
|
||||
if-no-files-found: ignore
|
||||
@@ -1,403 +0,0 @@
|
||||
name: Docker smoke
|
||||
|
||||
# Runtime smoke gate for Docker init logic.
|
||||
#
|
||||
# Background: v0.51.84 (PR #2470) shipped a startup-killing :ro mount + chown
|
||||
# interaction (EROFS under `set -e`) that 9 source-level pytest invariants +
|
||||
# 5800+ existing tests all passed. The independent reviewer caught it by eye.
|
||||
# This workflow closes that class of gap by actually `docker compose up`-ing
|
||||
# each variant against a real Docker daemon on the GHA runner.
|
||||
#
|
||||
# Scope (intentionally small for v1):
|
||||
# - 3 compose variants (single, two-container, three-container)
|
||||
# - For multi-container variants, rebuild the local Dockerfile and re-tag
|
||||
# it as ghcr.io/nesquena/hermes-webui:latest BEFORE `up` so the PR's
|
||||
# changes to docker_init.bash / Dockerfile actually execute. Without this
|
||||
# the multi-container variants would pull the previous release from GHCR
|
||||
# and silently miss every PR-level regression.
|
||||
# - Pre-flight `docker compose config` job to catch schema/interpolation drift.
|
||||
# - Reaper before each smoke run + trap on EXIT for orphan defence.
|
||||
#
|
||||
# Out of scope for v1 (per design review):
|
||||
# - HERMES_WEBUI_SMOKE_TEST env flag in docker_init.bash (production-code footgun)
|
||||
# - --user 60000:60000 (skips the chown branch we're protecting against)
|
||||
# - Hadolint / yamllint (separate lint workflow, follow-up PR)
|
||||
# - Local-runnable scripts/docker-smoke-test.sh (ship CI first, then iterate)
|
||||
# - Podman runtime smoke (defer until a podman-specific bug ships)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'docker_init.bash'
|
||||
- 'docker-compose*.yml'
|
||||
- '.dockerignore'
|
||||
- '.env.docker.example'
|
||||
- '.github/workflows/docker-smoke.yml'
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
- 'docker_init.bash'
|
||||
- 'docker-compose*.yml'
|
||||
- '.dockerignore'
|
||||
- '.env.docker.example'
|
||||
- '.github/workflows/docker-smoke.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Fork PRs run with no secrets — that's the right model. Pin to least privilege.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
compose-config:
|
||||
name: Compose config validation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate every compose file parses
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for f in docker-compose.yml docker-compose.two-container.yml docker-compose.three-container.yml; do
|
||||
echo "::group::compose config: $f"
|
||||
docker compose -f "$f" config > /dev/null
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
# Build the Docker image once and cache layers via GHA cache.
|
||||
# The smoke matrix jobs then pull from this cache instead of rebuilding
|
||||
# from scratch, saving ~1-3 minutes per variant.
|
||||
build-image:
|
||||
name: Build Docker image (cache layers)
|
||||
runs-on: ubuntu-latest
|
||||
needs: compose-config
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and cache Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: ghcr.io/nesquena/hermes-webui:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Startup-health gate for UID/GID auto-detection (#7027).
|
||||
#
|
||||
# The compose variants above all mount a hermes-home volume, so they never
|
||||
# exercise the plain single-container shape from the issue: a state directory
|
||||
# bind-mounted from a host directory owned by a non-1024 user, /workspace left
|
||||
# as the image's own build-time 1024, and no explicit WANTED_UID/WANTED_GID.
|
||||
# Before the fix the auto-detector read /workspace, remapped to 1024, failed
|
||||
# its own state-dir writability check and restart-looped. Source-level
|
||||
# invariants can't catch that — only actually booting the container can.
|
||||
state-dir-uid:
|
||||
name: State-dir UID detection (#7027)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compose-config, build-image]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Restore Docker image from cache
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: ghcr.io/nesquena/hermes-webui:latest
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Boot on a host-owned state mount with no explicit IDs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="hermes-smoke-uid-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
HOST_UID=1001
|
||||
STATE_DIR="$(mktemp -d -t hermes-smoke-uidstate-XXXXXX)"
|
||||
sudo chown -R "$HOST_UID:$HOST_UID" "$STATE_DIR"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
echo "::group::Cleanup (rc=$rc)"
|
||||
docker logs "$CONTAINER" 2>&1 | tail -200 || true
|
||||
docker rm -f "$CONTAINER" || true
|
||||
sudo rm -rf "$STATE_DIR" || true
|
||||
echo "::endgroup::"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Deliberately NOT mounting /workspace: the point of this gate is that
|
||||
# the stock image's own /workspace (owned 1024:1024) must not win over
|
||||
# the host-owned state mount.
|
||||
echo "::group::docker run"
|
||||
docker run -d --name "$CONTAINER" \
|
||||
-p 127.0.0.1:8788:8787 \
|
||||
-e HERMES_WEBUI_STATE_DIR=/app/data \
|
||||
-v "$STATE_DIR":/app/data \
|
||||
ghcr.io/nesquena/hermes-webui:latest
|
||||
echo "::endgroup::"
|
||||
|
||||
# ----- The product gate: the container must reach /health -----
|
||||
echo "::group::Probe /health"
|
||||
attempts=0
|
||||
max_attempts=60
|
||||
until curl --fail --silent --max-time 5 http://127.0.0.1:8788/health > /dev/null; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge "$max_attempts" ]; then
|
||||
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~5m)"
|
||||
echo " Startup logs follow in the cleanup group below."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
|
||||
echo "❌ Container exited before /health came up — this is the #7027 restart loop"
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "✅ /health = 200 after $attempts attempts"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Verify the detected identity"
|
||||
LOGS="$(docker logs "$CONTAINER" 2>&1)"
|
||||
|
||||
# The identity must have been detected from the state dir, not /workspace.
|
||||
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected UID: ${HOST_UID} \(from /app/data\)"; then
|
||||
echo "❌ UID was not auto-detected from the state directory (#7027)"
|
||||
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected GID: ${HOST_UID} \(from /app/data\)"; then
|
||||
echo "❌ GID was not auto-detected from the state directory (#7027)"
|
||||
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# And the runtime user must really carry it.
|
||||
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
|
||||
ACTUAL_GID="$(docker exec "$CONTAINER" id -g hermeswebui)"
|
||||
if [ "$ACTUAL_UID" != "$HOST_UID" ] || [ "$ACTUAL_GID" != "$HOST_UID" ]; then
|
||||
echo "❌ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, expected ${HOST_UID}:${HOST_UID}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, detected from the state mount"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Startup log scan"
|
||||
BAD_PATTERNS='!! ERROR|!! Exiting script|Failed to verify state directory|Permission denied'
|
||||
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
|
||||
echo "❌ Startup logs contain known-bad pattern (see above)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No known-bad patterns in startup logs"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Explicit WANTED_UID=1024 must survive auto-detection
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="hermes-smoke-uid1024-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
STATE_DIR="$(mktemp -d -t hermes-smoke-uid1024-XXXXXX)"
|
||||
HERMES_DIR="$(mktemp -d -t hermes-smoke-uid1024home-XXXXXX)"
|
||||
# Both mounts are owned by a *different* UID than the one requested.
|
||||
# The hermes-home mount is what makes this discriminating: it is a
|
||||
# probe candidate the pre-fix script already read, so if the explicit
|
||||
# 1024 is still treated as "unset" the container remaps to 1001 and
|
||||
# the assertion below fails. World-writable state dir so the only
|
||||
# thing under test is which identity wins, not mount permissions.
|
||||
sudo chown -R 1001:1001 "$STATE_DIR" "$HERMES_DIR"
|
||||
sudo chmod 0777 "$STATE_DIR"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
echo "::group::Cleanup (rc=$rc)"
|
||||
docker logs "$CONTAINER" 2>&1 | tail -100 || true
|
||||
docker rm -f "$CONTAINER" || true
|
||||
sudo rm -rf "$STATE_DIR" "$HERMES_DIR" || true
|
||||
echo "::endgroup::"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
docker run -d --name "$CONTAINER" \
|
||||
-p 127.0.0.1:8789:8787 \
|
||||
-e HERMES_WEBUI_STATE_DIR=/app/data \
|
||||
-e WANTED_UID=1024 \
|
||||
-e WANTED_GID=1024 \
|
||||
-v "$STATE_DIR":/app/data \
|
||||
-v "$HERMES_DIR":/home/hermeswebui/.hermes \
|
||||
ghcr.io/nesquena/hermes-webui:latest
|
||||
|
||||
attempts=0
|
||||
max_attempts=60
|
||||
until curl --fail --silent --max-time 5 http://127.0.0.1:8789/health > /dev/null; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge "$max_attempts" ]; then
|
||||
echo "❌ WebUI /health never returned 200 with explicit WANTED_UID=1024"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
|
||||
echo "❌ Container exited before /health came up"
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
|
||||
if [ "$ACTUAL_UID" != "1024" ]; then
|
||||
echo "❌ explicit WANTED_UID=1024 was overwritten by auto-detection — got ${ACTUAL_UID} (#7027)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ explicit WANTED_UID=1024 preserved (sentinel no longer shadows the valid value)"
|
||||
|
||||
smoke:
|
||||
name: Smoke ${{ matrix.variant }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compose-config, build-image]
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant:
|
||||
- single
|
||||
- two-container
|
||||
- three-container
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Restore the cached Docker image from the build-image job.
|
||||
# Read-only: the build-image job already populated the GHA cache, so the
|
||||
# smoke variants only need cache-from (no redundant cache-to re-export).
|
||||
- name: Restore Docker image from cache
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: ghcr.io/nesquena/hermes-webui:latest
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Resolve compose file + project name
|
||||
id: vars
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.variant }}" in
|
||||
single)
|
||||
echo "compose_file=docker-compose.yml" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
two-container)
|
||||
echo "compose_file=docker-compose.two-container.yml" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
three-container)
|
||||
echo "compose_file=docker-compose.three-container.yml" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
# Per-run project name so concurrent jobs / reruns can't clobber each other.
|
||||
echo "project=hermes-smoke-${{ matrix.variant }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reap any prior hermes-smoke resources on this runner
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Hosted GHA runners are fresh, so this is mostly defence-in-depth for
|
||||
# self-hosted runner re-use. We rely primarily on the unique per-run
|
||||
# project name + `compose down -v --remove-orphans` in the EXIT trap
|
||||
# to clean up the resources THIS run creates; this step only sweeps
|
||||
# leftovers from prior runs that crashed before their trap fired.
|
||||
# Match by project-name prefix instead of labels (the compose files
|
||||
# don't carry hermes-smoke labels on their resources).
|
||||
for c in $(docker ps -aq --filter "name=hermes-smoke-"); do
|
||||
docker rm -f "$c" || true
|
||||
done
|
||||
for v in $(docker volume ls -q | grep "^hermes-smoke-" || true); do
|
||||
docker volume rm -f "$v" || true
|
||||
done
|
||||
for n in $(docker network ls --format '{{.Name}}' | grep "^hermes-smoke-" || true); do
|
||||
docker network rm "$n" || true
|
||||
done
|
||||
|
||||
- name: Prepare ephemeral host paths
|
||||
id: paths
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STATE_DIR="$(mktemp -d -t hermes-smoke-state-XXXXXX)"
|
||||
WORK_DIR="$(mktemp -d -t hermes-smoke-work-XXXXXX)"
|
||||
echo "state_dir=$STATE_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "work_dir=$WORK_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "Allocated:"
|
||||
echo " HERMES_HOME = $STATE_DIR"
|
||||
echo " HERMES_WORKSPACE = $WORK_DIR"
|
||||
|
||||
- name: Smoke (up + health + log scan + down)
|
||||
env:
|
||||
COMPOSE_FILE: ${{ steps.vars.outputs.compose_file }}
|
||||
PROJECT: ${{ steps.vars.outputs.project }}
|
||||
HERMES_HOME: ${{ steps.paths.outputs.state_dir }}
|
||||
HERMES_WORKSPACE: ${{ steps.paths.outputs.work_dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# ----- Trap-guaranteed cleanup, regardless of exit reason -----
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
echo "::group::Cleanup (rc=$rc)"
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color --tail=200 || true
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans || true
|
||||
rm -rf "$HERMES_HOME" "$HERMES_WORKSPACE" || true
|
||||
echo "::endgroup::"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "::group::docker compose up"
|
||||
# --wait blocks until all services report healthy OR --wait-timeout fires.
|
||||
# Compose v2 returns nonzero on either failure mode.
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --wait --wait-timeout 120
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::container roster"
|
||||
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" ps
|
||||
echo "::endgroup::"
|
||||
|
||||
# ----- WebUI /health probe -----
|
||||
# Single-container: WebUI is on the host on 127.0.0.1:8787.
|
||||
# Two/three-container: same — both compose files publish 127.0.0.1:8787.
|
||||
echo "::group::Probe /health"
|
||||
attempts=0
|
||||
max_attempts=30
|
||||
until curl --fail --silent --max-time 5 http://127.0.0.1:8787/health > /dev/null; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge "$max_attempts" ]; then
|
||||
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~60s)"
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "✅ /health = 200 after $attempts attempts"
|
||||
echo "::endgroup::"
|
||||
|
||||
# ----- Startup log scan: must not contain any known-bad signatures -----
|
||||
# These are the exact patterns that would have flagged #2470 in real time.
|
||||
# The grep -i is anchored to actual error tokens; benign log lines that
|
||||
# contain the substring 'error' in a stack-friendly context (e.g.
|
||||
# "errorless", URL paths) are improbable for these specific tokens.
|
||||
echo "::group::Startup log scan"
|
||||
LOGS="$(docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --no-color)"
|
||||
# `!! ERROR` + `!! Exiting script` are the actual strings emitted by
|
||||
# docker_init.bash's error_exit() helper — the function name itself
|
||||
# never appears in output. The literal token `error_exit` is kept as
|
||||
# a belt-and-suspenders catch for any stray debug/echo of the name.
|
||||
BAD_PATTERNS='EROFS|Read-only file system|Traceback|PermissionError|!! ERROR|!! Exiting script|error_exit|groupmod: cannot|usermod: cannot|Failed to set (UID|GID|owner|permissions|ownership)'
|
||||
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
|
||||
echo "❌ Startup logs contain known-bad pattern (see above)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No known-bad patterns in startup logs"
|
||||
echo "::endgroup::"
|
||||
@@ -1,116 +0,0 @@
|
||||
name: Docs CI
|
||||
|
||||
# Lightweight checks for documentation changes. Intentionally MINIMAL (Nathan,
|
||||
# 2026-07-18): NOT a Markdown style linter — no line length, heading levels, list
|
||||
# markers, or cosmetic rules. It runs only two things, and only when docs change:
|
||||
# 1. critical_markdown_check.py — flags ONLY a rendering break (a link whose
|
||||
# destination token is split across a newline, or an unclosed inline link).
|
||||
# 2. lychee link check — flags broken/dead links (informational, non-blocking).
|
||||
#
|
||||
# Runs on docs paths only; does NOT gate code PRs. This job is NOT a required
|
||||
# status check — it's an informational quality gate; promote it via the ruleset
|
||||
# later if desired.
|
||||
#
|
||||
# SECURITY: changed filenames on a fork PR are UNTRUSTED. They are never
|
||||
# interpolated into `run:` source via ${{ }} (which Bash would re-evaluate,
|
||||
# executing $(...) in a crafted filename). The file list is passed through an
|
||||
# `env:` variable and referenced as "$DOC_FILES", and lychee is handed a FIXED
|
||||
# glob rather than an attacker-controlled explicit list.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- '**/*.md'
|
||||
- '**/*.markdown'
|
||||
- '**/*.rst'
|
||||
- 'docs/**'
|
||||
- 'CHANGELOG*'
|
||||
- '**/README*'
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- '**/*.md'
|
||||
- '**/*.markdown'
|
||||
- '**/*.rst'
|
||||
- 'docs/**'
|
||||
- 'CHANGELOG*'
|
||||
- '**/README*'
|
||||
|
||||
# Read-only; no secrets needed. Fork PRs already get a read-only token, but pin it.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docs-ci:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Resolve changed doc files (NUL-safe, untrusted names)
|
||||
id: docs
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Compute the changed markdown/doc files and write them to a file, ONE PER
|
||||
# LINE. We never echo untrusted names into another shell; downstream steps
|
||||
# read this file. Fall back to all tracked docs if the diff can't compute.
|
||||
if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin master || true
|
||||
RANGE="$BASE_SHA...$HEAD_SHA"
|
||||
else
|
||||
RANGE="${{ github.event.before }}...${{ github.sha }}"
|
||||
fi
|
||||
: > doc_files.txt
|
||||
if CHANGED="$(git diff --name-only --no-renames --diff-filter=ACMR "$RANGE" 2>/dev/null)"; then
|
||||
printf '%s\n' "$CHANGED" | grep -Ei '\.(md|markdown|rst)$' > doc_files.txt || true
|
||||
fi
|
||||
if [ ! -s doc_files.txt ]; then
|
||||
git ls-files '*.md' '*.markdown' '*.rst' > doc_files.txt 2>/dev/null || true
|
||||
fi
|
||||
# Keep only files that still exist (skip deletions), preserving exact names.
|
||||
: > doc_files_existing.txt
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] && [ -f "$f" ] && printf '%s\n' "$f" >> doc_files_existing.txt
|
||||
done < doc_files.txt
|
||||
mv doc_files_existing.txt doc_files.txt
|
||||
echo "Doc files to check:"; cat doc_files.txt
|
||||
echo "count=$(wc -l < doc_files.txt)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Critical Markdown check (rendering-breaks only, not style)
|
||||
if: steps.docs.outputs.count != '0'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Read the NUL-safe-ish file list (one path per line) into an array. Names
|
||||
# are passed as ARGUMENTS to python, never evaluated as shell.
|
||||
mapfile -t FILES < doc_files.txt
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then echo "No doc files."; exit 0; fi
|
||||
python3 scripts/critical_markdown_check.py "${FILES[@]}"
|
||||
|
||||
# Broken-link check. Handed a FIXED glob (not the attacker-controlled list) so
|
||||
# no untrusted filename ever reaches lychee's `eval`. Over-checking docs is
|
||||
# harmless. Informational only: continue-on-error keeps it from blocking.
|
||||
- name: Broken-link check (lychee)
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
with:
|
||||
args: >-
|
||||
--no-progress
|
||||
--max-concurrency 4
|
||||
--accept 200,206,301,302,303,307,308,401,403,429
|
||||
--timeout 20
|
||||
--max-retries 2
|
||||
"**/*.md"
|
||||
"**/*.markdown"
|
||||
"**/*.rst"
|
||||
fail: true
|
||||
continue-on-error: true
|
||||
@@ -1,132 +0,0 @@
|
||||
name: Native Windows startup
|
||||
|
||||
# Runs on PRs that touch start.ps1 (or this workflow). Validates the
|
||||
# native-Windows launch script catches the bug classes the recent
|
||||
# Windows-only batch caught manually (#2805 WOW64 ProgramFiles redirect,
|
||||
# #2806 venv-portability claim, #2807 port-parse + finally-cleanup).
|
||||
#
|
||||
# Scope (per nesquena-hermes comment on #2811 — option 1, mock-only):
|
||||
# hermes-agent is not published to PyPI, so we cannot pip-install it on
|
||||
# the runner. Instead we stub a hermes_cli/ directory next to a sibling
|
||||
# hermes-agent/ folder — just enough for start.ps1's existence guard to
|
||||
# pass. The workflow then runs start.ps1 for a few seconds and asserts
|
||||
# that none of start.ps1's own Write-Error guards fired. Server-boot
|
||||
# regressions remain covered by the Linux jobs and docker-smoke.yml.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'start.ps1'
|
||||
- '.github/workflows/native-windows-startup.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
native-windows-startup:
|
||||
name: start.ps1 path discovery (mock hermes-agent)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 8
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
# Create the WebUI venv. start.ps1 prefers $AgentDir\venv if it
|
||||
# exists, then falls back to the python on PATH. We create a
|
||||
# WebUI-local venv to mirror the README's documented native path
|
||||
# and to give start.ps1 a real python.exe to invoke.
|
||||
- name: Create venv (README path)
|
||||
shell: pwsh
|
||||
run: |
|
||||
python -m venv venv
|
||||
if (-not (Test-Path venv\Scripts\python.exe)) {
|
||||
throw "venv\Scripts\python.exe missing after venv create"
|
||||
}
|
||||
|
||||
# Mock-only hermes-agent provisioning. We can't pip-install
|
||||
# hermes-agent (not on PyPI), so we stub the minimum that
|
||||
# start.ps1's `Test-Path hermes_cli -PathType Container` guard
|
||||
# needs to pass. server.py would crash on this stub at import
|
||||
# time — we deliberately do NOT probe /health below.
|
||||
- name: Stub hermes-agent (mock hermes_cli only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$agentDir = Join-Path (Split-Path -Parent $PWD) 'hermes-agent'
|
||||
$cliDir = Join-Path $agentDir 'hermes_cli'
|
||||
New-Item -ItemType Directory -Force -Path $cliDir | Out-Null
|
||||
Set-Content -Path (Join-Path $cliDir '__init__.py') -Value '# stub for CI path-discovery test only'
|
||||
"HERMES_WEBUI_AGENT_DIR=$agentDir" >> $env:GITHUB_ENV
|
||||
Write-Host "Stub hermes-agent provisioned at $agentDir"
|
||||
|
||||
# Run start.ps1 and verify it passes its own discovery guards
|
||||
# without erroring out. server.py will exit non-zero on the stub
|
||||
# (no real CLI code) — that's expected and not asserted against.
|
||||
# We only fail if start.ps1's own Write-Error guards fire.
|
||||
- name: Run start.ps1 + verify path discovery
|
||||
shell: pwsh
|
||||
run: |
|
||||
$stdout = Join-Path $env:RUNNER_TEMP 'start-ps1.out'
|
||||
$stderr = Join-Path $env:RUNNER_TEMP 'start-ps1.err'
|
||||
$proc = Start-Process -FilePath 'pwsh' `
|
||||
-ArgumentList '-NoLogo','-File','.\start.ps1' `
|
||||
-WorkingDirectory $PWD `
|
||||
-PassThru `
|
||||
-RedirectStandardOutput $stdout `
|
||||
-RedirectStandardError $stderr
|
||||
"SERVER_PID=$($proc.Id)" >> $env:GITHUB_ENV
|
||||
Write-Host "Spawned start.ps1 wrapper PID $($proc.Id)"
|
||||
|
||||
# Path discovery is sub-second; the 8s buffer lets the python
|
||||
# launch land in the logs (and immediately exit on the stub).
|
||||
Start-Sleep -Seconds 8
|
||||
|
||||
Write-Host "===== start.ps1 stdout ====="
|
||||
$stdoutContent = if (Test-Path $stdout) { Get-Content $stdout -Raw } else { '<empty>' }
|
||||
Write-Host $stdoutContent
|
||||
Write-Host "===== start.ps1 stderr ====="
|
||||
$stderrContent = if (Test-Path $stderr) { Get-Content $stderr -Raw } else { '<empty>' }
|
||||
Write-Host $stderrContent
|
||||
|
||||
# Pattern set: every Write-Error message start.ps1 can emit on
|
||||
# its own discovery path. If any of these appear in stderr,
|
||||
# path discovery regressed and the job must fail.
|
||||
$guardErrors = @(
|
||||
'Python 3 is required',
|
||||
'hermes-agent not found',
|
||||
'HERMES_WEBUI_AGENT_DIR is set to',
|
||||
'is not a valid integer port',
|
||||
'is out of TCP-port range',
|
||||
'server.py not found'
|
||||
)
|
||||
foreach ($msg in $guardErrors) {
|
||||
if ($stderrContent -and $stderrContent -match [regex]::Escape($msg)) {
|
||||
throw "REGRESSION: start.ps1 errored on guard '$msg' - path discovery failed."
|
||||
}
|
||||
}
|
||||
Write-Host "OK: start.ps1 path discovery - all guards passed."
|
||||
|
||||
# taskkill /T walks the process tree, /F forces. taskkill returns
|
||||
# 128 ("process not found") if the PID is already gone — that's
|
||||
# the expected steady state for this mock-only workflow because
|
||||
# server.py exits immediately on the stub hermes_cli. Reset
|
||||
# $LASTEXITCODE so the step never fails on the cleanup itself.
|
||||
- name: Stop background server (tree-kill)
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:SERVER_PID) {
|
||||
& taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host
|
||||
$global:LASTEXITCODE = 0
|
||||
}
|
||||
# Belt-and-suspenders: kill anything still bound to 8787.
|
||||
$hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue
|
||||
if ($hanging) {
|
||||
foreach ($c in $hanging) {
|
||||
try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {}
|
||||
}
|
||||
}
|
||||
exit 0
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Release & Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'exp-v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required: create GitHub Release
|
||||
packages: write # required: push to ghcr.io
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Check whether this is a stable or experimental release
|
||||
- name: Detect release channel
|
||||
id: channel
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
echo "is_stable=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_stable=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Create GitHub Release from tag with auto-generated notes
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ steps.channel.outputs.is_stable == 'false' }}
|
||||
|
||||
# Set up multi-arch build (QEMU + Buildx)
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract tags from the git ref (supports vX.Y and vX.Y.Z formats)
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=match,pattern=v(\d+\.\d+(?:\.\d+)?),group=1
|
||||
type=match,pattern=exp-v(\d+\.\d+(?:\.\d+)?),group=1
|
||||
type=raw,value=latest,enable=${{ steps.channel.outputs.is_stable == 'true' }}
|
||||
type=raw,value=experimental,enable=${{ steps.channel.outputs.is_stable == 'false' }}
|
||||
|
||||
# Build and push multi-arch image (amd64 + arm64)
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: HERMES_VERSION=${{ github.ref_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -1,252 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
# Docs-only fast path: a CHANGELOG/README/docs-only PR does not need the full pytest
|
||||
# matrix or the JS/lint runtime guards — nothing executable changed. But the `test (…)`
|
||||
# shards and `lint` are REQUIRED status checks in the master ruleset, and a *skipped*
|
||||
# required check reports as pending forever (auto-merge would hang). So we keep the jobs
|
||||
# running and reporting green, but SHORT-CIRCUIT their expensive work when every changed
|
||||
# path is documentation. Detection FAILS SAFE: if anything is uncertain (no base to diff,
|
||||
# any non-doc path touched), docs_only=false and the full suite runs.
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.detect.outputs.docs_only }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect docs-only change set
|
||||
id: detect
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# Fail-safe: this step ALWAYS exits 0 and ALWAYS emits docs_only (default
|
||||
# 'false' = run the full suite). If the `changes` job ever failed, the
|
||||
# required test/browser jobs (needs: changes) would be SKIPPED and their
|
||||
# required contexts would never report — wedging the PR in perpetual
|
||||
# pending. So the detector can never fail; worst case it over-runs CI.
|
||||
docs_only="false"
|
||||
emit() { echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"; }
|
||||
trap emit EXIT
|
||||
set -uo pipefail
|
||||
# STRICT ALLOWLIST — a path counts as "docs" ONLY if it is unambiguously
|
||||
# documentation. Everything else runs the full suite (fail-open to safety).
|
||||
# A file is docs iff:
|
||||
# * it has a doc extension: *.md / *.markdown / *.rst, OR
|
||||
# * its basename is an extensionless doc file (LICENSE/README/CHANGELOG/…).
|
||||
# NOT docs (deliberately): *.txt (requirements.txt controls deps!), anything
|
||||
# under docs/ that isn't a doc extension (docs/Makefile, docs/build.py,
|
||||
# docs/*.json), and any code file whose NAME merely contains README/CHANGELOG
|
||||
# (scripts/CHANGELOG_stamp.py, static/README_renderer.js). Extension/type
|
||||
# wins over name, and a non-doc extension is never docs.
|
||||
is_docs() {
|
||||
f="$1"; low="$(printf '%s' "$f" | tr '[:upper:]' '[:lower:]')"; base="${f##*/}"
|
||||
case "$low" in
|
||||
*.md|*.markdown|*.rst) return 0 ;;
|
||||
esac
|
||||
case "$base" in
|
||||
LICENSE|README|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the changed-file list. PR event = base..head; push = before..after.
|
||||
if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin master || true
|
||||
RANGE="$BASE_SHA...$HEAD_SHA"
|
||||
else
|
||||
RANGE="${{ github.event.before }}...${{ github.sha }}"
|
||||
fi
|
||||
|
||||
# --no-renames so a rename shows BOTH sides: `git mv src/app.py docs.md` must
|
||||
# reveal the src/app.py deletion (a code change) instead of collapsing to the
|
||||
# docs.md destination and being mis-classified as docs-only.
|
||||
if ! FILES="$(git diff --name-only --no-renames "$RANGE" 2>/dev/null)"; then
|
||||
echo "Could not diff $RANGE — running full suite (fail-safe)."; exit 0
|
||||
fi
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "Empty diff — running full suite (fail-safe)."; exit 0
|
||||
fi
|
||||
|
||||
echo "Changed files:"; echo "$FILES"
|
||||
result="true"
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
if ! is_docs "$f"; then
|
||||
echo "Non-docs path -> full suite required: $f"
|
||||
result="false"
|
||||
break
|
||||
fi
|
||||
done <<< "$FILES"
|
||||
docs_only="$result"
|
||||
echo "docs_only=$docs_only"
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
# `lint` is not currently a required check, but add always() anyway so it can
|
||||
# never wedge if it's promoted to required later — same rationale as the test/
|
||||
# browser-smoke jobs; a failed `changes` degrades to running the full lint.
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Docs-only short-circuit
|
||||
if: needs.changes.outputs.docs_only == 'true'
|
||||
run: echo "Docs-only change set — skipping lint/runtime guards (nothing executable changed)."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
with:
|
||||
# Need history so the gate can diff against the merge-base with master.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install ruff
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: pip install ruff
|
||||
|
||||
- name: Ensure origin/master ref is available for the diff gate
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: git fetch --no-tags --depth=1 origin master || true
|
||||
|
||||
# Whole-tree Python syntax gate. The Python analog of the JS `node --check`
|
||||
# pass. The ruff E9 gate below is diff-scoped (new/changed lines only), so a
|
||||
# SyntaxError/IndentationError whose parser-reported line sits on an UNTOUCHED
|
||||
# line (e.g. a dedented `try:` above an unchanged import) can slip the diff
|
||||
# filter and only surface as a confusing mass-failure of every test shard at
|
||||
# the conftest server-boot fixture. compileall fails in ~2s with a clear
|
||||
# file:line, before the matrix suite runs. Whole-tree + unconditional so it
|
||||
# cannot be bypassed by the diff scope. (#4641)
|
||||
- name: Python syntax gate (whole tree — fails fast on any unparseable .py)
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: python3 -m compileall -q api server.py bootstrap.py mcp_server.py tests scripts
|
||||
|
||||
- name: Ruff forward gate (new/changed lines only)
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: python3 scripts/ruff_lint.py --diff origin/master
|
||||
|
||||
- name: Ruff whole-tree report (informational — never blocks)
|
||||
if: needs.changes.outputs.docs_only != 'true' && always()
|
||||
run: python3 scripts/ruff_lint.py --all
|
||||
|
||||
# Static-JS runtime-error guards. These catch brick-class bugs that throw only
|
||||
# when the browser executes the code — node --check, source-presence tests, and
|
||||
# the mocked pytest suite all miss them. Two complementary ESLint passes:
|
||||
# * runtime-guard config: no-const-assign / no-import-assign (#3162 class)
|
||||
# * scope_undef_gate.py: no-undef across the shared classic-<script> global
|
||||
# scope, catching a function defined nested but called from a sibling
|
||||
# scope (#3696 — ReferenceError: _sessionAttentionState is not defined).
|
||||
- name: Set up Node for ESLint runtime guards
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install ESLint
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: npm install --no-save eslint@^10
|
||||
|
||||
- name: ESLint runtime-error gate (no-const-assign / no-import-assign, #3162)
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js"
|
||||
|
||||
- name: Scope / undefined-reference gate (#3696)
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: python3 scripts/scope_undef_gate.py
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
# Belt-and-suspenders: run even if `changes` errored/was skipped. A required job
|
||||
# that gets SKIPPED reports its context as pending forever (wedge). With always(),
|
||||
# the job runs regardless; the per-step `if: … != 'true'` guards then treat a
|
||||
# missing/empty docs_only (which is what a failed `changes` yields) as "run full
|
||||
# CI" — so a detector failure degrades to running everything, never to a wedge.
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# Don't cancel the other shards/versions when one fails — we want the full
|
||||
# failure picture across the matrix in a single run.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12', '3.13']
|
||||
# Split the suite across 5 parallel shards per Python version. pytest-shard
|
||||
# partitions tests deterministically by test-id hash; the suite was made
|
||||
# shard-safe (no cross-test state leakage) so every shard passes
|
||||
# independently. See docs/agent-memory note on test-suite shard-safety.
|
||||
# NOTE: pytest-shard is 0-indexed — shard ids must be 0..num_shards-1.
|
||||
# Using 1-based ids would crash the out-of-range job AND silently skip
|
||||
# shard 0's tests.
|
||||
shard: [0, 1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Docs-only short-circuit
|
||||
if: needs.changes.outputs.docs_only == 'true'
|
||||
run: echo "Docs-only change set — skipping pytest shard ${{ matrix.shard }} (no executable change)."
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache-dependency-path: |
|
||||
**/setup.cfg
|
||||
**/requirements*.txt
|
||||
**/pyproject.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Office parsers stay optional at runtime; CI installs them explicitly for the workspace preview tests.
|
||||
pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard python-docx openpyxl python-pptx playwright
|
||||
# ruff is installed so tests/test_ruff_forward_lint.py runs its E9/F821
|
||||
# tree-clean assertions in-suite (mirrors how eslint is available for
|
||||
# tests/test_static_js_runtime_lint.py). If install fails the test
|
||||
# skips cleanly — it never blocks the matrix.
|
||||
pip install ruff || echo "ruff install failed — test_ruff_forward_lint.py will skip"
|
||||
# Install the `mcp` package so tests/test_mcp_server.py runs in CI.
|
||||
# The package is an optional runtime dep of mcp_server.py — users
|
||||
# who run the MCP integration install it themselves; CI installs
|
||||
# it so test coverage exists. If mcp install fails (Python 3.13
|
||||
# wheel not yet available, etc.), tests/test_mcp_server.py uses
|
||||
# importorskip and the matrix stays green.
|
||||
pip install "mcp<2" || echo "mcp install failed — test_mcp_server.py will importorskip"
|
||||
|
||||
- name: Get Playwright version
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
id: pw-version
|
||||
run: echo "version=$(pip show playwright | grep '^Version:' | cut -d' ' -f2)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.pw-version.outputs.version }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: python -m playwright install --with-deps chromium
|
||||
|
||||
- name: Run tests (shard ${{ matrix.shard }} of 5)
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
run: pytest tests/ -v --timeout=60 --shard-id=${{ matrix.shard }} --num-shards=5
|
||||
+1
-82
@@ -1,86 +1,5 @@
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Backup and temporary files
|
||||
*.bak
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Archive directory (pre-git backups, kept on disk but not tracked)
|
||||
archive/
|
||||
|
||||
# Local environment and secrets (but keep the example templates)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.local.md
|
||||
.cursorrules
|
||||
.windsurfrules
|
||||
.aider*
|
||||
copilot-instructions.md
|
||||
|
||||
# Generated screenshots and transient artifacts
|
||||
screenshot-*.png
|
||||
full-UI.png
|
||||
# Playwright MCP scratch — directory + ad-hoc PNGs the agent drops while
|
||||
# debugging browser sessions. /final-check.png is the convention used by the
|
||||
# local agent loop for a one-off end-of-task verification grab; ignore
|
||||
# either way so they never sneak into a commit.
|
||||
.playwright-mcp/
|
||||
final-check.png
|
||||
|
||||
# Version file written by Docker/CI build — generated, never committed
|
||||
api/_version.py
|
||||
api/_scm_version.py
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local reference clones/artifacts — never committed by default.
|
||||
# Markdown docs at docs/*.md are intentionally trackable for contributor docs.
|
||||
docs/*
|
||||
!docs/*.md
|
||||
!docs/ui-ux/
|
||||
!docs/ui-ux/**
|
||||
!docs/rfcs/
|
||||
!docs/rfcs/**
|
||||
!docs/architecture/
|
||||
!docs/architecture/**
|
||||
|
||||
# Local-only AI assistant context — never committed even under docs/.
|
||||
docs/AGENTS.md
|
||||
docs/CLAUDE.md
|
||||
docs/.cursorrules
|
||||
docs/.windsurfrules
|
||||
.specify/
|
||||
AGENT_TRACE.yaml
|
||||
EXECUTION_LOG.md
|
||||
|
||||
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
|
||||
# Used by Claude during deep reviews; never shared in the repo.
|
||||
.local-review/
|
||||
.review-crops/
|
||||
graphify-out/
|
||||
.graphify_cached.json
|
||||
.graphify_uncached.txt
|
||||
|
||||
.venv/
|
||||
|
||||
# Dev-only lint tooling (ESLint runtime-error guard) — see TESTING.md
|
||||
node_modules/
|
||||
package-lock.json
|
||||
docs/ui-ux/review-shots/
|
||||
|
||||
# Python build metadata (never commit generated egg-info)
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
|
||||
# Local WebUI runtime data and credentials
|
||||
.webui_password
|
||||
attachments/
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Agent instructions for Hermes WebUI
|
||||
|
||||
This file is the shared entry point for AI assistants working in this
|
||||
repository. Keep it project-specific and safe to publish. Do not put personal
|
||||
machine setup, private network details, credentials, tokens, or local-only
|
||||
workflow notes here.
|
||||
|
||||
## Read first
|
||||
|
||||
Before making changes, read:
|
||||
|
||||
1. `README.md`
|
||||
2. `CONTRIBUTING.md`
|
||||
3. `docs/CONTRACTS.md`
|
||||
4. `CHANGELOG.md`
|
||||
|
||||
For architecture, testing, or setup work, also read the matching reference:
|
||||
|
||||
- `ARCHITECTURE.md` for design constraints and current module layout
|
||||
- `TESTING.md` for local verification commands and manual test guidance
|
||||
- `docs/onboarding.md` for first-run onboarding behavior
|
||||
- `docs/troubleshooting.md` for diagnostic flows
|
||||
- `docs/rfcs/README.md` for larger RFCs and state/durability contracts
|
||||
|
||||
For UI or UX work, read `docs/UIUX-GUIDE.md` and `DESIGN.md` before
|
||||
changing layout, interaction flow, themes, chat rendering, or composer chrome.
|
||||
|
||||
## Onboarding and reinstall support
|
||||
|
||||
If the task involves install, reinstall, bootstrap, first-run onboarding,
|
||||
provider setup, local model server setup, Docker onboarding, WSL onboarding, or
|
||||
support for a failed first run, read `docs/onboarding-agent-checklist.md`
|
||||
before running commands or inspecting logs.
|
||||
|
||||
Follow that checklist's safety rules:
|
||||
|
||||
- use isolated `HERMES_HOME` and `HERMES_WEBUI_STATE_DIR` for trials unless the
|
||||
human explicitly asks to use real state
|
||||
- do not delete or overwrite a real `~/.hermes` directory without explicit
|
||||
approval
|
||||
- do not print API keys, OAuth tokens, cookies, full `.env` files, full
|
||||
`auth.json` files, or password hashes
|
||||
- collect non-secret status and log evidence before recommending a fix
|
||||
|
||||
## Contribution style
|
||||
|
||||
- Keep one logical change per PR; split unrelated refactors or cleanup.
|
||||
- Read `docs/CONTRACTS.md` and the linked contract/RFC for the touched
|
||||
subsystem before editing.
|
||||
- For local pytest runs, use `./scripts/test.sh` instead of bare `python3`,
|
||||
`python -m pytest`, or `pytest`. The script creates/uses the repo `.venv`,
|
||||
pins execution to Python 3.11-3.13, and installs missing dev test dependencies.
|
||||
`HERMES_WEBUI_TEST_PYTHON` selects the supported base interpreter used to
|
||||
create or rebuild `.venv`; it must not install test dependencies into a
|
||||
system/Homebrew interpreter directly.
|
||||
If a direct pytest invocation reports an unsupported interpreter, rerun through
|
||||
`./scripts/test.sh` before debugging product code.
|
||||
- Prefer the existing Python + vanilla JavaScript structure. Do not add
|
||||
dependencies, build tools, frameworks, or long-lived processes without clear
|
||||
justification and a rollback story.
|
||||
- Update docs when changing setup, onboarding, runtime behavior, architecture,
|
||||
testing guidance, or user-facing workflows.
|
||||
- Do not edit `CHANGELOG.md` in ordinary contributor PRs. The release workflow
|
||||
owns changelog updates through release commits. If a change is release-note
|
||||
worthy, include concise release-note wording in the PR body instead.
|
||||
- For UI or UX changes, include before/after evidence and test relevant
|
||||
desktop, narrow, and mobile states.
|
||||
- For behavior changes, add or update automated tests where practical and list
|
||||
the manual verification performed.
|
||||
- For runtime, streaming, recovery, replay, compression, or sidebar metadata
|
||||
changes, name the state layer being mutated and prove the relevant invariant.
|
||||
- For Docker build changes in `docker_init.bash`, mirror directory exclusions
|
||||
in both the `rsync` and `cp -a` paths — `/opt/hermes` may contain subdirectories
|
||||
with restricted permissions (e.g. `.playwright/`).
|
||||
|
||||
## Before you open a PR — the change guidelines
|
||||
|
||||
Read [`docs/GUIDELINES.md`](docs/GUIDELINES.md) in full before non-trivial work. It is the
|
||||
distilled set of habits that get a change merged in one review round instead of several. The
|
||||
compressed form:
|
||||
|
||||
1. **Fix the class, not the instance.** A bug usually has siblings — other call sites, backends,
|
||||
companion endpoints, layouts, exit paths. Find them all and fix the shared chokepoint, or name
|
||||
the ones you left out of scope.
|
||||
2. **Trace one authoritative value end-to-end** (`input → normalize → decision → action → persist →
|
||||
cleanup`); the code that *decides* and the code that *acts* must use the same resolved value.
|
||||
3. **When you can't confirm something, fail closed and say so.** Never take the permissive branch on
|
||||
uncertainty; never report a failure as success. "Unknown" is not "allowed."
|
||||
4. **Enumerate the state-space before editing** — entry point, backend, item count (0/1/many), every
|
||||
lifecycle exit (success/error/cancel/replace/teardown), auth on/off, concurrency, hostile input —
|
||||
and cover each or mark it out of scope. Most redo rounds are one un-considered dimension.
|
||||
5. **Assume inputs and check-then-use gaps are adversarial** — validate at the point of use (hold a
|
||||
handle, don't re-resolve a path), scope caches by complete identity, handle crafted input.
|
||||
6. **A test must fail before your fix and pass after it.** Assert observable behavior, not a source
|
||||
string or a mock of the thing under test; use multiple items if selection is what's being tested.
|
||||
7. **Name the owner of every piece of state and prove it's released on every exit** (success, error,
|
||||
cancel, replace, shrink, teardown) — not just the happy path.
|
||||
8. **Fallbacks/defaults are contracts — extend the mechanism, don't copy it.** Editing N parallel
|
||||
blocks identically means you missed a chokepoint (e.g. new copy goes in the `en` locale only).
|
||||
9. **The diff is the task and nothing else.** Extras go in the PR description, not the diff; run the
|
||||
affected + neighboring tests before opening.
|
||||
10. **A visible control costs attention on every visit** — place it by frequency of use and by where
|
||||
mainstream chat apps put the equivalent, not by where your diff already is; verify with
|
||||
before/after images at desktop and narrow widths.
|
||||
|
||||
Show the work in the PR body: the siblings you found, proof the test failed before the fix, the
|
||||
verification run, before/after images for visible changes, and an explicit list of what you could
|
||||
not verify.
|
||||
|
||||
## Local state and secrets
|
||||
|
||||
Hermes WebUI can read and write real agent state, sessions, workspaces,
|
||||
credentials, and cron data. Treat local validation as potentially destructive
|
||||
unless you have confirmed the active state directories.
|
||||
|
||||
Prefer isolated trial state for experiments:
|
||||
|
||||
```bash
|
||||
HERMES_HOME=/tmp/hermes-webui-agent-home \
|
||||
HERMES_WEBUI_STATE_DIR=/tmp/hermes-webui-agent-state \
|
||||
HERMES_WEBUI_PORT=8789 \
|
||||
python3 bootstrap.py
|
||||
```
|
||||
|
||||
Do not include private machine instructions in this tracked file. Use a
|
||||
git-ignored local note for personal workflow details.
|
||||
-1704
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
# Bugs Backlog
|
||||
|
||||
This file tracks UI bugs and polish items. Fixed items are kept for reference.
|
||||
|
||||
---
|
||||
|
||||
## Open Bugs
|
||||
|
||||
*No open bugs at this time.*
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Two-container Docker setup: tools run in WebUI container** — In the two-container setup (hermes-agent + hermes-webui as separate containers), WebUI-initiated agent sessions run tools in the WebUI container, not the agent container. This is a known architectural constraint. Workaround: use the combined single-image approach, or initiate sessions via the CLI in the agent container. (#681)
|
||||
|
||||
- **Image-in-chat vs. saved-to-workspace mismatch** — When the agent displays an inline image (from a URL) and the user asks it to save that image, the agent issues a fresh download which may return a different file if the source URL is CDN-rotated or parameterized. The WebUI correctly renders whatever URL the agent provides. Fix requires agent-side URL caching. (#641)
|
||||
|
||||
- **MCP tools not available in WebUI sessions** — MCP servers must be configured in the active profile's config.yaml under mcp_servers:. If MCP tools are not appearing, check that the profile is correct and the MCP server process is reachable from inside the WebUI container. (#628)
|
||||
|
||||
- **os.environ race condition in concurrent sessions** — Concurrent agent sessions share process-level os.environ for TERMINAL_CWD, HERMES_SESSION_KEY, and HERMES_HOME. _ENV_LOCK serializes mutations but does not fully isolate env vars during agent execution. Upstream fix pending in hermes-agent. (#195)
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### ~~Session title truncation / hover actions~~ -- Fixed (Sprint 16)
|
||||
|
||||
- **Was:** Action icons reserved ~30px of space even when invisible, truncating titles.
|
||||
- **Fix:** Wrapped all action buttons in a `.session-actions` overlay container with `position:absolute`. Titles now use full available width. Actions appear on hover with a gradient fade from the right edge.
|
||||
|
||||
### ~~Folder/project assignment interaction feels sticky~~ -- Fixed (Sprint 16)
|
||||
|
||||
- **Was:** Folder icon stayed permanently visible (blue, 60% opacity) when a session belonged to a project.
|
||||
- **Fix:** Replaced `.has-project` persistent button with a colored left border matching the project color. The folder button now only appears in the hover overlay like all other actions.
|
||||
|
||||
### ~~Project picker clipping and width~~ -- Fixed (v0.17.3)
|
||||
|
||||
- **Was:** Picker was clipped by `overflow:hidden` on `.session-item` ancestors. With `position:fixed`, no containing block constrained width -- picker stretched to full viewport.
|
||||
- **Fix:** Dynamic width calculation (min 160px, max 220px). Event listener reordering. Cleanup sequence corrected. (PR #25)
|
||||
|
||||
### ~~NameError crash in model discovery~~ -- Fixed (v0.17.3)
|
||||
|
||||
- **Was:** `logger.debug()` called in custom endpoint `except` block, but `logger` was never imported in `config.py`. Every failed endpoint fetch crashed with `NameError`.
|
||||
- **Fix:** Replaced with silent `pass` -- unreachable endpoints are expected when no local LLM is configured. (PR #24)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sprint 16 replaced all emoji HTML entities with monochrome SVG line icons (`ICONS` constant in `sessions.js`).
|
||||
- All session action buttons now use the overlay pattern for consistent UX.
|
||||
-12048
File diff suppressed because it is too large
Load Diff
-229
@@ -1,229 +0,0 @@
|
||||
# Contributing to Hermes WebUI
|
||||
|
||||
Thanks for contributing.
|
||||
|
||||
Hermes WebUI is intentionally simple to work on: Python on the server, vanilla JS in the browser, no build step, no bundler, no frontend framework. The best pull requests preserve that simplicity while solving a real problem cleanly.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Read [`AGENTS.md`](AGENTS.md) if an AI assistant is doing or helping with the
|
||||
change.
|
||||
- Read [`docs/GUIDELINES.md`](docs/GUIDELINES.md) — the change guidelines that get a PR
|
||||
merged in one review round instead of several (what "complete and verified" means here).
|
||||
- Read [`docs/CONTRACTS.md`](docs/CONTRACTS.md) and any linked contract/RFC for
|
||||
the subsystem you will touch.
|
||||
- For UI or UX work, read [`docs/UIUX-GUIDE.md`](docs/UIUX-GUIDE.md)
|
||||
and [`DESIGN.md`](DESIGN.md).
|
||||
- For runtime, streaming, recovery, replay, compression, context reconstruction,
|
||||
or session metadata work, start with [`docs/rfcs/README.md`](docs/rfcs/README.md)
|
||||
and the relevant RFC listed there.
|
||||
|
||||
Use those documents as review guardrails: keep the change scoped, preserve the
|
||||
no-build-step architecture, update docs when behavior changes, put
|
||||
release-note-worthy details in the PR body, include UI evidence for UI changes,
|
||||
and add tests for behavior changes where practical.
|
||||
|
||||
### Contract-affecting PRs
|
||||
|
||||
A contract-affecting PR is any change that updates a public contract document,
|
||||
an RFC, a contributor guide, a product-semantics test, or behavior that those
|
||||
documents already describe. These PRs need an explicit `Contract Routing` section
|
||||
in the PR body that names the touched contract family and the evidence used.
|
||||
See [`docs/CONTRACTS.md#contract-routing`](docs/CONTRACTS.md#contract-routing)
|
||||
for the short routing shape and [`docs/CONTRACTS.md#contract-changes`](docs/CONTRACTS.md#contract-changes)
|
||||
for intentional contract changes.
|
||||
|
||||
If the PR intentionally changes an existing contract, add a `Contract Change`
|
||||
section that states the old rule, the new rule, and why the change is justified.
|
||||
Do not silently redefine product behavior by changing tests alone; update the
|
||||
corresponding docs in the same PR.
|
||||
|
||||
A release batch should call out included contract-affecting PRs separately
|
||||
from ordinary fixes, even when the code diff is small and CI is green.
|
||||
|
||||
## Two Paths to a Strong Pull Request
|
||||
|
||||
### Path 1: Small, Focused Changes
|
||||
|
||||
This is the fastest path to review and merge.
|
||||
|
||||
- Fix one clear bug or add one tightly scoped improvement
|
||||
- Touch the fewest files you can
|
||||
- Avoid drive-by refactors mixed into functional changes
|
||||
- Run the relevant tests locally before opening the PR
|
||||
- Keep the PR description concise and specific
|
||||
|
||||
These are the changes that are easiest to review and safest to merge quickly.
|
||||
|
||||
### Path 2: Bigger Changes
|
||||
|
||||
If you want to change architecture, reshape a workflow, add a substantial UI feature, or alter core behavior, align on direction first.
|
||||
|
||||
- Open an issue, start a discussion, or open a draft PR early
|
||||
- Explain the problem you are solving, not just the implementation you want
|
||||
- Call out tradeoffs, migration risk, and any alternatives you considered
|
||||
- Keep the final PR easy to review by separating unrelated work
|
||||
|
||||
Large changes are welcome, but surprise rewrites are hard to review well.
|
||||
|
||||
## What We Expect in Every PR
|
||||
|
||||
### 1. One Logical Change Per PR
|
||||
|
||||
Keep each PR focused. A small related group of fixes is fine. A bug fix plus a CSS cleanup plus a refactor plus a docs rewrite is not.
|
||||
|
||||
### 2. Local Verification
|
||||
|
||||
Run the test suite locally through the repo runner. It creates/uses a supported
|
||||
Python 3.11-3.13 `.venv` and installs missing dev test dependencies, avoiding
|
||||
unsupported system interpreters during collection:
|
||||
|
||||
```bash
|
||||
./scripts/test.sh
|
||||
```
|
||||
|
||||
CI also runs this suite on Python `3.11`, `3.12`, and `3.13`.
|
||||
|
||||
If your change affects browser behavior, also run the relevant manual checks from [TESTING.md](TESTING.md).
|
||||
|
||||
### 3. Clear PR Description
|
||||
|
||||
There is currently no PR template in this repo, so include the important sections yourself:
|
||||
|
||||
- Thinking Path
|
||||
- What Changed
|
||||
- Why It Matters
|
||||
- Verification
|
||||
- Risks / Follow-ups
|
||||
- Model Used
|
||||
|
||||
Two things reviewers look for specifically in Verification, both detailed in
|
||||
[`docs/GUIDELINES.md`](docs/GUIDELINES.md):
|
||||
|
||||
- When the issue pins the bug's shape — a session capture, a script, exact steps, or
|
||||
a fenced JSON block / field-level trigger conditions — bind your test to *that* shape
|
||||
rather than a fixture rebuilt from your reading of it, satisfying every condition it
|
||||
names; otherwise the test can pass while the reported bug goes untouched. Reconstruct
|
||||
the shape yourself only when the issue leaves it unpinned, and then say what you assumed.
|
||||
- For any claim the repo doesn't own (browser behavior, a provider's API, a registry,
|
||||
an OS convention), name who owns the truth and confirm your proof is one they'd accept.
|
||||
In-page automation proves page behavior, not browser behavior; a mock proves your
|
||||
intent, not the provider's contract.
|
||||
|
||||
If the change is user-visible, include screenshots or a short video.
|
||||
|
||||
If the change is release-note-worthy, include concise release-note wording in
|
||||
the PR body. Do not edit `CHANGELOG.md` directly in ordinary contributor PRs;
|
||||
the release workflow maintains it through release commits.
|
||||
|
||||
For UI or UX changes, before/after images are required. PRs that change the interface or interaction flow without before/after images may not receive meaningful review until that evidence is added.
|
||||
|
||||
### 4. AI Usage Disclosure
|
||||
|
||||
If AI helped produce the change, say so in the PR description.
|
||||
|
||||
Include:
|
||||
|
||||
- Provider
|
||||
- Exact model name or ID
|
||||
- Any notable mode or tool use that mattered
|
||||
|
||||
If no AI was used, write: `None — human-authored`.
|
||||
|
||||
### 5. Keep the Docs Honest
|
||||
|
||||
If your change alters behavior, architecture, testing, setup, or user-facing workflows, update the relevant docs in the same PR.
|
||||
|
||||
Common files:
|
||||
|
||||
- [README.md](README.md) for setup, usage, and contributor-facing commands
|
||||
- [ROADMAP.md](ROADMAP.md) for shipped features and sprint history
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) for implementation details and design constraints
|
||||
- [TESTING.md](TESTING.md) for manual and automated verification guidance
|
||||
- [CHANGELOG.md](CHANGELOG.md) for release history context. Do not edit it in
|
||||
ordinary contributor PRs; include release-note-ready wording in the PR body
|
||||
so maintainers can carry it into the release workflow.
|
||||
|
||||
## Project-Specific Guidelines
|
||||
|
||||
### Preserve the Design Constraints
|
||||
|
||||
Hermes WebUI is deliberately:
|
||||
|
||||
- No build step
|
||||
- No bundler
|
||||
- No frontend framework
|
||||
- Easy to modify from a terminal
|
||||
|
||||
Do not introduce new infrastructure or dependencies unless the gain is clear and the tradeoff is justified.
|
||||
|
||||
### Match the Existing Shape of the Codebase
|
||||
|
||||
- Server logic belongs in `api/` with `server.py` staying thin
|
||||
- Frontend behavior belongs in the existing `static/*.js` modules
|
||||
- Prefer extending current patterns over introducing parallel abstractions
|
||||
- Keep changes legible to future contributors working directly from the repo in a terminal
|
||||
|
||||
### Be Careful With User-Facing Changes
|
||||
|
||||
This project is heavily UI-driven. If you change interaction flows, session behavior, workspace browsing, onboarding, or mobile layouts:
|
||||
|
||||
- test the happy path
|
||||
- test reload behavior where relevant
|
||||
- test narrow/mobile layouts where relevant
|
||||
- include before/after images in the PR
|
||||
|
||||
### Security and Safety Matter
|
||||
|
||||
This app can expose workspace contents, run agent actions, and optionally sit behind a reverse proxy or Docker deployment. Treat auth, path handling, uploads, streaming, and environment handling as high-risk areas.
|
||||
|
||||
If your PR touches security-sensitive behavior, say so explicitly in the PR description and explain how you verified it.
|
||||
|
||||
## Writing a Good PR Message
|
||||
|
||||
Start with a short Thinking Path that explains the chain from project goal to the specific fix.
|
||||
|
||||
Example:
|
||||
|
||||
> - Hermes WebUI aims for near 1:1 parity with the Hermes CLI in a browser
|
||||
> - Long-running chat turns rely on SSE streaming and session recovery
|
||||
> - Reloading during an in-flight turn can leave the UI in an inconsistent state
|
||||
> - The bug was that recovered sessions restored messages but not the live stream state
|
||||
> - This PR fixes the recovery path so in-flight turns reconnect cleanly after reload
|
||||
> - The benefit is that users can refresh or reconnect without losing visibility into active work
|
||||
|
||||
Another example:
|
||||
|
||||
> - Hermes WebUI is intentionally a simple Python + vanilla JS application
|
||||
> - The right panel is used for workspace browsing and previews
|
||||
> - On mobile, panel state changes need to be obvious and touch-friendly
|
||||
> - The existing close affordance was inconsistent with the bottom-nav flow
|
||||
> - This PR fixes the mobile panel close behavior and aligns it with the current navigation model
|
||||
> - The result is fewer dead-end UI states on phones
|
||||
|
||||
After that, cover:
|
||||
|
||||
- what you changed
|
||||
- why you changed it
|
||||
- how you verified it
|
||||
- what risks remain
|
||||
|
||||
## Review Tips
|
||||
|
||||
Want the smoothest review?
|
||||
|
||||
- Keep diffs tight
|
||||
- Name things clearly
|
||||
- Avoid unnecessary rewrites
|
||||
- Add short comments only where the code would otherwise be hard to follow
|
||||
- Respond directly to review feedback and update the PR description if the scope changes
|
||||
|
||||
## Development References
|
||||
|
||||
- [README.md](README.md)
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- [TESTING.md](TESTING.md)
|
||||
- [ROADMAP.md](ROADMAP.md)
|
||||
- [SPRINTS.md](SPRINTS.md)
|
||||
|
||||
Questions are best raised early, before a large change is finished.
|
||||
-154
File diff suppressed because one or more lines are too long
@@ -1,181 +0,0 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Hermes Calm Console
|
||||
description: "A restrained agent control surface: conversational content first, tool traces as quiet metadata, minimal chrome."
|
||||
colors:
|
||||
primary: "#EAE0D5"
|
||||
secondary: "#C6AC8F"
|
||||
tertiary: "#C6AC8F"
|
||||
neutral: "#0A0908"
|
||||
surface: "#22333B"
|
||||
surfaceSubtle: "#11100E"
|
||||
borderSubtle: "#3B4A50"
|
||||
ink: "#0A0908"
|
||||
success: "#86C08B"
|
||||
warning: "#E0B15D"
|
||||
error: "#F87171"
|
||||
typography:
|
||||
body-md:
|
||||
fontFamily: "Georgia, Times New Roman, serif"
|
||||
fontSize: 15px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.68
|
||||
body-sm:
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif"
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.45
|
||||
user-message:
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, Inter, system-ui, sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.55
|
||||
mono-xs:
|
||||
fontFamily: "SF Mono, ui-monospace, monospace"
|
||||
fontSize: 11px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.55
|
||||
rounded:
|
||||
sm: 4px
|
||||
md: 8px
|
||||
lg: 12px
|
||||
pill: 999px
|
||||
spacing:
|
||||
xs: 4px
|
||||
sm: 8px
|
||||
md: 12px
|
||||
lg: 16px
|
||||
components:
|
||||
app-shell:
|
||||
backgroundColor: "{colors.neutral}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 16px
|
||||
panel:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 16px
|
||||
border-line:
|
||||
backgroundColor: "{colors.borderSubtle}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-success:
|
||||
backgroundColor: "{colors.success}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-warning:
|
||||
backgroundColor: "{colors.warning}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
state-error:
|
||||
backgroundColor: "{colors.error}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 4px
|
||||
tool-call-group:
|
||||
backgroundColor: "{colors.neutral}"
|
||||
textColor: "{colors.secondary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 4px
|
||||
tool-card:
|
||||
backgroundColor: "{colors.surfaceSubtle}"
|
||||
textColor: "{colors.secondary}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 8px
|
||||
user-message:
|
||||
backgroundColor: "{colors.tertiary}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 12px
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hermes WebUI should feel like a calm developer console, not a demo page assembled from colorful cards. The primary artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, and runtime status are useful, but they are transcript metadata and should sit below the visual priority of user and assistant prose.
|
||||
|
||||
The desired direction is Linear/Vercel precision with a little Claude-style conversational warmth: quiet surfaces, clear spacing, restrained accent use, and progressive disclosure for debugging detail.
|
||||
|
||||
## Colors
|
||||
|
||||
- **Primary (#EAE0D5):** main text on dark surfaces. The warm parchment should feel readable and grounded, not like bright white terminal text.
|
||||
- **Secondary/Tertiary (#C6AC8F):** metadata and restrained accent. Use sparingly for active state, focus, user bubbles, and quiet emphasis.
|
||||
- **Neutral (#0A0908):** app background and ink. This gives the WebUI depth without returning to the previous navy/gold theme.
|
||||
- **Surface (#22333B):** panels, sidebar, and stronger interactive surfaces. It should carry the structure while the conversation remains primary.
|
||||
- **Light surfaces (#EAE0D5 / #F4EEE7):** light mode uses the palette's parchment as the field and a slightly lifted derived surface for panels.
|
||||
- **Semantic colors:** success/warning/error/info are state colors only, not decorative palette choices.
|
||||
|
||||
## Typography
|
||||
|
||||
Use Claude-like split typography: assistant prose gets an editorial serif stack (Georgia as the available substitute for Anthropic Serif), while user bubbles and functional UI stay in a crisp sans stack. This keeps the bot voice calmer and more readable without making controls feel bookish. Use monospace only for code, file paths, commands, tool names, and compact metadata. Avoid making whole cards feel like terminal output unless they actually are logs.
|
||||
|
||||
Scale should stay tight: 11px metadata, 12px labels, 14px body, 16–18px headings. Do not proliferate 10px/10.5px/12.5px one-offs unless there is a real layout constraint.
|
||||
|
||||
## Layout
|
||||
|
||||
Conversation rhythm:
|
||||
|
||||
1. User message — right aligned, compact bubble.
|
||||
2. Assistant content — left aligned, prose-first, no heavy bubble.
|
||||
3. Tool/thinking/context traces — quiet disclosure rows inside the assistant turn.
|
||||
4. Raw logs/details — hidden until explicitly expanded.
|
||||
|
||||
Metadata should not break the reading flow. A turn that used ten tools should read as one assistant turn with one compact `Used 10 tools` disclosure, not ten content cards.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Use almost no shadows in the transcript. Shadows are reserved for popovers, dropdowns, modal dialogs, and floating controls. Cards inside chat should use either a subtle border or a subtle tint, not both aggressively.
|
||||
|
||||
## Shapes
|
||||
|
||||
- Rows/list items: `4–8px` radius.
|
||||
- Cards/panels: `8–12px` radius.
|
||||
- Pills: only true chips/badges use `999px`.
|
||||
- Avoid stacks of nested rounded rectangles. If a card contains another card, one of them is probably unnecessary.
|
||||
|
||||
## Components
|
||||
|
||||
### Tool/thinking activity group
|
||||
|
||||
Collapsed by default in settled history and during live runs unless the user has explicitly opened that Activity row before. Persist open/closed disclosure state per chat and per turn, so switching away from a chat and coming back preserves the mode the user left it in. Summary line uses one disclosure for internals and stays intentionally terse, e.g. `Activity: 4 tools`. It should not repeat the always-present thinking area, list individual tool names, or add a second trailing count badge. Expanding reveals thinking and individual tool cards together. Thinking and tools should not create separate transcript rows unless there is an error or approval state that needs attention.
|
||||
|
||||
### Tool card
|
||||
|
||||
A tool card is a debug event row, not a chat message. Show icon, name, short target/preview, and status. Arguments and result snippets stay behind expansion. Result snippets should be truncated; full logs belong behind “show more”.
|
||||
|
||||
### Thinking/context cards
|
||||
|
||||
Same visual family as tool-call metadata. They should be quieter than assistant prose and should not use bright tinted full cards unless the user expands them.
|
||||
|
||||
Automatic compression follows a quiet live-only divider treatment rather than a
|
||||
tool-card row. Use `Compressing context` for the active barrier and
|
||||
`Context auto-compressed` after continuation/completion; render both as centered
|
||||
non-interactive text with horizontal rules. Do not give it a caret, click
|
||||
target, distinct accent color, special leading dot, or separate card identity.
|
||||
Once the final answer is settled, omit the live-only compression row unless it is
|
||||
needed to explain a visible recovery or error state.
|
||||
|
||||
### Composer
|
||||
|
||||
The composer is the command surface. Keep it legible and focused: modest radius, subtle border, transparent inactive chips, no theatrical hover scaling.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
Do:
|
||||
|
||||
- Collapse noisy agent internals by default.
|
||||
- Use one accent color at a time.
|
||||
- Prefer neutral borders and restrained surfaces.
|
||||
- Make debug traces accessible and inspectable without making them visually dominant.
|
||||
- Add stable class/data hooks for future visual regression tests.
|
||||
|
||||
Don't:
|
||||
|
||||
- Render every tool call as a first-class chat card.
|
||||
- Mix gold, cyan, purple, orange, red, and green as decorative colors in the same viewport.
|
||||
- Add new hardcoded radius/color values when a token exists.
|
||||
- Use shadows, gradients, and hover transforms for routine controls.
|
||||
- Hide important error or approval states; those are allowed to be prominent because they require action.
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
LABEL maintainer="nesquena"
|
||||
LABEL description="Hermes Web UI — browser interface for Hermes Agent"
|
||||
|
||||
# Install system packages
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Make use of apt-cacher-ng if available
|
||||
RUN if [ "A${BUILD_APT_PROXY:-}" != "A" ]; then \
|
||||
echo "Using APT proxy: ${BUILD_APT_PROXY}"; \
|
||||
printf 'Acquire::http::Proxy "%s";\n' "$BUILD_APT_PROXY" > /etc/apt/apt.conf.d/01proxy; \
|
||||
fi \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
RUN apt-get update -y --fix-missing --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
apt-utils \
|
||||
locales \
|
||||
ca-certificates \
|
||||
curl \
|
||||
rsync \
|
||||
openssh-client \
|
||||
git \
|
||||
xz-utils \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── SQLite upgrade ──────────────────────────────────────────────────────────
|
||||
# The python:3.12-slim base ships SQLite 3.46.1 (Debian Trixie), which is
|
||||
# vulnerable to the WAL-reset corruption bug discovered March 2026.
|
||||
# https://sqlite.org/wal.html#walresetbug
|
||||
#
|
||||
# Debian has not backported the fix, so we compile from the amalgamation.
|
||||
# Installs to /usr/local/lib (registered in ld.so.conf.d for arm64 priority).
|
||||
# Build tools are purged after compilation to keep the image lean.
|
||||
# Build args are for forward version bumps only (3.54+, etc.).
|
||||
# When bumping SQLITE_VERSION, recompute the SHA-256 from the official
|
||||
# download and update SQLITE_SHA256 accordingly.
|
||||
ARG SQLITE_VERSION=3530000
|
||||
ARG SQLITE_YEAR=2026
|
||||
ARG SQLITE_SHA256=851e9b38192fe2ceaa65e0baa665e7fa06230c3d9bd1a6a9662d02380d73365a
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc make libc6-dev \
|
||||
&& cd /tmp \
|
||||
&& curl -fsSL "https://sqlite.org/${SQLITE_YEAR}/sqlite-autoconf-${SQLITE_VERSION}.tar.gz" \
|
||||
-o sqlite.tar.gz \
|
||||
&& echo "${SQLITE_SHA256} sqlite.tar.gz" | sha256sum -c - \
|
||||
&& tar xzf sqlite.tar.gz \
|
||||
&& cd "sqlite-autoconf-${SQLITE_VERSION}" \
|
||||
&& CPPFLAGS="-DSQLITE_SECURE_DELETE" ./configure --prefix=/usr/local --disable-static --disable-readline \
|
||||
--enable-fts5 --enable-fts4 --enable-rtree \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install \
|
||||
&& echo "/usr/local/lib" > /etc/ld.so.conf.d/000-usr-local-lib.conf \
|
||||
&& /sbin/ldconfig \
|
||||
&& cd / && rm -rf /tmp/sqlite* \
|
||||
&& apt-get purge -y gcc make libc6-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -c "\
|
||||
import sqlite3; \
|
||||
v = sqlite3.sqlite_version; \
|
||||
assert tuple(int(x) for x in v.split('.')) >= (3, 51, 3), \
|
||||
f'SQLite {v} still vulnerable'; \
|
||||
c = sqlite3.connect(':memory:'); \
|
||||
assert c.execute('PRAGMA secure_delete').fetchone()[0] == 1, \
|
||||
'SQLITE_SECURE_DELETE not compiled in (deleted rows would remain recoverable)'; \
|
||||
c.execute('CREATE VIRTUAL TABLE _fts5_build_check USING fts5(x)'); \
|
||||
c.execute('DROP TABLE _fts5_build_check'); \
|
||||
c.close()"
|
||||
|
||||
# Optional GPU user-space acceleration libraries for users who pass through
|
||||
# host GPU devices. The default image remains CPU-only.
|
||||
ARG INSTALL_GPU_LIBS=0
|
||||
RUN if [ "$INSTALL_GPU_LIBS" = "1" ]; then \
|
||||
apt-get update -y --fix-missing --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libva2 \
|
||||
vainfo \
|
||||
mesa-va-drivers \
|
||||
&& if apt-cache show intel-media-va-driver-non-free >/dev/null 2>&1; then \
|
||||
apt-get install -y --no-install-recommends intel-media-va-driver-non-free; \
|
||||
else \
|
||||
echo "intel-media-va-driver-non-free is not available from the configured Debian repositories; skipping Intel non-free VA-API driver."; \
|
||||
fi \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
else \
|
||||
echo "Skipping optional GPU user-space acceleration libraries (INSTALL_GPU_LIBS=0)."; \
|
||||
fi
|
||||
|
||||
# UTF-8
|
||||
RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
|
||||
ENV LANG=en_US.utf8
|
||||
ENV LC_ALL=C
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONIOENCODING=utf-8
|
||||
|
||||
WORKDIR /apptoo
|
||||
|
||||
# Create the unprivileged runtime user. The entrypoint starts as root only for
|
||||
# UID/GID alignment and filesystem preparation, then execs the server as this user.
|
||||
RUN groupadd -g 1024 hermeswebui \
|
||||
&& useradd -u 1024 -d /home/hermeswebui -g hermeswebui -G users -s /bin/bash -m hermeswebui \
|
||||
&& mkdir -p /app /uv_cache /workspace \
|
||||
&& chown -R hermeswebui:hermeswebui /home/hermeswebui /app /uv_cache /workspace \
|
||||
&& chmod 0755 /home/hermeswebui \
|
||||
&& chmod 1777 /app /uv_cache /workspace
|
||||
|
||||
COPY --chmod=555 docker_init.bash /hermeswebui_init.bash
|
||||
|
||||
RUN touch /.within_container
|
||||
|
||||
# Remove APT proxy configuration and clean up APT downloaded files
|
||||
RUN rm -rf /var/lib/apt/lists/* /etc/apt/apt.conf.d/01proxy \
|
||||
&& apt-get clean
|
||||
|
||||
USER root
|
||||
|
||||
# Pre-install uv system-wide so the container doesn't need internet access at runtime.
|
||||
# Installing as root places uv in /usr/local/bin, available to all users.
|
||||
# The init script will skip the download when uv is already on PATH.
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
|
||||
|
||||
COPY --chown=root:root . /apptoo
|
||||
|
||||
# Bake the git version tag into the image so the settings badge works even
|
||||
# when .git is not present (it is excluded by .dockerignore).
|
||||
# CI passes: --build-arg HERMES_VERSION=$(git describe --tags --always)
|
||||
# Local builds that omit the arg get "unknown" as the fallback.
|
||||
ARG HERMES_VERSION=unknown
|
||||
RUN echo "__version__ = '${HERMES_VERSION}'" > /apptoo/api/_version.py
|
||||
|
||||
# Default to binding all interfaces (required for container networking)
|
||||
ENV HERMES_WEBUI_HOST=0.0.0.0
|
||||
ENV HERMES_WEBUI_PORT=8787
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=8s --start-period=10s --retries=3 \
|
||||
CMD bash /apptoo/scripts/lib/health_probe.sh localhost 8787 /health 2 >/dev/null || exit 1
|
||||
|
||||
# docker_init.bash performs root-only bind-mount setup, then drops to hermeswebui
|
||||
# before starting the WebUI server. The production image does not ship sudo.
|
||||
USER root
|
||||
CMD ["/hermeswebui_init.bash"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Hermes Web UI Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,839 +0,0 @@
|
||||
# Hermes Web UI
|
||||
|
||||
[Hermes Agent](https://hermes-agent.nousresearch.com/) is a sophisticated autonomous agent that lives on your server, accessed via a terminal or messaging apps, that remembers what it learns and gets more capable the longer it runs.
|
||||
|
||||
Hermes WebUI is a lightweight, dark-themed web app interface in your browser for [Hermes Agent](https://hermes-agent.nousresearch.com/).
|
||||
Full parity with the CLI experience - everything you can do from a terminal, you can do from this UI. No build step, no framework, no bundler. Just Python and vanilla JS.
|
||||
|
||||
Layout: three-panel. Left sidebar for sessions and navigation, center for chat,
|
||||
right for workspace file browsing. Model, profile, and workspace controls live in
|
||||
the **composer footer** — always visible while composing. A circular context ring
|
||||
shows token usage at a glance. All settings and session tools are in the
|
||||
**Hermes Control Center** (launcher at the sidebar bottom).
|
||||
|
||||
Setup Hermes so you can access it natively on every device:
|
||||
|
||||
<img width="1467" height="881" alt="image" src="https://github.com/user-attachments/assets/9a72cdf3-a5b4-45ed-a836-a715ce46287e" />
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img width="2940" height="1848" alt="Light mode with full profile support" src="https://github.com/user-attachments/assets/4ef3a59c-7a66-4705-b4e7-cb9148fe4c47" />
|
||||
<br /><sub>Light mode with full profile support</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Customize your settings, configure a password" src="https://github.com/user-attachments/assets/941f3156-21e3-41fd-bcc8-f975d5000cb8" />
|
||||
<br /><sub>Customize your settings, configure a password</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Workspace file browser with inline preview" src="docs/images/ui-workspace.png" />
|
||||
<br /><sub>Workspace file browser with inline preview</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Session projects, tags, and tool call cards" src="docs/images/ui-sessions.png" />
|
||||
<br /><sub>Session projects, tags, and tool call cards</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** which you can access securely through an SSH tunnel from your Hermes setup. Single command to start this up, and a single command to SSH tunnel for access on your computer. Every single part of the web UI uses your existing Hermes agent and existing models, without requiring any additional setup.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
[<img width="750" alt="image" src="https://github.com/user-attachments/assets/7e9544a7-ba47-4fc7-8142-1d9d16b17065" />
|
||||
](https://get-hermes.ai/setup/)
|
||||
|
||||
- [Why Hermes](#why-hermes) — what it is and how it compares
|
||||
- [Quick start](#quick-start) — clone + `bootstrap.py` / `start.sh` / `ctl.sh`
|
||||
- [Features](#features) — chat, sessions, workspace, voice, profiles, security, themes, panels, mobile
|
||||
- [Configuration & access](#configuration--access) — auto-discovery, overrides, remote/Tailscale/phone, manual launch
|
||||
- [Nix flake/module](#nix-flake-and-nixos-module) — declarative install and service
|
||||
- [Docker](#docker) — single- and multi-container deploys
|
||||
- [Running tests](#running-tests)
|
||||
- [Architecture](#architecture) — backend/frontend layout, state dir
|
||||
- [Docs](#docs) — the full documentation index
|
||||
- [Contributors](#contributors)
|
||||
|
||||
---
|
||||
|
||||
## Why Hermes
|
||||
|
||||
Most AI tools reset every session. They don't know who you are, what you worked on, or what
|
||||
conventions your project follows. You re-explain yourself every time.
|
||||
|
||||
Hermes retains context across sessions, runs scheduled jobs while you're offline, and gets
|
||||
smarter about your environment the longer it runs. It uses your existing Hermes agent setup,
|
||||
your existing models, and requires no additional configuration to start.
|
||||
|
||||
What makes it different from other agentic tools:
|
||||
|
||||
- **Persistent memory** — user profile, agent notes, and a skills system that saves reusable
|
||||
procedures; Hermes learns your environment and does not have to relearn it
|
||||
- **Self-hosted scheduling** — cron jobs that fire while you're offline and deliver results to
|
||||
Telegram, Discord, Slack, Signal, email, and more
|
||||
- **10+ messaging platforms** — the same agent available in the terminal is reachable from your phone
|
||||
- **Self-improving skills** — Hermes writes and saves its own skills automatically from experience;
|
||||
no marketplace to browse, no plugins to install
|
||||
- **Provider-agnostic** — OpenAI, Anthropic, Google, DeepSeek, OpenRouter, and more
|
||||
- **Orchestrates other agents** — can spawn Claude Code or Codex for heavy coding tasks and bring
|
||||
the results back into its own memory
|
||||
- **Self-hosted** — your conversations, your memory, your hardware
|
||||
|
||||
**vs. the field** *(landscape is actively shifting — see [docs/why-hermes.md](docs/why-hermes.md) for the full breakdown)*:
|
||||
|
||||
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Hermes |
|
||||
|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | Yes |
|
||||
| Scheduled jobs (self-hosted) | Yes | No‡ | No | No | Yes |
|
||||
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview) | No | No | Yes (10+) |
|
||||
| Web UI (self-hosted) | Dashboard only | No | No | Yes | Yes |
|
||||
| Self-improving skills | Partial | No | No | No | Yes |
|
||||
| Python / ML ecosystem | No (Node.js) | No | No | No | Yes |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Yes |
|
||||
| Open source | Yes (MIT) | No | Yes | Yes | Yes |
|
||||
|
||||
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
|
||||
‡ Claude Code has cloud-managed scheduling (Anthropic infrastructure) and session-scoped `/loop`; no self-hosted cron
|
||||
|
||||
**The closest competitor is OpenClaw** — both are always-on, self-hosted, open-source agents
|
||||
with memory, cron, and messaging. The key differences: Hermes writes and saves its own skills
|
||||
automatically as a core behavior (OpenClaw's skill system centers on a community marketplace);
|
||||
Hermes is more stable across updates (OpenClaw has documented release regressions and ClawHub
|
||||
has had security incidents involving malicious skills); and Hermes runs natively in the Python
|
||||
ecosystem. See [docs/why-hermes.md](docs/why-hermes.md) for the full side-by-side.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Run the repo bootstrap:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nesquena/hermes-webui.git hermes-webui
|
||||
cd hermes-webui
|
||||
python3 bootstrap.py
|
||||
```
|
||||
|
||||
Or keep using the shell launcher:
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
For self-hosted VM or homelab installs, `ctl.sh` wraps the common daemon lifecycle commands without requiring `fuser` or `pkill`:
|
||||
|
||||
```bash
|
||||
./ctl.sh start # background daemon, PID at ~/.hermes/webui.pid
|
||||
./ctl.sh status # PID, uptime, bound host/port, log path, /health
|
||||
./ctl.sh logs --lines 100 # tail ~/.hermes/webui.log
|
||||
./ctl.sh restart
|
||||
./ctl.sh stop
|
||||
```
|
||||
|
||||
`ctl.sh start` runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to `~/.hermes/webui.log`, and respects `.env` plus inline overrides such as `HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start`.
|
||||
|
||||
> **Stopping the server.** Each launch method has its own stop path because only `ctl.sh start` writes a PID file (`~/.hermes/webui.pid`):
|
||||
>
|
||||
> | Launch method | How to stop |
|
||||
> |---|---|
|
||||
> | `python3 bootstrap.py` | **Ctrl-C** in the terminal (runs in the foreground) |
|
||||
> | `./ctl.sh start` | `./ctl.sh stop` (sends SIGTERM, waits, then SIGKILL) |
|
||||
> | Detached `bootstrap.py` (no `--foreground`) or `./start.sh` | Find the PID via `lsof -i :8787` (or `ss -tlnp`) and `kill` it |
|
||||
>
|
||||
> `./ctl.sh stop` cannot stop a server launched by `bootstrap.py` or `start.sh` directly — it only manages processes it started itself.
|
||||
|
||||
> **How chat runs by default.** WebUI runs the Hermes agent in-process, reading
|
||||
> your `HERMES_HOME` config directly. It does not connect to an external
|
||||
> Hermes/agent OpenAI-compatible API server to run chat. `HERMES_API_URL` is only
|
||||
> read by the Tasks/cron health probe and does not route chat.
|
||||
>
|
||||
> Two options if you run an external endpoint:
|
||||
>
|
||||
> 1. **Use its models as a chat provider** (supported today): add it in
|
||||
> **Settings → Providers** as a custom OpenAI-compatible provider with
|
||||
> `base_url = http://127.0.0.1:8642/v1` and your bearer token.
|
||||
> 2. **Route chat through a Hermes Gateway API server** (supported today via
|
||||
> `HERMES_WEBUI_CHAT_BACKEND=gateway`): see [`docs/advanced-chat-setup.md`](docs/advanced-chat-setup.md).
|
||||
> Full agent-loop delegation is not yet shipped; tracked in [#1925](https://github.com/nesquena/hermes-webui/issues/1925).
|
||||
|
||||
### Advanced: dynamic recall prefill & Gateway-backed chat
|
||||
|
||||
Two optional, self-hosted-deployment features — attaching dynamic **session-recall prefill** to browser turns (Joplin/Obsidian/Notion/llm-wiki routers), and routing browser chat through a running **Hermes Gateway** — are documented in [`docs/advanced-chat-setup.md`](docs/advanced-chat-setup.md). Most users need neither.
|
||||
|
||||
The bootstrap will:
|
||||
|
||||
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
|
||||
2. Find or create a Python environment with the WebUI dependencies.
|
||||
3. Start the web server and wait for `/health`.
|
||||
4. Open the browser unless you pass `--no-browser`.
|
||||
5. Drop you into a first-run onboarding wizard inside the WebUI.
|
||||
|
||||
> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
|
||||
> For Windows / WSL auto-start at login, see [`docs/wsl-autostart.md`](docs/wsl-autostart.md).
|
||||
|
||||
A community-maintained native Windows setup is documented at [@markwang2658/hermes-windows-native-guide](https://github.com/markwang2658/hermes-windows-native-guide) (companion setup repo: [@markwang2658/hermes-windows-native](https://github.com/markwang2658/hermes-windows-native)). Notes from the community report in [#1952](https://github.com/nesquena/hermes-webui/issues/1952):
|
||||
|
||||
- **Memory:** community-measured ~330 MB native vs ~1080 MB with WSL2+Docker (varies by configuration).
|
||||
- **What works:** chat, workspace browser, session management, all themes.
|
||||
- **Known limitations:** some POSIX-style file paths surface in the workspace browser; bash-assuming agent tools may not work natively.
|
||||
- **Native Windows setup:** install Python 3.11+, then from the hermes-agent root in PowerShell: `python -m venv venv` → `pip install -r requirements.txt` → `pwsh .\start.ps1` (it auto-discovers `venv\Scripts\python.exe`).
|
||||
- **WSL2 relationship:** not a prerequisite — a WSL2-built venv (`venv/bin/python`, ELF) isn't invokable by native Windows Python, so use the native setup above. WSL2 stays useful as a parallel install if you want the full `bootstrap.py` + Linux runtime.
|
||||
|
||||
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser.
|
||||
For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see [`docs/onboarding.md`](docs/onboarding.md).
|
||||
If an AI assistant is helping with install, reinstall, bootstrap, provider setup, or first-run support, have it read [`docs/onboarding-agent-checklist.md`](docs/onboarding-agent-checklist.md) before running commands or inspecting logs.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Chat and agent
|
||||
- Streaming responses via SSE (tokens appear as they are generated)
|
||||
- Multi-provider model support -- any Hermes API provider (OpenAI, Anthropic, Google, DeepSeek, Nous Portal, OpenRouter, MiniMax, Xiaomi MiMo, Z.AI); dynamic model dropdown populated from configured keys
|
||||
- Send a message while one is processing -- it queues automatically
|
||||
- Edit any past user message inline and regenerate from that point
|
||||
- Retry the last assistant response with one click
|
||||
- Cancel a running task directly from the composer footer (Stop button next to Send)
|
||||
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
|
||||
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border
|
||||
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
|
||||
- Thinking/reasoning display -- collapsible gold-themed cards for Claude extended thinking and o3 reasoning blocks
|
||||
- Approval card for dangerous shell commands (allow once / session / always / deny)
|
||||
- SSE auto-reconnect on network blips (SSH tunnel resilience)
|
||||
- File attachments persist across page reloads and are stored outside the active workspace by default (`~/.hermes/webui/attachments/<session_id>/`, or `HERMES_WEBUI_ATTACHMENT_DIR/<session_id>/` when configured)
|
||||
- Message timestamps (HH:MM next to each message, full date on hover)
|
||||
- Code block copy button with "Copied!" feedback
|
||||
- Syntax highlighting via Prism.js (Python, JS, bash, JSON, SQL, and more)
|
||||
- Safe HTML rendering in AI responses (bold, italic, code converted to markdown)
|
||||
- rAF-throttled token streaming for smoother rendering during long responses
|
||||
- Context usage indicator in composer footer -- token count, cost, and fill bar (model-aware)
|
||||
|
||||
### Sessions
|
||||
- Create, rename, duplicate, delete, search by title and message content
|
||||
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
|
||||
- Pin/star sessions to the top of the sidebar (gold indicator)
|
||||
- Archive sessions (hide without deleting, toggle to show)
|
||||
- Session projects -- named groups with colors for organizing sessions
|
||||
- Session tags -- add #tag to titles for colored chips and click-to-filter
|
||||
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
|
||||
- Download as Markdown transcript, full JSON export, or import from JSON
|
||||
- Create a public read-only share link for the active conversation from the Control Center; shared pages show a sanitized transcript snapshot without workspace, profile, or live controls
|
||||
- Sessions persist across page reloads and SSH tunnel reconnects
|
||||
- Browser tab title reflects the active session name
|
||||
- CLI session bridge -- CLI sessions from hermes-agent's SQLite store appear in the sidebar with a gold "cli" badge; click to import with full history and reply normally
|
||||
- Token/cost display -- input tokens, output tokens, estimated cost shown per conversation (toggle in Settings or `/usage` command)
|
||||
|
||||
### Workspace file browser
|
||||
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
|
||||
- Breadcrumb navigation with clickable path segments
|
||||
- Preview text, code, Markdown (rendered), and images inline
|
||||
- Chat links using `workspace://path/to/file` open files in the right-side preview pane
|
||||
- Edit, create, delete, and rename files; create folders
|
||||
- Binary file download (auto-detected from server)
|
||||
- File preview auto-closes on directory navigation (with unsaved-edit guard)
|
||||
- Git detection -- branch name and dirty file count badge in workspace header
|
||||
- Right panel is drag-resizable
|
||||
- Syntax highlighted code preview (Prism.js)
|
||||
|
||||
### Voice input
|
||||
- Microphone button in the composer (Web Speech API)
|
||||
- Tap to record, tap again or send to stop
|
||||
- Live interim transcription appears in the textarea
|
||||
- Auto-stops after ~2s of silence
|
||||
- Appends to existing textarea content (doesn't replace)
|
||||
- Hidden when browser doesn't support Web Speech API (Chrome, Edge, Safari)
|
||||
|
||||
### Profiles
|
||||
- Profile chip in the **composer footer** -- dropdown showing all profiles with gateway status and model info
|
||||
- Gateway status dots (green = running), model info, skill count per profile
|
||||
- Profiles management panel -- create, switch, and delete profiles from the sidebar
|
||||
- Clone config from active profile on create
|
||||
- Optional custom endpoint fields on create -- Base URL and API key written into the profile's `config.yaml` at creation time, so Ollama, LMStudio, and other local endpoints can be configured without editing files manually
|
||||
- Seamless switching -- no server restart; reloads config, skills, memory, cron, models
|
||||
- Per-session profile tracking (records which profile was active at creation)
|
||||
|
||||
### Authentication and security
|
||||
- Optional password auth -- off by default, zero friction for localhost
|
||||
- Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel
|
||||
- Installed PWAs work best with WebUI's own password. Reverse proxies are supported, but proxy basic auth can block the service-worker update fetches an installed app needs and leave it on a blank screen after an update; see `docs/troubleshooting.md` for recovery steps.
|
||||
- Optional passkeys/WebAuthn -- register from Settings -> System after signing in with a password; the login page only shows passkey sign-in after at least one passkey exists
|
||||
- After registering at least one passkey, Settings -> System can remove the password and keep passkey-only sign-in enabled. Password auth remains the bootstrap/recovery path until you choose to go passwordless; passkeys are same-origin and stored locally in the WebUI state directory
|
||||
- Optional native OIDC login for WebUI sessions -- configure `webui_oidc.issuer`, `client_id`, `allow_claim`, and `allow_values` in `config.yaml`, or set the matching `HERMES_WEBUI_OIDC_*` environment variables. OIDC stays disabled until all four are present, and startup prints a warning if the config is partial.
|
||||
- Native OIDC stores the PKCE/state nonce flow in process memory. That works for the shipped single-process server, and it also works behind a load balancer when callbacks stay sticky to the same WebUI instance. Multi-instance deployments need session affinity, or the callback can land on a different process and fail state validation.
|
||||
- Signed HMAC HTTP-only cookie with 24h TTL
|
||||
- Minimal dark-themed login page at `/login`
|
||||
- Security headers on all responses (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
|
||||
- 20MB POST body size limit
|
||||
- CDN resources pinned with SRI integrity hashes
|
||||
|
||||
### Themes
|
||||
- Appearance is split into two axes: Theme (`system`, `dark`, `light`) and Skin
|
||||
(`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`, `charizard`,
|
||||
`sienna`, `catppuccin`, `nous`, `geist-contrast` / Geist Contrast)
|
||||
- Switch via Settings -> Appearance (instant live preview) or `/theme <theme-or-skin>`
|
||||
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
|
||||
- Skins use `data-skin` plus CSS variables; dark mode resolves through the
|
||||
`.dark` class, not a `data-theme` custom-theme axis — see [THEMES.md](THEMES.md)
|
||||
|
||||
### Settings and configuration
|
||||
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
|
||||
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
||||
- Show/hide CLI sessions toggle (enabled by default)
|
||||
- Token usage display toggle (off by default, also via `/usage` command)
|
||||
- Control Center always opens on the Conversation tab; resets on close
|
||||
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
|
||||
- Cron completion alerts -- toast notifications and unread badges scoped to the active profile on the Tasks tab and session sidebar
|
||||
- Background agent error alerts -- banner when a non-active session encounters an error
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Plain skills match case-insensitive keywords in their name or description; built-in, agent/plugin, and bundle commands keep prefix matching and take precedence over a same-slug skill
|
||||
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
### Panels
|
||||
- **Chat** -- session list, search, pin, archive, projects, new conversation
|
||||
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; run history; completion alerts
|
||||
- **Skills** -- list all skills by category, search, preview, create/edit/delete; linked files viewer
|
||||
- **Memory** -- view and edit MEMORY.md and USER.md inline
|
||||
- **Profiles** -- create, switch, delete agent profiles; clone config
|
||||
- **Todos** -- live task list from the current session
|
||||
- **Spaces** -- add, rename, remove workspaces; quick-switch from topbar
|
||||
|
||||
### Mobile responsive
|
||||
- Hamburger sidebar -- slide-in overlay on mobile (<640px)
|
||||
- Sidebar top tabs stay available on mobile; no fixed bottom nav stealing chat height
|
||||
- Files slide-over panel from right edge
|
||||
- Touch targets minimum 44px on all interactive elements
|
||||
- Full-height chat/composer on phones without bottom-nav spacing
|
||||
- Desktop layout completely unchanged
|
||||
|
||||
---
|
||||
|
||||
## Configuration & access
|
||||
|
||||
`start.sh` auto-detects almost everything; the subsections below cover the knobs for when it can't, and how to reach the UI remotely.
|
||||
|
||||
### What start.sh discovers automatically
|
||||
|
||||
| Thing | How it finds it |
|
||||
|---|---|
|
||||
| Hermes agent dir | `HERMES_WEBUI_AGENT_DIR`, then known checkout paths, the `hermes` launcher on `PATH`, and finally the installed `run_agent` module exposed by `HERMES_WEBUI_PYTHON` |
|
||||
| Python executable | Agent venv first, then `.venv` in this repo, then system `python3` |
|
||||
| State directory | `HERMES_WEBUI_STATE_DIR` env, then `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) |
|
||||
| Default workspace | `HERMES_WEBUI_DEFAULT_WORKSPACE` env, then `~/workspace`, then state dir |
|
||||
| Port | `HERMES_WEBUI_PORT` env or first argument, default `8787` |
|
||||
|
||||
If discovery finds everything, nothing else is required.
|
||||
|
||||
---
|
||||
|
||||
### Overrides (only needed if auto-detection misses)
|
||||
|
||||
```bash
|
||||
export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent
|
||||
export HERMES_WEBUI_PYTHON=/path/to/python
|
||||
export HERMES_WEBUI_PORT=9000
|
||||
export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default)
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Or inline:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_AGENT_DIR=/custom/path ./start.sh 9000
|
||||
```
|
||||
|
||||
Full list of environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `HERMES_WEBUI_AGENT_DIR` | auto-discovered | Path to the Hermes Agent source or installed module root |
|
||||
| `HERMES_WEBUI_PYTHON` | auto-discovered | Python executable |
|
||||
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) |
|
||||
| `HERMES_WEBUI_PORT` | `8787` | Port |
|
||||
| `HERMES_WEBUI_STATE_DIR` | `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | Where sessions and state are stored. **Note (upgrade):** the default now follows `HERMES_HOME` — if you previously relocated `HERMES_HOME` to a non-default base **without** setting `HERMES_WEBUI_STATE_DIR`, your WebUI state now resolves to `$HERMES_HOME/webui` instead of the old platform-default `~/.hermes/webui`. To keep using the old location, set `HERMES_WEBUI_STATE_DIR` to it (or move the directory). Installs with `HERMES_HOME` unset or at the default base are unaffected. |
|
||||
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
|
||||
| `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default |
|
||||
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
|
||||
| `HERMES_WEBUI_CSP_CONNECT_EXTRA` | *(unset)* | Optional space-separated `http(s)://` or `ws(s)://` origins to append to the enforced and report-only CSP `connect-src` directives for trusted reverse-proxy, tunnel, or extension sidecar deployments |
|
||||
| `HERMES_WEBUI_SSE_CHUNKED` | *(unset)* | Set truthy (`1`/`true`/`yes`/`on`) to send SSE with `Transfer-Encoding: chunked`. Needed behind buffering reverse proxies (e.g. `jupyter-server-proxy`) that otherwise buffer the whole stream; harmless but unnecessary for directly-served deployments |
|
||||
| `HERMES_WEBUI_EXTENSION_DIR` | *(unset)* | Optional local directory served at `/extensions/`; must point to an existing directory before extension injection is enabled |
|
||||
| `HERMES_WEBUI_EXTENSION_MANIFEST` | *(unset)* | Optional relative JSON manifest inside `HERMES_WEBUI_EXTENSION_DIR` listing bundled scripts/styles to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; appended after manifest scripts; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; appended after manifest stylesheets; see [WebUI Extensions](docs/EXTENSIONS.md) |
|
||||
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
|
||||
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
|
||||
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
|
||||
| `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) |
|
||||
| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access |
|
||||
|
||||
Extension deployments can inspect sanitized, authenticated diagnostics at `GET /api/extensions/status`; see [WebUI Extensions](docs/EXTENSIONS.md#diagnostics).
|
||||
|
||||
---
|
||||
|
||||
### Nix flake and NixOS module
|
||||
|
||||
Hermes WebUI has a Nix flake package and a NixOS service module so you can run it declaratively.
|
||||
|
||||
Install the latest package with:
|
||||
|
||||
```bash
|
||||
nix shell github:nesquena/hermes-webui#default
|
||||
```
|
||||
|
||||
Use this flake input in your system configuration:
|
||||
|
||||
```nix
|
||||
inputs.hermes-webui.url = "github:nesquena/hermes-webui";
|
||||
```
|
||||
|
||||
Then add the module and configure it in `nixosModules`:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
hermes-agent.url = "github:NousResearch/hermes-agent";
|
||||
hermes-webui.url = "github:nesquena/hermes-webui";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, hermes-agent, hermes-webui, ... }: {
|
||||
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
hermes-agent.nixosModules.default
|
||||
hermes-webui.nixosModules.default
|
||||
({ pkgs, ... }: {
|
||||
services.hermes-agent.enable = true;
|
||||
services.hermes-webui = {
|
||||
enable = true;
|
||||
host = "127.0.0.1";
|
||||
port = 8787;
|
||||
stateDir = "/var/lib/hermes-webui";
|
||||
user = "hermes";
|
||||
group = "hermes";
|
||||
hermesHome = "/var/lib/hermes/.hermes";
|
||||
agent.package = hermes-agent.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
environmentFiles = [ "/run/secrets/hermes-webui.env" ];
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The module defaults to `127.0.0.1`. Set `host = "0.0.0.0"` and `openFirewall = true` only when you want direct network access, and pair that with auth, for example `HERMES_WEBUI_PASSWORD` via `environmentFiles`.
|
||||
|
||||
The published Hermes Agent package exposes `passthru.hermesVenv`, so the module derives `HERMES_WEBUI_PYTHON` from its interpreter. Bootstrap then locates the installed `run_agent.py` through that interpreter without importing Agent code and exports its parent as `HERMES_WEBUI_AGENT_DIR`. Packages may expose `passthru.hermesAgentDir` as a direct path instead. Use `agent.dir` and `agent.python` as explicit overrides for custom package layouts.
|
||||
|
||||
When WebUI reads shared Hermes Agent state, run the service as a user that can already read that state. For a co-located Hermes Agent service, set `user` and `group` to the agent service account; the module only creates the default `hermes-webui` account and never changes ownership of an existing `hermesHome`.
|
||||
|
||||
The module maps directly onto existing WebUI environment variables, including:
|
||||
`HERMES_WEBUI_HOST`, `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_HOME`, `HERMES_WEBUI_AGENT_DIR`, and `HERMES_WEBUI_PYTHON`.
|
||||
|
||||
Set `environmentFiles` for secrets like API keys. Protected WebUI runtime keys from the module are rejected there, so keep host, port, state, and agent wiring in the module options. Keep reverse proxy and TLS configuration in your surrounding deployment module because those details are deployment-specific.
|
||||
|
||||
### Remote access (SSH tunnel, Tailscale, phone)
|
||||
|
||||
The server binds to `127.0.0.1` by default. To reach it from another machine use an SSH tunnel (`ssh -N -L 8787:127.0.0.1:8787 user@host`, which `start.sh` prints for you over SSH), or join your server and phone to a [Tailscale](https://tailscale.com) network and browse to `http://<server-tailscale-ip>:8787` with `HERMES_WEBUI_HOST=0.0.0.0` + `HERMES_WEBUI_PASSWORD` set. Full walkthrough (incl. a community ARM64-Android field report): [`docs/remote-access.md`](docs/remote-access.md).
|
||||
|
||||
### Manual launch (without start.sh)
|
||||
|
||||
If you prefer to launch the server directly:
|
||||
|
||||
```bash
|
||||
cd /path/to/hermes-agent # or wherever sys.path can find Hermes modules
|
||||
HERMES_WEBUI_PORT=8787 venv/bin/python /path/to/hermes-webui/server.py
|
||||
```
|
||||
|
||||
Note: use the agent venv Python (or any Python environment that has the Hermes agent dependencies installed). System Python will be missing `openai`, `httpx`, and other required packages.
|
||||
|
||||
Health check:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
**Pre-built images** (amd64 + arm64) are published to GHCR on every release.
|
||||
|
||||
For a comprehensive setup guide covering all 3 compose files, common failure modes, and bind-mount migration, see [`docs/docker.md`](docs/docker.md). The README covers the 5-minute happy path.
|
||||
|
||||
### 5-minute quickstart (single container)
|
||||
|
||||
The simplest setup: one WebUI container that runs the agent in-process.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nesquena/hermes-webui
|
||||
cd hermes-webui
|
||||
cp .env.docker.example .env
|
||||
# Edit .env if your host UID isn't 1000 (e.g. macOS where UIDs start at 501)
|
||||
docker compose up -d
|
||||
# Open http://localhost:8787
|
||||
```
|
||||
|
||||
Run Compose as the user who owns your Hermes home. `sudo docker compose up -d` can make `${HOME}` expand to the root user's home, so Docker mounts the wrong `.hermes` directory instead of your real `~/.hermes` and the WebUI starts with `config.yaml (not found, using defaults)`. Prefer adding your user to the Docker group and running `docker compose up -d`; if you must use sudo, set absolute paths first, for example `HERMES_HOME=/home/you/.hermes HERMES_WORKSPACE=/home/you/workspace sudo -E docker compose up -d`, then verify with `docker compose config`.
|
||||
|
||||
The container auto-detects your UID/GID from the mounted `~/.hermes` volume so files written by the agent stay readable by you on the host.
|
||||
|
||||
To enable password protection (required if you expose the port outside `127.0.0.1`):
|
||||
|
||||
```bash
|
||||
echo "HERMES_WEBUI_PASSWORD=change-me-to-something-strong" >> .env
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
### Manual `docker run` (no compose)
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/nesquena/hermes-webui:latest
|
||||
docker run -d \
|
||||
-e WANTED_UID=$(id -u) -e WANTED_GID=$(id -g) \
|
||||
-v ~/.hermes:/home/hermeswebui/.hermes \
|
||||
-e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui \
|
||||
-v ~/workspace:/workspace \
|
||||
-p 127.0.0.1:8787:8787 \
|
||||
ghcr.io/nesquena/hermes-webui:latest
|
||||
```
|
||||
|
||||
### Build locally
|
||||
|
||||
```bash
|
||||
docker build -t hermes-webui .
|
||||
docker run -d \
|
||||
-e WANTED_UID=$(id -u) -e WANTED_GID=$(id -g) \
|
||||
-v ~/.hermes:/home/hermeswebui/.hermes \
|
||||
-e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui \
|
||||
-v ~/workspace:/workspace \
|
||||
-p 127.0.0.1:8787:8787 \
|
||||
hermes-webui
|
||||
```
|
||||
|
||||
### Multi-container setups
|
||||
|
||||
If you want the agent and WebUI in separate containers (for isolation, or because you're already running an agent gateway elsewhere):
|
||||
|
||||
```bash
|
||||
# Agent + WebUI
|
||||
docker compose -f docker-compose.two-container.yml up -d
|
||||
|
||||
# Agent + Dashboard + WebUI
|
||||
docker compose -f docker-compose.three-container.yml up -d
|
||||
```
|
||||
|
||||
Both compose files use **named Docker volumes** by default, which solves the UID/GID problem by construction. If you need bind mounts to share an existing host directory, see [`docs/docker.md`](docs/docker.md) for the full migration recipe.
|
||||
|
||||
> **Known limitation (#681)**: in the two-container setup, tools triggered from the WebUI run in the **WebUI container**, not the agent container. If you need git/node/etc. on the WebUI's filesystem, either use the single-container setup, extend the WebUI Dockerfile, or use the community [all-in-one image](https://github.com/sunnysktsang/hermes-suite).
|
||||
>
|
||||
> **Source boundary note (#2453)**: the multi-container setup mounts `hermes-agent-src` read-only into the WebUI by default. This prevents WebUI-side source rewrites but is still an implementation-coupling bridge, not a stable Agent API boundary. See [`docs/rfcs/agent-source-boundary.md`](docs/rfcs/agent-source-boundary.md) for the current source/API decoupling inventory.
|
||||
|
||||
### Common failure modes
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `PermissionError` at startup | UID mismatch on bind mount | Set `UID=$(id -u)` in `.env` |
|
||||
| `.env: permission denied` (#1389) | `fix_credential_permissions()` enforced 0600 | Set `HERMES_SKIP_CHMOD=1` in `.env` |
|
||||
| Workspace appears empty | UID mismatch on `/workspace` mount | Set `UID=$(id -u)` in `.env` |
|
||||
| `git: command not found` in chat | Two-container architectural limit (#681) | Use single-container or extend Dockerfile |
|
||||
| WebUI can't find agent source | `hermes-agent-src` volume misconfigured | Use the named volumes from compose files as-is |
|
||||
| Podman shared `.hermes` fails | Podman 3.4 `keep-id` limitation | Use Podman 4+ or single-container |
|
||||
| Host API at `localhost` fails from WebUI | Container `localhost` means the container, not your host (#3012) | Use `http://host.docker.internal:<port>` on Docker Desktop, or `http://host.containers.internal:<port>` on Podman |
|
||||
| WebUI can't see `~/.hermes` after `sudo docker compose` | `${HOME}` expanded to the root user's home (#3006) | Run Compose as your user, or pass absolute `HERMES_HOME`/`HERMES_WORKSPACE` with `sudo -E` |
|
||||
|
||||
For the deep dive on each of these, see [`docs/docker.md`](docs/docker.md).
|
||||
|
||||
> **Note:** By default, Docker Compose binds to `127.0.0.1` (localhost only).
|
||||
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
|
||||
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
Tests discover the repo and the Hermes agent dynamically -- no hardcoded paths.
|
||||
Use the repo test runner so local runs do not accidentally use an unsupported
|
||||
system Python. It creates/uses `.venv` with Python 3.11, 3.12, or 3.13 and
|
||||
installs the dev test dependencies from `requirements-dev.txt` when missing.
|
||||
|
||||
```bash
|
||||
cd hermes-webui
|
||||
./scripts/test.sh
|
||||
```
|
||||
|
||||
Pass normal pytest arguments after the script for focused runs:
|
||||
|
||||
```bash
|
||||
./scripts/test.sh tests/test_regressions.py -v
|
||||
```
|
||||
|
||||
Or seed the repo `.venv` from an explicit supported base interpreter:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_TEST_PYTHON=/path/to/python3.12 ./scripts/test.sh tests/ -v
|
||||
```
|
||||
|
||||
The override selects the Python used to create or rebuild `.venv`; dependencies
|
||||
are still installed into the repo-local virtual environment, not into the
|
||||
system/Homebrew interpreter.
|
||||
|
||||
Tests run against an isolated server with a separate state directory.
|
||||
Production data and real cron jobs are never touched. Current snapshot:
|
||||
**~11,500 tests collected** across **~1,150 test files**, run in CI on Python 3.11,
|
||||
3.12, and 3.13 (3 parallel shards each).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
No build step, no framework, no bundler — a Python standard-library HTTP server
|
||||
and vanilla JS. The backend lives in `api/`, the frontend in `static/`.
|
||||
|
||||
**Backend (`api/`)**
|
||||
|
||||
```
|
||||
server.py HTTP routing shell + auth middleware
|
||||
api/
|
||||
auth.py Optional password authentication, signed cookies, passkeys
|
||||
config.py Discovery, globals, model detection, reloadable config
|
||||
helpers.py HTTP helpers, security headers
|
||||
models.py Session model + CRUD + CLI/state.db bridge
|
||||
onboarding.py First-run onboarding wizard, OAuth provider support
|
||||
profiles.py Profile state management, hermes_cli wrapper
|
||||
routes.py All GET + POST route handlers (if/elif dispatch, no decorators)
|
||||
state_sync.py /insights sync — message_count to state.db
|
||||
streaming.py SSE engine, run_agent, cancellation, compression
|
||||
updates.py Self-update check and release notes
|
||||
upload.py Multipart parser, file upload handler
|
||||
workspace.py File ops, workspace helpers, git detection
|
||||
```
|
||||
|
||||
**Frontend (`static/`)**
|
||||
|
||||
```
|
||||
index.html HTML template
|
||||
style.css All CSS incl. mobile responsive, themes + skins
|
||||
ui.js DOM helpers, renderMd, tool cards, context indicator
|
||||
workspace.js File preview, file ops, git badge, central api() fetch wrapper
|
||||
sessions.js Session CRUD, collapsible groups, search, reload recovery
|
||||
messages.js send(), SSE handlers, live streaming, session recovery
|
||||
panels.js Cron, skills, memory, profiles, settings (Control Center)
|
||||
commands.js Slash command autocomplete
|
||||
boot.js Mobile nav, voice input, theme/skin boot, bfcache handler
|
||||
```
|
||||
|
||||
**Tests + packaging**
|
||||
|
||||
```
|
||||
tests/ Pytest suite (~11,500 tests; isolated server/state fixtures)
|
||||
pyproject.toml Standard build metadata plus the Ruff lint gate; checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh
|
||||
Dockerfile python:3.12-slim container image
|
||||
docker-compose.yml Compose with named volume and optional auth
|
||||
.github/workflows/ CI: ruff + sharded pytest, browser smoke, Docker smoke,
|
||||
multi-arch Docker build + GitHub Release on tag
|
||||
```
|
||||
|
||||
State lives outside the repo at `~/.hermes/webui/` by default
|
||||
(sessions, workspaces, settings, projects, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
|
||||
Full design notes and the endpoint catalog are in [`ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Compatibility
|
||||
|
||||
The version shown in the WebUI runtime status is the **WebUI version only** (build/image/tag currently running). It is not a full compatibility map.
|
||||
|
||||
The WebUI is still coupled to Hermes Agent internals for runtime execution, provider/model access, and state/schema usage until the stable agent boundary work in [#1925](https://github.com/nesquena/hermes-webui/issues/1925) and [#2491](https://github.com/nesquena/hermes-webui/issues/2491) land. In practice, the WebUI imports Agent modules directly (`api/config.py`, `api/providers.py`, `api/streaming.py`) and reads Agent state layout directly, so version skew can cause import or behavior drift.
|
||||
|
||||
**Compatibility policy**
|
||||
- WebUI release branches are tested against the matching Hermes Agent release available at that WebUI release time.
|
||||
- **Upgrade both together**: upgrade or pin WebUI and hermes-agent together (same release train/version/date), especially before enabling production traffic.
|
||||
- Running pinned older/newer combinations is **untested and unsupported** until the stable API boundary work in [#1925](https://github.com/nesquena/hermes-webui/issues/1925) / [#2491](https://github.com/nesquena/hermes-webui/issues/2491) is in place.
|
||||
- Record the full `hermes-agent` + `hermes-webui` versions in issue reports when upgrade mismatches are suspected.
|
||||
|
||||
**Docker users**: pin both image tags (or corresponding pinned source revisions) rather than using `latest` on one side and a fixed tag on the other. When upgrading the multi-container setup, follow the agent-image upgrade procedure in [`docs/docker.md`](docs/docker.md) (which requires dropping the `hermes-agent-src` volume before recreating). The current source-boundary status is tracked in [`docs/rfcs/agent-source-boundary.md`](docs/rfcs/agent-source-boundary.md).
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
**Start here**
|
||||
- [`docs/why-hermes.md`](docs/why-hermes.md) — why Hermes, the mental model, and a detailed comparison to Claude Code / Codex / OpenCode / Cursor
|
||||
- [`docs/onboarding.md`](docs/onboarding.md) — first-run wizard, provider setup, local model server Base URLs, and safe re-runs
|
||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md) — diagnostic flows for common failures (e.g. "AIAgent not available")
|
||||
|
||||
**Using & customizing**
|
||||
- [`THEMES.md`](THEMES.md) — theme + skin system, custom theme guide
|
||||
- [`docs/workspace-git.md`](docs/workspace-git.md) — the workspace Git controls
|
||||
- [`docs/EXTENSIONS.md`](docs/EXTENSIONS.md) — administrator-controlled WebUI extension injection
|
||||
|
||||
**Deploying & operating**
|
||||
- [`docs/remote-access.md`](docs/remote-access.md) — SSH tunnel, Tailscale, and phone access (incl. a community ARM64-Android field report)
|
||||
- [`docs/advanced-chat-setup.md`](docs/advanced-chat-setup.md) — optional dynamic recall-prefill and Gateway-backed browser chat for self-hosted deployments
|
||||
- [`docs/docker.md`](docs/docker.md) — Docker compose setup, common failures, and bind-mount migration
|
||||
- [`docs/supervisor.md`](docs/supervisor.md) — launchd, systemd, supervisord, runit, and s6 process-supervisor setup
|
||||
- [`docs/wsl-autostart.md`](docs/wsl-autostart.md) — WSL2 auto-start at Windows login
|
||||
- [`docs/onboarding-agent-checklist.md`](docs/onboarding-agent-checklist.md) — safety rules and pass/fail checks for assistant-led install/reinstall support
|
||||
|
||||
**Contributing & design**
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contribution style, PR expectations, and local verification
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — system design, all API endpoints, implementation notes
|
||||
- [`TESTING.md`](TESTING.md) — manual browser test plan and automated coverage reference
|
||||
- [`DESIGN.md`](DESIGN.md) — design tokens and the calm-console direction
|
||||
- [`docs/UIUX-GUIDE.md`](docs/UIUX-GUIDE.md) — UI/UX principles sourced from the design docs and visual inventories
|
||||
- [`docs/sse-streams.md`](docs/sse-streams.md) — cross-client SSE endpoint reference: session streaming, gateway SSE probe scope, heartbeats, and proxy behavior
|
||||
- [`docs/CONTRACTS.md`](docs/CONTRACTS.md) — project contract/RFC/design index for contributors and agents
|
||||
- [`docs/rfcs/README.md`](docs/rfcs/README.md) — RFC index for larger architecture and durability proposals
|
||||
|
||||
**Release history & plan**
|
||||
- [`CHANGELOG.md`](CHANGELOG.md) — release notes per version
|
||||
- [`ROADMAP.md`](ROADMAP.md) — feature roadmap and sprint history
|
||||
- [`SPRINTS.md`](SPRINTS.md) — forward sprint plan with CLI + Claude parity targets
|
||||
- [`CONTRIBUTORS.md`](CONTRIBUTORS.md) — the full community credit roll
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Hermes WebUI is built with help from the open-source community. Every PR — whether merged directly, absorbed into a batch release, or salvaged from a larger proposal — shapes the project, and we're grateful to everyone who has taken the time to contribute.
|
||||
|
||||
<!-- BEGIN GENERATED CONTRIBUTORS -->
|
||||
Over **326 contributors** have shipped code that landed in a release tag. The full, continuously-updated credit roll — including everyone with one or two PRs and the special-thanks roll for design and architectural work — lives in [`CONTRIBUTORS.md`](CONTRIBUTORS.md). A snapshot of the most prolific contributors:
|
||||
|
||||
### Top contributors (by PR count, including absorbed/batch-released work)
|
||||
|
||||
| # | Contributor | PRs | First → latest release |
|
||||
|---|---|---:|---|
|
||||
| 1 | [@rodboev](https://github.com/rodboev) | 336 | `v0.51.223` → `v0.51.893` |
|
||||
| 2 | [@franksong2702](https://github.com/franksong2702) | 301 | `v0.49.3` → `v0.51.893` |
|
||||
| 3 | [@Michaelyklam](https://github.com/Michaelyklam) | 157 | `v0.50.240` → `v0.51.198` |
|
||||
| 4 | [@ai-ag2026](https://github.com/ai-ag2026) | 121 | `v0.50.279` → `v0.51.835` |
|
||||
| 5 | [@bergeouss](https://github.com/bergeouss) | 80 | `v0.48.0` → `v0.51.703` |
|
||||
| 6 | [@AJV20](https://github.com/AJV20) | 57 | `v0.51.93` → `v0.51.346` |
|
||||
| 7 | [@dso2ng](https://github.com/dso2ng) | 43 | `v0.50.227` → `v0.51.578` |
|
||||
| 8 | [@starship-s](https://github.com/starship-s) | 28 | `v0.50.123` → `v0.51.763` |
|
||||
| 9 | [@Sanjays2402](https://github.com/Sanjays2402) | 27 | `v0.50.292` → `v0.51.484` |
|
||||
| 10 | [@allenliang2022](https://github.com/allenliang2022) | 24 | `v0.51.185` → `v0.51.869` |
|
||||
|
||||
See [`CONTRIBUTORS.md`](CONTRIBUTORS.md) for the full ranked list of all 326 contributors — the 3+ PR tables, the 1–2 PR roll, and the special-thanks notes for design and architectural contributions.
|
||||
<!-- END GENERATED CONTRIBUTORS -->
|
||||
|
||||
### Notable contributions
|
||||
|
||||
**[@franksong2702](https://github.com/franksong2702)** — Most prolific external contributor (180 PRs, `v0.49.3` → `v0.51.384`)
|
||||
Across the longest tenure of any external contributor: the session title guard (#301), breadcrumb workspace navigation (#302), embedded workspace terminal (#1099), worktree-backed session creation (#2053), onboarding documentation (#2052), composer footer container queries, streaming-session sidebar exemption (#1327), session sidecar repair, cron output preservation (#1295), profile default workspace persistence, manual `/compress` async start/status endpoints (#2128), worktree status surface (#2109) + guarded remove (#2156) for the lifecycle umbrella #2057, session post-render dedup (#2166), native-WebUI fast path (#2170), tail-window response trim (#2171), stale-stream guard extension (#2158), CSP report collector (#2160), and a long tail of polish across mobile/responsive, the session sidebar, and the workspace state machine.
|
||||
|
||||
**[@Michaelyklam](https://github.com/Michaelyklam)** — Most prolific contributor of recent releases (118 PRs, `v0.50.240` → `v0.51.198`)
|
||||
Production Docker hardening (#1921, drops sudo-capable staging user), profile-scoped skills endpoints (#1903), gateway PID resolution under profile-scoped HERMES_HOME (#1901), profile-aware AIAgent cache (#1898/#1904), backslash LaTeX delimiters (#1848), Codex quota error surfacing (#1770), shell-route HTML 503 (#1836), stale Kanban client recovery (#1828), context auto-compression toast lifetime (#1988), `/goal` command (#1866), Kanban detail-view scrolling (#1916), CLI session tool metadata preservation (#1778), Traditional Chinese kanban locale backfill (#1979), v0.51.51 mobile Insights bucketing/layout (#2120/#2121), Hermes run adapter RFC (#2105 for #1925), fork-from-here absolute index (#2198 for #2184), opencode-go custom-provider overlap routing (#2204 for #1894).
|
||||
|
||||
**[@rodboev](https://github.com/rodboev)** — Windows / cross-platform correctness + test reliability (83 PRs, `v0.51.223` → `v0.51.384`)
|
||||
A broad, sustained sweep with a strong focus on making the project work outside Linux: the `ctl.sh` Windows process-tree-kill fix (#3670), the local Windows full-suite signal/orphan-process work, slash-command autocomplete polish, and a long tail of frontend and infrastructure fixes shipped across dozens of release batches.
|
||||
|
||||
**[@bergeouss](https://github.com/bergeouss)** — Provider management UI + Docker hardening (70 PRs, `v0.48.0` → `v0.51.385`)
|
||||
Provider management UI for adding/editing custom providers from Settings, OAuth provider status detection (#1552), two-container Docker setup, profile isolation hardening (per-profile `.env` secrets), the bulk of what users see when they touch Settings → Providers, Reveal-in-Finder context menu (#1551), gateway status card (#1552), auto-assign session to active project filter (#1550), "What's new?" link in update banner (#1549), OpenRouter free-tier live fetch (#1548), credential pool 401 self-heal (#1553), inline provider chip + group model count in model picker (#1644).
|
||||
|
||||
**[@ai-ag2026](https://github.com/ai-ag2026)** — Session recovery + audit infrastructure (75 PRs, `v0.50.279` → `v0.51.367`)
|
||||
Autonomous-AI contributor (Hermes Agent-driven) focused on durability: `state.db`-backed sidecar reconciliation (#2041), orphan `.json.bak` recovery on startup (#2035), read-only session recovery audit endpoints (#2036, #2040), active run lifecycle in `/health` (#2039), crash-safe turn-journal RFC at `docs/rfcs/turn-journal.md` (#2042), append-only turn-journal helper (#2059), lifecycle events layer (#2062), `Content-Security-Policy-Report-Only` header (#2084), per-cron toast toggle (#2100), fork-session compression lineage isolation (#2014).
|
||||
|
||||
**[@dso2ng](https://github.com/dso2ng)** — Session lineage + diagnostics (30 PRs, `v0.50.227` → `v0.51.327`)
|
||||
`/api/session/lineage-report/<sid>` endpoint for bounded session graph diagnostics (#2012), stale Mermaid render error cleanup (#1337), `session_source="fork"` continuation-chain isolation (#2063), lazy lineage-report fetch on sidebar badge expand (#2130), and a long tail of frontend reliability fixes around session loading.
|
||||
|
||||
**[@jasonjcwu](https://github.com/jasonjcwu)** — Composer + transcript polish (16 PRs, `v0.50.227` → `v0.51.132`)
|
||||
Sidebar collapse via active-rail click (#2054, fuses #1884 + #1924), composer chip lightbox (#1758), title fixes for tool-heavy first turns, silent compress-status during session switch (#2185), concurrent-send loss fix (#2186), in-transcript steer message badges (#2187), and a string of frontend polish fixes.
|
||||
|
||||
**[@Jordan-SkyLF](https://github.com/Jordan-SkyLF)** — Live streaming + UX polish (12 PRs, `v0.50.18` → `v0.51.66`)
|
||||
Original sprint of workspace fallback resolution, live reasoning cards (#366, #367, #394–#397), then a recent burst: manual "Refresh usage" button on the Provider quota card (#2150), cancelled-turn status classification (#2151), Firefox sidebar scroll stabilization (#2200), early provisional session titles (#2202), target-aware "What's new?" update-banner links (#2207), and MCP tools overflow fix in Settings (#2210).
|
||||
|
||||
**[@aronprins](https://github.com/aronprins)** — `v0.50.0` UI overhaul (PR #242, plus 9 follow-ups)
|
||||
The biggest single contribution to the project: a complete UI redesign that moved model/profile/workspace controls into the composer footer, replaced the gear-icon settings panel with the Hermes Control Center (tabbed modal), removed the activity bar in favor of inline composer status, redesigned the session list with a `⋯` action dropdown, and added the workspace panel state machine. Plus chat transcript redesign (#587), sidebar declutter (#584), three-column layout refactor (#899), light/dark theme + accent skins (#627), and shared `confirm()`/`prompt()` dialog replacement (PR #251 extracted from #242).
|
||||
|
||||
**[@iRonin](https://github.com/iRonin)** — Security hardening sprint (PRs #196–#204)
|
||||
Six consecutive, focused security PRs: session memory leak fix (expired token pruning), CSP + Permissions-Policy headers, 30-second slow-client connection timeout, optional HTTPS/TLS support via environment variables, upstream branch tracking fix for self-update, and CLI session support in the file-browser API. The kind of focused, high-quality security work that makes a self-hosted tool trustworthy.
|
||||
|
||||
**[@lucasrc](https://github.com/lucasrc)** — Auth-hardening trilogy (PRs #2191, #2192, #2193)
|
||||
Three coordinated security PRs that all landed in v0.51.57: thread-safe login rate limiter with PBKDF2 key separation, password-hash cache invalidation on Settings save, and the full 64-char HMAC-SHA256 session signature with a backwards-compatible migration bridge. The kind of cleanly-decomposed security work that's reviewable as three independent pieces.
|
||||
|
||||
**[@LumenYoung](https://github.com/LumenYoung)** — Streaming hot-path correctness (8 PRs, `v0.51.47` → `v0.51.99`)
|
||||
The original stale-stream writeback guard (#2136 — the bug class the next two releases extended), gateway-state alive-null classification (#2075), compression-banner anchor alignment (#2182), and context-progress ring auto-refresh on compression complete (#2188). Each PR opened a small surgical fix in one of the most fragile subsystems in the codebase.
|
||||
|
||||
**[@dobby-d-elf](https://github.com/dobby-d-elf)** — Frontend reliability + motion polish (15 PRs, `v0.51.38` → `v0.51.161`)
|
||||
Workspace fallback on deleted directories (#2138), iPhone PWA bottom-scroll fix (#2143), the new "Activity: X tools" composer footer shimmer animation (#2203), and follow-up animation tuning (#2212).
|
||||
|
||||
**[@JKJameson](https://github.com/JKJameson)** — Composer + session polish (10 PRs)
|
||||
Persistent composer draft per session (#1956), and a long tail of polish across the composer and session sidebar.
|
||||
|
||||
**[@gabogabucho](https://github.com/gabogabucho)** — Spanish locale + onboarding wizard
|
||||
Full Spanish (`es`) locale covering all UI strings, plus the one-shot bootstrap onboarding wizard that guides new users through provider setup on first launch.
|
||||
|
||||
**[@deboste](https://github.com/deboste)** — Reverse-proxy auth + mobile responsive layout (PRs #3, #4, #5)
|
||||
Three of the very first community PRs: fixed EventSource/fetch to use URL origin for reverse-proxy setups, corrected model provider routing from config, and added mobile responsive layout with dvh viewport fix. Early foundation work.
|
||||
|
||||
**[@indigokarasu](https://github.com/indigokarasu)** — Visual redesign proposal (PR #213)
|
||||
A CSS-only redesign of the full UI — proper design tokens, an icon rail sidebar replacing the emoji tab strip, consistent form cards, breadcrumb nav, and 7 built-in themes as custom properties. The PR didn't merge as-is but shaped the design language and theme architecture that shipped in v0.50.0.
|
||||
|
||||
**[@zenc-cp](https://github.com/zenc-cp)** — Anti-hallucination guard for the ReAct loop (PR #133)
|
||||
A three-layer approach (ephemeral anti-hallucination prompt, live token filtering, session-history cleanup) that the streaming pipeline still uses.
|
||||
|
||||
**[@Hinotoi-agent](https://github.com/Hinotoi-agent)** — Profile + session security (PRs #351, #2048)
|
||||
Profile `.env` secret isolation fix (PR #351) preventing API key leakage between profiles, and session-import workspace validation (PR #2048) blocking a crafted-JSON file-read against `/`.
|
||||
|
||||
**[@Sanjays2402](https://github.com/Sanjays2402)** — Endless-scroll + Start-jump race fix (PR #1949)
|
||||
A generation-token + mutex pair fixing the v0.51.30 race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`. The naive same-flag-check approach (proposed in #1942 and #1962) was a no-op for the post-await race — Sanjays2402's fix was the correct shape.
|
||||
|
||||
**[@fxd-jason](https://github.com/fxd-jason)** — Real-time approval + clarify via SSE (PRs #1350, #1355)
|
||||
Replaced 1.5s HTTP polling with SSE long-connections for both approval and clarify, cutting latency from up to 1.5s to near-instant. Got all the correctness details right (atomic subscribe + snapshot, notify-inside-lock, head-of-queue payload, trailing event re-emission).
|
||||
|
||||
**[@happy5318](https://github.com/happy5318)** — Custom provider model dedup (PR #1947)
|
||||
Fixed the same model from different named custom providers being silently deduplicated in the picker, with Opus catching a race in the original tests that needed augmentation.
|
||||
|
||||
**[@NocGeek](https://github.com/NocGeek)** — Streaming scroll + manual cron output persistence (7 PRs)
|
||||
Streaming scroll viewport stability when tool/queue cards insert (#1360), manual cron-run output and metadata persistence (#1372, split from held #1352).
|
||||
|
||||
**[@DavidSchuchert](https://github.com/DavidSchuchert)** — German translation (PR #190)
|
||||
Complete German locale (`de`) covering all UI strings, settings labels, commands, and system messages — and stress-tested the i18n system, exposing several elements that weren't yet translatable and getting them fixed as part of the same PR.
|
||||
|
||||
**[@Bobby9228](https://github.com/Bobby9228)** — Mobile Profiles button (PR #265)
|
||||
Added the Profiles entry to the mobile navigation flow, making profile switching reachable on phones.
|
||||
|
||||
**[@kevin-ho](https://github.com/kevin-ho)** — OLED theme (PR #168)
|
||||
The 7th built-in theme: pure black backgrounds with warm accents tuned to reduce burn-in risk.
|
||||
|
||||
**[@andrewy-wizard](https://github.com/andrewy-wizard)** — Chinese localization (PR #177)
|
||||
Initial Simplified Chinese (`zh`) locale. One of the first non-English locales.
|
||||
|
||||
**[@DelightRun](https://github.com/DelightRun)** — `session_search` fix for WebUI sessions (PR #356)
|
||||
Tracked down the missing `SessionDB` injection in the streaming path that was silently breaking the tool for every WebUI session.
|
||||
|
||||
**[@lawrencel1ng](https://github.com/lawrencel1ng)** — Bandit security fixes (PR #354)
|
||||
Systematic bandit-scan fixes: URL scheme validation before `urlopen`, MD5 `usedforsecurity=False`, and 40+ bare `except: pass` blocks replaced with proper logging.
|
||||
|
||||
**[@shaoxianbilly](https://github.com/shaoxianbilly)** — Unicode filename downloads (PR #378)
|
||||
Proper `Content-Disposition` with RFC 5987 `filename*=UTF-8''...` encoding so non-ASCII filenames download without crashing.
|
||||
|
||||
**[@lx3133584](https://github.com/lx3133584)** — CSRF fix for reverse proxy (PR #360)
|
||||
A real-world blocker for anyone hosting behind Nginx Proxy Manager or similar on a port other than 80/443.
|
||||
|
||||
**[@betamod](https://github.com/betamod)** — Security audit (PR #171)
|
||||
A comprehensive CSRF / SSRF / XSS / env-race-condition audit that shipped in v0.39.0.
|
||||
|
||||
**[@TaraTheStar](https://github.com/TaraTheStar)** — Bot name + thinking blocks + login refactor (PRs #132, #176, #181)
|
||||
Configurable assistant display name, thinking/reasoning block display, and a login page refactor.
|
||||
|
||||
---
|
||||
|
||||
## Repo
|
||||
|
||||
```
|
||||
git@github.com:nesquena/hermes-webui.git
|
||||
```
|
||||
-431
@@ -1,431 +0,0 @@
|
||||
# Hermes Web UI — Roadmap
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> This roadmap tracks the whole ecosystem: the web app, the native clients that
|
||||
> wrap it (macOS, Windows/Linux, Android, iOS), the extension system, and the
|
||||
> autonomous maintenance framework that runs the project.
|
||||
>
|
||||
> Version, test count, and release history are **derived live**, not stamped here
|
||||
> (they drift every release). For the current numbers:
|
||||
> - Version + history: `git tag --sort=-v:refname | head` + [CHANGELOG.md](./CHANGELOG.md)
|
||||
> - Test count: `pytest tests/ --collect-only -q`
|
||||
|
||||
---
|
||||
|
||||
## Status snapshot
|
||||
|
||||
| Surface | Status |
|
||||
|---|---|
|
||||
| **Hermes CLI parity** | ✅ Complete — every CLI workflow has a web equivalent |
|
||||
| **Streaming + tool transparency** | ✅ Live tool cards, reasoning cards, approval prompts, clarify, cancel, steer |
|
||||
| **Multi-provider model support** | ✅ Any provider configured in `config.yaml` shows in the picker + live custom-endpoint discovery |
|
||||
| **Sessions + projects + search** | ✅ CRUD, content search, projects, tags, archive, fork, import, batch ops, CLI bridge |
|
||||
| **Mobile + Docker + auth** | ✅ Hamburger nav, slide-overs, PWA install, password auth, GHCR images |
|
||||
| **Auxiliary surfaces** | ✅ Workspace tree + edit + terminal, cron CRUD, skills CRUD, memory write, MCP server UI |
|
||||
| **Visual polish** | ✅ Light / dark / system × 21 skins, Mermaid, KaTeX, syntax highlighting, Transparent Stream |
|
||||
| **Internationalization** | ✅ 15 locales with key-parity guards + CJK/RTL/IME handling |
|
||||
| **Gateway integration** | ✅ Live external sessions (Telegram, Discord, Slack, WeChat/Weixin, Signal, SMS) + cross-channel handoff |
|
||||
| **Extension system** | ✅ Opt-in loader, capability surface (themes / TTS / nav / sidecars), one-click gallery, vetted library repo |
|
||||
| **Native clients** | ✅ macOS (Swift), Windows + Linux + macOS (Rust/Tauri), Android, iOS — each in its own repo |
|
||||
| **Autonomous maintenance** | ✅ Self-running triage / review / release pipeline; generalized publicly as [StewardOS](https://github.com/nesquena/steward-os) |
|
||||
|
||||
Remaining gaps and forward work live in [Forward Work](#forward-work) below. The
|
||||
short version: the original CLI-parity + Claude-parity vision is done, and the
|
||||
work has shifted from "reach parity" to "harden reliability + widen distribution."
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
| Layer | Files | Status |
|
||||
|---|---|---|
|
||||
| Python server | `server.py` + `api/` modules | Thin HTTP shell + auth middleware over the `api/` business logic (config, sessions, streaming, profiles, routes, onboarding, workspace, updates, upload, extensions) |
|
||||
| HTML template | `static/index.html` | Served from disk |
|
||||
| CSS | `static/style.css` | Themes + skins, mobile responsive, KaTeX, table styles |
|
||||
| JavaScript | `static/{ui,sessions,messages,workspace,panels,boot,commands,icons,i18n,login,onboarding,extension_settings}.js` | Vanilla-JS modules served as static files — no bundler |
|
||||
| Service worker | `static/sw.js` | Offline shell cache, version-pinned assets |
|
||||
| Docker | `Dockerfile`, `docker-compose.yml` | `python:3.12-slim`, multi-arch (amd64+arm64), HEALTHCHECK |
|
||||
| CI/CD | `.github/workflows/` | ruff lint + sharded pytest + Playwright browser + Docker smoke on every PR; auto-release + GHCR publish on tag push |
|
||||
| Test isolation | `tests/_pytest_port.py` | Per-worktree port + state-dir derivation, no collisions |
|
||||
|
||||
> Per-file line counts drift every release; see `ARCHITECTURE.md` for the current module map and `git ls-files` for exact sizes.
|
||||
|
||||
---
|
||||
|
||||
## Feature parity checklist
|
||||
|
||||
### Chat and streaming
|
||||
- [x] Send messages, get SSE-streaming responses
|
||||
- [x] Composer-scoped model picker (per-conversation model selection)
|
||||
- [x] Rich provider-grouped, searchable model picker everywhere (composer + Preferences default-model)
|
||||
- [x] Multi-provider API support — OpenAI, Anthropic, Google, OpenRouter, xAI, GLM, DeepSeek, Mistral, MiniMax, Kimi, OpenCode, Nous Portal, custom OpenAI-compatible endpoints
|
||||
- [x] Live custom-endpoint model discovery (Ollama, LM Studio, vLLM via `/v1/models`)
|
||||
- [x] Add a self-hosted provider (Ollama / LM Studio) from Settings — no `config.yaml` edit
|
||||
- [x] Free-form OpenRouter model name (autocomplete + custom input)
|
||||
- [x] Tool progress shown inline via live tool cards
|
||||
- [x] Approval card for dangerous commands (Allow once / session / always, Deny)
|
||||
- [x] Approval polling + SSE-pushed approval events
|
||||
- [x] Clarify dialog — agent can ask blocking clarifying questions
|
||||
- [x] Subagent delegation cards in tool view
|
||||
- [x] INFLIGHT guard: switch sessions mid-request without losing response
|
||||
- [x] Session restores from localStorage on page load
|
||||
- [x] Reconnect banner if page reloaded mid-stream
|
||||
- [x] SSE auto-reconnect with extended backoff ladder + full-session poll fallback
|
||||
- [x] Token / cost estimate per message and per session
|
||||
- [x] Context usage indicator (compact ring badge in composer footer)
|
||||
- [x] Auto-compaction handling + `/compact` command
|
||||
- [x] One-click recovery from "context compression exhausted" (focused continuation)
|
||||
- [x] rAF-throttled token rendering (smooth, no DOM thrash)
|
||||
- [x] Cancel / stop button in composer footer
|
||||
- [x] Reasoning effort selector (low / medium / high / xhigh) + `/reasoning`
|
||||
- [x] Pure-text streaming with crash-recovery — partial messages restored from localStorage on reload
|
||||
- [x] Default message mode on a busy turn: Queue / Interrupt / Steer (new installs default to Steer)
|
||||
- [x] Steer a live turn with attachments (files uploaded + scoped to the owning session)
|
||||
- [x] Transparent Stream mode — chronological worklog with per-word fade-in (respects reduced-motion)
|
||||
|
||||
### Conversation controls
|
||||
- [x] Copy message to clipboard (hover icon on each bubble)
|
||||
- [x] Edit last user message and regenerate
|
||||
- [x] Regenerate last response
|
||||
- [x] Clear conversation (truncation-watermarked so cleared history cannot resurrect)
|
||||
- [x] Branch / fork conversation from any message point (turn-boundary aligned on compacted sessions)
|
||||
- [x] Pure-text + tool-call streams both recover
|
||||
|
||||
### Sessions
|
||||
- [x] Create session (+ button or Cmd/Ctrl+K)
|
||||
- [x] Load session (click in sidebar)
|
||||
- [x] Delete session (hover trash, toast undo, fallback)
|
||||
- [x] Auto-title from first user message + adaptive title refresh (configurable cadence)
|
||||
- [x] LLM-generated titles via auxiliary route (configurable model)
|
||||
- [x] Rename session inline (double-click, Enter saves, Escape cancels)
|
||||
- [x] Title search (live filter)
|
||||
- [x] Content search (full-text across all sessions)
|
||||
- [x] Date group headers (Today / Yesterday / Earlier) with collapsible groups
|
||||
- [x] Pin / star sessions to top
|
||||
- [x] Duplicate session
|
||||
- [x] Import / Export session as JSON (full messages + metadata)
|
||||
- [x] Download as Markdown transcript
|
||||
- [x] Tags (`#tag` extraction + filter chips)
|
||||
- [x] Archive sessions (hidden by default, "Show N archived" toggle)
|
||||
- [x] Projects / folders (chip filter bar, "Unassigned" filter)
|
||||
- [x] Per-session profile tracking
|
||||
- [x] Per-session toolset override (`/toolsets`)
|
||||
- [x] Batch select mode (multi-select, bulk delete / move / archive)
|
||||
- [x] CLI session bridge — read CLI/agent sessions from state.db, surface running ones live, import as WebUI sessions
|
||||
- [x] Fork a read-only cron (scheduled-job) session into an editable chat
|
||||
- [x] Stable sidebar grouping (fork / compaction / subagent clusters don't reshuffle on refresh)
|
||||
- [x] Cross-device recency re-sort (a reactivated conversation bumps to top even in a background tab)
|
||||
|
||||
### Workspace and files
|
||||
- [x] Add workspace with path validation (existing directory, follows symlinks)
|
||||
- [x] Remove / rename workspace
|
||||
- [x] Quick-switch from topbar dropdown
|
||||
- [x] Sidebar live workspace display (name + path)
|
||||
- [x] New sessions inherit last-used workspace
|
||||
- [x] Browse workspace directory tree with type icons
|
||||
- [x] Tree view with expand / collapse + lazy load (#22)
|
||||
- [x] Breadcrumb navigation in subdirectories
|
||||
- [x] Preview text / code (read-only)
|
||||
- [x] Preview markdown (rendered + tables + Mermaid + KaTeX)
|
||||
- [x] Preview images (PNG, JPG, GIF, SVG, WEBP, AVIF inline)
|
||||
- [x] Preview PDF / SVG / audio / video / Excalidraw / CSV / JSON / YAML
|
||||
- [x] Edit files inline (Edit button, Enter saves, Escape cancels)
|
||||
- [x] Create / rename / delete files and folders (in current directory)
|
||||
- [x] Drag-drop / click / clipboard paste upload
|
||||
- [x] Archive upload (zip / tar) with extraction
|
||||
- [x] Copy absolute + relative file path
|
||||
- [x] Syntax highlighted code preview (Prism.js, language-aware)
|
||||
- [x] File preview auto-close on directory navigation
|
||||
- [x] Right panel resizable (drag inner edge)
|
||||
- [x] Embedded workspace terminal (`/api/terminal/{start,input,output}`)
|
||||
- [x] Git branch + dirty status badge in workspace header
|
||||
|
||||
### Cron jobs
|
||||
- [x] List all cron jobs (Tasks sidebar tab)
|
||||
- [x] View job details (prompt, schedule, last run, output)
|
||||
- [x] Run / pause / resume / delete
|
||||
- [x] Create job from UI (name, schedule, prompt, delivery target)
|
||||
- [x] Edit job inline (full create-form parity, including skills)
|
||||
- [x] Skill picker in create + edit forms
|
||||
- [x] No-cron-syntax schedule builder (frequency presets + time / day pickers, inline expression preview)
|
||||
- [x] Cron run history viewer (expandable per job)
|
||||
- [x] Cron completion alerts (toast + badge)
|
||||
- [x] Run-status tracking with live watch mode
|
||||
|
||||
### Skills
|
||||
- [x] List all skills grouped by category
|
||||
- [x] Search / filter by name, description, category
|
||||
- [x] View full SKILL.md content
|
||||
- [x] View skill linked files
|
||||
- [x] Create / edit / delete skill
|
||||
- [x] `/skills` slash command
|
||||
|
||||
### Memory
|
||||
- [x] View personal notes (MEMORY.md) rendered as markdown
|
||||
- [x] View user profile (USER.md) rendered as markdown
|
||||
- [x] Last-modified timestamp per section
|
||||
- [x] Add / edit memory entries inline
|
||||
|
||||
### Profiles
|
||||
- [x] Multi-profile support — create, switch, delete (#28)
|
||||
- [x] Topbar profile picker with gateway-status dots
|
||||
- [x] Profile management panel (full CRUD)
|
||||
- [x] Seamless switching (no server restart, refreshes models / skills / memory / cron / workspace + forces config reload)
|
||||
- [x] Profile-local workspace storage
|
||||
- [x] First-run onboarding wizard with provider config (OpenRouter / Anthropic / OpenAI / Custom)
|
||||
- [x] In-app OAuth for Codex and Claude
|
||||
- [x] Concurrent per-profile isolation (context-local home override so parallel workers can't clobber each other)
|
||||
|
||||
### Configuration
|
||||
- [x] Settings panel (default model, default workspace, send key, theme, skin, voice, font size)
|
||||
- [x] Searchable settings
|
||||
- [x] Send key preference (Enter or Ctrl+Enter)
|
||||
- [x] Password authentication (off by default)
|
||||
- [x] Per-session toolset override
|
||||
- [x] Personality config via `config.yaml`
|
||||
- [x] Reasoning effort persistence
|
||||
|
||||
### Notifications
|
||||
- [x] Cron job completion alerts
|
||||
- [x] Background agent error banner
|
||||
- [x] Approval pending badge
|
||||
- [x] Provider / model mismatch toast warning
|
||||
|
||||
### Slash commands
|
||||
- [x] Command registry + autocomplete dropdown
|
||||
- [x] Built-ins: `/help`, `/clear`, `/model`, `/workspace`, `/new`, `/usage`, `/theme`, `/compact`, `/queue`, `/interrupt`, `/steer`, `/goal`, `/btw`, `/reasoning`, `/skills`, `/toolsets`
|
||||
- [x] Transparent pass-through for unrecognized commands
|
||||
|
||||
### Security
|
||||
- [x] Password auth with signed HMAC HTTP-only cookies (24h TTL)
|
||||
- [x] Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
|
||||
- [x] CSRF protection (scheme-aware, port-normalized for reverse proxies)
|
||||
- [x] CORS preflight echoes allowlisted origin only — never wildcard
|
||||
- [x] PBKDF2 password hashing
|
||||
- [x] Rate limiting on auth endpoints
|
||||
- [x] Session ID validation
|
||||
- [x] SSRF guard on `/api/models/live`, `cfg_base_url`, `custom_providers[]`
|
||||
- [x] ENV_LOCK around env mutations
|
||||
- [x] XSS sanitization on all rendered HTML
|
||||
- [x] HMAC-signed signing keys (random per install)
|
||||
- [x] Skills path-traversal guard
|
||||
- [x] Secure cookie flags (HttpOnly, SameSite, Secure when HTTPS)
|
||||
- [x] Error message sanitization (no stack traces in responses)
|
||||
- [x] POST body size limit (20MB)
|
||||
- [x] Upload path-traversal guard
|
||||
- [x] Credential redaction in API responses
|
||||
- [x] Profile `.env` secret isolation on switch
|
||||
- [x] Open-redirect guard on `?next=` login param (bounded decode, collapses login-loop chains)
|
||||
- [x] Auto-install gate (opt-in via `HERMES_WEBUI_AUTO_INSTALL=1`)
|
||||
|
||||
### Visual / UX
|
||||
- [x] 3 base modes — Light, Dark, System (auto-sync)
|
||||
- [x] 21 skins layered on the base mode — default, ares, catppuccin, charizard, codex, geist-contrast, github, graphite, hepburn, mono, neon, neon-paint, neon-soft, nous, poseidon, sienna, sisyphus, slate, terracotta, verdigris, zeus
|
||||
- [x] 2-axis appearance model (base mode + skin) for community theme contributions
|
||||
- [x] Mermaid diagram rendering (with fit / fullscreen toolbar)
|
||||
- [x] KaTeX math rendering with fence-before-math fix
|
||||
- [x] Syntax highlighting (Prism.js, language-aware, YAML newline preservation)
|
||||
- [x] Markdown image syntax `` and inline MEDIA: tokens render as `<img>`
|
||||
- [x] Plain URL auto-linking
|
||||
- [x] Inline markdown in table cells (bold, italic, code, links)
|
||||
- [x] Code block copy button
|
||||
- [x] Tool card expand / collapse toggle
|
||||
- [x] Collapsible thinking / reasoning cards (Claude extended thinking, o3 reasoning tokens)
|
||||
- [x] Message timestamps (subtle, full date on hover)
|
||||
- [x] Chat header (model, icon, TPS badge, timestamp) scales with text-size preference
|
||||
- [x] Empty composer hides send button (icon-circle with pop-in animation)
|
||||
- [x] Pluggable Lucide SVG icons (no emoji rendering inconsistencies)
|
||||
- [x] Composer-centric controls (v0.50.0 UI overhaul)
|
||||
- [x] Hermes Control Center modal (centralized actions)
|
||||
- [x] Workspace panel state machine (defaults closed, opens for browsing / preview)
|
||||
- [x] Three-panel desktop layout keeps a readable conversation floor when resizing
|
||||
- [x] PWA manifest + service worker (offline shell)
|
||||
- [x] Favicon (SVG + PNG + ICO)
|
||||
- [x] Branded onboarding wizard
|
||||
|
||||
### Voice
|
||||
- [x] Voice input via Web Speech API (push-to-talk dictation)
|
||||
- [x] Hands-free voice mode (turn-based conversation, opt-in via Settings → Preferences)
|
||||
- [x] TTS playback of responses (configurable voice, rate, pitch)
|
||||
|
||||
### Mobile
|
||||
- [x] Hamburger sidebar (slide-in overlay)
|
||||
- [x] Bottom navigation bar (5-tab iOS-style)
|
||||
- [x] Files slide-over (right panel as slide-over)
|
||||
- [x] 44px minimum touch targets
|
||||
- [x] Container queries on composer
|
||||
- [x] Android Chrome compatibility fixes
|
||||
- [x] PWA installation (manifest + icons + Android support)
|
||||
- [x] Streaming-scroll hardening — off-screen height retention, native scroll-anchoring, no jump-to-top on iOS/Android
|
||||
- [x] Mobile drawer surfaces dashboard link + extension nav-actions
|
||||
|
||||
### Internationalization
|
||||
- [x] 15 locales — English, Italian, Japanese, Russian, Spanish, German, Chinese (zh + zh-Hant), Portuguese, Korean, French, Czech, Turkish, Polish, Vietnamese
|
||||
- [x] Near-full translation coverage across non-English locales — a small English-fallback tail remains on recently added keys (measured ~2% for Czech up to ~15% for Turkish), backfilled by periodic translation passes
|
||||
- [x] Key-parity test ensures every locale has every key
|
||||
- [x] Right-to-left and CJK input (IME composition fixes)
|
||||
|
||||
### Gateway integration
|
||||
- [x] Real-time gateway sessions in sidebar (Telegram, Discord, Slack, WeChat/Weixin, Signal, SMS) via SSE + DB polling
|
||||
- [x] Cross-channel handoff dock — composer-docked flyout summarizing the live external session
|
||||
- [x] Transcript-summary card at 10+ rounds
|
||||
- [x] Sidebar dedup keying on per-conversation identity (distinct chats from same platform stay separate)
|
||||
- [x] Gateway session sync skips dup / delete options for external sessions
|
||||
- [x] LLM Gateway routing metadata display — assistant turns and session metadata show the served model/provider, failover path, and model-switch warnings (#732)
|
||||
- [x] Gateway status card in Settings (#1457)
|
||||
- [x] Gateway approval-runs API opt-in (documented)
|
||||
|
||||
### MCP integration
|
||||
- [x] MCP server management UI (System Settings → MCP Servers)
|
||||
- [x] Add / edit / delete MCP server entries
|
||||
|
||||
### Extension system
|
||||
- [x] Opt-in extension loader — serve local static assets, inject same-origin CSS/JS into the app shell
|
||||
- [x] One-click install from an in-app gallery (Settings → Extensions) into a WebUI-managed state-dir directory (no restart, no env vars)
|
||||
- [x] Manifest contract (`extensions.json`) for bundled / multi-extension installs
|
||||
- [x] Capability surface — register custom themes (skins), register custom TTS engines, add nav-actions, declare loopback sidecars, embed an external web app in an iframe tab
|
||||
- [x] Per-extension settings schema + owned storage
|
||||
- [x] Consented extension sidecar proxy path (no arbitrary backend route registration)
|
||||
- [x] Status + diagnostics endpoint
|
||||
- [x] Trust model documented — extensions run with full session authority; install only vetted/self-authored
|
||||
- [x] Vetted, versioned library repo with CI safety gates — [hermes-webui/hermes-webui-extensions](https://github.com/hermes-webui/hermes-webui-extensions) ("in the registry == vetted")
|
||||
- [x] Core repo bundles zero extensions — clean client-side extensions are migrated to the library repo, not merged into core
|
||||
|
||||
### Distribution
|
||||
- [x] Docker support (multi-arch amd64 + arm64, HEALTHCHECK, UID/GID auto-detect)
|
||||
- [x] Two-container Docker compose (webui + agent)
|
||||
- [x] GHCR auto-publish on tag push
|
||||
- [x] Subpath mount support (reverse proxy at `/hermes/`)
|
||||
- [x] PWA installable from any browser
|
||||
- [x] Native macOS app — universal Intel + Apple Silicon, signed + notarized DMG, Sparkle 2 auto-update
|
||||
- [x] Native Windows + Linux + macOS desktop app — Rust/Tauri, per-platform installers
|
||||
- [x] Native Android app — Play-ready APK/AAB releases
|
||||
- [x] Native iOS app — iPhone client (connects over Tailscale)
|
||||
|
||||
See [Native clients](#native-clients) for the per-repo detail.
|
||||
|
||||
---
|
||||
|
||||
## Native clients
|
||||
|
||||
The web app is the engine; a small fleet of native shells wrap it so Hermes runs
|
||||
as a real app on every platform. Each lives in its **own repo** with its own
|
||||
versioning and release cadence — none is a fork of the WebUI source.
|
||||
|
||||
| Client | Platforms | Tech | Repo |
|
||||
|---|---|---|---|
|
||||
| **Hermes for Mac** | macOS (Intel + Apple Silicon) | Swift + WKWebView, SSH tunnel, Sparkle 2 auto-update, signed + notarized DMG | [hermes-webui/hermes-swift-mac](https://github.com/hermes-webui/hermes-swift-mac) |
|
||||
| **Hermes Desktop** | Windows + Linux + macOS | Rust / Tauri (WebView2 / WebKitGTK), per-platform installers | [hermes-webui/hermes-desktop-rust](https://github.com/hermes-webui/hermes-desktop-rust) |
|
||||
| **Hermes for Android** | Android | Native Android, Play-ready releases | [hermes-webui/hermes-android](https://github.com/hermes-webui/hermes-android) |
|
||||
| **Hermes for iOS** | iOS (iPhone) | Native Swift, connects over Tailscale (QR / hostname pairing) | [hermes-webui/hermes-swift-ios](https://github.com/hermes-webui/hermes-swift-ios) |
|
||||
|
||||
One server, every client: run the WebUI on one machine and reach it from web,
|
||||
desktop, and phone. Live versions and release notes are on each repo's Releases
|
||||
page — this table intentionally carries no version numbers (they drift per repo).
|
||||
|
||||
---
|
||||
|
||||
## Autonomous project maintenance
|
||||
|
||||
Hermes WebUI is maintained by an autonomous agent system: inbound issues and PRs
|
||||
are triaged, deep-reviewed (multi-model gate + full test suite + browser QA),
|
||||
released, and closed with attribution — largely without manual steering. The
|
||||
project-agnostic distillation of that system is published as an open framework so
|
||||
any maintainer can adopt it:
|
||||
|
||||
- **StewardOS** — [nesquena/steward-os](https://github.com/nesquena/steward-os) ·
|
||||
docs at [nesquena.github.io/steward-os](https://nesquena.github.io/steward-os/).
|
||||
Roles, autonomy bands, the security spine, issue/PR/quality-gate lifecycles,
|
||||
and reusable skills — harness-agnostic (no specific agent runtime required).
|
||||
|
||||
StewardOS is the *generalization*; the WebUI's own operating procedures are the
|
||||
*source*. This is the reason contributor PRs get same-day deep review and the
|
||||
release cadence runs multiple ships per day.
|
||||
|
||||
---
|
||||
|
||||
## Forward work
|
||||
|
||||
Most of the original forward-work list has shipped. What remains is either an
|
||||
open feature request under active consideration or an explicitly-deferred concept.
|
||||
Issue state drifts — re-derive with `gh issue list --repo nesquena/hermes-webui`.
|
||||
|
||||
### Open candidates (feature requests under active consideration)
|
||||
|
||||
| Theme | Tracking | Why |
|
||||
|---|---|---|
|
||||
| Lightweight in-app Canvas editing | #1255 | Text canvas for prompt drafting / shared notes |
|
||||
| Provider / Model source-of-truth alignment | #1240 | Reconcile WebUI vs CLI vs Gateway provider resolution |
|
||||
| Built-in SearXNG web search | #1037 | Lightweight search tool with on / off toggle |
|
||||
| Sunset legacy `LMSTUDIO_API_KEY` env var | #1502 | Alias stays for one minor cycle, then removed |
|
||||
| Native MCP server expose | #733 | Hermes WebUI as an MCP server for direct agent integration |
|
||||
| Teams / agents management panel | #719 | Editable names, roles, assignments |
|
||||
| WebUI profile ↔ Hermes runtime model alignment | #749 | Design parity between WebUI profiles and the runtime model |
|
||||
| Add agent / replace model modals | #698 | Dedicated modals for agent + model management |
|
||||
|
||||
### Backlog (deferred, listed for visibility)
|
||||
|
||||
- **Insights / monitoring suite** — data tabs / live integration (#722), monitor dashboard concepts (#721)
|
||||
- **Code execution inline cells** — Jupyter-style cell rendering inside chat
|
||||
- **Sharing / public conversation URLs** — requires hosted backend with access control (out of scope for self-host)
|
||||
|
||||
### Intentionally not planned
|
||||
- Full SwiftUI/native rewrite of the frontend — the WebView shells already get ~95% of native benefit at a fraction of the maintenance cost
|
||||
- App Store distribution for the desktop apps — sandboxing breaks the local-server model
|
||||
- Real-time multi-user collaboration — single-user assumption throughout
|
||||
- Multi-tenant / white-label hosting — the project is single-user / multi-profile, not multi-tenant
|
||||
- Anthropic / Claude proprietary features — Projects AI memory, Claude artifacts sync (not reproducible)
|
||||
|
||||
> Note: a plugin/extension marketplace was once "not planned" — that gap is now
|
||||
> filled by the [extension system](#extension-system) + the vetted library repo,
|
||||
> which cover the customization surface without a heavyweight dependency system.
|
||||
|
||||
---
|
||||
|
||||
## Sprint history
|
||||
|
||||
Per-version detail lives in [CHANGELOG.md](./CHANGELOG.md). The table below is a high-level chronology of major sprint themes; individual PR / fix detail lives in CHANGELOG to keep this file readable.
|
||||
|
||||
| Range | Theme | Highlights |
|
||||
|---|---|---|
|
||||
| Sprints 1–6 | Foundations + workspace | server / static split, JS module split, workspace CRUD, file editor, message queue + INFLIGHT, isolated test environment |
|
||||
| Sprint 7 | Wave 2 core | Cron / skill / memory CRUD, session content search, health endpoint, git init |
|
||||
| Sprint 8 | Daily-driver finish line | Edit + regenerate, regenerate last response, clear conversation, Prism.js, queue + INFLIGHT polish |
|
||||
| Sprints 9–10 | Codebase health + operational polish | `app.js` → 6 modules, server.py → `api/` modules, tool card UX, background task cancel, regression tests |
|
||||
| Sprint 11 | Multi-provider models + streaming | Dynamic model dropdown, smooth scroll pinning, routes extracted to `api/routes.py` |
|
||||
| Sprint 12 | Settings + reliability + session QoL | Settings panel, SSE auto-reconnect, pin sessions, JSON import |
|
||||
| Sprint 13 | Alerts + polish | Cron alerts, background error banner, session duplicate, browser tab title |
|
||||
| Sprint 14 | Visual polish + workspace ops | Mermaid, message timestamps, file rename, folder create, session tags, archive |
|
||||
| Sprint 15 | Session projects + code copy | Projects / folders, code copy button, tool card expand / collapse |
|
||||
| Sprint 16 | Sidebar visual polish | SVG icons, action dropdown, pin indicator, project border, safe HTML rendering |
|
||||
| Sprint 17 | Workspace polish + slash commands | Breadcrumb nav, slash command autocomplete, send key setting (#26) |
|
||||
| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking / reasoning cards, expandable directory tree (#22) |
|
||||
| Sprint 19 | Auth + security hardening | Password auth, login page, security headers, body limit (#23) |
|
||||
| Sprint 20 | Voice input + send button | Web Speech API voice, send button polish |
|
||||
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, mobile nav, slide-over files, Docker support (#21, #7) |
|
||||
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) |
|
||||
| Sprint 23 | Agentic transparency | Token / cost display, subagent cards, skill picker in cron, profile-local storage |
|
||||
| Sprint 24 | Web polish | rAF streaming, git detection, collapsible date groups, context ring (#80–#83) |
|
||||
| Sprint 25 | macOS desktop application | Native Swift + WKWebView shell, universal DMG, Sparkle 2 auto-update |
|
||||
| Sprint 26 | Pluggable themes | Light / Slate / Solarized / Monokai / Nord, settings unsaved-changes guard, `/theme` |
|
||||
| Sprint 27 | Theme polish | 30+ hardcoded colors → CSS variables, light theme final polish |
|
||||
| Sprint 28 | Security hardening | Env race fix, random signing key, upload traversal, PBKDF2 |
|
||||
| Sprints 29–32 | Model routing + custom endpoints + reasoning | Model routing by provider prefix, custom endpoint URL fix, OLED theme, top-level reasoning, message_count sync |
|
||||
| Sprint 33 | Approval card + Lucide icons | Approval prompt surfaced, emoji → SVG, login CSP fix, update diagnostics |
|
||||
| Sprint 34 | v0.50.0 UI overhaul | Composer-centric controls, Control Center modal, workspace state machine, collapsible date groups, rAF throttle, context ring |
|
||||
| Sprints 35–37 | Onboarding + i18n | First-run wizard, provider config, Spanish locale, Docker two-container, mobile Profiles button |
|
||||
| Sprints 38–40 | Session + UI polish | Five-bug clean-up, sidebar timestamp, test port isolation |
|
||||
| Sprints 41–42 | Renderer hardening + KaTeX + handoff | Context ring live usage, renderMd link / image / code stash chain, MEDIA: image rendering, gateway handoff foundation |
|
||||
| Sprints 43+ | Continuous contributor sprints | Custom providers, more locales, IME fixes, model-switch toast, approval queue multi-slot, profile polish, font-size CSS, contributor wave |
|
||||
| Ongoing | Ecosystem expansion + reliability | Extension system + library, Rust/Android/iOS native clients, StewardOS, gateway platform expansion (Signal/SMS/WeChat), Transparent Stream, mobile scroll hardening, session-load perf, test-isolation flake elimination |
|
||||
|
||||
---
|
||||
|
||||
## Versioning conventions
|
||||
|
||||
- **Patch** (`v0.51.X`) — small batches, contributor PR releases, hotfixes
|
||||
- **Minor** (`v0.X.0`) — sprint completion, new feature surface, architecture milestone
|
||||
- **Major** (`v1.0.0`) — declared when the feature surface stabilizes and reliability across all clients reaches steady state
|
||||
|
||||
Per-version detail and contributor attribution live in [CHANGELOG.md](./CHANGELOG.md).
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
# Hermes Web UI — Sprint Planning
|
||||
|
||||
> Forward-looking sprint plan and active queue.
|
||||
>
|
||||
> Current state: v0.51.792 | ~11,500 tests | port 8787
|
||||
> Target A (CLI parity): ✅ Complete
|
||||
> Target B (Claude parity): ~95% — full subagent transparency UI and code-execution cells remain
|
||||
>
|
||||
> Per-version detail: [CHANGELOG.md](./CHANGELOG.md)
|
||||
> Sprint history (chronology): [ROADMAP.md](./ROADMAP.md)
|
||||
|
||||
---
|
||||
|
||||
## How sprints work here
|
||||
|
||||
A sprint is a thematic batch — usually 3–8 PRs landed together as a release. Each sprint has:
|
||||
|
||||
1. **Theme** — single-sentence framing of what changes
|
||||
2. **Items** — selected work, with tracking issue / PR
|
||||
3. **Out of scope** — what is explicitly deferred and why
|
||||
4. **Risks** — known sharp edges
|
||||
5. **Retro** — once shipped, what we learned
|
||||
|
||||
External contributor PRs that don't fit a planned sprint get released individually as patch versions (`v0.50.X`). Sprints are reserved for coherent batches where the items reinforce each other.
|
||||
|
||||
---
|
||||
|
||||
## Active sprint candidates
|
||||
|
||||
These are the issues currently labeled `sprint-candidate` — the inbox the next sprint plan draws from. Each item already has a confirmed root cause or design direction.
|
||||
|
||||
| # | Title | Type | Sprint fit |
|
||||
|---|---|---|---|
|
||||
| #1458 | Persistent-host crashes — bootstrap fork pattern, state.db FD leak, HTTP-unhealthy wedge | bug, stability | Stability sprint candidate |
|
||||
| #1426 | OpenRouter free-tier `:free` variants invisible (tool-support filter) | bug + feat | Model picker sprint candidate |
|
||||
| #1362 | In-app OAuth login for Codex and Claude (currently terminal-only) | feat, ux | Onboarding sprint candidate |
|
||||
| #1360 | macOS desktop app — auto-scroll overrides user scroll (#677 regression) | bug, ux | Desktop app polish |
|
||||
| #1291 | GLM mutually exclusive between main agent and auxiliary title generation | bug | Auxiliary-route sprint |
|
||||
|
||||
The active queue stays small — it's the next 1-2 sprints' worth of work. The broader backlog of feature requests lives in [ROADMAP.md → Forward Work](./ROADMAP.md#forward-work) and on the GitHub `enhancement` label.
|
||||
|
||||
---
|
||||
|
||||
## Planning principles
|
||||
|
||||
**Phase-0 fit assessment.** Every sprint candidate gets a marginal-benefit screen first: does this make the product noticeably better for real users, or does it add surface area that costs more to maintain than it earns? Five-question fit screen — need / shape / bloat / clutter / scope.
|
||||
|
||||
**Salvage over absorb.** When a contributor PR is partial or scoped wrong, prefer splicing the good parts into a maintainer-side PR with `Co-authored-by` attribution rather than asking for multiple rebase rounds. Only absorb whole when the PR is genuinely shippable as-is.
|
||||
|
||||
**Independent-review gate.** Self-built PRs need either (a) Opus advisor pass on the merged stage diff, or (b) independent review from a separate reviewer. High-risk batches (large LOC, security, locks, durability) get both.
|
||||
|
||||
**Per-PR release velocity.** When a single PR fixes a real bug and has a clean review, ship it as its own patch release the same day rather than waiting for a sprint batch. Friction-free is the goal — sprint batches exist for coherence, not for arbitrary grouping.
|
||||
|
||||
**No feature creep mid-PR.** If a contributor proposes a scope addition during review, file it as a separate PR and link from the original. The current PR keeps its original boundaries.
|
||||
|
||||
**Pre-release gate (mandatory).** Every release runs:
|
||||
1. `pytest tests/ -q --timeout=120` clean
|
||||
2. Browser sanity check (HTTP-level API tests against a test server)
|
||||
3. Opus advisor pass on the merged stage diff with a written brief
|
||||
4. CHANGELOG.md + ROADMAP.md + TESTING.md version stamp
|
||||
5. CI green on Python 3.11 / 3.12 / 3.13
|
||||
|
||||
Skipping any of these requires a documented "I'm doing an override" from the maintainer.
|
||||
|
||||
---
|
||||
|
||||
## Sprint shape
|
||||
|
||||
A typical sprint runs 3-7 days end to end:
|
||||
|
||||
| Phase | Duration | Output |
|
||||
|---|---|---|
|
||||
| Triage | 0.5 day | Active queue → selected items + scope notes |
|
||||
| Design / spike | 0.5–1 day | Design notes for items needing them; deferrals documented |
|
||||
| Build | 2–4 days | Each item on its own branch + PR for independent review |
|
||||
| Review | 0.5–1 day | Maintainer + Opus advisor passes; SHOULD-FIX absorbed in-release |
|
||||
| Stage + ship | 0.5 day | Stage branch, full test suite, release PR, tag, deploy, verify live |
|
||||
| Hygiene | 0.5 day | Close PRs / issues, GitHub release notes, docs sync, retro |
|
||||
|
||||
Smaller sprints (1–2 PRs) compress to 1–2 days end to end. Single-PR releases skip the stage branch and ship via a release PR directly off the contributor's branch (or a maintainer-rebased copy if the fork doesn't grant write access).
|
||||
|
||||
---
|
||||
|
||||
## Sprint history
|
||||
|
||||
Per-version detail is in [CHANGELOG.md](./CHANGELOG.md). High-level theme chronology is in [ROADMAP.md → Sprint History](./ROADMAP.md#sprint-history). A few notable sprints worth highlighting:
|
||||
|
||||
- **Sprint 19** — auth + security hardening. First sprint that made the app safe to leave running beyond localhost.
|
||||
- **Sprint 21** — mobile responsive + Docker. Two-container compose enabled the first wave of self-host deployments.
|
||||
- **Sprint 22** — multi-profile support. Major CLI-parity unlock; profile switching is now seamless without server restart.
|
||||
- **Sprint 25** — macOS desktop application. Native Swift + WKWebView shell, universal Intel + Apple Silicon DMG, Sparkle 2 auto-update. Lives in the separate `hermes-webui/hermes-swift-mac` repo.
|
||||
- **Sprint 26** — pluggable themes. CSS-variable-driven 8-theme system that lets community contributors add themes as pure CSS.
|
||||
- **Sprint 34** — v0.50.0 UI overhaul. Composer-centric controls, Control Center modal, workspace state machine, rAF streaming throttle.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (across all sprints)
|
||||
|
||||
These are intentionally not on the roadmap. Listing them here to save planning cycles.
|
||||
|
||||
- **Multi-user collaboration** — single-user assumption throughout the codebase. Refactoring would be a from-scratch architecture change.
|
||||
- **Sharing / public conversation URLs** — requires hosted backend with access control + CDN. Out of scope for self-hosted.
|
||||
- **Plugin marketplace** — Hermes skills already cover this surface.
|
||||
- **Anthropic / Claude proprietary features** — Projects AI memory, Claude artifacts sync. Not reproducible.
|
||||
- **Linux / Windows native app wrappers** — macOS done; demand on other platforms not yet established. Web UI works in any browser.
|
||||
- **App Store distribution** — sandboxing breaks the local-server model.
|
||||
- **Auto-update mechanism for the Python webapp** — Sparkle 2 covers the Mac app; the webapp updates via `git pull` + restart, which is the same as every other Python service.
|
||||
|
||||
---
|
||||
|
||||
## Templates
|
||||
|
||||
When opening a new sprint plan, copy this structure:
|
||||
|
||||
```markdown
|
||||
# Sprint NN — <theme>
|
||||
|
||||
**Version target:** vX.Y.Z
|
||||
**Theme:** <single sentence>
|
||||
**Date started:** YYYY-MM-DD
|
||||
**Status:** PLANNED | IN PROGRESS | COMPLETED — vX.Y.Z
|
||||
|
||||
## Items
|
||||
| # | Issue | Title | Complexity | Files | PR |
|
||||
|---|-------|-------|------------|-------|-----|
|
||||
|
||||
## Rationale
|
||||
Why these items, why now.
|
||||
|
||||
## Build approach
|
||||
Per-item branch + PR; or single combined branch if items are tightly coupled.
|
||||
|
||||
## Out of scope
|
||||
What did NOT make this sprint and why.
|
||||
|
||||
## Known risks
|
||||
Sharp edges to watch during review.
|
||||
|
||||
## PR Status
|
||||
| Issue | PR | Status |
|
||||
|-------|-----|--------|
|
||||
|
||||
## Retro (post-ship)
|
||||
What worked, what we'd do differently.
|
||||
```
|
||||
|
||||
The maintainer's planning notes for each sprint live in the workspace repo (private), not in this file. This file is the public-facing planning shape.
|
||||
-2011
File diff suppressed because it is too large
Load Diff
@@ -1,209 +0,0 @@
|
||||
# Hermes Web UI — Themes
|
||||
|
||||
Hermes Web UI splits **appearance** into two independent pickers:
|
||||
|
||||
- **Theme** — the mode: `System`, `Dark`, or `Light`. Drives the background,
|
||||
text, surface, and chrome colors.
|
||||
- **Skin** — the accent palette: built-in skins ship as named keys. Drives only
|
||||
the `--accent` family (active states, links, focus rings, primary actions).
|
||||
|
||||
You pick one of each and they combine, so the look adapts to your environment
|
||||
without losing your favorite accent — pure CSS, no Python changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Switching Appearance
|
||||
|
||||
**Settings panel:** Click the gear icon → **Appearance**. The **Theme** card
|
||||
toggles Light/Dark/System; the **Skin** grid offers the built-in accent palettes.
|
||||
Preview is instant — the UI updates as you click.
|
||||
|
||||
**Slash command:** Type `/theme <name>` in the composer. The command accepts
|
||||
both theme names (`system`, `dark`, `light`) and skin names (`default`, `ares`,
|
||||
`mono`, `slate`, `poseidon`, `sisyphus`, `charizard`, `sienna`,
|
||||
`catppuccin`, `nous`, `geist-contrast`, `zeus`). It updates the matching axis and leaves the other one
|
||||
alone.
|
||||
|
||||
**Persistence:** Both choices are stored in `localStorage` for flicker-free
|
||||
loading, and saved server-side via `POST /api/settings` (under `theme` and
|
||||
`skin` keys in `settings.json`).
|
||||
|
||||
---
|
||||
|
||||
## Built-in Themes
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| **System** (default) | Follows the OS `prefers-color-scheme` preference and updates live. |
|
||||
| **Dark** | Deep dark surfaces, low-glare for long sessions. |
|
||||
| **Light** | Bright surfaces with dark text, high contrast for daylight environments. |
|
||||
|
||||
The theme is applied as a class on `<html>`: `.dark` is present for dark mode,
|
||||
absent for light. System mode tracks the OS preference at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Built-in Skins
|
||||
|
||||
| Skin | Description |
|
||||
|------|-------------|
|
||||
| **Default** | The original Hermes gold accent. Warm and understated. |
|
||||
| **Ares** | Fiery red. High-energy and assertive. |
|
||||
| **Mono** | Neutral gray. Distraction-free, for deep focus. |
|
||||
| **Slate** | Slate blue-gray. Subtle and grown-up. |
|
||||
| **Poseidon** | Ocean blue. Calm and focused for long sessions. |
|
||||
| **Sisyphus** | Vivid purple. Distinctive without being loud. |
|
||||
| **Charizard** | Warm orange. Energetic and easy on the eyes. |
|
||||
| **Sienna** | Warm clay and sand earth palette. Soft and natural. |
|
||||
| **Catppuccin** | Catppuccin Latte/Mocha palette with Mauve accent. |
|
||||
| **Nous** | Steel-blue accent with dashed technical surfaces. |
|
||||
| **Geist Contrast** (`geist-contrast`) | Geist-inspired monochrome surfaces with a restrained dark-mode `#FFF175` accent. |
|
||||
| **Zeus** | OLED-near-black dark surfaces that keep the default gold accent. Dark-focused; falls back to the default light palette in light mode. |
|
||||
|
||||
Each skin defines paired light + dark variants so it reads cleanly on either
|
||||
theme. The skin is applied as `data-skin="<name>"` on `<html>` (the default
|
||||
skin clears the attribute).
|
||||
|
||||
---
|
||||
|
||||
## Creating a Custom Skin
|
||||
|
||||
A skin is a small CSS block that overrides the accent variables for both the
|
||||
light and dark variants:
|
||||
|
||||
```css
|
||||
/* Light variant */
|
||||
:root[data-skin="my-skin"] {
|
||||
--accent: #2E7D32; /* Active states, links, primary buttons */
|
||||
--accent-hover: #1B5E20; /* Hover */
|
||||
--accent-bg: rgba(46,125,50,0.08); /* Soft tinted backgrounds */
|
||||
--accent-bg-strong: rgba(46,125,50,0.15); /* Highlighted backgrounds */
|
||||
--accent-text: #1B5E20; /* Text on accent bg */
|
||||
}
|
||||
|
||||
/* Dark variant — usually lighter or more saturated for contrast */
|
||||
:root.dark[data-skin="my-skin"] {
|
||||
--accent: #66BB6A;
|
||||
--accent-hover: #43A047;
|
||||
--accent-bg: rgba(102,187,106,0.08);
|
||||
--accent-bg-strong: rgba(102,187,106,0.15);
|
||||
--accent-text: #66BB6A;
|
||||
}
|
||||
```
|
||||
|
||||
Two ways to ship it:
|
||||
|
||||
1. **In the repo (built-in):** add the block to `static/style.css`, register it
|
||||
in the Settings skin picker (`static/index.html`) and in the `/theme` command
|
||||
list (`static/commands.js`), then open a PR.
|
||||
|
||||
2. **Self-hosted (no fork):** use the WebUI extensions surface — see
|
||||
`docs/EXTENSIONS.md`. Drop your CSS in `HERMES_WEBUI_EXTENSION_DIR` and
|
||||
declare it in `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS`. No code changes
|
||||
needed; the skin attribute can be set from your own JS.
|
||||
|
||||
Extensions that register a skin through `window.registerHermesSkin()` may
|
||||
also set `scheme: "light"` or `scheme: "dark"` for light-only or dark-only
|
||||
skins. The saved Theme preference stays unchanged, but WebUI applies the
|
||||
matching effective base class while that skin is selected so System/Light or
|
||||
System/Dark does not mix incompatible base tokens into the skin.
|
||||
|
||||
### Tips
|
||||
|
||||
- **Test both themes.** A skin that pops on Dark can be illegible on Light.
|
||||
Always check `:root[data-skin]` (light) *and* `:root.dark[data-skin]` (dark).
|
||||
- **Pick contrasting `--accent-text` on `--accent-bg`.** The strong variant
|
||||
appears behind small labels and chips; weak contrast there reads as blur.
|
||||
- **The logo gradient uses `--accent` automatically**, so it adapts to your
|
||||
skin without any extra work.
|
||||
- **No server changes needed.** The `skin` setting in `settings.json` accepts
|
||||
any string, so your custom skin name persists without code changes once you
|
||||
load the CSS.
|
||||
|
||||
---
|
||||
|
||||
## Creating a Custom Theme
|
||||
|
||||
A full custom *theme* (a different overall mood, not just an accent change) is
|
||||
a larger task than a skin: it has to redefine the core palette variables
|
||||
(`--bg`, `--surface`, `--text`, `--border`, `--code-bg`, and friends) for one
|
||||
or both modes. The contract is defined in the top `:root` and `:root.dark`
|
||||
blocks of `static/style.css` — start there.
|
||||
|
||||
Most of the time, a custom **skin** is what you actually want. Reach for a
|
||||
custom theme only when the existing Light/Dark modes don't fit (for example,
|
||||
a high-contrast accessibility theme or an OLED black variant).
|
||||
|
||||
---
|
||||
|
||||
## Font Size
|
||||
|
||||
Right under Theme/Skin in **Settings → Appearance**: `Small`, `Default`,
|
||||
`Large`. Applied as `data-font-size` on `<html>` and scales the WebUI's root
|
||||
font size. Persists alongside theme and skin.
|
||||
|
||||
---
|
||||
|
||||
## Typography Tokens
|
||||
|
||||
The three font tokens are declared in `static/style.css`:
|
||||
|
||||
- `--font-ui`: user interface chrome, controls, composer chrome, labels, and
|
||||
ordinary non-code UI text
|
||||
- `--font-conversation`: user/assistant message prose
|
||||
- `--font-mono`: code, tool details, logs, identifiers, and terminal text
|
||||
|
||||
By default:
|
||||
|
||||
```css
|
||||
--font-conversation: var(--font-ui);
|
||||
--font-mono: ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
```
|
||||
|
||||
For CSS-based custom skins and extension stylesheets/scripts, override the three
|
||||
font tokens directly (`--font-ui`, `--font-conversation`, `--font-mono`) on the
|
||||
skin root (`:root[data-skin="..."]`), including any mode-specific variant.
|
||||
`window.registerHermesSkin()` currently accepts only the documented design-token
|
||||
allowlist (no `--font-*` tokens), so custom font families should be supplied via
|
||||
CSS rather than skin registration.
|
||||
|
||||
Leaving `--font-conversation` untouched keeps prose coupled to `--font-ui`;
|
||||
override it only when a different reading face is explicitly required. Avoid
|
||||
hard-coded selector-level font declarations when one of these tokens already
|
||||
expresses the intent.
|
||||
|
||||
---
|
||||
|
||||
## How It Works Internally
|
||||
|
||||
1. **Theme:** `document.documentElement.classList.toggle('dark', isDark)` —
|
||||
light mode removes the class. System mode tracks
|
||||
`matchMedia('(prefers-color-scheme: dark)')`. Extension-registered skins
|
||||
may declare a light/dark `scheme`; when selected, that scheme controls the
|
||||
effective `.dark` class without rewriting the saved Theme preference.
|
||||
2. **Skin:** `document.documentElement.dataset.skin = name` (or remove the
|
||||
attribute for `default`).
|
||||
3. **Font size:** `document.documentElement.dataset.fontSize = size` (or
|
||||
remove for `default`).
|
||||
4. **No flash on load:** a tiny inline `<script>` in `<head>` reads
|
||||
`localStorage` before the stylesheet does, so the right look is applied
|
||||
before paint.
|
||||
5. **Server sync:** preferences are saved via `POST /api/settings` and
|
||||
rehydrated on boot via `GET /api/settings`.
|
||||
|
||||
---
|
||||
|
||||
## Contributing a Skin
|
||||
|
||||
Skins are the easiest extension point — pure CSS, no Python, no JS logic. To
|
||||
contribute one upstream:
|
||||
|
||||
1. Add your `:root[data-skin="name"]` and `:root.dark[data-skin="name"]`
|
||||
blocks to `static/style.css`.
|
||||
2. Register it in the Settings skin picker in `static/index.html` and in the
|
||||
skin list used by `cmdTheme()` in `static/commands.js`.
|
||||
3. Test on desktop and mobile across both Light and Dark themes.
|
||||
4. Open a PR — skins are pure CSS additions with no backend changes needed.
|
||||
|
||||
For a custom *theme* (overriding the base palette), prefer opening an issue
|
||||
first to discuss scope, since it touches many selectors.
|
||||
@@ -0,0 +1,941 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" class="no-js">
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
|
||||
<meta name="description" content="The HTTP + SSE API that lets any client — iOS, Android, iPad, or anything — reach full Hermes parity with the Web UI.">
|
||||
|
||||
|
||||
<meta name="author" content="Hermes WebUI">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<link rel="icon" href="/api-docs/assets/images/favicon.png">
|
||||
<meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.7.6">
|
||||
|
||||
|
||||
|
||||
<title>Hermes Bridge API</title>
|
||||
|
||||
|
||||
|
||||
<link rel="stylesheet" href="/api-docs/assets/stylesheets/main.484c7ddc.min.css">
|
||||
|
||||
|
||||
<link rel="stylesheet" href="/api-docs/assets/stylesheets/palette.ab4e12ef.min.css">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
|
||||
<style>:root{--md-text-font:"Roboto";--md-code-font:"Roboto Mono"}</style>
|
||||
|
||||
|
||||
|
||||
<script>__md_scope=new URL("/api-docs/",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<body dir="ltr" data-md-color-scheme="slate" data-md-color-primary="cyan" data-md-color-accent="cyan">
|
||||
|
||||
|
||||
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
|
||||
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
|
||||
<label class="md-overlay" for="__drawer"></label>
|
||||
<div data-md-component="skip">
|
||||
|
||||
</div>
|
||||
<div data-md-component="announce">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<header class="md-header md-header--shadow" data-md-component="header">
|
||||
<nav class="md-header__inner md-grid" aria-label="Header">
|
||||
<a href="/api-docs/." title="Hermes Bridge API" class="md-header__button md-logo" aria-label="Hermes Bridge API" data-md-component="logo">
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
|
||||
|
||||
</a>
|
||||
<label class="md-header__button md-icon" for="__drawer">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
|
||||
</label>
|
||||
<div class="md-header__title" data-md-component="header-title">
|
||||
<div class="md-header__ellipsis">
|
||||
<div class="md-header__topic">
|
||||
<span class="md-ellipsis">
|
||||
Hermes Bridge API
|
||||
</span>
|
||||
</div>
|
||||
<div class="md-header__topic" data-md-component="header-topic">
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<form class="md-header__option" data-md-component="palette">
|
||||
|
||||
|
||||
|
||||
|
||||
<input class="md-option" data-md-color-media="(prefers-color-scheme: dark)" data-md-color-scheme="slate" data-md-color-primary="cyan" data-md-color-accent="cyan" aria-label="Switch to light mode" type="radio" name="__palette" id="__palette_0">
|
||||
|
||||
<label class="md-header__button md-icon" title="Switch to light mode" for="__palette_1" hidden>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3m0-7 2.39 3.42C13.65 5.15 12.84 5 12 5s-1.65.15-2.39.42zM3.34 7l4.16-.35A7.2 7.2 0 0 0 5.94 8.5c-.44.74-.69 1.5-.83 2.29zm.02 10 1.76-3.77a7.131 7.131 0 0 0 2.38 4.14zM20.65 7l-1.77 3.79a7.02 7.02 0 0 0-2.38-4.15zm-.01 10-4.14.36c.59-.51 1.12-1.14 1.54-1.86.42-.73.69-1.5.83-2.29zM12 22l-2.41-3.44c.74.27 1.55.44 2.41.44.82 0 1.63-.17 2.37-.44z"/></svg>
|
||||
</label>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<input class="md-option" data-md-color-media="(prefers-color-scheme: light)" data-md-color-scheme="default" data-md-color-primary="cyan" data-md-color-accent="cyan" aria-label="Switch to dark mode" type="radio" name="__palette" id="__palette_1">
|
||||
|
||||
<label class="md-header__button md-icon" title="Switch to dark mode" for="__palette_0" hidden>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m17.75 4.09-2.53 1.94.91 3.06-2.63-1.81-2.63 1.81.91-3.06-2.53-1.94L12.44 4l1.06-3 1.06 3zm3.5 6.91-1.64 1.25.59 1.98-1.7-1.17-1.7 1.17.59-1.98L15.75 11l2.06-.05L18.5 9l.69 1.95zm-2.28 4.95c.83-.08 1.72 1.1 1.19 1.85-.32.45-.66.87-1.08 1.27C15.17 23 8.84 23 4.94 19.07c-3.91-3.9-3.91-10.24 0-14.14.4-.4.82-.76 1.27-1.08.75-.53 1.93.36 1.85 1.19-.27 2.86.69 5.83 2.89 8.02a9.96 9.96 0 0 0 8.02 2.89m-1.64 2.02a12.08 12.08 0 0 1-7.8-3.47c-2.17-2.19-3.33-5-3.49-7.82-2.81 3.14-2.7 7.96.31 10.98 3.02 3.01 7.84 3.12 10.98.31"/></svg>
|
||||
</label>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<label class="md-header__button md-icon" for="__search">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
|
||||
</label>
|
||||
<div class="md-search" data-md-component="search" role="dialog">
|
||||
<label class="md-search__overlay" for="__search"></label>
|
||||
<div class="md-search__inner" role="search">
|
||||
<form class="md-search__form" name="search">
|
||||
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
|
||||
<label class="md-search__icon md-icon" for="__search">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
|
||||
</label>
|
||||
<nav class="md-search__options" aria-label="Search">
|
||||
|
||||
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="md-search__suggest" data-md-component="search-suggest"></div>
|
||||
|
||||
</form>
|
||||
<div class="md-search__output">
|
||||
<div class="md-search__scrollwrap" tabindex="0" data-md-scrollfix>
|
||||
<div class="md-search-result" data-md-component="search-result">
|
||||
<div class="md-search-result__meta">
|
||||
Initializing search
|
||||
</div>
|
||||
<ol class="md-search-result__list" role="presentation"></ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</nav>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="md-container" data-md-component="container">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<main class="md-main" data-md-component="main">
|
||||
<div class="md-main__inner md-grid">
|
||||
|
||||
|
||||
|
||||
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
|
||||
<div class="md-sidebar__scrollwrap">
|
||||
<div class="md-sidebar__inner">
|
||||
|
||||
|
||||
|
||||
|
||||
<nav class="md-nav md-nav--primary" aria-label="Navigation" data-md-level="0">
|
||||
<label class="md-nav__title" for="__drawer">
|
||||
<a href="/api-docs/." title="Hermes Bridge API" class="md-nav__button md-logo" aria-label="Hermes Bridge API" data-md-component="logo">
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
|
||||
|
||||
</a>
|
||||
Hermes Bridge API
|
||||
</label>
|
||||
|
||||
<ul class="md-nav__list" data-md-scrollfix>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/." class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Overview
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item md-nav__item--section md-nav__item--nested">
|
||||
|
||||
|
||||
|
||||
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_2" >
|
||||
|
||||
|
||||
<label class="md-nav__link" for="__nav_2" id="__nav_2_label" tabindex="">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Guides
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
<span class="md-nav__icon md-icon"></span>
|
||||
</label>
|
||||
|
||||
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_2_label" aria-expanded="false">
|
||||
<label class="md-nav__title" for="__nav_2">
|
||||
<span class="md-nav__icon md-icon"></span>
|
||||
|
||||
|
||||
Guides
|
||||
|
||||
|
||||
</label>
|
||||
<ul class="md-nav__list" data-md-scrollfix>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/getting-started/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Getting started
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/authentication/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Authentication
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/streaming/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Streaming (SSE)
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/conventions/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Conventions
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item md-nav__item--section md-nav__item--nested">
|
||||
|
||||
|
||||
|
||||
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_3" >
|
||||
|
||||
|
||||
<label class="md-nav__link" for="__nav_3" id="__nav_3_label" tabindex="">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Endpoint reference
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
<span class="md-nav__icon md-icon"></span>
|
||||
</label>
|
||||
|
||||
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_3_label" aria-expanded="false">
|
||||
<label class="md-nav__title" for="__nav_3">
|
||||
<span class="md-nav__icon md-icon"></span>
|
||||
|
||||
|
||||
Endpoint reference
|
||||
|
||||
|
||||
</label>
|
||||
<ul class="md-nav__list" data-md-scrollfix>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/all-endpoints/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
All endpoints
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/sessions/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Sessions & projects
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/chat-streaming/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Chat & streaming
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/approvals-clarify/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Approvals & clarify
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/models-providers/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Models, providers & personas
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/profiles-settings/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Profiles & settings
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/memory/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Memory
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/skills/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Skills
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/commands/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Commands
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/workspace-git/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Workspace, files & git
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/cron/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Cron / scheduled jobs
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/voice/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Voice
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<li class="md-nav__item">
|
||||
<a href="/api-docs/reference/insights-updates/" class="md-nav__link">
|
||||
|
||||
|
||||
|
||||
<span class="md-ellipsis">
|
||||
|
||||
|
||||
Insights & updates
|
||||
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
|
||||
<div class="md-sidebar__scrollwrap">
|
||||
<div class="md-sidebar__inner">
|
||||
|
||||
|
||||
<nav class="md-nav md-nav--secondary" aria-label="Table of contents">
|
||||
|
||||
|
||||
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="md-content" data-md-component="content">
|
||||
|
||||
<article class="md-content__inner md-typeset">
|
||||
|
||||
<h1>404 - Not found</h1>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
||||
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
|
||||
</div>
|
||||
|
||||
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg>
|
||||
Back to top
|
||||
</button>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="md-footer">
|
||||
|
||||
<div class="md-footer-meta md-typeset">
|
||||
<div class="md-footer-meta__inner md-grid">
|
||||
<div class="md-copyright">
|
||||
|
||||
<div class="md-copyright__highlight">
|
||||
Hermes WebUI — the bridge API layer. Self-hosted, sovereign, client-agnostic.
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="md-social">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<a href="https://github.com/nesquena/hermes-webui" target="_blank" rel="noopener" title="github.com" class="md-social__link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9M252.8 8C114.1 8 8 113.3 8 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C436.2 457.8 504 362.9 504 252 504 113.3 391.5 8 252.8 8M105.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2"/></svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<div class="md-dialog" data-md-component="dialog">
|
||||
<div class="md-dialog__inner md-typeset"></div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script id="__config" type="application/json">{"annotate": null, "base": "/api-docs/", "features": ["navigation.sections", "navigation.top", "navigation.tracking", "navigation.instant", "toc.follow", "search.suggest", "search.highlight", "content.code.copy", "content.code.annotate"], "search": "/api-docs/assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
|
||||
|
||||
|
||||
<script src="/api-docs/assets/javascripts/bundle.79ae519e.min.js"></script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* Lunr languages, `Danish` language
|
||||
* https://github.com/MihaiValentin/lunr-languages
|
||||
*
|
||||
* Copyright 2014, Mihai Valentin
|
||||
* http://www.mozilla.org/MPL/
|
||||
*/
|
||||
/*!
|
||||
* based on
|
||||
* Snowball JavaScript Library v0.3
|
||||
* http://code.google.com/p/urim/
|
||||
* http://snowball.tartarus.org/
|
||||
*
|
||||
* Copyright 2010, Oleg Mazko
|
||||
* http://www.mozilla.org/MPL/
|
||||
*/
|
||||
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.da=function(){this.pipeline.reset(),this.pipeline.add(e.da.trimmer,e.da.stopWordFilter,e.da.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.da.stemmer))},e.da.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.da.trimmer=e.trimmerSupport.generateTrimmer(e.da.wordCharacters),e.Pipeline.registerFunction(e.da.trimmer,"trimmer-da"),e.da.stemmer=function(){var r=e.stemmerSupport.Among,i=e.stemmerSupport.SnowballProgram,n=new function(){function e(){var e,r=f.cursor+3;if(d=f.limit,0<=r&&r<=f.limit){for(a=r;;){if(e=f.cursor,f.in_grouping(w,97,248)){f.cursor=e;break}if(f.cursor=e,e>=f.limit)return;f.cursor++}for(;!f.out_grouping(w,97,248);){if(f.cursor>=f.limit)return;f.cursor++}d=f.cursor,d<a&&(d=a)}}function n(){var e,r;if(f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(c,32),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del();break;case 2:f.in_grouping_b(p,97,229)&&f.slice_del()}}function t(){var e,r=f.limit-f.cursor;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.find_among_b(l,4)?(f.bra=f.cursor,f.limit_backward=e,f.cursor=f.limit-r,f.cursor>f.limit_backward&&(f.cursor--,f.bra=f.cursor,f.slice_del())):f.limit_backward=e)}function s(){var e,r,i,n=f.limit-f.cursor;if(f.ket=f.cursor,f.eq_s_b(2,"st")&&(f.bra=f.cursor,f.eq_s_b(2,"ig")&&f.slice_del()),f.cursor=f.limit-n,f.cursor>=d&&(r=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,e=f.find_among_b(m,5),f.limit_backward=r,e))switch(f.bra=f.cursor,e){case 1:f.slice_del(),i=f.limit-f.cursor,t(),f.cursor=f.limit-i;break;case 2:f.slice_from("løs")}}function o(){var e;f.cursor>=d&&(e=f.limit_backward,f.limit_backward=d,f.ket=f.cursor,f.out_grouping_b(w,97,248)?(f.bra=f.cursor,u=f.slice_to(u),f.limit_backward=e,f.eq_v_b(u)&&f.slice_del()):f.limit_backward=e)}var a,d,u,c=[new r("hed",-1,1),new r("ethed",0,1),new r("ered",-1,1),new r("e",-1,1),new r("erede",3,1),new r("ende",3,1),new r("erende",5,1),new r("ene",3,1),new r("erne",3,1),new r("ere",3,1),new r("en",-1,1),new r("heden",10,1),new r("eren",10,1),new r("er",-1,1),new r("heder",13,1),new r("erer",13,1),new r("s",-1,2),new r("heds",16,1),new r("es",16,1),new r("endes",18,1),new r("erendes",19,1),new r("enes",18,1),new r("ernes",18,1),new r("eres",18,1),new r("ens",16,1),new r("hedens",24,1),new r("erens",24,1),new r("ers",16,1),new r("ets",16,1),new r("erets",28,1),new r("et",-1,1),new r("eret",30,1)],l=[new r("gd",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("elig",1,1),new r("els",-1,1),new r("løst",-1,2)],w=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],p=[239,254,42,3,0,0,0,0,0,0,0,0,0,0,0,0,16],f=new i;this.setCurrent=function(e){f.setCurrent(e)},this.getCurrent=function(){return f.getCurrent()},this.stem=function(){var r=f.cursor;return e(),f.limit_backward=r,f.cursor=f.limit,n(),f.cursor=f.limit,t(),f.cursor=f.limit,s(),f.cursor=f.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return n.setCurrent(e),n.stem(),n.getCurrent()}):(n.setCurrent(e),n.stem(),n.getCurrent())}}(),e.Pipeline.registerFunction(e.da.stemmer,"stemmer-da"),e.da.stopWordFilter=e.generateStopWordFilter("ad af alle alt anden at blev blive bliver da de dem den denne der deres det dette dig din disse dog du efter eller en end er et for fra ham han hans har havde have hende hendes her hos hun hvad hvis hvor i ikke ind jeg jer jo kunne man mange med meget men mig min mine mit mod ned noget nogle nu når og også om op os over på selv sig sin sine sit skal skulle som sådan thi til ud under var vi vil ville vor være været".split(" ")),e.Pipeline.registerFunction(e.da.stopWordFilter,"stopWordFilter-da")}});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hi=function(){this.pipeline.reset(),this.pipeline.add(e.hi.trimmer,e.hi.stopWordFilter,e.hi.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.hi.stemmer))},e.hi.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿa-zA-Za-zA-Z0-90-9",e.hi.trimmer=e.trimmerSupport.generateTrimmer(e.hi.wordCharacters),e.Pipeline.registerFunction(e.hi.trimmer,"trimmer-hi"),e.hi.stopWordFilter=e.generateStopWordFilter("अत अपना अपनी अपने अभी अंदर आदि आप इत्यादि इन इनका इन्हीं इन्हें इन्हों इस इसका इसकी इसके इसमें इसी इसे उन उनका उनकी उनके उनको उन्हीं उन्हें उन्हों उस उसके उसी उसे एक एवं एस ऐसे और कई कर करता करते करना करने करें कहते कहा का काफ़ी कि कितना किन्हें किन्हों किया किर किस किसी किसे की कुछ कुल के को कोई कौन कौनसा गया घर जब जहाँ जा जितना जिन जिन्हें जिन्हों जिस जिसे जीधर जैसा जैसे जो तक तब तरह तिन तिन्हें तिन्हों तिस तिसे तो था थी थे दबारा दिया दुसरा दूसरे दो द्वारा न नके नहीं ना निहायत नीचे ने पर पहले पूरा पे फिर बनी बही बहुत बाद बाला बिलकुल भी भीतर मगर मानो मे में यदि यह यहाँ यही या यिह ये रखें रहा रहे ऱ्वासा लिए लिये लेकिन व वग़ैरह वर्ग वह वहाँ वहीं वाले वुह वे वो सकता सकते सबसे सभी साथ साबुत साभ सारा से सो संग ही हुआ हुई हुए है हैं हो होता होती होते होना होने".split(" ")),e.hi.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.hi.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var t=i.toString().toLowerCase().replace(/^\s+/,"");return r.cut(t).split("|")},e.Pipeline.registerFunction(e.hi.stemmer,"stemmer-hi"),e.Pipeline.registerFunction(e.hi.stopWordFilter,"stopWordFilter-hi")}});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.hy=function(){this.pipeline.reset(),this.pipeline.add(e.hy.trimmer,e.hy.stopWordFilter)},e.hy.wordCharacters="[A-Za-z-֏ff-ﭏ]",e.hy.trimmer=e.trimmerSupport.generateTrimmer(e.hy.wordCharacters),e.Pipeline.registerFunction(e.hy.trimmer,"trimmer-hy"),e.hy.stopWordFilter=e.generateStopWordFilter("դու և եք էիր էիք հետո նաև նրանք որը վրա է որ պիտի են այս մեջ ն իր ու ի այդ որոնք այն կամ էր մի ես համար այլ իսկ էին ենք հետ ին թ էինք մենք նրա նա դուք եմ էի ըստ որպես ում".split(" ")),e.Pipeline.registerFunction(e.hy.stopWordFilter,"stopWordFilter-hy"),e.hy.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}(),e.Pipeline.registerFunction(e.hy.stemmer,"stemmer-hy")}});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.ja=function(){this.pipeline.reset(),this.pipeline.add(e.ja.trimmer,e.ja.stopWordFilter,e.ja.stemmer),r?this.tokenizer=e.ja.tokenizer:(e.tokenizer&&(e.tokenizer=e.ja.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.ja.tokenizer))};var t=new e.TinySegmenter;e.ja.tokenizer=function(i){var n,o,s,p,a,u,m,l,c,f;if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t.toLowerCase()):t.toLowerCase()});for(o=i.toString().toLowerCase().replace(/^\s+/,""),n=o.length-1;n>=0;n--)if(/\S/.test(o.charAt(n))){o=o.substring(0,n+1);break}for(a=[],s=o.length,c=0,l=0;c<=s;c++)if(u=o.charAt(c),m=c-l,u.match(/\s/)||c==s){if(m>0)for(p=t.segment(o.slice(l,c)).filter(function(e){return!!e}),f=l,n=0;n<p.length;n++)r?a.push(new e.Token(p[n],{position:[f,p[n].length],index:a.length})):a.push(p[n]),f+=p[n].length;l=c+1}return a},e.ja.stemmer=function(){return function(e){return e}}(),e.Pipeline.registerFunction(e.ja.stemmer,"stemmer-ja"),e.ja.wordCharacters="一二三四五六七八九十百千万億兆一-龠々〆ヵヶぁ-んァ-ヴーア-ン゙a-zA-Za-zA-Z0-90-9",e.ja.trimmer=e.trimmerSupport.generateTrimmer(e.ja.wordCharacters),e.Pipeline.registerFunction(e.ja.trimmer,"trimmer-ja"),e.ja.stopWordFilter=e.generateStopWordFilter("これ それ あれ この その あの ここ そこ あそこ こちら どこ だれ なに なん 何 私 貴方 貴方方 我々 私達 あの人 あのかた 彼女 彼 です あります おります います は が の に を で え から まで より も どの と し それで しかし".split(" ")),e.Pipeline.registerFunction(e.ja.stopWordFilter,"stopWordFilter-ja"),e.jp=e.ja,e.Pipeline.registerFunction(e.jp.stemmer,"stemmer-jp"),e.Pipeline.registerFunction(e.jp.trimmer,"trimmer-jp"),e.Pipeline.registerFunction(e.jp.stopWordFilter,"stopWordFilter-jp")}});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=require("./lunr.ja");
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.kn=function(){this.pipeline.reset(),this.pipeline.add(e.kn.trimmer,e.kn.stopWordFilter,e.kn.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.kn.stemmer))},e.kn.wordCharacters="ಀ-಄ಅ-ಔಕ-ಹಾ-ೌ಼-ಽೕ-ೖೝ-ೞೠ-ೡೢ-ೣ೦-೯ೱ-ೳ",e.kn.trimmer=e.trimmerSupport.generateTrimmer(e.kn.wordCharacters),e.Pipeline.registerFunction(e.kn.trimmer,"trimmer-kn"),e.kn.stopWordFilter=e.generateStopWordFilter("ಮತ್ತು ಈ ಒಂದು ರಲ್ಲಿ ಹಾಗೂ ಎಂದು ಅಥವಾ ಇದು ರ ಅವರು ಎಂಬ ಮೇಲೆ ಅವರ ತನ್ನ ಆದರೆ ತಮ್ಮ ನಂತರ ಮೂಲಕ ಹೆಚ್ಚು ನ ಆ ಕೆಲವು ಅನೇಕ ಎರಡು ಹಾಗು ಪ್ರಮುಖ ಇದನ್ನು ಇದರ ಸುಮಾರು ಅದರ ಅದು ಮೊದಲ ಬಗ್ಗೆ ನಲ್ಲಿ ರಂದು ಇತರ ಅತ್ಯಂತ ಹೆಚ್ಚಿನ ಸಹ ಸಾಮಾನ್ಯವಾಗಿ ನೇ ಹಲವಾರು ಹೊಸ ದಿ ಕಡಿಮೆ ಯಾವುದೇ ಹೊಂದಿದೆ ದೊಡ್ಡ ಅನ್ನು ಇವರು ಪ್ರಕಾರ ಇದೆ ಮಾತ್ರ ಕೂಡ ಇಲ್ಲಿ ಎಲ್ಲಾ ವಿವಿಧ ಅದನ್ನು ಹಲವು ರಿಂದ ಕೇವಲ ದ ದಕ್ಷಿಣ ಗೆ ಅವನ ಅತಿ ನೆಯ ಬಹಳ ಕೆಲಸ ಎಲ್ಲ ಪ್ರತಿ ಇತ್ಯಾದಿ ಇವು ಬೇರೆ ಹೀಗೆ ನಡುವೆ ಇದಕ್ಕೆ ಎಸ್ ಇವರ ಮೊದಲು ಶ್ರೀ ಮಾಡುವ ಇದರಲ್ಲಿ ರೀತಿಯ ಮಾಡಿದ ಕಾಲ ಅಲ್ಲಿ ಮಾಡಲು ಅದೇ ಈಗ ಅವು ಗಳು ಎ ಎಂಬುದು ಅವನು ಅಂದರೆ ಅವರಿಗೆ ಇರುವ ವಿಶೇಷ ಮುಂದೆ ಅವುಗಳ ಮುಂತಾದ ಮೂಲ ಬಿ ಮೀ ಒಂದೇ ಇನ್ನೂ ಹೆಚ್ಚಾಗಿ ಮಾಡಿ ಅವರನ್ನು ಇದೇ ಯ ರೀತಿಯಲ್ಲಿ ಜೊತೆ ಅದರಲ್ಲಿ ಮಾಡಿದರು ನಡೆದ ಆಗ ಮತ್ತೆ ಪೂರ್ವ ಆತ ಬಂದ ಯಾವ ಒಟ್ಟು ಇತರೆ ಹಿಂದೆ ಪ್ರಮಾಣದ ಗಳನ್ನು ಕುರಿತು ಯು ಆದ್ದರಿಂದ ಅಲ್ಲದೆ ನಗರದ ಮೇಲಿನ ಏಕೆಂದರೆ ರಷ್ಟು ಎಂಬುದನ್ನು ಬಾರಿ ಎಂದರೆ ಹಿಂದಿನ ಆದರೂ ಆದ ಸಂಬಂಧಿಸಿದ ಮತ್ತೊಂದು ಸಿ ಆತನ ".split(" ")),e.kn.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.kn.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var n=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(n).split("|")},e.Pipeline.registerFunction(e.kn.stemmer,"stemmer-kn"),e.Pipeline.registerFunction(e.kn.stopWordFilter,"stopWordFilter-kn")}});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){e.multiLanguage=function(){for(var t=Array.prototype.slice.call(arguments),i=t.join("-"),r="",n=[],s=[],p=0;p<t.length;++p)"en"==t[p]?(r+="\\w",n.unshift(e.stopWordFilter),n.push(e.stemmer),s.push(e.stemmer)):(r+=e[t[p]].wordCharacters,e[t[p]].stopWordFilter&&n.unshift(e[t[p]].stopWordFilter),e[t[p]].stemmer&&(n.push(e[t[p]].stemmer),s.push(e[t[p]].stemmer)));var o=e.trimmerSupport.generateTrimmer(r);return e.Pipeline.registerFunction(o,"lunr-multi-trimmer-"+i),n.unshift(o),function(){this.pipeline.reset(),this.pipeline.add.apply(this.pipeline,n),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add.apply(this.searchPipeline,s))}}}});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* Lunr languages, `Norwegian` language
|
||||
* https://github.com/MihaiValentin/lunr-languages
|
||||
*
|
||||
* Copyright 2014, Mihai Valentin
|
||||
* http://www.mozilla.org/MPL/
|
||||
*/
|
||||
/*!
|
||||
* based on
|
||||
* Snowball JavaScript Library v0.3
|
||||
* http://code.google.com/p/urim/
|
||||
* http://snowball.tartarus.org/
|
||||
*
|
||||
* Copyright 2010, Oleg Mazko
|
||||
* http://www.mozilla.org/MPL/
|
||||
*/
|
||||
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.no=function(){this.pipeline.reset(),this.pipeline.add(e.no.trimmer,e.no.stopWordFilter,e.no.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.no.stemmer))},e.no.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.no.trimmer=e.trimmerSupport.generateTrimmer(e.no.wordCharacters),e.Pipeline.registerFunction(e.no.trimmer,"trimmer-no"),e.no.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,i=new function(){function e(){var e,r=w.cursor+3;if(a=w.limit,0<=r||r<=w.limit){for(s=r;;){if(e=w.cursor,w.in_grouping(d,97,248)){w.cursor=e;break}if(e>=w.limit)return;w.cursor=e+1}for(;!w.out_grouping(d,97,248);){if(w.cursor>=w.limit)return;w.cursor++}a=w.cursor,a<s&&(a=s)}}function i(){var e,r,n;if(w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(m,29),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:n=w.limit-w.cursor,w.in_grouping_b(c,98,122)?w.slice_del():(w.cursor=w.limit-n,w.eq_s_b(1,"k")&&w.out_grouping_b(d,97,248)&&w.slice_del());break;case 3:w.slice_from("er")}}function t(){var e,r=w.limit-w.cursor;w.cursor>=a&&(e=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,w.find_among_b(u,2)?(w.bra=w.cursor,w.limit_backward=e,w.cursor=w.limit-r,w.cursor>w.limit_backward&&(w.cursor--,w.bra=w.cursor,w.slice_del())):w.limit_backward=e)}function o(){var e,r;w.cursor>=a&&(r=w.limit_backward,w.limit_backward=a,w.ket=w.cursor,e=w.find_among_b(l,11),e?(w.bra=w.cursor,w.limit_backward=r,1==e&&w.slice_del()):w.limit_backward=r)}var s,a,m=[new r("a",-1,1),new r("e",-1,1),new r("ede",1,1),new r("ande",1,1),new r("ende",1,1),new r("ane",1,1),new r("ene",1,1),new r("hetene",6,1),new r("erte",1,3),new r("en",-1,1),new r("heten",9,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",12,1),new r("s",-1,2),new r("as",14,1),new r("es",14,1),new r("edes",16,1),new r("endes",16,1),new r("enes",16,1),new r("hetenes",19,1),new r("ens",14,1),new r("hetens",21,1),new r("ers",14,1),new r("ets",14,1),new r("et",-1,1),new r("het",25,1),new r("ert",-1,3),new r("ast",-1,1)],u=[new r("dt",-1,-1),new r("vt",-1,-1)],l=[new r("leg",-1,1),new r("eleg",0,1),new r("ig",-1,1),new r("eig",2,1),new r("lig",2,1),new r("elig",4,1),new r("els",-1,1),new r("lov",-1,1),new r("elov",7,1),new r("slov",7,1),new r("hetslov",9,1)],d=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,48,0,128],c=[119,125,149,1],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,i(),w.cursor=w.limit,t(),w.cursor=w.limit,o(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return i.setCurrent(e),i.stem(),i.getCurrent()}):(i.setCurrent(e),i.stem(),i.getCurrent())}}(),e.Pipeline.registerFunction(e.no.stemmer,"stemmer-no"),e.no.stopWordFilter=e.generateStopWordFilter("alle at av bare begge ble blei bli blir blitt både båe da de deg dei deim deira deires dem den denne der dere deres det dette di din disse ditt du dykk dykkar då eg ein eit eitt eller elles en enn er et ett etter for fordi fra før ha hadde han hans har hennar henne hennes her hjå ho hoe honom hoss hossen hun hva hvem hver hvilke hvilken hvis hvor hvordan hvorfor i ikke ikkje ikkje ingen ingi inkje inn inni ja jeg kan kom korleis korso kun kunne kva kvar kvarhelst kven kvi kvifor man mange me med medan meg meget mellom men mi min mine mitt mot mykje ned no noe noen noka noko nokon nokor nokre nå når og også om opp oss over på samme seg selv si si sia sidan siden sin sine sitt sjøl skal skulle slik so som som somme somt så sånn til um upp ut uten var vart varte ved vere verte vi vil ville vore vors vort vår være være vært å".split(" ")),e.Pipeline.registerFunction(e.no.stopWordFilter,"stopWordFilter-no")}});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sa=function(){this.pipeline.reset(),this.pipeline.add(e.sa.trimmer,e.sa.stopWordFilter,e.sa.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sa.stemmer))},e.sa.wordCharacters="ऀ-ःऄ-एऐ-टठ-यर-िी-ॏॐ-य़ॠ-९॰-ॿ꣠-꣱ꣲ-ꣷ꣸-ꣻ꣼-ꣽꣾ-ꣿᆰ0-ᆰ9",e.sa.trimmer=e.trimmerSupport.generateTrimmer(e.sa.wordCharacters),e.Pipeline.registerFunction(e.sa.trimmer,"trimmer-sa"),e.sa.stopWordFilter=e.generateStopWordFilter('तथा अयम् एकम् इत्यस्मिन् तथा तत् वा अयम् इत्यस्य ते आहूत उपरि तेषाम् किन्तु तेषाम् तदा इत्यनेन अधिकः इत्यस्य तत् केचन बहवः द्वि तथा महत्वपूर्णः अयम् अस्य विषये अयं अस्ति तत् प्रथमः विषये इत्युपरि इत्युपरि इतर अधिकतमः अधिकः अपि सामान्यतया ठ इतरेतर नूतनम् द न्यूनम् कश्चित् वा विशालः द सः अस्ति तदनुसारम् तत्र अस्ति केवलम् अपि अत्र सर्वे विविधाः तत् बहवः यतः इदानीम् द दक्षिण इत्यस्मै तस्य उपरि नथ अतीव कार्यम् सर्वे एकैकम् इत्यादि। एते सन्ति उत इत्थम् मध्ये एतदर्थं . स कस्य प्रथमः श्री. करोति अस्मिन् प्रकारः निर्मिता कालः तत्र कर्तुं समान अधुना ते सन्ति स एकः अस्ति सः अर्थात् तेषां कृते . स्थितम् विशेषः अग्रिम तेषाम् समान स्रोतः ख म समान इदानीमपि अधिकतया करोतु ते समान इत्यस्य वीथी सह यस्मिन् कृतवान् धृतः तदा पुनः पूर्वं सः आगतः किम् कुल इतर पुरा मात्रा स विषये उ अतएव अपि नगरस्य उपरि यतः प्रतिशतं कतरः कालः साधनानि भूत तथापि जात सम्बन्धि अन्यत् ग अतः अस्माकं स्वकीयाः अस्माकं इदानीं अन्तः इत्यादयः भवन्तः इत्यादयः एते एताः तस्य अस्य इदम् एते तेषां तेषां तेषां तान् तेषां तेषां तेषां समानः सः एकः च तादृशाः बहवः अन्ये च वदन्ति यत् कियत् कस्मै कस्मै यस्मै यस्मै यस्मै यस्मै न अतिनीचः किन्तु प्रथमं सम्पूर्णतया ततः चिरकालानन्तरं पुस्तकं सम्पूर्णतया अन्तः किन्तु अत्र वा इह इव श्रद्धाय अवशिष्यते परन्तु अन्ये वर्गाः सन्ति ते सन्ति शक्नुवन्ति सर्वे मिलित्वा सर्वे एकत्र"'.split(" ")),e.sa.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var r=e.wordcut;r.init(),e.sa.tokenizer=function(t){if(!arguments.length||null==t||void 0==t)return[];if(Array.isArray(t))return t.map(function(r){return isLunr2?new e.Token(r.toLowerCase()):r.toLowerCase()});var i=t.toString().toLowerCase().replace(/^\s+/,"");return r.cut(i).split("|")},e.Pipeline.registerFunction(e.sa.stemmer,"stemmer-sa"),e.Pipeline.registerFunction(e.sa.stopWordFilter,"stopWordFilter-sa")}});
|
||||
@@ -0,0 +1 @@
|
||||
!function(r,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(r.lunr)}(this,function(){return function(r){r.stemmerSupport={Among:function(r,t,i,s){if(this.toCharArray=function(r){for(var t=r.length,i=new Array(t),s=0;s<t;s++)i[s]=r.charCodeAt(s);return i},!r&&""!=r||!t&&0!=t||!i)throw"Bad Among initialisation: s:"+r+", substring_i: "+t+", result: "+i;this.s_size=r.length,this.s=this.toCharArray(r),this.substring_i=t,this.result=i,this.method=s},SnowballProgram:function(){var r;return{bra:0,ket:0,limit:0,cursor:0,limit_backward:0,setCurrent:function(t){r=t,this.cursor=0,this.limit=t.length,this.limit_backward=0,this.bra=this.cursor,this.ket=this.limit},getCurrent:function(){var t=r;return r=null,t},in_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},in_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e<=s&&e>=i&&(e-=i,t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},out_grouping:function(t,i,s){if(this.cursor<this.limit){var e=r.charCodeAt(this.cursor);if(e>s||e<i)return this.cursor++,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor++,!0}return!1},out_grouping_b:function(t,i,s){if(this.cursor>this.limit_backward){var e=r.charCodeAt(this.cursor-1);if(e>s||e<i)return this.cursor--,!0;if(e-=i,!(t[e>>3]&1<<(7&e)))return this.cursor--,!0}return!1},eq_s:function(t,i){if(this.limit-this.cursor<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor+s)!=i.charCodeAt(s))return!1;return this.cursor+=t,!0},eq_s_b:function(t,i){if(this.cursor-this.limit_backward<t)return!1;for(var s=0;s<t;s++)if(r.charCodeAt(this.cursor-t+s)!=i.charCodeAt(s))return!1;return this.cursor-=t,!0},find_among:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=l;m<_.s_size;m++){if(n+l==u){f=-1;break}if(f=r.charCodeAt(n+l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n+_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n+_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},find_among_b:function(t,i){for(var s=0,e=i,n=this.cursor,u=this.limit_backward,o=0,h=0,c=!1;;){for(var a=s+(e-s>>1),f=0,l=o<h?o:h,_=t[a],m=_.s_size-1-l;m>=0;m--){if(n-l==u){f=-1;break}if(f=r.charCodeAt(n-1-l)-_.s[m])break;l++}if(f<0?(e=a,h=l):(s=a,o=l),e-s<=1){if(s>0||e==s||c)break;c=!0}}for(;;){var _=t[s];if(o>=_.s_size){if(this.cursor=n-_.s_size,!_.method)return _.result;var b=_.method();if(this.cursor=n-_.s_size,b)return _.result}if((s=_.substring_i)<0)return 0}},replace_s:function(t,i,s){var e=s.length-(i-t),n=r.substring(0,t),u=r.substring(i);return r=n+s+u,this.limit+=e,this.cursor>=i?this.cursor+=e:this.cursor>t&&(this.cursor=t),e},slice_check:function(){if(this.bra<0||this.bra>this.ket||this.ket>this.limit||this.limit>r.length)throw"faulty slice operation"},slice_from:function(r){this.slice_check(),this.replace_s(this.bra,this.ket,r)},slice_del:function(){this.slice_from("")},insert:function(r,t,i){var s=this.replace_s(r,t,i);r<=this.bra&&(this.bra+=s),r<=this.ket&&(this.ket+=s)},slice_to:function(){return this.slice_check(),r.substring(this.bra,this.ket)},eq_v_b:function(r){return this.eq_s_b(r.length,r)}}}},r.trimmerSupport={generateTrimmer:function(r){var t=new RegExp("^[^"+r+"]+"),i=new RegExp("[^"+r+"]+$");return function(r){return"function"==typeof r.update?r.update(function(r){return r.replace(t,"").replace(i,"")}):r.replace(t,"").replace(i,"")}}}}});
|
||||
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* Lunr languages, `Swedish` language
|
||||
* https://github.com/MihaiValentin/lunr-languages
|
||||
*
|
||||
* Copyright 2014, Mihai Valentin
|
||||
* http://www.mozilla.org/MPL/
|
||||
*/
|
||||
/*!
|
||||
* based on
|
||||
* Snowball JavaScript Library v0.3
|
||||
* http://code.google.com/p/urim/
|
||||
* http://snowball.tartarus.org/
|
||||
*
|
||||
* Copyright 2010, Oleg Mazko
|
||||
* http://www.mozilla.org/MPL/
|
||||
*/
|
||||
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.sv=function(){this.pipeline.reset(),this.pipeline.add(e.sv.trimmer,e.sv.stopWordFilter,e.sv.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.sv.stemmer))},e.sv.wordCharacters="A-Za-zªºÀ-ÖØ-öø-ʸˠ-ˤᴀ-ᴥᴬ-ᵜᵢ-ᵥᵫ-ᵷᵹ-ᶾḀ-ỿⁱⁿₐ-ₜKÅℲⅎⅠ-ↈⱠ-ⱿꜢ-ꞇꞋ-ꞭꞰ-ꞷꟷ-ꟿꬰ-ꭚꭜ-ꭤff-stA-Za-z",e.sv.trimmer=e.trimmerSupport.generateTrimmer(e.sv.wordCharacters),e.Pipeline.registerFunction(e.sv.trimmer,"trimmer-sv"),e.sv.stemmer=function(){var r=e.stemmerSupport.Among,n=e.stemmerSupport.SnowballProgram,t=new function(){function e(){var e,r=w.cursor+3;if(o=w.limit,0<=r||r<=w.limit){for(a=r;;){if(e=w.cursor,w.in_grouping(l,97,246)){w.cursor=e;break}if(w.cursor=e,w.cursor>=w.limit)return;w.cursor++}for(;!w.out_grouping(l,97,246);){if(w.cursor>=w.limit)return;w.cursor++}o=w.cursor,o<a&&(o=a)}}function t(){var e,r=w.limit_backward;if(w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(u,37),w.limit_backward=r,e))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.in_grouping_b(d,98,121)&&w.slice_del()}}function i(){var e=w.limit_backward;w.cursor>=o&&(w.limit_backward=o,w.cursor=w.limit,w.find_among_b(c,7)&&(w.cursor=w.limit,w.ket=w.cursor,w.cursor>w.limit_backward&&(w.bra=--w.cursor,w.slice_del())),w.limit_backward=e)}function s(){var e,r;if(w.cursor>=o){if(r=w.limit_backward,w.limit_backward=o,w.cursor=w.limit,w.ket=w.cursor,e=w.find_among_b(m,5))switch(w.bra=w.cursor,e){case 1:w.slice_del();break;case 2:w.slice_from("lös");break;case 3:w.slice_from("full")}w.limit_backward=r}}var a,o,u=[new r("a",-1,1),new r("arna",0,1),new r("erna",0,1),new r("heterna",2,1),new r("orna",0,1),new r("ad",-1,1),new r("e",-1,1),new r("ade",6,1),new r("ande",6,1),new r("arne",6,1),new r("are",6,1),new r("aste",6,1),new r("en",-1,1),new r("anden",12,1),new r("aren",12,1),new r("heten",12,1),new r("ern",-1,1),new r("ar",-1,1),new r("er",-1,1),new r("heter",18,1),new r("or",-1,1),new r("s",-1,2),new r("as",21,1),new r("arnas",22,1),new r("ernas",22,1),new r("ornas",22,1),new r("es",21,1),new r("ades",26,1),new r("andes",26,1),new r("ens",21,1),new r("arens",29,1),new r("hetens",29,1),new r("erns",21,1),new r("at",-1,1),new r("andet",-1,1),new r("het",-1,1),new r("ast",-1,1)],c=[new r("dd",-1,-1),new r("gd",-1,-1),new r("nn",-1,-1),new r("dt",-1,-1),new r("gt",-1,-1),new r("kt",-1,-1),new r("tt",-1,-1)],m=[new r("ig",-1,1),new r("lig",0,1),new r("els",-1,1),new r("fullt",-1,3),new r("löst",-1,2)],l=[17,65,16,1,0,0,0,0,0,0,0,0,0,0,0,0,24,0,32],d=[119,127,149],w=new n;this.setCurrent=function(e){w.setCurrent(e)},this.getCurrent=function(){return w.getCurrent()},this.stem=function(){var r=w.cursor;return e(),w.limit_backward=r,w.cursor=w.limit,t(),w.cursor=w.limit,i(),w.cursor=w.limit,s(),!0}};return function(e){return"function"==typeof e.update?e.update(function(e){return t.setCurrent(e),t.stem(),t.getCurrent()}):(t.setCurrent(e),t.stem(),t.getCurrent())}}(),e.Pipeline.registerFunction(e.sv.stemmer,"stemmer-sv"),e.sv.stopWordFilter=e.generateStopWordFilter("alla allt att av blev bli blir blivit de dem den denna deras dess dessa det detta dig din dina ditt du där då efter ej eller en er era ert ett från för ha hade han hans har henne hennes hon honom hur här i icke ingen inom inte jag ju kan kunde man med mellan men mig min mina mitt mot mycket ni nu när någon något några och om oss på samma sedan sig sin sina sitta själv skulle som så sådan sådana sådant till under upp ut utan vad var vara varför varit varje vars vart vem vi vid vilka vilkas vilken vilket vår våra vårt än är åt över".split(" ")),e.Pipeline.registerFunction(e.sv.stopWordFilter,"stopWordFilter-sv")}});
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.ta=function(){this.pipeline.reset(),this.pipeline.add(e.ta.trimmer,e.ta.stopWordFilter,e.ta.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.ta.stemmer))},e.ta.wordCharacters="-உஊ-ஏஐ-ஙச-ட-னப-யர-ஹ-ிீ-ொ-ௐ---௩௪-௯௰-௹௺-a-zA-Za-zA-Z0-90-9",e.ta.trimmer=e.trimmerSupport.generateTrimmer(e.ta.wordCharacters),e.Pipeline.registerFunction(e.ta.trimmer,"trimmer-ta"),e.ta.stopWordFilter=e.generateStopWordFilter("அங்கு அங்கே அது அதை அந்த அவர் அவர்கள் அவள் அவன் அவை ஆக ஆகவே ஆகையால் ஆதலால் ஆதலினால் ஆனாலும் ஆனால் இங்கு இங்கே இது இதை இந்த இப்படி இவர் இவர்கள் இவள் இவன் இவை இவ்வளவு உனக்கு உனது உன் உன்னால் எங்கு எங்கே எது எதை எந்த எப்படி எவர் எவர்கள் எவள் எவன் எவை எவ்வளவு எனக்கு எனது எனவே என் என்ன என்னால் ஏது ஏன் தனது தன்னால் தானே தான் நாங்கள் நாம் நான் நீ நீங்கள்".split(" ")),e.ta.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.ta.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.ta.stemmer,"stemmer-ta"),e.Pipeline.registerFunction(e.ta.stopWordFilter,"stopWordFilter-ta")}});
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,t){"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():t()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.te=function(){this.pipeline.reset(),this.pipeline.add(e.te.trimmer,e.te.stopWordFilter,e.te.stemmer),this.searchPipeline&&(this.searchPipeline.reset(),this.searchPipeline.add(e.te.stemmer))},e.te.wordCharacters="ఀ-ఄఅ-ఔక-హా-ౌౕ-ౖౘ-ౚౠ-ౡౢ-ౣ౦-౯౸-౿఼ఽ్ౝ౷",e.te.trimmer=e.trimmerSupport.generateTrimmer(e.te.wordCharacters),e.Pipeline.registerFunction(e.te.trimmer,"trimmer-te"),e.te.stopWordFilter=e.generateStopWordFilter("అందరూ అందుబాటులో అడగండి అడగడం అడ్డంగా అనుగుణంగా అనుమతించు అనుమతిస్తుంది అయితే ఇప్పటికే ఉన్నారు ఎక్కడైనా ఎప్పుడు ఎవరైనా ఎవరో ఏ ఏదైనా ఏమైనప్పటికి ఒక ఒకరు కనిపిస్తాయి కాదు కూడా గా గురించి చుట్టూ చేయగలిగింది తగిన తర్వాత దాదాపు దూరంగా నిజంగా పై ప్రకారం ప్రక్కన మధ్య మరియు మరొక మళ్ళీ మాత్రమే మెచ్చుకో వద్ద వెంట వేరుగా వ్యతిరేకంగా సంబంధం".split(" ")),e.te.stemmer=function(){return function(e){return"function"==typeof e.update?e.update(function(e){return e}):e}}();var t=e.wordcut;t.init(),e.te.tokenizer=function(r){if(!arguments.length||null==r||void 0==r)return[];if(Array.isArray(r))return r.map(function(t){return isLunr2?new e.Token(t.toLowerCase()):t.toLowerCase()});var i=r.toString().toLowerCase().replace(/^\s+/,"");return t.cut(i).split("|")},e.Pipeline.registerFunction(e.te.stemmer,"stemmer-te"),e.Pipeline.registerFunction(e.te.stopWordFilter,"stopWordFilter-te")}});
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var r="2"==e.version[0];e.th=function(){this.pipeline.reset(),this.pipeline.add(e.th.trimmer),r?this.tokenizer=e.th.tokenizer:(e.tokenizer&&(e.tokenizer=e.th.tokenizer),this.tokenizerFn&&(this.tokenizerFn=e.th.tokenizer))},e.th.wordCharacters="[-]",e.th.trimmer=e.trimmerSupport.generateTrimmer(e.th.wordCharacters),e.Pipeline.registerFunction(e.th.trimmer,"trimmer-th");var t=e.wordcut;t.init(),e.th.tokenizer=function(i){if(!arguments.length||null==i||void 0==i)return[];if(Array.isArray(i))return i.map(function(t){return r?new e.Token(t):t});var n=i.toString().replace(/^\s+/,"");return t.cut(n).split("|")}}});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r():r()(e.lunr)}(this,function(){return function(e){if(void 0===e)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===e.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");e.vi=function(){this.pipeline.reset(),this.pipeline.add(e.vi.stopWordFilter,e.vi.trimmer)},e.vi.wordCharacters="[A-Za-ẓ̀͐́͑̉̃̓ÂâÊêÔôĂ-ăĐ-đƠ-ơƯ-ư]",e.vi.trimmer=e.trimmerSupport.generateTrimmer(e.vi.wordCharacters),e.Pipeline.registerFunction(e.vi.trimmer,"trimmer-vi"),e.vi.stopWordFilter=e.generateStopWordFilter("là cái nhưng mà".split(" "))}});
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,r){"function"==typeof define&&define.amd?define(r):"object"==typeof exports?module.exports=r(require("@node-rs/jieba")):r()(e.lunr)}(this,function(e){return function(r,t){if(void 0===r)throw new Error("Lunr is not present. Please include / require Lunr before this script.");if(void 0===r.stemmerSupport)throw new Error("Lunr stemmer support is not present. Please include / require Lunr stemmer support before this script.");var i="2"==r.version[0];r.zh=function(){this.pipeline.reset(),this.pipeline.add(r.zh.trimmer,r.zh.stopWordFilter,r.zh.stemmer),i?this.tokenizer=r.zh.tokenizer:(r.tokenizer&&(r.tokenizer=r.zh.tokenizer),this.tokenizerFn&&(this.tokenizerFn=r.zh.tokenizer))},r.zh.tokenizer=function(n){if(!arguments.length||null==n||void 0==n)return[];if(Array.isArray(n))return n.map(function(e){return i?new r.Token(e.toLowerCase()):e.toLowerCase()});t&&e.load(t);var o=n.toString().trim().toLowerCase(),s=[];e.cut(o,!0).forEach(function(e){s=s.concat(e.split(" "))}),s=s.filter(function(e){return!!e});var u=0;return s.map(function(e,t){if(i){var n=o.indexOf(e,u),s={};return s.position=[n,e.length],s.index=t,u=n,new r.Token(e,s)}return e})},r.zh.wordCharacters="\\w一-龥",r.zh.trimmer=r.trimmerSupport.generateTrimmer(r.zh.wordCharacters),r.Pipeline.registerFunction(r.zh.trimmer,"trimmer-zh"),r.zh.stemmer=function(){return function(e){return e}}(),r.Pipeline.registerFunction(r.zh.stemmer,"stemmer-zh"),r.zh.stopWordFilter=r.generateStopWordFilter("的 一 不 在 人 有 是 为 為 以 于 於 上 他 而 后 後 之 来 來 及 了 因 下 可 到 由 这 這 与 與 也 此 但 并 並 个 個 其 已 无 無 小 我 们 們 起 最 再 今 去 好 只 又 或 很 亦 某 把 那 你 乃 它 吧 被 比 别 趁 当 當 从 從 得 打 凡 儿 兒 尔 爾 该 該 各 给 給 跟 和 何 还 還 即 几 幾 既 看 据 據 距 靠 啦 另 么 麽 每 嘛 拿 哪 您 凭 憑 且 却 卻 让 讓 仍 啥 如 若 使 谁 誰 虽 雖 随 隨 同 所 她 哇 嗡 往 些 向 沿 哟 喲 用 咱 则 則 怎 曾 至 致 着 著 诸 諸 自".split(" ")),r.Pipeline.registerFunction(r.zh.stopWordFilter,"stopWordFilter-zh")}});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* export the module via AMD, CommonJS or as a browser global
|
||||
* Export code from https://github.com/umdjs/umd/blob/master/returnExports.js
|
||||
*/
|
||||
;(function (root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(factory)
|
||||
} else if (typeof exports === 'object') {
|
||||
/**
|
||||
* Node. Does not work with strict CommonJS, but
|
||||
* only CommonJS-like environments that support module.exports,
|
||||
* like Node.
|
||||
*/
|
||||
module.exports = factory()
|
||||
} else {
|
||||
// Browser globals (root is window)
|
||||
factory()(root.lunr);
|
||||
}
|
||||
}(this, function () {
|
||||
/**
|
||||
* Just return a value to define the module export.
|
||||
* This example returns an object, but the module
|
||||
* can return a function as the exported value.
|
||||
*/
|
||||
|
||||
return function(lunr) {
|
||||
// TinySegmenter 0.1 -- Super compact Japanese tokenizer in Javascript
|
||||
// (c) 2008 Taku Kudo <taku@chasen.org>
|
||||
// TinySegmenter is freely distributable under the terms of a new BSD licence.
|
||||
// For details, see http://chasen.org/~taku/software/TinySegmenter/LICENCE.txt
|
||||
|
||||
function TinySegmenter() {
|
||||
var patterns = {
|
||||
"[一二三四五六七八九十百千万億兆]":"M",
|
||||
"[一-龠々〆ヵヶ]":"H",
|
||||
"[ぁ-ん]":"I",
|
||||
"[ァ-ヴーア-ン゙ー]":"K",
|
||||
"[a-zA-Za-zA-Z]":"A",
|
||||
"[0-90-9]":"N"
|
||||
}
|
||||
this.chartype_ = [];
|
||||
for (var i in patterns) {
|
||||
var regexp = new RegExp(i);
|
||||
this.chartype_.push([regexp, patterns[i]]);
|
||||
}
|
||||
|
||||
this.BIAS__ = -332
|
||||
this.BC1__ = {"HH":6,"II":2461,"KH":406,"OH":-1378};
|
||||
this.BC2__ = {"AA":-3267,"AI":2744,"AN":-878,"HH":-4070,"HM":-1711,"HN":4012,"HO":3761,"IA":1327,"IH":-1184,"II":-1332,"IK":1721,"IO":5492,"KI":3831,"KK":-8741,"MH":-3132,"MK":3334,"OO":-2920};
|
||||
this.BC3__ = {"HH":996,"HI":626,"HK":-721,"HN":-1307,"HO":-836,"IH":-301,"KK":2762,"MK":1079,"MM":4034,"OA":-1652,"OH":266};
|
||||
this.BP1__ = {"BB":295,"OB":304,"OO":-125,"UB":352};
|
||||
this.BP2__ = {"BO":60,"OO":-1762};
|
||||
this.BQ1__ = {"BHH":1150,"BHM":1521,"BII":-1158,"BIM":886,"BMH":1208,"BNH":449,"BOH":-91,"BOO":-2597,"OHI":451,"OIH":-296,"OKA":1851,"OKH":-1020,"OKK":904,"OOO":2965};
|
||||
this.BQ2__ = {"BHH":118,"BHI":-1159,"BHM":466,"BIH":-919,"BKK":-1720,"BKO":864,"OHH":-1139,"OHM":-181,"OIH":153,"UHI":-1146};
|
||||
this.BQ3__ = {"BHH":-792,"BHI":2664,"BII":-299,"BKI":419,"BMH":937,"BMM":8335,"BNN":998,"BOH":775,"OHH":2174,"OHM":439,"OII":280,"OKH":1798,"OKI":-793,"OKO":-2242,"OMH":-2402,"OOO":11699};
|
||||
this.BQ4__ = {"BHH":-3895,"BIH":3761,"BII":-4654,"BIK":1348,"BKK":-1806,"BMI":-3385,"BOO":-12396,"OAH":926,"OHH":266,"OHK":-2036,"ONN":-973};
|
||||
this.BW1__ = {",と":660,",同":727,"B1あ":1404,"B1同":542,"、と":660,"、同":727,"」と":1682,"あっ":1505,"いう":1743,"いっ":-2055,"いる":672,"うし":-4817,"うん":665,"から":3472,"がら":600,"こう":-790,"こと":2083,"こん":-1262,"さら":-4143,"さん":4573,"した":2641,"して":1104,"すで":-3399,"そこ":1977,"それ":-871,"たち":1122,"ため":601,"った":3463,"つい":-802,"てい":805,"てき":1249,"でき":1127,"です":3445,"では":844,"とい":-4915,"とみ":1922,"どこ":3887,"ない":5713,"なっ":3015,"など":7379,"なん":-1113,"にし":2468,"には":1498,"にも":1671,"に対":-912,"の一":-501,"の中":741,"ませ":2448,"まで":1711,"まま":2600,"まる":-2155,"やむ":-1947,"よっ":-2565,"れた":2369,"れで":-913,"をし":1860,"を見":731,"亡く":-1886,"京都":2558,"取り":-2784,"大き":-2604,"大阪":1497,"平方":-2314,"引き":-1336,"日本":-195,"本当":-2423,"毎日":-2113,"目指":-724,"B1あ":1404,"B1同":542,"」と":1682};
|
||||
this.BW2__ = {"..":-11822,"11":-669,"――":-5730,"−−":-13175,"いう":-1609,"うか":2490,"かし":-1350,"かも":-602,"から":-7194,"かれ":4612,"がい":853,"がら":-3198,"きた":1941,"くな":-1597,"こと":-8392,"この":-4193,"させ":4533,"され":13168,"さん":-3977,"しい":-1819,"しか":-545,"した":5078,"して":972,"しな":939,"その":-3744,"たい":-1253,"たた":-662,"ただ":-3857,"たち":-786,"たと":1224,"たは":-939,"った":4589,"って":1647,"っと":-2094,"てい":6144,"てき":3640,"てく":2551,"ては":-3110,"ても":-3065,"でい":2666,"でき":-1528,"でし":-3828,"です":-4761,"でも":-4203,"とい":1890,"とこ":-1746,"とと":-2279,"との":720,"とみ":5168,"とも":-3941,"ない":-2488,"なが":-1313,"など":-6509,"なの":2614,"なん":3099,"にお":-1615,"にし":2748,"にな":2454,"によ":-7236,"に対":-14943,"に従":-4688,"に関":-11388,"のか":2093,"ので":-7059,"のに":-6041,"のの":-6125,"はい":1073,"はが":-1033,"はず":-2532,"ばれ":1813,"まし":-1316,"まで":-6621,"まれ":5409,"めて":-3153,"もい":2230,"もの":-10713,"らか":-944,"らし":-1611,"らに":-1897,"りし":651,"りま":1620,"れた":4270,"れて":849,"れば":4114,"ろう":6067,"われ":7901,"を通":-11877,"んだ":728,"んな":-4115,"一人":602,"一方":-1375,"一日":970,"一部":-1051,"上が":-4479,"会社":-1116,"出て":2163,"分の":-7758,"同党":970,"同日":-913,"大阪":-2471,"委員":-1250,"少な":-1050,"年度":-8669,"年間":-1626,"府県":-2363,"手権":-1982,"新聞":-4066,"日新":-722,"日本":-7068,"日米":3372,"曜日":-601,"朝鮮":-2355,"本人":-2697,"東京":-1543,"然と":-1384,"社会":-1276,"立て":-990,"第に":-1612,"米国":-4268,"11":-669};
|
||||
this.BW3__ = {"あた":-2194,"あり":719,"ある":3846,"い.":-1185,"い。":-1185,"いい":5308,"いえ":2079,"いく":3029,"いた":2056,"いっ":1883,"いる":5600,"いわ":1527,"うち":1117,"うと":4798,"えと":1454,"か.":2857,"か。":2857,"かけ":-743,"かっ":-4098,"かに":-669,"から":6520,"かり":-2670,"が,":1816,"が、":1816,"がき":-4855,"がけ":-1127,"がっ":-913,"がら":-4977,"がり":-2064,"きた":1645,"けど":1374,"こと":7397,"この":1542,"ころ":-2757,"さい":-714,"さを":976,"し,":1557,"し、":1557,"しい":-3714,"した":3562,"して":1449,"しな":2608,"しま":1200,"す.":-1310,"す。":-1310,"する":6521,"ず,":3426,"ず、":3426,"ずに":841,"そう":428,"た.":8875,"た。":8875,"たい":-594,"たの":812,"たり":-1183,"たる":-853,"だ.":4098,"だ。":4098,"だっ":1004,"った":-4748,"って":300,"てい":6240,"てお":855,"ても":302,"です":1437,"でに":-1482,"では":2295,"とう":-1387,"とし":2266,"との":541,"とも":-3543,"どう":4664,"ない":1796,"なく":-903,"など":2135,"に,":-1021,"に、":-1021,"にし":1771,"にな":1906,"には":2644,"の,":-724,"の、":-724,"の子":-1000,"は,":1337,"は、":1337,"べき":2181,"まし":1113,"ます":6943,"まっ":-1549,"まで":6154,"まれ":-793,"らし":1479,"られ":6820,"るる":3818,"れ,":854,"れ、":854,"れた":1850,"れて":1375,"れば":-3246,"れる":1091,"われ":-605,"んだ":606,"んで":798,"カ月":990,"会議":860,"入り":1232,"大会":2217,"始め":1681,"市":965,"新聞":-5055,"日,":974,"日、":974,"社会":2024,"カ月":990};
|
||||
this.TC1__ = {"AAA":1093,"HHH":1029,"HHM":580,"HII":998,"HOH":-390,"HOM":-331,"IHI":1169,"IOH":-142,"IOI":-1015,"IOM":467,"MMH":187,"OOI":-1832};
|
||||
this.TC2__ = {"HHO":2088,"HII":-1023,"HMM":-1154,"IHI":-1965,"KKH":703,"OII":-2649};
|
||||
this.TC3__ = {"AAA":-294,"HHH":346,"HHI":-341,"HII":-1088,"HIK":731,"HOH":-1486,"IHH":128,"IHI":-3041,"IHO":-1935,"IIH":-825,"IIM":-1035,"IOI":-542,"KHH":-1216,"KKA":491,"KKH":-1217,"KOK":-1009,"MHH":-2694,"MHM":-457,"MHO":123,"MMH":-471,"NNH":-1689,"NNO":662,"OHO":-3393};
|
||||
this.TC4__ = {"HHH":-203,"HHI":1344,"HHK":365,"HHM":-122,"HHN":182,"HHO":669,"HIH":804,"HII":679,"HOH":446,"IHH":695,"IHO":-2324,"IIH":321,"III":1497,"IIO":656,"IOO":54,"KAK":4845,"KKA":3386,"KKK":3065,"MHH":-405,"MHI":201,"MMH":-241,"MMM":661,"MOM":841};
|
||||
this.TQ1__ = {"BHHH":-227,"BHHI":316,"BHIH":-132,"BIHH":60,"BIII":1595,"BNHH":-744,"BOHH":225,"BOOO":-908,"OAKK":482,"OHHH":281,"OHIH":249,"OIHI":200,"OIIH":-68};
|
||||
this.TQ2__ = {"BIHH":-1401,"BIII":-1033,"BKAK":-543,"BOOO":-5591};
|
||||
this.TQ3__ = {"BHHH":478,"BHHM":-1073,"BHIH":222,"BHII":-504,"BIIH":-116,"BIII":-105,"BMHI":-863,"BMHM":-464,"BOMH":620,"OHHH":346,"OHHI":1729,"OHII":997,"OHMH":481,"OIHH":623,"OIIH":1344,"OKAK":2792,"OKHH":587,"OKKA":679,"OOHH":110,"OOII":-685};
|
||||
this.TQ4__ = {"BHHH":-721,"BHHM":-3604,"BHII":-966,"BIIH":-607,"BIII":-2181,"OAAA":-2763,"OAKK":180,"OHHH":-294,"OHHI":2446,"OHHO":480,"OHIH":-1573,"OIHH":1935,"OIHI":-493,"OIIH":626,"OIII":-4007,"OKAK":-8156};
|
||||
this.TW1__ = {"につい":-4681,"東京都":2026};
|
||||
this.TW2__ = {"ある程":-2049,"いった":-1256,"ころが":-2434,"しょう":3873,"その後":-4430,"だって":-1049,"ていた":1833,"として":-4657,"ともに":-4517,"もので":1882,"一気に":-792,"初めて":-1512,"同時に":-8097,"大きな":-1255,"対して":-2721,"社会党":-3216};
|
||||
this.TW3__ = {"いただ":-1734,"してい":1314,"として":-4314,"につい":-5483,"にとっ":-5989,"に当た":-6247,"ので,":-727,"ので、":-727,"のもの":-600,"れから":-3752,"十二月":-2287};
|
||||
this.TW4__ = {"いう.":8576,"いう。":8576,"からな":-2348,"してい":2958,"たが,":1516,"たが、":1516,"ている":1538,"という":1349,"ました":5543,"ません":1097,"ようと":-4258,"よると":5865};
|
||||
this.UC1__ = {"A":484,"K":93,"M":645,"O":-505};
|
||||
this.UC2__ = {"A":819,"H":1059,"I":409,"M":3987,"N":5775,"O":646};
|
||||
this.UC3__ = {"A":-1370,"I":2311};
|
||||
this.UC4__ = {"A":-2643,"H":1809,"I":-1032,"K":-3450,"M":3565,"N":3876,"O":6646};
|
||||
this.UC5__ = {"H":313,"I":-1238,"K":-799,"M":539,"O":-831};
|
||||
this.UC6__ = {"H":-506,"I":-253,"K":87,"M":247,"O":-387};
|
||||
this.UP1__ = {"O":-214};
|
||||
this.UP2__ = {"B":69,"O":935};
|
||||
this.UP3__ = {"B":189};
|
||||
this.UQ1__ = {"BH":21,"BI":-12,"BK":-99,"BN":142,"BO":-56,"OH":-95,"OI":477,"OK":410,"OO":-2422};
|
||||
this.UQ2__ = {"BH":216,"BI":113,"OK":1759};
|
||||
this.UQ3__ = {"BA":-479,"BH":42,"BI":1913,"BK":-7198,"BM":3160,"BN":6427,"BO":14761,"OI":-827,"ON":-3212};
|
||||
this.UW1__ = {",":156,"、":156,"「":-463,"あ":-941,"う":-127,"が":-553,"き":121,"こ":505,"で":-201,"と":-547,"ど":-123,"に":-789,"の":-185,"は":-847,"も":-466,"や":-470,"よ":182,"ら":-292,"り":208,"れ":169,"を":-446,"ん":-137,"・":-135,"主":-402,"京":-268,"区":-912,"午":871,"国":-460,"大":561,"委":729,"市":-411,"日":-141,"理":361,"生":-408,"県":-386,"都":-718,"「":-463,"・":-135};
|
||||
this.UW2__ = {",":-829,"、":-829,"〇":892,"「":-645,"」":3145,"あ":-538,"い":505,"う":134,"お":-502,"か":1454,"が":-856,"く":-412,"こ":1141,"さ":878,"ざ":540,"し":1529,"す":-675,"せ":300,"そ":-1011,"た":188,"だ":1837,"つ":-949,"て":-291,"で":-268,"と":-981,"ど":1273,"な":1063,"に":-1764,"の":130,"は":-409,"ひ":-1273,"べ":1261,"ま":600,"も":-1263,"や":-402,"よ":1639,"り":-579,"る":-694,"れ":571,"を":-2516,"ん":2095,"ア":-587,"カ":306,"キ":568,"ッ":831,"三":-758,"不":-2150,"世":-302,"中":-968,"主":-861,"事":492,"人":-123,"会":978,"保":362,"入":548,"初":-3025,"副":-1566,"北":-3414,"区":-422,"大":-1769,"天":-865,"太":-483,"子":-1519,"学":760,"実":1023,"小":-2009,"市":-813,"年":-1060,"強":1067,"手":-1519,"揺":-1033,"政":1522,"文":-1355,"新":-1682,"日":-1815,"明":-1462,"最":-630,"朝":-1843,"本":-1650,"東":-931,"果":-665,"次":-2378,"民":-180,"気":-1740,"理":752,"発":529,"目":-1584,"相":-242,"県":-1165,"立":-763,"第":810,"米":509,"自":-1353,"行":838,"西":-744,"見":-3874,"調":1010,"議":1198,"込":3041,"開":1758,"間":-1257,"「":-645,"」":3145,"ッ":831,"ア":-587,"カ":306,"キ":568};
|
||||
this.UW3__ = {",":4889,"1":-800,"−":-1723,"、":4889,"々":-2311,"〇":5827,"」":2670,"〓":-3573,"あ":-2696,"い":1006,"う":2342,"え":1983,"お":-4864,"か":-1163,"が":3271,"く":1004,"け":388,"げ":401,"こ":-3552,"ご":-3116,"さ":-1058,"し":-395,"す":584,"せ":3685,"そ":-5228,"た":842,"ち":-521,"っ":-1444,"つ":-1081,"て":6167,"で":2318,"と":1691,"ど":-899,"な":-2788,"に":2745,"の":4056,"は":4555,"ひ":-2171,"ふ":-1798,"へ":1199,"ほ":-5516,"ま":-4384,"み":-120,"め":1205,"も":2323,"や":-788,"よ":-202,"ら":727,"り":649,"る":5905,"れ":2773,"わ":-1207,"を":6620,"ん":-518,"ア":551,"グ":1319,"ス":874,"ッ":-1350,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278,"・":-3794,"一":-1619,"下":-1759,"世":-2087,"両":3815,"中":653,"主":-758,"予":-1193,"二":974,"人":2742,"今":792,"他":1889,"以":-1368,"低":811,"何":4265,"作":-361,"保":-2439,"元":4858,"党":3593,"全":1574,"公":-3030,"六":755,"共":-1880,"円":5807,"再":3095,"分":457,"初":2475,"別":1129,"前":2286,"副":4437,"力":365,"動":-949,"務":-1872,"化":1327,"北":-1038,"区":4646,"千":-2309,"午":-783,"協":-1006,"口":483,"右":1233,"各":3588,"合":-241,"同":3906,"和":-837,"員":4513,"国":642,"型":1389,"場":1219,"外":-241,"妻":2016,"学":-1356,"安":-423,"実":-1008,"家":1078,"小":-513,"少":-3102,"州":1155,"市":3197,"平":-1804,"年":2416,"広":-1030,"府":1605,"度":1452,"建":-2352,"当":-3885,"得":1905,"思":-1291,"性":1822,"戸":-488,"指":-3973,"政":-2013,"教":-1479,"数":3222,"文":-1489,"新":1764,"日":2099,"旧":5792,"昨":-661,"時":-1248,"曜":-951,"最":-937,"月":4125,"期":360,"李":3094,"村":364,"東":-805,"核":5156,"森":2438,"業":484,"氏":2613,"民":-1694,"決":-1073,"法":1868,"海":-495,"無":979,"物":461,"特":-3850,"生":-273,"用":914,"町":1215,"的":7313,"直":-1835,"省":792,"県":6293,"知":-1528,"私":4231,"税":401,"立":-960,"第":1201,"米":7767,"系":3066,"約":3663,"級":1384,"統":-4229,"総":1163,"線":1255,"者":6457,"能":725,"自":-2869,"英":785,"見":1044,"調":-562,"財":-733,"費":1777,"車":1835,"軍":1375,"込":-1504,"通":-1136,"選":-681,"郎":1026,"郡":4404,"部":1200,"金":2163,"長":421,"開":-1432,"間":1302,"関":-1282,"雨":2009,"電":-1045,"非":2066,"駅":1620,"1":-800,"」":2670,"・":-3794,"ッ":-1350,"ア":551,"グ":1319,"ス":874,"ト":521,"ム":1109,"ル":1591,"ロ":2201,"ン":278};
|
||||
this.UW4__ = {",":3930,".":3508,"―":-4841,"、":3930,"。":3508,"〇":4999,"「":1895,"」":3798,"〓":-5156,"あ":4752,"い":-3435,"う":-640,"え":-2514,"お":2405,"か":530,"が":6006,"き":-4482,"ぎ":-3821,"く":-3788,"け":-4376,"げ":-4734,"こ":2255,"ご":1979,"さ":2864,"し":-843,"じ":-2506,"す":-731,"ず":1251,"せ":181,"そ":4091,"た":5034,"だ":5408,"ち":-3654,"っ":-5882,"つ":-1659,"て":3994,"で":7410,"と":4547,"な":5433,"に":6499,"ぬ":1853,"ね":1413,"の":7396,"は":8578,"ば":1940,"ひ":4249,"び":-4134,"ふ":1345,"へ":6665,"べ":-744,"ほ":1464,"ま":1051,"み":-2082,"む":-882,"め":-5046,"も":4169,"ゃ":-2666,"や":2795,"ょ":-1544,"よ":3351,"ら":-2922,"り":-9726,"る":-14896,"れ":-2613,"ろ":-4570,"わ":-1783,"を":13150,"ん":-2352,"カ":2145,"コ":1789,"セ":1287,"ッ":-724,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637,"・":-4371,"ー":-11870,"一":-2069,"中":2210,"予":782,"事":-190,"井":-1768,"人":1036,"以":544,"会":950,"体":-1286,"作":530,"側":4292,"先":601,"党":-2006,"共":-1212,"内":584,"円":788,"初":1347,"前":1623,"副":3879,"力":-302,"動":-740,"務":-2715,"化":776,"区":4517,"協":1013,"参":1555,"合":-1834,"和":-681,"員":-910,"器":-851,"回":1500,"国":-619,"園":-1200,"地":866,"場":-1410,"塁":-2094,"士":-1413,"多":1067,"大":571,"子":-4802,"学":-1397,"定":-1057,"寺":-809,"小":1910,"屋":-1328,"山":-1500,"島":-2056,"川":-2667,"市":2771,"年":374,"庁":-4556,"後":456,"性":553,"感":916,"所":-1566,"支":856,"改":787,"政":2182,"教":704,"文":522,"方":-856,"日":1798,"時":1829,"最":845,"月":-9066,"木":-485,"来":-442,"校":-360,"業":-1043,"氏":5388,"民":-2716,"気":-910,"沢":-939,"済":-543,"物":-735,"率":672,"球":-1267,"生":-1286,"産":-1101,"田":-2900,"町":1826,"的":2586,"目":922,"省":-3485,"県":2997,"空":-867,"立":-2112,"第":788,"米":2937,"系":786,"約":2171,"経":1146,"統":-1169,"総":940,"線":-994,"署":749,"者":2145,"能":-730,"般":-852,"行":-792,"規":792,"警":-1184,"議":-244,"谷":-1000,"賞":730,"車":-1481,"軍":1158,"輪":-1433,"込":-3370,"近":929,"道":-1291,"選":2596,"郎":-4866,"都":1192,"野":-1100,"銀":-2213,"長":357,"間":-2344,"院":-2297,"際":-2604,"電":-878,"領":-1659,"題":-792,"館":-1984,"首":1749,"高":2120,"「":1895,"」":3798,"・":-4371,"ッ":-724,"ー":-11870,"カ":2145,"コ":1789,"セ":1287,"ト":-403,"メ":-1635,"ラ":-881,"リ":-541,"ル":-856,"ン":-3637};
|
||||
this.UW5__ = {",":465,".":-299,"1":-514,"E2":-32768,"]":-2762,"、":465,"。":-299,"「":363,"あ":1655,"い":331,"う":-503,"え":1199,"お":527,"か":647,"が":-421,"き":1624,"ぎ":1971,"く":312,"げ":-983,"さ":-1537,"し":-1371,"す":-852,"だ":-1186,"ち":1093,"っ":52,"つ":921,"て":-18,"で":-850,"と":-127,"ど":1682,"な":-787,"に":-1224,"の":-635,"は":-578,"べ":1001,"み":502,"め":865,"ゃ":3350,"ょ":854,"り":-208,"る":429,"れ":504,"わ":419,"を":-1264,"ん":327,"イ":241,"ル":451,"ン":-343,"中":-871,"京":722,"会":-1153,"党":-654,"務":3519,"区":-901,"告":848,"員":2104,"大":-1296,"学":-548,"定":1785,"嵐":-1304,"市":-2991,"席":921,"年":1763,"思":872,"所":-814,"挙":1618,"新":-1682,"日":218,"月":-4353,"査":932,"格":1356,"機":-1508,"氏":-1347,"田":240,"町":-3912,"的":-3149,"相":1319,"省":-1052,"県":-4003,"研":-997,"社":-278,"空":-813,"統":1955,"者":-2233,"表":663,"語":-1073,"議":1219,"選":-1018,"郎":-368,"長":786,"間":1191,"題":2368,"館":-689,"1":-514,"E2":-32768,"「":363,"イ":241,"ル":451,"ン":-343};
|
||||
this.UW6__ = {",":227,".":808,"1":-270,"E1":306,"、":227,"。":808,"あ":-307,"う":189,"か":241,"が":-73,"く":-121,"こ":-200,"じ":1782,"す":383,"た":-428,"っ":573,"て":-1014,"で":101,"と":-105,"な":-253,"に":-149,"の":-417,"は":-236,"も":-206,"り":187,"る":-135,"を":195,"ル":-673,"ン":-496,"一":-277,"中":201,"件":-800,"会":624,"前":302,"区":1792,"員":-1212,"委":798,"学":-960,"市":887,"広":-695,"後":535,"業":-697,"相":753,"社":-507,"福":974,"空":-822,"者":1811,"連":463,"郎":1082,"1":-270,"E1":306,"ル":-673,"ン":-496};
|
||||
|
||||
return this;
|
||||
}
|
||||
TinySegmenter.prototype.ctype_ = function(str) {
|
||||
for (var i in this.chartype_) {
|
||||
if (str.match(this.chartype_[i][0])) {
|
||||
return this.chartype_[i][1];
|
||||
}
|
||||
}
|
||||
return "O";
|
||||
}
|
||||
|
||||
TinySegmenter.prototype.ts_ = function(v) {
|
||||
if (v) { return v; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
TinySegmenter.prototype.segment = function(input) {
|
||||
if (input == null || input == undefined || input == "") {
|
||||
return [];
|
||||
}
|
||||
var result = [];
|
||||
var seg = ["B3","B2","B1"];
|
||||
var ctype = ["O","O","O"];
|
||||
var o = input.split("");
|
||||
for (i = 0; i < o.length; ++i) {
|
||||
seg.push(o[i]);
|
||||
ctype.push(this.ctype_(o[i]))
|
||||
}
|
||||
seg.push("E1");
|
||||
seg.push("E2");
|
||||
seg.push("E3");
|
||||
ctype.push("O");
|
||||
ctype.push("O");
|
||||
ctype.push("O");
|
||||
var word = seg[3];
|
||||
var p1 = "U";
|
||||
var p2 = "U";
|
||||
var p3 = "U";
|
||||
for (var i = 4; i < seg.length - 3; ++i) {
|
||||
var score = this.BIAS__;
|
||||
var w1 = seg[i-3];
|
||||
var w2 = seg[i-2];
|
||||
var w3 = seg[i-1];
|
||||
var w4 = seg[i];
|
||||
var w5 = seg[i+1];
|
||||
var w6 = seg[i+2];
|
||||
var c1 = ctype[i-3];
|
||||
var c2 = ctype[i-2];
|
||||
var c3 = ctype[i-1];
|
||||
var c4 = ctype[i];
|
||||
var c5 = ctype[i+1];
|
||||
var c6 = ctype[i+2];
|
||||
score += this.ts_(this.UP1__[p1]);
|
||||
score += this.ts_(this.UP2__[p2]);
|
||||
score += this.ts_(this.UP3__[p3]);
|
||||
score += this.ts_(this.BP1__[p1 + p2]);
|
||||
score += this.ts_(this.BP2__[p2 + p3]);
|
||||
score += this.ts_(this.UW1__[w1]);
|
||||
score += this.ts_(this.UW2__[w2]);
|
||||
score += this.ts_(this.UW3__[w3]);
|
||||
score += this.ts_(this.UW4__[w4]);
|
||||
score += this.ts_(this.UW5__[w5]);
|
||||
score += this.ts_(this.UW6__[w6]);
|
||||
score += this.ts_(this.BW1__[w2 + w3]);
|
||||
score += this.ts_(this.BW2__[w3 + w4]);
|
||||
score += this.ts_(this.BW3__[w4 + w5]);
|
||||
score += this.ts_(this.TW1__[w1 + w2 + w3]);
|
||||
score += this.ts_(this.TW2__[w2 + w3 + w4]);
|
||||
score += this.ts_(this.TW3__[w3 + w4 + w5]);
|
||||
score += this.ts_(this.TW4__[w4 + w5 + w6]);
|
||||
score += this.ts_(this.UC1__[c1]);
|
||||
score += this.ts_(this.UC2__[c2]);
|
||||
score += this.ts_(this.UC3__[c3]);
|
||||
score += this.ts_(this.UC4__[c4]);
|
||||
score += this.ts_(this.UC5__[c5]);
|
||||
score += this.ts_(this.UC6__[c6]);
|
||||
score += this.ts_(this.BC1__[c2 + c3]);
|
||||
score += this.ts_(this.BC2__[c3 + c4]);
|
||||
score += this.ts_(this.BC3__[c4 + c5]);
|
||||
score += this.ts_(this.TC1__[c1 + c2 + c3]);
|
||||
score += this.ts_(this.TC2__[c2 + c3 + c4]);
|
||||
score += this.ts_(this.TC3__[c3 + c4 + c5]);
|
||||
score += this.ts_(this.TC4__[c4 + c5 + c6]);
|
||||
// score += this.ts_(this.TC5__[c4 + c5 + c6]);
|
||||
score += this.ts_(this.UQ1__[p1 + c1]);
|
||||
score += this.ts_(this.UQ2__[p2 + c2]);
|
||||
score += this.ts_(this.UQ3__[p3 + c3]);
|
||||
score += this.ts_(this.BQ1__[p2 + c2 + c3]);
|
||||
score += this.ts_(this.BQ2__[p2 + c3 + c4]);
|
||||
score += this.ts_(this.BQ3__[p3 + c2 + c3]);
|
||||
score += this.ts_(this.BQ4__[p3 + c3 + c4]);
|
||||
score += this.ts_(this.TQ1__[p2 + c1 + c2 + c3]);
|
||||
score += this.ts_(this.TQ2__[p2 + c2 + c3 + c4]);
|
||||
score += this.ts_(this.TQ3__[p3 + c1 + c2 + c3]);
|
||||
score += this.ts_(this.TQ4__[p3 + c2 + c3 + c4]);
|
||||
var p = "O";
|
||||
if (score > 0) {
|
||||
result.push(word);
|
||||
word = "";
|
||||
p = "B";
|
||||
}
|
||||
p1 = p2;
|
||||
p2 = p3;
|
||||
p3 = p;
|
||||
word += seg[i];
|
||||
}
|
||||
result.push(word);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
lunr.TinySegmenter = TinySegmenter;
|
||||
};
|
||||
|
||||
}));
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"AA2BA,cAGE,6BAME,sDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CACA,mDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CAGA,mDAAA,CACA,gDAAA,CACA,yDAAA,CACA,4DAAA,CAGA,0BAAA,CACA,mCAAA,CAGA,iCAAA,CACA,kCAAA,CACA,mCAAA,CACA,mCAAA,CACA,kCAAA,CACA,iCAAA,CACA,+CAAA,CACA,6DAAA,CACA,gEAAA,CACA,4DAAA,CACA,4DAAA,CACA,6DAAA,CAGA,6CAAA,CAGA,+CAAA,CAGA,uDAAA,CACA,6DAAA,CACA,2DAAA,CAGA,iCAAA,CAGA,yDAAA,CACA,iEAAA,CAGA,mDAAA,CACA,mDAAA,CAGA,qDAAA,CACA,uDAAA,CAGA,8DAAA,CAKA,8DAAA,CAKA,0DAAA,CAzEA,iBCiBF,CD6DE,kHAEE,YC3DJ,CDkFE,yDACE,4BChFJ,CD+EE,2DACE,4BC7EJ,CD4EE,gEACE,4BC1EJ,CDyEE,2DACE,4BCvEJ,CDsEE,yDACE,4BCpEJ,CDmEE,0DACE,4BCjEJ,CDgEE,gEACE,4BC9DJ,CD6DE,0DACE,4BC3DJ,CD0DE,2OACE,4BC/CJ,CDsDA,+FAGE,iCCpDF,CACF,CCjDE,2BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD6CN,CCvDE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDoDN,CC9DE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD2DN,CCrEE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDkEN,CC5EE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDyEN,CCnFE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDgFN,CC1FE,kCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDuFN,CCjGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD8FN,CCxGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDqGN,CC/GE,6BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD4GN,CCtHE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDmHN,CC7HE,4BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD6HN,CCpIE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDoIN,CC3IE,6BACE,yBAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD2IN,CClJE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDkJN,CCzJE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDsJN,CE3JE,4BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwJN,CEnKE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgKN,CE3KE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwKN,CEnLE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgLN,CE3LE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwLN,CEnME,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgMN,CE3ME,mCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwMN,CEnNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgNN,CE3NE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwNN,CEnOE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgON,CE3OE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwON,CEnPE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmPN,CE3PE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2PN,CEnQE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFmQN,CE3QE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCF2QN,CEnRE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFgRN,CE3RE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFwRN,CEnSE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BF4RN,CE5SE,kCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BFqSN,CEtRE,sEACE,4BFyRJ,CE1RE,+DACE,4BF6RJ,CE9RE,iEACE,4BFiSJ,CElSE,gEACE,4BFqSJ,CEtSE,iEACE,4BFySJ,CEhSA,8BACE,mDAAA,CACA,4DAAA,CACA,0DAAA,CACA,oDAAA,CACA,2DAAA,CAGA,4BFiSF,CE9RE,yCACE,+BFgSJ,CE7RI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCFiSN,CG7MI,mCD1EA,+CACE,8CF0RJ,CEvRI,qDACE,8CFyRN,CEpRE,iEACE,mCFsRJ,CACF,CGxNI,sCDvDA,uCACE,oCFkRJ,CACF,CEzQA,8BACE,kDAAA,CACA,4DAAA,CACA,wDAAA,CACA,oDAAA,CACA,6DAAA,CAGA,4BF0QF,CEvQE,yCACE,+BFyQJ,CEtQI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCF0QN,CEnQE,yCACE,6CFqQJ,CG9NI,0CDhCA,8CACE,gDFiQJ,CACF,CGnOI,0CDvBA,iFACE,6CF6PJ,CACF,CG3PI,sCDKA,uCACE,6CFyPJ,CACF","file":"palette.css"}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1245
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/authentication/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/conventions/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/getting-started/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/streaming/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/all-endpoints/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/approvals-clarify/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/chat-streaming/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/commands/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/cron/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/insights-updates/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/memory/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/models-providers/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/profiles-settings/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/sessions/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/skills/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/voice/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://get-hermes.ai/api-docs/reference/workspace-git/</loc>
|
||||
<lastmod>2026-07-02</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
"""Hermes Web UI -- API modules."""
|
||||
@@ -1,831 +0,0 @@
|
||||
"""Hermes agent/gateway heartbeat payload helpers (#716, #1879).
|
||||
|
||||
The WebUI process is not always paired with a long-running Hermes gateway. Some
|
||||
setups use WebUI only, while self-hosted messaging deployments run a separate
|
||||
Hermes gateway daemon that records runtime metadata in the Hermes Agent home.
|
||||
This module turns those existing safe runtime signals into a small UI-facing
|
||||
heartbeat without shelling out or adding psutil as a hard dependency.
|
||||
|
||||
Cross-container note (#1879): ``gateway.status.get_running_pid()`` uses
|
||||
``fcntl.flock`` and ``os.kill(pid, 0)``, both of which require the caller to
|
||||
share a PID namespace with the gateway process. In multi-container deployments
|
||||
where the WebUI runs separately from ``hermes-agent`` and only a Hermes data
|
||||
volume is shared, those checks always return ``None`` and the dashboard
|
||||
incorrectly shows "Gateway not running". To stay accurate without forcing a
|
||||
``pid: "service:hermes-agent"`` compose workaround, we accept a recent
|
||||
``updated_at`` timestamp on ``gateway_state.json`` (combined with
|
||||
``gateway_state == "running"``) as an equivalent live-process signal. Older
|
||||
gateway builds do not refresh that file periodically, so a stale
|
||||
``gateway_state == "running"`` record is treated as inconclusive rather than a
|
||||
confirmed outage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error as urllib_error
|
||||
from urllib import request as urllib_request
|
||||
|
||||
_GATEWAY_PID_FILE = "gateway.pid"
|
||||
_GATEWAY_RUNTIME_STATUS_FILE = "gateway_state.json"
|
||||
|
||||
|
||||
# Two cron ticks (~60s each). Chosen to avoid false negatives during brief
|
||||
# gateway restarts while still surfacing a true outage within a couple of
|
||||
# minutes. Override is intentionally not exposed: keep the check deterministic
|
||||
# and identical across deployments so support diagnostics are reproducible.
|
||||
GATEWAY_FRESHNESS_THRESHOLD_S: float = 120.0
|
||||
|
||||
|
||||
def _checked_at() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _runtime_status_is_fresh(
|
||||
runtime_status: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
|
||||
) -> bool:
|
||||
"""Return ``True`` when ``gateway_state.json`` looks freshly written.
|
||||
|
||||
"Fresh" means the gateway self-reported ``running`` and the ``updated_at``
|
||||
ISO-8601 timestamp is no older than ``threshold_s`` seconds. This is the
|
||||
cross-container liveness signal used when ``get_running_pid()`` returns
|
||||
``None`` purely because of PID-namespace isolation (#1879).
|
||||
|
||||
Any unparseable input is treated as "not fresh" — a stale or missing
|
||||
timestamp must never report alive.
|
||||
"""
|
||||
if not isinstance(runtime_status, dict):
|
||||
return False
|
||||
if runtime_status.get("gateway_state") != "running":
|
||||
return False
|
||||
|
||||
raw_updated_at = runtime_status.get("updated_at")
|
||||
if not isinstance(raw_updated_at, str) or not raw_updated_at:
|
||||
return False
|
||||
|
||||
# ``datetime.fromisoformat`` accepts the exact format gateway/status.py
|
||||
# writes (``datetime.now(timezone.utc).isoformat()``). We deliberately
|
||||
# don't pull in dateutil — keeping this stdlib-only matches the rest of
|
||||
# this module.
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(raw_updated_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
if updated_at.tzinfo is None:
|
||||
# A naive timestamp could mean anything across containers / hosts.
|
||||
# Refuse to interpret it rather than assume UTC.
|
||||
return False
|
||||
|
||||
reference = now if now is not None else datetime.now(timezone.utc)
|
||||
age_s = (reference - updated_at).total_seconds()
|
||||
if age_s < 0:
|
||||
# Clock skew between containers can produce small negatives. A future
|
||||
# timestamp is still a "fresh" signal — the gateway clearly wrote it
|
||||
# very recently — so accept it. A wildly-future timestamp (> threshold
|
||||
# in the future) is rejected to avoid trusting a broken clock.
|
||||
return -age_s <= threshold_s
|
||||
return age_s <= threshold_s
|
||||
|
||||
|
||||
def _runtime_status_is_stale_stopped(
|
||||
runtime_status: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
|
||||
) -> bool:
|
||||
"""Return ``True`` for an old clean-stop root gateway state.
|
||||
|
||||
A user may run only profile-scoped gateways while a root
|
||||
``gateway_state.json`` from an older, intentionally stopped gateway remains
|
||||
on disk (#1944). Treat that stale stopped file like "no root gateway
|
||||
configured" so the heartbeat banner does not keep warning about a service
|
||||
the user is not running. Fresh stopped state still reports down.
|
||||
"""
|
||||
if not isinstance(runtime_status, dict):
|
||||
return False
|
||||
if runtime_status.get("gateway_state") != "stopped":
|
||||
return False
|
||||
|
||||
raw_updated_at = runtime_status.get("updated_at")
|
||||
if not isinstance(raw_updated_at, str) or not raw_updated_at:
|
||||
return False
|
||||
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(raw_updated_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if updated_at.tzinfo is None:
|
||||
return False
|
||||
|
||||
reference = now if now is not None else datetime.now(timezone.utc)
|
||||
age_s = (reference - updated_at).total_seconds()
|
||||
return age_s > threshold_s
|
||||
|
||||
|
||||
def _runtime_status_is_stale_running(
|
||||
runtime_status: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
|
||||
) -> bool:
|
||||
"""Return ``True`` when the gateway last self-reported running, but stale.
|
||||
|
||||
WebUI often runs in a separate container from the gateway. In that shape PID
|
||||
checks can be impossible, and older gateway versions only update
|
||||
``gateway_state.json`` on lifecycle/platform changes. A stale ``running``
|
||||
file therefore means "not enough information from WebUI" rather than
|
||||
"gateway is down".
|
||||
"""
|
||||
if not isinstance(runtime_status, dict):
|
||||
return False
|
||||
if runtime_status.get("gateway_state") != "running":
|
||||
return False
|
||||
|
||||
raw_updated_at = runtime_status.get("updated_at")
|
||||
if not isinstance(raw_updated_at, str) or not raw_updated_at:
|
||||
return False
|
||||
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(raw_updated_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if updated_at.tzinfo is None:
|
||||
return False
|
||||
|
||||
reference = now if now is not None else datetime.now(timezone.utc)
|
||||
age_s = (reference - updated_at).total_seconds()
|
||||
return age_s > threshold_s
|
||||
|
||||
|
||||
def _gateway_status_module():
|
||||
"""Load gateway.status lazily so tests and WebUI-only installs stay isolated."""
|
||||
return importlib.import_module("gateway.status")
|
||||
|
||||
|
||||
def _gateway_root_pid_path() -> Path | None:
|
||||
"""Return the root Hermes gateway PID path.
|
||||
|
||||
Gateway runtime files are root-level singletons. A profile-scoped WebUI
|
||||
process may have HERMES_HOME=<root>/profiles/<name>, but gateway.pid,
|
||||
gateway.lock, and gateway_state.json still live under <root>.
|
||||
|
||||
When the root-level gateway.pid is absent (profile-scoped gateway
|
||||
deployments write it under <root>/profiles/<name>/), fall back to the
|
||||
active profile's directory so the gateway is detected correctly.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
root_pid = get_default_hermes_root() / _GATEWAY_PID_FILE
|
||||
if root_pid.exists():
|
||||
return root_pid
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
profile_pid = Path(get_active_hermes_home()) / _GATEWAY_PID_FILE
|
||||
if profile_pid.exists():
|
||||
return profile_pid
|
||||
except Exception:
|
||||
pass
|
||||
return root_pid
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_runtime_status_path(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _read_gateway_runtime_status(gateway_status: Any, pid_path: Path | None) -> dict[str, Any] | None:
|
||||
read_runtime_status = gateway_status.read_runtime_status
|
||||
if pid_path is not None:
|
||||
try:
|
||||
return read_runtime_status(pid_path=pid_path)
|
||||
except TypeError:
|
||||
runtime_status_file = str(
|
||||
getattr(gateway_status, "_RUNTIME_STATUS_FILE", _GATEWAY_RUNTIME_STATUS_FILE)
|
||||
)
|
||||
runtime_status_path = pid_path.with_name(runtime_status_file)
|
||||
try:
|
||||
return read_runtime_status(runtime_status_path)
|
||||
except TypeError:
|
||||
if getattr(gateway_status, "__name__", "") == "gateway.status" or hasattr(
|
||||
gateway_status,
|
||||
"_read_json_file",
|
||||
):
|
||||
runtime_status = _read_runtime_status_path(runtime_status_path)
|
||||
if runtime_status is not None:
|
||||
return runtime_status
|
||||
return read_runtime_status()
|
||||
|
||||
|
||||
def _gateway_running_pid(gateway_status: Any, pid_path: Path | None) -> int | None:
|
||||
get_running_pid = gateway_status.get_running_pid
|
||||
if pid_path is not None:
|
||||
try:
|
||||
return get_running_pid(pid_path=pid_path, cleanup_stale=False)
|
||||
except TypeError:
|
||||
try:
|
||||
return get_running_pid(pid_path, cleanup_stale=False)
|
||||
except TypeError:
|
||||
pass
|
||||
try:
|
||||
return get_running_pid(cleanup_stale=False)
|
||||
except TypeError:
|
||||
# Older agent versions may not expose cleanup_stale. Keep compatibility.
|
||||
return get_running_pid()
|
||||
|
||||
|
||||
def _callable_code_declares_parameter(get_running_pid: Any, name: str) -> bool:
|
||||
"""Return True when a Python callable's own code declares *name*."""
|
||||
target = getattr(get_running_pid, "__func__", get_running_pid)
|
||||
code = getattr(target, "__code__", None)
|
||||
if code is None:
|
||||
return False
|
||||
declared_count = int(getattr(code, "co_argcount", 0)) + int(
|
||||
getattr(code, "co_kwonlyargcount", 0)
|
||||
)
|
||||
return name in code.co_varnames[:declared_count]
|
||||
|
||||
|
||||
def _declared_pid_path_parameter(
|
||||
get_running_pid: Any,
|
||||
) -> tuple[inspect.Parameter, int, list[inspect.Parameter]] | None:
|
||||
try:
|
||||
signature = inspect.signature(get_running_pid, follow_wrapped=False)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
params = list(signature.parameters.values())
|
||||
for idx, param in enumerate(params):
|
||||
if param.kind not in (
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
):
|
||||
continue
|
||||
name = str(param.name or "").lower()
|
||||
if (
|
||||
"path" in name
|
||||
and "pid" in name
|
||||
and _callable_code_declares_parameter(get_running_pid, param.name)
|
||||
):
|
||||
positional_index = sum(
|
||||
1
|
||||
for earlier in params[:idx]
|
||||
if earlier.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
)
|
||||
return param, positional_index, params
|
||||
return None
|
||||
|
||||
|
||||
def _positional_only_pid_args(
|
||||
params: list[inspect.Parameter],
|
||||
positional_index: int,
|
||||
pid_path: Path,
|
||||
) -> list[Any] | None:
|
||||
positional_params = [
|
||||
param
|
||||
for param in params
|
||||
if param.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
]
|
||||
if positional_index >= len(positional_params):
|
||||
return None
|
||||
args: list[Any] = []
|
||||
for param in positional_params[:positional_index]:
|
||||
if param.default is inspect.Parameter.empty:
|
||||
return None
|
||||
args.append(param.default)
|
||||
args.append(pid_path)
|
||||
return args
|
||||
|
||||
|
||||
def _accepts_cleanup_stale_keyword(params: list[inspect.Parameter]) -> bool:
|
||||
return any(
|
||||
param.name == "cleanup_stale"
|
||||
and param.kind
|
||||
in (
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
for param in params
|
||||
)
|
||||
|
||||
|
||||
def _gateway_running_pid_strict_path(gateway_status: Any, pid_path: Path) -> int | None:
|
||||
"""Read a PID from an explicit path only; never fall back to ambient state."""
|
||||
get_running_pid = gateway_status.get_running_pid
|
||||
pid_path_match = _declared_pid_path_parameter(get_running_pid)
|
||||
if pid_path_match is None:
|
||||
return None
|
||||
pid_path_param, positional_index, params = pid_path_match
|
||||
|
||||
if pid_path_param.kind is inspect.Parameter.KEYWORD_ONLY:
|
||||
kwargs = {pid_path_param.name: pid_path}
|
||||
try:
|
||||
return get_running_pid(**kwargs, cleanup_stale=False)
|
||||
except TypeError:
|
||||
try:
|
||||
return get_running_pid(**kwargs)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
if pid_path_param.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
|
||||
kwargs = {pid_path_param.name: pid_path}
|
||||
try:
|
||||
return get_running_pid(**kwargs, cleanup_stale=False)
|
||||
except TypeError:
|
||||
try:
|
||||
return get_running_pid(**kwargs)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
if pid_path_param.kind is inspect.Parameter.POSITIONAL_ONLY:
|
||||
args = _positional_only_pid_args(params, positional_index, pid_path)
|
||||
if args is None:
|
||||
return None
|
||||
if _accepts_cleanup_stale_keyword(params):
|
||||
try:
|
||||
return get_running_pid(*args, cleanup_stale=False)
|
||||
except TypeError:
|
||||
pass
|
||||
try:
|
||||
return get_running_pid(*args)
|
||||
except TypeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_active_profile_gateway_running_pid(profile: str | None = None) -> int | None:
|
||||
"""Return the confirmed active-profile PID without state fallbacks.
|
||||
|
||||
Update recovery needs process identity, not just a recent ``running`` state:
|
||||
an old gateway can remain alive when the restart CLI fails before executing.
|
||||
Use the same active-profile home targeted by the restart helper rather than
|
||||
the default-root health path. Keep this fail-closed when status is unavailable.
|
||||
"""
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home, get_hermes_home_for_profile
|
||||
|
||||
gateway_status = _gateway_status_module()
|
||||
if profile is None:
|
||||
gateway_home = get_active_hermes_home()
|
||||
else:
|
||||
gateway_home = get_hermes_home_for_profile(profile)
|
||||
gateway_pid_path = Path(gateway_home) / _GATEWAY_PID_FILE
|
||||
return _gateway_running_pid_strict_path(gateway_status, gateway_pid_path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _runtime_detail_subset(runtime_status: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return only non-sensitive runtime fields for the browser.
|
||||
|
||||
gateway.status records argv/PID metadata so the CLI can validate process
|
||||
identity. The WebUI alert only needs health semantics, never raw command
|
||||
lines, paths, environment, or tokens.
|
||||
"""
|
||||
if not isinstance(runtime_status, dict):
|
||||
return {}
|
||||
|
||||
details: dict[str, Any] = {}
|
||||
gateway_state = runtime_status.get("gateway_state")
|
||||
if isinstance(gateway_state, str) and gateway_state:
|
||||
details["gateway_state"] = gateway_state
|
||||
|
||||
updated_at = runtime_status.get("updated_at")
|
||||
if isinstance(updated_at, str) and updated_at:
|
||||
details["updated_at"] = updated_at
|
||||
|
||||
try:
|
||||
details["active_agents"] = max(0, int(runtime_status.get("active_agents") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
platforms = runtime_status.get("platforms")
|
||||
if isinstance(platforms, dict):
|
||||
details["platform_count"] = len(platforms)
|
||||
states: dict[str, int] = {}
|
||||
for payload in platforms.values():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
state = payload.get("state")
|
||||
if isinstance(state, str) and state:
|
||||
states[state] = states.get(state, 0) + 1
|
||||
if states:
|
||||
details["platform_states"] = states
|
||||
|
||||
return details
|
||||
|
||||
|
||||
# Remote-gateway probe (#3281)
|
||||
# ------------------------------------------------------------------
|
||||
# In multi-container Docker deployments the WebUI container does not ship the
|
||||
# ``gateway`` Python package. The lazy ``importlib.import_module("gateway.status")``
|
||||
# therefore raises ``ModuleNotFoundError`` and the payload falls through to
|
||||
# ``gateway_not_configured`` even though ``HERMES_API_URL`` points at a perfectly
|
||||
# reachable remote gateway. The Tasks/Cron banner then shows a spurious amber
|
||||
# "Gateway not configured" warning.
|
||||
#
|
||||
# When a gateway base URL is set in any supported env var, we treat that as an
|
||||
# explicit declaration that the gateway lives elsewhere, and probe it over HTTP
|
||||
# before touching any local filesystem / module signal. The probe result is
|
||||
# cached briefly so a dashboard rerender that fans out to multiple panels does
|
||||
# not hammer the gateway.
|
||||
|
||||
_REMOTE_PROBE_TIMEOUT_S: float = 2.0
|
||||
_REMOTE_PROBE_CACHE_TTL_S: float = 5.0
|
||||
_REMOTE_PROBE_PATHS: tuple[str, ...] = ("/health/detailed", "/health", "/v1/health")
|
||||
# A gateway health payload is small JSON; cap the 2xx body read so a large or
|
||||
# slow-trickled remote response can't hang /api/health/agent or balloon memory.
|
||||
_REMOTE_PROBE_BODY_LIMIT_BYTES: int = 64 * 1024
|
||||
|
||||
_remote_probe_lock = threading.Lock()
|
||||
# Condition wraps the same lock so cache reads/writes and single-flight waits
|
||||
# share one mutex (mirrors the Condition(_lock) idiom in api/session_lifecycle).
|
||||
_remote_probe_cond = threading.Condition(_remote_probe_lock)
|
||||
_remote_probe_cache: dict[str, Any] = {"url": None, "expires_at": 0.0, "result": None}
|
||||
# base_urls currently being probed by a "leader" thread. Latecomers wait on the
|
||||
# Condition for the leader's result instead of stampeding the (possibly dead)
|
||||
# gateway themselves (#5455 dashboard fan-out, #2476).
|
||||
_remote_probe_inflight: set[str] = set()
|
||||
def _remote_probe_wait_budget_s() -> float:
|
||||
"""How long a latecomer waits for the leader before giving up and self-probing.
|
||||
|
||||
The leader can walk every path (each up to ``_REMOTE_PROBE_TIMEOUT_S``), so
|
||||
budget for the full walk plus a small margin; timing out is a safety valve
|
||||
against a hung leader, never the normal path. Computed on each call (not a
|
||||
module-level constant) so a test that monkeypatches the timeout or the path
|
||||
list gets a budget consistent with those values instead of a stale one.
|
||||
"""
|
||||
return _REMOTE_PROBE_TIMEOUT_S * len(_REMOTE_PROBE_PATHS) + 1.0
|
||||
|
||||
|
||||
def _remote_gateway_base_url() -> str | None:
|
||||
"""Return an explicit remote gateway base URL, or None for local-only setups.
|
||||
|
||||
Priority: GATEWAY_HEALTH_URL > HERMES_GATEWAY_HEALTH_URL > HERMES_API_URL
|
||||
> HERMES_WEBUI_GATEWAY_BASE_URL.
|
||||
Returns ``None`` when no env var is set so the caller falls through to
|
||||
local PID/state checks.
|
||||
|
||||
Any of these env vars may legitimately point AT a health endpoint
|
||||
(e.g. ``GATEWAY_HEALTH_URL=http://host:8642/health``). Since the probe
|
||||
appends ``/health/detailed`` etc. to the returned base, strip a trailing
|
||||
health-path suffix first so we don't build ``/health/health/detailed``
|
||||
(mirrors the normalization in api/updates.py).
|
||||
"""
|
||||
for var in (
|
||||
"GATEWAY_HEALTH_URL",
|
||||
"HERMES_GATEWAY_HEALTH_URL",
|
||||
"HERMES_API_URL",
|
||||
"HERMES_WEBUI_GATEWAY_BASE_URL",
|
||||
):
|
||||
val = os.environ.get(var, "").strip()
|
||||
if val:
|
||||
base = val.rstrip("/")
|
||||
for suffix in ("/health/detailed", "/health", "/v1/health", "/status"):
|
||||
if base.endswith(suffix):
|
||||
base = base[: -len(suffix)].rstrip("/")
|
||||
break
|
||||
return base
|
||||
return None
|
||||
|
||||
|
||||
def _remote_gateway_api_key() -> str:
|
||||
"""Return the Bearer token for authenticated gateway health probes.
|
||||
|
||||
Mirrors ``api.gateway_chat._gateway_api_key``: WebUI containers in
|
||||
multi-service deployments must present the same key the agent's API server
|
||||
expects on ``/health/detailed`` (#5418).
|
||||
"""
|
||||
return str(
|
||||
os.environ.get("HERMES_WEBUI_GATEWAY_API_KEY")
|
||||
or os.environ.get("API_SERVER_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _http_probe(
|
||||
url: str,
|
||||
timeout_s: float,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
) -> tuple[bool, int | None, str | None, bytes | None]:
|
||||
"""GET ``url`` and return (ok, status_code, error_name, body).
|
||||
|
||||
``ok`` is True only for a 2xx response. 5xx and network errors are not OK.
|
||||
4xx is also treated as "responded" (the gateway is up, just answering 404
|
||||
on this particular path) so the caller can move on to the next path.
|
||||
``body`` is the raw response bytes for 2xx responses, None otherwise.
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib_request.Request(url, method="GET", headers=headers)
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=timeout_s) as resp: # noqa: S310 - trusted env var URL
|
||||
status = getattr(resp, "status", None) or resp.getcode()
|
||||
ok = 200 <= int(status) < 300
|
||||
# Cap the body read: we only need a small JSON health payload, and an
|
||||
# unbounded resp.read() on a large/trickled 2xx body could hang the
|
||||
# /api/health/agent handler or balloon memory. Read one byte over the
|
||||
# cap so the caller can detect (and skip) an oversized body.
|
||||
body = resp.read(_REMOTE_PROBE_BODY_LIMIT_BYTES + 1) if ok else None
|
||||
return (ok, int(status), None, body)
|
||||
except urllib_error.HTTPError as exc:
|
||||
return (False, int(exc.code), "HTTPError", None)
|
||||
except Exception as exc: # urllib_error.URLError, socket.timeout, ssl, etc.
|
||||
return (False, None, type(exc).__name__, None)
|
||||
|
||||
|
||||
def _cached_remote_result_locked(base_url: str, current: float) -> dict[str, Any] | None:
|
||||
"""Return a fresh cached probe result for *base_url*, or None if stale/absent.
|
||||
|
||||
Must be called while holding ``_remote_probe_cond``.
|
||||
"""
|
||||
if (
|
||||
_remote_probe_cache.get("url") == base_url
|
||||
and _remote_probe_cache.get("expires_at", 0.0) > current
|
||||
and _remote_probe_cache.get("result") is not None
|
||||
):
|
||||
return _remote_probe_cache["result"]
|
||||
return None
|
||||
|
||||
|
||||
def _run_remote_probe(base_url: str) -> dict[str, Any]:
|
||||
"""Walk the remote gateway health paths and build a payload (no caching/locking).
|
||||
|
||||
This is the expensive, network-bound step: each path can block up to
|
||||
``_REMOTE_PROBE_TIMEOUT_S``. It runs OUTSIDE the probe lock so a single
|
||||
"leader" thread does it while latecomers wait for the cached result.
|
||||
"""
|
||||
last_status: int | None = None
|
||||
last_error: str | None = None
|
||||
gateway_api_key = _remote_gateway_api_key()
|
||||
for path in _REMOTE_PROBE_PATHS:
|
||||
probe_key = gateway_api_key if path == "/health/detailed" else None
|
||||
ok, status, err, body = _http_probe(
|
||||
base_url + path,
|
||||
_REMOTE_PROBE_TIMEOUT_S,
|
||||
api_key=probe_key,
|
||||
)
|
||||
if ok:
|
||||
details: dict[str, Any] = {
|
||||
"state": "alive",
|
||||
"reason": "remote_gateway",
|
||||
"endpoint": base_url + path,
|
||||
"status_code": status,
|
||||
}
|
||||
if body and len(body) <= _REMOTE_PROBE_BODY_LIMIT_BYTES:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
if isinstance(data, dict) and "gateway_state" in data:
|
||||
details["gateway_state"] = data["gateway_state"]
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
# An over-cap body (len > limit, i.e. the +1 sentinel byte was read)
|
||||
# is treated as "alive but no parseable gateway_state" — we still
|
||||
# report the gateway as up, just without the detailed state.
|
||||
return {
|
||||
"alive": True,
|
||||
"checked_at": _checked_at(),
|
||||
"details": details,
|
||||
}
|
||||
# Remember the most informative failure signal we saw.
|
||||
if status is not None:
|
||||
last_status = status
|
||||
if err is not None:
|
||||
last_error = err
|
||||
|
||||
details = {
|
||||
"state": "down",
|
||||
"reason": "remote_gateway_unreachable",
|
||||
"endpoint": base_url,
|
||||
}
|
||||
if last_status is not None:
|
||||
details["status_code"] = last_status
|
||||
if last_error is not None:
|
||||
details["error"] = last_error
|
||||
return {
|
||||
"alive": False,
|
||||
"checked_at": _checked_at(),
|
||||
"details": details,
|
||||
}
|
||||
|
||||
|
||||
def _probe_remote_gateway(base_url: str, *, now: float | None = None) -> dict[str, Any]:
|
||||
"""Return an agent-health payload dict for a remote gateway base URL.
|
||||
|
||||
Result is cached for ``_REMOTE_PROBE_CACHE_TTL_S`` seconds per base_url.
|
||||
|
||||
Concurrency (single-flight): when several dashboard panels fan out on a cold
|
||||
cache, only the first "leader" thread runs the ~2s-per-path network probe;
|
||||
latecomers wait on ``_remote_probe_cond`` for the leader's cached result
|
||||
rather than each hammering the (possibly dead) gateway (#5455, #2476). The
|
||||
leader always clears the in-flight marker and wakes waiters — even on error —
|
||||
so waiters can never deadlock.
|
||||
"""
|
||||
current = time.monotonic() if now is None else now
|
||||
with _remote_probe_cond:
|
||||
cached = _cached_remote_result_locked(base_url, current)
|
||||
if cached is not None:
|
||||
# Refresh checked_at so the UI shows a current timestamp without
|
||||
# actually re-hitting the gateway.
|
||||
return {**cached, "checked_at": _checked_at()}
|
||||
|
||||
# A leader is already probing this base_url: wait for its result instead
|
||||
# of starting a duplicate probe.
|
||||
if base_url in _remote_probe_inflight:
|
||||
deadline = current + _remote_probe_wait_budget_s()
|
||||
while base_url in _remote_probe_inflight:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break # leader stuck; fall through and probe ourselves
|
||||
_remote_probe_cond.wait(remaining)
|
||||
cached = _cached_remote_result_locked(base_url, time.monotonic())
|
||||
if cached is not None:
|
||||
return {**cached, "checked_at": _checked_at()}
|
||||
# Woke without a usable cached result (leader failed, produced no
|
||||
# cacheable result, or we timed out): become a leader ourselves.
|
||||
|
||||
# Become the leader for this base_url.
|
||||
_remote_probe_inflight.add(base_url)
|
||||
|
||||
payload: dict[str, Any] | None = None
|
||||
try:
|
||||
payload = _run_remote_probe(base_url)
|
||||
finally:
|
||||
with _remote_probe_cond:
|
||||
if payload is not None:
|
||||
_remote_probe_cache["url"] = base_url
|
||||
# Expire from the moment the probe COMPLETES, not from the
|
||||
# leader's entry time: walking every path of a hung gateway
|
||||
# takes len(paths) * timeout (~6s) which exceeds the 5s TTL, so
|
||||
# `current + TTL` would write an already-expired cache line.
|
||||
# Waiters woken right after this would then miss the cache and
|
||||
# each re-probe the dead gateway — collapsing single-flight and
|
||||
# regressing latency to worse-than-serial (#5455, #2476).
|
||||
_remote_probe_cache["expires_at"] = (
|
||||
time.monotonic() + _REMOTE_PROBE_CACHE_TTL_S
|
||||
)
|
||||
_remote_probe_cache["result"] = payload
|
||||
# Always release the in-flight marker and wake waiters, even if the
|
||||
# probe raised — otherwise latecomers would wait out the full budget.
|
||||
_remote_probe_inflight.discard(base_url)
|
||||
_remote_probe_cond.notify_all()
|
||||
# Only reached when _run_remote_probe returned normally (a raise would have
|
||||
# propagated through the finally), so payload is always a dict here. The
|
||||
# assert makes that explicit for the type checker (declared -> dict[str, Any]).
|
||||
assert payload is not None
|
||||
return payload
|
||||
|
||||
|
||||
def _reset_remote_probe_cache_for_tests() -> None:
|
||||
"""Test hook: clear the in-process remote-probe cache and in-flight state."""
|
||||
with _remote_probe_cond:
|
||||
_remote_probe_cache["url"] = None
|
||||
_remote_probe_cache["expires_at"] = 0.0
|
||||
_remote_probe_cache["result"] = None
|
||||
_remote_probe_inflight.clear()
|
||||
_remote_probe_cond.notify_all()
|
||||
|
||||
|
||||
def build_agent_health_payload() -> dict[str, Any]:
|
||||
"""Return `{alive, checked_at, details}` for the Hermes gateway/agent.
|
||||
|
||||
`alive` is intentionally tri-state:
|
||||
* True: a gateway runtime signal says the process is alive.
|
||||
* False: gateway metadata exists, but no live gateway process owns it.
|
||||
* None: no gateway metadata/status is available, so this WebUI setup is
|
||||
probably not configured with a separate gateway process.
|
||||
"""
|
||||
checked_at = _checked_at()
|
||||
|
||||
# Multi-container deployments (#3281): when HERMES_API_URL is set the
|
||||
# gateway lives in another container/host. Probe it over HTTP before
|
||||
# touching local module/pid/state-file signals, otherwise a missing
|
||||
# ``gateway`` Python package in this image masquerades as
|
||||
# "gateway_not_configured" and produces a spurious banner.
|
||||
remote_base = _remote_gateway_base_url()
|
||||
if remote_base is not None:
|
||||
return _probe_remote_gateway(remote_base)
|
||||
|
||||
try:
|
||||
gateway_status = _gateway_status_module()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"alive": None,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "unknown",
|
||||
"reason": "gateway_status_unavailable",
|
||||
"error": type(exc).__name__,
|
||||
},
|
||||
}
|
||||
|
||||
gateway_pid_path = _gateway_root_pid_path()
|
||||
|
||||
runtime_status = None
|
||||
try:
|
||||
runtime_status = _read_gateway_runtime_status(gateway_status, gateway_pid_path)
|
||||
except Exception:
|
||||
runtime_status = None
|
||||
|
||||
try:
|
||||
running_pid = _gateway_running_pid(gateway_status, gateway_pid_path)
|
||||
except Exception:
|
||||
running_pid = None
|
||||
|
||||
safe_details = _runtime_detail_subset(runtime_status)
|
||||
if running_pid is not None:
|
||||
return {
|
||||
"alive": True,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "alive",
|
||||
**safe_details,
|
||||
},
|
||||
}
|
||||
|
||||
# Cross-container fallback (#1879): when ``get_running_pid()`` cannot see
|
||||
# the gateway because we're in a different PID namespace, a recent
|
||||
# ``updated_at`` on ``gateway_state.json`` is a reliable equivalent signal
|
||||
# since the gateway writes it on every tick. We only trust this fallback
|
||||
# when the gateway also self-reports ``gateway_state == "running"`` so
|
||||
# crash-without-cleanup scenarios still surface as "down".
|
||||
if _runtime_status_is_fresh(runtime_status):
|
||||
return {
|
||||
"alive": True,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "alive",
|
||||
"reason": "cross_container_freshness",
|
||||
**safe_details,
|
||||
},
|
||||
}
|
||||
|
||||
if _runtime_status_is_stale_stopped(runtime_status):
|
||||
return {
|
||||
"alive": None,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "unknown",
|
||||
"reason": "gateway_stale_stopped_state",
|
||||
**safe_details,
|
||||
},
|
||||
}
|
||||
|
||||
if _runtime_status_is_stale_running(runtime_status):
|
||||
return {
|
||||
"alive": None,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "unknown",
|
||||
"reason": "gateway_stale_running_state",
|
||||
**safe_details,
|
||||
},
|
||||
}
|
||||
|
||||
if isinstance(runtime_status, dict):
|
||||
return {
|
||||
"alive": False,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "down",
|
||||
"reason": "gateway_not_running",
|
||||
**safe_details,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"alive": None,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "unknown",
|
||||
"reason": "gateway_not_configured",
|
||||
},
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
"""Fail-closed guard for in-process Hermes Agent source revisions.
|
||||
|
||||
Hermes WebUI currently imports ``run_agent.AIAgent`` into its long-lived server
|
||||
process. If the Agent checkout changes while that process is alive, Python may
|
||||
combine already-cached modules with newly-read source. Refuse to reuse that
|
||||
mixed runtime and require a clean WebUI restart instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Retain the discovered path as a diagnostic/test-visible compatibility value;
|
||||
# runtime identity is deliberately captured from the loaded module below.
|
||||
from api.config import (
|
||||
PYTHON_EXE,
|
||||
_AGENT_DIR, # noqa: F401
|
||||
_DEFAULT_STATE_HOME,
|
||||
)
|
||||
from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
_RESTART_REQUIRED_MESSAGE = (
|
||||
"Hermes Agent was updated while Hermes WebUI was running. "
|
||||
"WebUI cannot verify that the Agent update completed safely. "
|
||||
"Check the Agent update outcome and environment first. "
|
||||
"Restart Hermes WebUI manually before retrying this action."
|
||||
)
|
||||
_AGENT_UPDATE_MARKER = ".hermes-update-in-progress"
|
||||
_AGENT_RECOVERY_MARKERS = (".update-incomplete", ".lazy-refresh-incomplete")
|
||||
_AGENT_UPDATE_MAX_AGE_SECONDS = 20 * 60
|
||||
# The update marker holds a PID and a start timestamp (two short numeric lines).
|
||||
# Anything larger is not a legitimate marker; cap the read so a huge or growing
|
||||
# regular file can never exhaust memory on the stale-runtime request path.
|
||||
_AGENT_UPDATE_MARKER_MAX_BYTES = 64 * 1024
|
||||
# O_NOFOLLOW is POSIX; on platforms that lack it the fast os.open() path is not
|
||||
# taken at all (see _MARKER_SAFE_OPEN_AVAILABLE below).
|
||||
# The marker read hardening relies on two POSIX-only open flags to stay both
|
||||
# non-blocking (never hang on a FIFO/device) and symlink-safe. O_NONBLOCK is
|
||||
# Unix-only and O_NOFOLLOW is absent on some platforms; accessing them
|
||||
# unconditionally raises AttributeError on native Windows. Resolve them safely
|
||||
# and only take the os.open() fast path when BOTH are genuinely available —
|
||||
# otherwise the read cannot prove non-blocking + no-follow and must fall back to
|
||||
# an lstat-only classification (see _read_live_agent_update).
|
||||
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
||||
_O_NONBLOCK = getattr(os, "O_NONBLOCK", 0)
|
||||
_MARKER_SAFE_OPEN_AVAILABLE = bool(getattr(os, "O_NOFOLLOW", 0)) and bool(
|
||||
getattr(os, "O_NONBLOCK", 0)
|
||||
)
|
||||
_HERMES_HOME = Path(_DEFAULT_STATE_HOME)
|
||||
_AGENT_PYTHON = Path(PYTHON_EXE).expanduser() if PYTHON_EXE else None
|
||||
|
||||
|
||||
def _read_agent_revision(
|
||||
agent_dir: Path | None,
|
||||
*,
|
||||
module_path: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Return the loaded Agent checkout HEAD, or ``None`` if it is not tracked."""
|
||||
if agent_dir is None:
|
||||
return None
|
||||
|
||||
if module_path is None:
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
try:
|
||||
worktree_result = subprocess.run(
|
||||
["git", "-C", str(agent_dir), "rev-parse", "--show-toplevel"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if worktree_result.returncode != 0:
|
||||
return None
|
||||
worktree = Path(worktree_result.stdout.strip()).resolve()
|
||||
relative_module = module_path.relative_to(worktree).as_posix()
|
||||
tracked_result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"--literal-pathspecs",
|
||||
"-C",
|
||||
str(worktree),
|
||||
"ls-files",
|
||||
"--error-unmatch",
|
||||
"--",
|
||||
relative_module,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if tracked_result.returncode != 0:
|
||||
return None
|
||||
revision_result = subprocess.run(
|
||||
["git", "-C", str(worktree), "rev-parse", "--verify", "HEAD"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
|
||||
return None
|
||||
|
||||
revision = revision_result.stdout.strip()
|
||||
return revision if revision_result.returncode == 0 and revision else None
|
||||
|
||||
|
||||
_AGENT_SOURCE_DIR: Path | None = None
|
||||
_AGENT_MODULE_PATH: Path | None = None
|
||||
_AGENT_REVISION: str | None = None
|
||||
_AIAgent = None
|
||||
_RUNTIME_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class AgentRuntimeChangedError(RuntimeError):
|
||||
"""Raised when the loaded Agent runtime no longer matches its source tree."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
agent_update_state: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.agent_update_state = agent_update_state
|
||||
|
||||
|
||||
def agent_runtime_stale_payload(exc: AgentRuntimeChangedError) -> dict:
|
||||
"""Return the shared retry response for every stale-runtime entry point."""
|
||||
payload = {
|
||||
"error": str(exc),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
"restart_scheduled": False,
|
||||
}
|
||||
if exc.agent_update_state is not None:
|
||||
payload["agent_update_state"] = exc.agent_update_state
|
||||
return payload
|
||||
|
||||
|
||||
def _pid_is_alive(pid: int) -> bool | None:
|
||||
"""Return PID liveness, or ``None`` when the platform cannot confirm it."""
|
||||
if pid <= 0:
|
||||
return False
|
||||
if pid.bit_length() > 32:
|
||||
return None
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = (
|
||||
wintypes.DWORD,
|
||||
wintypes.BOOL,
|
||||
wintypes.DWORD,
|
||||
)
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = (
|
||||
wintypes.HANDLE,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
)
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
handle = kernel32.OpenProcess(0x1000, False, pid)
|
||||
if not handle:
|
||||
error = ctypes.get_last_error()
|
||||
if error == 5: # ERROR_ACCESS_DENIED still proves the PID exists.
|
||||
return True
|
||||
if error == 87: # ERROR_INVALID_PARAMETER for a missing PID.
|
||||
return False
|
||||
return None
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return None
|
||||
return exit_code.value == 259 # STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
except (AttributeError, OSError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (OverflowError, ValueError):
|
||||
return None
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ESRCH:
|
||||
return False
|
||||
if exc.errno == errno.EPERM:
|
||||
return True
|
||||
return None
|
||||
return True
|
||||
|
||||
|
||||
def _read_live_agent_update(marker: Path) -> str:
|
||||
"""Classify the shared Agent update marker without changing Agent state.
|
||||
|
||||
The marker is attacker-adjacent shared state (any process that can write the
|
||||
Agent home can create it), so the read is hardened: never follow a symlink,
|
||||
never block on a FIFO/device, and never read an unbounded regular file.
|
||||
Anything that is not a small regular file is classified ``unknown`` rather
|
||||
than allowed to hang or exhaust memory on a stale-runtime request path.
|
||||
"""
|
||||
if not _MARKER_SAFE_OPEN_AVAILABLE:
|
||||
# Without both O_NONBLOCK and O_NOFOLLOW we cannot prove the read is
|
||||
# non-blocking and symlink-safe (e.g. native Windows), so never open the
|
||||
# marker: an unverifiable marker fails closed to ``unknown``, and only a
|
||||
# genuinely missing path is ``absent``.
|
||||
try:
|
||||
marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return "absent"
|
||||
except (OSError, ValueError, TypeError):
|
||||
return "unknown"
|
||||
return "unknown"
|
||||
try:
|
||||
fd = os.open(marker, os.O_RDONLY | _O_NONBLOCK | _O_NOFOLLOW)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return "absent"
|
||||
except OSError:
|
||||
return "unknown"
|
||||
# Path exists to lstat (e.g. a dangling/looping symlink) but O_NOFOLLOW
|
||||
# refused to open it — treat as an unverifiable marker.
|
||||
return "unknown"
|
||||
except (OSError, ValueError, TypeError):
|
||||
# ELOOP (symlink under O_NOFOLLOW), ENXIO/EWOULDBLOCK (FIFO with no
|
||||
# writer under O_NONBLOCK), a non-path marker object, or any other open
|
||||
# failure — fail closed: an unreadable marker is never proof of safety.
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
# FIFO, device, directory, socket — never a legitimate marker.
|
||||
return "unknown"
|
||||
if st.st_size > _AGENT_UPDATE_MARKER_MAX_BYTES:
|
||||
return "unknown"
|
||||
try:
|
||||
# Read one byte past the cap so an oversized file that lied about
|
||||
# st_size (or grew mid-read) is still rejected rather than truncated.
|
||||
data = os.read(fd, _AGENT_UPDATE_MARKER_MAX_BYTES + 1)
|
||||
except (OSError, BlockingIOError):
|
||||
return "unknown"
|
||||
finally:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if len(data) > _AGENT_UPDATE_MARKER_MAX_BYTES:
|
||||
return "unknown"
|
||||
try:
|
||||
raw = data.decode("utf-8")
|
||||
except UnicodeError:
|
||||
return "unknown"
|
||||
|
||||
lines = raw.splitlines()
|
||||
try:
|
||||
pid = int(lines[0].strip())
|
||||
started_at = float(lines[1].strip())
|
||||
except (IndexError, TypeError, ValueError):
|
||||
return "unknown"
|
||||
if pid <= 0 or not math.isfinite(started_at):
|
||||
return "unknown"
|
||||
|
||||
age_seconds = time.time() - started_at
|
||||
if age_seconds < 0:
|
||||
return "unknown"
|
||||
if age_seconds > _AGENT_UPDATE_MAX_AGE_SECONDS:
|
||||
return "stale"
|
||||
alive = _pid_is_alive(pid)
|
||||
if alive is None:
|
||||
return "unknown"
|
||||
return "active" if alive else "stale"
|
||||
|
||||
|
||||
def _marker_presence(marker: Path) -> str:
|
||||
"""Return ``present``, ``absent``, or ``unknown`` for a recovery marker."""
|
||||
try:
|
||||
marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return "absent"
|
||||
except OSError:
|
||||
return "unknown"
|
||||
return "present"
|
||||
|
||||
|
||||
def _agent_install_roots() -> tuple[Path, ...]:
|
||||
"""Return portable roots that can own the Agent's venv recovery markers."""
|
||||
candidates: list[Path] = []
|
||||
if _AGENT_SOURCE_DIR is not None:
|
||||
candidates.append(_AGENT_SOURCE_DIR)
|
||||
if _AGENT_PYTHON is not None:
|
||||
# A venv Python is commonly a symlink to a shared interpreter. Keep the
|
||||
# configured venv path so its installation's recovery markers are read.
|
||||
python_path = _AGENT_PYTHON
|
||||
if python_path.parent.name.lower() in {"bin", "scripts"}:
|
||||
venv_dir = python_path.parent.parent
|
||||
if venv_dir.name.lower() in {"venv", ".venv"}:
|
||||
candidates.append(venv_dir.parent)
|
||||
|
||||
roots: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
key = os.path.normcase(os.path.abspath(str(candidate)))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
roots.append(candidate)
|
||||
return tuple(roots)
|
||||
|
||||
|
||||
def _agent_update_transaction_state() -> str:
|
||||
"""Report marker diagnostics, never proof of successful completion.
|
||||
|
||||
The Agent removes its active marker on failed/interrupted exits too. Neither
|
||||
its absence nor a stale PID proves the checkout or environment is healthy.
|
||||
"""
|
||||
live_state = _read_live_agent_update(_HERMES_HOME / _AGENT_UPDATE_MARKER)
|
||||
if live_state == "unknown":
|
||||
return "unknown"
|
||||
|
||||
recovery_present = False
|
||||
for root in _agent_install_roots():
|
||||
for marker_name in _AGENT_RECOVERY_MARKERS:
|
||||
presence = _marker_presence(root / marker_name)
|
||||
if presence == "unknown":
|
||||
return "unknown"
|
||||
recovery_present = recovery_present or presence == "present"
|
||||
if recovery_present:
|
||||
return "incomplete"
|
||||
return "unverified" if live_state == "absent" else live_state
|
||||
|
||||
|
||||
def _loaded_agent_source_identity() -> tuple[Path, Path] | None:
|
||||
"""Return the source directory and file that supplied ``run_agent``."""
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
return module_path.parent, module_path
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _capture_loaded_agent_revision() -> None:
|
||||
"""Bind the guard to the checkout that supplied the loaded Agent module."""
|
||||
global _AGENT_SOURCE_DIR, _AGENT_MODULE_PATH, _AGENT_REVISION
|
||||
|
||||
if _AGENT_REVISION is not None:
|
||||
ensure_agent_runtime_current()
|
||||
return
|
||||
|
||||
identity = _loaded_agent_source_identity()
|
||||
if identity is None:
|
||||
return
|
||||
source_dir, module_path = identity
|
||||
current_revision = _read_agent_revision(source_dir, module_path=module_path)
|
||||
_AGENT_SOURCE_DIR = source_dir
|
||||
_AGENT_MODULE_PATH = module_path
|
||||
_AGENT_REVISION = current_revision
|
||||
|
||||
|
||||
def ensure_agent_runtime_current() -> None:
|
||||
"""Reject a known Git checkout change instead of mixing Python modules."""
|
||||
if _AGENT_REVISION is None:
|
||||
return
|
||||
if (
|
||||
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
|
||||
!= _AGENT_REVISION
|
||||
):
|
||||
# Automatic restart needs an Agent-owned success receipt bound to this
|
||||
# transaction, final revision and healthy environment, plus an atomic
|
||||
# handoff excluding mutations across replacement. Marker polling and a
|
||||
# final revision read supply neither contract. Keep this path manual.
|
||||
raise AgentRuntimeChangedError(
|
||||
_RESTART_REQUIRED_MESSAGE,
|
||||
agent_update_state=_agent_update_transaction_state(),
|
||||
)
|
||||
|
||||
|
||||
def require_ai_agent_class():
|
||||
"""Import ``AIAgent`` after proving the loaded source revision is current."""
|
||||
ensure_agent_runtime_current()
|
||||
from run_agent import AIAgent # noqa: PLC0415
|
||||
|
||||
_capture_loaded_agent_revision()
|
||||
return AIAgent
|
||||
|
||||
|
||||
def get_ai_agent_class():
|
||||
"""Return ``AIAgent`` while preserving the existing lazy-import retry."""
|
||||
global _AIAgent, _AGENT_REVISION
|
||||
|
||||
with _RUNTIME_LOCK:
|
||||
ensure_agent_runtime_current()
|
||||
if _AIAgent is None:
|
||||
try:
|
||||
agent_class = require_ai_agent_class()
|
||||
except ImportError:
|
||||
return None
|
||||
_AIAgent = agent_class
|
||||
return _AIAgent
|
||||
File diff suppressed because it is too large
Load Diff
-1263
File diff suppressed because it is too large
Load Diff
@@ -1,728 +0,0 @@
|
||||
import copy
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa, utils
|
||||
|
||||
from api.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_SCOPES = ("openid", "profile", "email")
|
||||
_PENDING_TTL_SECONDS = 600
|
||||
_MAX_PENDING_FLOWS = 128
|
||||
_CLOCK_SKEW_SECONDS = 60
|
||||
_CACHE_TTL_SECONDS = 300
|
||||
|
||||
_pending_lock = threading.Lock()
|
||||
_pending_flows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
_discovery_lock = threading.Lock()
|
||||
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
_jwks_lock = threading.Lock()
|
||||
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
_warned_allow_values: set[str] = set()
|
||||
|
||||
_ALLOW_VALUES_WHITESPACE_WARNING = (
|
||||
"webui_oidc.allow_values (HERMES_WEBUI_OIDC_ALLOW_VALUES) has one or more entries "
|
||||
"with internal whitespace; whitespace is not a value separator, so a value like "
|
||||
'"alice@example.com bob@example.com" is treated as a single entry. '
|
||||
'Use a comma-delimited scalar (e.g. "value1,value2") or a YAML array. '
|
||||
"If this is one intentional multi-word group, it is already correct and no action is needed."
|
||||
)
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class OIDCConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OIDCAuthError(Exception):
|
||||
def __init__(self, message: str, *, status_code: int = 401):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def is_oidc_enabled() -> bool:
|
||||
cfg = _resolve_oidc_config()
|
||||
return bool(
|
||||
cfg.get("issuer")
|
||||
and cfg.get("client_id")
|
||||
and cfg.get("allow_claim")
|
||||
and cfg.get("allow_values")
|
||||
)
|
||||
|
||||
|
||||
def build_authorization_redirect(
|
||||
request_base_url: str,
|
||||
next_path: str | None = None,
|
||||
) -> str:
|
||||
cfg = _require_oidc_config()
|
||||
discovery = _get_discovery_document(cfg["issuer"])
|
||||
authorization_endpoint = str(discovery.get("authorization_endpoint") or "").strip()
|
||||
if not authorization_endpoint:
|
||||
raise OIDCConfigError("OIDC discovery document is missing authorization_endpoint")
|
||||
redirect_uri = _resolve_redirect_uri(cfg, request_base_url)
|
||||
state = secrets.token_urlsafe(24)
|
||||
nonce = secrets.token_urlsafe(24)
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
challenge = _b64u(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
_store_pending_flow(
|
||||
state,
|
||||
{
|
||||
"created_at": time.time(),
|
||||
"nonce": nonce,
|
||||
"code_verifier": verifier,
|
||||
"next_path": _safe_next_path(next_path),
|
||||
},
|
||||
)
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": cfg["client_id"],
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(cfg["scopes"]),
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return authorization_endpoint + "?" + urllib.parse.urlencode(params)
|
||||
|
||||
|
||||
def complete_authorization_code_flow(
|
||||
request_base_url: str,
|
||||
state: str,
|
||||
code: str,
|
||||
) -> dict[str, Any]:
|
||||
cfg = _require_oidc_config()
|
||||
pending = _consume_pending_flow(state)
|
||||
if pending is None:
|
||||
raise OIDCAuthError("Invalid OIDC state", status_code=401)
|
||||
discovery = _get_discovery_document(cfg["issuer"])
|
||||
discovery_issuer = str(discovery.get("issuer") or "").strip()
|
||||
if discovery_issuer and discovery_issuer != cfg["issuer"]:
|
||||
raise OIDCAuthError("OIDC discovery issuer did not match the configured issuer", status_code=502)
|
||||
token_endpoint = str(discovery.get("token_endpoint") or "").strip()
|
||||
if not token_endpoint:
|
||||
raise OIDCConfigError("OIDC discovery document is missing token_endpoint")
|
||||
redirect_uri = _resolve_redirect_uri(cfg, request_base_url)
|
||||
token_response = _post_form_json(
|
||||
token_endpoint,
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": cfg["client_id"],
|
||||
"code": code,
|
||||
"code_verifier": pending["code_verifier"],
|
||||
"redirect_uri": redirect_uri,
|
||||
**({"client_secret": cfg["client_secret"]} if cfg.get("client_secret") else {}),
|
||||
},
|
||||
)
|
||||
id_token = str(token_response.get("id_token") or "").strip()
|
||||
if not id_token:
|
||||
raise OIDCAuthError("OIDC token response did not include an id_token", status_code=502)
|
||||
claims = _validate_id_token(
|
||||
id_token,
|
||||
client_id=cfg["client_id"],
|
||||
issuer=cfg["issuer"],
|
||||
nonce=pending["nonce"],
|
||||
jwks_uri=str(discovery.get("jwks_uri") or "").strip(),
|
||||
)
|
||||
_enforce_allowlist(
|
||||
claims,
|
||||
allow_claim=cfg.get("allow_claim"),
|
||||
allow_values=cfg.get("allow_values") or [],
|
||||
)
|
||||
return {
|
||||
"next_path": pending["next_path"],
|
||||
"subject": str(claims.get("sub") or ""),
|
||||
"email": str(claims.get("email") or ""),
|
||||
"claims": claims,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_oidc_config() -> dict[str, Any]:
|
||||
raw = {}
|
||||
try:
|
||||
cfg = get_config()
|
||||
value = cfg.get("webui_oidc") if isinstance(cfg, dict) else None
|
||||
if isinstance(value, dict):
|
||||
raw.update(value)
|
||||
except Exception:
|
||||
logger.debug("Failed to read webui_oidc config", exc_info=True)
|
||||
|
||||
def pick(name: str, env_name: str) -> Any:
|
||||
env_value = os.getenv(env_name)
|
||||
return env_value if env_value is not None else raw.get(name)
|
||||
|
||||
scopes = _normalize_scopes(pick("scopes", "HERMES_WEBUI_OIDC_SCOPES"))
|
||||
raw_allow = pick("allow_values", "HERMES_WEBUI_OIDC_ALLOW_VALUES")
|
||||
allow_values = _normalize_allow_values(raw_allow)
|
||||
if (
|
||||
raw_allow is not None
|
||||
and not isinstance(raw_allow, (list, tuple, set))
|
||||
and any(any(ch.isspace() for ch in v) for v in allow_values)
|
||||
):
|
||||
key = str(raw_allow)
|
||||
if key not in _warned_allow_values:
|
||||
_warned_allow_values.add(key)
|
||||
logger.warning(_ALLOW_VALUES_WHITESPACE_WARNING)
|
||||
return {
|
||||
"issuer": str(pick("issuer", "HERMES_WEBUI_OIDC_ISSUER") or "").strip(),
|
||||
"client_id": str(pick("client_id", "HERMES_WEBUI_OIDC_CLIENT_ID") or "").strip(),
|
||||
"client_secret": str(pick("client_secret", "HERMES_WEBUI_OIDC_CLIENT_SECRET") or "").strip(),
|
||||
"redirect_uri": str(pick("redirect_uri", "HERMES_WEBUI_OIDC_REDIRECT_URI") or "").strip(),
|
||||
"scopes": scopes,
|
||||
"allow_claim": str(pick("allow_claim", "HERMES_WEBUI_OIDC_ALLOW_CLAIM") or "").strip(),
|
||||
"allow_values": allow_values,
|
||||
}
|
||||
|
||||
|
||||
def _require_oidc_config() -> dict[str, Any]:
|
||||
cfg = _resolve_oidc_config()
|
||||
if not cfg.get("issuer") or not cfg.get("client_id"):
|
||||
raise OIDCConfigError("Native OIDC login is not configured")
|
||||
if not cfg.get("allow_claim") or not cfg.get("allow_values"):
|
||||
raise OIDCConfigError(
|
||||
"Native OIDC login requires webui_oidc.allow_claim and allow_values"
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _normalize_scopes(raw: Any) -> list[str]:
|
||||
items = _normalize_text_list(raw)
|
||||
if not items:
|
||||
return list(_DEFAULT_SCOPES)
|
||||
if "openid" not in items:
|
||||
items.insert(0, "openid")
|
||||
deduped = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
|
||||
def _normalize_allow_values(raw: Any) -> list[str]:
|
||||
"""Normalize allowlist values, splitting only on commas/newlines.
|
||||
|
||||
Unlike ``_normalize_text_list`` (which also splits on whitespace), this
|
||||
preserves multi-word values such as OIDC group names containing spaces
|
||||
(e.g. ``"Hermes Users"`` stays as one entry).
|
||||
|
||||
RFC 6749 §3.3 requires space-delimited scope strings, so
|
||||
``_normalize_scopes`` must keep using ``_normalize_text_list`` -- this
|
||||
function is for allow-values only.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
return [value for value in (str(item).strip() for item in raw) if value]
|
||||
text = str(raw).replace("\n", ",")
|
||||
return [part.strip() for part in text.split(",") if part.strip()]
|
||||
|
||||
|
||||
def _normalize_text_list(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
values = [str(item).strip() for item in raw]
|
||||
else:
|
||||
text = str(raw).replace("\n", ",")
|
||||
values = []
|
||||
for comma_part in text.split(","):
|
||||
values.extend(piece.strip() for piece in comma_part.split() if piece.strip())
|
||||
return [value for value in values if value]
|
||||
|
||||
|
||||
def _safe_next_path(raw_path: str | None) -> str:
|
||||
path = str(raw_path or "").strip()
|
||||
if not path:
|
||||
return "/"
|
||||
if path[0] != "/":
|
||||
return "/"
|
||||
if path[1:2] in {"/", "\\"}:
|
||||
return "/"
|
||||
if any(ord(ch) < 32 or ord(ch) == 127 or ch.isspace() for ch in path):
|
||||
return "/"
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_redirect_uri(cfg: dict[str, Any], request_base_url: str) -> str:
|
||||
explicit = str(cfg.get("redirect_uri") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
return request_base_url.rstrip("/") + "/api/auth/oidc/callback"
|
||||
|
||||
|
||||
def _store_pending_flow(state: str, payload: dict[str, Any]) -> None:
|
||||
now = time.time()
|
||||
with _pending_lock:
|
||||
_prune_pending_flows(now)
|
||||
_trim_pending_flows()
|
||||
_pending_flows[state] = payload
|
||||
|
||||
|
||||
def _consume_pending_flow(state: str) -> dict[str, Any] | None:
|
||||
now = time.time()
|
||||
with _pending_lock:
|
||||
_prune_pending_flows(now)
|
||||
payload = _pending_flows.pop(state, None)
|
||||
return payload
|
||||
|
||||
|
||||
def _prune_pending_flows(now: float) -> None:
|
||||
expired = [
|
||||
state
|
||||
for state, payload in _pending_flows.items()
|
||||
if now - float(payload.get("created_at") or 0) > _PENDING_TTL_SECONDS
|
||||
]
|
||||
for state in expired:
|
||||
_pending_flows.pop(state, None)
|
||||
|
||||
|
||||
def _trim_pending_flows() -> None:
|
||||
overflow = len(_pending_flows) - _MAX_PENDING_FLOWS + 1
|
||||
if overflow <= 0:
|
||||
return
|
||||
oldest = sorted(
|
||||
_pending_flows,
|
||||
key=lambda state: float(_pending_flows[state].get("created_at") or 0),
|
||||
)
|
||||
for state in oldest[:overflow]:
|
||||
_pending_flows.pop(state, None)
|
||||
|
||||
|
||||
def _get_discovery_document(issuer: str) -> dict[str, Any]:
|
||||
discovery_url = _discovery_url_for_issuer(issuer)
|
||||
cached = _cache_get(_discovery_lock, _discovery_cache, discovery_url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = _fetch_json(discovery_url)
|
||||
if not isinstance(data, dict):
|
||||
raise OIDCAuthError("OIDC discovery response was not a JSON object", status_code=502)
|
||||
_cache_put(_discovery_lock, _discovery_cache, discovery_url, data)
|
||||
return data
|
||||
|
||||
|
||||
def _discovery_url_for_issuer(issuer: str) -> str:
|
||||
if issuer.endswith("/.well-known/openid-configuration"):
|
||||
return issuer
|
||||
return issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||
|
||||
|
||||
def _get_jwks_document(jwks_uri: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
if not jwks_uri:
|
||||
raise OIDCConfigError("OIDC discovery document is missing jwks_uri")
|
||||
if force_refresh:
|
||||
with _jwks_lock:
|
||||
_jwks_cache.pop(jwks_uri, None)
|
||||
else:
|
||||
cached = _cache_get(_jwks_lock, _jwks_cache, jwks_uri)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = _fetch_json(jwks_uri)
|
||||
if not isinstance(data, dict):
|
||||
raise OIDCAuthError("OIDC JWKS response was not a JSON object", status_code=502)
|
||||
_cache_put(_jwks_lock, _jwks_cache, jwks_uri, data)
|
||||
return data
|
||||
|
||||
|
||||
def _cache_get(
|
||||
lock: threading.Lock,
|
||||
cache: dict[str, tuple[float, dict[str, Any]]],
|
||||
key: str,
|
||||
) -> dict[str, Any] | None:
|
||||
now = time.time()
|
||||
with lock:
|
||||
entry = cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
expires_at, value = entry
|
||||
if expires_at <= now:
|
||||
cache.pop(key, None)
|
||||
return None
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def _cache_put(
|
||||
lock: threading.Lock,
|
||||
cache: dict[str, tuple[float, dict[str, Any]]],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
) -> None:
|
||||
with lock:
|
||||
cache[key] = (time.time() + _CACHE_TTL_SECONDS, copy.deepcopy(value))
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> dict[str, Any]:
|
||||
_validate_outbound_oidc_url(url)
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with _oidc_opener().open(req, timeout=10) as resp:
|
||||
payload = json.loads(
|
||||
resp.read().decode("utf-8"),
|
||||
parse_constant=_reject_non_finite_json_constant,
|
||||
)
|
||||
except (urllib.error.HTTPError, urllib.error.URLError) as exc:
|
||||
raise OIDCAuthError(f"Failed to reach OIDC endpoint: {url}", status_code=502) from exc
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise OIDCAuthError(f"OIDC endpoint returned invalid JSON: {url}", status_code=502) from exc
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _post_form_json(url: str, form_data: dict[str, Any]) -> dict[str, Any]:
|
||||
_validate_outbound_oidc_url(url)
|
||||
body = urllib.parse.urlencode(form_data).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with _oidc_opener().open(req, timeout=10) as resp:
|
||||
payload = json.loads(
|
||||
resp.read().decode("utf-8"),
|
||||
parse_constant=_reject_non_finite_json_constant,
|
||||
)
|
||||
except (urllib.error.HTTPError, urllib.error.URLError) as exc:
|
||||
raise OIDCAuthError("Failed to exchange the OIDC authorization code", status_code=502) from exc
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise OIDCAuthError("OIDC token endpoint returned invalid JSON", status_code=502) from exc
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _oidc_opener() -> urllib.request.OpenerDirector:
|
||||
return urllib.request.build_opener(_NoRedirect)
|
||||
|
||||
|
||||
def _validate_outbound_oidc_url(url: str) -> None:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise OIDCAuthError("OIDC endpoint URLs must use https", status_code=502)
|
||||
if parsed.username or parsed.password:
|
||||
raise OIDCAuthError("OIDC endpoint URLs must not contain credentials", status_code=502)
|
||||
hostname = str(parsed.hostname or "").strip()
|
||||
if not hostname:
|
||||
raise OIDCAuthError("OIDC endpoint URL was missing a hostname", status_code=502)
|
||||
if _is_disallowed_oidc_host(hostname):
|
||||
raise OIDCAuthError(
|
||||
"OIDC endpoint URLs must not target private or local addresses",
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
|
||||
def _is_disallowed_oidc_host(hostname: str) -> bool:
|
||||
literal_ip = _parse_ip_address(hostname)
|
||||
if literal_ip is not None:
|
||||
return _is_disallowed_oidc_ip(literal_ip)
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, 443, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
address = _parse_ip_address(sockaddr[0] if sockaddr else "")
|
||||
if address is not None and _is_disallowed_oidc_ip(address):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parse_ip_address(value: str):
|
||||
try:
|
||||
return ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_disallowed_oidc_ip(address) -> bool:
|
||||
candidate = getattr(address, "ipv4_mapped", None) or address
|
||||
return (
|
||||
candidate.is_loopback
|
||||
or candidate.is_private
|
||||
or candidate.is_link_local
|
||||
or candidate.is_multicast
|
||||
or candidate.is_unspecified
|
||||
or candidate.is_reserved
|
||||
)
|
||||
|
||||
|
||||
def _reject_non_finite_json_constant(value: str):
|
||||
raise ValueError(f"OIDC JSON contained unsupported constant: {value}")
|
||||
|
||||
|
||||
def _validate_id_token(
|
||||
token: str,
|
||||
*,
|
||||
client_id: str,
|
||||
issuer: str,
|
||||
nonce: str,
|
||||
jwks_uri: str,
|
||||
) -> dict[str, Any]:
|
||||
header, claims, signed, signature = _parse_jwt(token)
|
||||
alg = str(header.get("alg") or "").strip()
|
||||
if not alg or alg == "none":
|
||||
raise OIDCAuthError("OIDC id_token uses an unsupported signing algorithm")
|
||||
jwks = _get_jwks_document(jwks_uri)
|
||||
try:
|
||||
public_key = _select_public_key(jwks, header)
|
||||
except OIDCAuthError as exc:
|
||||
if "did not contain the signing key" not in str(exc):
|
||||
raise
|
||||
jwks = _get_jwks_document(jwks_uri, force_refresh=True)
|
||||
public_key = _select_public_key(jwks, header)
|
||||
_verify_jwt_signature(public_key, alg, signed, signature)
|
||||
_validate_registered_claims(claims, client_id=client_id, issuer=issuer, nonce=nonce)
|
||||
if not str(claims.get("sub") or "").strip():
|
||||
raise OIDCAuthError("OIDC id_token did not include a subject")
|
||||
return claims
|
||||
|
||||
|
||||
def _parse_jwt(token: str) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise OIDCAuthError("OIDC id_token was not a JWT")
|
||||
header_b64, payload_b64, signature_b64 = parts
|
||||
try:
|
||||
header = json.loads(
|
||||
_b64u_decode(header_b64),
|
||||
parse_constant=_reject_non_finite_json_constant,
|
||||
)
|
||||
claims = json.loads(
|
||||
_b64u_decode(payload_b64),
|
||||
parse_constant=_reject_non_finite_json_constant,
|
||||
)
|
||||
signature = _b64u_decode_bytes(signature_b64)
|
||||
except Exception as exc:
|
||||
raise OIDCAuthError("OIDC id_token could not be decoded") from exc
|
||||
if not isinstance(header, dict) or not isinstance(claims, dict):
|
||||
raise OIDCAuthError("OIDC id_token payload was malformed")
|
||||
signed = f"{header_b64}.{payload_b64}".encode("ascii")
|
||||
return header, claims, signed, signature
|
||||
|
||||
|
||||
def _select_public_key(jwks: dict[str, Any], header: dict[str, Any]):
|
||||
keys = jwks.get("keys")
|
||||
if not isinstance(keys, list) or not keys:
|
||||
raise OIDCAuthError("OIDC JWKS did not contain any signing keys", status_code=502)
|
||||
kid = str(header.get("kid") or "").strip()
|
||||
alg = str(header.get("alg") or "").strip()
|
||||
matches = []
|
||||
for key in keys:
|
||||
if not isinstance(key, dict):
|
||||
continue
|
||||
if key.get("use") not in (None, "sig"):
|
||||
continue
|
||||
if kid and str(key.get("kid") or "").strip() != kid:
|
||||
continue
|
||||
if key.get("alg") not in (None, alg):
|
||||
continue
|
||||
if not _jwk_matches_alg_family(key, alg):
|
||||
continue
|
||||
matches.append(key)
|
||||
if not matches:
|
||||
raise OIDCAuthError("OIDC JWKS did not contain the signing key for this id_token", status_code=502)
|
||||
return _jwk_to_public_key(matches[0])
|
||||
|
||||
|
||||
def _jwk_matches_alg_family(jwk: dict[str, Any], alg: str) -> bool:
|
||||
kty = str(jwk.get("kty") or "").strip()
|
||||
if alg.startswith("RS"):
|
||||
return kty == "RSA"
|
||||
if alg.startswith("ES"):
|
||||
return kty == "EC" and str(jwk.get("crv") or "").strip() == _ec_curve_for_alg(alg)
|
||||
return True
|
||||
|
||||
|
||||
def _ec_curve_for_alg(alg: str) -> str:
|
||||
return {
|
||||
"ES256": "P-256",
|
||||
"ES384": "P-384",
|
||||
"ES512": "P-521",
|
||||
}.get(alg, "")
|
||||
|
||||
|
||||
def _jwk_to_public_key(jwk: dict[str, Any]):
|
||||
kty = str(jwk.get("kty") or "").strip()
|
||||
if kty == "RSA":
|
||||
n = _int_from_b64u(jwk.get("n"))
|
||||
e = _int_from_b64u(jwk.get("e"))
|
||||
return rsa.RSAPublicNumbers(e, n).public_key()
|
||||
if kty == "EC":
|
||||
crv = str(jwk.get("crv") or "").strip()
|
||||
curve = {
|
||||
"P-256": ec.SECP256R1(),
|
||||
"P-384": ec.SECP384R1(),
|
||||
"P-521": ec.SECP521R1(),
|
||||
}.get(crv)
|
||||
if curve is None:
|
||||
raise OIDCAuthError(f"Unsupported OIDC EC curve: {crv}", status_code=502)
|
||||
x = _int_from_b64u(jwk.get("x"))
|
||||
y = _int_from_b64u(jwk.get("y"))
|
||||
return ec.EllipticCurvePublicNumbers(x, y, curve).public_key()
|
||||
raise OIDCAuthError(f"Unsupported OIDC key type: {kty}", status_code=502)
|
||||
|
||||
|
||||
def _verify_jwt_signature(public_key, alg: str, signed: bytes, signature: bytes) -> None:
|
||||
try:
|
||||
if alg == "RS256":
|
||||
public_key.verify(signature, signed, padding.PKCS1v15(), hashes.SHA256())
|
||||
return
|
||||
if alg == "RS384":
|
||||
public_key.verify(signature, signed, padding.PKCS1v15(), hashes.SHA384())
|
||||
return
|
||||
if alg == "RS512":
|
||||
public_key.verify(signature, signed, padding.PKCS1v15(), hashes.SHA512())
|
||||
return
|
||||
if alg == "ES256":
|
||||
public_key.verify(_jose_ecdsa_signature_to_der(signature, 32), signed, ec.ECDSA(hashes.SHA256()))
|
||||
return
|
||||
if alg == "ES384":
|
||||
public_key.verify(_jose_ecdsa_signature_to_der(signature, 48), signed, ec.ECDSA(hashes.SHA384()))
|
||||
return
|
||||
if alg == "ES512":
|
||||
public_key.verify(_jose_ecdsa_signature_to_der(signature, 66), signed, ec.ECDSA(hashes.SHA512()))
|
||||
return
|
||||
except InvalidSignature as exc:
|
||||
raise OIDCAuthError("OIDC id_token signature verification failed") from exc
|
||||
raise OIDCAuthError(f"Unsupported OIDC signing algorithm: {alg}", status_code=502)
|
||||
|
||||
|
||||
def _jose_ecdsa_signature_to_der(signature: bytes, part_size: int) -> bytes:
|
||||
if len(signature) != part_size * 2:
|
||||
raise OIDCAuthError("OIDC id_token ECDSA signature was malformed")
|
||||
r = int.from_bytes(signature[:part_size], "big")
|
||||
s = int.from_bytes(signature[part_size:], "big")
|
||||
return utils.encode_dss_signature(r, s)
|
||||
|
||||
|
||||
def _validate_registered_claims(
|
||||
claims: dict[str, Any],
|
||||
*,
|
||||
client_id: str,
|
||||
issuer: str,
|
||||
nonce: str,
|
||||
) -> None:
|
||||
now = time.time()
|
||||
if str(claims.get("iss") or "").strip() != issuer:
|
||||
raise OIDCAuthError("OIDC id_token issuer did not match the configured issuer")
|
||||
aud = claims.get("aud")
|
||||
if isinstance(aud, list):
|
||||
audiences = [str(item) for item in aud]
|
||||
elif aud is None:
|
||||
audiences = []
|
||||
else:
|
||||
audiences = [str(aud)]
|
||||
if client_id not in audiences:
|
||||
raise OIDCAuthError("OIDC id_token audience did not include this client")
|
||||
if len(audiences) > 1 and str(claims.get("azp") or "").strip() not in {"", client_id}:
|
||||
raise OIDCAuthError("OIDC id_token azp did not match this client")
|
||||
exp = _coerce_numeric_claim(claims, "exp")
|
||||
if exp is None or exp < now - _CLOCK_SKEW_SECONDS:
|
||||
raise OIDCAuthError("OIDC id_token has expired")
|
||||
nbf = _coerce_numeric_claim(claims, "nbf")
|
||||
if nbf is not None and nbf > now + _CLOCK_SKEW_SECONDS:
|
||||
raise OIDCAuthError("OIDC id_token is not valid yet")
|
||||
iat = _coerce_numeric_claim(claims, "iat")
|
||||
if iat is not None and iat > now + _CLOCK_SKEW_SECONDS:
|
||||
raise OIDCAuthError("OIDC id_token has an invalid issued-at time")
|
||||
if str(claims.get("nonce") or "").strip() != nonce:
|
||||
raise OIDCAuthError("OIDC id_token nonce did not match the login request")
|
||||
|
||||
|
||||
def _coerce_numeric_claim(claims: dict[str, Any], name: str) -> float | None:
|
||||
value = claims.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise OIDCAuthError(f"OIDC id_token claim {name} was not numeric") from exc
|
||||
if not math.isfinite(number):
|
||||
raise OIDCAuthError(f"OIDC id_token claim {name} was not numeric")
|
||||
return number
|
||||
|
||||
|
||||
def _enforce_allowlist(
|
||||
claims: dict[str, Any],
|
||||
*,
|
||||
allow_claim: str,
|
||||
allow_values: list[str],
|
||||
) -> None:
|
||||
if not allow_claim:
|
||||
return
|
||||
claim_value = _get_claim_path(claims, allow_claim)
|
||||
if claim_value is None:
|
||||
raise OIDCAuthError("OIDC identity is not allowed", status_code=403)
|
||||
actual_values = _claim_values(claim_value)
|
||||
if allow_values:
|
||||
if not any(value in actual_values for value in allow_values):
|
||||
raise OIDCAuthError("OIDC identity is not allowed", status_code=403)
|
||||
return
|
||||
if not actual_values:
|
||||
raise OIDCAuthError("OIDC identity is not allowed", status_code=403)
|
||||
|
||||
|
||||
def _get_claim_path(claims: dict[str, Any], dotted_key: str) -> Any:
|
||||
current: Any = claims
|
||||
for part in dotted_key.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
return None
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
|
||||
def _claim_values(value: Any) -> set[str]:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return {str(item) for item in value if str(item).strip()}
|
||||
if isinstance(value, dict):
|
||||
return {str(item) for item in value.values() if str(item).strip()}
|
||||
text = str(value or "").strip()
|
||||
return {text} if text else set()
|
||||
|
||||
|
||||
def _b64u(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64u_decode(data: str) -> str:
|
||||
return _b64u_decode_bytes(data).decode("utf-8")
|
||||
|
||||
|
||||
def _b64u_decode_bytes(data: str) -> bytes:
|
||||
padded = data + "=" * ((4 - len(data) % 4) % 4)
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def _int_from_b64u(data: Any) -> int:
|
||||
if not data:
|
||||
raise OIDCAuthError("OIDC JWKS key was missing a required parameter", status_code=502)
|
||||
return int.from_bytes(_b64u_decode_bytes(str(data)), "big")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user