diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8aa945..74cbf6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added -- Add a read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki, while explicitly leaving automatic session recall unchanged. +- Add a default-off, read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged. ## [v0.51.115] — 2026-05-22 — Release CM (stage-pr2731 — 1-PR — clarify prompt collapse/expand with chevron-icon polish) diff --git a/api/routes.py b/api/routes.py index d53f90c0..ef4a16d6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7878,6 +7878,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(), }, ) @@ -11558,6 +11559,29 @@ 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", @@ -11707,6 +11731,16 @@ def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) 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 = {} @@ -11721,6 +11755,7 @@ def _handle_notes_sources_list(handler): 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", @@ -11936,6 +11971,8 @@ def _joplin_recent_ai_notes(*, limit: int = 6) -> list[dict]: 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() @@ -11952,6 +11989,8 @@ def _handle_notes_search(handler, parsed): 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() diff --git a/static/panels.js b/static/panels.js index 1b2adf1f..dbada1b3 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3837,6 +3837,7 @@ async function previewExternalNote(source, id) { } 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'); @@ -5228,12 +5229,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/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 7c1b5174..bcf8c8fe 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -60,6 +60,26 @@ def test_notes_sources_shows_configured_note_servers_without_tool_inventory(): 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 @@ -162,6 +182,14 @@ def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes(): 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