diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b692fc4..a1f805e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/models.py b/api/models.py index 3c8d895d..0a734bc5 100644 --- a/api/models.py +++ b/api/models.py @@ -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, diff --git a/api/routes.py b/api/routes.py index 2b11e118..b2aef7f7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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() diff --git a/api/streaming.py b/api/streaming.py index cd6cb960..d319d9d7 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 diff --git a/bootstrap.py b/bootstrap.py index 92d08245..a1b7ea62 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -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 diff --git a/ctl.sh b/ctl.sh index 132a146b..bba8c58f 100755 --- a/ctl.sh +++ b/ctl.sh @@ -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=$! diff --git a/static/boot.js b/static/boot.js index 5a7a21de..e32d11d3 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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. diff --git a/static/i18n.js b/static/i18n.js index d103d996..35adf026 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/messages.js b/static/messages.js index 1a4988d6..f2cb3299 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); } } diff --git a/static/panels.js b/static/panels.js index 5530e05d..bc6bc965 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 + ? `
${esc(t('external_notes_auto_recall_hint'))}
` + : ''; + if (!sources.length) { + body.innerHTML = `
${recall}
${esc(t('external_notes_empty'))}
`; + } else { + const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0]; + _notesSelectedSource = (selected && selected.name) || 'joplin'; + const sourceOptions = sources.map(src => ``).join(''); + const recentAiNotes = Array.isArray(data.recent_ai_notes) ? data.recent_ai_notes : []; + const recentAiHtml = recentAiNotes.length + ? `
+
${li('bot', 14)}${esc(t('external_notes_recent_ai'))}${esc(t('external_notes_auto'))}
+
${recentAiNotes.map(note => { + const updated = note.updated_time ? new Date(Number(note.updated_time)).toLocaleString() : ''; + return ``; + }).join('')}
+
` + : ''; + const searchError = _notesSearchError ? `
${esc(_notesSearchError)}
` : ''; + const resultHtml = _notesSearchResults.length + ? `
${_notesSearchResults.map(note => ``).join('')}
` + : `
${esc(t('external_notes_search_empty'))}
`; + const previewHtml = _notesPreviewNote + ? `
${esc(_notesPreviewNote.title||'Untitled')}${esc(_notesPreviewNote.source||_notesSelectedSource)}
${renderMd(_notesPreviewNote.body||'')}
` + : ''; + 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' + ? `
${esc(t('external_notes_configured_hint'))}
` + : ''; + const toolHtml = tools.length + ? `` + : `
${esc(t('external_notes_no_tools'))}
`; + return `
+
${esc(src.label||src.name||'')}${esc(status)}
+
${esc(t('external_notes_tool_count', src.tool_count||0))}
+ ${hintHtml} + ${toolHtml} +
`; + }).join(''); + const searchUi = `
+
+ + + +
+ ${searchError} + ${resultHtml} +
`; + body.innerHTML = `
${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}
`; + } + 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'; diff --git a/static/style.css b/static/style.css index 80793360..b658d1fa 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/tests/test_bootstrap_dotenv.py b/tests/test_bootstrap_dotenv.py index da8715bd..2f354d1f 100644 --- a/tests/test_bootstrap_dotenv.py +++ b/tests/test_bootstrap_dotenv.py @@ -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) diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index 95134fc9..526d5ded 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -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() diff --git a/tests/test_default_personality.py b/tests/test_default_personality.py index 28169a0a..98d4c920 100644 --- a/tests/test_default_personality.py +++ b/tests/test_default_personality.py @@ -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" \ No newline at end of file + try: + assert s.personality is None + finally: + with m.LOCK: + m.SESSIONS.pop(s.session_id, None) diff --git a/tests/test_issue798.py b/tests/test_issue798.py index 5f5fd6a1..7957570e 100644 --- a/tests/test_issue798.py +++ b/tests/test_issue798.py @@ -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 diff --git a/tests/test_pwa_sidebar_swipe.py b/tests/test_pwa_sidebar_swipe.py new file mode 100644 index 00000000..4c8b4b5c --- /dev/null +++ b/tests/test_pwa_sidebar_swipe.py @@ -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 diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index d85f9e42..1b6230a2 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -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 = [] diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py new file mode 100644 index 00000000..bcf8c8fe --- /dev/null +++ b/tests/test_webui_notes_sources.py @@ -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 diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py new file mode 100644 index 00000000..06a18e0c --- /dev/null +++ b/tests/test_webui_prefill_context.py @@ -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 diff --git a/tests/test_webui_surface_context.py b/tests/test_webui_surface_context.py new file mode 100644 index 00000000..d50dc0f8 --- /dev/null +++ b/tests/test_webui_surface_context.py @@ -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