From 1f3b7aa2c31a2ecf2ec8b26819d52899b69fb511 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 09:28:11 -0400 Subject: [PATCH] feat(memory): show third-party notes sources --- CHANGELOG.md | 4 ++ api/routes.py | 103 ++++++++++++++++++++++++++++++ static/i18n.js | 7 ++ static/panels.js | 65 +++++++++++++++++-- static/style.css | 3 + tests/test_webui_notes_sources.py | 41 ++++++++++++ 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 tests/test_webui_notes_sources.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e938441..94cfda89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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. + ## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index) ### Changed diff --git a/api/routes.py b/api/routes.py index 3cf867ef..fa70f90c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4350,6 +4350,9 @@ 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) + # ── Checkpoints / Rollback (GET) ── if parsed.path == "/api/rollback/list": qs = parse_qs(parsed.query) @@ -10531,6 +10534,106 @@ def _handle_mcp_tools_list(handler): }) +_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", +} + + +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 _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) -> list[dict]: + """Build a safe notes/knowledge-source inventory from MCP servers/tools.""" + 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) + + 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 = [] + 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, + }) + 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(tool_rows), + "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() + 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, { + "sources": _notes_sources_from_mcp_inventory(server_summaries, tools), + "source": source, + "inventory_scope": "already_known_runtime_only", + "attach_supported": False, + "automatic_recall_unchanged": True, + }) + + def _handle_mcp_servers_list(handler): """List configured MCP servers with safe, read-only runtime visibility.""" cfg = get_config() diff --git a/static/i18n.js b/static/i18n.js index e1ad98e3..095a69f9 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1055,6 +1055,13 @@ 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`, + source_active: 'active', + source_configured: 'configured', no_notes_yet: 'No notes yet.', no_profile_yet: 'No profile yet.', agent_soul: 'Agent Soul', diff --git a/static/panels.js b/static/panels.js index d52439f1..586e9e37 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3563,13 +3563,15 @@ async function deleteCurrentSkill() { // ── Memory (main view) ── let _memoryData = null; -let _currentMemorySection = null; // 'memory' | 'user' | 'soul' +let _notesSourcesData = null; +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) { @@ -3596,12 +3598,51 @@ 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 + ? `