feat(hermes): replace Prompts entry with Memory panel

Hermes has no slash-prompt concept (templates live as Skills), so the
Prompts tab for the Hermes app was always empty. Swap the toolbar Book
button for a Brain button that opens a new Memory panel editing
~/.hermes/memories/{MEMORY,USER}.md — Hermes' first-class memory store
which its Web UI exposes only as on/off toggles, never as an editor.

The panel shows each file in its own tab with a character-budget bar
read from config.yaml's nested memory.* section (memory_char_limit /
user_char_limit, default 2200 / 1375). Edits are written atomically;
Hermes picks them up on the next session start per MemoryStore.

Also extract useDarkMode to src/hooks/useDarkMode.ts — the codebase
already repeats the same MutationObserver pattern in 12+ places; this
PR introduces the shared hook and uses it once, leaving the migration
of the other copies to a follow-up.
This commit is contained in:
Jason
2026-04-19 21:49:27 +08:00
parent 088b47b08a
commit acc6d795e4
12 changed files with 552 additions and 6 deletions
+19
View File
@@ -57,6 +57,25 @@ pub fn get_hermes_model_config() -> Result<Option<hermes_config::HermesModelConf
hermes_config::get_model_config().map_err(|e| e.to_string())
}
// ============================================================================
// Memory Files Commands
// ============================================================================
#[tauri::command]
pub fn get_hermes_memory(kind: hermes_config::MemoryKind) -> Result<String, String> {
hermes_config::read_memory(kind).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_hermes_memory(kind: hermes_config::MemoryKind, content: String) -> Result<(), String> {
hermes_config::write_memory(kind, &content).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_hermes_memory_limits() -> Result<hermes_config::HermesMemoryLimits, String> {
hermes_config::read_memory_limits().map_err(|e| e.to_string())
}
// ============================================================================
// Hermes Web UI launcher
// ============================================================================
+204
View File
@@ -763,6 +763,111 @@ pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result<serde_yaml::Value
.map_err(|e| AppError::Config(format!("Failed to convert JSON to YAML: {e}")))
}
// ============================================================================
// Memory Files (~/.hermes/memories/{MEMORY,USER}.md)
// ============================================================================
//
// Hermes Agent persists two memory blobs on disk:
// - `MEMORY.md` — agent's personal notes, snapshotted into the system prompt
// - `USER.md` — user profile, same treatment
// Entries are separated by a `§` on its own line. Hermes' own Web UI only
// exposes on/off toggles and character budgets — it has no content editor.
// CC Switch fills that gap by reading/writing the whole file as a markdown
// blob. Character budgets (`memory_char_limit`, `user_char_limit`) and enable
// flags (`memory_enabled`, `user_profile_enabled`) live at the top level of
// `config.yaml`; Hermes truncates over-budget content at load time.
/// Which of Hermes' two memory files to operate on. Tauri deserializes this
/// directly from the `"memory"` / `"user"` strings the frontend sends, so an
/// unknown value is rejected at the IPC boundary instead of deep in the stack.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryKind {
Memory,
User,
}
impl MemoryKind {
fn filename(self) -> &'static str {
match self {
Self::Memory => "MEMORY.md",
Self::User => "USER.md",
}
}
}
fn memories_dir() -> PathBuf {
get_hermes_dir().join("memories")
}
/// Read a Hermes memory file as a markdown blob. Returns an empty string
/// when the file doesn't exist yet (first-run case).
pub fn read_memory(kind: MemoryKind) -> Result<String, AppError> {
let path = memories_dir().join(kind.filename());
match fs::read_to_string(&path) {
Ok(content) => Ok(content),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(AppError::io(&path, e)),
}
}
/// Atomically replace a Hermes memory file. `atomic_write` creates parent
/// directories as needed, so `~/.hermes/memories/` is materialized on first
/// write without a separate `create_dir_all` call.
pub fn write_memory(kind: MemoryKind, content: &str) -> Result<(), AppError> {
let path = memories_dir().join(kind.filename());
atomic_write(&path, content.as_bytes())
}
/// Character budget + enable flags for the two memory blobs, as configured
/// in Hermes' `config.yaml`. Defaults mirror `~/.hermes`'s own defaults so
/// callers get a usable budget bar even before the user edits config.yaml.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesMemoryLimits {
pub memory: usize,
pub user: usize,
pub memory_enabled: bool,
pub user_enabled: bool,
}
impl Default for HermesMemoryLimits {
fn default() -> Self {
Self {
memory: 2200,
user: 1375,
memory_enabled: true,
user_enabled: true,
}
}
}
/// Read memory budgets + toggles from `config.yaml`. Missing/unparsable
/// fields fall back to `HermesMemoryLimits::default()` rather than erroring,
/// so an empty or partially-populated config still yields a usable UI.
pub fn read_memory_limits() -> Result<HermesMemoryLimits, AppError> {
let mut out = HermesMemoryLimits::default();
let config = read_hermes_config()?;
let Some(memory) = config.get("memory") else {
return Ok(out);
};
if let Some(v) = memory.get("memory_char_limit").and_then(|v| v.as_u64()) {
out.memory = v as usize;
}
if let Some(v) = memory.get("user_char_limit").and_then(|v| v.as_u64()) {
out.user = v as usize;
}
if let Some(v) = memory.get("memory_enabled").and_then(|v| v.as_bool()) {
out.memory_enabled = v;
}
if let Some(v) = memory.get("user_profile_enabled").and_then(|v| v.as_bool()) {
out.user_enabled = v;
}
Ok(out)
}
// ============================================================================
// Tests
// ============================================================================
@@ -1349,4 +1454,103 @@ custom_providers:
assert_eq!(model.default.as_deref(), Some("prev-default"));
});
}
// ---- memory file tests ----
#[test]
#[serial]
fn read_memory_returns_empty_when_file_missing() {
with_test_home(|| {
let memory = read_memory(MemoryKind::Memory).unwrap();
let user = read_memory(MemoryKind::User).unwrap();
assert!(memory.is_empty());
assert!(user.is_empty());
});
}
#[test]
#[serial]
fn write_then_read_memory_round_trip() {
with_test_home(|| {
let blob = "> note\n§\nfirst entry\n§\nsecond entry\n";
write_memory(MemoryKind::Memory, blob).unwrap();
assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob);
// Writing USER.md doesn't clobber MEMORY.md.
write_memory(MemoryKind::User, "user profile").unwrap();
assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob);
assert_eq!(read_memory(MemoryKind::User).unwrap(), "user profile");
});
}
#[test]
#[serial]
fn memory_limits_fall_back_to_defaults_when_config_missing() {
with_test_home(|| {
let limits = read_memory_limits().unwrap();
let defaults = HermesMemoryLimits::default();
assert_eq!(limits.memory, defaults.memory);
assert_eq!(limits.user, defaults.user);
assert_eq!(limits.memory_enabled, defaults.memory_enabled);
assert_eq!(limits.user_enabled, defaults.user_enabled);
});
}
#[test]
#[serial]
fn memory_limits_read_from_config_yaml() {
with_test_home(|| {
let yaml = "\
memory:
memory_char_limit: 4096
user_char_limit: 2048
memory_enabled: false
user_profile_enabled: true
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let limits = read_memory_limits().unwrap();
assert_eq!(limits.memory, 4096);
assert_eq!(limits.user, 2048);
assert!(!limits.memory_enabled);
assert!(limits.user_enabled);
});
}
#[test]
#[serial]
fn memory_limits_ignore_top_level_keys() {
// Regression guard: Hermes nests memory settings under `memory:`, so
// identically-named keys at the top level must be ignored rather than
// silently consumed.
with_test_home(|| {
let yaml = "\
memory_char_limit: 9999
user_char_limit: 9999
memory_enabled: false
user_profile_enabled: false
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let limits = read_memory_limits().unwrap();
let defaults = HermesMemoryLimits::default();
assert_eq!(limits.memory, defaults.memory);
assert_eq!(limits.user, defaults.user);
assert_eq!(limits.memory_enabled, defaults.memory_enabled);
assert_eq!(limits.user_enabled, defaults.user_enabled);
});
}
#[test]
fn memory_kind_deserializes_from_lowercase_strings() {
let memory: MemoryKind = serde_json::from_str("\"memory\"").unwrap();
let user: MemoryKind = serde_json::from_str("\"user\"").unwrap();
assert_eq!(memory, MemoryKind::Memory);
assert_eq!(user, MemoryKind::User);
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
}
}
+3
View File
@@ -1258,6 +1258,9 @@ pub fn run() {
commands::scan_hermes_config_health,
commands::get_hermes_model_config,
commands::open_hermes_web_ui,
commands::get_hermes_memory,
commands::set_hermes_memory,
commands::get_hermes_memory_limits,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
+11 -4
View File
@@ -14,6 +14,7 @@ import {
Minimize2,
X,
Book,
Brain,
Wrench,
RefreshCw,
History,
@@ -89,6 +90,7 @@ import ToolsPanel from "@/components/openclaw/ToolsPanel";
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
import HermesHealthBanner from "@/components/hermes/HermesHealthBanner";
import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel";
type View =
| "providers"
@@ -103,7 +105,8 @@ type View =
| "workspace"
| "openclawEnv"
| "openclawTools"
| "openclawAgents";
| "openclawAgents"
| "hermesMemory";
interface WebDavSyncStatusUpdatedPayload {
source?: string;
@@ -147,6 +150,7 @@ const VALID_VIEWS: View[] = [
"openclawEnv",
"openclawTools",
"openclawAgents",
"hermesMemory",
];
const getInitialView = (): View => {
@@ -909,6 +913,8 @@ function App() {
appId={activeApp}
/>
);
case "hermesMemory":
return <HermesMemoryPanel />;
case "skills":
return (
<UnifiedSkillsPanel
@@ -1171,6 +1177,7 @@ function App() {
{currentView === "openclawTools" && t("openclaw.tools.title")}
{currentView === "openclawAgents" &&
t("openclaw.agents.title")}
{currentView === "hermesMemory" && t("hermes.memory.title")}
</h1>
</div>
) : (
@@ -1384,11 +1391,11 @@ function App() {
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
onClick={() => setCurrentView("hermesMemory")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
title={t("hermes.memory.title")}
>
<Book className="w-4 h-4" />
<Brain className="w-4 h-4" />
</Button>
<Button
variant="ghost"
+155
View File
@@ -0,0 +1,155 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import MarkdownEditor from "@/components/MarkdownEditor";
import {
useHermesMemory,
useHermesMemoryLimits,
useOpenHermesWebUI,
useSaveHermesMemory,
} from "@/hooks/useHermes";
import { useDarkMode } from "@/hooks/useDarkMode";
import type { HermesMemoryKind } from "@/types";
import { cn } from "@/lib/utils";
interface MemoryTabPaneProps {
kind: HermesMemoryKind;
limit: number;
enabled: boolean;
}
const MemoryTabPane: React.FC<MemoryTabPaneProps> = ({
kind,
limit,
enabled,
}) => {
const { t } = useTranslation();
const darkMode = useDarkMode();
const { data, isLoading } = useHermesMemory(kind, true);
const saveMutation = useSaveHermesMemory();
const [content, setContent] = useState("");
const [loaded, setLoaded] = useState(false);
// Hydrate local dirty buffer from query data only on first load. Later
// refetches (e.g. after a successful save) must not clobber in-flight user
// edits — the caller owns `content` until they click Save again.
useEffect(() => {
if (!loaded && data !== undefined) {
setContent(data);
setLoaded(true);
}
}, [data, loaded]);
const handleSave = async () => {
try {
await saveMutation.mutateAsync({ kind, content });
toast.success(t("hermes.memory.saveSuccess"));
} catch {
// useSaveHermesMemory already surfaces a localized error toast.
}
};
const charCount = content.length;
const isOver = charCount > limit;
return (
<div className="flex flex-col gap-3">
{!enabled && (
<div className="text-sm text-amber-700 dark:text-amber-400 px-3 py-2 rounded-md bg-amber-500/10 border border-amber-500/30">
{t("hermes.memory.disabled")}
</div>
)}
{isLoading && !loaded ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
{t("prompts.loading")}
</div>
) : (
<MarkdownEditor
value={content}
onChange={setContent}
darkMode={darkMode}
minHeight="calc(100vh - 320px)"
/>
)}
<div className="flex items-center justify-between gap-3 text-sm">
<span
className={cn(
"text-muted-foreground",
isOver && "text-red-600 dark:text-red-400 font-medium",
)}
>
{t("hermes.memory.usage", { current: charCount, limit })}
{isOver ? `${t("hermes.memory.overLimit")}` : ""}
</span>
<div className="flex items-center gap-3">
<span className="hidden md:inline text-xs text-muted-foreground">
{t("hermes.memory.runtimeNote")}
</span>
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !loaded}
>
{saveMutation.isPending ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
</div>
);
};
const HermesMemoryPanel: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HermesMemoryKind>("memory");
const openHermesWebUI = useOpenHermesWebUI();
const { data: limits } = useHermesMemoryLimits(true);
const memoryLimit = limits?.memory ?? 2200;
const userLimit = limits?.user ?? 1375;
const memoryEnabled = limits?.memoryEnabled ?? true;
const userEnabled = limits?.userEnabled ?? true;
return (
<div className="flex flex-col h-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as HermesMemoryKind)}
className="flex-1 flex flex-col"
>
<div className="px-6 pt-4 flex items-center justify-between gap-3 flex-wrap">
<TabsList>
<TabsTrigger value="memory">
{t("hermes.memory.agentTab")}
</TabsTrigger>
<TabsTrigger value="user">{t("hermes.memory.userTab")}</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => void openHermesWebUI("/config")}
>
<ExternalLink className="w-3.5 h-3.5 mr-1" />
{t("hermes.memory.openConfig")}
</Button>
</div>
<TabsContent value="memory" className="flex-1 px-6 pb-4 mt-4">
<MemoryTabPane
kind="memory"
limit={memoryLimit}
enabled={memoryEnabled}
/>
</TabsContent>
<TabsContent value="user" className="flex-1 px-6 pb-4 mt-4">
<MemoryTabPane kind="user" limit={userLimit} enabled={userEnabled} />
</TabsContent>
</Tabs>
</div>
);
};
export default HermesMemoryPanel;
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
/**
* Subscribe to the presence of `class="dark"` on `<html>`. The theme-provider
* toggles this class whenever the effective theme changes (including when the
* OS changes theme while the app is in "system" mode), so observing the class
* is the simplest way to drive components that need a boolean `darkMode` prop
* (e.g. CodeMirror-based editors that don't consume the theme context).
*/
export function useDarkMode(): boolean {
const [isDark, setIsDark] = useState(() =>
typeof document !== "undefined"
? document.documentElement.classList.contains("dark")
: false,
);
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return isDark;
}
+56 -1
View File
@@ -1,9 +1,15 @@
import { useCallback } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { hermesApi } from "@/lib/api/hermes";
import { providersApi } from "@/lib/api/providers";
import type { HermesMemoryKind } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
/**
@@ -22,6 +28,8 @@ export const hermesKeys = {
liveProviderIds: ["hermes", "liveProviderIds"] as const,
modelConfig: ["hermes", "modelConfig"] as const,
health: ["hermes", "health"] as const,
memory: (kind: HermesMemoryKind) => ["hermes", "memory", kind] as const,
memoryLimits: ["hermes", "memoryLimits"] as const,
};
/**
@@ -66,10 +74,57 @@ export function useHermesHealth(enabled: boolean) {
});
}
export function useHermesMemory(kind: HermesMemoryKind, enabled: boolean) {
return useQuery({
queryKey: hermesKeys.memory(kind),
queryFn: () => hermesApi.getMemory(kind),
enabled,
});
}
export function useHermesMemoryLimits(enabled: boolean) {
return useQuery({
queryKey: hermesKeys.memoryLimits,
queryFn: () => hermesApi.getMemoryLimits(),
staleTime: 60_000,
enabled,
});
}
// ============================================================
// Mutation hooks
// ============================================================
/**
* Save a Hermes memory file atomically and refresh the corresponding query.
* Error toasts are emitted here so caller components don't need their own
* try/catch; success toasts are intentionally left to the caller (to pick
* the right localized message per tab).
*/
export function useSaveHermesMemory() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: ({
kind,
content,
}: {
kind: HermesMemoryKind;
content: string;
}) => hermesApi.setMemory(kind, content),
onSuccess: async (_data, variables) => {
await queryClient.invalidateQueries({
queryKey: hermesKeys.memory(variables.kind),
});
},
onError: (error) => {
toast.error(t("hermes.memory.saveFailed"), {
description: extractErrorMessage(error) || undefined,
});
},
});
}
/**
* Returns a handler that probes the local Hermes Web UI, opens it in the
* system browser, and surfaces a localized toast on failure. Callers only
+13
View File
@@ -1675,6 +1675,19 @@
"parseFailed": "config.yaml could not be parsed as valid YAML. Fix the file before editing it here.",
"configNotFound": "Hermes config.yaml not found. Create it at ~/.hermes/config.yaml or configure the path in settings.",
"envParseFailed": "The .env file could not be parsed."
},
"memory": {
"title": "Memory",
"agentTab": "Agent Memory (MEMORY.md)",
"userTab": "User Profile (USER.md)",
"usage": "{{current}} / {{limit}} characters",
"overLimit": "Over budget — Hermes will truncate on next load",
"disabled": "Memory is disabled in config.yaml. Enable it in Hermes config for edits to take effect.",
"saveSuccess": "Memory saved",
"saveFailed": "Failed to save memory",
"loadFailed": "Failed to read memory file",
"openConfig": "Adjust limits in Hermes Web UI",
"runtimeNote": "Changes apply on Hermes restart or new session."
}
},
"env": {
+13
View File
@@ -1675,6 +1675,19 @@
"parseFailed": "config.yaml を有効な YAML として解析できません。ここで編集する前にファイルを修正してください。",
"configNotFound": "Hermes config.yaml が見つかりません。~/.hermes/config.yaml に作成するか、設定でパスを構成してください。",
"envParseFailed": ".env ファイルを解析できません。"
},
"memory": {
"title": "メモリ",
"agentTab": "エージェント記憶 (MEMORY.md)",
"userTab": "ユーザープロファイル (USER.md)",
"usage": "{{current}} / {{limit}} 文字",
"overLimit": "上限を超えました。Hermes は次回読み込み時に切り詰めます",
"disabled": "Memory が config.yaml で無効化されています。Hermes 設定で有効化すると編集内容が反映されます。",
"saveSuccess": "記憶を保存しました",
"saveFailed": "記憶の保存に失敗しました",
"loadFailed": "記憶ファイルの読み込みに失敗しました",
"openConfig": "Hermes Web UI で上限を調整",
"runtimeNote": "変更は Hermes の再起動または新規セッション時に反映されます。"
}
},
"env": {
+13
View File
@@ -1675,6 +1675,19 @@
"parseFailed": "config.yaml 无法解析为有效 YAML。请先修复文件再在此编辑。",
"configNotFound": "未找到 Hermes config.yaml。请在 ~/.hermes/config.yaml 创建或在设置中配置路径。",
"envParseFailed": ".env 文件无法解析。"
},
"memory": {
"title": "记忆管理",
"agentTab": "Agent 记忆 (MEMORY.md)",
"userTab": "用户画像 (USER.md)",
"usage": "已用 {{current}} / {{limit}} 字符",
"overLimit": "已超过上限,Hermes 下次加载时会截断",
"disabled": "Memory 在 config.yaml 中已禁用,编辑后需在 Hermes 配置启用才会生效。",
"saveSuccess": "记忆已保存",
"saveFailed": "保存记忆失败",
"loadFailed": "读取记忆文件失败",
"openConfig": "在 Hermes Web UI 调整上限",
"runtimeNote": "更改将在 Hermes 下次启动或新建会话时生效。"
}
},
"env": {
+27 -1
View File
@@ -1,5 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import type { HermesModelConfig, HermesHealthWarning } from "@/types";
import type {
HermesHealthWarning,
HermesMemoryKind,
HermesMemoryLimits,
HermesModelConfig,
} from "@/types";
/**
* Hermes Agent configuration API (CC Switch side).
@@ -27,4 +32,25 @@ export const hermesApi = {
async openWebUI(path?: string): Promise<void> {
await invoke("open_hermes_web_ui", { path: path ?? null });
},
/**
* Read one of Hermes' memory blobs (`MEMORY.md` or `USER.md`). Returns an
* empty string when the file hasn't been created yet.
*/
async getMemory(kind: HermesMemoryKind): Promise<string> {
return await invoke("get_hermes_memory", { kind });
},
/** Atomically overwrite a Hermes memory file. */
async setMemory(kind: HermesMemoryKind, content: string): Promise<void> {
await invoke("set_hermes_memory", { kind, content });
},
/**
* Character budgets + enable flags for both memory blobs, read from
* config.yaml with Hermes defaults as fallback.
*/
async getMemoryLimits(): Promise<HermesMemoryLimits> {
return await invoke("get_hermes_memory_limits");
},
};
+9
View File
@@ -597,3 +597,12 @@ export interface HermesWriteOutcome {
backupPath?: string;
warnings: HermesHealthWarning[];
}
export type HermesMemoryKind = "memory" | "user";
export interface HermesMemoryLimits {
memory: number;
user: number;
memoryEnabled: boolean;
userEnabled: boolean;
}