diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 1034fc495..c6af00a4a 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -57,6 +57,25 @@ pub fn get_hermes_model_config() -> Result Result { + 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::read_memory_limits().map_err(|e| e.to_string()) +} + // ============================================================================ // Hermes Web UI launcher // ============================================================================ diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 431e616d9..d2e438ef7 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -763,6 +763,111 @@ pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result &'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 { + 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 { + 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::("\"bogus\"").is_err()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42d06b0b4..2040c3a20 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index 741d83d2f..01a1e105d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; case "skills": return ( ) : ( @@ -1384,11 +1391,11 @@ function App() { + + + + ); +}; + +const HermesMemoryPanel: React.FC = () => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState("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 ( +
+ setActiveTab(v as HermesMemoryKind)} + className="flex-1 flex flex-col" + > +
+ + + {t("hermes.memory.agentTab")} + + {t("hermes.memory.userTab")} + + +
+ + + + + + + +
+
+ ); +}; + +export default HermesMemoryPanel; diff --git a/src/hooks/useDarkMode.ts b/src/hooks/useDarkMode.ts new file mode 100644 index 000000000..a3db69c5f --- /dev/null +++ b/src/hooks/useDarkMode.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +/** + * Subscribe to the presence of `class="dark"` on ``. 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; +} diff --git a/src/hooks/useHermes.ts b/src/hooks/useHermes.ts index 63599dd9c..1576224c0 100644 --- a/src/hooks/useHermes.ts +++ b/src/hooks/useHermes.ts @@ -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 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a2a21aa5c..dd18e1c8b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 50925fcea..2f9abdae4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b0edf8d9c..acefa6c9e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": { diff --git a/src/lib/api/hermes.ts b/src/lib/api/hermes.ts index f5cbb504a..a11e37923 100644 --- a/src/lib/api/hermes.ts +++ b/src/lib/api/hermes.ts @@ -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 { 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 { + return await invoke("get_hermes_memory", { kind }); + }, + + /** Atomically overwrite a Hermes memory file. */ + async setMemory(kind: HermesMemoryKind, content: string): Promise { + 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 { + return await invoke("get_hermes_memory_limits"); + }, }; diff --git a/src/types.ts b/src/types.ts index b868faa74..9a183d140 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +}