feat(memory): gate third-party notes drawer

This commit is contained in:
AJV20
2026-05-22 14:54:41 -04:00
parent 42a6cf38ea
commit 7305d470b9
4 changed files with 73 additions and 1 deletions
+1 -1
View File
@@ -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)
+39
View File
@@ -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()
+5
View File
@@ -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';
+28
View File
@@ -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