From 2684d6fa98f43d72cb96a840c4352837e241a16d Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 4 May 2026 16:54:08 -0700 Subject: [PATCH 1/8] feat: add LLM Wiki status panel --- api/routes.py | 184 +++++++++++++++++++++++- docs/pr-media/1257/llm-wiki-status.png | Bin 0 -> 56304 bytes static/index.html | 4 +- static/panels.js | 59 +++++++- static/style.css | 15 ++ tests/test_issue1257_llm_wiki_status.py | 100 +++++++++++++ 6 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 docs/pr-media/1257/llm-wiki-status.png create mode 100644 tests/test_issue1257_llm_wiki_status.py diff --git a/api/routes.py b/api/routes.py index 0f28a15ef7..6ff0279a0b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1631,6 +1631,186 @@ def _resolve_login_locale_key(raw_lang: str | None) -> str: # ── 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") + ) + + +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 + count = 0 + for item in root.rglob("*"): + 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] = [] + 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"): + 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 +2323,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 +2331,8 @@ 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 == "/health": return _handle_health(handler, parsed) diff --git a/docs/pr-media/1257/llm-wiki-status.png b/docs/pr-media/1257/llm-wiki-status.png new file mode 100644 index 0000000000000000000000000000000000000000..b488310e7ae716b0f5bd46f0123a50dfb115de67 GIT binary patch literal 56304 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 z
  • Rmq3Qevb9Bn$A7yl_GM}4*%|1GnU7U75H5!9TguyL?~?G0-|~43amHmSOzjvb z^%V%FXG)g68g^LCS9u&A-_Y=cyV#aWb3HvSk%<9q_%%QO=x8M(PhrI^l7eS*@CjaL zOw4F*fEx2LmFD;0ISQ|B&QWL2w>>7s0W7gZqH2XX?4w=ij-XDZOSAClL$TIy4f}+_ zy(ip$gqEFUo|d=xt70HK=``;x#Eos;6F{)$IrQ@df^7F|)RROd?3a92)w`JL!ZT46 zYP!PqwTGtx0Vz3yfuIK0)|Wp<{W@s!Q|giGqoyUD_35GzYGw9NOug!rix3vX%tKrQhghH!#QkH7zZdf<%+ZQXIgc*BJO@W>&5b~6Vrs; z!0S6xNNsBvx!Cu`2CH9)IB!R1JeG1lKpycpxlOq!IQTZN%vWn4Fb!Vgk_)=7KgYfr ziZ~Gm1cMUgoo4+T8i?>0du7AgbA>&ew+i(!>xI4&bDPq zi7B)-$?%p-c-{1gHicwFd-4`NX!GpI1`R zuao5sG5RO^G!VUUVojT*u1c+73sXy0#K-!!#3d2mN*RGq(h*oDd5Bf!l2~CpG5{i* z{15(Lqg`y3F8V3#Zg%@bMbs#4dy6MSQ)x#gmMp@TG}EJBl^<@80xlDg-atVXbl z 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 literal 0 HcmV?d00001 diff --git a/static/index.html b/static/index.html index ebef736880..90d28892ba 100644 --- a/static/index.html +++ b/static/index.html @@ -716,7 +716,9 @@

    What can I help with?

    Usage Analytics
    -
    Loading...
    +
    +
    Loading...
    +
    diff --git a/static/panels.js b/static/panels.js index d92ed2bf41..77f32f6677 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1995,8 +1995,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 = `
    ${esc(t('error_prefix') + e.message)}
    `; } finally { @@ -2007,7 +2010,56 @@ 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 docsUrl = status.docs_url || 'https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki'; + 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 ` +
    +
    +
    +
    LLM Wiki
    +
    Knowledge-base observability
    +
    + ${esc(badgeText)} +
    +
    ${esc(statusNote)}
    +
    +
    Enabled${status.enabled ? 'Yes' : 'No'}
    +
    Entries${Number(status.entry_count || 0).toLocaleString()}
    +
    Pages${Number(status.page_count || 0).toLocaleString()}
    +
    raw/ files${Number(status.raw_source_count || 0).toLocaleString()}
    +
    Last updated${esc(_formatLlmWikiTimestamp(status.last_updated))}
    +
    Last writer${esc(status.last_writer || 'Not available')}
    +
    + +
    `; +} + +function _renderInsights(d, box, wikiStatus) { const fmtNum = n => Number(n || 0).toLocaleString(); const fmtCost = c => { const value = Number(c || 0); @@ -2106,6 +2158,7 @@ function _renderInsights(d, box) {
    `; box.innerHTML = ` + ${_renderLlmWikiStatus(wikiStatus)}
    ${overviewCards.map(c => `
    ${c.icon}
    ${c.value}
    ${esc(c.label)}
    `).join('')}
    diff --git a/static/style.css b/static/style.css index 8cd53bd803..eb5746e8f5 100644 --- a/static/style.css +++ b/static/style.css @@ -3142,6 +3142,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;} diff --git a/tests/test_issue1257_llm_wiki_status.py b/tests/test_issue1257_llm_wiki_status.py new file mode 100644 index 0000000000..7a62bc94fd --- /dev/null +++ b/tests/test_issue1257_llm_wiki_status.py @@ -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 From af1c628292214cc06594d902e3e5b6ef09f541db Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 4 May 2026 16:34:10 -0700 Subject: [PATCH 2/8] feat: add logs tab MVP --- api/routes.py | 77 +++++++++++++++ docs/pr-media/1455/logs-tab-mvp.png | Bin 0 -> 55685 bytes static/i18n.js | 123 ++++++++++++++++++++++++ static/index.html | 45 +++++++++ static/panels.js | 122 +++++++++++++++++++++++- static/style.css | 30 +++++- tests/test_logs_endpoint.py | 118 +++++++++++++++++++++++ tests/test_logs_ui_static.py | 139 ++++++++++++++++++++++++++++ 8 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 docs/pr-media/1455/logs-tab-mvp.png create mode 100644 tests/test_logs_endpoint.py create mode 100644 tests/test_logs_ui_static.py diff --git a/api/routes.py b/api/routes.py index 0f28a15ef7..a91997d791 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1629,6 +1629,81 @@ def _resolve_login_locale_key(raw_lang: str | None) -> str: """ + +# ── 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 ────────────────────────────────────────────────────────── def _handle_insights(handler, parsed) -> bool: @@ -2151,6 +2226,8 @@ def handle_get(handler, parsed) -> bool: from api.kanban_bridge import handle_kanban_get return handle_kanban_get(handler, parsed) + if parsed.path == "/api/logs": + return _handle_logs(handler, parsed) if parsed.path == "/health": return _handle_health(handler, parsed) diff --git a/docs/pr-media/1455/logs-tab-mvp.png b/docs/pr-media/1455/logs-tab-mvp.png new file mode 100644 index 0000000000000000000000000000000000000000..ef87f91b4a3127733198be64c0ea3d049cf1cdb0 GIT binary patch literal 55685 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%0AC8WOUr1vW0MMPseSd~$8VYYiIX?itW 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 @@ -226,6 +228,33 @@
    Loading...
    + +
    +
    + Logs +
    + +
    +
    +
    + + + + + + + +
    +
    @@ -719,6 +748,22 @@

    What can I help with?

    Loading...
    +
    +
    +
    +
    Logs
    +
    Choose a log file to view recent lines.
    +
    +
    + +
    +
    +
    +
    +
    No log lines yet.
    +
    +
    +
    diff --git a/static/panels.js b/static/panels.js index d92ed2bf41..044bcf3802 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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- class on
    ; 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 = `
    ${esc(t('error_prefix') + e.message)}
    `; + 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 ? `
    ${esc(data.hint)}
    ` : ''; + const truncated = data && data.truncated ? `
    ${esc(t('logs_truncated_hint'))}
    ` : ''; + if (!lines.length) { + box.innerHTML = `${hint}${truncated}
    ${esc(t('logs_empty'))}
    `; + } else { + box.innerHTML = `${hint}${truncated}` + lines.map(line => { + const cls = _logLineSeverityClass(line); + return `
    ${esc(line)}
    `; + }).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'); diff --git a/static/style.css b/static/style.css index 8cd53bd803..5881661fbd 100644 --- a/static/style.css +++ b/static/style.css @@ -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) */ @@ -3450,3 +3452,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);} diff --git a/tests/test_logs_endpoint.py b/tests/test_logs_endpoint.py new file mode 100644 index 0000000000..a526439dd5 --- /dev/null +++ b/tests/test_logs_endpoint.py @@ -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 diff --git a/tests/test_logs_ui_static.py b/tests/test_logs_ui_static.py new file mode 100644 index 0000000000..1cb0079127 --- /dev/null +++ b/tests/test_logs_ui_static.py @@ -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('
    Date: Mon, 4 May 2026 09:44:11 +0800 Subject: [PATCH 3/8] Filter low-value CLI agent sessions --- CHANGELOG.md | 2 - api/agent_sessions.py | 114 ++++++++++++++++++++++ api/models.py | 17 +++- api/routes.py | 57 ++++++++++- static/sessions.js | 24 ++++- tests/test_1466_sidebar_cancel_clarify.py | 24 +++++ 6 files changed, 228 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ce1cae5f..5e4d98dabe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,7 +182,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 +447,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) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 87061b7370..222a188d9a 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -14,6 +14,9 @@ '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. @@ -301,6 +413,7 @@ def read_importable_agent_session_rows( {ended_expr}, {end_reason_expr}, COUNT(m.id) AS actual_message_count, + COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END) 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 +425,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))] diff --git a/api/models.py b/api/models.py index a71e76f1ac..aadb3963bd 100644 --- a/api/models.py +++ b/api/models.py @@ -21,6 +21,7 @@ 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 @@ -537,6 +538,11 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict: last_message_at = _last_message_timestamp(self.messages) or self.updated_at if has_pending_user_message and self.pending_started_at: last_message_at = self.pending_started_at + + def _role(message): + if not isinstance(message, dict): + return "" + return str(message.get('role', '')).strip().lower() return { 'session_id': self.session_id, 'title': self.title, @@ -554,6 +560,9 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict: 'input_tokens': self.input_tokens, 'output_tokens': self.output_tokens, 'estimated_cost': self.estimated_cost, + 'user_message_count': sum( + 1 for message in self.messages if _role(message) == 'user' + ) if isinstance(self.messages, list) else 0, 'personality': self.personality, 'compression_anchor_visible_idx': self.compression_anchor_visible_idx, 'compression_anchor_message_key': self.compression_anchor_message_key, @@ -1507,7 +1516,12 @@ def _cron_pid(): 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 +1587,7 @@ def _cron_pid(): '_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'), diff --git a/api/routes.py b/api/routes.py index 0f28a15ef7..29ec47b259 100644 --- a/api/routes.py +++ b/api/routes.py @@ -22,7 +22,11 @@ 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. @@ -2431,7 +2473,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 +2489,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 +2528,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) diff --git a/static/sessions.js b/static/sessions.js index c501f5ecb7..e0e07bf744 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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'), diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py index 2029dc8627..8f277cce2b 100644 --- a/tests/test_1466_sidebar_cancel_clarify.py +++ b/tests/test_1466_sidebar_cancel_clarify.py @@ -52,3 +52,27 @@ def test_cancel_session_stream_clears_only_owned_clarify_and_approval_cards(self ) 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" From 8981d335433e8b1093a8035cbdb8970bd1dbccbb Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 4 May 2026 10:10:24 +0800 Subject: [PATCH 4/8] Fix CLI session CI compatibility --- api/agent_sessions.py | 11 +++++++++-- api/models.py | 17 +++++++++-------- tests/test_cron_session_title.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 222a188d9a..1bdaf48b26 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -313,7 +313,7 @@ def compression_tip(row: dict) -> tuple[dict | None, int]: # 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: @@ -367,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 " @@ -387,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] = [] @@ -413,7 +420,7 @@ def read_importable_agent_session_rows( {ended_expr}, {end_reason_expr}, COUNT(m.id) AS actual_message_count, - COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END) AS actual_user_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 diff --git a/api/models.py b/api/models.py index aadb3963bd..6a939b4f02 100644 --- a/api/models.py +++ b/api/models.py @@ -226,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 @@ -538,11 +544,6 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict: last_message_at = _last_message_timestamp(self.messages) or self.updated_at if has_pending_user_message and self.pending_started_at: last_message_at = self.pending_started_at - - def _role(message): - if not isinstance(message, dict): - return "" - return str(message.get('role', '')).strip().lower() return { 'session_id': self.session_id, 'title': self.title, @@ -560,9 +561,6 @@ def _role(message): 'input_tokens': self.input_tokens, 'output_tokens': self.output_tokens, 'estimated_cost': self.estimated_cost, - 'user_message_count': sum( - 1 for message in self.messages if _role(message) == 'user' - ) if isinstance(self.messages, list) else 0, 'personality': self.personality, 'compression_anchor_visible_idx': self.compression_anchor_visible_idx, 'compression_anchor_message_key': self.compression_anchor_message_key, @@ -572,6 +570,9 @@ def _role(message): # 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, diff --git a/tests/test_cron_session_title.py b/tests/test_cron_session_title.py index 78b9f38402..f6db3103be 100644 --- a/tests/test_cron_session_title.py +++ b/tests/test_cron_session_title.py @@ -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() From d76ef2a2b63091b431cd196956e0bd1d09483bc8 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 4 May 2026 11:25:32 +0800 Subject: [PATCH 5/8] Cover CLI compression lineage filtering --- tests/test_gateway_sync.py | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index 4d30b9b1e6..d364606e88 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -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() From 4e9ec6f191d13715061471398724cccff4db6ff3 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Tue, 5 May 2026 02:02:54 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix(sidebar):=20scroll=20jumps=20back=20to?= =?UTF-8?q?=200=20on=20small=20lists=20(=E2=89=A480=20sessions)=20?= =?UTF-8?q?=E2=80=94=20#1669=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1669 added DOM virtualization to renderSessionListFromCache() with two issues for lists below the virtualization threshold (≤80 rows): 1. The unconditional scroll listener triggered renderSessionListFromCache() on every rAF, rebuilding the entire list DOM on every scroll event. 2. After each rebuild, scrollTop was only restored when virtualWindow.virtualized was true (i.e. total > 80). For lists ≤ 80 rows, scrollTop dropped to 0 on every scroll event, producing a 'scroll keeps jumping back' feel. Fix: - Always restore scrollTop after re-render when listScrollTopBeforeRender > 0 (regardless of virtualized flag). - Short-circuit _scheduleSessionVirtualizedRender when total <= SESSION_VIRTUAL_THRESHOLD_ROWS (saves wasteful rebuild on small lists). Live verified on a 56-session sidebar: scrollTop holds across animation frames. 3 regression tests pin the fix shape. --- static/sessions.js | 18 +++- .../test_issue1669_sidebar_scroll_jump_fix.py | 87 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue1669_sidebar_scroll_jump_fix.py diff --git a/static/sessions.js b/static/sessions.js index e0e07bf744..1fdf6ff21c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1936,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();}); } @@ -2202,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) diff --git a/tests/test_issue1669_sidebar_scroll_jump_fix.py b/tests/test_issue1669_sidebar_scroll_jump_fix.py new file mode 100644 index 0000000000..848656cf25 --- /dev/null +++ b/tests/test_issue1669_sidebar_scroll_jump_fix.py @@ -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 From e2748fe9617861f31025a6d9dbc1f258a9381341 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Tue, 5 May 2026 02:12:57 +0000 Subject: [PATCH 7/8] Apply Opus pre-release SHOULD-FIX (absorbed in stage-299) Per Opus advisor on stage-299: 1. Bounded WIKI_PATH walk + forbidden-root guard (api/routes.py) - _LLM_WIKI_MAX_FILES = 10000 caps rglob iteration (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 would block the endpoint 2. URL-scheme guard for docs_url interpolation (static/panels.js) - rawDocsUrl is regex-validated against /^https?:\/\//i before being interpolated into the 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-proof against javascript: scheme XSS 6 regression tests in tests/test_stage299_opus_fixes.py pin both fixes. --- api/routes.py | 31 +++++++++++ static/panels.js | 5 +- tests/test_stage299_opus_fixes.py | 85 +++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/test_stage299_opus_fixes.py diff --git a/api/routes.py b/api/routes.py index 6d85dcbfcf..85bb662742 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1804,6 +1804,15 @@ def _llm_wiki_config_path() -> str | None: ) +# 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) @@ -1832,8 +1841,20 @@ def _llm_wiki_safe_iso(ts: float | None) -> str | 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 @@ -1844,11 +1865,21 @@ def _llm_wiki_count_files(root: Path) -> int: 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): diff --git a/static/panels.js b/static/panels.js index 6f955db1b8..ea5ddf3fbc 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2141,7 +2141,10 @@ function _renderLlmWikiStatus(d) { const isError = status.status === 'error'; const badgeClass = isReady ? 'ok' : isError ? 'err' : isEmpty ? 'warn' : 'muted'; const badgeText = isReady ? 'Available' : isError ? 'Error' : isEmpty ? 'Empty' : 'Unavailable'; - const docsUrl = status.docs_url || 'https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/research/research-llm-wiki'; + 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.'); diff --git a/tests/test_stage299_opus_fixes.py b/tests/test_stage299_opus_fixes.py new file mode 100644 index 0000000000..8c1aeb6416 --- /dev/null +++ b/tests/test_stage299_opus_fixes.py @@ -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." + ) From e095ed90be290e06dbfb0bd9ffc44b5dcd4be9cf Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Tue, 5 May 2026 02:19:56 +0000 Subject: [PATCH 8/8] =?UTF-8?q?chore(release):=20stamp=20v0.51.2=20?= =?UTF-8?q?=E2=80=94=203-PR=20follow-up=20+=20#1669=20scroll=20hotfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md: full v0.51.2 entry covering 3 PRs + sidebar scroll hotfix ROADMAP.md: bump version + test count to 4457 TESTING.md: bump version + test count to 4457 Independent review: Opus advisor on stage-299 diff (1336 LOC). 6/6 verification questions verified clean. Verdict: SHIP. 0 MUST-FIX, 2 SHOULD-FIX absorbed in-release (bounded WIKI walk + URL scheme guard). --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 2 +- TESTING.md | 4 ++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4d98dabe..073e02d2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 diff --git a/ROADMAP.md b/ROADMAP.md index b6db18eb96..0d79f03657 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/TESTING.md b/TESTING.md index db5326004f..4e232d8517 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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: /*