Merge pull request #2884 from nesquena/release/stage-batch13

Release/stage-batch13
This commit is contained in:
nesquena-hermes
2026-05-24 16:43:13 -07:00
committed by GitHub
20 changed files with 1461 additions and 130 deletions
+32
View File
@@ -3,6 +3,38 @@
## [Unreleased]
## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
### Added
- **PR #2868** by @AJV20 — Installed/mobile PWA sessions now support an edge swipe from the left side of the screen to open the mobile sidebar drawer, while preserving the existing hamburger and overlay controls. PWA-standalone-gated, edge X<28px, vertical-tolerance 48px, interactive-target exclusion. Defends against accidental triggers from text selection or button taps.
- **PR #2527** by @AJV20 — Default-off, read-only Third-party notes drawer in the Memory panel. Lists configured note/knowledge MCP sources (Joplin, Obsidian, Notion, llm-wiki) when explicitly enabled via `webui_external_notes_sources` config or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`. Automatic session recall unchanged. 4 API endpoints (`/api/notes/sources`, `/api/notes/search`, `/api/notes/item`, plus `external_notes_enabled` in memory_read response) all gated behind the feature flag.
- **PR #2547** by @AJV20 — SSE stream runtime diagnostics in deep health checks: active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Non-sensitive payload only.
- **PR #2547** by @AJV20 — WebUI session prefill parity for bounded JSON files. Browser-originated chat turns can load configured prefill context from `prefill_messages_file`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. WebUI intentionally does not execute `prefill_messages_script`; executable recall should go through the existing MCP/tool surface. Backward-compatible: degrades gracefully on older agent builds that don't support the `prefill_messages` kwarg.
### Changed
- **PR #2547** by @AJV20 — Browser-surface session context is now attached to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts. Context is ephemeral (not saved to history). WebUI progress guidance now preserves the normal Hermes messaging style instead of encouraging extra browser-only status chatter.
### Fixed
- **PR #2865** by @AJV20 — New WebUI sessions no longer persist `display.personality` into per-session `Session.personality`; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845.
- **PR #2882** by @ycj — zh-CN (Simplified Chinese) session-time relative labels are now clearer: `${n}分钟前`, `${n}小时前`, `${n}天前`, and the more natural last-week phrasing `上周` instead of the previous bare-unit shorthand. Also corrects a small indentation glitch in the zh-TW (Traditional Chinese) locale. (Cherry-picked onto fresh stage with `Co-authored-by` attribution — original PR was based on stale master.)
- **PR #2873** by @Charanis — The WebUI launcher (`ctl.sh` + `bootstrap.py`) now preserves environment variables that have already been resolved by the shell (for example `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_WEBUI_HOST`) instead of letting a repo-level `.env` clobber them mid-launch. The `.env` keeps working as a default-only source for unset variables, gated by `HERMES_WEBUI_PRESERVE_ENV=1` set by the launcher subshell.
### Notes
- **6,503 pytest passed** (sequential mode; the test infrastructure uses a single test server that doesn't support xdist parallelism — known limitation, tracked separately).
- **Opus Advisor verdict: SHIP-AS-IS.** Zero MUST-FIX. Three SHOULD-FIX items filed as follow-up issues (incomplete locale coverage for notes-drawer i18n keys, `_joplin_api_get` URL-token defense-in-depth, prefill `setattr` cache-reuse safety net).
- **#2527 i18n coverage**: 10 of the 11 non-en locales currently ship the English string `'Third-party notes'` for the drawer header. Since the drawer is default-off, user impact is zero today; follow-up issue tracks proper translations before any default-on transition.
## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab)
### Fixed
+1 -13
View File
@@ -1945,18 +1945,6 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
if model_provider:
effective_model_provider = model_provider
# Read default personality from config display.personality
_default_personality = None
try:
from api.config import get_config as _get_cfg_for_personality
_cfg_personality = (_get_cfg_for_personality().get('display') or {}).get('personality')
if _cfg_personality and isinstance(_cfg_personality, str):
_cfg_personality = _cfg_personality.strip().lower()
if _cfg_personality and _cfg_personality not in ('default', 'none', 'neutral'):
_default_personality = _cfg_personality
except Exception:
pass
wt = worktree_info if isinstance(worktree_info, dict) else None
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
s = Session(
@@ -1965,7 +1953,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
model_provider=effective_model_provider,
profile=profile,
project_id=project_id,
personality=_default_personality,
personality=None,
worktree_path=wt.get('path') if wt else None,
worktree_branch=wt.get('branch') if wt else None,
worktree_repo_root=wt.get('repo_root') if wt else None,
+451
View File
@@ -4634,6 +4634,13 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/mcp/tools":
return _handle_mcp_tools_list(handler)
if parsed.path == "/api/notes/sources":
return _handle_notes_sources_list(handler)
if parsed.path == "/api/notes/search":
return _handle_notes_search(handler, parsed)
if parsed.path == "/api/notes/item":
return _handle_notes_item(handler, parsed)
# ── Checkpoints / Rollback (GET) ──
if parsed.path == "/api/rollback/list":
qs = parse_qs(parsed.query)
@@ -8129,6 +8136,7 @@ def _handle_memory_read(handler):
"memory_mtime": mem_file.stat().st_mtime if mem_file.exists() else None,
"user_mtime": user_file.stat().st_mtime if user_file.exists() else None,
"soul_mtime": soul_file.stat().st_mtime if soul_file.exists() else None,
"external_notes_enabled": _external_notes_sources_enabled(),
},
)
@@ -12020,6 +12028,449 @@ def _handle_mcp_tools_list(handler):
})
def _webui_truthy(value) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _external_notes_sources_enabled(config_data: dict | None = None) -> bool:
"""Return whether the third-party notes drawer is explicitly enabled.
The Memory panel is a primary surface, so this power-user drawer stays
default-off unless a deployment opts in through config or environment.
"""
env_value = os.getenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "")
if env_value:
return _webui_truthy(env_value)
cfg = config_data if isinstance(config_data, dict) else get_config()
if not isinstance(cfg, dict):
return False
return _webui_truthy(
cfg.get("webui_external_notes_sources")
or cfg.get("external_notes_sources")
or cfg.get("notes_sources_drawer")
)
_NOTES_SOURCE_SERVER_HINTS = {
"joplin", "obsidian", "notion", "llm-wiki", "llmwiki", "wiki",
"notes", "note", "knowledge", "kb", "readwise", "logseq",
}
_NOTES_SOURCE_TOOL_HINTS = {
"note", "notes", "notebook", "page", "pages", "wiki", "knowledge",
"search_notes", "get_note", "list_notes", "read_note",
}
_NOTES_SOURCE_CONFIGURED_TOOL_HINTS = {
"joplin": [
{"name": "search_notes", "description": "Search Joplin notes by keyword."},
{"name": "list_notes", "description": "List notes from a Joplin notebook."},
{"name": "get_note", "description": "Read a specific Joplin note by ID."},
],
"obsidian": [
{"name": "search_notes", "description": "Search Obsidian notes by keyword."},
{"name": "read_note", "description": "Read a specific Obsidian note or file."},
],
"notion": [
{"name": "search_pages", "description": "Search Notion pages or databases."},
{"name": "get_page", "description": "Read a specific Notion page."},
],
"llm-wiki": [
{"name": "query_knowledge_base", "description": "Query the LLM Wiki knowledge base."},
{"name": "read_page", "description": "Read a specific wiki page."},
],
"llmwiki": [
{"name": "query_knowledge_base", "description": "Query the LLM Wiki knowledge base."},
{"name": "read_page", "description": "Read a specific wiki page."},
],
}
def _note_source_label(name: str) -> str:
labels = {
"joplin": "Joplin",
"obsidian": "Obsidian",
"notion": "Notion",
"llm-wiki": "LLM Wiki",
"llmwiki": "LLM Wiki",
"readwise": "Readwise",
"logseq": "Logseq",
}
lowered = str(name or "").strip().lower()
return labels.get(lowered, str(name or "").replace("_", " ").replace("-", " ").title())
def _looks_like_notes_source(server_name: str, tool_rows: list[dict]) -> bool:
server_l = str(server_name or "").lower()
if any(hint in server_l for hint in _NOTES_SOURCE_SERVER_HINTS):
return True
for tool in tool_rows:
haystack = " ".join([
str(tool.get("name") or ""),
str(tool.get("description") or ""),
]).lower()
if any(hint in haystack for hint in _NOTES_SOURCE_TOOL_HINTS):
return True
return False
def _configured_note_tool_hints(server_name: str) -> list[dict]:
"""Return safe expected note-tool hints for configured known sources."""
server_l = str(server_name or "").strip().lower()
hints = _NOTES_SOURCE_CONFIGURED_TOOL_HINTS.get(server_l)
if hints is None:
if any(hint in server_l for hint in ("wiki", "knowledge", "kb")):
hints = [
{"name": "search", "description": "Search this configured knowledge source."},
{"name": "read", "description": "Read an item from this configured knowledge source."},
]
elif any(hint in server_l for hint in ("note", "notes")):
hints = [
{"name": "search_notes", "description": "Search this configured notes source."},
{"name": "read_note", "description": "Read a note from this configured notes source."},
]
else:
hints = []
return [
{
"name": _mcp_safe_display_text(row.get("name") or "", limit=96),
"description": _mcp_safe_display_text(row.get("description") or "", limit=180),
"inferred": True,
}
for row in hints
if isinstance(row, dict)
]
def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) -> list[dict]:
"""Build a safe notes/knowledge-source inventory from MCP servers/tools.
Some WebUI deployments can read ``mcp_servers`` from config before their
local runtime/tool registry has hydrated MCP tool metadata. Still show
configured note/knowledge servers (for example Joplin) in that case so the
drawer reflects connection/configuration state instead of appearing empty.
"""
by_server: dict[str, list[dict]] = {}
for tool in tools or []:
if not isinstance(tool, dict):
continue
server = str(tool.get("server") or "").strip()
if not server:
continue
by_server.setdefault(server, []).append(tool)
if isinstance(server_summaries, dict):
for server, summary in server_summaries.items():
server_name = str(server or "").strip()
if not server_name or server_name in by_server:
continue
if _looks_like_notes_source(server_name, []):
by_server.setdefault(server_name, [])
sources = []
for server, tool_rows in by_server.items():
if not _looks_like_notes_source(server, tool_rows):
continue
summary = server_summaries.get(server, {"name": server}) if isinstance(server_summaries, dict) else {"name": server}
safe_tools = []
tool_source = "runtime"
for tool in tool_rows[:8]:
desc = _mcp_safe_display_text(tool.get("description") or "", limit=180)
desc = re.sub(r"(?i)\b(api[_-]?key|token|password|secret)\s*[:=]\s*\S+", "[REDACTED]", desc)
safe_tools.append({
"name": _mcp_safe_display_text(tool.get("name") or "", limit=96),
"description": desc,
})
if not safe_tools:
safe_tools = _configured_note_tool_hints(server)
if safe_tools:
tool_source = "configured_hint"
sources.append({
"name": server,
"label": _note_source_label(server),
"enabled": bool(summary.get("enabled", True)),
"active": bool(summary.get("active")),
"status": summary.get("status") or "unknown",
"tool_count": len(safe_tools),
"tool_source": tool_source,
"tools": safe_tools,
})
sources.sort(key=lambda row: (not row.get("active"), row.get("label", "")))
return sources
def _handle_notes_sources_list(handler):
"""List note/knowledge MCP sources for the WebUI Notes drawer."""
cfg = get_config()
if not _external_notes_sources_enabled(cfg):
return j(handler, {
"enabled": False,
"sources": [],
"source": "disabled",
"inventory_scope": "disabled_by_default",
"attach_supported": False,
"automatic_recall_unchanged": True,
"recent_ai_notes": [],
})
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
runtime = _mcp_runtime_status_by_name()
server_summaries = {
str(name): _server_summary(str(name), scfg, runtime.get(str(name)))
for name, scfg in servers.items()
}
tools = _mcp_tools_from_runtime_status(runtime, server_summaries)
source = "mcp_runtime_status"
if not tools:
tools = _mcp_tools_from_registry(server_summaries)
source = "tool_registry" if tools else "none"
return j(handler, {
"enabled": True,
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
"source": source,
"inventory_scope": "already_known_runtime_only",
"attach_supported": False,
"automatic_recall_unchanged": True,
"recent_ai_notes": _joplin_recent_ai_notes(limit=6),
})
def _notes_configured_server(source: str) -> dict:
cfg = get_config()
servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
if not isinstance(servers, dict):
return {}
source_l = str(source or "").strip().lower()
for name, server_cfg in servers.items():
if str(name or "").strip().lower() == source_l and isinstance(server_cfg, dict):
return server_cfg
return {}
def _joplin_connection_from_config() -> tuple[str, str]:
cfg = _notes_configured_server("joplin")
env = cfg.get("env", {}) if isinstance(cfg, dict) else {}
if not isinstance(env, dict):
env = {}
url = str(env.get("JOPLIN_URL") or os.environ.get("JOPLIN_URL") or "http://127.0.0.1:41184").rstrip("/")
token = str(env.get("JOPLIN_TOKEN") or os.environ.get("JOPLIN_TOKEN") or "")
return url, token
def _joplin_api_get(path: str, params: dict | None = None) -> dict:
"""Call the local Joplin Web Clipper API without logging credentials."""
from urllib.parse import urlencode
from urllib.request import urlopen
from urllib.error import HTTPError, URLError
base_url, token = _joplin_connection_from_config()
if not token:
raise ValueError("Joplin token is not configured")
safe_path = "/" + str(path or "").lstrip("/")
query = dict(params or {})
query["token"] = token
url = f"{base_url}{safe_path}?{urlencode(query)}"
try:
with urlopen(url, timeout=8) as response:
raw = response.read(2_000_000).decode("utf-8", errors="replace")
except HTTPError as exc:
raise ValueError(f"Joplin API returned HTTP {exc.code}") from None
except URLError as exc:
raise ValueError("Joplin API is not reachable") from None
try:
data = json.loads(raw)
except Exception:
raise ValueError("Joplin API returned invalid JSON") from None
return data if isinstance(data, dict) else {}
def _note_snippet(body: str, query: str = "", *, limit: int = 220) -> str:
text = re.sub(r"\s+", " ", str(body or "")).strip()
if not text:
return ""
q = str(query or "").strip().lower()
if q:
idx = text.lower().find(q)
if idx > 40:
text = "" + text[max(0, idx - 60):]
if len(text) > limit:
return text[:limit].rstrip() + ""
return text
def _joplin_search_notes(query: str, *, limit: int = 20) -> list[dict]:
query = str(query or "").strip()
if not query:
return []
limit = max(1, min(int(limit or 20), 50))
data = _joplin_api_get("/search", {
"query": query,
"type": "note",
"fields": "id,title,body,parent_id,updated_time",
"limit": limit,
})
rows = data.get("items") if isinstance(data, dict) else []
results = []
for row in rows if isinstance(rows, list) else []:
if not isinstance(row, dict):
continue
note_id = _mcp_safe_display_text(row.get("id") or "", limit=64)
if not note_id:
continue
title = _mcp_safe_display_text(row.get("title") or "Untitled", limit=180)
body = str(row.get("body") or "")
results.append({
"id": note_id,
"title": title,
"snippet": _mcp_safe_display_text(_note_snippet(body, query), limit=260),
"parent_id": _mcp_safe_display_text(row.get("parent_id") or "", limit=64),
"updated_time": row.get("updated_time"),
"source": "joplin",
})
return results
def _joplin_get_note(note_id: str) -> dict:
note_id = str(note_id or "").strip()
if not re.fullmatch(r"[A-Za-z0-9]{16,64}", note_id):
raise ValueError("Invalid Joplin note id")
data = _joplin_api_get(f"/notes/{note_id}", {
"fields": "id,title,body,parent_id,updated_time,created_time",
})
if not data.get("id"):
raise ValueError("Joplin note not found")
body = str(data.get("body") or "")
if len(body) > 50_000:
body = body[:50_000].rstrip() + "\n\n[Preview truncated at 50,000 characters]"
return {
"id": _mcp_safe_display_text(data.get("id") or "", limit=64),
"title": _mcp_safe_display_text(data.get("title") or "Untitled", limit=180),
"body": _redact_text(body),
"parent_id": _mcp_safe_display_text(data.get("parent_id") or "", limit=64),
"updated_time": data.get("updated_time"),
"created_time": data.get("created_time"),
"source": "joplin",
}
_JOPLIN_AI_RECALL_NOTE_PRIORITY = [
("CURRENT_CONTEXT_ID", "Current Context"),
("OPEN_ISSUES_ID", "Open Issues"),
("AGENT_MEMORY_ID", "Agent Memory"),
("CONVENTIONS_ID", "Conventions / Preferences"),
("INFRA_ID", "Infrastructure"),
("SERVICES_ID", "Services"),
]
def _joplin_prefill_script_path() -> Path | None:
cfg = get_config()
path_value = cfg.get("prefill_messages_script") if isinstance(cfg, dict) else None
if not path_value:
return None
try:
return Path(str(path_value)).expanduser()
except Exception:
return None
def _joplin_recall_note_refs(script_path: Path | None = None) -> list[dict]:
"""Find stable Joplin note IDs referenced by the configured recall script.
This keeps the WebUI generic: it does not hard-code a user's note IDs, but
can still surface the notes that the configured AI prefill/recall script is
known to read for automatic context.
"""
script_path = script_path or _joplin_prefill_script_path()
if not script_path or not script_path.exists() or not script_path.is_file():
return []
try:
text = script_path.read_text(encoding="utf-8", errors="replace")
except Exception:
return []
constants = {
match.group(1): match.group(2)
for match in re.finditer(r'(?m)^\s*([A-Z0-9_]+_ID)\s*=\s*["\']([A-Fa-f0-9]{16,64})["\']', text)
}
refs = []
seen = set()
for const_name, label in _JOPLIN_AI_RECALL_NOTE_PRIORITY:
note_id = constants.get(const_name)
if not note_id or note_id in seen:
continue
seen.add(note_id)
refs.append({
"id": note_id,
"label": label,
"constant": const_name,
"used_by": "ai_prefill",
"used_reason": "automatic_recall",
})
return refs
def _joplin_recent_ai_notes(*, limit: int = 6) -> list[dict]:
"""Return safe Joplin notes that the configured AI recall path recently uses."""
try:
limit = max(1, min(int(limit or 6), 20))
except Exception:
limit = 6
notes = []
for ref in _joplin_recall_note_refs()[:limit]:
try:
data = _joplin_api_get(f"/notes/{ref['id']}", {
"fields": "id,title,parent_id,updated_time,user_updated_time,created_time",
})
except Exception:
continue
note_id = _mcp_safe_display_text(data.get("id") or ref.get("id") or "", limit=64)
if not note_id:
continue
notes.append({
"id": note_id,
"title": _mcp_safe_display_text(data.get("title") or ref.get("label") or "Untitled", limit=180),
"label": _mcp_safe_display_text(ref.get("label") or "", limit=120),
"parent_id": _mcp_safe_display_text(data.get("parent_id") or "", limit=64),
"updated_time": data.get("user_updated_time") or data.get("updated_time"),
"created_time": data.get("created_time"),
"source": "joplin",
"used_by": ref.get("used_by") or "ai_prefill",
"used_reason": ref.get("used_reason") or "automatic_recall",
})
return notes
def _handle_notes_search(handler, parsed):
if not _external_notes_sources_enabled():
return j(handler, {"source": "disabled", "results": [], "error": "External notes sources are disabled."}, status=404)
query = parse_qs(parsed.query or "")
source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower()
q = str(query.get("q", [""])[0] or "").strip()
try:
limit = int(query.get("limit", ["20"])[0] or 20)
except Exception:
limit = 20
if source != "joplin":
return j(handler, {"source": source, "results": [], "error": "Search is currently implemented for Joplin sources only."}, status=400)
try:
return j(handler, {"source": "joplin", "query": q, "results": _joplin_search_notes(q, limit=limit)})
except ValueError as exc:
return j(handler, {"source": "joplin", "query": q, "results": [], "error": str(exc)}, status=502)
def _handle_notes_item(handler, parsed):
if not _external_notes_sources_enabled():
return j(handler, {"source": "disabled", "error": "External notes sources are disabled."}, status=404)
query = parse_qs(parsed.query or "")
source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower()
note_id = str(query.get("id", [""])[0] or "").strip()
if source != "joplin":
return j(handler, {"source": source, "error": "Preview is currently implemented for Joplin sources only."}, status=400)
try:
return j(handler, {"source": "joplin", "note": _joplin_get_note(note_id)})
except ValueError as exc:
return j(handler, {"source": "joplin", "error": str(exc)}, status=502)
def _handle_mcp_servers_list(handler):
"""List configured MCP servers with safe, read-only runtime visibility."""
cfg = get_config()
+139 -6
View File
@@ -10,6 +10,7 @@ import mimetypes
import os
import queue
import re
import sys
import threading
import time
import traceback
@@ -189,9 +190,10 @@ def _clarify_timeout_seconds(default: int = 120) -> int:
_CANCEL_MARKER_PATTERNS = ('task cancelled', 'task canceled', 'response interrupted')
_WEBUI_VISIBLE_PROGRESS_PROMPT = """
WebUI progress contract:
- For multi-step work that uses tools, provide brief user-visible progress updates as normal assistant content before continuing with tool calls.
_WEBUI_PROGRESS_PROMPT = """
WebUI progress guidance:
- Match the normal Hermes messaging style; do not add extra status updates solely because this is a browser session.
- For long multi-step work that uses tools, you may provide brief user-visible progress updates before continuing with tool calls.
- Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed.
- Keep updates concise, factual, and in the user's language. One or two short sentences are enough.
- Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output.
@@ -199,15 +201,126 @@ WebUI progress contract:
""".strip()
def _webui_ephemeral_system_prompt(personality_prompt: Optional[str]) -> str:
def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str:
"""Return safe WebUI session metadata for the agent's ephemeral context.
Messaging gateways inject platform/channel context before each run. Browser
sessions do not have a chat platform wrapper, so provide an explicit, small
surface description here instead of relying on the model to infer where it
is running from the transcript alone.
"""
if not isinstance(surface_context, dict):
return ""
lines = [
"WebUI session context:",
"- This browser session is not the same live transcript as Telegram, Discord, Slack, or other messaging surfaces.",
"- Use durable memory, saved sessions, and available tools for cross-surface recall instead of assuming those transcripts are in this browser chat.",
]
fields = (
("source", "Source"),
("session_id", "Session ID"),
("profile", "Profile"),
("workspace", "Workspace"),
)
for key, label in fields:
raw = surface_context.get(key)
value = str(raw).strip() if raw is not None else ""
if value:
lines.append(f"- {label}: {value}")
return "\n".join(lines)
def _webui_ephemeral_system_prompt(
personality_prompt: Optional[str],
surface_context: Optional[dict] = None,
) -> str:
"""Build WebUI-only runtime instructions that are not persisted to history."""
parts = []
if personality_prompt:
parts.append(str(personality_prompt).strip())
parts.append(_WEBUI_VISIBLE_PROGRESS_PROMPT)
surface_prompt = _webui_surface_context_prompt(surface_context)
if surface_prompt:
parts.append(surface_prompt)
parts.append(_WEBUI_PROGRESS_PROMPT)
return "\n\n".join(part for part in parts if part)
_SECRET_SHAPED_RE = re.compile(
r"(?i)(api[_-]?key|token|password|secret)\s*[:=]\s*[^\s]+|"
r"\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b|"
r"[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"
)
def _redact_prefill_status_text(text: str) -> str:
"""Return a short, non-secret diagnostic string for prefill status."""
clean = _SECRET_SHAPED_RE.sub("[REDACTED]", str(text or ""))
return " ".join(clean.split())[:240]
def _valid_prefill_messages(value) -> list[dict]:
"""Normalize a prefill payload to role/content messages."""
if not isinstance(value, list):
return []
messages: list[dict] = []
for item in value:
if not isinstance(item, dict):
continue
role = item.get("role")
content = item.get("content")
if role not in {"system", "user", "assistant"} or not isinstance(content, str) or not content.strip():
continue
messages.append({"role": role, "content": content})
return messages
def _resolve_prefill_path(raw: str) -> Path:
path = Path(str(raw)).expanduser()
if not path.is_absolute():
try:
from api.config import _get_config_path
path = _get_config_path().parent / path
except Exception:
path = Path.cwd() / path
return path
def _load_webui_prefill_context(
config_data: Optional[dict] = None,
) -> dict:
"""Load configured WebUI session prefill messages.
Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does
not execute a configured prefill script here; session recall that requires
code execution should go through the normal MCP/tool path instead of an
always-on per-turn subprocess before SSE starts.
"""
cfg = config_data if isinstance(config_data, dict) else get_config()
file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "")
if file_raw:
path = _resolve_prefill_path(file_raw)
label = path.name or "prefill file"
if not path.exists():
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"}
try:
messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8")))
return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)}
except Exception as exc:
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0}
def _public_prefill_context_status(prefill_context: dict) -> dict:
"""Strip message bodies before sending context status to the browser."""
return {
"status": prefill_context.get("status", "not_configured"),
"source": prefill_context.get("source", "none"),
"label": prefill_context.get("label", ""),
"message_count": int(prefill_context.get("message_count") or 0),
**({"error": prefill_context.get("error", "")} if prefill_context.get("error") else {}),
}
def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool:
"""Return True if *new* messages (beyond ``prev_count``) contain an
assistant message with non-empty content.
@@ -3896,6 +4009,12 @@ def _run_agent_streaming(
# Read per-profile config at call time (not module-level snapshot)
from api.config import get_config as _get_config
_cfg = _get_config()
_prefill_context = _load_webui_prefill_context(_cfg)
_prefill_messages = _prefill_context.get('messages') or []
put('context_status', {
'session_id': session_id,
'prefill': _public_prefill_context_status(_prefill_context),
})
# Per-profile toolsets — use _resolve_cli_toolsets() so MCP
# server toolsets are included, matching native CLI behaviour.
@@ -4018,6 +4137,7 @@ def _run_agent_streaming(
fallback_model=_fallback_resolved,
session_id=session_id,
session_db=_session_db,
prefill_messages=_prefill_messages,
stream_delta_callback=on_token,
reasoning_callback=on_reasoning,
tool_progress_callback=on_tool,
@@ -4031,6 +4151,8 @@ def _run_agent_streaming(
# but guard defensively to avoid TypeError on an older agent build.
if 'reasoning_config' in _agent_params and _reasoning_config is not None:
_agent_kwargs['reasoning_config'] = _reasoning_config
if 'prefill_messages' not in _agent_params:
_agent_kwargs.pop('prefill_messages', None)
if 'interim_assistant_callback' in _agent_params:
_agent_kwargs['interim_assistant_callback'] = on_interim_assistant
if 'tool_start_callback' in _agent_params:
@@ -4084,6 +4206,7 @@ def _run_agent_streaming(
_fallback_resolved or {},
sorted(_toolsets) if _toolsets else [],
_reasoning_config or {},
_public_prefill_context_status(_prefill_context),
# #1897: profile_home is part of the agent's identity because
# AIAgent caches `_cached_system_prompt` from `load_soul_md()`
# at construction time, sourced from HERMES_HOME. Same-session
@@ -4143,6 +4266,8 @@ def _run_agent_streaming(
agent.reasoning_callback = _agent_kwargs.get('reasoning_callback')
if hasattr(agent, 'clarify_callback'):
agent.clarify_callback = _agent_kwargs.get('clarify_callback')
if hasattr(agent, 'prefill_messages'):
agent.prefill_messages = list(_agent_kwargs.get('prefill_messages') or [])
if _session_db is not None:
# Close any previously held SessionDB connection before
# replacing it. Without this, each streaming request creates
@@ -4258,7 +4383,15 @@ def _run_agent_streaming(
# (agent's own mechanism). This preserves any selected personality
# while making long tool runs emit real user-visible interim text
# through interim_assistant_callback instead of frontend guesses.
agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt(_personality_prompt)
agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt(
_personality_prompt,
surface_context={
'source': 'webui',
'session_id': session_id,
'profile': getattr(s, 'profile', None),
'workspace': s.workspace,
},
)
_pending_started_at = getattr(s, 'pending_started_at', None)
# Normal chat-start sets pending_started_at before spawning this thread;
# fallback to now only for recovered/legacy flows where that marker is absent
+10 -2
View File
@@ -28,8 +28,8 @@ def _load_repo_dotenv() -> None:
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
Variables are set unconditionally (matching shell source semantics), so a
value in .env overrides one already present in the shell environment.
To keep a CLI-supplied value, unset it from .env or launch via start.sh
and override there.
``ctl.sh`` sets HERMES_WEBUI_PRESERVE_ENV=1 when it has already resolved
launcher-specific values such as HERMES_HOME or HERMES_WEBUI_STATE_DIR.
Only loads the webui repo .env — not ~/.hermes/.env, which the server
loads independently at startup for provider credentials.
@@ -41,6 +41,12 @@ def _load_repo_dotenv() -> None:
if not env_path.exists():
return
try:
preserve_existing = os.getenv("HERMES_WEBUI_PRESERVE_ENV", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
@@ -52,6 +58,8 @@ def _load_repo_dotenv() -> None:
k = k[7:].strip()
v = v.strip().strip('"').strip("'")
if k:
if preserve_existing and k in os.environ:
continue
os.environ[k] = v
except Exception as exc:
import sys as _sys
+3 -1
View File
@@ -219,7 +219,9 @@ start_cmd() {
: >> "${LOG_FILE}"
(
cd "${REPO_ROOT}"
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
trap '' HUP
export HERMES_WEBUI_PRESERVE_ENV=1
exec nohup "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
) >> "${LOG_FILE}" 2>&1 &
pid=$!
+65
View File
@@ -228,6 +228,71 @@ function closeMobileSidebar(){
if(overlay)overlay.classList.remove('visible');
}
const _PWA_SIDEBAR_SWIPE_EDGE=28;
const _PWA_SIDEBAR_SWIPE_TRIGGER=72;
const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=48;
let _pwaSidebarSwipe=null;
function _isPwaStandalone(){
try{
return document.documentElement.classList.contains('pwa-standalone')
|| window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone===true;
}catch(_){return false;}
}
function _isInteractiveSwipeTarget(target){
try{return !!(target&&target.closest&&target.closest('input,textarea,select,button,a,[contenteditable="true"],.topbar-chips,.composer-left,.sidebar,.rightpanel'));}
catch(_){return false;}
}
function _openMobileSidebarFromGesture(){
if(_isDesktopWidth())return;
const sidebar=document.querySelector('.sidebar');
const overlay=$('mobileOverlay');
if(!sidebar)return;
const layout=document.querySelector('.layout');
if(layout)layout.classList.remove('sidebar-collapsed');
sidebar.classList.remove('sidebar-collapsed');
try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){}
sidebar.classList.add('mobile-open');
if(overlay)overlay.classList.add('visible');
}
function _onPwaSidebarSwipeStart(e){
if(!_isPwaStandalone()||_isDesktopWidth())return;
if(e.pointerType==='mouse'||(e.pointerType&&e.pointerType!=='touch'&&e.pointerType!=='pen'))return;
if(document.querySelector('.sidebar')?.classList.contains('mobile-open'))return;
const clientX=Number(e.clientX)||0;
if(clientX>_PWA_SIDEBAR_SWIPE_EDGE)return;
if(_isInteractiveSwipeTarget(e.target))return;
_pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false};
}
function _onPwaSidebarSwipeMove(e){
const swipe=_pwaSidebarSwipe;
if(!swipe||!swipe.active||swipe.opened)return;
const dx=(Number(e.clientX)||0)-swipe.startX;
const dy=(Number(e.clientY)||0)-swipe.startY;
if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;}
if(dx>=_PWA_SIDEBAR_SWIPE_TRIGGER&&Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL&&dx>Math.abs(dy)*1.5){
if(e.cancelable)e.preventDefault();
swipe.opened=true;
_openMobileSidebarFromGesture();
}
}
function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;}
function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;}
function _installPwaSidebarSwipeGesture(){
window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true});
window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false});
window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true});
window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel, {passive:true});
}
_installPwaSidebarSwipeGesture();
// ── Desktop sidebar collapse toggle ────────────────────────────────────────
// Two discoverability paths into the same state:
// (1) Click the already-active rail icon → collapse / expand the sidebar.
+147 -4
View File
@@ -1101,6 +1101,19 @@ const LOCALES = {
memory_saved: 'Memory saved',
my_notes: 'My Notes',
user_profile: 'User Profile',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'No notes yet.',
no_profile_yet: 'No profile yet.',
agent_soul: 'Agent Soul',
@@ -2352,6 +2365,19 @@ const LOCALES = {
memory_saved: 'Memoria salvata',
my_notes: 'Le Mie Note',
user_profile: 'Profilo Utente',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'Ancora nessuna nota.',
no_profile_yet: 'Ancora nessun profilo.',
agent_soul: 'Anima dell\'Agente',
@@ -3608,6 +3634,19 @@ const LOCALES = {
memory_saved: 'メモリを保存しました',
my_notes: 'マイノート',
user_profile: 'ユーザープロファイル',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'まだノートはありません。',
no_profile_yet: 'まだプロファイルはありません。',
agent_soul: 'エージェントソウル',
@@ -4597,6 +4636,19 @@ const LOCALES = {
memory_saved: 'Память сохранена',
my_notes: 'Мои заметки',
user_profile: 'Пользовательский профиль',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'Пока нет заметок.',
no_profile_yet: 'Пока нет профиля.',
agent_soul: 'Душа агента',
@@ -5805,6 +5857,19 @@ const LOCALES = {
memory_saved: 'Memory saved',
my_notes: 'My Notes',
user_profile: 'User Profile',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'No notes yet.',
no_profile_yet: 'No profile yet.',
agent_soul: 'Agent Soul',
@@ -7185,6 +7250,19 @@ const LOCALES = {
memory_saved: 'Notiz gespeichert.',
my_notes: 'Meine Notizen',
user_profile: 'Benutzerprofil',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'Noch keine Notizen.',
no_profile_yet: 'Noch kein Profil.',
agent_soul: 'Agenten-Seele',
@@ -7871,10 +7949,10 @@ const LOCALES = {
new_conversation: '新建对话',
filter_conversations: '筛选对话…',
session_time_unknown: '未知',
session_time_minutes_ago: (n) => `${n}`,
session_time_hours_ago: (n) => `${n}小时`,
session_time_days_ago: (n) => `${n}`,
session_time_last_week: '1周',
session_time_minutes_ago: (n) => `${n}钟前`,
session_time_hours_ago: (n) => `${n}小时`,
session_time_days_ago: (n) => `${n}`,
session_time_last_week: '周',
session_time_bucket_today: '今天',
session_time_bucket_yesterday: '昨天',
session_time_bucket_this_week: '本周',
@@ -8179,6 +8257,19 @@ const LOCALES = {
memory_saved: '记忆已保存',
my_notes: '我的备注',
user_profile: '用户画像',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: '暂无备注。',
no_profile_yet: '暂无用户画像。',
agent_soul: '智能体灵魂',
@@ -10720,6 +10811,19 @@ const LOCALES = {
memory_saved: 'Memória salva',
my_notes: 'Minhas Notas',
user_profile: 'Perfil do Usuário',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'Nenhuma nota ainda.',
no_profile_yet: 'Nenhum perfil definido.',
agent_soul: 'Alma do Agente',
@@ -11880,6 +11984,19 @@ const LOCALES = {
memory_saved: 'Memory saved',
my_notes: 'My Notes',
user_profile: 'User Profile',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'No notes yet.',
no_profile_yet: 'No profile yet.',
agent_soul: 'Agent Soul',
@@ -13051,6 +13168,19 @@ const LOCALES = {
memory_saved: 'Mémoire sauvegardée',
my_notes: 'Mes notes',
user_profile: 'Profil utilisateur',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'Aucune note pour l\'instant.',
no_profile_yet: 'Pas encore de profil.',
agent_soul: 'Âme de l\'agent',
@@ -14315,6 +14445,19 @@ const LOCALES = {
memory_saved: 'Bellek kaydedildi',
my_notes: 'Notlarım',
user_profile: 'Kullanıcı Profili',
external_notes_sources: 'Third-party notes',
external_notes_empty: 'No note or knowledge MCP sources are visible yet. Configure Joplin, Obsidian, Notion, llm-wiki, or another notes server to list it here.',
external_notes_auto_recall_hint: 'Automatic session recall is unchanged; this drawer only shows configured sources and available read/search tools.',
external_notes_no_tools: 'No read/search tools are currently visible for this source.',
external_notes_tool_count: (count) => `${count} note tool${Number(count)===1?'':'s'} available`,
external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.',
external_notes_recent_ai: 'Recently used by AI',
external_notes_auto: 'auto',
external_notes_recent_ai_reason: 'Automatic recall',
external_notes_search_placeholder: 'Search notes…',
external_notes_search_empty: 'Search a configured notes source to preview notes here.',
source_active: 'active',
source_configured: 'configured',
no_notes_yet: 'Henüz not yok.',
no_profile_yet: 'Henüz profil yok.',
agent_soul: 'Ajan Ruhu',
+16 -1
View File
@@ -1633,6 +1633,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){}
});
source.addEventListener('context_status',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
if((d.session_id||activeSid)!==activeSid) return;
const prefill=d.prefill||{};
const status=String(prefill.status||'not_configured');
const label=String(prefill.label||'session recall');
if(status==='loaded'){
setComposerStatus(`Context loaded: ${label}`);
}else if(status==='error'){
setComposerStatus(`Context unavailable: ${label}`);
if(typeof showToast==='function') showToast(`Context unavailable: ${String(prefill.error||label)}`,3600,'warning');
}
});
function _resolveGoalMessage(d){
const key=String(d && d.message_key ? d.message_key : '').trim();
const args=Array.isArray(d && d.message_args) ? d.message_args : [];
@@ -2147,7 +2162,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_setActivePaneIdleIfOwner();
});
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
source.addEventListener(_runJournalEventName,_rememberRunJournalCursor);
}
}
+153 -4
View File
@@ -3854,13 +3854,20 @@ async function deleteCurrentSkill() {
// ── Memory (main view) ──
let _memoryData = null;
let _currentMemorySection = null; // 'memory' | 'user' | 'soul'
let _notesSourcesData = null;
let _notesSearchResults = [];
let _notesSelectedSource = 'joplin';
let _notesPreviewNote = null;
let _notesSearchError = '';
let _notesSearchLoading = false;
let _currentMemorySection = null; // 'memory' | 'user' | 'soul' | 'external_notes'
let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit'
const MEMORY_SECTIONS = [
{ key: 'memory', labelKey: 'my_notes', emptyKey: 'no_notes_yet', iconKey: 'brain' },
{ key: 'user', labelKey: 'user_profile', emptyKey: 'no_profile_yet', iconKey: 'user' },
{ key: 'soul', labelKey: 'agent_soul', emptyKey: 'no_soul_yet', iconKey: 'sparkles' },
{ key: 'external_notes', labelKey: 'external_notes_sources', emptyKey: 'external_notes_empty', iconKey: 'book-open' },
];
function _memorySectionMeta(key) {
@@ -3887,12 +3894,84 @@ function _setMemoryHeaderButtons(mode) {
const editBtn = $('btnEditMemoryDetail');
const cancelBtn = $('btnCancelMemoryDetail');
const saveBtn = $('btnSaveMemoryDetail');
if (mode === 'read') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
if (mode === 'read' && _currentMemorySection !== 'external_notes') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
else if (mode === 'edit') { hide(editBtn); show(cancelBtn); show(saveBtn); }
else { hide(editBtn); hide(cancelBtn); hide(saveBtn); }
}
function _renderExternalNotesSources() {
const title = $('memoryDetailTitle');
const body = $('memoryDetailBody');
const empty = $('memoryDetailEmpty');
if (!title || !body) return;
title.textContent = t('external_notes_sources');
const data = _notesSourcesData || {};
const sources = Array.isArray(data.sources) ? data.sources : [];
const recall = data.automatic_recall_unchanged !== false
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
: '';
if (!sources.length) {
body.innerHTML = `<div class="main-view-content">${recall}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
} else {
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
_notesSelectedSource = (selected && selected.name) || 'joplin';
const sourceOptions = sources.map(src => `<option value="${esc(src.name||'')}" ${src.name===_notesSelectedSource?'selected':''}>${esc(src.label||src.name||'')}</option>`).join('');
const recentAiNotes = Array.isArray(data.recent_ai_notes) ? data.recent_ai_notes : [];
const recentAiHtml = recentAiNotes.length
? `<section class="notes-source-card notes-ai-recent-card">
<div class="notes-source-card-head notes-ai-recent-head"><strong>${li('bot', 14)}${esc(t('external_notes_recent_ai'))}</strong><span class="detail-badge">${esc(t('external_notes_auto'))}</span></div>
<div class="notes-ai-recent-list">${recentAiNotes.map(note => {
const updated = note.updated_time ? new Date(Number(note.updated_time)).toLocaleString() : '';
return `<button type="button" class="notes-result-card notes-ai-recent-item" onclick="previewExternalNote('${esc(note.source||'joplin')}','${esc(note.id||'')}')"><strong>${esc(note.title||note.label||'Untitled')}</strong><span>${li('clock', 14)}${esc(note.label||t('external_notes_recent_ai_reason'))}${updated ? ` · ${esc(updated)}` : ''}</span></button>`;
}).join('')}</div>
</section>`
: '';
const searchError = _notesSearchError ? `<div class="detail-form-error">${esc(_notesSearchError)}</div>` : '';
const resultHtml = _notesSearchResults.length
? `<div class="notes-search-results">${_notesSearchResults.map(note => `<button type="button" class="notes-result-card" onclick="previewExternalNote('${esc(note.source||_notesSelectedSource)}','${esc(note.id||'')}')"><strong>${esc(note.title||'Untitled')}</strong>${note.snippet?`<span>${esc(note.snippet)}</span>`:''}</button>`).join('')}</div>`
: `<div class="memory-empty">${esc(t('external_notes_search_empty'))}</div>`;
const previewHtml = _notesPreviewNote
? `<section class="notes-source-card notes-preview-card"><div class="notes-source-card-head"><strong>${esc(_notesPreviewNote.title||'Untitled')}</strong><span class="detail-badge">${esc(_notesPreviewNote.source||_notesSelectedSource)}</span></div><div class="memory-content preview-md">${renderMd(_notesPreviewNote.body||'')}</div></section>`
: '';
const cards = sources.map(src => {
const status = src.active ? t('source_active') : (src.status || t('source_configured'));
const tools = Array.isArray(src.tools) ? src.tools : [];
const hintHtml = src.tool_source === 'configured_hint'
? `<div class="memory-detail-mtime">${esc(t('external_notes_configured_hint'))}</div>`
: '';
const toolHtml = tools.length
? `<ul class="notes-source-tools">${tools.map(tool => `<li><strong>${esc(tool.name||'')}</strong>${tool.description?`${esc(tool.description)}`:''}</li>`).join('')}</ul>`
: `<div class="memory-empty">${esc(t('external_notes_no_tools'))}</div>`;
return `<section class="notes-source-card">
<div class="notes-source-card-head"><strong>${esc(src.label||src.name||'')}</strong><span class="detail-badge ${src.active?'active':''}">${esc(status)}</span></div>
<div class="memory-detail-mtime">${esc(t('external_notes_tool_count', src.tool_count||0))}</div>
${hintHtml}
${toolHtml}
</section>`;
}).join('');
const searchUi = `<section class="notes-source-card notes-search-card">
<form class="notes-search-form" onsubmit="event.preventDefault(); searchExternalNotes();">
<select id="externalNotesSource" onchange="selectExternalNotesSource(this.value)">${sourceOptions}</select>
<input id="externalNotesQuery" type="search" placeholder="${esc(t('external_notes_search_placeholder'))}" />
<button type="submit" class="btn-secondary">${esc(_notesSearchLoading ? t('loading') : t('search'))}</button>
</form>
${searchError}
${resultHtml}
</section>`;
body.innerHTML = `<div class="main-view-content">${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
}
body.style.display = '';
if (empty) empty.style.display = 'none';
_memoryMode = 'read';
_setMemoryHeaderButtons('read');
}
function _renderMemoryDetail(section) {
if (section === 'external_notes') {
_renderExternalNotesSources();
return;
}
const meta = _memorySectionMeta(section);
const title = $('memoryDetailTitle');
const body = $('memoryDetailBody');
@@ -3939,15 +4018,78 @@ function _renderMemoryEdit(section) {
if (ta) ta.focus();
}
function openMemorySection(section, el) {
async function loadNotesSources(force) {
if (_notesSourcesData && !force) return _notesSourcesData;
try {
_notesSourcesData = await api('/api/notes/sources');
} catch (e) {
_notesSourcesData = {sources: [], automatic_recall_unchanged: true, error: e && e.message ? e.message : String(e)};
}
return _notesSourcesData;
}
function selectExternalNotesSource(source) {
_notesSelectedSource = source || 'joplin';
_notesSearchResults = [];
_notesPreviewNote = null;
_notesSearchError = '';
_renderExternalNotesSources();
}
async function searchExternalNotes() {
const input = $('externalNotesQuery');
const sourceEl = $('externalNotesSource');
const q = input ? input.value.trim() : '';
_notesSelectedSource = sourceEl ? sourceEl.value : (_notesSelectedSource || 'joplin');
_notesPreviewNote = null;
_notesSearchError = '';
if (!q) {
_notesSearchResults = [];
_renderExternalNotesSources();
return;
}
_notesSearchLoading = true;
_renderExternalNotesSources();
try {
const data = await api(`/api/notes/search?source=${encodeURIComponent(_notesSelectedSource)}&q=${encodeURIComponent(q)}&limit=20`);
_notesSearchResults = Array.isArray(data.results) ? data.results : [];
_notesSearchError = data.error || '';
} catch (e) {
_notesSearchResults = [];
_notesSearchError = e && e.message ? e.message : String(e);
} finally {
_notesSearchLoading = false;
_renderExternalNotesSources();
const nextInput = $('externalNotesQuery');
if (nextInput) nextInput.value = q;
}
}
async function previewExternalNote(source, id) {
_notesSearchError = '';
try {
const data = await api(`/api/notes/item?source=${encodeURIComponent(source||_notesSelectedSource)}&id=${encodeURIComponent(id||'')}`);
_notesPreviewNote = data && data.note ? data.note : null;
} catch (e) {
_notesPreviewNote = null;
_notesSearchError = e && e.message ? e.message : String(e);
}
_renderExternalNotesSources();
}
async function openMemorySection(section, el) {
if (section === 'external_notes' && _memoryData && !_memoryData.external_notes_enabled) return;
_currentMemorySection = section;
document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active'));
if (el) el.classList.add('active');
if (section === 'external_notes') {
await loadNotesSources(false);
}
_renderMemoryDetail(section);
}
function editCurrentMemory() {
if (!_currentMemorySection) return;
if (!_currentMemorySection || _currentMemorySection === 'external_notes') return;
_renderMemoryEdit(_currentMemorySection);
}
@@ -5328,9 +5470,16 @@ async function loadMemory(force) {
try {
const data = await api('/api/memory');
_memoryData = data;
if (_currentMemorySection === 'external_notes' && !data.external_notes_enabled) {
_currentMemorySection = null;
}
if (_currentMemorySection === 'external_notes') {
await loadNotesSources(!!force);
}
if (panel) {
panel.innerHTML = '';
for (const s of MEMORY_SECTIONS) {
if (s.key === 'external_notes' && !_memoryData.external_notes_enabled) continue;
const el = document.createElement('button');
el.type = 'button';
el.className = 'side-menu-item';
+18
View File
@@ -1166,6 +1166,24 @@
.memory-content p{margin-bottom:6px;}
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
.memory-detail-mtime{font-size:11px;color:var(--muted);opacity:.7;margin-bottom:12px;}
.notes-source-card{border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.03);padding:14px 16px;margin:0 0 12px;}
.notes-source-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:4px;}
.notes-source-tools{margin:10px 0 0 18px;padding:0;color:var(--muted);font-size:12px;line-height:1.55;}
.notes-search-card{gap:12px;}
.notes-search-form{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.notes-search-form select,.notes-search-form input{min-height:34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);padding:6px 10px;}
.notes-search-form input{flex:1;min-width:220px;}
.notes-search-form button{min-height:34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer;transition:border-color .15s,color .15s,background .15s;}
.notes-search-form button:hover{border-color:var(--accent);color:var(--accent);background:var(--panel-soft);}
.notes-search-form button:disabled{opacity:.55;cursor:default;}
.notes-search-results{display:grid;gap:8px;}
.notes-ai-recent-list{display:grid;gap:8px;margin-top:10px;}
.notes-ai-recent-head strong{display:inline-flex;align-items:center;gap:7px;}
.notes-ai-recent-item span{display:inline-flex;align-items:center;gap:6px;}
.notes-result-card{display:grid;gap:4px;width:100%;text-align:left;border:1px solid var(--border);border-radius:12px;background:var(--panel-soft);color:var(--text);padding:10px 12px;cursor:pointer;}
.notes-result-card:hover{border-color:var(--accent);}
.notes-result-card span{color:var(--muted);font-size:.9rem;}
.notes-preview-card .memory-content{max-height:420px;overflow:auto;}
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:8px 28px 8px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
select:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg);}
+12
View File
@@ -112,6 +112,18 @@ class TestLoadRepoDotenv:
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_preserve_existing_env_keeps_ctl_overrides(self, tmp_path):
"""ctl.sh can ask bootstrap.py to keep wrapper-provided env values."""
os.environ["HERMES_WEBUI_PRESERVE_ENV"] = "1"
os.environ["HERMES_HOME"] = "/runtime/hermesOne"
os.environ["HERMES_WEBUI_PASSWORD"] = ""
self._run(
tmp_path,
"HERMES_HOME=/repo/default\nHERMES_WEBUI_PASSWORD=repo-password\n",
)
assert os.environ.get("HERMES_HOME") == "/runtime/hermesOne"
assert os.environ.get("HERMES_WEBUI_PASSWORD") == ""
def test_does_not_set_empty_values(self, tmp_path):
"""A key whose value is empty after stripping is not set to a non-empty string."""
os.environ.pop("HERMES_EMPTY_KEY", None)
+7
View File
@@ -138,6 +138,13 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs(
assert not pid_file.exists()
def test_start_uses_nohup_so_daemon_survives_launcher_exit():
ctl_text = CTL.read_text(encoding="utf-8")
assert "trap '' HUP" in ctl_text
assert 'exec nohup "${python_exe}"' in ctl_text
def test_start_loads_dotenv_but_inline_overrides_win(tmp_path):
repo_root = tmp_path / "repo"
repo_root.mkdir()
+23 -98
View File
@@ -1,123 +1,48 @@
"""Test that new_session() reads display.personality from config and uses it as default.
"""Regression coverage for display.personality not becoming durable session state.
Regression test for the feature that makes /personality taleb sticky across
new sessions when display.personality is set in config.yaml, every new
session should inherit it without requiring an explicit /personality command.
Issue #2845: display.personality is a display/default hint, but new_session()
previously copied it into Session.personality. That made cosmetic config durable
per-session state and could override profile-scoped behavior later. Only an
explicit /api/personality/set call should persist Session.personality.
"""
import pytest
from unittest.mock import patch
# ---------------------------------------------------------------------------
# R1: new_session() inherits display.personality from config
# ---------------------------------------------------------------------------
def test_new_session_reads_default_personality_from_config():
"""When display.personality is set to 'taleb', new_session() should
create a Session with personality='taleb'."""
def test_new_session_does_not_inherit_display_personality_from_config():
"""display.personality='taleb' must not stamp Session.personality."""
import api.models as m
import api.config as cfg_mod
_cfg = {
cfg = {
"display": {"personality": "taleb"},
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb", "tone": "blunt"}}},
}
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
with patch.object(cfg_mod, "get_config", return_value=cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality")
assert s.personality == "taleb", (
f"Expected personality='taleb', got {s.personality!r}"
)
try:
assert s.personality is None
finally:
with m.LOCK:
m.SESSIONS.pop(s.session_id, None)
# ---------------------------------------------------------------------------
# R2: 'none', 'default', 'neutral' are treated as no personality
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("personality_value", ["none", "default", "neutral", ""])
def test_new_session_ignores_neutral_personality_values(personality_value):
"""Values like 'none', 'default', 'neutral', and '' should NOT be set as
the session personality they mean 'no personality overlay'."""
def test_new_session_still_defaults_to_no_personality_when_config_missing():
"""Missing display.personality continues to produce personality=None."""
import api.models as m
import api.config as cfg_mod
_cfg = {
"display": {"personality": personality_value},
"agent": {"personalities": {}},
}
cfg = {"agent": {"personalities": {}}}
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-neutral")
assert s.personality is None, (
f"Expected None for display.personality={personality_value!r}, "
f"got {s.personality!r}"
)
# ---------------------------------------------------------------------------
# R3: Missing display.personality → personality=None
# ---------------------------------------------------------------------------
def test_new_session_no_personality_when_config_missing():
"""When config has no display.personality (or display section is absent),
new_session() should set personality=None."""
import api.models as m
import api.config as cfg_mod
_cfg = {"agent": {"personalities": {}}} # No display section at all
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
with patch.object(cfg_mod, "get_config", return_value=cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-missing")
assert s.personality is None
# ---------------------------------------------------------------------------
# R4: Config exception is handled gracefully → personality=None
# ---------------------------------------------------------------------------
def test_new_session_handles_config_exception_gracefully():
"""If get_config() raises, we should still get a valid session with
personality=None (the try/except should swallow the error)."""
import api.models as m
import api.config as cfg_mod
def _boom():
raise RuntimeError("config exploded")
with patch.object(cfg_mod, "get_config", side_effect=_boom), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-boom")
assert s.personality is None
# ---------------------------------------------------------------------------
# R5: display.personality is case-insensitive
# ---------------------------------------------------------------------------
def test_new_session_personality_is_case_insensitive():
"""display.personality='Taleb' should be normalized to 'taleb'."""
import api.models as m
import api.config as cfg_mod
_cfg = {
"display": {"personality": "Taleb"},
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb"}}},
}
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-case")
assert s.personality == "taleb"
try:
assert s.personality is None
finally:
with m.LOCK:
m.SESSIONS.pop(s.session_id, None)
+18
View File
@@ -290,6 +290,24 @@ def test_new_session_uses_explicit_profile_default_model_and_provider(tmp_path,
m.SESSIONS.pop(s.session_id, None)
def test_new_session_does_not_persist_display_personality(monkeypatch, tmp_path):
"""display.personality is a UI/default hint, not durable per-session state."""
import api.config as c
import api.models as m
monkeypatch.setattr(c, "get_config", lambda: {"display": {"personality": "kawaii"}})
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session(workspace=str(tmp_path), profile="default")
try:
assert s.personality is None, (
"new_session() must not stamp display.personality into the persistent "
"Session.personality field; only explicit /api/personality/set should."
)
finally:
with m.LOCK:
m.SESSIONS.pop(s.session_id, None)
def test_get_hermes_home_for_profile_rejects_path_traversal():
"""R19j: get_hermes_home_for_profile() must reject names that don't match
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base
+43
View File
@@ -0,0 +1,43 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
def test_pwa_edge_swipe_gesture_is_registered_for_mobile_sidebar():
assert "function _installPwaSidebarSwipeGesture" in BOOT_JS
assert "window.addEventListener('pointerdown', _onPwaSidebarSwipeStart" in BOOT_JS
assert "window.addEventListener('pointermove', _onPwaSidebarSwipeMove" in BOOT_JS
assert "window.addEventListener('pointerup', _onPwaSidebarSwipeEnd" in BOOT_JS
assert "window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel" in BOOT_JS
def test_pwa_sidebar_swipe_is_edge_gated_standalone_and_horizontal():
assert "_isPwaStandalone()" in BOOT_JS
assert "_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS
assert "_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS
assert "_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS
assert "clientX>_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS.replace(" ", "")
assert "dx>=_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS.replace(" ", "")
assert "Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS.replace(" ", "")
assert "dx>Math.abs(dy)*1.5" in BOOT_JS.replace(" ", "")
assert "input,textarea,select,button,a,[contenteditable=\"true\"],.topbar-chips,.composer-left,.sidebar,.rightpanel" in BOOT_JS
assert ".messages" not in BOOT_JS[BOOT_JS.find("function _isInteractiveSwipeTarget"):BOOT_JS.find("function _openMobileSidebarFromGesture")]
def test_pwa_sidebar_swipe_opens_existing_mobile_drawer_without_desktop_collapse():
assert "_openMobileSidebarFromGesture" in BOOT_JS
assert "sidebar.classList.remove('sidebar-collapsed')" in BOOT_JS
assert "sidebar.classList.add('mobile-open')" in BOOT_JS
assert "overlay.classList.add('visible')" in BOOT_JS
assert "toggleSidebar(" not in BOOT_JS[BOOT_JS.find("function _openMobileSidebarFromGesture"):BOOT_JS.find("function _installPwaSidebarSwipeGesture")]
def test_pwa_sidebar_swipe_does_not_disable_horizontal_scrollers_globally():
compact = STYLE_CSS.replace(" ", "")
assert "html{touch-action" not in compact
assert "body{touch-action" not in compact
assert ".layout{touch-action" not in compact
+2 -1
View File
@@ -389,7 +389,8 @@ class TestRuntimeRouteInjection(unittest.TestCase):
init_kwargs = captured["init_kwargs"]
self.assertIsNotNone(init_kwargs["interim_assistant_callback"])
self.assertTrue(callable(init_kwargs["interim_assistant_callback"]))
self.assertIn("WebUI progress contract", captured["agent"].ephemeral_system_prompt)
self.assertIn("WebUI progress guidance", captured["agent"].ephemeral_system_prompt)
self.assertIn("Match the normal Hermes messaging style", captured["agent"].ephemeral_system_prompt)
self.assertIn("user-visible progress updates", captured["agent"].ephemeral_system_prompt)
interim_events = []
+202
View File
@@ -0,0 +1,202 @@
"""Regression tests for WebUI notes source discovery."""
from __future__ import annotations
def test_notes_sources_identifies_note_or_knowledge_mcp_servers():
from api.routes import _notes_sources_from_mcp_inventory
servers = {
"joplin": {"name": "joplin", "enabled": True, "active": True, "status": "healthy"},
"filesystem": {"name": "filesystem", "enabled": True, "active": True, "status": "healthy"},
"llm-wiki": {"name": "llm-wiki", "enabled": True, "active": False, "status": "configured"},
}
tools = [
{"server": "joplin", "name": "search_notes", "description": "Search notes by keyword"},
{"server": "joplin", "name": "get_note", "description": "Get full note content"},
{"server": "filesystem", "name": "read_text_file", "description": "Read files"},
{"server": "llm-wiki", "name": "query_knowledge_base", "description": "Search wiki knowledge"},
]
sources = _notes_sources_from_mcp_inventory(servers, tools)
assert [source["name"] for source in sources] == ["joplin", "llm-wiki"]
assert sources[0]["label"] == "Joplin"
assert sources[0]["tool_count"] == 2
assert sources[0]["active"] is True
assert sources[1]["active"] is False
def test_notes_sources_redacts_tool_descriptions_and_omits_plain_file_tools():
from api.routes import _notes_sources_from_mcp_inventory
servers = {"notion": {"name": "notion", "enabled": True, "active": True, "status": "healthy"}}
tools = [
{"server": "notion", "name": "search_pages", "description": "Search notes api_key=redaction-test-placeholder"},
]
[source] = _notes_sources_from_mcp_inventory(servers, tools)
assert source["name"] == "notion"
assert "token" not in source["tools"][0]["description"].lower()
assert "[REDACTED]" in source["tools"][0]["description"]
def test_notes_sources_shows_configured_note_servers_without_tool_inventory():
from api.routes import _notes_sources_from_mcp_inventory
servers = {
"joplin": {"name": "joplin", "enabled": True, "active": False, "status": "configured"},
"filesystem": {"name": "filesystem", "enabled": True, "active": True, "status": "healthy"},
}
sources = _notes_sources_from_mcp_inventory(servers, [])
assert [source["name"] for source in sources] == ["joplin"]
assert sources[0]["label"] == "Joplin"
assert sources[0]["tool_count"] == 3
assert [tool["name"] for tool in sources[0]["tools"]] == ["search_notes", "list_notes", "get_note"]
assert all(tool.get("inferred") is True for tool in sources[0]["tools"])
assert sources[0]["tool_source"] == "configured_hint"
assert sources[0]["status"] == "configured"
def test_external_notes_sources_drawer_is_default_off(monkeypatch):
from api import routes
monkeypatch.delenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", raising=False)
assert routes._external_notes_sources_enabled({}) is False
assert routes._external_notes_sources_enabled({"webui_external_notes_sources": False}) is False
def test_external_notes_sources_drawer_can_be_enabled_by_config_or_env(monkeypatch):
from api import routes
monkeypatch.delenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", raising=False)
assert routes._external_notes_sources_enabled({"webui_external_notes_sources": True}) is True
assert routes._external_notes_sources_enabled({"external_notes_sources": "yes"}) is True
monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1")
assert routes._external_notes_sources_enabled({}) is True
def test_joplin_search_notes_returns_safe_snippets(monkeypatch):
from api import routes
def fake_get(path, params=None):
assert path == "/search"
assert params["type"] == "note"
return {"items": [{
"id": "abc123def4567890",
"title": "Hermes Context",
"body": "This is a long Hermes context note with useful details.",
"parent_id": "folder123",
"updated_time": 123,
}]}
monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
results = routes._joplin_search_notes("Hermes")
assert results == [{
"id": "abc123def4567890",
"title": "Hermes Context",
"snippet": "This is a long Hermes context note with useful details.",
"parent_id": "folder123",
"updated_time": 123,
"source": "joplin",
}]
def test_joplin_get_note_validates_id_and_truncates_body(monkeypatch):
from api import routes
def fake_get(path, params=None):
assert path == "/notes/abc123def4567890"
return {
"id": "abc123def4567890",
"title": "Big Note",
"body": "x" * 60000,
"parent_id": "folder123",
"updated_time": 456,
"created_time": 123,
}
monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
note = routes._joplin_get_note("abc123def4567890")
assert note["title"] == "Big Note"
assert note["source"] == "joplin"
assert len(note["body"]) < 51000
assert "Preview truncated" in note["body"]
def test_joplin_recent_ai_notes_uses_configured_prefill_script(monkeypatch, tmp_path):
from api import routes
script = tmp_path / "joplin_context.py"
script.write_text(
'\n'.join([
'CURRENT_CONTEXT_ID = "5ba9ab822c344115939205ca4e8eaec0"',
'OPEN_ISSUES_ID = "623aeb6e55cb4aa39a0541f2ac09aa36"',
'AGENT_MEMORY_ID = "0a7a232ea46b4b8bb0bbd4358f725a84"',
'RAW_CAPTURES_ID = "cb1087795c7d4129a863ab0a5642233d"',
]),
encoding="utf-8",
)
monkeypatch.setattr(routes, "get_config", lambda: {"prefill_messages_script": str(script)})
def fake_get(path, params=None):
note_id = path.rsplit("/", 1)[-1]
titles = {
"5ba9ab822c344115939205ca4e8eaec0": "Current Context",
"623aeb6e55cb4aa39a0541f2ac09aa36": "Open Issues",
"0a7a232ea46b4b8bb0bbd4358f725a84": "Agent Memory",
}
assert note_id in titles
return {"id": note_id, "title": titles[note_id], "updated_time": 123, "parent_id": "folder"}
monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
notes = routes._joplin_recent_ai_notes(limit=3)
assert [note["title"] for note in notes] == ["Current Context", "Open Issues", "Agent Memory"]
assert all(note["source"] == "joplin" for note in notes)
assert all(note["used_by"] == "ai_prefill" for note in notes)
assert all(note["used_reason"] == "automatic_recall" for note in notes)
def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes():
from pathlib import Path
panels = Path("static/panels.js").read_text(encoding="utf-8")
start = panels.index("function _renderExternalNotesSources()")
end = panels.index("function _renderMemoryDetail", start)
notes_block = panels[start:end]
assert "notes-ai-recent-card" in notes_block
assert "li('bot', 14)" in notes_block
assert "li('clock', 14)" in notes_block
assert "Recently used by AI" not in notes_block # i18n key, not hard-coded UI copy
assert "🤖" not in notes_block
assert "📚" not in notes_block
def test_external_notes_menu_item_is_default_off_from_memory_payload():
from pathlib import Path
panels = Path("static/panels.js").read_text(encoding="utf-8")
assert "external_notes_enabled" in panels
assert "if (s.key === 'external_notes' && !_memoryData.external_notes_enabled) continue;" in panels
def test_external_notes_search_button_matches_minimal_dark_controls():
from pathlib import Path
css = Path("static/style.css").read_text(encoding="utf-8")
assert ".notes-search-form button" in css
button_block = css[css.index(".notes-search-form button"):css.index(".notes-search-form button:hover")]
assert "background:var(--panel)" in button_block or "background:var(--surface)" in button_block
assert "border:1px solid var(--border)" in button_block
assert "color:var(--text)" in button_block
assert "border-radius:10px" in button_block
+80
View File
@@ -0,0 +1,80 @@
"""Regression tests for WebUI session prefill parity."""
from __future__ import annotations
import json
def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path):
from api.streaming import _load_webui_prefill_context
prefill = tmp_path / "prefill.json"
prefill.write_text(
json.dumps(
[
{"role": "user", "content": "Pinned context"},
{"role": "tool", "content": "drop invalid role"},
{"role": "assistant", "content": "Useful assistant context"},
{"role": "system", "content": " "},
"not a message",
]
),
encoding="utf-8",
)
result = _load_webui_prefill_context({"prefill_messages_file": str(prefill)})
assert result["status"] == "loaded"
assert result["source"] == "file"
assert result["label"] == "prefill.json"
assert result["messages"] == [
{"role": "user", "content": "Pinned context"},
{"role": "assistant", "content": "Useful assistant context"},
]
def test_prefill_script_config_is_ignored_in_webui(tmp_path):
from api.streaming import _load_webui_prefill_context
script = tmp_path / "recall.py"
script.write_text("raise SystemExit('should not run')\n", encoding="utf-8")
result = _load_webui_prefill_context({"prefill_messages_script": str(script)})
assert result == {
"status": "not_configured",
"source": "none",
"label": "",
"messages": [],
"message_count": 0,
}
def test_public_prefill_status_strips_message_bodies():
from api.streaming import _public_prefill_context_status
public = _public_prefill_context_status(
{
"status": "loaded",
"source": "file",
"label": "prefill.json",
"message_count": 1,
"messages": [{"role": "user", "content": "private recall payload"}],
}
)
assert public == {
"status": "loaded",
"source": "file",
"label": "prefill.json",
"message_count": 1,
}
assert "messages" not in public
def test_prefill_status_redactor_handles_secret_shaped_text():
from api.streaming import _redact_prefill_status_text
redacted = _redact_prefill_status_text("api_key=redaction-test-placeholder leaked")
assert "redaction-test-placeholder" not in redacted
assert "[REDACTED]" in redacted
+39
View File
@@ -0,0 +1,39 @@
from api.streaming import _webui_ephemeral_system_prompt
def test_webui_ephemeral_prompt_includes_browser_surface_context():
prompt = _webui_ephemeral_system_prompt(
"Use a concise tone.",
surface_context={
"source": "webui",
"session_id": "session-123",
"profile": "default",
"workspace": "/tmp/example-workspace",
},
)
assert "Use a concise tone." in prompt
assert "WebUI session context" in prompt
assert "Source: webui" in prompt
assert "Session ID: session-123" in prompt
assert "Profile: default" in prompt
assert "Workspace: /tmp/example-workspace" in prompt
assert "not the same live transcript as Telegram" in prompt
def test_webui_ephemeral_prompt_skips_empty_surface_fields():
prompt = _webui_ephemeral_system_prompt(
None,
surface_context={
"source": "webui",
"session_id": "",
"profile": None,
"workspace": " ",
},
)
assert "WebUI session context" in prompt
assert "Source: webui" in prompt
assert "Session ID:" not in prompt
assert "Profile:" not in prompt
assert "Workspace:" not in prompt