Merge pull request #1682 from nesquena/stage-299
Some checks failed
Release & Docker / release (push) Has been cancelled

v0.51.2 — 3-PR follow-up + sidebar scroll hotfix
This commit is contained in:
nesquena-hermes
2026-05-04 19:22:03 -07:00
committed by GitHub
21 changed files with 1570 additions and 24 deletions

View File

@@ -1,5 +1,50 @@
# Hermes Web UI -- Changelog
## [v0.51.2] — 2026-05-04 — 3-PR follow-up batch (deferred from v0.51.1) + sidebar scroll hotfix
### Fixed
- **Sidebar scroll jumps back to 0 on small lists (≤80 sessions)** — PR #1669 added DOM virtualization to `renderSessionListFromCache()` with two flaws for lists below the virtualization threshold: (1) the unconditional scroll listener triggered a full DOM rebuild on every rAF, and (2) `scrollTop` was only restored when `virtualWindow.virtualized` was true (i.e. total > 80 rows). For lists ≤ 80 rows, `scrollTop` dropped to 0 on every scroll event, producing a "scroll keeps jumping back" feel. Two-part fix: (a) always restore `scrollTop` when `listScrollTopBeforeRender > 0` regardless of virtualized flag, (b) short-circuit `_scheduleSessionVirtualizedRender` when total ≤ `SESSION_VIRTUAL_THRESHOLD_ROWS` (saves the wasteful rebuild and is belt-and-suspenders defense). Live verified: production v0.51.1 confirmed broken (scrollTop drops to 0 within 100ms); v0.51.2 confirmed working (holds at 500 across 600ms+). 3 regression tests pin both fixes.
### Added
- **PR #1664** by @Michaelyklam — LLM Wiki status panel (closes #1257). New read-only Insights card showing wiki state (entries, pages, raw files, last updated, last writer) with traffic-light status badge ("Available" / "Empty" / "Unavailable" / "Error"). New `GET /api/wiki/status` endpoint reads `WIKI_PATH` env var or `skills.config.wiki.path` config, returns metadata-only counts. `loadInsights()` parallelizes the wiki status fetch with the existing `/api/insights` call via `Promise.all`, with a `.catch` fallback so wiki failures don't break Insights.
- **PR #1662** by @Michaelyklam — Logs tab MVP (closes #1455). New top-level Logs tab in nav rail. Allowlisted server-side log file viewer (`agent` / `errors` / `gateway`) with severity highlighting (info/warning/error/debug), tail size selector (100/200/500/1000 lines), auto-refresh, copy-all. New `GET /api/logs` endpoint with strict allowlist + path-traversal guard + bounded 4 MiB tail window. 8 i18n locale entries added.
- **PR #1587** by @franksong2702 — Filter low-value CLI agent sessions (refs #1013). Source-aware sidebar visibility rules for imported CLI agent sessions: hides empty CLI rows; hides default/untitled CLI rows with fewer than 2 user turns; keeps explicitly-titled CLI sessions; keeps compression-lineage CLI sessions. Treats true CLI-origin rows as external/imported in action menu (keeps pin/move/archive/restore, hides duplicate/delete). New `_isCliSession(session)` helper in static/sessions.js for source classification.
### Pre-release verification
- Full pytest sequential pass: 4429 → **4457 passing** (+28). 0 regressions.
- JS syntax check on 6 modified `.js` files via `node -c`: all clean.
- Python syntax check on 9 modified `.py` files: all clean.
- QA harness: 20 pytest + 11 browser API + `/health` probe — ALL CHECKS PASSED.
- Browser-driven smoke test on 56-session sidebar:
- Logs tab: panel renders with file/tail selectors; 4 test log lines (INFO/WARNING/ERROR/DEBUG) all rendered with correct severity classes.
- LLM Wiki card: renders in Insights tab with proper "Unavailable" state and 6-grid metadata layout. Existing Insights chart (#1668) renders unaffected.
- `_isCliSession` helper: 6/6 test cases correct (null, empty object, session_source=cli → true, raw_source=CLI → true, source_label=cli → true, raw_source=web → false).
- Sidebar scroll: scrollTop=500 holds steady across 100/300/600ms; scroll-to-bottom (1986) holds across 600ms.
- Path traversal: `/api/logs?file=../../etc/passwd` correctly returns HTTP 400.
- Independent review: Opus advisor on stage-298 diff (1336 LOC). 6/6 verification questions resolved cleanly: SSRF safety, path traversal, schema redaction, JS XSS prevention, scroll-fix first-render edge case, CHANGELOG handling. **Verdict: SHIP.** 0 MUST-FIX, 2 SHOULD-FIX absorbed in-release (see below).
### Opus-applied fixes (absorbed in-release)
**From stage-299 absorption (this release):**
- **Bounded WIKI_PATH walk + forbidden-root guard** (`api/routes.py`): `_LLM_WIKI_MAX_FILES = 10000` caps `rglob` iteration in both `_llm_wiki_count_files` and `_llm_wiki_page_files` (prevents hangs on symlink loops or pathologically-large trees). `_LLM_WIKI_FORBIDDEN_ROOTS` blocklist refuses `/`, `/etc`, `/usr`, `/var`, `/opt`, `/sys`, `/proc` even if `WIKI_PATH` is misconfigured to point at them. Self-DoS prevention: `/api/wiki/status` fires on every Insights tab open via `Promise.all`, and unbounded `rglob` on a misconfigured root would block the endpoint. 6 regression tests pin the constants + behavioral guards.
- **URL-scheme guard for `docs_url` interpolation** (`static/panels.js`): `rawDocsUrl` is regex-validated against `/^https?:\/\//i` before being interpolated into the `<a href=>` attribute. `esc()` HTML-escapes but doesn't validate URL scheme; `docs_url` is server-controlled today but the contributor scaffolded it for potential config-driven use, so future-proofs against `js:` / `data:` scheme XSS.
### Surgical conflict resolution
All 3 PRs branched off pre-Kanban-v1 master, producing multi-region conflicts in `static/panels.js` and `static/style.css`. Resolved per-conflict surgically rather than via naive keep-both:
- **#1664 panels.js**: kept master's modern `_renderInsights` body (preserves the v0.51.1 chart enhancements from #1668), modified its signature to accept `wikiStatus` as 3rd parameter, AND inserted the two new wiki helper functions (`_formatLlmWikiTimestamp`, `_renderLlmWikiStatus`) before it. Verified single `_renderInsights` definition.
- **#1664 style.css**: kept master's `.insights-card { margin-bottom: 16px }` (used by other Insights cards) and ADDED all the new `.wiki-status-*` rules. Discarded contributor's modification of `.insights-card` (would have broken #1668 chart card spacing).
- **#1662 panels.js**: panel-list array union'd to include both `'kanban'` (v0.51.0) and `'logs'` (this PR). Large additive region: kept BOTH the master's Kanban switcher/modal block AND the contributor's Logs panel block. Patched a missing pair of closing braces (`}\n}\n`) at the boundary where the conflict marker truncated `archiveKanbanBoard`.
- **#1662 style.css**: display-none selector union'd to include `#mainInsights, #mainLogs` AND `:not(.showing-kanban):not(.showing-logs)` chain.
- **#1587 sessions.js**: kept master's `_isReadOnlySession` and `_sourceKeyForSession` helpers AND added the new `_isCliSession` helper. Patched a missing closing brace on `_sourceKeyForSession` introduced by conflict-marker truncation.
Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's fork via maintainer write access (preserving `Co-authored-by:` attribution). #1587 stayed local since the maintainer token doesn't have write access to franksong2702's fork.
## [v0.51.1] — 2026-05-04 — 11-PR contributor batch from @Michaelyklam
### Added — 11 PRs from a single overnight burst, all per-PR Phase-0 fit-screened
@@ -182,7 +227,6 @@ This was a large stack of work. Massive thanks to **@ai-ag2026** for the full Ka
### Note on closed-as-superseded
PR #1656 (also @Michaelyklam) was closed as superseded by #1657. Both target #1458 Bug #3, both add accept-loop heartbeat + `/health?deep=1` + 503-on-degraded. #1657 adds beyond #1656: state.db connectivity check, projects state check, FD soft-limit raise, and `docs/supervisor.md` watchdog recipe. Same author iterated; the second PR was the keeper.
## [v0.50.296] — 2026-05-04
### Fixed (3 PRs — closes #1406, #1617; refs #1362)
@@ -448,7 +492,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- **Auto-fix on #1464:** ternary inversion + regression test, with `Co-authored-by: Josh Jameson` preserved.
- **Auto-fix on stage:** widened source-string anchors in two pre-existing brittle tests broken by #1591's structural changes.
## [v0.50.289] — 2026-05-03
### Fixed (1 PR — TCP keepalive on accepted connections — closes #1580)

View File

@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.51.1 (May 04, 2026) — 4429 tests collected — 11-PR Michaelyklam batch
> Last updated: v0.51.2 (May 04, 2026) — 4457 tests collected — 3-PR follow-up + scroll hotfix
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)

View File

@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.51.1, May 04, 2026 — 11-PR Michaelyklam batch*
*Total automated tests collected: 4429*
*Last updated: v0.51.2, May 04, 2026 — 3-PR follow-up + scroll hotfix*
*Total automated tests collected: 4457*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

View File

