From 8c08acec5adc20cd84b3c647a55835c17de73ff5 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 10:23:20 -0400 Subject: [PATCH] feat(memory): show AI-used Joplin notes --- api/routes.py | 88 +++++++++++++++++++++++++++++++ static/i18n.js | 3 ++ static/panels.js | 12 ++++- static/style.css | 3 ++ tests/test_webui_notes_sources.py | 50 ++++++++++++++++++ 5 files changed, 155 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 4174d3fb..f44ed8ac 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10706,6 +10706,7 @@ def _handle_notes_sources_list(handler): "inventory_scope": "already_known_runtime_only", "attach_supported": False, "automatic_recall_unchanged": True, + "recent_ai_notes": _joplin_recent_ai_notes(limit=6), }) @@ -10827,6 +10828,93 @@ def _joplin_get_note(note_id: str) -> dict: } +_JOPLIN_AI_RECALL_NOTE_PRIORITY = [ + ("CURRENT_CONTEXT_ID", "Current Context"), + ("OPEN_ISSUES_ID", "Open Issues"), + ("AGENT_MEMORY_ID", "Agent Memory"), + ("CONVENTIONS_ID", "Conventions / Preferences"), + ("INFRA_ID", "Infrastructure"), + ("SERVICES_ID", "Services"), +] + + +def _joplin_prefill_script_path() -> Path | None: + cfg = get_config() + path_value = cfg.get("prefill_messages_script") if isinstance(cfg, dict) else None + if not path_value: + return None + try: + return Path(str(path_value)).expanduser() + except Exception: + return None + + +def _joplin_recall_note_refs(script_path: Path | None = None) -> list[dict]: + """Find stable Joplin note IDs referenced by the configured recall script. + + This keeps the WebUI generic: it does not hard-code a user's note IDs, but + can still surface the notes that the configured AI prefill/recall script is + known to read for automatic context. + """ + script_path = script_path or _joplin_prefill_script_path() + if not script_path or not script_path.exists() or not script_path.is_file(): + return [] + try: + text = script_path.read_text(encoding="utf-8", errors="replace") + except Exception: + return [] + constants = { + match.group(1): match.group(2) + for match in re.finditer(r'(?m)^\s*([A-Z0-9_]+_ID)\s*=\s*["\']([A-Fa-f0-9]{16,64})["\']', text) + } + refs = [] + seen = set() + for const_name, label in _JOPLIN_AI_RECALL_NOTE_PRIORITY: + note_id = constants.get(const_name) + if not note_id or note_id in seen: + continue + seen.add(note_id) + refs.append({ + "id": note_id, + "label": label, + "constant": const_name, + "used_by": "ai_prefill", + "used_reason": "automatic_recall", + }) + return refs + + +def _joplin_recent_ai_notes(*, limit: int = 6) -> list[dict]: + """Return safe Joplin notes that the configured AI recall path recently uses.""" + try: + limit = max(1, min(int(limit or 6), 20)) + except Exception: + limit = 6 + notes = [] + for ref in _joplin_recall_note_refs()[:limit]: + try: + data = _joplin_api_get(f"/notes/{ref['id']}", { + "fields": "id,title,parent_id,updated_time,user_updated_time,created_time", + }) + except Exception: + continue + note_id = _mcp_safe_display_text(data.get("id") or ref.get("id") or "", limit=64) + if not note_id: + continue + notes.append({ + "id": note_id, + "title": _mcp_safe_display_text(data.get("title") or ref.get("label") or "Untitled", limit=180), + "label": _mcp_safe_display_text(ref.get("label") or "", limit=120), + "parent_id": _mcp_safe_display_text(data.get("parent_id") or "", limit=64), + "updated_time": data.get("user_updated_time") or data.get("updated_time"), + "created_time": data.get("created_time"), + "source": "joplin", + "used_by": ref.get("used_by") or "ai_prefill", + "used_reason": ref.get("used_reason") or "automatic_recall", + }) + return notes + + def _handle_notes_search(handler, parsed): query = parse_qs(parsed.query or "") source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower() diff --git a/static/i18n.js b/static/i18n.js index 78d7605f..011d66a6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1061,6 +1061,9 @@ const LOCALES = { 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`, external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.', + external_notes_recent_ai: 'Recently used by AI', + external_notes_auto: 'auto', + external_notes_recent_ai_reason: 'Automatic recall', external_notes_search_placeholder: 'Search notes…', external_notes_search_empty: 'Search a configured notes source to preview notes here.', source_active: 'active', diff --git a/static/panels.js b/static/panels.js index d84a84cb..4510c1f8 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3625,6 +3625,16 @@ function _renderExternalNotesSources() { const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0]; _notesSelectedSource = (selected && selected.name) || 'joplin'; const sourceOptions = sources.map(src => ``).join(''); + const recentAiNotes = Array.isArray(data.recent_ai_notes) ? data.recent_ai_notes : []; + const recentAiHtml = recentAiNotes.length + ? `
+
${li('bot', 14)}${esc(t('external_notes_recent_ai'))}${esc(t('external_notes_auto'))}
+
${recentAiNotes.map(note => { + const updated = note.updated_time ? new Date(Number(note.updated_time)).toLocaleString() : ''; + return ``; + }).join('')}
+
` + : ''; const searchError = _notesSearchError ? `
${esc(_notesSearchError)}
` : ''; const resultHtml = _notesSearchResults.length ? `
${_notesSearchResults.map(note => ``).join('')}
` @@ -3657,7 +3667,7 @@ function _renderExternalNotesSources() { ${searchError} ${resultHtml} `; - body.innerHTML = `
${recall}${searchUi}${previewHtml}${cards}
`; + body.innerHTML = `
${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}
`; } body.style.display = ''; if (empty) empty.style.display = 'none'; diff --git a/static/style.css b/static/style.css index 0f6615b2..77af419e 100644 --- a/static/style.css +++ b/static/style.css @@ -911,6 +911,9 @@ .notes-search-form select,.notes-search-form input{min-height:34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);padding:6px 10px;} .notes-search-form input{flex:1;min-width:220px;} .notes-search-results{display:grid;gap:8px;} + .notes-ai-recent-list{display:grid;gap:8px;margin-top:10px;} + .notes-ai-recent-head strong{display:inline-flex;align-items:center;gap:7px;} + .notes-ai-recent-item span{display:inline-flex;align-items:center;gap:6px;} .notes-result-card{display:grid;gap:4px;width:100%;text-align:left;border:1px solid var(--border);border-radius:12px;background:var(--panel-soft);color:var(--text);padding:10px 12px;cursor:pointer;} .notes-result-card:hover{border-color:var(--accent);} .notes-result-card span{color:var(--muted);font-size:.9rem;} diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 1a4d8607..919434b7 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -110,3 +110,53 @@ def test_joplin_get_note_validates_id_and_truncates_body(monkeypatch): assert note["source"] == "joplin" assert len(note["body"]) < 51000 assert "Preview truncated" in note["body"] + + +def test_joplin_recent_ai_notes_uses_configured_prefill_script(monkeypatch, tmp_path): + from api import routes + + script = tmp_path / "joplin_context.py" + script.write_text( + '\n'.join([ + 'CURRENT_CONTEXT_ID = "5ba9ab822c344115939205ca4e8eaec0"', + 'OPEN_ISSUES_ID = "623aeb6e55cb4aa39a0541f2ac09aa36"', + 'AGENT_MEMORY_ID = "0a7a232ea46b4b8bb0bbd4358f725a84"', + 'RAW_CAPTURES_ID = "cb1087795c7d4129a863ab0a5642233d"', + ]), + encoding="utf-8", + ) + monkeypatch.setattr(routes, "get_config", lambda: {"prefill_messages_script": str(script)}) + + def fake_get(path, params=None): + note_id = path.rsplit("/", 1)[-1] + titles = { + "5ba9ab822c344115939205ca4e8eaec0": "Current Context", + "623aeb6e55cb4aa39a0541f2ac09aa36": "Open Issues", + "0a7a232ea46b4b8bb0bbd4358f725a84": "Agent Memory", + } + assert note_id in titles + return {"id": note_id, "title": titles[note_id], "updated_time": 123, "parent_id": "folder"} + + monkeypatch.setattr(routes, "_joplin_api_get", fake_get) + + notes = routes._joplin_recent_ai_notes(limit=3) + + assert [note["title"] for note in notes] == ["Current Context", "Open Issues", "Agent Memory"] + assert all(note["source"] == "joplin" for note in notes) + assert all(note["used_by"] == "ai_prefill" for note in notes) + assert all(note["used_reason"] == "automatic_recall" for note in notes) + + +def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes(): + from pathlib import Path + + panels = Path("static/panels.js").read_text(encoding="utf-8") + start = panels.index("function _renderExternalNotesSources()") + end = panels.index("function _renderMemoryDetail", start) + notes_block = panels[start:end] + assert "notes-ai-recent-card" in notes_block + assert "li('bot', 14)" in notes_block + assert "li('clock', 14)" in notes_block + assert "Recently used by AI" not in notes_block # i18n key, not hard-coded UI copy + assert "🤖" not in notes_block + assert "📚" not in notes_block