feat(memory): show AI-used Joplin notes

This commit is contained in:
AJV20 committed 2026-05-18 10:24:05 -04:00
1 parent 54ca6bf2e3
commit 8c08acec5a
5 files changed
+155 -1

No files matched your search

+88
View File
@@ -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()
+3
View File
@@ -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',
+11 -1
View File
@@ -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 => `<option value="${esc(src.name||'')}" ${src.name===_notesSelectedSource?'selected':''}>${esc(src.label||src.name||'')}</option>`).join('');
const recentAiNotes = Array.isArray(data.recent_ai_notes) ? data.recent_ai_notes : [];
const recentAiHtml = recentAiNotes.length
? `<section class="notes-source-card notes-ai-recent-card">
<div class="notes-source-card-head notes-ai-recent-head"><strong>${li('bot', 14)}${esc(t('external_notes_recent_ai'))}</strong><span class="detail-badge">${esc(t('external_notes_auto'))}</span></div>
<div class="notes-ai-recent-list">${recentAiNotes.map(note => {
const updated = note.updated_time ? new Date(Number(note.updated_time)).toLocaleString() : '';
return `<button type="button" class="notes-result-card notes-ai-recent-item" onclick="previewExternalNote('${esc(note.source||'joplin')}','${esc(note.id||'')}')"><strong>${esc(note.title||note.label||'Untitled')}</strong><span>${li('clock', 14)}${esc(note.label||t('external_notes_recent_ai_reason'))}${updated ? ` · ${esc(updated)}` : ''}</span></button>`;
}).join('')}</div>
</section>`
: '';
const searchError = _notesSearchError ? `<div class="detail-form-error">${esc(_notesSearchError)}</div>` : '';
const resultHtml = _notesSearchResults.length
? `<div class="notes-search-results">${_notesSearchResults.map(note => `<button type="button" class="notes-result-card" onclick="previewExternalNote('${esc(note.source||_notesSelectedSource)}','${esc(note.id||'')}')"><strong>${esc(note.title||'Untitled')}</strong>${note.snippet?`<span>${esc(note.snippet)}</span>`:''}</button>`).join('')}</div>`
@@ -3657,7 +3667,7 @@ function _renderExternalNotesSources() {
${searchError}
${resultHtml}
</section>`;
body.innerHTML = `<div class="main-view-content">${recall}${searchUi}${previewHtml}${cards}</div>`;
body.innerHTML = `<div class="main-view-content">${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
}
body.style.display = '';
if (empty) empty.style.display = 'none';
+3
View File
@@ -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;}
+50
View File
@@ -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