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."
+ )