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 + ? `
${esc(t('external_notes_auto_recall_hint'))}
` + : ''; + if (!sources.length) { + body.innerHTML = `
${recall}
${esc(t('external_notes_empty'))}
`; + } else { + 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 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))}
+ ${toolHtml} +
`; + }).join(''); + body.innerHTML = `
${recall}${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'); @@ -3648,15 +3689,28 @@ 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; +} + +async function openMemorySection(section, el) { _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); } @@ -4997,6 +5051,9 @@ async function loadMemory(force) { try { const data = await api('/api/memory'); _memoryData = data; + if (_currentMemorySection === 'external_notes') { + await loadNotesSources(!!force); + } if (panel) { panel.innerHTML = ''; for (const s of MEMORY_SECTIONS) { diff --git a/static/style.css b/static/style.css index 7a594b90..c56ee2e9 100644 --- a/static/style.css +++ b/static/style.css @@ -903,6 +903,9 @@ .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;} .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_webui_notes_sources.py b/tests/test_webui_notes_sources.py new file mode 100644 index 00000000..ee5112bf --- /dev/null +++ b/tests/test_webui_notes_sources.py @@ -0,0 +1,41 @@ +"""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 token=abc123SECRET"}, + ] + + [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"]