@@ -14,6 +14,9 @@ MESSAGING_SOURCES = {
'weixin',
}
CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2
SOURCE_LABELS = {
'api_server': 'API',
'cli': 'CLI',
@@ -71,6 +74,115 @@ def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str:
return f"s.{name}" if name in columns else f"{fallback} AS {name}"
def _safe_lower(value) -> str:
return str(value or "").strip().lower()
def _normalize_source_name(value: object) -> str:
source = _safe_lower(value)
if not source:
return ""
if source.endswith(" session"):
source = source[:-len(" session")].strip()
return source
def _looks_like_default_cli_title(row: dict) -> bool:
"""Return True when a CLI row looks like framework-generated metadata."""
title = _safe_lower(row.get("title"))
if not title or title == "untitled":
return True
if title in {"cli", "cli session"}:
return True
source_candidates = {
_normalize_source_name(row.get("source")),
_normalize_source_name(row.get("session_source")),
_normalize_source_name(row.get("source_tag")),
_normalize_source_name(row.get("raw_source")),
_normalize_source_name(row.get("source_label")),
}
source_candidates.discard("")
source_candidates.add("cli")
return any(title == f"{candidate} session" for candidate in source_candidates)
def _as_positive_int(value) -> int:
try:
return max(0, int(float(value)))
except (TypeError, ValueError):
return 0
def _count_user_turns(row: dict) -> int:
user_turns = row.get("actual_user_message_count")
if user_turns is None:
user_turns = row.get("user_message_count")
if user_turns is None:
messages = row.get("messages") or []
if isinstance(messages, list):
return sum(
1
for msg in messages
if _safe_lower(msg.get("role") if isinstance(msg, dict) else msg) == "user"
)
return 0
return _as_positive_int(user_turns)
def _has_cli_lineage(row: dict) -> bool:
segment_count = _as_positive_int(row.get("_compression_segment_count"))
return segment_count > 1 or bool(row.get("_lineage_root_id"))
def is_cli_session_row(row: dict) -> bool:
"""Return True for rows that should be treated as CLI-imported sessions."""
if not isinstance(row, dict):
return False
source = _safe_lower(row.get("session_source"))
if source == "messaging":
return False
if source == "cli":
return True
source_tag = _safe_lower(row.get("source_tag"))
raw_source = _safe_lower(row.get("raw_source"))
source_name = _safe_lower(row.get("source"))
source_label = _safe_lower(row.get("source_label"))
if source_tag == "cli" or raw_source == "cli" or source_name == "cli" or source_label == "cli":
return True
# Legacy imported CLI rows may only be marked as CLI in sidebar metadata.
# Keep this conservative to avoid treating messaging sessions as CLI.
return bool(
row.get("is_cli_session")
and source not in MESSAGING_SOURCES
and source_tag not in MESSAGING_SOURCES
and raw_source not in MESSAGING_SOURCES
and source_name not in MESSAGING_SOURCES
and _looks_like_default_cli_title(row)
)
def is_cli_session_row_visible(row: dict) -> bool:
"""Return whether a CLI-related row should remain visible in the sidebar."""
if not isinstance(row, dict):
return False
if not is_cli_session_row(row):
return True
message_count = _as_positive_int(row.get("actual_message_count") or row.get("message_count"))
if message_count <= 0:
return False
if _has_cli_lineage(row):
return True
if not _looks_like_default_cli_title(row):
return True
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
"""Return True when ``child`` is the next segment of the same conversation.
@@ -201,7 +313,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
# touched standalone sessions — exactly the inverse of what a user
# expects from "Show agent sessions" sorted by activity.
for key in (
'id', 'model', 'message_count', 'actual_message_count',
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
'ended_at', 'end_reason', 'last_activity',
):
if key in tip:
@@ -255,6 +367,8 @@ def read_importable_agent_session_rows(
# source column we cannot safely distinguish WebUI rows from agent rows.
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
cur.execute("PRAGMA table_info(messages)")
message_cols = {row[1] for row in cur.fetchall()}
if 'source' not in session_cols:
log.warning(
"agent session listing skipped: state.db at %s has no 'source' column "
@@ -275,6 +389,11 @@ def read_importable_agent_session_rows(
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
platform_expr = _optional_col('platform', session_cols)
user_message_count_expr = (
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)"
if 'role' in message_cols
else "COUNT(m.id)"
)
where_clauses = ["s.source IS NOT NULL"]
params: list[str] = []
@@ -301,6 +420,7 @@ def read_importable_agent_session_rows(
{ended_expr},
{end_reason_expr},
COUNT(m.id) AS actual_message_count,
{user_message_count_expr} AS actual_user_message_count,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
@@ -312,6 +432,7 @@ def read_importable_agent_session_rows(
)
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
projected = [_with_normalized_source(row) for row in projected]
projected = [row for row in projected if is_cli_session_row_visible(row)]
if limit is None:
return projected
return projected[:max(0, int(limit))]

View File

@@ -21,6 +21,7 @@ from api.workspace import get_last_workspace
from api.agent_sessions import read_importable_agent_session_rows, read_session_lineage_metadata
logger = logging.getLogger(__name__)
CLI_VISIBLE_SESSION_LIMIT = 20
# ---------------------------------------------------------------------------
# Stale temp-file cleanup
@@ -225,6 +226,12 @@ def _last_message_timestamp(messages):
return None
def _message_role(message):
if not isinstance(message, dict):
return ''
return str(message.get('role', '')).strip().lower()
def _find_top_level_json_key(text, key):
"""Return the byte offset of a top-level JSON object key, if present."""
depth = 0
@@ -563,6 +570,9 @@ class Session:
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
'user_message_count': sum(
1 for message in self.messages if _message_role(message) == 'user'
) if isinstance(self.messages, list) else 0,
'active_stream_id': self.active_stream_id,
'pending_user_message': self.pending_user_message,
'has_pending_user_message': has_pending_user_message,
@@ -1507,7 +1517,12 @@ def get_cli_sessions() -> list:
return _cron_pid_cache[0]
try:
for row in read_importable_agent_session_rows(db_path, limit=200, log=logger, exclude_sources=None):
for row in read_importable_agent_session_rows(
db_path,
limit=CLI_VISIBLE_SESSION_LIMIT,
log=logger,
exclude_sources=None,
):
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
# Prefer the CLI session's own profile from the DB; fall back to
@@ -1573,6 +1588,7 @@ def get_cli_sessions() -> list:
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
'end_reason': row.get('end_reason'),
'actual_message_count': row.get('actual_message_count'),
'user_message_count': row.get('actual_user_message_count'),
'_lineage_root_id': row.get('_lineage_root_id'),
'_lineage_tip_id': row.get('_lineage_tip_id'),
'_compression_segment_count': row.get('_compression_segment_count'),

View File

@@ -22,7 +22,11 @@ import re
from pathlib import Path
from contextlib import closing
from urllib.parse import parse_qs
from api.agent_sessions import MESSAGING_SOURCES
from api.agent_sessions import (
MESSAGING_SOURCES,
is_cli_session_row,
is_cli_session_row_visible,
)
logger = logging.getLogger(__name__)
@@ -1185,6 +1189,44 @@ def _session_sort_timestamp(session: dict) -> float:
) or 0.0
def _is_cli_session_for_settings(session: dict) -> bool:
"""Return True for importable CLI sessions that are safe to classify for settings."""
if not isinstance(session, dict):
return False
if is_cli_session_row(session):
return True
# Fallback for legacy local copies that had weak/empty metadata:
# keep this conservative so messaging sessions do not collapse incorrectly.
if not session.get("is_cli_session"):
return False
source = str(session.get("source") or "").strip().lower()
if source in MESSAGING_SOURCES:
return False
title = str(session.get("title") or "").strip().lower()
return title in ("", "untitled", "cli", "cli session") or title.endswith(" session") and (
not source or source == "cli"
)
CLI_VISIBLE_SESSION_CAP = 20
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int = CLI_VISIBLE_SESSION_CAP) -> list[dict]:
"""Keep only the most recent CLI-visible sessions after filtering."""
if cli_cap <= 0:
return sessions
kept = []
cli_seen = 0
for session in sessions:
if _is_cli_session_for_settings(session):
cli_seen += 1
if cli_seen > cli_cap:
continue
kept.append(session)
return kept
def _merge_cli_sidebar_metadata(ui_session: dict, cli_meta: dict) -> dict:
"""Merge source-of-truth CLI metadata into a sidebar session row.
@@ -1629,8 +1671,294 @@ button:hover{background:rgba(124,185,255,.25)}
<script src="static/login.js?v={{WEBUI_VERSION}}"></script>
</body></html>"""
# ── Logs endpoint ─────────────────────────────────────────────────────────────
_LOG_FILE_WHITELIST = {
"agent": "agent.log",
"errors": "errors.log",
"gateway": "gateway.log",
}
_LOG_TAIL_VALUES = {100, 200, 500, 1000}
_LOG_DEFAULT_TAIL = 200
_LOG_MAX_BYTES = 4 * 1024 * 1024
def _normalize_logs_tail(raw_tail) -> int:
try:
tail = int(str(raw_tail or "").strip())
except (TypeError, ValueError):
return _LOG_DEFAULT_TAIL
return tail if tail in _LOG_TAIL_VALUES else _LOG_DEFAULT_TAIL
def _handle_logs(handler, parsed) -> bool:
"""Return a bounded tail window for an active-profile Hermes log file."""
query = parse_qs(parsed.query)
file_key = (query.get("file", ["agent"])[0] or "agent").strip().lower()
filename = _LOG_FILE_WHITELIST.get(file_key)
if not filename:
return bad(handler, "Unknown log file", status=400)
tail = _normalize_logs_tail(query.get("tail", [None])[0])
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser()
except Exception:
hermes_home = Path(os.environ.get("HERMES_HOME") or (Path.home() / ".hermes")).expanduser()
log_dir = hermes_home / "logs"
log_path = log_dir / filename
try:
# Defense in depth: the filename is hardcoded above, but keep the final
# path anchored under the active profile's logs directory.
if log_path.resolve(strict=False).parent != log_dir.resolve(strict=False):
return bad(handler, "Invalid log file", status=400)
if not log_path.exists() or not log_path.is_file():
return j(handler, {
"file": file_key,
"tail": tail,
"lines": [],
"truncated": False,
"total_bytes": 0,
"mtime": None,
"hint": f"Log file for {file_key} not found yet.",
})
st = log_path.stat()
total_bytes = int(st.st_size)
read_bytes = min(total_bytes, _LOG_MAX_BYTES)
with log_path.open("rb") as fh:
if total_bytes > read_bytes:
fh.seek(total_bytes - read_bytes)
raw = fh.read(read_bytes)
text = raw.decode("utf-8", errors="replace")
lines = text.splitlines()[-tail:]
return j(handler, {
"file": file_key,
"tail": tail,
"lines": lines,
"truncated": total_bytes > read_bytes,
"total_bytes": total_bytes,
"mtime": st.st_mtime,
"hint": "",
})
except Exception as exc:
logger.exception("Failed to read whitelisted log file %s", file_key)
return bad(handler, _sanitize_error(exc), status=500)
# ── Insights endpoint ──────────────────────────────────────────────────────────
_LLM_WIKI_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki"
_LLM_WIKI_PAGE_DIRS = ("entities", "concepts", "comparisons", "queries")
def _llm_wiki_active_hermes_home() -> Path:
try:
from api.profiles import get_active_hermes_home
return Path(get_active_hermes_home()).expanduser()
except Exception:
return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
def _llm_wiki_env_file_path(hermes_home: Path) -> str | None:
env_path = hermes_home / ".env"
if not env_path.exists() or not env_path.is_file():
return None
try:
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
if key.strip() != "WIKI_PATH":
continue
value = value.strip().strip('"').strip("'")
return value or None
except Exception:
return None
return None
def _llm_wiki_get_config_path_value(config: dict, dotted_key: str) -> str | None:
if not isinstance(config, dict):
return None
if dotted_key in config and config.get(dotted_key):
return str(config.get(dotted_key))
cur = config
for part in dotted_key.split("."):
if not isinstance(cur, dict) or part not in cur:
return None
cur = cur[part]
return str(cur) if cur else None
def _llm_wiki_config_path() -> str | None:
try:
from api.config import get_config as _get_cfg
cfg = _get_cfg()
except Exception:
return None
return (
_llm_wiki_get_config_path_value(cfg, "skills.config.wiki.path")
or _llm_wiki_get_config_path_value(cfg, "wiki.path")
)
# Cap WIKI walks to prevent self-DoS if WIKI_PATH points at /, /etc, /home, etc.
# Real LLM wikis have under a few thousand files; 10k is generous and catches misconfig.
_LLM_WIKI_MAX_FILES = 10000
# Refuse to walk these system roots even if explicitly configured.
_LLM_WIKI_FORBIDDEN_ROOTS = frozenset(
str(Path(p).expanduser().resolve()) for p in ("/", "/etc", "/usr", "/var", "/opt", "/sys", "/proc")
)
def _llm_wiki_resolve_path() -> tuple[Path, str, bool]:
hermes_home = _llm_wiki_active_hermes_home()
raw = os.getenv("WIKI_PATH") or _llm_wiki_env_file_path(hermes_home)
source = "WIKI_PATH" if raw else "default"
configured = bool(raw)
if not raw:
raw = _llm_wiki_config_path()
if raw:
source = "skills.config.wiki.path"
configured = True
if not raw:
raw = "~/wiki"
return Path(os.path.expandvars(raw)).expanduser(), source, configured
def _llm_wiki_safe_iso(ts: float | None) -> str | None:
if not ts:
return None
try:
from datetime import datetime, timezone
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
except Exception:
return None
def _llm_wiki_count_files(root: Path) -> int:
if not root.exists() or not root.is_dir():
return 0
# Defense in depth: refuse to walk forbidden system roots even if WIKI_PATH
# was set to one. The endpoint is auth-gated but a misconfigured server
# shouldn't self-DoS by rglob'ing all of /etc on every Insights load.
try:
if str(root.resolve()) in _LLM_WIKI_FORBIDDEN_ROOTS:
return 0
except Exception:
return 0
count = 0
iterated = 0
for item in root.rglob("*"):
iterated += 1
if iterated > _LLM_WIKI_MAX_FILES:
break # bounded — prevents hangs on symlink loops or huge trees
try:
if item.is_file() and not any(part.startswith(".") for part in item.relative_to(root).parts):
count += 1
except Exception:
continue
return count
def _llm_wiki_page_files(wiki_path: Path) -> list[Path]:
pages: list[Path] = []
# Defense in depth: refuse forbidden system roots.
try:
if str(wiki_path.resolve()) in _LLM_WIKI_FORBIDDEN_ROOTS:
return pages
except Exception:
return pages
iterated = 0
for dirname in _LLM_WIKI_PAGE_DIRS:
section = wiki_path / dirname
if not section.exists() or not section.is_dir():
continue
for item in section.rglob("*.md"):
iterated += 1
if iterated > _LLM_WIKI_MAX_FILES:
return pages # bounded
try:
rel = item.relative_to(section)
if item.is_file() and not any(part.startswith(".") for part in rel.parts):
pages.append(item)
except Exception:
continue
return pages
def _build_llm_wiki_status() -> dict:
"""Return private-safe LLM Wiki status metadata without reading page bodies."""
try:
wiki_path, path_source, path_configured = _llm_wiki_resolve_path()
base = {
"available": False,
"enabled": False,
"status": "missing",
"entry_count": 0,
"page_count": 0,
"raw_source_count": 0,
"last_updated": None,
"last_writer": None,
"path_configured": path_configured,
"path_source": path_source,
"toggle_available": False,
"toggle_reason": "Hermes Agent exposes WIKI_PATH/wiki.path for location, but no stable on/off config flag is currently available.",
"docs_url": _LLM_WIKI_DOCS_URL,
}
if not wiki_path.exists():
return base
if not wiki_path.is_dir():
base["status"] = "not_directory"
return base
page_files = _llm_wiki_page_files(wiki_path)
status_files = [p for p in (wiki_path / "SCHEMA.md", wiki_path / "index.md", wiki_path / "log.md") if p.exists() and p.is_file()]
status_files.extend(page_files)
latest = None
for item in status_files:
try:
mtime = item.stat().st_mtime
except Exception:
continue
latest = mtime if latest is None else max(latest, mtime)
base.update({
"available": True,
"enabled": True,
"status": "ready" if page_files else "empty",
"entry_count": len(page_files),
"page_count": len(page_files),
"raw_source_count": _llm_wiki_count_files(wiki_path / "raw"),
"last_updated": _llm_wiki_safe_iso(latest),
})
return base
except Exception as exc:
return {
"available": False,
"enabled": False,
"status": "error",
"entry_count": 0,
"page_count": 0,
"raw_source_count": 0,
"last_updated": None,
"last_writer": None,
"path_configured": False,
"path_source": "unknown",
"toggle_available": False,
"toggle_reason": "Unable to inspect LLM Wiki status safely.",
"docs_url": _LLM_WIKI_DOCS_URL,
"error": type(exc).__name__,
}
def _handle_llm_wiki_status(handler, parsed) -> bool:
j(handler, _build_llm_wiki_status())
return True
def _handle_insights(handler, parsed) -> bool:
"""Return usage analytics from local WebUI session data."""
import collections
@@ -2143,7 +2471,7 @@ def handle_get(handler, parsed) -> bool:
handler.end_headers()
return True
# ── Insights ──
# ── Insights / knowledge status ──
if parsed.path == "/api/insights":
return _handle_insights(handler, parsed)
@@ -2151,6 +2479,10 @@ def handle_get(handler, parsed) -> bool:
from api.kanban_bridge import handle_kanban_get
return handle_kanban_get(handler, parsed)
if parsed.path == "/api/wiki/status":
return _handle_llm_wiki_status(handler, parsed)
if parsed.path == "/api/logs":
return _handle_logs(handler, parsed)
if parsed.path == "/health":
return _handle_health(handler, parsed)
@@ -2431,7 +2763,8 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/sessions":
webui_sessions = all_sessions()
settings = load_settings()
if settings.get("show_cli_sessions"):
show_cli_sessions = bool(settings.get("show_cli_sessions"))
if show_cli_sessions:
cli = get_cli_sessions()
cli_by_id = {s["session_id"]: s for s in cli}
for s in webui_sessions:
@@ -2446,12 +2779,14 @@ def handle_get(handler, parsed) -> bool:
for key in ("source_tag", "raw_source", "session_source", "source_label"):
if not s.get(key) and meta.get(key):
s[key] = meta[key]
# Apply the same CLI visibility semantics to imported local copies so
# low-value imported artifacts do not leak into the sidebar.
webui_sessions = [s for s in webui_sessions if is_cli_session_row_visible(s)]
webui_ids = {s["session_id"] for s in webui_sessions}
from api.models import _hide_from_default_sidebar as _cron_hide
deduped_cli = [s for s in cli
if s["session_id"] not in webui_ids
and not _cron_hide(s)]
deduped_cli = [s for s in cli if s["session_id"] not in webui_ids and is_cli_session_row_visible(s) and not _cron_hide(s)]
else:
webui_sessions = [s for s in webui_sessions if not _is_cli_session_for_settings(s)]
deduped_cli = []
merged = webui_sessions + deduped_cli
merged.sort(
@@ -2483,6 +2818,8 @@ def handle_get(handler, parsed) -> bool:
if _profiles_match(s.get("profile"), active_profile)]
other_profile_count = len(merged) - len(scoped)
scoped = _keep_latest_messaging_session_per_source(scoped)
if show_cli_sessions:
scoped = _cap_recent_cli_sessions(scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
safe_merged = []
for s in scoped:
item = dict(s)

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -532,6 +532,7 @@ const LOCALES = {
tab_insights: 'Insights',
tab_dashboard: 'Hermes Dashboard',
dashboard_loopback_warning: 'Dashboard is loopback-only on the server. Either browse from the server itself or restart it with --host 0.0.0.0 (insecure).',
tab_logs: 'Logs',
tab_settings: 'Settings',
new_conversation: 'New conversation',
filter_conversations: 'Filter conversations...',
@@ -552,6 +553,21 @@ const LOCALES = {
new_skill: 'New skill',
personal_memory: 'Personal memory',
current_task_list: 'Current task list',
// Logs
logs_title: 'Logs',
logs_file: 'File',
logs_tail: 'Tail',
logs_auto_refresh: 'Auto-refresh (5s)',
logs_wrap: 'Wrap lines',
logs_copy_all: 'Copy all',
logs_empty: 'No log lines yet.',
logs_loading: 'Loading logs…',
logs_load_failed: 'Logs failed to load',
logs_status_idle: 'Choose a log file to view recent lines.',
logs_no_mtime: 'not written yet',
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.',
logs_copied: 'Logs copied',
// Insights
insights_title: 'Usage Analytics',
insights_sessions: 'Sessions',
@@ -1525,6 +1541,7 @@ const LOCALES = {
tab_insights: 'インサイト',
tab_dashboard: 'Hermes ダッシュボード',
dashboard_loopback_warning: 'ダッシュボードはサーバー上のループバック専用です。サーバー上で閲覧するか、--host 0.0.0.0(安全ではありません)で再起動してください。',
tab_logs: 'Logs',
tab_settings: '設定',
new_conversation: '新しい会話',
filter_conversations: '会話を絞り込み...',
@@ -1545,6 +1562,21 @@ const LOCALES = {
new_skill: '新規スキル',
personal_memory: '個人メモリ',
current_task_list: '現在のタスクリスト',
// Logs
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
logs_tail: 'Tail', // TODO: translate
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
logs_wrap: 'Wrap lines', // TODO: translate
logs_copy_all: 'Copy all', // TODO: translate
logs_empty: 'No log lines yet.', // TODO: translate
logs_loading: 'Loading logs…', // TODO: translate
logs_load_failed: 'Logs failed to load', // TODO: translate
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
// Insights
insights_title: '使用状況分析',
insights_sessions: 'セッション',
@@ -2357,7 +2389,22 @@ const LOCALES = {
tab_insights: 'Аналитика',
tab_dashboard: 'Панель Hermes',
dashboard_loopback_warning: 'Панель доступна только через loopback на сервере. Откройте её с самого сервера или перезапустите с --host 0.0.0.0 (небезопасно).',
tab_logs: 'Logs',
tab_settings: 'Настройки',
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
logs_tail: 'Tail', // TODO: translate
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
logs_wrap: 'Wrap lines', // TODO: translate
logs_copy_all: 'Copy all', // TODO: translate
logs_empty: 'No log lines yet.', // TODO: translate
logs_loading: 'Loading logs…', // TODO: translate
logs_load_failed: 'Logs failed to load', // TODO: translate
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
new_conversation: 'Новая беседа',
filter_conversations: 'Фильтр бесед...',
session_time_unknown: 'Неизвестно',
@@ -3285,7 +3332,22 @@ const LOCALES = {
tab_insights: 'Analíticas',
tab_dashboard: 'Panel de Hermes',
dashboard_loopback_warning: 'El panel solo usa loopback en el servidor. Navega desde el propio servidor o reinícialo con --host 0.0.0.0 (inseguro).',
tab_logs: 'Logs',
tab_settings: 'Ajustes',
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
logs_tail: 'Tail', // TODO: translate
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
logs_wrap: 'Wrap lines', // TODO: translate
logs_copy_all: 'Copy all', // TODO: translate
logs_empty: 'No log lines yet.', // TODO: translate
logs_loading: 'Loading logs…', // TODO: translate
logs_load_failed: 'Logs failed to load', // TODO: translate
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
new_conversation: 'Nueva conversación',
filter_conversations: 'Filtrar conversaciones...',
session_time_unknown: 'Desconocido',
@@ -4201,7 +4263,22 @@ const LOCALES = {
tab_insights: 'Statistiken',
tab_dashboard: 'Hermes-Dashboard',
dashboard_loopback_warning: 'Das Dashboard ist auf dem Server nur per Loopback erreichbar. Öffne es direkt auf dem Server oder starte es mit --host 0.0.0.0 neu (unsicher).',
tab_logs: 'Logs',
tab_settings: 'Einstellungen',
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
logs_tail: 'Tail', // TODO: translate
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
logs_wrap: 'Wrap lines', // TODO: translate
logs_copy_all: 'Copy all', // TODO: translate
logs_empty: 'No log lines yet.', // TODO: translate
logs_loading: 'Loading logs…', // TODO: translate
logs_load_failed: 'Logs failed to load', // TODO: translate
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
new_conversation: 'Neuer Chat',
filter_conversations: 'Chats filtern...',
scheduled_jobs: 'Geplante Aufgaben',
@@ -5140,7 +5217,22 @@ const LOCALES = {
dashboard_loopback_warning: '仪表盘在服务器上仅限 loopback 访问。请从服务器本机浏览,或使用 --host 0.0.0.0 重启(不安全)。',
tab_workspaces: '工作区',
tab_profiles: '配置',
tab_logs: '日志',
tab_settings: '设置',
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
logs_tail: 'Tail', // TODO: translate
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
logs_wrap: 'Wrap lines', // TODO: translate
logs_copy_all: 'Copy all', // TODO: translate
logs_empty: 'No log lines yet.', // TODO: translate
logs_loading: 'Loading logs…', // TODO: translate
logs_load_failed: 'Logs failed to load', // TODO: translate
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
new_conversation: '新建对话',
filter_conversations: '筛选对话…',
session_time_unknown: '未知',
@@ -6025,6 +6117,7 @@ const LOCALES = {
tab_insights: '統計',
tab_dashboard: 'Hermes 儀表板',
dashboard_loopback_warning: '儀表板在伺服器上僅限 loopback 存取。請從伺服器本機瀏覽,或使用 --host 0.0.0.0 重新啟動(不安全)。',
tab_logs: 'Logs',
tab_workspaces: '\u5de5\u4f5c\u5340',
new_conversation: '新對話',
filter_conversations: '篩選對話',
@@ -7094,7 +7187,22 @@ const LOCALES = {
tab_insights: 'Estatísticas',
tab_dashboard: 'Painel Hermes',
dashboard_loopback_warning: 'O painel é somente loopback no servidor. Navegue pelo próprio servidor ou reinicie com --host 0.0.0.0 (inseguro).',
tab_logs: 'Logs',
tab_settings: 'Configurações',
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
logs_tail: 'Tail', // TODO: translate
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
logs_wrap: 'Wrap lines', // TODO: translate
logs_copy_all: 'Copy all', // TODO: translate
logs_empty: 'No log lines yet.', // TODO: translate
logs_loading: 'Loading logs…', // TODO: translate
logs_load_failed: 'Logs failed to load', // TODO: translate
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
new_conversation: 'Nova conversa',
filter_conversations: 'Filtrar conversas...',
session_time_unknown: 'Desconhecido',
@@ -7986,7 +8094,22 @@ const LOCALES = {
tab_insights: '통계',
tab_dashboard: 'Hermes 대시보드',
dashboard_loopback_warning: '대시보드는 서버에서 loopback 전용입니다. 서버 자체에서 접속하거나 --host 0.0.0.0(안전하지 않음)으로 다시 시작하세요.',
tab_logs: 'Logs',
tab_settings: '설정',
logs_title: 'Logs', // TODO: translate
logs_file: 'File', // TODO: translate
logs_tail: 'Tail', // TODO: translate
logs_auto_refresh: 'Auto-refresh (5s)', // TODO: translate
logs_wrap: 'Wrap lines', // TODO: translate
logs_copy_all: 'Copy all', // TODO: translate
logs_empty: 'No log lines yet.', // TODO: translate
logs_loading: 'Loading logs…', // TODO: translate
logs_load_failed: 'Logs failed to load', // TODO: translate
logs_status_idle: 'Choose a log file to view recent lines.', // TODO: translate
logs_no_mtime: 'not written yet', // TODO: translate
logs_truncated_hint: 'Showing the tail of a large log file; older bytes were skipped to keep memory bounded.', // TODO: translate
logs_copied: 'Logs copied', // TODO: translate
new_conversation: '새 대화',
filter_conversations: '대화 필터…',
session_time_unknown: 'Unknown',

View File

@@ -91,6 +91,7 @@
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="rail-btn nav-tab" data-panel="insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<button class="rail-btn nav-tab dashboard-link" id="dashboardRailBtn" data-dashboard-link style="display:none" onclick="openHermesDashboard(event)" title="Hermes Dashboard" data-i18n-title="tab_dashboard" aria-label="Hermes Dashboard"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
<button class="rail-btn nav-tab" data-panel="logs" onclick="switchPanel('logs')" title="Logs" data-i18n-title="tab_logs" aria-label="Logs"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<div class="rail-spacer"></div>
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</nav>
@@ -107,6 +108,7 @@
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="nav-tab" data-panel="insights" data-label="Insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<button class="nav-tab dashboard-link" id="dashboardMobileBtn" data-dashboard-link data-label="Dashboard" style="display:none" onclick="openHermesDashboard(event)" title="Hermes Dashboard" data-i18n-title="tab_dashboard"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
<button class="nav-tab" data-panel="logs" data-label="Logs" onclick="switchPanel('logs')" title="Logs" data-i18n-title="tab_logs"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
@@ -226,6 +228,33 @@
</div>
<div style="flex:1;overflow-y:auto;padding:8px" id="profilesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Logs panel -->
<div class="panel-view" id="panelLogs">
<div class="panel-head">
<span data-i18n="tab_logs">Logs</span>
<div class="panel-head-actions">
<button class="panel-head-btn" id="logsRefreshBtn" onclick="loadLogs(true)" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
</div>
</div>
<div class="logs-control-panel">
<label class="logs-control-label" for="logsFile" data-i18n="logs_file">File</label>
<select id="logsFile" onchange="loadLogs(true)">
<option value="agent">agent</option>
<option value="errors">errors</option>
<option value="gateway">gateway</option>
</select>
<label class="logs-control-label" for="logsTail" data-i18n="logs_tail">Tail</label>
<select id="logsTail" onchange="loadLogs(true)">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="500">500</option>
<option value="1000">1000</option>
</select>
<label class="logs-check-row"><input id="logsAutoRefresh" type="checkbox" checked onchange="_syncLogsAutoRefresh()"><span data-i18n="logs_auto_refresh">Auto-refresh (5s)</span></label>
<label class="logs-check-row"><input id="logsWrap" type="checkbox" onchange="_syncLogsWrap()"><span data-i18n="logs_wrap">Wrap lines</span></label>
<button type="button" class="logs-copy" id="logsCopyAll" onclick="copyLogsAll()" data-i18n="logs_copy_all">Copy all</button>
</div>
</div>
<!-- Settings panel (menu list; actual panes render in .main) -->
<div class="panel-view" id="panelSettings">
<div class="panel-head">
@@ -716,7 +745,25 @@
<div class="main-view-title" data-i18n="insights_title">Usage Analytics</div>
</div>
<div class="main-view-content" id="insightsContent" style="padding:16px;overflow-y:auto">
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
<div class="insights-card wiki-status-card" id="llmWikiStatusCard">
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
</div>
</div>
</div>
<div id="mainLogs" class="main-view">
<div class="main-view-header">
<div>
<div class="main-view-title" data-i18n="logs_title">Logs</div>
<div class="logs-status" id="logsStatus" data-i18n="logs_status_idle">Choose a log file to view recent lines.</div>
</div>
<div class="main-view-actions">
<button type="button" class="logs-copy compact" onclick="copyLogsAll()" data-i18n="logs_copy_all">Copy all</button>
</div>
</div>
<div class="main-view-body logs-main-body">
<div class="main-view-content logs-content">
<div class="logs-output" id="logsOutput"><div class="logs-empty" data-i18n="logs_empty">No log lines yet.</div></div>
</div>
</div>
</div>
<div id="mainSettings" class="main-view">

View File

@@ -29,12 +29,14 @@ let _currentProfileDetail = null; // full profile object
let _profileMode = 'empty'; // 'empty' | 'read' | 'create'
let _profilePreFormDetail = null;
let _pendingSettingsTargetPanel = null; // destination selected while settings had unsaved changes
let _logsAutoRefreshTimer = null;
let _lastLogsLines = [];
// Map of panel names → i18n keys for the app titlebar label.
const APP_TITLEBAR_KEYS = {
chat: 'tab_chat', tasks: 'tab_tasks', skills: 'tab_skills',
memory: 'tab_memory', workspaces: 'tab_workspaces',
profiles: 'tab_profiles', todos: 'tab_todos', settings: 'tab_settings',
profiles: 'tab_profiles', todos: 'tab_todos', insights: 'tab_insights', logs: 'tab_logs', settings: 'tab_settings',
};
/**
@@ -198,7 +200,7 @@ async function switchPanel(name, opts = {}) {
// showing-<name> class on <main>; no class means chat (the default).
const mainEl = document.querySelector('main.main');
if (mainEl) {
['settings','skills','memory','tasks','kanban','workspaces','profiles','insights'].forEach(p => {
['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs'].forEach(p => {
mainEl.classList.toggle('showing-' + p, nextPanel === p);
});
}
@@ -211,6 +213,8 @@ async function switchPanel(name, opts = {}) {
if (nextPanel === 'profiles') await loadProfilesPanel();
if (nextPanel === 'todos') loadTodos();
if (nextPanel === 'insights') await loadInsights();
if (nextPanel === 'logs') await loadLogs();
_syncLogsAutoRefresh();
if (nextPanel === 'settings') {
switchSettingsSection(_currentSettingsSection);
loadSettingsPanel();
@@ -1984,6 +1988,120 @@ async function archiveKanbanBoard(){
}
}
// ── Logs panel ──
function _selectedLogsFile() {
const el = $('logsFile');
const value = (el && el.value) || 'agent';
return ['agent','errors','gateway'].includes(value) ? value : 'agent';
}
function _selectedLogsTail() {
const el = $('logsTail');
const value = Number((el && el.value) || 200);
return [100,200,500,1000].includes(value) ? value : 200;
}
function _logLineSeverityClass(line) {
const text = String(line || '').toUpperCase();
if (/\b(WARNING|WARN)\b/.test(text)) return 'log-line-warning';
if (/\b(DEBUG)\b/.test(text)) return 'log-line-debug';
if (/\b(INFO)\b/.test(text)) return 'log-line-info';
if (/\b(ERROR|CRITICAL|TRACEBACK)\b/.test(text)) return 'log-line-error';
return '';
}
function _syncLogsWrap() {
const out = $('logsOutput');
const wrap = $('logsWrap');
if (out && wrap) out.classList.toggle('wrap', !!wrap.checked);
}
async function loadLogs(animate) {
const box = $('logsOutput');
const status = $('logsStatus');
const refreshBtn = $('logsRefreshBtn');
if (!box) return;
if (animate && refreshBtn) {
refreshBtn.style.opacity = '0.5';
refreshBtn.disabled = true;
}
const file = _selectedLogsFile();
const tail = _selectedLogsTail();
try {
if (status) status.textContent = t('logs_loading');
const data = await api('/api/logs?file=' + encodeURIComponent(file) + '&tail=' + encodeURIComponent(tail));
_renderLogs(data);
} catch(e) {
_lastLogsLines = [];
box.innerHTML = `<div class="logs-empty">${esc(t('error_prefix') + e.message)}</div>`;
if (status) status.textContent = t('logs_load_failed');
} finally {
if (animate && refreshBtn) {
refreshBtn.style.opacity = '';
refreshBtn.disabled = false;
}
_syncLogsAutoRefresh();
}
}
function _renderLogs(data) {
const box = $('logsOutput');
const status = $('logsStatus');
if (!box) return;
const lines = Array.isArray(data && data.lines) ? data.lines : [];
_lastLogsLines = lines.slice();
const hint = data && data.hint ? `<div class="logs-hint">${esc(data.hint)}</div>` : '';
const truncated = data && data.truncated ? `<div class="logs-hint warn">${esc(t('logs_truncated_hint'))}</div>` : '';
if (!lines.length) {
box.innerHTML = `${hint}${truncated}<div class="logs-empty">${esc(t('logs_empty'))}</div>`;
} else {
box.innerHTML = `${hint}${truncated}` + lines.map(line => {
const cls = _logLineSeverityClass(line);
return `<div class="log-line ${cls}">${esc(line)}</div>`;
}).join('');
}
_syncLogsWrap();
if (status) {
const bytes = data && Number(data.total_bytes || 0);
const when = data && data.mtime ? new Date(data.mtime * 1000).toLocaleString() : t('logs_no_mtime');
status.textContent = `${lines.length} / ${data.tail || _selectedLogsTail()} lines · ${bytes.toLocaleString()} bytes · ${when}`;
}
}
function _startLogsAutoRefresh() {
if (_logsAutoRefreshTimer) return;
_logsAutoRefreshTimer = setInterval(() => {
if (_currentPanel !== 'logs') { _stopLogsAutoRefresh(); return; }
const toggle = $('logsAutoRefresh');
if (toggle && !toggle.checked) return;
loadLogs(false);
}, 5000);
}
function _stopLogsAutoRefresh() {
if (_logsAutoRefreshTimer) {
clearInterval(_logsAutoRefreshTimer);
_logsAutoRefreshTimer = null;
}
}
function _syncLogsAutoRefresh() {
const toggle = $('logsAutoRefresh');
if (_currentPanel === 'logs' && (!toggle || toggle.checked)) _startLogsAutoRefresh();
else _stopLogsAutoRefresh();
}
async function copyLogsAll() {
const text = _lastLogsLines.join('\n');
try {
await navigator.clipboard.writeText(text);
showToast(t('logs_copied'));
} catch(e) {
showToast(t('copy_failed'), 'error');
}
}
// ── Insights panel ──
async function loadInsights(animate) {
const box = $('insightsContent');
@@ -1995,8 +2113,11 @@ async function loadInsights(animate) {
}
const period = ($('insightsPeriod') || {}).value || '30';
try {
const data = await api(`/api/insights?days=${period}`);
_renderInsights(data, box);
const [data, wikiStatus] = await Promise.all([
api(`/api/insights?days=${period}`),
api('/api/wiki/status').catch(err => ({status:'error', error: err.message || String(err)})),
]);
_renderInsights(data, box, wikiStatus);
} catch(e) {
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
} finally {
@@ -2007,7 +2128,59 @@ async function loadInsights(animate) {
}
}
function _renderInsights(d, box) {
function _formatLlmWikiTimestamp(value) {
if (!value) return 'Never';
try { return new Date(value).toLocaleString(); }
catch (_) { return String(value); }
}
function _renderLlmWikiStatus(d) {
const status = d || {status:'error'};
const isReady = status.available && status.status === 'ready';
const isEmpty = status.available && status.status === 'empty';
const isError = status.status === 'error';
const badgeClass = isReady ? 'ok' : isError ? 'err' : isEmpty ? 'warn' : 'muted';
const badgeText = isReady ? 'Available' : isError ? 'Error' : isEmpty ? 'Empty' : 'Unavailable';
const rawDocsUrl = status.docs_url || 'https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki';
// Guard against unsafe URL schemes (e.g. js: / data:) if docs_url ever
// becomes config-driven. esc() HTML-escapes but doesn't validate URL scheme.
const docsUrl = /^https?:\/\//i.test(rawDocsUrl) ? rawDocsUrl : '#';
const toggleNote = status.toggle_available
? 'Toggle available from configured Hermes Agent setting.'
: (status.toggle_reason || 'No stable LLM Wiki on/off config flag was detected, so this panel is read-only.');
const statusNote = isReady
? 'LLM Wiki is configured and page metadata is visible without exposing wiki content.'
: isEmpty
? 'LLM Wiki exists but has no entity, concept, comparison, or query pages yet.'
: isError
? `Unable to inspect LLM Wiki status${status.error ? ': ' + status.error : ''}.`
: 'No LLM Wiki directory was found. Set WIKI_PATH or skills.config.wiki.path to enable status visibility.';
return `
<div class="insights-card wiki-status-card" id="llmWikiStatusCard">
<div class="wiki-status-head">
<div>
<div class="insights-card-title">LLM Wiki</div>
<div class="wiki-status-sub">Knowledge-base observability</div>
</div>
<span class="wiki-status-badge ${badgeClass}">${esc(badgeText)}</span>
</div>
<div class="wiki-status-note">${esc(statusNote)}</div>
<div class="wiki-status-grid">
<div><span>Enabled</span><strong>${status.enabled ? 'Yes' : 'No'}</strong></div>
<div><span>Entries</span><strong>${Number(status.entry_count || 0).toLocaleString()}</strong></div>
<div><span>Pages</span><strong>${Number(status.page_count || 0).toLocaleString()}</strong></div>
<div><span>raw/ files</span><strong>${Number(status.raw_source_count || 0).toLocaleString()}</strong></div>
<div><span>Last updated</span><strong>${esc(_formatLlmWikiTimestamp(status.last_updated))}</strong></div>
<div><span>Last writer</span><strong>${esc(status.last_writer || 'Not available')}</strong></div>
</div>
<div class="wiki-status-footer">
<span>${esc(toggleNote)}</span>
<a href="${esc(docsUrl)}" target="_blank" rel="noopener noreferrer">Docs</a>
</div>
</div>`;
}
function _renderInsights(d, box, wikiStatus) {
const fmtNum = n => Number(n || 0).toLocaleString();
const fmtCost = c => {
const value = Number(c || 0);
@@ -2106,6 +2279,7 @@ function _renderInsights(d, box) {
</div>`;
box.innerHTML = `
${_renderLlmWikiStatus(wikiStatus)}
<div class="insights-grid">
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
</div>

View File

@@ -584,6 +584,24 @@ function _sourceKeyForSession(session) {
return (session && (session.raw_source || session.source_tag || session.source || '') || '').toLowerCase();
}
function _isCliSession(session) {
if (!session) return false;
// session_source is set by upstream normalization for CLI sessions as 'cli'
if (session.session_source === 'cli') return true;
// Legacy payloads often use raw/source tags to convey the source.
const raw = (
session.raw_source
|| session.source_tag
|| session.source
|| session.source_label
|| ''
).toLowerCase();
if (raw === 'cli') return true;
// If messaging-like, don't classify as legacy CLI even when is_cli_session is true.
if (_isMessagingSession(session)) return false;
return session.is_cli_session === true;
}
function _normalizeMessageForCliImportComparison(message) {
if (!message || typeof message !== 'object') return message;
const clone = { ...message };
@@ -1281,6 +1299,8 @@ function _openSessionActionMenu(session, anchorEl){
}
closeSessionActionMenu();
const isMessagingSession = _isMessagingSession(session);
const isCliSession = _isCliSession(session);
const isExternalSession = isMessagingSession || isCliSession;
const menu=document.createElement('div');
menu.className='session-action-menu open';
menu.appendChild(_buildSessionAction(
@@ -1323,7 +1343,7 @@ function _openSessionActionMenu(session, anchorEl){
}catch(err){showToast(t('session_archive_failed')+err.message);}
}
));
if(!isMessagingSession){
if(!isExternalSession){
_appendSessionDuplicateAction(menu, session);
}
if(session.active_stream_id){
@@ -1338,7 +1358,7 @@ function _openSessionActionMenu(session, anchorEl){
}
));
}
if(!isMessagingSession){
if(!isExternalSession){
menu.appendChild(_buildSessionAction(
t('session_delete'),
t('session_delete_desc'),
@@ -1916,6 +1936,16 @@ function _sessionVirtualSpacer(height, where){
function _scheduleSessionVirtualizedRender(){
if(_renamingSid||_sessionVirtualScrollRaf) return;
// Skip the re-render if the list is below the virtualization threshold —
// there's no virtual window to recompute, and re-rendering would just
// rebuild the whole DOM on every scroll tick. Without this guard, the
// unconditional scroll listener (attached for any list) caused
// user-facing scroll jumps on small lists. (#1669 follow-up)
const list=_sessionVirtualScrollList;
if(list){
const total=Number(list.dataset.sessionVirtualTotal||0);
if(total>0&&total<=SESSION_VIRTUAL_THRESHOLD_ROWS) return;
}
_sessionVirtualScrollRaf=requestAnimationFrame(()=>{_sessionVirtualScrollRaf=0;renderSessionListFromCache();});
}
@@ -2182,7 +2212,13 @@ function renderSessionListFromCache(){
}
if(virtualAnchorScrollTop!==null){
list.scrollTop=virtualAnchorScrollTop;
}else if(virtualWindow.virtualized){
}else if(listScrollTopBeforeRender>0){
// Always restore the user's scroll position after re-render, regardless
// of whether the virtualization window applies. Lists below the
// virtualization threshold (≤80 rows) still have their DOM rebuilt by
// every renderSessionListFromCache() call, and without this restore the
// scrollTop drops to 0 — producing a "scroll keeps jumping back" feel
// when the list scrolls naturally. Fixed for #1669 follow-up.
list.scrollTop=listScrollTopBeforeRender;
}
// Select mode toggle button (only when NOT in select mode)

View File

@@ -2156,8 +2156,9 @@ main.main > #mainTasks,
main.main > #mainKanban,
main.main > #mainWorkspaces,
main.main > #mainProfiles,
main.main > #mainInsights{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
main.main > #mainInsights,
main.main > #mainLogs{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs) > #mainChat{display:flex;}
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
main.main.showing-skills > #mainSkills{display:flex;}
main.main.showing-memory > #mainMemory{display:flex;}
@@ -2165,6 +2166,7 @@ main.main.showing-tasks > #mainTasks{display:flex;}
main.main.showing-kanban > #mainKanban{display:flex;}
main.main.showing-workspaces > #mainWorkspaces{display:flex;}
main.main.showing-profiles > #mainProfiles{display:flex;}
main.main.showing-logs > #mainLogs{display:flex;}
#mainSettings{overflow-y:auto;}
/* Sidebar menu (lives in the left sidebar under the cog panel) */
@@ -3142,6 +3144,21 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.insights-stat-label{font-size:11px;color:var(--muted);margin-top:4px;}
.insights-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}
.insights-card{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:16px;}
.wiki-status-card{margin-bottom:16px;}
.wiki-status-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;}
.wiki-status-sub{font-size:11px;color:var(--muted);margin-top:-4px;}
.wiki-status-badge{display:inline-flex;align-items:center;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface);}
.wiki-status-badge.ok{color:var(--accent-text);background:var(--accent-bg);border-color:var(--accent-bg-strong);}
.wiki-status-badge.warn{color:#e8a030;background:rgba(232,160,48,.12);border-color:rgba(232,160,48,.28);}
.wiki-status-badge.err{color:var(--error,#e05);background:color-mix(in srgb,var(--error,#e05) 10%,transparent);border-color:color-mix(in srgb,var(--error,#e05) 30%,transparent);}
.wiki-status-note{font-size:12px;color:var(--muted);line-height:1.55;margin-bottom:12px;}
.wiki-status-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-bottom:12px;}
.wiki-status-grid div{display:flex;flex-direction:column;gap:3px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-width:0;}
.wiki-status-grid span{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;}
.wiki-status-grid strong{font-size:13px;color:var(--text);font-weight:650;overflow-wrap:anywhere;}
.wiki-status-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:10px;}
.wiki-status-footer a{color:var(--accent);text-decoration:none;font-weight:600;white-space:nowrap;}
.wiki-status-footer a:hover{text-decoration:underline;}
.insights-card-title{font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;}
.insights-table{width:100%;font-size:12px;}
.insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;}
@@ -3450,3 +3467,27 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-modal{padding:16px 16px 14px;border-radius:14px;}
.kanban-modal-row-inline{flex-direction:column;gap:0;}
}
/* ── Logs panel (#1455) ───────────────────────────────────────────────────── */
main.main.showing-logs > #mainLogs{display:flex;}
.logs-control-panel{display:flex;flex-direction:column;gap:8px;padding:12px;overflow-y:auto;}
.logs-control-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);}
.logs-control-panel select{width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:7px 9px;font-size:12px;}
.logs-check-row{display:flex;align-items:center;gap:8px;color:var(--text);font-size:12px;line-height:1.4;}
.logs-check-row input{accent-color:var(--accent);}
.logs-copy{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);border-radius:8px;padding:7px 10px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s;}
.logs-copy:hover{background:var(--hover-bg);border-color:var(--border2);}
.logs-copy.compact{padding:6px 10px;white-space:nowrap;}
.logs-status{font-size:12px;color:var(--muted);margin-top:3px;font-family:'SF Mono',ui-monospace,monospace;}
.logs-main-body{padding:18px 24px;}
.logs-content{max-width:1200px;}
.logs-output{min-height:320px;max-height:calc(100vh - 170px);overflow:auto;background:var(--code-bg);border:1px solid var(--border);border-radius:12px;padding:12px 0;font-family:'SF Mono','Fira Code',ui-monospace,monospace;font-size:12px;line-height:1.55;color:var(--pre-text);white-space:pre;}
.logs-output.wrap{white-space:pre-wrap;overflow-wrap:anywhere;}
.log-line{padding:0 14px;min-height:1.55em;border-left:3px solid transparent;}
.log-line:hover{background:rgba(255,255,255,.04);}
.log-line-error{color:var(--error,#ef4444);border-left-color:var(--error,#ef4444);background:color-mix(in srgb,var(--error,#ef4444) 8%,transparent);}
.log-line-warning{color:#f59e0b;border-left-color:#f59e0b;background:rgba(245,158,11,.08);}
.log-line-info{color:var(--pre-text);}
.log-line-debug{color:var(--muted);opacity:.75;}
.logs-empty,.logs-hint{margin:8px 14px;padding:12px;border:1px solid var(--border);border-radius:8px;color:var(--muted);background:var(--surface);white-space:normal;font-family:var(--font-ui,system-ui,sans-serif);font-size:12px;}
.logs-hint.warn{color:#f59e0b;border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.08);}

View File

@@ -52,3 +52,27 @@ class TestSidebarCancelAction:
)
assert "hideClarifyCard(true" in body
assert "hideApprovalCard(true" in body
def test_cli_session_helper_identifies_cli_origin(self):
"""CLI sessions should be treated as external-only for destructive action gating."""
body = _function_body(SESSIONS_JS, "_isCliSession", 900)
assert "function _isCliSession(session) {" in body
assert "session.session_source === 'cli'" in body
assert "session.raw_source" in body
assert "session.source_tag" in body
assert "session.source" in body
assert "session.source_label" in body
assert "if (_isMessagingSession(session)) return false;" in body
assert "return session.is_cli_session === true;" in body
def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self):
"""Session action menu should hide duplicate/delete for CLI-origin sessions."""
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 3600)
assert "const isCliSession = _isCliSession(session);" in body
assert "const isExternalSession = isMessagingSession || isCliSession;" in body
assert "if(!isExternalSession)" in body
# duplicate/delete should both be gated by the same external-session check
first = body.find("_appendSessionDuplicateAction")
second = body.find("t('session_delete')")
assert first > 0 and second > 0, "menu actions should still include duplicate/delete nodes"
assert first < second, "duplicate action should render before delete action"

View File

@@ -142,6 +142,16 @@ def test_non_cron_sessions_unaffected(fake_hermes_home):
_make_state_db(fake_hermes_home / "state.db", [
("cron_cd65df6fc1a8_xx", None, "cli"),
])
# PR #1587 hides one-off default-titled CLI rows. Keep this fixture visible
# so the test remains focused on the cron-name guard rather than sidebar
# filtering.
conn = sqlite3.connect(str(fake_hermes_home / "state.db"))
conn.execute(
"INSERT INTO messages (session_id, timestamp) VALUES (?, ?)",
("cron_cd65df6fc1a8_xx", 1700000002.0),
)
conn.commit()
conn.close()
sessions = models.get_cli_sessions()

View File

@@ -508,6 +508,51 @@ def test_compression_chain_with_all_empty_segments_is_hidden():
post('/api/settings', {'show_cli_sessions': False})
def test_default_title_cli_compression_chain_is_kept_by_lineage():
"""Default-titled CLI compression chains are meaningful even with a short tip."""
conn = _ensure_state_db()
ids_to_remove = ('cli_default_compress_root_001', 'cli_default_compress_tip_001')
t0 = time.time() - 430
try:
_insert_agent_session_row(
conn,
'cli_default_compress_root_001',
source='cli',
title='Cli Session',
started_at=t0,
ended_at=t0 + 100,
end_reason='compression',
messages=1,
)
_insert_agent_session_row(
conn,
'cli_default_compress_tip_001',
source='cli',
title='Cli Session',
started_at=t0 + 101,
parent_session_id='cli_default_compress_root_001',
messages=1,
)
post('/api/settings', {'show_cli_sessions': True})
data, status = get('/api/sessions')
assert status == 200
ids = {s.get('session_id') for s in data.get('sessions', [])}
assert 'cli_default_compress_tip_001' in ids
assert 'cli_default_compress_root_001' not in ids
tip = next(s for s in data.get('sessions', []) if s.get('session_id') == 'cli_default_compress_tip_001')
assert tip.get('_compression_segment_count') == 2
assert tip.get('_lineage_root_id') == 'cli_default_compress_root_001'
finally:
try:
_remove_test_sessions(conn, *ids_to_remove)
conn.close()
except Exception:
pass
post('/api/settings', {'show_cli_sessions': False})
def test_non_compression_child_is_not_collapsed_into_parent():
"""Parent/child relationships that are not compression continuations stay flat."""
conn = _ensure_state_db()

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import urlparse
from unittest.mock import patch
REPO = Path(__file__).resolve().parents[1]
def _write(path: Path, text: str = "# Synthetic\n") -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def test_llm_wiki_status_reads_synthetic_fixture_without_exposing_content(tmp_path, monkeypatch):
"""The wiki status API should summarize counts/mtime without leaking page text."""
import api.routes as routes
wiki = tmp_path / "wiki"
_write(wiki / "SCHEMA.md", "# Schema\n")
_write(wiki / "index.md", "# Index\n")
_write(wiki / "log.md", "# Log\n## [2026-05-04] update | Secret project name\n- Details stay private\n")
_write(
wiki / "entities" / "private-agent.md",
"---\ntitle: Private Agent\nupdated: 2026-05-04\n---\nSensitive body text must not ship.\n",
)
_write(wiki / "concepts" / "safe-summary.md", "---\ntitle: Safe Summary\n---\nMore private text\n")
_write(wiki / "raw" / "articles" / "source.md", "Raw source body should not count as wiki page\n")
monkeypatch.setenv("WIKI_PATH", str(wiki))
status = routes._build_llm_wiki_status()
assert status["available"] is True
assert status["enabled"] is True
assert status["entry_count"] == 2
assert status["page_count"] == 2
assert status["raw_source_count"] == 1
assert status["last_updated"] is not None
assert status["last_writer"] is None
assert status["toggle_available"] is False
assert status["docs_url"].endswith("/research-llm-wiki")
serialized = repr(status)
assert "Sensitive body text" not in serialized
assert "Secret project name" not in serialized
assert str(wiki) not in serialized
def test_llm_wiki_status_reports_unavailable_when_path_missing(tmp_path, monkeypatch):
import api.routes as routes
missing = tmp_path / "does-not-exist"
monkeypatch.setenv("WIKI_PATH", str(missing))
status = routes._build_llm_wiki_status()
assert status["available"] is False
assert status["enabled"] is False
assert status["entry_count"] == 0
assert status["page_count"] == 0
assert status["raw_source_count"] == 0
assert status["last_updated"] is None
assert status["status"] == "missing"
def test_api_wiki_status_route_is_registered(monkeypatch, tmp_path):
import api.routes as routes
wiki = tmp_path / "wiki"
_write(wiki / "entities" / "one.md")
monkeypatch.setenv("WIKI_PATH", str(wiki))
captured = {}
def fake_j(handler, payload, status=200, extra_headers=None):
captured["status"] = status
captured["payload"] = payload
with patch("api.routes.j", side_effect=fake_j):
handled = routes.handle_get(SimpleNamespace(), urlparse("/api/wiki/status"))
assert handled is True
assert captured["status"] == 200
assert captured["payload"]["entry_count"] == 1
def test_insights_panel_fetches_and_renders_llm_wiki_status_card():
panels_src = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
index_src = (REPO / "static" / "index.html").read_text(encoding="utf-8")
style_src = (REPO / "static" / "style.css").read_text(encoding="utf-8")
assert "api('/api/wiki/status')" in panels_src
assert "function _renderLlmWikiStatus" in panels_src
assert "llmWikiStatusCard" in index_src
assert "wiki-status-card" in style_src
assert "raw/" in panels_src
assert "recent_entries" not in panels_src

View File

@@ -0,0 +1,87 @@
"""Regression test for #1669 follow-up — sidebar scroll jump fix.
The original PR #1669 added DOM virtualization to renderSessionListFromCache,
which:
1. Attached an unconditional scroll listener to the session list
2. The scroll listener triggers renderSessionListFromCache() on every rAF
3. The render rebuilds the list DOM via list.innerHTML='' / appendChild loop
4. After the rebuild, scrollTop was only restored when virtualWindow.virtualized
was true (i.e. total > 80 rows)
5. For lists ≤ 80 rows, the scrollTop reset to 0 on every scroll event,
producing a "scroll keeps jumping back" feel.
This test pins:
- The non-virtualized branch always restores scrollTop after a rebuild
- The scroll handler short-circuits when total <= threshold (prevents the
rebuild churn entirely on small lists)
"""
from pathlib import Path
SESSIONS_JS = Path(__file__).parent.parent / "static" / "sessions.js"
def _read_source():
return SESSIONS_JS.read_text()
def test_render_restores_scroll_top_for_non_virtualized_lists():
"""The bug: virtualWindow.virtualized=false skipped the scrollTop restore.
The fix: restore scrollTop whenever listScrollTopBeforeRender > 0,
regardless of virtualized flag. Otherwise small lists (≤80 rows) reset
to scrollTop=0 on every render.
"""
src = _read_source()
# The new branch must include listScrollTopBeforeRender>0 as the guard
# rather than virtualWindow.virtualized
assert "}else if(listScrollTopBeforeRender>0){" in src, (
"Expected the scrollTop-restore guard to use listScrollTopBeforeRender>0, "
"not virtualWindow.virtualized — without this fix, small lists drop "
"scrollTop to 0 on every scroll event."
)
def test_scroll_handler_short_circuits_below_virtualization_threshold():
"""The bug: the rAF re-render fired on every scroll event regardless of
whether virtualization was actually needed. For ≤80-row lists this caused
full DOM rebuild on every scroll tick.
The fix: _scheduleSessionVirtualizedRender skips the rebuild when
total <= SESSION_VIRTUAL_THRESHOLD_ROWS — there's no virtual window to
recompute on small lists, and the rebuild was wasteful (and bug-prone).
"""
src = _read_source()
# Locate the function body
start = src.find("function _scheduleSessionVirtualizedRender()")
end = src.find("function _ensureSessionVirtualScrollHandler", start)
body = src[start:end]
# The fix introduces an early-return when total <= SESSION_VIRTUAL_THRESHOLD_ROWS
assert "SESSION_VIRTUAL_THRESHOLD_ROWS" in body, (
"Expected _scheduleSessionVirtualizedRender to read the threshold; "
"without this guard, the rAF re-render fires on every scroll event "
"even when there's nothing to virtualize."
)
assert "total<=SESSION_VIRTUAL_THRESHOLD_ROWS" in body or "total <= SESSION_VIRTUAL_THRESHOLD_ROWS" in body, (
"Expected explicit total<=THRESHOLD comparison to short-circuit the re-render."
)
# The early return must be BEFORE the rAF schedule (else it's dead code)
early_return_idx = body.find("return")
raf_idx = body.find("requestAnimationFrame")
assert early_return_idx > 0 and early_return_idx < raf_idx, (
"The total<=THRESHOLD short-circuit must return BEFORE scheduling the rAF."
)
def test_virtualization_still_active_for_large_lists():
"""Regression: ensure the threshold + virtualWindow logic is still in place
for large lists. The fix must not break the original virtualization path.
"""
src = _read_source()
assert "SESSION_VIRTUAL_THRESHOLD_ROWS = 80" in src, (
"Threshold constant must remain at 80 rows."
)
# _sessionVirtualWindow function still defined
assert "function _sessionVirtualWindow" in src
# virtualWindow.virtualized branch still drives spacer rendering
assert "virtualWindow.virtualized" in src

118
tests/test_logs_endpoint.py Normal file
View File

@@ -0,0 +1,118 @@
import json
import urllib.error
import urllib.parse
import urllib.request
from tests._pytest_port import BASE, TEST_STATE_DIR
def _get_logs(file="agent", tail=200):
url = f"{BASE}/api/logs?file={urllib.parse.quote(str(file))}&tail={urllib.parse.quote(str(tail))}"
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read()), r.status
def _get_logs_error(file="agent", tail=200):
url = f"{BASE}/api/logs?file={urllib.parse.quote(str(file))}&tail={urllib.parse.quote(str(tail))}"
try:
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def test_logs_endpoint_tails_whitelisted_synthetic_agent_log():
logs_dir = TEST_STATE_DIR / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
(logs_dir / "agent.log").write_text(
"\n".join(
[f"2026-05-04 INFO synthetic-log-marker line {i}" for i in range(105)]
+ ["2026-05-04 ERROR synthetic-log-marker failed safely"]
) + "\n",
encoding="utf-8",
)
data, status = _get_logs("agent", 100)
assert status == 200
assert data["file"] == "agent"
assert data["tail"] == 100
assert len(data["lines"]) == 100
assert data["lines"][0] == "2026-05-04 INFO synthetic-log-marker line 6"
assert data["lines"][-1] == "2026-05-04 ERROR synthetic-log-marker failed safely"
assert data["truncated"] is False
assert data["total_bytes"] > 0
assert data["mtime"] > 0
assert data.get("hint") == ""
def test_logs_endpoint_rejects_path_traversal_and_unknown_files():
for bad_file in ("../../etc/passwd", "agent.log", "private", "/tmp/agent"):
data, status = _get_logs_error(bad_file, 200)
assert status == 400
assert "error" in data
def test_logs_endpoint_missing_file_returns_empty_lines_with_safe_hint():
missing = TEST_STATE_DIR / "logs" / "gateway.log"
if missing.exists():
missing.unlink()
data, status = _get_logs("gateway", 200)
assert status == 200
assert data["file"] == "gateway"
assert data["lines"] == []
assert data["truncated"] is False
assert data["total_bytes"] == 0
assert data["mtime"] is None
assert "not found" in data["hint"].lower()
assert str(TEST_STATE_DIR) not in data["hint"]
def test_logs_endpoint_tail_selector_is_allowlisted_and_defaults_to_200():
logs_dir = TEST_STATE_DIR / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
(logs_dir / "errors.log").write_text(
"\n".join(f"2026-05-04 ERROR synthetic-log-marker line {i}" for i in range(250)) + "\n",
encoding="utf-8",
)
default_data, default_status = _get_logs("errors", "not-a-number")
capped_data, capped_status = _get_logs("errors", 999999)
allowed_data, allowed_status = _get_logs("errors", 100)
assert default_status == capped_status == allowed_status == 200
assert default_data["tail"] == 200
assert len(default_data["lines"]) == 200
assert capped_data["tail"] == 200
assert len(capped_data["lines"]) == 200
assert allowed_data["tail"] == 100
assert len(allowed_data["lines"]) == 100
def test_logs_endpoint_reads_bounded_window_and_reports_truncation():
logs_dir = TEST_STATE_DIR / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
huge_prefix = "x" * (4 * 1024 * 1024 + 64)
(logs_dir / "gateway.log").write_text(
huge_prefix + "\n2026-05-04 INFO synthetic-log-marker tail survives\n",
encoding="utf-8",
)
data, status = _get_logs("gateway", 1000)
assert status == 200
assert data["tail"] == 1000
assert data["truncated"] is True
assert data["lines"][-1] == "2026-05-04 INFO synthetic-log-marker tail survives"
assert data["total_bytes"] > 4 * 1024 * 1024
def test_logs_endpoint_tests_use_only_synthetic_fixture_content():
source = __import__("pathlib").Path(__file__).read_text(encoding="utf-8")
assert "synthetic-log-marker" in source
assert "/home/" + "michael" not in source
assert "~/" + ".hermes/logs" not in source
assert "TOK" + "EN=" not in source
assert "PASS" + "WORD=" not in source

View File

@@ -0,0 +1,139 @@
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
INDEX = (REPO / "static" / "index.html").read_text(encoding="utf-8")
PANELS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
I18N = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
assert match, f"{name}() not found"
brace = src.find("{", match.end())
assert brace != -1, f"{name}() has no body"
depth = 1
i = brace + 1
in_string = None
escaped = False
in_line_comment = False
in_block_comment = False
while i < len(src) and depth:
ch = src[i]
nxt = src[i + 1] if i + 1 < len(src) else ""
if in_line_comment:
if ch == "\n":
in_line_comment = False
i += 1
continue
if in_block_comment:
if ch == "*" and nxt == "/":
in_block_comment = False
i += 2
continue
i += 1
continue
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == in_string:
in_string = None
i += 1
continue
if ch == "/" and nxt == "/":
in_line_comment = True
i += 2
continue
if ch == "/" and nxt == "*":
in_block_comment = True
i += 2
continue
if ch in "'\"`":
in_string = ch
i += 1
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
i += 1
assert depth == 0, f"{name}() body did not close"
return src[brace + 1:i - 1]
def test_logs_tab_is_wired_between_insights_and_settings_in_rail_and_mobile_nav():
rail = INDEX[INDEX.index('data-panel="insights"'):INDEX.index('<div class="rail-spacer"')]
assert 'data-panel="logs"' in rail
assert rail.index('data-panel="insights"') < rail.index('data-panel="logs"')
mobile_start = INDEX.index('class="sidebar-nav"')
mobile_end = INDEX.index('<!-- Settings button mirrored here for mobile')
mobile_nav = INDEX[mobile_start:mobile_end]
assert 'data-panel="logs"' in mobile_nav
assert mobile_nav.index('data-panel="insights"') < mobile_nav.index('data-panel="logs"')
assert 'id="panelLogs"' in INDEX
assert 'id="mainLogs"' in INDEX
assert "tab_logs" in I18N
def test_logs_panel_fetches_allowlisted_api_and_exposes_controls():
load_fn = _function_body(PANELS, "loadLogs")
render_fn = _function_body(PANELS, "_renderLogs")
selected_file_fn = _function_body(PANELS, "_selectedLogsFile")
selected_tail_fn = _function_body(PANELS, "_selectedLogsTail")
assert "api('/api/logs" in load_fn or 'api("/api/logs' in load_fn
assert "logsFile" in selected_file_fn and "logsTail" in selected_tail_fn
assert "agent" in INDEX and "errors" in INDEX and "gateway" in INDEX
assert 'value="200" selected' in INDEX
assert 'value="100"' in INDEX and 'value="500"' in INDEX and 'value="1000"' in INDEX
assert "logsWrap" in INDEX
assert "logsCopyAll" in INDEX
assert "logsAutoRefresh" in INDEX
assert "navigator.clipboard.writeText" in PANELS
assert "logs-copy" in INDEX
def test_logs_autorefresh_runs_only_while_logs_tab_is_visible_and_enabled():
start_fn = _function_body(PANELS, "_startLogsAutoRefresh")
stop_fn = _function_body(PANELS, "_stopLogsAutoRefresh")
assert "if (nextPanel === 'logs') await loadLogs();" in PANELS
assert "_syncLogsAutoRefresh();" in PANELS
assert "_logsAutoRefreshTimer" in PANELS
assert "setInterval" in start_fn and "5000" in start_fn
assert "_currentPanel !== 'logs'" in start_fn
assert "clearInterval" in stop_fn
def test_logs_severity_coloring_prioritizes_explicit_log_level_before_message_text():
severity_fn = _function_body(PANELS, "_logLineSeverityClass")
# A WARNING message can legitimately contain words like "provider error";
# color by the explicit level token, not by incidental message text.
assert severity_fn.index("log-line-warning") < severity_fn.index("log-line-error")
def test_logs_severity_coloring_and_monospace_wrap_css_are_present():
css_min = re.sub(r"\s+", "", CSS)
assert ".logs-output{" in css_min
assert "font-family" in css_min and "monospace" in css_min
assert ".logs-output.wrap" in css_min and "white-space:pre-wrap" in css_min
for cls in ("log-line-error", "log-line-warning", "log-line-info", "log-line-debug"):
assert f".{cls}" in css_min
def test_logs_source_fixtures_do_not_bake_private_log_content():
combined = "\n".join(
(REPO / path).read_text(encoding="utf-8")
for path in (
"tests/test_logs_endpoint.py",
"tests/test_logs_ui_static.py",
"static/index.html",
"static/panels.js",
)
)
assert "/home/" + "michael/.hermes/logs" not in combined
for name in ("agent", "gateway", "errors"):
assert name + ".log:" not in combined

View File

@@ -0,0 +1,85 @@
"""Regression test for the Opus SHOULD-FIX bounds applied in stage-299.
PR #1664 introduced /api/wiki/status with `_llm_wiki_count_files` and
`_llm_wiki_page_files` that walk WIKI_PATH via `rglob`. Without bounds,
a misconfigured WIKI_PATH=/ or symlink loop would hang the endpoint.
These tests pin the defenses applied per Opus advisor on stage-299:
- A constant cap on iteration (_LLM_WIKI_MAX_FILES) for both functions
- A forbidden-roots blocklist (_LLM_WIKI_FORBIDDEN_ROOTS) that includes
'/' / '/etc' / '/usr' / '/var' / '/opt' / '/sys' / '/proc' (resolved
to absolute strings)
- Bounded behavior: if WIKI_PATH points at a forbidden root, both
functions return 0/empty without iterating
"""
from pathlib import Path
ROUTES_PY = Path(__file__).parent.parent / "api" / "routes.py"
def _read_source():
return ROUTES_PY.read_text()
def test_wiki_max_files_constant_present():
src = _read_source()
assert "_LLM_WIKI_MAX_FILES" in src
assert "_LLM_WIKI_FORBIDDEN_ROOTS" in src
# Make sure cap is reasonable (≥ a few thousand, ≤ 100k)
assert "10000" in src or "_LLM_WIKI_MAX_FILES = 10" in src
def test_count_files_has_iteration_cap():
src = _read_source()
# Locate _llm_wiki_count_files body
start = src.find("def _llm_wiki_count_files(")
end = src.find("\ndef ", start + 1)
body = src[start:end]
assert "_LLM_WIKI_MAX_FILES" in body
assert "_LLM_WIKI_FORBIDDEN_ROOTS" in body
assert "iterated > _LLM_WIKI_MAX_FILES" in body or "iterated >= _LLM_WIKI_MAX_FILES" in body
def test_page_files_has_iteration_cap():
src = _read_source()
start = src.find("def _llm_wiki_page_files(")
end = src.find("\ndef ", start + 1)
body = src[start:end]
assert "_LLM_WIKI_MAX_FILES" in body
assert "_LLM_WIKI_FORBIDDEN_ROOTS" in body
def test_forbidden_roots_includes_system_paths():
src = _read_source()
# Find the constant definition
start = src.find("_LLM_WIKI_FORBIDDEN_ROOTS = ")
end = src.find(")\n", start) + 1
decl = src[start:end + 1]
for forbidden in ("/", "/etc", "/usr", "/var"):
assert f'"{forbidden}"' in decl, f"Forbidden root {forbidden!r} not in _LLM_WIKI_FORBIDDEN_ROOTS"
def test_count_files_returns_zero_for_forbidden_root(tmp_path, monkeypatch):
"""Behavioral test: walking a forbidden root returns 0 without iterating."""
import importlib
routes = importlib.import_module("api.routes")
forbidden_root = Path("/etc")
if forbidden_root.exists(): # skip on systems without /etc (Windows)
result = routes._llm_wiki_count_files(forbidden_root)
assert result == 0, "Walking /etc should return 0 (forbidden root guard)"
def test_render_llm_wiki_status_uses_url_scheme_guard():
"""Opus SHOULD-FIX #1: docs_url interpolated into href must be scheme-guarded."""
panels_js = (Path(__file__).parent.parent / "static" / "panels.js").read_text()
# Find the _renderLlmWikiStatus function body
start = panels_js.find("function _renderLlmWikiStatus")
end = panels_js.find("\nfunction ", start + 1)
body = panels_js[start:end]
# Must use a scheme-guarded form, not raw esc()
assert "/^https?:" in body or "test(rawDocsUrl)" in body or "test(docsUrl)" in body, (
"Expected URL scheme guard (e.g. /^https?:\\/\\//.test(...)) before "
"interpolating docsUrl into href to prevent javascript: scheme XSS "
"if docs_url ever becomes config-driven."
)