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 01/23] 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"] From faf1160ca91c67867068e41a9f690c4f6699498e Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 09:57:01 -0400 Subject: [PATCH 02/23] fix(memory): show configured notes sources without tools --- api/routes.py | 16 +++++++++++++++- tests/test_webui_notes_sources.py | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index fa70f90c..c084efa6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10573,7 +10573,13 @@ def _looks_like_notes_source(server_name: str, tool_rows: list[dict]) -> bool: 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.""" + """Build a safe notes/knowledge-source inventory from MCP servers/tools. + + Some WebUI deployments can read ``mcp_servers`` from config before their + local runtime/tool registry has hydrated MCP tool metadata. Still show + configured note/knowledge servers (for example Joplin) in that case so the + drawer reflects connection/configuration state instead of appearing empty. + """ by_server: dict[str, list[dict]] = {} for tool in tools or []: if not isinstance(tool, dict): @@ -10583,6 +10589,14 @@ def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) continue by_server.setdefault(server, []).append(tool) + if isinstance(server_summaries, dict): + for server, summary in server_summaries.items(): + server_name = str(server or "").strip() + if not server_name or server_name in by_server: + continue + if _looks_like_notes_source(server_name, []): + by_server.setdefault(server_name, []) + sources = [] for server, tool_rows in by_server.items(): if not _looks_like_notes_source(server, tool_rows): diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index ee5112bf..5b757c3f 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -39,3 +39,20 @@ def test_notes_sources_redacts_tool_descriptions_and_omits_plain_file_tools(): assert source["name"] == "notion" assert "token" not in source["tools"][0]["description"].lower() assert "[REDACTED]" in source["tools"][0]["description"] + + +def test_notes_sources_shows_configured_note_servers_without_tool_inventory(): + from api.routes import _notes_sources_from_mcp_inventory + + servers = { + "joplin": {"name": "joplin", "enabled": True, "active": False, "status": "configured"}, + "filesystem": {"name": "filesystem", "enabled": True, "active": True, "status": "healthy"}, + } + + sources = _notes_sources_from_mcp_inventory(servers, []) + + assert [source["name"] for source in sources] == ["joplin"] + assert sources[0]["label"] == "Joplin" + assert sources[0]["tool_count"] == 0 + assert sources[0]["tools"] == [] + assert sources[0]["status"] == "configured" From 2f7883580ff00777a478c6b59423584c10f71bbf Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 10:03:44 -0400 Subject: [PATCH 03/23] fix(memory): infer configured notes source tools --- api/routes.py | 59 ++++++++++++++++++++++++++++++- static/i18n.js | 1 + static/panels.js | 4 +++ tests/test_webui_notes_sources.py | 6 ++-- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/api/routes.py b/api/routes.py index c084efa6..2a7886a0 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10542,6 +10542,29 @@ _NOTES_SOURCE_TOOL_HINTS = { "note", "notes", "notebook", "page", "pages", "wiki", "knowledge", "search_notes", "get_note", "list_notes", "read_note", } +_NOTES_SOURCE_CONFIGURED_TOOL_HINTS = { + "joplin": [ + {"name": "search_notes", "description": "Search Joplin notes by keyword."}, + {"name": "list_notes", "description": "List notes from a Joplin notebook."}, + {"name": "get_note", "description": "Read a specific Joplin note by ID."}, + ], + "obsidian": [ + {"name": "search_notes", "description": "Search Obsidian notes by keyword."}, + {"name": "read_note", "description": "Read a specific Obsidian note or file."}, + ], + "notion": [ + {"name": "search_pages", "description": "Search Notion pages or databases."}, + {"name": "get_page", "description": "Read a specific Notion page."}, + ], + "llm-wiki": [ + {"name": "query_knowledge_base", "description": "Query the LLM Wiki knowledge base."}, + {"name": "read_page", "description": "Read a specific wiki page."}, + ], + "llmwiki": [ + {"name": "query_knowledge_base", "description": "Query the LLM Wiki knowledge base."}, + {"name": "read_page", "description": "Read a specific wiki page."}, + ], +} def _note_source_label(name: str) -> str: @@ -10572,6 +10595,34 @@ def _looks_like_notes_source(server_name: str, tool_rows: list[dict]) -> bool: return False +def _configured_note_tool_hints(server_name: str) -> list[dict]: + """Return safe expected note-tool hints for configured known sources.""" + server_l = str(server_name or "").strip().lower() + hints = _NOTES_SOURCE_CONFIGURED_TOOL_HINTS.get(server_l) + if hints is None: + if any(hint in server_l for hint in ("wiki", "knowledge", "kb")): + hints = [ + {"name": "search", "description": "Search this configured knowledge source."}, + {"name": "read", "description": "Read an item from this configured knowledge source."}, + ] + elif any(hint in server_l for hint in ("note", "notes")): + hints = [ + {"name": "search_notes", "description": "Search this configured notes source."}, + {"name": "read_note", "description": "Read a note from this configured notes source."}, + ] + else: + hints = [] + return [ + { + "name": _mcp_safe_display_text(row.get("name") or "", limit=96), + "description": _mcp_safe_display_text(row.get("description") or "", limit=180), + "inferred": True, + } + for row in hints + if isinstance(row, dict) + ] + + 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. @@ -10603,6 +10654,7 @@ def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) continue summary = server_summaries.get(server, {"name": server}) if isinstance(server_summaries, dict) else {"name": server} safe_tools = [] + tool_source = "runtime" 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) @@ -10610,13 +10662,18 @@ def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) "name": _mcp_safe_display_text(tool.get("name") or "", limit=96), "description": desc, }) + if not safe_tools: + safe_tools = _configured_note_tool_hints(server) + if safe_tools: + tool_source = "configured_hint" 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), + "tool_count": len(safe_tools), + "tool_source": tool_source, "tools": safe_tools, }) sources.sort(key=lambda row: (not row.get("active"), row.get("label", ""))) diff --git a/static/i18n.js b/static/i18n.js index 095a69f9..5fdf353b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1060,6 +1060,7 @@ const LOCALES = { 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`, + external_notes_configured_hint: 'Tool names are expected from this configured source; live schemas will appear when the WebUI runtime exposes them.', source_active: 'active', source_configured: 'configured', no_notes_yet: 'No notes yet.', diff --git a/static/panels.js b/static/panels.js index 586e9e37..b89a5152 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3620,12 +3620,16 @@ function _renderExternalNotesSources() { 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 hintHtml = src.tool_source === 'configured_hint' + ? `
${esc(t('external_notes_configured_hint'))}
` + : ''; 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))}
+ ${hintHtml} ${toolHtml}
`; }).join(''); diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 5b757c3f..efb2119f 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -53,6 +53,8 @@ def test_notes_sources_shows_configured_note_servers_without_tool_inventory(): assert [source["name"] for source in sources] == ["joplin"] assert sources[0]["label"] == "Joplin" - assert sources[0]["tool_count"] == 0 - assert sources[0]["tools"] == [] + assert sources[0]["tool_count"] == 3 + assert [tool["name"] for tool in sources[0]["tools"]] == ["search_notes", "list_notes", "get_note"] + assert all(tool.get("inferred") is True for tool in sources[0]["tools"]) + assert sources[0]["tool_source"] == "configured_hint" assert sources[0]["status"] == "configured" From 54ca6bf2e39597d5b42257d2972396aa24129a46 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 10:10:31 -0400 Subject: [PATCH 04/23] feat(memory): browse Joplin notes from notes drawer --- api/routes.py | 150 ++++++++++++++++++++++++++++++ static/i18n.js | 2 + static/panels.js | 75 ++++++++++++++- static/style.css | 9 ++ tests/test_webui_notes_sources.py | 52 +++++++++++ 5 files changed, 287 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 2a7886a0..4174d3fb 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4352,6 +4352,10 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/notes/sources": return _handle_notes_sources_list(handler) + if parsed.path == "/api/notes/search": + return _handle_notes_search(handler, parsed) + if parsed.path == "/api/notes/item": + return _handle_notes_item(handler, parsed) # ── Checkpoints / Rollback (GET) ── if parsed.path == "/api/rollback/list": @@ -10705,6 +10709,152 @@ def _handle_notes_sources_list(handler): }) +def _notes_configured_server(source: str) -> dict: + cfg = get_config() + servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {} + if not isinstance(servers, dict): + return {} + source_l = str(source or "").strip().lower() + for name, server_cfg in servers.items(): + if str(name or "").strip().lower() == source_l and isinstance(server_cfg, dict): + return server_cfg + return {} + + +def _joplin_connection_from_config() -> tuple[str, str]: + cfg = _notes_configured_server("joplin") + env = cfg.get("env", {}) if isinstance(cfg, dict) else {} + if not isinstance(env, dict): + env = {} + url = str(env.get("JOPLIN_URL") or os.environ.get("JOPLIN_URL") or "http://127.0.0.1:41184").rstrip("/") + token = str(env.get("JOPLIN_TOKEN") or os.environ.get("JOPLIN_TOKEN") or "") + return url, token + + +def _joplin_api_get(path: str, params: dict | None = None) -> dict: + """Call the local Joplin Web Clipper API without logging credentials.""" + from urllib.parse import urlencode + from urllib.request import urlopen + from urllib.error import HTTPError, URLError + + base_url, token = _joplin_connection_from_config() + if not token: + raise ValueError("Joplin token is not configured") + safe_path = "/" + str(path or "").lstrip("/") + query = dict(params or {}) + query["token"] = token + url = f"{base_url}{safe_path}?{urlencode(query)}" + try: + with urlopen(url, timeout=8) as response: + raw = response.read(2_000_000).decode("utf-8", errors="replace") + except HTTPError as exc: + raise ValueError(f"Joplin API returned HTTP {exc.code}") from None + except URLError as exc: + raise ValueError("Joplin API is not reachable") from None + try: + data = json.loads(raw) + except Exception: + raise ValueError("Joplin API returned invalid JSON") from None + return data if isinstance(data, dict) else {} + + +def _note_snippet(body: str, query: str = "", *, limit: int = 220) -> str: + text = re.sub(r"\s+", " ", str(body or "")).strip() + if not text: + return "" + q = str(query or "").strip().lower() + if q: + idx = text.lower().find(q) + if idx > 40: + text = "…" + text[max(0, idx - 60):] + if len(text) > limit: + return text[:limit].rstrip() + "…" + return text + + +def _joplin_search_notes(query: str, *, limit: int = 20) -> list[dict]: + query = str(query or "").strip() + if not query: + return [] + limit = max(1, min(int(limit or 20), 50)) + data = _joplin_api_get("/search", { + "query": query, + "type": "note", + "fields": "id,title,body,parent_id,updated_time", + "limit": limit, + }) + rows = data.get("items") if isinstance(data, dict) else [] + results = [] + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + note_id = _mcp_safe_display_text(row.get("id") or "", limit=64) + if not note_id: + continue + title = _mcp_safe_display_text(row.get("title") or "Untitled", limit=180) + body = str(row.get("body") or "") + results.append({ + "id": note_id, + "title": title, + "snippet": _mcp_safe_display_text(_note_snippet(body, query), limit=260), + "parent_id": _mcp_safe_display_text(row.get("parent_id") or "", limit=64), + "updated_time": row.get("updated_time"), + "source": "joplin", + }) + return results + + +def _joplin_get_note(note_id: str) -> dict: + note_id = str(note_id or "").strip() + if not re.fullmatch(r"[A-Za-z0-9]{16,64}", note_id): + raise ValueError("Invalid Joplin note id") + data = _joplin_api_get(f"/notes/{note_id}", { + "fields": "id,title,body,parent_id,updated_time,created_time", + }) + if not data.get("id"): + raise ValueError("Joplin note not found") + body = str(data.get("body") or "") + if len(body) > 50_000: + body = body[:50_000].rstrip() + "\n\n[Preview truncated at 50,000 characters]" + return { + "id": _mcp_safe_display_text(data.get("id") or "", limit=64), + "title": _mcp_safe_display_text(data.get("title") or "Untitled", limit=180), + "body": _redact_text(body), + "parent_id": _mcp_safe_display_text(data.get("parent_id") or "", limit=64), + "updated_time": data.get("updated_time"), + "created_time": data.get("created_time"), + "source": "joplin", + } + + +def _handle_notes_search(handler, parsed): + query = parse_qs(parsed.query or "") + source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower() + q = str(query.get("q", [""])[0] or "").strip() + try: + limit = int(query.get("limit", ["20"])[0] or 20) + except Exception: + limit = 20 + if source != "joplin": + return j(handler, {"source": source, "results": [], "error": "Search is currently implemented for Joplin sources only."}, status=400) + try: + return j(handler, {"source": "joplin", "query": q, "results": _joplin_search_notes(q, limit=limit)}) + except ValueError as exc: + return j(handler, {"source": "joplin", "query": q, "results": [], "error": str(exc)}, status=502) + + +def _handle_notes_item(handler, parsed): + query = parse_qs(parsed.query or "") + source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower() + note_id = str(query.get("id", [""])[0] or "").strip() + if source != "joplin": + return j(handler, {"source": source, "error": "Preview is currently implemented for Joplin sources only."}, status=400) + try: + return j(handler, {"source": "joplin", "note": _joplin_get_note(note_id)}) + except ValueError as exc: + return j(handler, {"source": "joplin", "error": str(exc)}, status=502) + + 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 5fdf353b..78d7605f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1061,6 +1061,8 @@ 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_search_placeholder: 'Search notes…', + external_notes_search_empty: 'Search a configured notes source to preview notes here.', source_active: 'active', source_configured: 'configured', no_notes_yet: 'No notes yet.', diff --git a/static/panels.js b/static/panels.js index b89a5152..d84a84cb 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3564,6 +3564,11 @@ async function deleteCurrentSkill() { // ── Memory (main view) ── let _memoryData = null; let _notesSourcesData = null; +let _notesSearchResults = []; +let _notesSelectedSource = 'joplin'; +let _notesPreviewNote = null; +let _notesSearchError = ''; +let _notesSearchLoading = false; let _currentMemorySection = null; // 'memory' | 'user' | 'soul' | 'external_notes' let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit' @@ -3617,6 +3622,16 @@ function _renderExternalNotesSources() { if (!sources.length) { body.innerHTML = `
${recall}
${esc(t('external_notes_empty'))}
`; } else { + const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0]; + _notesSelectedSource = (selected && selected.name) || 'joplin'; + const sourceOptions = sources.map(src => ``).join(''); + const searchError = _notesSearchError ? `
${esc(_notesSearchError)}
` : ''; + const resultHtml = _notesSearchResults.length + ? `
${_notesSearchResults.map(note => ``).join('')}
` + : `
${esc(t('external_notes_search_empty'))}
`; + const previewHtml = _notesPreviewNote + ? `
${esc(_notesPreviewNote.title||'Untitled')}${esc(_notesPreviewNote.source||_notesSelectedSource)}
${renderMd(_notesPreviewNote.body||'')}
` + : ''; 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 : []; @@ -3633,7 +3648,16 @@ function _renderExternalNotesSources() { ${toolHtml} `; }).join(''); - body.innerHTML = `
${recall}${cards}
`; + const searchUi = `
+
+ + + +
+ ${searchError} + ${resultHtml} +
`; + body.innerHTML = `
${recall}${searchUi}${previewHtml}${cards}
`; } body.style.display = ''; if (empty) empty.style.display = 'none'; @@ -3703,6 +3727,55 @@ async function loadNotesSources(force) { return _notesSourcesData; } +function selectExternalNotesSource(source) { + _notesSelectedSource = source || 'joplin'; + _notesSearchResults = []; + _notesPreviewNote = null; + _notesSearchError = ''; + _renderExternalNotesSources(); +} + +async function searchExternalNotes() { + const input = $('externalNotesQuery'); + const sourceEl = $('externalNotesSource'); + const q = input ? input.value.trim() : ''; + _notesSelectedSource = sourceEl ? sourceEl.value : (_notesSelectedSource || 'joplin'); + _notesPreviewNote = null; + _notesSearchError = ''; + if (!q) { + _notesSearchResults = []; + _renderExternalNotesSources(); + return; + } + _notesSearchLoading = true; + _renderExternalNotesSources(); + try { + const data = await api(`/api/notes/search?source=${encodeURIComponent(_notesSelectedSource)}&q=${encodeURIComponent(q)}&limit=20`); + _notesSearchResults = Array.isArray(data.results) ? data.results : []; + _notesSearchError = data.error || ''; + } catch (e) { + _notesSearchResults = []; + _notesSearchError = e && e.message ? e.message : String(e); + } finally { + _notesSearchLoading = false; + _renderExternalNotesSources(); + const nextInput = $('externalNotesQuery'); + if (nextInput) nextInput.value = q; + } +} + +async function previewExternalNote(source, id) { + _notesSearchError = ''; + try { + const data = await api(`/api/notes/item?source=${encodeURIComponent(source||_notesSelectedSource)}&id=${encodeURIComponent(id||'')}`); + _notesPreviewNote = data && data.note ? data.note : null; + } catch (e) { + _notesPreviewNote = null; + _notesSearchError = e && e.message ? e.message : String(e); + } + _renderExternalNotesSources(); +} + async function openMemorySection(section, el) { _currentMemorySection = section; document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active')); diff --git a/static/style.css b/static/style.css index c56ee2e9..0f6615b2 100644 --- a/static/style.css +++ b/static/style.css @@ -906,6 +906,15 @@ .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;} + .notes-search-card{gap:12px;} + .notes-search-form{display:flex;gap:10px;align-items:center;flex-wrap:wrap;} + .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-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;} + .notes-preview-card .memory-content{max-height:420px;overflow:auto;} .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 index efb2119f..1a4d8607 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -58,3 +58,55 @@ def test_notes_sources_shows_configured_note_servers_without_tool_inventory(): assert all(tool.get("inferred") is True for tool in sources[0]["tools"]) assert sources[0]["tool_source"] == "configured_hint" assert sources[0]["status"] == "configured" + + +def test_joplin_search_notes_returns_safe_snippets(monkeypatch): + from api import routes + + def fake_get(path, params=None): + assert path == "/search" + assert params["type"] == "note" + return {"items": [{ + "id": "abc123def4567890", + "title": "Hermes Context", + "body": "This is a long Hermes context note with useful details.", + "parent_id": "folder123", + "updated_time": 123, + }]} + + monkeypatch.setattr(routes, "_joplin_api_get", fake_get) + + results = routes._joplin_search_notes("Hermes") + + assert results == [{ + "id": "abc123def4567890", + "title": "Hermes Context", + "snippet": "This is a long Hermes context note with useful details.", + "parent_id": "folder123", + "updated_time": 123, + "source": "joplin", + }] + + +def test_joplin_get_note_validates_id_and_truncates_body(monkeypatch): + from api import routes + + def fake_get(path, params=None): + assert path == "/notes/abc123def4567890" + return { + "id": "abc123def4567890", + "title": "Big Note", + "body": "x" * 60000, + "parent_id": "folder123", + "updated_time": 456, + "created_time": 123, + } + + monkeypatch.setattr(routes, "_joplin_api_get", fake_get) + + note = routes._joplin_get_note("abc123def4567890") + + assert note["title"] == "Big Note" + assert note["source"] == "joplin" + assert len(note["body"]) < 51000 + assert "Preview truncated" in note["body"] 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 05/23] 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 From 372d090c6c9a32a96bb7528449b4701f11744032 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 10:31:22 -0400 Subject: [PATCH 06/23] fix(memory): match notes search button styling --- static/style.css | 3 +++ tests/test_webui_notes_sources.py | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/static/style.css b/static/style.css index 77af419e..98749d50 100644 --- a/static/style.css +++ b/static/style.css @@ -910,6 +910,9 @@ .notes-search-form{display:flex;gap:10px;align-items:center;flex-wrap:wrap;} .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-form button{min-height:34px;border:1px solid var(--border);border-radius:10px;background:var(--panel);color:var(--text);padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer;transition:border-color .15s,color .15s,background .15s;} + .notes-search-form button:hover{border-color:var(--accent);color:var(--accent);background:var(--panel-soft);} + .notes-search-form button:disabled{opacity:.55;cursor:default;} .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;} diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 919434b7..33959517 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -160,3 +160,15 @@ def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes(): 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 + + +def test_external_notes_search_button_matches_minimal_dark_controls(): + from pathlib import Path + + css = Path("static/style.css").read_text(encoding="utf-8") + assert ".notes-search-form button" in css + button_block = css[css.index(".notes-search-form button"):css.index(".notes-search-form button:hover")] + assert "background:var(--panel)" in button_block or "background:var(--surface)" in button_block + assert "border:1px solid var(--border)" in button_block + assert "color:var(--text)" in button_block + assert "border-radius:10px" in button_block From 8e65ad3063df74b765975ffd29e9076e0d09227d Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 08:13:28 -0400 Subject: [PATCH 07/23] fix(chat): add WebUI surface context to agent turns --- CHANGELOG.md | 4 +++ api/streaming.py | 48 +++++++++++++++++++++++++++-- tests/test_webui_surface_context.py | 39 +++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/test_webui_surface_context.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e5fa89b5..98c1838e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Add browser-surface session context to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts while keeping the metadata ephemeral and out of saved history. + ## [v0.51.90] — 2026-05-18 — Release BN (stage-383 — 10-PR full sweep batch — empty-gateway messaging history fix + previous-messaging-sessions setting + Kanban board switcher layout + UI/UX demo theme controls + Slice 3c queue/goal RFC gate + keyless custom endpoints + custom-provider remote model catalog parity + auto-compression elapsed timer + new-conversation cold-start guard + Kanban drag-drop detail open fix) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index 2cb0daa2..ca6095b6 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -181,11 +181,47 @@ WebUI progress contract: """.strip() -def _webui_ephemeral_system_prompt(personality_prompt: Optional[str]) -> str: +def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str: + """Return safe WebUI session metadata for the agent's ephemeral context. + + Messaging gateways inject platform/channel context before each run. Browser + sessions do not have a chat platform wrapper, so provide an explicit, small + surface description here instead of relying on the model to infer where it + is running from the transcript alone. + """ + if not isinstance(surface_context, dict): + return "" + + lines = [ + "WebUI session context:", + "- This browser session is not the same live transcript as Telegram, Discord, Slack, or other messaging surfaces.", + "- Use durable memory, saved sessions, and available tools for cross-surface recall instead of assuming those transcripts are in this browser chat.", + ] + fields = ( + ("source", "Source"), + ("session_id", "Session ID"), + ("profile", "Profile"), + ("workspace", "Workspace"), + ) + for key, label in fields: + raw = surface_context.get(key) + value = str(raw).strip() if raw is not None else "" + if value: + lines.append(f"- {label}: {value}") + return "\n".join(lines) + + +def _webui_ephemeral_system_prompt( + personality_prompt: Optional[str], + surface_context: Optional[dict] = None, +) -> str: """Build WebUI-only runtime instructions that are not persisted to history.""" parts = [] if personality_prompt: parts.append(str(personality_prompt).strip()) + surface_prompt = _webui_surface_context_prompt(surface_context) + if surface_prompt: + parts.append(surface_prompt) parts.append(_WEBUI_VISIBLE_PROGRESS_PROMPT) return "\n\n".join(part for part in parts if part) @@ -3870,7 +3906,15 @@ def _run_agent_streaming( # (agent's own mechanism). This preserves any selected personality # while making long tool runs emit real user-visible interim text # through interim_assistant_callback instead of frontend guesses. - agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt(_personality_prompt) + agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt( + _personality_prompt, + surface_context={ + 'source': 'webui', + 'session_id': session_id, + 'profile': getattr(s, 'profile', None), + 'workspace': s.workspace, + }, + ) _pending_started_at = getattr(s, 'pending_started_at', None) # Normal chat-start sets pending_started_at before spawning this thread; # fallback to now only for recovered/legacy flows where that marker is absent diff --git a/tests/test_webui_surface_context.py b/tests/test_webui_surface_context.py new file mode 100644 index 00000000..d50dc0f8 --- /dev/null +++ b/tests/test_webui_surface_context.py @@ -0,0 +1,39 @@ +from api.streaming import _webui_ephemeral_system_prompt + + +def test_webui_ephemeral_prompt_includes_browser_surface_context(): + prompt = _webui_ephemeral_system_prompt( + "Use a concise tone.", + surface_context={ + "source": "webui", + "session_id": "session-123", + "profile": "default", + "workspace": "/tmp/example-workspace", + }, + ) + + assert "Use a concise tone." in prompt + assert "WebUI session context" in prompt + assert "Source: webui" in prompt + assert "Session ID: session-123" in prompt + assert "Profile: default" in prompt + assert "Workspace: /tmp/example-workspace" in prompt + assert "not the same live transcript as Telegram" in prompt + + +def test_webui_ephemeral_prompt_skips_empty_surface_fields(): + prompt = _webui_ephemeral_system_prompt( + None, + surface_context={ + "source": "webui", + "session_id": "", + "profile": None, + "workspace": " ", + }, + ) + + assert "WebUI session context" in prompt + assert "Source: webui" in prompt + assert "Session ID:" not in prompt + assert "Profile:" not in prompt + assert "Workspace:" not in prompt From 5c1161f84f145871e4967e26c2307d5f6a73166c Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 09:22:00 -0400 Subject: [PATCH 08/23] feat(chat): load WebUI prefill context --- CHANGELOG.md | 5 ++ api/streaming.py | 120 ++++++++++++++++++++++++++++ static/messages.js | 17 +++- tests/test_webui_prefill_context.py | 62 ++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 tests/test_webui_prefill_context.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c1838e..c639dc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Add non-sensitive SSE stream runtime diagnostics to deep health checks, including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. +- Add WebUI session prefill parity: browser-originated chat turns now load configured prefill context from `prefill_messages_file` or `prefill_messages_script`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. + ### Changed - Add browser-surface session context to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts while keeping the metadata ephemeral and out of saved history. diff --git a/api/streaming.py b/api/streaming.py index ca6095b6..a196e288 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -10,6 +10,8 @@ import mimetypes import os import queue import re +import subprocess +import sys import threading import time import traceback @@ -226,6 +228,112 @@ def _webui_ephemeral_system_prompt( return "\n\n".join(part for part in parts if part) +_SECRET_SHAPED_RE = re.compile( + r"(?i)(api[_-]?key|token|password|secret)\s*[:=]\s*[^\s]+|" + r"[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}" +) + + +def _redact_prefill_status_text(text: str) -> str: + """Return a short, non-secret diagnostic string for prefill status.""" + clean = _SECRET_SHAPED_RE.sub("[REDACTED]", str(text or "")) + return " ".join(clean.split())[:240] + + +def _valid_prefill_messages(value) -> list[dict]: + """Normalize a prefill payload to role/content messages.""" + if not isinstance(value, list): + return [] + messages: list[dict] = [] + for item in value: + if not isinstance(item, dict): + continue + role = item.get("role") + content = item.get("content") + if role not in {"system", "user", "assistant"} or not isinstance(content, str) or not content.strip(): + continue + messages.append({"role": role, "content": content}) + return messages + + +def _resolve_prefill_path(raw: str) -> Path: + path = Path(str(raw)).expanduser() + if not path.is_absolute(): + try: + from api.config import _get_config_path + path = _get_config_path().parent / path + except Exception: + path = Path.cwd() / path + return path + + +def _load_webui_prefill_context( + config_data: Optional[dict] = None, + *, + python_exe: Optional[str] = None, + env: Optional[dict] = None, + timeout: float = 20.0, +) -> dict: + """Load configured WebUI session prefill messages. + + Supports the same JSON-file shape used by Hermes Agent plus a WebUI-friendly + ``prefill_messages_script`` hook whose stdout becomes one user-role recall + message. Script output is intentionally ephemeral: it is passed to the + agent as prefill context and is not written into the session transcript. + """ + cfg = config_data if isinstance(config_data, dict) else get_config() + file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "") + script_raw = os.getenv("HERMES_PREFILL_MESSAGES_SCRIPT", "") or str(cfg.get("prefill_messages_script") or "") + if file_raw: + path = _resolve_prefill_path(file_raw) + label = path.name or "prefill file" + if not path.exists(): + return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"} + try: + messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8"))) + return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)} + except Exception as exc: + return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} + if script_raw: + path = _resolve_prefill_path(script_raw) + label = path.name or "prefill script" + if not path.exists(): + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script not found"} + exe = python_exe or sys.executable + cmd = [exe, str(path)] if path.suffix == ".py" else [str(path)] + try: + proc = subprocess.run( + cmd, + cwd=str(path.parent), + env=env, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + except Exception as exc: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} + if proc.returncode != 0: + err = proc.stderr or proc.stdout or f"script exited with code {proc.returncode}" + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(err)} + output = (proc.stdout or "").strip() + messages = [{"role": "user", "content": output}] if output else [] + status = "loaded" if messages else "empty" + return {"status": status, "source": "script", "label": label, "messages": messages, "message_count": len(messages)} + return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0} + + +def _public_prefill_context_status(prefill_context: dict) -> dict: + """Strip message bodies before sending context status to the browser.""" + return { + "status": prefill_context.get("status", "not_configured"), + "source": prefill_context.get("source", "none"), + "label": prefill_context.get("label", ""), + "message_count": int(prefill_context.get("message_count") or 0), + **({"error": prefill_context.get("error", "")} if prefill_context.get("error") else {}), + } + + def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool: """Return True if *new* messages (beyond ``prev_count``) contain an assistant message with non-empty content. @@ -3544,6 +3652,12 @@ def _run_agent_streaming( # Read per-profile config at call time (not module-level snapshot) from api.config import get_config as _get_config _cfg = _get_config() + _prefill_context = _load_webui_prefill_context(_cfg) + _prefill_messages = _prefill_context.get('messages') or [] + put('context_status', { + 'session_id': session_id, + 'prefill': _public_prefill_context_status(_prefill_context), + }) # Per-profile toolsets — use _resolve_cli_toolsets() so MCP # server toolsets are included, matching native CLI behaviour. @@ -3666,6 +3780,7 @@ def _run_agent_streaming( fallback_model=_fallback_resolved, session_id=session_id, session_db=_session_db, + prefill_messages=_prefill_messages, stream_delta_callback=on_token, reasoning_callback=on_reasoning, tool_progress_callback=on_tool, @@ -3679,6 +3794,8 @@ def _run_agent_streaming( # but guard defensively to avoid TypeError on an older agent build. if 'reasoning_config' in _agent_params and _reasoning_config is not None: _agent_kwargs['reasoning_config'] = _reasoning_config + if 'prefill_messages' not in _agent_params: + _agent_kwargs.pop('prefill_messages', None) if 'interim_assistant_callback' in _agent_params: _agent_kwargs['interim_assistant_callback'] = on_interim_assistant if 'tool_start_callback' in _agent_params: @@ -3732,6 +3849,7 @@ def _run_agent_streaming( _fallback_resolved or {}, sorted(_toolsets) if _toolsets else [], _reasoning_config or {}, + _public_prefill_context_status(_prefill_context), # #1897: profile_home is part of the agent's identity because # AIAgent caches `_cached_system_prompt` from `load_soul_md()` # at construction time, sourced from HERMES_HOME. Same-session @@ -3791,6 +3909,8 @@ def _run_agent_streaming( agent.reasoning_callback = _agent_kwargs.get('reasoning_callback') if hasattr(agent, 'clarify_callback'): agent.clarify_callback = _agent_kwargs.get('clarify_callback') + if hasattr(agent, 'prefill_messages'): + agent.prefill_messages = list(_agent_kwargs.get('prefill_messages') or []) if _session_db is not None: # Close any previously held SessionDB connection before # replacing it. Without this, each streaming request creates diff --git a/static/messages.js b/static/messages.js index 98c63daf..d83e91aa 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1520,6 +1520,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }catch(_){} }); + source.addEventListener('context_status',e=>{ + let d={}; + try{ d=JSON.parse(e.data||'{}'); }catch(_){} + if((d.session_id||activeSid)!==activeSid) return; + const prefill=d.prefill||{}; + const status=String(prefill.status||'not_configured'); + const label=String(prefill.label||'session recall'); + if(status==='loaded'){ + setComposerStatus(`Context loaded: ${label}`); + }else if(status==='error'){ + setComposerStatus(`Context unavailable: ${label}`); + if(typeof showToast==='function') showToast(`Context unavailable: ${String(prefill.error||label)}`,3600,'warning'); + } + }); + function _resolveGoalMessage(d){ const key=String(d && d.message_key ? d.message_key : '').trim(); const args=Array.isArray(d && d.message_args) ? d.message_args : []; @@ -1996,7 +2011,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _setActivePaneIdleIfOwner(); }); - for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){ + for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){ source.addEventListener(_runJournalEventName,_rememberRunJournalCursor); } } diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py new file mode 100644 index 00000000..3d8feae0 --- /dev/null +++ b/tests/test_webui_prefill_context.py @@ -0,0 +1,62 @@ +"""Regression tests for WebUI session prefill parity.""" +from __future__ import annotations + +import json +import os +import stat +import sys +from pathlib import Path + + +def test_prefill_script_output_becomes_safe_user_message(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "recall.py" + script.write_text("print('JOPLIN SESSION RECALL\\nCurrent Context: loaded')\n", encoding="utf-8") + script.chmod(script.stat().st_mode | stat.S_IXUSR) + + result = _load_webui_prefill_context( + {"prefill_messages_script": str(script)}, + python_exe=sys.executable, + env={"PATH": os.environ.get("PATH", "")}, + ) + + assert result["status"] == "loaded" + assert result["source"] == "script" + assert result["label"] == "recall.py" + assert result["message_count"] == 1 + assert result["messages"] == [ + { + "role": "user", + "content": "JOPLIN SESSION RECALL\nCurrent Context: loaded", + } + ] + + +def test_prefill_json_file_is_loaded_without_script_execution(tmp_path): + from api.streaming import _load_webui_prefill_context + + prefill = tmp_path / "prefill.json" + prefill.write_text(json.dumps([{"role": "user", "content": "Pinned context"}]), encoding="utf-8") + + result = _load_webui_prefill_context({"prefill_messages_file": str(prefill)}, python_exe=sys.executable) + + assert result["status"] == "loaded" + assert result["source"] == "file" + assert result["label"] == "prefill.json" + assert result["messages"] == [{"role": "user", "content": "Pinned context"}] + + +def test_prefill_context_redacts_secret_shaped_errors(tmp_path): + from api.streaming import _load_webui_prefill_context + + missing = tmp_path / "missing.py" + result = _load_webui_prefill_context( + {"prefill_messages_script": str(missing)}, + python_exe=sys.executable, + ) + + assert result["status"] == "error" + assert result["messages"] == [] + assert "missing.py" in result["label"] + assert "token" not in result.get("error", "").lower() From 540292a7cdd40dbe699de344e686423fb6ae2099 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 14:07:06 -0400 Subject: [PATCH 09/23] fix(chat): align WebUI context with messaging sessions --- CHANGELOG.md | 2 +- api/streaming.py | 9 +++++---- tests/test_sprint42.py | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c639dc4c..4fe24490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Changed -- Add browser-surface session context to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts while keeping the metadata ephemeral and out of saved history. +- Add browser-surface session context to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts while keeping the metadata ephemeral and out of saved history. WebUI progress guidance now explicitly preserves the normal Hermes messaging style instead of encouraging extra browser-only status chatter. ## [v0.51.90] — 2026-05-18 — Release BN (stage-383 — 10-PR full sweep batch — empty-gateway messaging history fix + previous-messaging-sessions setting + Kanban board switcher layout + UI/UX demo theme controls + Slice 3c queue/goal RFC gate + keyless custom endpoints + custom-provider remote model catalog parity + auto-compression elapsed timer + new-conversation cold-start guard + Kanban drag-drop detail open fix) diff --git a/api/streaming.py b/api/streaming.py index a196e288..395e2b4d 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -173,9 +173,10 @@ def _clarify_timeout_seconds(default: int = 120) -> int: _CANCEL_MARKER_PATTERNS = ('task cancelled', 'task canceled', 'response interrupted') -_WEBUI_VISIBLE_PROGRESS_PROMPT = """ -WebUI progress contract: -- For multi-step work that uses tools, provide brief user-visible progress updates as normal assistant content before continuing with tool calls. +_WEBUI_PROGRESS_PROMPT = """ +WebUI progress guidance: +- Match the normal Hermes messaging style; do not add extra status updates solely because this is a browser session. +- For long multi-step work that uses tools, you may provide brief user-visible progress updates before continuing with tool calls. - Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed. - Keep updates concise, factual, and in the user's language. One or two short sentences are enough. - Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output. @@ -224,7 +225,7 @@ def _webui_ephemeral_system_prompt( surface_prompt = _webui_surface_context_prompt(surface_context) if surface_prompt: parts.append(surface_prompt) - parts.append(_WEBUI_VISIBLE_PROGRESS_PROMPT) + parts.append(_WEBUI_PROGRESS_PROMPT) return "\n\n".join(part for part in parts if part) diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index db92f7ce..919449d9 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -389,7 +389,8 @@ class TestRuntimeRouteInjection(unittest.TestCase): init_kwargs = captured["init_kwargs"] self.assertIsNotNone(init_kwargs["interim_assistant_callback"]) self.assertTrue(callable(init_kwargs["interim_assistant_callback"])) - self.assertIn("WebUI progress contract", captured["agent"].ephemeral_system_prompt) + self.assertIn("WebUI progress guidance", captured["agent"].ephemeral_system_prompt) + self.assertIn("Match the normal Hermes messaging style", captured["agent"].ephemeral_system_prompt) self.assertIn("user-visible progress updates", captured["agent"].ephemeral_system_prompt) interim_events = [] From 56a8c6d21a7ec90572f4cb9d913a6208c9a757fb Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 14:56:28 -0400 Subject: [PATCH 10/23] fix(chat): harden WebUI prefill scripts --- CHANGELOG.md | 2 +- api/streaming.py | 54 ++++++++++++- tests/test_webui_prefill_context.py | 116 ++++++++++++++++++++++++++-- 3 files changed, 162 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe24490..dd16b8c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Add non-sensitive SSE stream runtime diagnostics to deep health checks, including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. -- Add WebUI session prefill parity: browser-originated chat turns now load configured prefill context from `prefill_messages_file` or `prefill_messages_script`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. +- Add WebUI session prefill parity: browser-originated chat turns now load configured prefill context from `prefill_messages_file` or `prefill_messages_script`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. File prefill is cheap to read each turn; script prefill runs before SSE output starts, inherits only a minimal `PATH`/`HOME` environment, and uses a short path/mtime TTL cache to avoid re-shelling during rapid browser turns. ### Changed diff --git a/api/streaming.py b/api/streaming.py index 395e2b4d..cf3c9dfd 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -231,9 +231,14 @@ def _webui_ephemeral_system_prompt( _SECRET_SHAPED_RE = re.compile( r"(?i)(api[_-]?key|token|password|secret)\s*[:=]\s*[^\s]+|" + r"\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b|" r"[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}" ) +_PREFILL_SCRIPT_CACHE_TTL_SECONDS = 10.0 +_PREFILL_SCRIPT_CACHE_LOCK = threading.Lock() +_PREFILL_SCRIPT_CACHE: dict[tuple[str, int], tuple[float, dict]] = {} + def _redact_prefill_status_text(text: str) -> str: """Return a short, non-secret diagnostic string for prefill status.""" @@ -241,6 +246,36 @@ def _redact_prefill_status_text(text: str) -> str: return " ".join(clean.split())[:240] +def _prefill_script_env() -> dict[str, str]: + """Return the minimal environment inherited by configured prefill scripts.""" + return { + "PATH": os.environ.get("PATH", ""), + "HOME": os.environ.get("HOME", ""), + } + + +def _prefill_script_cache_get(cache_key: tuple[str, int], ttl: float) -> dict | None: + if ttl <= 0: + return None + now = time.monotonic() + with _PREFILL_SCRIPT_CACHE_LOCK: + cached = _PREFILL_SCRIPT_CACHE.get(cache_key) + if not cached: + return None + cached_at, result = cached + if now - cached_at > ttl: + _PREFILL_SCRIPT_CACHE.pop(cache_key, None) + return None + return copy.deepcopy(result) + + +def _prefill_script_cache_put(cache_key: tuple[str, int], result: dict, ttl: float) -> None: + if ttl <= 0: + return + with _PREFILL_SCRIPT_CACHE_LOCK: + _PREFILL_SCRIPT_CACHE[cache_key] = (time.monotonic(), copy.deepcopy(result)) + + def _valid_prefill_messages(value) -> list[dict]: """Normalize a prefill payload to role/content messages.""" if not isinstance(value, list): @@ -274,6 +309,7 @@ def _load_webui_prefill_context( python_exe: Optional[str] = None, env: Optional[dict] = None, timeout: float = 20.0, + script_cache_ttl: float = _PREFILL_SCRIPT_CACHE_TTL_SECONDS, ) -> dict: """Load configured WebUI session prefill messages. @@ -281,6 +317,10 @@ def _load_webui_prefill_context( ``prefill_messages_script`` hook whose stdout becomes one user-role recall message. Script output is intentionally ephemeral: it is passed to the agent as prefill context and is not written into the session transcript. + + File prefill is read directly each turn. Script prefill executes before SSE + output starts, so successful script results are cached briefly by path/mtime + to avoid re-shelling on every rapid browser turn. """ cfg = config_data if isinstance(config_data, dict) else get_config() file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "") @@ -300,6 +340,14 @@ def _load_webui_prefill_context( label = path.name or "prefill script" if not path.exists(): return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script not found"} + try: + mtime_ns = path.stat().st_mtime_ns + except OSError as exc: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} + cache_key = (str(path), mtime_ns) + cached = _prefill_script_cache_get(cache_key, script_cache_ttl) + if cached is not None: + return cached exe = python_exe or sys.executable cmd = [exe, str(path)] if path.suffix == ".py" else [str(path)] try: @@ -320,7 +368,9 @@ def _load_webui_prefill_context( output = (proc.stdout or "").strip() messages = [{"role": "user", "content": output}] if output else [] status = "loaded" if messages else "empty" - return {"status": status, "source": "script", "label": label, "messages": messages, "message_count": len(messages)} + result = {"status": status, "source": "script", "label": label, "messages": messages, "message_count": len(messages)} + _prefill_script_cache_put(cache_key, result, script_cache_ttl) + return result return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0} @@ -3653,7 +3703,7 @@ def _run_agent_streaming( # Read per-profile config at call time (not module-level snapshot) from api.config import get_config as _get_config _cfg = _get_config() - _prefill_context = _load_webui_prefill_context(_cfg) + _prefill_context = _load_webui_prefill_context(_cfg, env=_prefill_script_env()) _prefill_messages = _prefill_context.get('messages') or [] put('context_status', { 'session_id': session_id, diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py index 3d8feae0..930e0694 100644 --- a/tests/test_webui_prefill_context.py +++ b/tests/test_webui_prefill_context.py @@ -33,30 +33,132 @@ def test_prefill_script_output_becomes_safe_user_message(tmp_path): ] -def test_prefill_json_file_is_loaded_without_script_execution(tmp_path): +def test_prefill_script_uses_short_ttl_cache_keyed_by_path_and_mtime(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "recall.py" + counter = tmp_path / "counter.txt" + script.write_text( + "from pathlib import Path\n" + f"p=Path({str(counter)!r})\n" + "n=int(p.read_text() or '0') if p.exists() else 0\n" + "p.write_text(str(n+1))\n" + "print(f'run {n+1}')\n", + encoding="utf-8", + ) + + first = _load_webui_prefill_context( + {"prefill_messages_script": str(script)}, + python_exe=sys.executable, + env={"PATH": os.environ.get("PATH", "")}, + script_cache_ttl=10.0, + ) + second = _load_webui_prefill_context( + {"prefill_messages_script": str(script)}, + python_exe=sys.executable, + env={"PATH": os.environ.get("PATH", "")}, + script_cache_ttl=10.0, + ) + + assert first["messages"] == [{"role": "user", "content": "run 1"}] + assert second["messages"] == [{"role": "user", "content": "run 1"}] + assert counter.read_text(encoding="utf-8") == "1" + + script.write_text(script.read_text(encoding="utf-8") + "# invalidate\n", encoding="utf-8") + third = _load_webui_prefill_context( + {"prefill_messages_script": str(script)}, + python_exe=sys.executable, + env={"PATH": os.environ.get("PATH", "")}, + script_cache_ttl=10.0, + ) + + assert third["messages"] == [{"role": "user", "content": "run 2"}] + assert counter.read_text(encoding="utf-8") == "2" + + +def test_prefill_script_timeout_returns_error_without_hanging(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "slow.py" + script.write_text("import time\ntime.sleep(1)\nprint('late')\n", encoding="utf-8") + + result = _load_webui_prefill_context( + {"prefill_messages_script": str(script)}, + python_exe=sys.executable, + env={"PATH": os.environ.get("PATH", "")}, + timeout=0.05, + script_cache_ttl=0, + ) + + assert result["status"] == "error" + assert result["source"] == "script" + assert result["messages"] == [] + assert "timed out" in result["error"].lower() + + +def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): from api.streaming import _load_webui_prefill_context prefill = tmp_path / "prefill.json" - prefill.write_text(json.dumps([{"role": "user", "content": "Pinned context"}]), encoding="utf-8") + prefill.write_text( + json.dumps( + [ + {"role": "user", "content": "Pinned context"}, + {"role": "tool", "content": "drop invalid role"}, + {"role": "assistant", "content": "Useful assistant context"}, + {"role": "system", "content": " "}, + "not a message", + ] + ), + encoding="utf-8", + ) result = _load_webui_prefill_context({"prefill_messages_file": str(prefill)}, python_exe=sys.executable) assert result["status"] == "loaded" assert result["source"] == "file" assert result["label"] == "prefill.json" - assert result["messages"] == [{"role": "user", "content": "Pinned context"}] + assert result["messages"] == [ + {"role": "user", "content": "Pinned context"}, + {"role": "assistant", "content": "Useful assistant context"}, + ] + + +def test_public_prefill_status_strips_message_bodies(): + from api.streaming import _public_prefill_context_status + + public = _public_prefill_context_status( + { + "status": "loaded", + "source": "script", + "label": "recall.py", + "message_count": 1, + "messages": [{"role": "user", "content": "private recall payload"}], + } + ) + + assert public == { + "status": "loaded", + "source": "script", + "label": "recall.py", + "message_count": 1, + } + assert "messages" not in public def test_prefill_context_redacts_secret_shaped_errors(tmp_path): from api.streaming import _load_webui_prefill_context - missing = tmp_path / "missing.py" + script = tmp_path / "leaky.py" + script.write_text("import sys\nsys.stderr.write('sk-proj-abcdefghijklmnopqrstuvwxyz123456 leaked')\nsys.exit(2)\n", encoding="utf-8") result = _load_webui_prefill_context( - {"prefill_messages_script": str(missing)}, + {"prefill_messages_script": str(script)}, python_exe=sys.executable, + env={"PATH": os.environ.get("PATH", "")}, + script_cache_ttl=0, ) assert result["status"] == "error" assert result["messages"] == [] - assert "missing.py" in result["label"] - assert "token" not in result.get("error", "").lower() + assert "sk-proj" not in result.get("error", "") + assert "[REDACTED]" in result.get("error", "") From d43de571800322d70af81fd0ad6969e789cc5982 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Tue, 19 May 2026 10:27:58 -0400 Subject: [PATCH 11/23] test: Use generic redaction fixture --- tests/test_webui_prefill_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py index 930e0694..da9b61ba 100644 --- a/tests/test_webui_prefill_context.py +++ b/tests/test_webui_prefill_context.py @@ -150,7 +150,7 @@ def test_prefill_context_redacts_secret_shaped_errors(tmp_path): from api.streaming import _load_webui_prefill_context script = tmp_path / "leaky.py" - script.write_text("import sys\nsys.stderr.write('sk-proj-abcdefghijklmnopqrstuvwxyz123456 leaked')\nsys.exit(2)\n", encoding="utf-8") + script.write_text("import sys\nsys.stderr.write('api_key=redaction-test-placeholder leaked')\nsys.exit(2)\n", encoding="utf-8") result = _load_webui_prefill_context( {"prefill_messages_script": str(script)}, python_exe=sys.executable, From d3a07b8df697bd19ca64d55b87ef2e29da708773 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Tue, 19 May 2026 10:28:00 -0400 Subject: [PATCH 12/23] test: Use generic redaction fixture --- tests/test_webui_notes_sources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 33959517..7c1b5174 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -31,7 +31,7 @@ def test_notes_sources_redacts_tool_descriptions_and_omits_plain_file_tools(): servers = {"notion": {"name": "notion", "enabled": True, "active": True, "status": "healthy"}} tools = [ - {"server": "notion", "name": "search_pages", "description": "Search notes token=abc123SECRET"}, + {"server": "notion", "name": "search_pages", "description": "Search notes api_key=redaction-test-placeholder"}, ] [source] = _notes_sources_from_mcp_inventory(servers, tools) From 6e9f70904cdea7c26754c934995fa44dd072140f Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Tue, 19 May 2026 18:59:17 -0400 Subject: [PATCH 13/23] fix(memory): cover notes source locale keys --- static/i18n.js | 117 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index 67e7d1da..c79479d4 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -2295,6 +2295,19 @@ const LOCALES = { memory_saved: 'Memoria salvata', my_notes: 'Le Mie Note', user_profile: 'Profilo Utente', + 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`, + 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', + source_configured: 'configured', no_notes_yet: 'Ancora nessuna nota.', no_profile_yet: 'Ancora nessun profilo.', agent_soul: 'Anima dell\'Agente', @@ -3516,6 +3529,19 @@ const LOCALES = { memory_saved: 'メモリを保存しました', my_notes: 'マイノート', 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`, + 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', + source_configured: 'configured', no_notes_yet: 'まだノートはありません。', no_profile_yet: 'まだプロファイルはありません。', agent_soul: 'エージェントソウル', @@ -4475,6 +4501,19 @@ const LOCALES = { memory_saved: 'Память сохранена', my_notes: 'Мои заметки', 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`, + 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', + source_configured: 'configured', no_notes_yet: 'Пока нет заметок.', no_profile_yet: 'Пока нет профиля.', agent_soul: 'Душа агента', @@ -5648,6 +5687,19 @@ 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`, + 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', + source_configured: 'configured', no_notes_yet: 'No notes yet.', no_profile_yet: 'No profile yet.', agent_soul: 'Agent Soul', @@ -6988,6 +7040,19 @@ const LOCALES = { memory_saved: 'Notiz gespeichert.', my_notes: 'Meine Notizen', user_profile: 'Benutzerprofil', + 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`, + 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', + source_configured: 'configured', no_notes_yet: 'Noch keine Notizen.', no_profile_yet: 'Noch kein Profil.', agent_soul: 'Agenten-Seele', @@ -7950,6 +8015,19 @@ const LOCALES = { memory_saved: '记忆已保存', my_notes: '我的备注', 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`, + 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', + source_configured: 'configured', no_notes_yet: '暂无备注。', no_profile_yet: '暂无用户画像。', agent_soul: '智能体灵魂', @@ -10426,6 +10504,19 @@ const LOCALES = { memory_saved: 'Memória salva', my_notes: 'Minhas Notas', user_profile: 'Perfil do Usuário', + 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`, + 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', + source_configured: 'configured', no_notes_yet: 'Nenhuma nota ainda.', no_profile_yet: 'Nenhum perfil definido.', agent_soul: 'Alma do Agente', @@ -11551,6 +11642,19 @@ 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`, + 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', + source_configured: 'configured', no_notes_yet: 'No notes yet.', no_profile_yet: 'No profile yet.', agent_soul: 'Agent Soul', @@ -12689,6 +12793,19 @@ const LOCALES = { memory_saved: 'Mémoire sauvegardée', my_notes: 'Mes notes', user_profile: 'Profil utilisateur', + 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`, + 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', + source_configured: 'configured', no_notes_yet: 'Aucune note pour l\'instant.', no_profile_yet: 'Pas encore de profil.', agent_soul: 'Âme de l\'agent', From 50195c229bb19fbc0e57f22448a4d875dc21b8d6 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Fri, 22 May 2026 14:46:17 -0400 Subject: [PATCH 14/23] fix(chat): keep WebUI prefill file-only --- CHANGELOG.md | 2 +- api/streaming.py | 92 +------------------ tests/test_webui_prefill_context.py | 138 ++++++---------------------- 3 files changed, 33 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534bf096..7d796d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Added - Add non-sensitive SSE stream runtime diagnostics to deep health checks, including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. -- Add WebUI session prefill parity: browser-originated chat turns now load configured prefill context from `prefill_messages_file` or `prefill_messages_script`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. File prefill is cheap to read each turn; script prefill runs before SSE output starts, inherits only a minimal `PATH`/`HOME` environment, and uses a short path/mtime TTL cache to avoid re-shelling during rapid browser turns. +- Add WebUI session prefill parity for bounded JSON files: browser-originated chat turns can load configured prefill context from `prefill_messages_file`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. WebUI intentionally does not execute `prefill_messages_script`; executable recall should use the existing MCP/tool surface instead of a per-turn subprocess. ### Changed diff --git a/api/streaming.py b/api/streaming.py index a4d74d56..1fefa273 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -10,7 +10,6 @@ import mimetypes import os import queue import re -import subprocess import sys import threading import time @@ -253,47 +252,12 @@ _SECRET_SHAPED_RE = re.compile( r"[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}" ) -_PREFILL_SCRIPT_CACHE_TTL_SECONDS = 10.0 -_PREFILL_SCRIPT_CACHE_LOCK = threading.Lock() -_PREFILL_SCRIPT_CACHE: dict[tuple[str, int], tuple[float, dict]] = {} - - def _redact_prefill_status_text(text: str) -> str: """Return a short, non-secret diagnostic string for prefill status.""" clean = _SECRET_SHAPED_RE.sub("[REDACTED]", str(text or "")) return " ".join(clean.split())[:240] -def _prefill_script_env() -> dict[str, str]: - """Return the minimal environment inherited by configured prefill scripts.""" - return { - "PATH": os.environ.get("PATH", ""), - "HOME": os.environ.get("HOME", ""), - } - - -def _prefill_script_cache_get(cache_key: tuple[str, int], ttl: float) -> dict | None: - if ttl <= 0: - return None - now = time.monotonic() - with _PREFILL_SCRIPT_CACHE_LOCK: - cached = _PREFILL_SCRIPT_CACHE.get(cache_key) - if not cached: - return None - cached_at, result = cached - if now - cached_at > ttl: - _PREFILL_SCRIPT_CACHE.pop(cache_key, None) - return None - return copy.deepcopy(result) - - -def _prefill_script_cache_put(cache_key: tuple[str, int], result: dict, ttl: float) -> None: - if ttl <= 0: - return - with _PREFILL_SCRIPT_CACHE_LOCK: - _PREFILL_SCRIPT_CACHE[cache_key] = (time.monotonic(), copy.deepcopy(result)) - - def _valid_prefill_messages(value) -> list[dict]: """Normalize a prefill payload to role/content messages.""" if not isinstance(value, list): @@ -323,26 +287,16 @@ def _resolve_prefill_path(raw: str) -> Path: def _load_webui_prefill_context( config_data: Optional[dict] = None, - *, - python_exe: Optional[str] = None, - env: Optional[dict] = None, - timeout: float = 20.0, - script_cache_ttl: float = _PREFILL_SCRIPT_CACHE_TTL_SECONDS, ) -> dict: """Load configured WebUI session prefill messages. - Supports the same JSON-file shape used by Hermes Agent plus a WebUI-friendly - ``prefill_messages_script`` hook whose stdout becomes one user-role recall - message. Script output is intentionally ephemeral: it is passed to the - agent as prefill context and is not written into the session transcript. - - File prefill is read directly each turn. Script prefill executes before SSE - output starts, so successful script results are cached briefly by path/mtime - to avoid re-shelling on every rapid browser turn. + Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does + not execute a configured prefill script here; session recall that requires + code execution should go through the normal MCP/tool path instead of an + always-on per-turn subprocess before SSE starts. """ cfg = config_data if isinstance(config_data, dict) else get_config() file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "") - script_raw = os.getenv("HERMES_PREFILL_MESSAGES_SCRIPT", "") or str(cfg.get("prefill_messages_script") or "") if file_raw: path = _resolve_prefill_path(file_raw) label = path.name or "prefill file" @@ -353,42 +307,6 @@ def _load_webui_prefill_context( return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)} except Exception as exc: return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} - if script_raw: - path = _resolve_prefill_path(script_raw) - label = path.name or "prefill script" - if not path.exists(): - return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script not found"} - try: - mtime_ns = path.stat().st_mtime_ns - except OSError as exc: - return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} - cache_key = (str(path), mtime_ns) - cached = _prefill_script_cache_get(cache_key, script_cache_ttl) - if cached is not None: - return cached - exe = python_exe or sys.executable - cmd = [exe, str(path)] if path.suffix == ".py" else [str(path)] - try: - proc = subprocess.run( - cmd, - cwd=str(path.parent), - env=env, - text=True, - capture_output=True, - timeout=timeout, - check=False, - ) - except Exception as exc: - return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} - if proc.returncode != 0: - err = proc.stderr or proc.stdout or f"script exited with code {proc.returncode}" - return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(err)} - output = (proc.stdout or "").strip() - messages = [{"role": "user", "content": output}] if output else [] - status = "loaded" if messages else "empty" - result = {"status": status, "source": "script", "label": label, "messages": messages, "message_count": len(messages)} - _prefill_script_cache_put(cache_key, result, script_cache_ttl) - return result return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0} @@ -4099,7 +4017,7 @@ def _run_agent_streaming( # Read per-profile config at call time (not module-level snapshot) from api.config import get_config as _get_config _cfg = _get_config() - _prefill_context = _load_webui_prefill_context(_cfg, env=_prefill_script_env()) + _prefill_context = _load_webui_prefill_context(_cfg) _prefill_messages = _prefill_context.get('messages') or [] put('context_status', { 'session_id': session_id, diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py index da9b61ba..06a18e0c 100644 --- a/tests/test_webui_prefill_context.py +++ b/tests/test_webui_prefill_context.py @@ -2,98 +2,6 @@ from __future__ import annotations import json -import os -import stat -import sys -from pathlib import Path - - -def test_prefill_script_output_becomes_safe_user_message(tmp_path): - from api.streaming import _load_webui_prefill_context - - script = tmp_path / "recall.py" - script.write_text("print('JOPLIN SESSION RECALL\\nCurrent Context: loaded')\n", encoding="utf-8") - script.chmod(script.stat().st_mode | stat.S_IXUSR) - - result = _load_webui_prefill_context( - {"prefill_messages_script": str(script)}, - python_exe=sys.executable, - env={"PATH": os.environ.get("PATH", "")}, - ) - - assert result["status"] == "loaded" - assert result["source"] == "script" - assert result["label"] == "recall.py" - assert result["message_count"] == 1 - assert result["messages"] == [ - { - "role": "user", - "content": "JOPLIN SESSION RECALL\nCurrent Context: loaded", - } - ] - - -def test_prefill_script_uses_short_ttl_cache_keyed_by_path_and_mtime(tmp_path): - from api.streaming import _load_webui_prefill_context - - script = tmp_path / "recall.py" - counter = tmp_path / "counter.txt" - script.write_text( - "from pathlib import Path\n" - f"p=Path({str(counter)!r})\n" - "n=int(p.read_text() or '0') if p.exists() else 0\n" - "p.write_text(str(n+1))\n" - "print(f'run {n+1}')\n", - encoding="utf-8", - ) - - first = _load_webui_prefill_context( - {"prefill_messages_script": str(script)}, - python_exe=sys.executable, - env={"PATH": os.environ.get("PATH", "")}, - script_cache_ttl=10.0, - ) - second = _load_webui_prefill_context( - {"prefill_messages_script": str(script)}, - python_exe=sys.executable, - env={"PATH": os.environ.get("PATH", "")}, - script_cache_ttl=10.0, - ) - - assert first["messages"] == [{"role": "user", "content": "run 1"}] - assert second["messages"] == [{"role": "user", "content": "run 1"}] - assert counter.read_text(encoding="utf-8") == "1" - - script.write_text(script.read_text(encoding="utf-8") + "# invalidate\n", encoding="utf-8") - third = _load_webui_prefill_context( - {"prefill_messages_script": str(script)}, - python_exe=sys.executable, - env={"PATH": os.environ.get("PATH", "")}, - script_cache_ttl=10.0, - ) - - assert third["messages"] == [{"role": "user", "content": "run 2"}] - assert counter.read_text(encoding="utf-8") == "2" - - -def test_prefill_script_timeout_returns_error_without_hanging(tmp_path): - from api.streaming import _load_webui_prefill_context - - script = tmp_path / "slow.py" - script.write_text("import time\ntime.sleep(1)\nprint('late')\n", encoding="utf-8") - - result = _load_webui_prefill_context( - {"prefill_messages_script": str(script)}, - python_exe=sys.executable, - env={"PATH": os.environ.get("PATH", "")}, - timeout=0.05, - script_cache_ttl=0, - ) - - assert result["status"] == "error" - assert result["source"] == "script" - assert result["messages"] == [] - assert "timed out" in result["error"].lower() def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): @@ -113,7 +21,7 @@ def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): encoding="utf-8", ) - result = _load_webui_prefill_context({"prefill_messages_file": str(prefill)}, python_exe=sys.executable) + result = _load_webui_prefill_context({"prefill_messages_file": str(prefill)}) assert result["status"] == "loaded" assert result["source"] == "file" @@ -124,14 +32,31 @@ def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): ] +def test_prefill_script_config_is_ignored_in_webui(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "recall.py" + script.write_text("raise SystemExit('should not run')\n", encoding="utf-8") + + result = _load_webui_prefill_context({"prefill_messages_script": str(script)}) + + assert result == { + "status": "not_configured", + "source": "none", + "label": "", + "messages": [], + "message_count": 0, + } + + def test_public_prefill_status_strips_message_bodies(): from api.streaming import _public_prefill_context_status public = _public_prefill_context_status( { "status": "loaded", - "source": "script", - "label": "recall.py", + "source": "file", + "label": "prefill.json", "message_count": 1, "messages": [{"role": "user", "content": "private recall payload"}], } @@ -139,26 +64,17 @@ def test_public_prefill_status_strips_message_bodies(): assert public == { "status": "loaded", - "source": "script", - "label": "recall.py", + "source": "file", + "label": "prefill.json", "message_count": 1, } assert "messages" not in public -def test_prefill_context_redacts_secret_shaped_errors(tmp_path): - from api.streaming import _load_webui_prefill_context +def test_prefill_status_redactor_handles_secret_shaped_text(): + from api.streaming import _redact_prefill_status_text - script = tmp_path / "leaky.py" - script.write_text("import sys\nsys.stderr.write('api_key=redaction-test-placeholder leaked')\nsys.exit(2)\n", encoding="utf-8") - result = _load_webui_prefill_context( - {"prefill_messages_script": str(script)}, - python_exe=sys.executable, - env={"PATH": os.environ.get("PATH", "")}, - script_cache_ttl=0, - ) + redacted = _redact_prefill_status_text("api_key=redaction-test-placeholder leaked") - assert result["status"] == "error" - assert result["messages"] == [] - assert "sk-proj" not in result.get("error", "") - assert "[REDACTED]" in result.get("error", "") + assert "redaction-test-placeholder" not in redacted + assert "[REDACTED]" in redacted From 7305d470b95c52ffeddbac98b7bb4afb346d09d5 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Fri, 22 May 2026 14:54:41 -0400 Subject: [PATCH 15/23] feat(memory): gate third-party notes drawer --- CHANGELOG.md | 2 +- api/routes.py | 39 +++++++++++++++++++++++++++++++ static/panels.js | 5 ++++ tests/test_webui_notes_sources.py | 28 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8aa945..74cbf6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### 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. +- Add a default-off, 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 when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged. ## [v0.51.115] — 2026-05-22 — Release CM (stage-pr2731 — 1-PR — clarify prompt collapse/expand with chevron-icon polish) diff --git a/api/routes.py b/api/routes.py index d53f90c0..ef4a16d6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7878,6 +7878,7 @@ def _handle_memory_read(handler): "memory_mtime": mem_file.stat().st_mtime if mem_file.exists() else None, "user_mtime": user_file.stat().st_mtime if user_file.exists() else None, "soul_mtime": soul_file.stat().st_mtime if soul_file.exists() else None, + "external_notes_enabled": _external_notes_sources_enabled(), }, ) @@ -11558,6 +11559,29 @@ def _handle_mcp_tools_list(handler): }) +def _webui_truthy(value) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _external_notes_sources_enabled(config_data: dict | None = None) -> bool: + """Return whether the third-party notes drawer is explicitly enabled. + + The Memory panel is a primary surface, so this power-user drawer stays + default-off unless a deployment opts in through config or environment. + """ + env_value = os.getenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "") + if env_value: + return _webui_truthy(env_value) + cfg = config_data if isinstance(config_data, dict) else get_config() + if not isinstance(cfg, dict): + return False + return _webui_truthy( + cfg.get("webui_external_notes_sources") + or cfg.get("external_notes_sources") + or cfg.get("notes_sources_drawer") + ) + + _NOTES_SOURCE_SERVER_HINTS = { "joplin", "obsidian", "notion", "llm-wiki", "llmwiki", "wiki", "notes", "note", "knowledge", "kb", "readwise", "logseq", @@ -11707,6 +11731,16 @@ def _notes_sources_from_mcp_inventory(server_summaries: dict, tools: list[dict]) def _handle_notes_sources_list(handler): """List note/knowledge MCP sources for the WebUI Notes drawer.""" cfg = get_config() + if not _external_notes_sources_enabled(cfg): + return j(handler, { + "enabled": False, + "sources": [], + "source": "disabled", + "inventory_scope": "disabled_by_default", + "attach_supported": False, + "automatic_recall_unchanged": True, + "recent_ai_notes": [], + }) servers = cfg.get("mcp_servers", {}) if not isinstance(servers, dict): servers = {} @@ -11721,6 +11755,7 @@ def _handle_notes_sources_list(handler): tools = _mcp_tools_from_registry(server_summaries) source = "tool_registry" if tools else "none" return j(handler, { + "enabled": True, "sources": _notes_sources_from_mcp_inventory(server_summaries, tools), "source": source, "inventory_scope": "already_known_runtime_only", @@ -11936,6 +11971,8 @@ def _joplin_recent_ai_notes(*, limit: int = 6) -> list[dict]: def _handle_notes_search(handler, parsed): + if not _external_notes_sources_enabled(): + return j(handler, {"source": "disabled", "results": [], "error": "External notes sources are disabled."}, status=404) query = parse_qs(parsed.query or "") source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower() q = str(query.get("q", [""])[0] or "").strip() @@ -11952,6 +11989,8 @@ def _handle_notes_search(handler, parsed): def _handle_notes_item(handler, parsed): + if not _external_notes_sources_enabled(): + return j(handler, {"source": "disabled", "error": "External notes sources are disabled."}, status=404) query = parse_qs(parsed.query or "") source = str(query.get("source", ["joplin"])[0] or "joplin").strip().lower() note_id = str(query.get("id", [""])[0] or "").strip() diff --git a/static/panels.js b/static/panels.js index 1b2adf1f..dbada1b3 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3837,6 +3837,7 @@ async function previewExternalNote(source, id) { } async function openMemorySection(section, el) { + if (section === 'external_notes' && _memoryData && !_memoryData.external_notes_enabled) return; _currentMemorySection = section; document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active')); if (el) el.classList.add('active'); @@ -5228,12 +5229,16 @@ async function loadMemory(force) { try { const data = await api('/api/memory'); _memoryData = data; + if (_currentMemorySection === 'external_notes' && !data.external_notes_enabled) { + _currentMemorySection = null; + } if (_currentMemorySection === 'external_notes') { await loadNotesSources(!!force); } if (panel) { panel.innerHTML = ''; for (const s of MEMORY_SECTIONS) { + if (s.key === 'external_notes' && !_memoryData.external_notes_enabled) continue; const el = document.createElement('button'); el.type = 'button'; el.className = 'side-menu-item'; diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py index 7c1b5174..bcf8c8fe 100644 --- a/tests/test_webui_notes_sources.py +++ b/tests/test_webui_notes_sources.py @@ -60,6 +60,26 @@ def test_notes_sources_shows_configured_note_servers_without_tool_inventory(): assert sources[0]["status"] == "configured" +def test_external_notes_sources_drawer_is_default_off(monkeypatch): + from api import routes + + monkeypatch.delenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", raising=False) + + assert routes._external_notes_sources_enabled({}) is False + assert routes._external_notes_sources_enabled({"webui_external_notes_sources": False}) is False + + +def test_external_notes_sources_drawer_can_be_enabled_by_config_or_env(monkeypatch): + from api import routes + + monkeypatch.delenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", raising=False) + assert routes._external_notes_sources_enabled({"webui_external_notes_sources": True}) is True + assert routes._external_notes_sources_enabled({"external_notes_sources": "yes"}) is True + + monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1") + assert routes._external_notes_sources_enabled({}) is True + + def test_joplin_search_notes_returns_safe_snippets(monkeypatch): from api import routes @@ -162,6 +182,14 @@ def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes(): assert "📚" not in notes_block +def test_external_notes_menu_item_is_default_off_from_memory_payload(): + from pathlib import Path + + panels = Path("static/panels.js").read_text(encoding="utf-8") + assert "external_notes_enabled" in panels + assert "if (s.key === 'external_notes' && !_memoryData.external_notes_enabled) continue;" in panels + + def test_external_notes_search_button_matches_minimal_dark_controls(): from pathlib import Path From 9bd595de408034e9351b3d2aee8423d4c13e2a33 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sun, 24 May 2026 14:51:36 -0400 Subject: [PATCH 16/23] fix: avoid stamping display personality on sessions --- CHANGELOG.md | 4 + api/models.py | 14 +--- tests/test_default_personality.py | 121 ++++++------------------------ tests/test_issue798.py | 18 +++++ 4 files changed, 46 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0e742b..09db7ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- New WebUI sessions no longer persist `display.personality` into per-session `Session.personality`; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845. + ## [v0.51.128] — 2026-05-24 — Release CZ (stage-batch10 — 2-PR perf + correctness batch) ### Fixed diff --git a/api/models.py b/api/models.py index 3c8d895d..0a734bc5 100644 --- a/api/models.py +++ b/api/models.py @@ -1945,18 +1945,6 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p if model_provider: effective_model_provider = model_provider - # Read default personality from config display.personality - _default_personality = None - try: - from api.config import get_config as _get_cfg_for_personality - _cfg_personality = (_get_cfg_for_personality().get('display') or {}).get('personality') - if _cfg_personality and isinstance(_cfg_personality, str): - _cfg_personality = _cfg_personality.strip().lower() - if _cfg_personality and _cfg_personality not in ('default', 'none', 'neutral'): - _default_personality = _cfg_personality - except Exception: - pass - wt = worktree_info if isinstance(worktree_info, dict) else None workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace s = Session( @@ -1965,7 +1953,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p model_provider=effective_model_provider, profile=profile, project_id=project_id, - personality=_default_personality, + personality=None, worktree_path=wt.get('path') if wt else None, worktree_branch=wt.get('branch') if wt else None, worktree_repo_root=wt.get('repo_root') if wt else None, diff --git a/tests/test_default_personality.py b/tests/test_default_personality.py index 28169a0a..98d4c920 100644 --- a/tests/test_default_personality.py +++ b/tests/test_default_personality.py @@ -1,123 +1,48 @@ -"""Test that new_session() reads display.personality from config and uses it as default. +"""Regression coverage for display.personality not becoming durable session state. -Regression test for the feature that makes /personality taleb sticky across -new sessions — when display.personality is set in config.yaml, every new -session should inherit it without requiring an explicit /personality command. +Issue #2845: display.personality is a display/default hint, but new_session() +previously copied it into Session.personality. That made cosmetic config durable +per-session state and could override profile-scoped behavior later. Only an +explicit /api/personality/set call should persist Session.personality. """ -import pytest from unittest.mock import patch -# --------------------------------------------------------------------------- -# R1: new_session() inherits display.personality from config -# --------------------------------------------------------------------------- - -def test_new_session_reads_default_personality_from_config(): - """When display.personality is set to 'taleb', new_session() should - create a Session with personality='taleb'.""" +def test_new_session_does_not_inherit_display_personality_from_config(): + """display.personality='taleb' must not stamp Session.personality.""" import api.models as m import api.config as cfg_mod - _cfg = { + cfg = { "display": {"personality": "taleb"}, "agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb", "tone": "blunt"}}}, } - with patch.object(cfg_mod, "get_config", return_value=_cfg), \ + with patch.object(cfg_mod, "get_config", return_value=cfg), \ patch.object(m.Session, "save", return_value=None): s = m.new_session(workspace="/tmp/test-personality") - assert s.personality == "taleb", ( - f"Expected personality='taleb', got {s.personality!r}" - ) + try: + assert s.personality is None + finally: + with m.LOCK: + m.SESSIONS.pop(s.session_id, None) -# --------------------------------------------------------------------------- -# R2: 'none', 'default', 'neutral' are treated as no personality -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("personality_value", ["none", "default", "neutral", ""]) -def test_new_session_ignores_neutral_personality_values(personality_value): - """Values like 'none', 'default', 'neutral', and '' should NOT be set as - the session personality — they mean 'no personality overlay'.""" - +def test_new_session_still_defaults_to_no_personality_when_config_missing(): + """Missing display.personality continues to produce personality=None.""" import api.models as m import api.config as cfg_mod - _cfg = { - "display": {"personality": personality_value}, - "agent": {"personalities": {}}, - } + cfg = {"agent": {"personalities": {}}} - with patch.object(cfg_mod, "get_config", return_value=_cfg), \ - patch.object(m.Session, "save", return_value=None): - s = m.new_session(workspace="/tmp/test-personality-neutral") - - assert s.personality is None, ( - f"Expected None for display.personality={personality_value!r}, " - f"got {s.personality!r}" - ) - - -# --------------------------------------------------------------------------- -# R3: Missing display.personality → personality=None -# --------------------------------------------------------------------------- - -def test_new_session_no_personality_when_config_missing(): - """When config has no display.personality (or display section is absent), - new_session() should set personality=None.""" - - import api.models as m - import api.config as cfg_mod - - _cfg = {"agent": {"personalities": {}}} # No display section at all - - with patch.object(cfg_mod, "get_config", return_value=_cfg), \ + with patch.object(cfg_mod, "get_config", return_value=cfg), \ patch.object(m.Session, "save", return_value=None): s = m.new_session(workspace="/tmp/test-personality-missing") - assert s.personality is None - - -# --------------------------------------------------------------------------- -# R4: Config exception is handled gracefully → personality=None -# --------------------------------------------------------------------------- - -def test_new_session_handles_config_exception_gracefully(): - """If get_config() raises, we should still get a valid session with - personality=None (the try/except should swallow the error).""" - - import api.models as m - import api.config as cfg_mod - - def _boom(): - raise RuntimeError("config exploded") - - with patch.object(cfg_mod, "get_config", side_effect=_boom), \ - patch.object(m.Session, "save", return_value=None): - s = m.new_session(workspace="/tmp/test-personality-boom") - - assert s.personality is None - - -# --------------------------------------------------------------------------- -# R5: display.personality is case-insensitive -# --------------------------------------------------------------------------- - -def test_new_session_personality_is_case_insensitive(): - """display.personality='Taleb' should be normalized to 'taleb'.""" - - import api.models as m - import api.config as cfg_mod - - _cfg = { - "display": {"personality": "Taleb"}, - "agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb"}}}, - } - - with patch.object(cfg_mod, "get_config", return_value=_cfg), \ - patch.object(m.Session, "save", return_value=None): - s = m.new_session(workspace="/tmp/test-personality-case") - - assert s.personality == "taleb" \ No newline at end of file + try: + assert s.personality is None + finally: + with m.LOCK: + m.SESSIONS.pop(s.session_id, None) diff --git a/tests/test_issue798.py b/tests/test_issue798.py index 5f5fd6a1..7957570e 100644 --- a/tests/test_issue798.py +++ b/tests/test_issue798.py @@ -290,6 +290,24 @@ def test_new_session_uses_explicit_profile_default_model_and_provider(tmp_path, m.SESSIONS.pop(s.session_id, None) +def test_new_session_does_not_persist_display_personality(monkeypatch, tmp_path): + """display.personality is a UI/default hint, not durable per-session state.""" + import api.config as c + import api.models as m + + monkeypatch.setattr(c, "get_config", lambda: {"display": {"personality": "kawaii"}}) + with patch.object(m.Session, 'save', return_value=None): + s = m.new_session(workspace=str(tmp_path), profile="default") + try: + assert s.personality is None, ( + "new_session() must not stamp display.personality into the persistent " + "Session.personality field; only explicit /api/personality/set should." + ) + finally: + with m.LOCK: + m.SESSIONS.pop(s.session_id, None) + + def test_get_hermes_home_for_profile_rejects_path_traversal(): """R19j: get_hermes_home_for_profile() must reject names that don't match _PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base From b0f7a7bdff67c333326da76191372eb679873842 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sun, 24 May 2026 15:14:28 -0400 Subject: [PATCH 17/23] feat: add PWA sidebar edge swipe --- CHANGELOG.md | 4 ++ static/boot.js | 65 +++++++++++++++++++++++++++++++++ tests/test_pwa_sidebar_swipe.py | 43 ++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/test_pwa_sidebar_swipe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c634e625..9dc2685f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Added + +- Installed/mobile PWA sessions now support an edge swipe from the left side of the screen to open the mobile sidebar drawer, while preserving the existing hamburger and overlay controls. + ## [v0.51.129] — 2026-05-24 — Release DA (stage-batch11 — 4-PR feature + perf batch) ### Performance diff --git a/static/boot.js b/static/boot.js index b1c67dd6..177eba93 100644 --- a/static/boot.js +++ b/static/boot.js @@ -228,6 +228,71 @@ function closeMobileSidebar(){ if(overlay)overlay.classList.remove('visible'); } +const _PWA_SIDEBAR_SWIPE_EDGE=28; +const _PWA_SIDEBAR_SWIPE_TRIGGER=72; +const _PWA_SIDEBAR_SWIPE_MAX_VERTICAL=48; +let _pwaSidebarSwipe=null; + +function _isPwaStandalone(){ + try{ + return document.documentElement.classList.contains('pwa-standalone') + || window.matchMedia('(display-mode: standalone)').matches + || window.navigator.standalone===true; + }catch(_){return false;} +} + +function _isInteractiveSwipeTarget(target){ + try{return !!(target&&target.closest&&target.closest('input,textarea,select,button,a,[contenteditable="true"],.topbar-chips,.composer-left,.sidebar,.rightpanel'));} + catch(_){return false;} +} + +function _openMobileSidebarFromGesture(){ + if(_isDesktopWidth())return; + const sidebar=document.querySelector('.sidebar'); + const overlay=$('mobileOverlay'); + if(!sidebar)return; + const layout=document.querySelector('.layout'); + if(layout)layout.classList.remove('sidebar-collapsed'); + sidebar.classList.remove('sidebar-collapsed'); + try{document.documentElement.removeAttribute('data-sidebar-collapsed');}catch(_){} + sidebar.classList.add('mobile-open'); + if(overlay)overlay.classList.add('visible'); +} + +function _onPwaSidebarSwipeStart(e){ + if(!_isPwaStandalone()||_isDesktopWidth())return; + if(e.pointerType==='mouse'||(e.pointerType&&e.pointerType!=='touch'&&e.pointerType!=='pen'))return; + if(document.querySelector('.sidebar')?.classList.contains('mobile-open'))return; + const clientX=Number(e.clientX)||0; + if(clientX>_PWA_SIDEBAR_SWIPE_EDGE)return; + if(_isInteractiveSwipeTarget(e.target))return; + _pwaSidebarSwipe={startX:clientX,startY:Number(e.clientY)||0,active:true,opened:false}; +} + +function _onPwaSidebarSwipeMove(e){ + const swipe=_pwaSidebarSwipe; + if(!swipe||!swipe.active||swipe.opened)return; + const dx=(Number(e.clientX)||0)-swipe.startX; + const dy=(Number(e.clientY)||0)-swipe.startY; + if(dx<0||Math.abs(dy)>_PWA_SIDEBAR_SWIPE_MAX_VERTICAL*1.5){_pwaSidebarSwipe=null;return;} + if(dx>=_PWA_SIDEBAR_SWIPE_TRIGGER&&Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL&&dx>Math.abs(dy)*1.5){ + if(e.cancelable)e.preventDefault(); + swipe.opened=true; + _openMobileSidebarFromGesture(); + } +} + +function _onPwaSidebarSwipeEnd(){_pwaSidebarSwipe=null;} +function _onPwaSidebarSwipeCancel(){_pwaSidebarSwipe=null;} + +function _installPwaSidebarSwipeGesture(){ + window.addEventListener('pointerdown', _onPwaSidebarSwipeStart, {passive:true}); + window.addEventListener('pointermove', _onPwaSidebarSwipeMove, {passive:false}); + window.addEventListener('pointerup', _onPwaSidebarSwipeEnd, {passive:true}); + window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel, {passive:true}); +} +_installPwaSidebarSwipeGesture(); + // ── Desktop sidebar collapse toggle ──────────────────────────────────────── // Two discoverability paths into the same state: // (1) Click the already-active rail icon → collapse / expand the sidebar. diff --git a/tests/test_pwa_sidebar_swipe.py b/tests/test_pwa_sidebar_swipe.py new file mode 100644 index 00000000..4c8b4b5c --- /dev/null +++ b/tests/test_pwa_sidebar_swipe.py @@ -0,0 +1,43 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") + + +def test_pwa_edge_swipe_gesture_is_registered_for_mobile_sidebar(): + assert "function _installPwaSidebarSwipeGesture" in BOOT_JS + assert "window.addEventListener('pointerdown', _onPwaSidebarSwipeStart" in BOOT_JS + assert "window.addEventListener('pointermove', _onPwaSidebarSwipeMove" in BOOT_JS + assert "window.addEventListener('pointerup', _onPwaSidebarSwipeEnd" in BOOT_JS + assert "window.addEventListener('pointercancel', _onPwaSidebarSwipeCancel" in BOOT_JS + + +def test_pwa_sidebar_swipe_is_edge_gated_standalone_and_horizontal(): + assert "_isPwaStandalone()" in BOOT_JS + assert "_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS + assert "_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS + assert "_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS + assert "clientX>_PWA_SIDEBAR_SWIPE_EDGE" in BOOT_JS.replace(" ", "") + assert "dx>=_PWA_SIDEBAR_SWIPE_TRIGGER" in BOOT_JS.replace(" ", "") + assert "Math.abs(dy)<=_PWA_SIDEBAR_SWIPE_MAX_VERTICAL" in BOOT_JS.replace(" ", "") + assert "dx>Math.abs(dy)*1.5" in BOOT_JS.replace(" ", "") + + assert "input,textarea,select,button,a,[contenteditable=\"true\"],.topbar-chips,.composer-left,.sidebar,.rightpanel" in BOOT_JS + assert ".messages" not in BOOT_JS[BOOT_JS.find("function _isInteractiveSwipeTarget"):BOOT_JS.find("function _openMobileSidebarFromGesture")] + + +def test_pwa_sidebar_swipe_opens_existing_mobile_drawer_without_desktop_collapse(): + assert "_openMobileSidebarFromGesture" in BOOT_JS + assert "sidebar.classList.remove('sidebar-collapsed')" in BOOT_JS + assert "sidebar.classList.add('mobile-open')" in BOOT_JS + assert "overlay.classList.add('visible')" in BOOT_JS + assert "toggleSidebar(" not in BOOT_JS[BOOT_JS.find("function _openMobileSidebarFromGesture"):BOOT_JS.find("function _installPwaSidebarSwipeGesture")] + + +def test_pwa_sidebar_swipe_does_not_disable_horizontal_scrollers_globally(): + compact = STYLE_CSS.replace(" ", "") + assert "html{touch-action" not in compact + assert "body{touch-action" not in compact + assert ".layout{touch-action" not in compact From f0b08547739ad473ef33ba58de6b121a656a6a9d Mon Sep 17 00:00:00 2001 From: Charanis Date: Sun, 24 May 2026 16:52:59 +0200 Subject: [PATCH 18/23] fix: preserve webui launcher environment (cherry picked from commit 2297ab4db854b52b20cdd34731cd82e8cc5bdb72) --- bootstrap.py | 12 ++++++++++-- ctl.sh | 4 +++- tests/test_bootstrap_dotenv.py | 12 ++++++++++++ tests/test_ctl_script.py | 7 +++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/bootstrap.py b/bootstrap.py index 92d08245..a1b7ea62 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -28,8 +28,8 @@ def _load_repo_dotenv() -> None: ``python3 bootstrap.py`` directly behaves identically to ``./start.sh``. Variables are set unconditionally (matching shell source semantics), so a value in .env overrides one already present in the shell environment. - To keep a CLI-supplied value, unset it from .env or launch via start.sh - and override there. + ``ctl.sh`` sets HERMES_WEBUI_PRESERVE_ENV=1 when it has already resolved + launcher-specific values such as HERMES_HOME or HERMES_WEBUI_STATE_DIR. Only loads the webui repo .env — not ~/.hermes/.env, which the server loads independently at startup for provider credentials. @@ -41,6 +41,12 @@ def _load_repo_dotenv() -> None: if not env_path.exists(): return try: + preserve_existing = os.getenv("HERMES_WEBUI_PRESERVE_ENV", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: @@ -52,6 +58,8 @@ def _load_repo_dotenv() -> None: k = k[7:].strip() v = v.strip().strip('"').strip("'") if k: + if preserve_existing and k in os.environ: + continue os.environ[k] = v except Exception as exc: import sys as _sys diff --git a/ctl.sh b/ctl.sh index 132a146b..bba8c58f 100755 --- a/ctl.sh +++ b/ctl.sh @@ -219,7 +219,9 @@ start_cmd() { : >> "${LOG_FILE}" ( cd "${REPO_ROOT}" - exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"} + trap '' HUP + export HERMES_WEBUI_PRESERVE_ENV=1 + exec nohup "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"} ) >> "${LOG_FILE}" 2>&1 & pid=$! diff --git a/tests/test_bootstrap_dotenv.py b/tests/test_bootstrap_dotenv.py index da8715bd..2f354d1f 100644 --- a/tests/test_bootstrap_dotenv.py +++ b/tests/test_bootstrap_dotenv.py @@ -112,6 +112,18 @@ class TestLoadRepoDotenv: self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n") assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0" + def test_preserve_existing_env_keeps_ctl_overrides(self, tmp_path): + """ctl.sh can ask bootstrap.py to keep wrapper-provided env values.""" + os.environ["HERMES_WEBUI_PRESERVE_ENV"] = "1" + os.environ["HERMES_HOME"] = "/runtime/hermesOne" + os.environ["HERMES_WEBUI_PASSWORD"] = "" + self._run( + tmp_path, + "HERMES_HOME=/repo/default\nHERMES_WEBUI_PASSWORD=repo-password\n", + ) + assert os.environ.get("HERMES_HOME") == "/runtime/hermesOne" + assert os.environ.get("HERMES_WEBUI_PASSWORD") == "" + def test_does_not_set_empty_values(self, tmp_path): """A key whose value is empty after stripping is not set to a non-empty string.""" os.environ.pop("HERMES_EMPTY_KEY", None) diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index 95134fc9..526d5ded 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -138,6 +138,13 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs( assert not pid_file.exists() +def test_start_uses_nohup_so_daemon_survives_launcher_exit(): + ctl_text = CTL.read_text(encoding="utf-8") + + assert "trap '' HUP" in ctl_text + assert 'exec nohup "${python_exe}"' in ctl_text + + def test_start_loads_dotenv_but_inline_overrides_win(tmp_path): repo_root = tmp_path / "repo" repo_root.mkdir() From 7c460ef7b1f425decbb21d7ae64a834bf38486fe Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sun, 24 May 2026 17:54:14 -0400 Subject: [PATCH 19/23] fix(i18n): add Turkish notes-source strings --- static/i18n.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/static/i18n.js b/static/i18n.js index 460a837a..d2f713f8 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -14445,6 +14445,19 @@ const LOCALES = { memory_saved: 'Bellek kaydedildi', my_notes: 'Notlarım', user_profile: 'Kullanıcı Profili', + 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`, + 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', + source_configured: 'configured', no_notes_yet: 'Henüz not yok.', no_profile_yet: 'Henüz profil yok.', agent_soul: 'Ajan Ruhu', From 0279f1b6df5c29975e0241256ee3612206de6ab5 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 24 May 2026 15:39:21 +0800 Subject: [PATCH 20/23] Apply zh-CN session-time label clarifications from #2882 (ycj) PR #2882 was based on stale master (66de2367, pre-stage-batch7); naive merge would delete 5,627 lines of subsequent work. Extracted the actual zh-CN diff and applied it on top of fresh stage. Co-authored-by: john --- static/i18n.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index d103d996..fcaae044 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -7871,10 +7871,10 @@ const LOCALES = { new_conversation: '新建对话', filter_conversations: '筛选对话…', session_time_unknown: '未知', - session_time_minutes_ago: (n) => `${n}分`, - session_time_hours_ago: (n) => `${n}小时`, - session_time_days_ago: (n) => `${n}天`, - session_time_last_week: '1周', + session_time_minutes_ago: (n) => `${n}分钟前`, + session_time_hours_ago: (n) => `${n}小时前`, + session_time_days_ago: (n) => `${n}天前`, + session_time_last_week: '上周', session_time_bucket_today: '今天', session_time_bucket_yesterday: '昨天', session_time_bucket_this_week: '本周', @@ -9416,7 +9416,7 @@ const LOCALES = { session_time_bucket_today: '\u4eca\u5929', session_time_bucket_yesterday: '\u6628\u5929', session_time_days_ago: (d) => `${d}\u5929`, - session_time_hours_ago: (h) => `${h}\u5c0f\u6642`, + session_time_hours_ago: (h) => `${h}\u5c0f\u6642`, session_time_last_week: '1\u9031', session_time_minutes_ago: (m) => `${m}\u5206`, session_time_unknown: '\u672a\u77e5', From 1ec0bbc9e080091978aff34e40374a1c125b4284 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 23:09:23 +0000 Subject: [PATCH 21/23] =?UTF-8?q?Stage-batch13:=20PR=20#2882=20polish=20?= =?UTF-8?q?=E2=80=94=20fix=20zh-TW=20indent=20+=20CHANGELOG=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-pick of #2882 brought in an accidental two-space indent on a zh-TW key. Restored the existing two-space indentation level so the zh-CN clarification stays the only behavioural change. --- CHANGELOG.md | 2 ++ static/i18n.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ffde52..d95e06ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - New WebUI sessions no longer persist `display.personality` into per-session `Session.personality`; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845. +- Clarify zh-CN (Simplified Chinese) session-time relative labels to include explicit "ago" context (`${n}分钟前`, `${n}小时前`, `${n}天前`) and the more natural last-week phrasing (`上周`) instead of the previous bare-unit shorthand. Also corrects a small indentation glitch in the zh-TW (Traditional Chinese) locale. + ## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab) ### Fixed diff --git a/static/i18n.js b/static/i18n.js index fcaae044..3848ca06 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -9416,7 +9416,7 @@ const LOCALES = { session_time_bucket_today: '\u4eca\u5929', session_time_bucket_yesterday: '\u6628\u5929', session_time_days_ago: (d) => `${d}\u5929`, - session_time_hours_ago: (h) => `${h}\u5c0f\u6642`, + session_time_hours_ago: (h) => `${h}\u5c0f\u6642`, session_time_last_week: '1\u9031', session_time_minutes_ago: (m) => `${m}\u5206`, session_time_unknown: '\u672a\u77e5', From 376fb78906d2f334c0a5224633c89072230d0821 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 23:09:48 +0000 Subject: [PATCH 22/23] Stage-batch13: CHANGELOG for #2873 launcher env PR was fork-PR-style with no CHANGELOG entry; added an entry describing the launcher-env-preserve behavior change. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d95e06ab..65005336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - Clarify zh-CN (Simplified Chinese) session-time relative labels to include explicit "ago" context (`${n}分钟前`, `${n}小时前`, `${n}天前`) and the more natural last-week phrasing (`上周`) instead of the previous bare-unit shorthand. Also corrects a small indentation glitch in the zh-TW (Traditional Chinese) locale. +- The WebUI launcher (`ctl.sh` + `bootstrap.py`) now preserves environment variables that have already been resolved by the shell (for example `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_WEBUI_HOST`) instead of letting a repo-level `.env` clobber them mid-launch. The `.env` keeps working as a default-only source for unset variables. + ## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab) ### Fixed From d012436cb4d6298c6b57b9ba2537b503385ce509 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 24 May 2026 23:42:37 +0000 Subject: [PATCH 23/23] Stamp CHANGELOG for v0.51.131 (Release DC / stage-batch13 / 6-PR notes-drawer + context-parity + PWA-swipe + locale polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus Advisor verdict: SHIP-AS-IS. Zero MUST-FIX, three SHOULD-FIX filed as follow-up issues: - Notes drawer: 10 non-en locales ship English fallback (default-off so user impact = 0) - _joplin_api_get URL-token defense-in-depth (move to Authorization header) - prefill_messages setattr cache-reuse safety on older agent builds 6,503 pytest passed (sequential mode — xdist not supported by test infra). --- CHANGELOG.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4597a27..a1f805e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,27 +3,37 @@ ## [Unreleased] +## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish) + ### Added -- Installed/mobile PWA sessions now support an edge swipe from the left side of the screen to open the mobile sidebar drawer, while preserving the existing hamburger and overlay controls. +- **PR #2868** by @AJV20 — Installed/mobile PWA sessions now support an edge swipe from the left side of the screen to open the mobile sidebar drawer, while preserving the existing hamburger and overlay controls. PWA-standalone-gated, edge X<28px, vertical-tolerance 48px, interactive-target exclusion. Defends against accidental triggers from text selection or button taps. -- Add a default-off, 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 when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged. +- **PR #2527** by @AJV20 — Default-off, read-only Third-party notes drawer in the Memory panel. Lists configured note/knowledge MCP sources (Joplin, Obsidian, Notion, llm-wiki) when explicitly enabled via `webui_external_notes_sources` config or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`. Automatic session recall unchanged. 4 API endpoints (`/api/notes/sources`, `/api/notes/search`, `/api/notes/item`, plus `external_notes_enabled` in memory_read response) all gated behind the feature flag. -- Add non-sensitive SSE stream runtime diagnostics to deep health checks, including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. +- **PR #2547** by @AJV20 — SSE stream runtime diagnostics in deep health checks: active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Non-sensitive payload only. -- Add WebUI session prefill parity for bounded JSON files: browser-originated chat turns can load configured prefill context from `prefill_messages_file`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. WebUI intentionally does not execute `prefill_messages_script`; executable recall should use the existing MCP/tool surface instead of a per-turn subprocess. +- **PR #2547** by @AJV20 — WebUI session prefill parity for bounded JSON files. Browser-originated chat turns can load configured prefill context from `prefill_messages_file`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies. WebUI intentionally does not execute `prefill_messages_script`; executable recall should go through the existing MCP/tool surface. Backward-compatible: degrades gracefully on older agent builds that don't support the `prefill_messages` kwarg. ### Changed -- Add browser-surface session context to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts while keeping the metadata ephemeral and out of saved history. WebUI progress guidance now explicitly preserves the normal Hermes messaging style instead of encouraging extra browser-only status chatter. +- **PR #2547** by @AJV20 — Browser-surface session context is now attached to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts. Context is ephemeral (not saved to history). WebUI progress guidance now preserves the normal Hermes messaging style instead of encouraging extra browser-only status chatter. ### Fixed -- New WebUI sessions no longer persist `display.personality` into per-session `Session.personality`; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845. +- **PR #2865** by @AJV20 — New WebUI sessions no longer persist `display.personality` into per-session `Session.personality`; only explicit personality changes remain durable, preventing stale global display defaults from overriding profile-scoped session behavior. Closes #2845. + +- **PR #2882** by @ycj — zh-CN (Simplified Chinese) session-time relative labels are now clearer: `${n}分钟前`, `${n}小时前`, `${n}天前`, and the more natural last-week phrasing `上周` instead of the previous bare-unit shorthand. Also corrects a small indentation glitch in the zh-TW (Traditional Chinese) locale. (Cherry-picked onto fresh stage with `Co-authored-by` attribution — original PR was based on stale master.) + +- **PR #2873** by @Charanis — The WebUI launcher (`ctl.sh` + `bootstrap.py`) now preserves environment variables that have already been resolved by the shell (for example `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_WEBUI_HOST`) instead of letting a repo-level `.env` clobber them mid-launch. The `.env` keeps working as a default-only source for unset variables, gated by `HERMES_WEBUI_PRESERVE_ENV=1` set by the launcher subshell. + +### Notes + +- **6,503 pytest passed** (sequential mode; the test infrastructure uses a single test server that doesn't support xdist parallelism — known limitation, tracked separately). +- **Opus Advisor verdict: SHIP-AS-IS.** Zero MUST-FIX. Three SHOULD-FIX items filed as follow-up issues (incomplete locale coverage for notes-drawer i18n keys, `_joplin_api_get` URL-token defense-in-depth, prefill `setattr` cache-reuse safety net). +- **#2527 i18n coverage**: 10 of the 11 non-en locales currently ship the English string `'Third-party notes'` for the drawer header. Since the drawer is default-off, user impact is zero today; follow-up issue tracks proper translations before any default-on transition. -- Clarify zh-CN (Simplified Chinese) session-time relative labels to include explicit "ago" context (`${n}分钟前`, `${n}小时前`, `${n}天前`) and the more natural last-week phrasing (`上周`) instead of the previous bare-unit shorthand. Also corrects a small indentation glitch in the zh-TW (Traditional Chinese) locale. -- The WebUI launcher (`ctl.sh` + `bootstrap.py`) now preserves environment variables that have already been resolved by the shell (for example `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_WEBUI_HOST`) instead of letting a repo-level `.env` clobber them mid-launch. The `.env` keeps working as a default-only source for unset variables. ## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab)