feat(memory): show third-party notes sources

This commit is contained in:
AJV20
2026-05-18 09:28:11 -04:00
parent e6be01c4dd
commit 1f3b7aa2c3
6 changed files with 219 additions and 4 deletions
+4
View File
@@ -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
+103
View File
@@ -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()
+7
View File
@@ -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',
+61 -4
View File
@@ -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
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
: '';
if (!sources.length) {
body.innerHTML = `<div class="main-view-content">${recall}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
} 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
? `<ul class="notes-source-tools">${tools.map(tool => `<li><strong>${esc(tool.name||'')}</strong>${tool.description?`${esc(tool.description)}`:''}</li>`).join('')}</ul>`
: `<div class="memory-empty">${esc(t('external_notes_no_tools'))}</div>`;
return `<section class="notes-source-card">
<div class="notes-source-card-head"><strong>${esc(src.label||src.name||'')}</strong><span class="detail-badge ${src.active?'active':''}">${esc(status)}</span></div>
<div class="memory-detail-mtime">${esc(t('external_notes_tool_count', src.tool_count||0))}</div>
${toolHtml}
</section>`;
}).join('');
body.innerHTML = `<div class="main-view-content">${recall}${cards}</div>`;
}
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) {
+3
View File
@@ -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);}
+41
View File
@@ -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"]