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 + ? `