From cec1e87d043d58a140f4616c81d24f7280974e23 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 18 May 2026 03:45:02 +0000 Subject: [PATCH] Stage 382: PR #2500 (with whitespace polish on panels.js + ARCHITECTURE.md) --- ARCHITECTURE.md | 6 +- CHANGELOG.md | 4 + api/routes.py | 25 ++++-- static/i18n.js | 22 +++++ static/icons.js | 1 + static/panels.js | 11 ++- tests/test_security_redaction.py | 2 +- tests/test_soul_memory.py | 143 +++++++++++++++++++++++++++++++ 8 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 tests/test_soul_memory.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bad8dea6..6b865018 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1318,7 +1318,7 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3). /api/crons/output ?job_id=X&limit=N -> {outputs: [{filename, content}]} /api/skills All skills. Returns {skills: [{name, description, category}]} /api/skills/content ?name=X -> full skill data including SKILL.md content - /api/memory MEMORY.md + USER.md. Returns {memory, user, *_path, *_mtime} + /api/memory MEMORY.md + USER.md + SOUL.md. Returns {memory, user, soul, *_path, *_mtime} ### POST Endpoints Added in Sprint 3 @@ -1453,7 +1453,7 @@ fetches GET /api/skills/content and renders in the right panel using `showPrevie #### Memory Panel `loadMemory()` fetches GET /api/memory (reads MEMORY.md + USER.md from -~/.hermes/memories/), renders both as markdown via renderMd() with timestamps. +~/.hermes/memories/, and SOUL.md from ~/.hermes/), renders both as markdown via renderMd() with timestamps. #### New API Endpoints (Section 18 update) @@ -1464,7 +1464,7 @@ fetches GET /api/skills/content and renders in the right panel using `showPrevie POST /api/crons/resume {job_id} -> resume_job(job_id) GET /api/skills All skills via tools.skills_tool.skills_list() GET /api/skills/content ?name=X -> full skill data via skill_view(name) - GET /api/memory MEMORY.md + USER.md content and mtimes + GET /api/memory MEMORY.md + USER.md + SOUL.md content and mtimes #### Phase D Input Validation Applied diff --git a/CHANGELOG.md b/CHANGELOG.md index 423a1820..e9e06bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - **PR #2496** by @Michaelyklam (refs #1925) — Route approval and clarify responses through the default-off `RuntimeAdapter.respond_approval(...)` / `respond_clarify(...)` seam when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled. The default `legacy-direct` path still uses the existing callback helpers directly, legacy no-id responses keep their historical `ok: true` shape, and stale explicit approval ids are now bounded as not-active instead of falling back to the oldest queued command. No approval queue, clarify queue, callback registry, runner, sidecar, queue/goal migration, or cached-agent state is introduced. +### Added + +- **PR #2500** by @mccxj — Surface `SOUL.md` (the agent's third-person voice/persona profile, stored at `HERMES_HOME/SOUL.md` alongside `config.yaml` / `.env`) as a third section in the Memory panel next to MEMORY.md (notes) and USER.md (profile). `GET /api/memory` now returns `soul`, `soul_path`, and `soul_mtime`; `POST /api/memory/write` accepts `section="soul"` writing to `HERMES_HOME/SOUL.md` (not inside `memories/`). Redaction still applies, i18n labels (`agent_soul` / `no_soul_yet`) added across all 11 locales, new `sparkles` Lucide icon for the section header. + ### Fixed - **PR #2499** by @franksong2702 — Keep server-idle session rows from inheriting stale local streaming fields during sidebar optimistic merging, so PWA/browser caches cannot keep a completed session's spinner alive after `/api/sessions` reports no active stream or pending user message. diff --git a/api/routes.py b/api/routes.py index 0b956998..3cf867ef 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7384,11 +7384,14 @@ def _handle_memory_read(handler): try: from api.profiles import get_active_hermes_home - mem_dir = get_active_hermes_home() / "memories" + home = get_active_hermes_home() + mem_dir = home / "memories" except ImportError: - mem_dir = Path.home() / ".hermes" / "memories" + home = Path.home() / ".hermes" + mem_dir = home / "memories" mem_file = mem_dir / "MEMORY.md" user_file = mem_dir / "USER.md" + soul_file = home / "SOUL.md" memory = ( mem_file.read_text(encoding="utf-8", errors="replace") if mem_file.exists() @@ -7399,15 +7402,23 @@ def _handle_memory_read(handler): if user_file.exists() else "" ) + soul = ( + soul_file.read_text(encoding="utf-8", errors="replace") + if soul_file.exists() + else "" + ) return j( handler, { "memory": _redact_text(memory), "user": _redact_text(user), + "soul": _redact_text(soul), "memory_path": str(mem_file), "user_path": str(user_file), + "soul_path": str(soul_file), "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, }, ) @@ -9931,17 +9942,21 @@ def _handle_memory_write(handler, body): try: from api.profiles import get_active_hermes_home - mem_dir = get_active_hermes_home() / "memories" + home = get_active_hermes_home() + mem_dir = home / "memories" except ImportError: - mem_dir = Path.home() / ".hermes" / "memories" + home = Path.home() / ".hermes" + mem_dir = home / "memories" mem_dir.mkdir(parents=True, exist_ok=True) section = body["section"] if section == "memory": target = mem_dir / "MEMORY.md" elif section == "user": target = mem_dir / "USER.md" + elif section == "soul": + target = home / "SOUL.md" else: - return bad(handler, 'section must be "memory" or "user"') + return bad(handler, 'section must be "memory", "user", or "soul"') target.write_text(body["content"], encoding="utf-8") return j(handler, {"ok": True, "section": section, "path": str(target)}) diff --git a/static/i18n.js b/static/i18n.js index 4715f4c2..e1ad98e3 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1057,6 +1057,8 @@ const LOCALES = { user_profile: 'User Profile', no_notes_yet: 'No notes yet.', no_profile_yet: 'No profile yet.', + agent_soul: 'Agent Soul', + no_soul_yet: 'No soul defined yet.', workspace_choose_path: 'Choose workspace path', workspace_choose_path_meta: 'Add a validated path and switch this conversation', workspace_manage: 'Manage workspaces', @@ -2260,6 +2262,8 @@ const LOCALES = { user_profile: 'Profilo Utente', no_notes_yet: 'Ancora nessuna nota.', no_profile_yet: 'Ancora nessun profilo.', + agent_soul: 'Anima dell\'Agente', + no_soul_yet: 'Nessuna anima definita.', workspace_choose_path: 'Scegli percorso workspace', workspace_choose_path_meta: 'Aggiungi un percorso validato e cambia questa conversazione', workspace_manage: 'Gestisci workspace', @@ -3468,6 +3472,8 @@ const LOCALES = { user_profile: 'ユーザープロファイル', no_notes_yet: 'まだノートはありません。', no_profile_yet: 'まだプロファイルはありません。', + agent_soul: 'エージェントソウル', + no_soul_yet: 'ソウルがまだ定義されていません。', workspace_choose_path: 'ワークスペースのパスを選択', workspace_choose_path_meta: '検証済みパスを追加してこの会話を切り替え', workspace_manage: 'ワークスペースを管理', @@ -4417,6 +4423,8 @@ const LOCALES = { user_profile: 'Пользовательский профиль', no_notes_yet: 'Пока нет заметок.', no_profile_yet: 'Пока нет профиля.', + agent_soul: 'Душа агента', + no_soul_yet: 'Душа пока не задана.', workspace_choose_path: 'Выберите путь к рабочему пространству', workspace_choose_path_meta: 'Добавьте проверенный путь и переключите эту беседу', workspace_manage: 'Управление рабочими пространствами', @@ -5577,6 +5585,8 @@ const LOCALES = { user_profile: 'User Profile', no_notes_yet: 'No notes yet.', no_profile_yet: 'No profile yet.', + agent_soul: 'Agent Soul', + no_soul_yet: 'No soul defined yet.', workspace_choose_path: 'Choose workspace path', workspace_choose_path_meta: 'Add a validated path and switch this conversation', workspace_manage: 'Manage workspaces', @@ -6901,6 +6911,8 @@ const LOCALES = { user_profile: 'Benutzerprofil', no_notes_yet: 'Noch keine Notizen.', no_profile_yet: 'Noch kein Profil.', + agent_soul: 'Agenten-Seele', + no_soul_yet: 'Noch keine Seele definiert.', workspace_choose_path: 'Arbeitsbereich wählen', workspace_choose_path_meta: 'Wählen Sie ein Verzeichnis.', workspace_manage: 'Arbeitsbereiche verwalten', @@ -7853,6 +7865,8 @@ const LOCALES = { user_profile: '用户画像', no_notes_yet: '暂无备注。', no_profile_yet: '暂无用户画像。', + agent_soul: '智能体灵魂', + no_soul_yet: '暂无灵魂定义。', workspace_choose_path: '选择工作区路径', workspace_choose_path_meta: '添加已校验路径并切换当前会话', workspace_manage: '管理工作区', @@ -8999,6 +9013,8 @@ const LOCALES = { no_active_task: '\u7121\u57f7\u884c\u4e2d\u7684\u4efb\u52d9\u53ef\u505c\u6b62\u3002', no_notes_yet: '\u5c1a\u7121\u5099\u8a3b\u3002', no_profile_yet: '\u5c1a\u7121\u8a2d\u5b9a\u6a94\u3002', + agent_soul: '智能體靈魂', + no_soul_yet: '尚無靈魂定義。', not_available: '\u7121', profile_active: '\u555f\u7528\u4e2d', profile_api_key_placeholder: 'API \u91d1\u9470\uff08\u9078\u586b\uff09', @@ -10304,6 +10320,8 @@ const LOCALES = { user_profile: 'Perfil do Usuário', no_notes_yet: 'Nenhuma nota ainda.', no_profile_yet: 'Nenhum perfil definido.', + agent_soul: 'Alma do Agente', + no_soul_yet: 'Nenhuma alma definida.', // skill form skill_name: 'Nome', skill_category: 'Categoria', @@ -11416,6 +11434,8 @@ const LOCALES = { user_profile: 'User Profile', no_notes_yet: 'No notes yet.', no_profile_yet: 'No profile yet.', + agent_soul: 'Agent Soul', + no_soul_yet: 'No soul defined yet.', workspace_choose_path: 'Choose workspace path', workspace_choose_path_meta: 'Add a validated path and switch this conversation', workspace_manage: 'Manage workspaces', @@ -12543,6 +12563,8 @@ const LOCALES = { user_profile: 'Profil utilisateur', no_notes_yet: 'Aucune note pour l\'instant.', no_profile_yet: 'Pas encore de profil.', + agent_soul: 'Âme de l\'agent', + no_soul_yet: 'Pas encore d\'âme définie.', workspace_choose_path: 'Choisir le chemin de l\'espace de travail', workspace_choose_path_meta: 'Ajoutez un chemin validé et changez cette conversation', workspace_manage: 'Gérer les espaces de travail', diff --git a/static/icons.js b/static/icons.js index 10e5eb78..65fd1020 100644 --- a/static/icons.js +++ b/static/icons.js @@ -44,6 +44,7 @@ const LI_PATHS = { 'play': '', 'wrench': '', 'brain': '', + 'sparkles': '', 'book-open': '', 'grip-vertical': '', 'clock': '', diff --git a/static/panels.js b/static/panels.js index e5d23ca4..d52439f1 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3563,12 +3563,13 @@ async function deleteCurrentSkill() { // ── Memory (main view) ── let _memoryData = null; -let _currentMemorySection = null; // 'memory' | 'user' +let _currentMemorySection = null; // 'memory' | 'user' | 'soul' 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' }, ]; function _memorySectionMeta(key) { @@ -3577,12 +3578,16 @@ function _memorySectionMeta(key) { function _memorySectionContent(key) { if (!_memoryData) return ''; - return key === 'user' ? (_memoryData.user || '') : (_memoryData.memory || ''); + if (key === 'user') return _memoryData.user || ''; + if (key === 'soul') return _memoryData.soul || ''; + return _memoryData.memory || ''; } function _memorySectionMtime(key) { if (!_memoryData) return 0; - return key === 'user' ? (_memoryData.user_mtime || 0) : (_memoryData.memory_mtime || 0); + if (key === 'user') return _memoryData.user_mtime || 0; + if (key === 'soul') return _memoryData.soul_mtime || 0; + return _memoryData.memory_mtime || 0; } function _setMemoryHeaderButtons(mode) { diff --git a/tests/test_security_redaction.py b/tests/test_security_redaction.py index 7f908687..eb8cc905 100644 --- a/tests/test_security_redaction.py +++ b/tests/test_security_redaction.py @@ -3,7 +3,7 @@ Security tests: credential redaction in API responses. Verifies that credentials (GitHub PATs, API keys, etc.) are masked in: - GET /api/session (messages and tool_calls) - - GET /api/memory (MEMORY.md and USER.md content) + - GET /api/memory (MEMORY.md, USER.md, and SOUL.md content) - GET /api/session/export (downloaded JSON) - SSE done event (session payload in stream) diff --git a/tests/test_soul_memory.py b/tests/test_soul_memory.py new file mode 100644 index 00000000..b06ecdae --- /dev/null +++ b/tests/test_soul_memory.py @@ -0,0 +1,143 @@ +""" +Tests for SOUL.md support in the memory API (GET /api/memory, POST /api/memory/write). + +SOUL.md lives at HERMES_HOME/SOUL.md (not in the memories/ subdirectory). +This test file verifies: +- GET /api/memory returns soul content, path, and mtime +- POST /api/memory/write with section="soul" writes to HERMES_HOME/SOUL.md +- Redaction still applies to soul content +- Existing memory/user sections remain unaffected +""" +import json, pathlib, urllib.error, urllib.parse, urllib.request + +from tests._pytest_port import BASE + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()) + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +# ── GET /api/memory includes soul ────────────────────────────────── + +def test_memory_read_includes_soul_fields(): + """GET /api/memory must include soul, soul_path, and soul_mtime.""" + data = get("/api/memory") + assert "soul" in data, "Response missing 'soul' key" + assert "soul_path" in data, "Response missing 'soul_path' key" + assert "soul_mtime" in data, "Response missing 'soul_mtime' key" + # soul_path should end with SOUL.md, not be inside memories/ + assert data["soul_path"].endswith("SOUL.md"), f"soul_path should end with SOUL.md, got {data['soul_path']}" + assert "/memories/" not in data["soul_path"], f"soul_path should NOT be inside memories/, got {data['soul_path']}" + + +def test_memory_read_soul_default_empty(): + """When no SOUL.md exists, the soul field should be empty string.""" + data = get("/api/memory") + # soul may be empty if no SOUL.md file exists — that's fine + assert isinstance(data.get("soul"), str), "soul should be a string" + + +# ── POST /api/memory/write supports section="soul" ───────────────── + +def test_memory_write_soul_roundtrip(): + """Writing to section='soul' should persist and be readable via GET.""" + original = get("/api/memory").get("soul", "") + test_content = "# Test Soul\nWritten by test_memory_write_soul_roundtrip." + data, status = post("/api/memory/write", {"section": "soul", "content": test_content}) + assert status == 200, f"Expected 200, got {status}: {data}" + assert data.get("ok") is True + assert data.get("section") == "soul" + # Path should be at HERMES_HOME/SOUL.md + assert data.get("path", "").endswith("SOUL.md"), f"path should end with SOUL.md, got {data.get('path')}" + # Read back + read_back = get("/api/memory").get("soul") + assert read_back == test_content + # Restore + post("/api/memory/write", {"section": "soul", "content": original}) + + +def test_memory_write_soul_does_not_affect_memory_or_user(): + """Writing soul should not change memory or user sections.""" + state_before = get("/api/memory") + original_soul = state_before.get("soul", "") + original_memory = state_before.get("memory", "") + original_user = state_before.get("user", "") + + post("/api/memory/write", {"section": "soul", "content": "# Temp Soul"}) + state_after = get("/api/memory") + + assert state_after.get("memory") == original_memory, "memory section changed unexpectedly" + assert state_after.get("user") == original_user, "user section changed unexpectedly" + + # Restore + post("/api/memory/write", {"section": "soul", "content": original_soul}) + + +def test_memory_write_soul_path_not_in_memories_dir(): + """The SOUL.md file should be at HERMES_HOME/SOUL.md, not memories/SOUL.md.""" + data, status = post("/api/memory/write", {"section": "soul", "content": "# Path check"}) + assert status == 200 + assert "/memories/" not in data.get("path", ""), f"SOUL.md should NOT be in memories/ dir, got {data.get('path')}" + # Cleanup + post("/api/memory/write", {"section": "soul", "content": ""}) + + +def test_memory_write_invalid_section_still_rejected(): + """Invalid sections should still be rejected even with soul added.""" + data, status = post("/api/memory/write", {"section": "invalid", "content": "test"}) + assert status == 400 + + +# ── i18n parity ──────────────────────────────────────────────────── + +REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() + + +def test_agent_soul_i18n_key_present_in_all_locales(): + """`agent_soul` and `no_soul_yet` keys must be defined for every locale block. + + Quoting these correctly across all 11 locales matters: an unescaped + apostrophe inside a single-quoted string ('l'agent') breaks JS parsing of + the entire module, which silently disables i18n for every language. The + PR's initial commit failed CI for exactly this reason (it / fr). + """ + i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + # i18n.js currently exposes 11 locales (en, it, ja, ru, es, de, zh-CN, + # zh-TW, pt, ko, fr). Lock that both new keys are present at least 10 + # times — that's enough to catch a missing locale without coupling the + # test to the exact locale count, which shifts as new ones land. + assert i18n.count("agent_soul:") >= 10, ( + "agent_soul i18n key should be defined in all locale blocks; " + f"found {i18n.count('agent_soul:')} occurrences." + ) + assert i18n.count("no_soul_yet:") >= 10, ( + "no_soul_yet i18n key should be defined in all locale blocks; " + f"found {i18n.count('no_soul_yet:')} occurrences." + ) + + +def test_sparkles_icon_is_defined(): + """The Memory panel's soul section uses iconKey='sparkles'. + + If the icon is missing from `static/icons.js`, `li('sparkles', 16)` emits + a console warning and returns an empty string — the section heading then + renders without an icon, which looks broken next to the memory/user + sections that do show one. + """ + icons = (REPO_ROOT / "static" / "icons.js").read_text(encoding="utf-8") + assert "'sparkles':" in icons, ( + "The Memory panel's soul section references iconKey='sparkles' " + "(static/panels.js MEMORY_SECTIONS); the corresponding entry must " + "exist in static/icons.js LI_PATHS." + )