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)