diff --git a/scripts/dev-api.js b/scripts/dev-api.js index e2815c3..2343c2b 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3453,6 +3453,35 @@ export function mergeHermesToolLoopGuardrailsConfig(config = {}, form = {}) { return next } +export function buildHermesMemoryConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const memory = root.memory && typeof root.memory === 'object' && !Array.isArray(root.memory) + ? root.memory + : {} + return { + memoryEnabled: readHermesBool(memory.memory_enabled, true), + userProfileEnabled: readHermesBool(memory.user_profile_enabled, true), + memoryCharLimit: parseHermesInteger(memory.memory_char_limit, 'memory.memory_char_limit', 2200, 100, 200000, false), + userCharLimit: parseHermesInteger(memory.user_char_limit, 'memory.user_char_limit', 1375, 100, 200000, false), + nudgeInterval: parseHermesInteger(memory.nudge_interval, 'memory.nudge_interval', 10, 0, 1000, false), + } +} + +export function mergeHermesMemoryConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesMemoryConfigValues(next) + const memory = next.memory && typeof next.memory === 'object' && !Array.isArray(next.memory) + ? mergeConfigsPreservingFields(next.memory, {}) + : {} + memory.memory_enabled = formHermesBool(form, 'memoryEnabled', currentValues.memoryEnabled) + memory.user_profile_enabled = formHermesBool(form, 'userProfileEnabled', currentValues.userProfileEnabled) + memory.memory_char_limit = parseHermesInteger(Object.hasOwn(form, 'memoryCharLimit') ? form.memoryCharLimit : currentValues.memoryCharLimit, 'memory.memory_char_limit', 2200, 100, 200000, true) + memory.user_char_limit = parseHermesInteger(Object.hasOwn(form, 'userCharLimit') ? form.userCharLimit : currentValues.userCharLimit, 'memory.user_char_limit', 1375, 100, 200000, true) + memory.nudge_interval = parseHermesInteger(Object.hasOwn(form, 'nudgeInterval') ? form.nudgeInterval : currentValues.nudgeInterval, 'memory.nudge_interval', 10, 0, 1000, true) + next.memory = memory + return next +} + export function buildHermesSessionRuntimeConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const sessionReset = root.session_reset && typeof root.session_reset === 'object' && !Array.isArray(root.session_reset) @@ -9717,6 +9746,27 @@ const handlers = { } }, + hermes_memory_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesMemoryConfigValues(config), + } + }, + + hermes_memory_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesMemoryConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesMemoryConfigValues(next), + } + }, + // P1-3 lazy_deps: Web 模式下不能调 venv python,但仍提供 feature 列表 + 提示用户走桌面端装 hermes_lazy_deps_features() { const features = [ diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 111f40c..383f7ed 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3321,6 +3321,99 @@ fn merge_hermes_tool_loop_guardrails_config( Ok(()) } +fn build_hermes_memory_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let memory = root.and_then(|map| yaml_get_mapping(map, "memory")); + let memory_enabled = memory + .and_then(|map| yaml_bool_field(map, "memory_enabled")) + .unwrap_or(true); + let user_profile_enabled = memory + .and_then(|map| yaml_bool_field(map, "user_profile_enabled")) + .unwrap_or(true); + let memory_char_limit = memory + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "memory_char_limit"), 2200, 100, 200000)) + .unwrap_or(2200); + let user_char_limit = memory + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "user_char_limit"), 1375, 100, 200000)) + .unwrap_or(1375); + let nudge_interval = memory + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "nudge_interval"), 10, 0, 1000)) + .unwrap_or(10); + + serde_json::json!({ + "memoryEnabled": memory_enabled, + "userProfileEnabled": user_profile_enabled, + "memoryCharLimit": memory_char_limit, + "userCharLimit": user_char_limit, + "nudgeInterval": nudge_interval, + }) +} + +fn merge_hermes_memory_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_memory_config_values(config); + let memory_enabled = form_bool(form, "memoryEnabled") + .unwrap_or_else(|| current["memoryEnabled"].as_bool().unwrap_or(true)); + let user_profile_enabled = form_bool(form, "userProfileEnabled") + .unwrap_or_else(|| current["userProfileEnabled"].as_bool().unwrap_or(true)); + let memory_char_limit = validate_hermes_i64( + if form.get("memoryCharLimit").is_some() { + form_i64(form, "memoryCharLimit") + } else { + Some(current["memoryCharLimit"].as_i64().unwrap_or(2200)) + }, + "memory.memory_char_limit", + 2200, + 100, + 200000, + )?; + let user_char_limit = validate_hermes_i64( + if form.get("userCharLimit").is_some() { + form_i64(form, "userCharLimit") + } else { + Some(current["userCharLimit"].as_i64().unwrap_or(1375)) + }, + "memory.user_char_limit", + 1375, + 100, + 200000, + )?; + let nudge_interval = validate_hermes_i64( + if form.get("nudgeInterval").is_some() { + form_i64(form, "nudgeInterval") + } else { + Some(current["nudgeInterval"].as_i64().unwrap_or(10)) + }, + "memory.nudge_interval", + 10, + 0, + 1000, + )?; + + let root = ensure_yaml_object(config)?; + let memory = yaml_child_object(root, "memory")?; + memory.insert( + yaml_key("memory_enabled"), + serde_yaml::Value::Bool(memory_enabled), + ); + memory.insert( + yaml_key("user_profile_enabled"), + serde_yaml::Value::Bool(user_profile_enabled), + ); + memory.insert( + yaml_key("memory_char_limit"), + serde_yaml::Value::Number(memory_char_limit.into()), + ); + memory.insert( + yaml_key("user_char_limit"), + serde_yaml::Value::Number(user_char_limit.into()), + ); + memory.insert( + yaml_key("nudge_interval"), + serde_yaml::Value::Number(nudge_interval.into()), + ); + Ok(()) +} + fn build_hermes_session_runtime_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let session_reset = root.and_then(|map| yaml_get_mapping(map, "session_reset")); @@ -4179,6 +4272,30 @@ pub fn hermes_tool_loop_guardrails_config_save(form: Value) -> Result Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_memory_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_memory_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_memory_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_memory_config_values(&config), + })) +} + // --------------------------------------------------------------------------- // hermes_read_config — 读取 Hermes config.yaml + .env // --------------------------------------------------------------------------- @@ -9365,6 +9482,82 @@ streaming: } } +#[cfg(test)] +mod hermes_memory_config_tests { + use super::{build_hermes_memory_config_values, merge_hermes_memory_config}; + use serde_json::json; + + #[test] + fn memory_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_memory_config_values(&config); + assert_eq!(values["memoryEnabled"], true); + assert_eq!(values["userProfileEnabled"], true); + assert_eq!(values["memoryCharLimit"], 2200); + assert_eq!(values["userCharLimit"], 1375); + assert_eq!(values["nudgeInterval"], 10); + } + + #[test] + fn merge_memory_config_preserves_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +memory: + memory_enabled: true + provider: honcho + custom_flag: keep-me +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_memory_config( + &mut config, + &json!({ + "memoryEnabled": false, + "userProfileEnabled": false, + "memoryCharLimit": "2600", + "userCharLimit": "1500", + "nudgeInterval": "0", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["memory"]["memory_enabled"].as_bool(), Some(false)); + assert_eq!( + config["memory"]["user_profile_enabled"].as_bool(), + Some(false) + ); + assert_eq!(config["memory"]["memory_char_limit"].as_i64(), Some(2600)); + assert_eq!(config["memory"]["user_char_limit"].as_i64(), Some(1500)); + assert_eq!(config["memory"]["nudge_interval"].as_i64(), Some(0)); + assert_eq!(config["memory"]["provider"].as_str(), Some("honcho")); + assert_eq!(config["memory"]["custom_flag"].as_str(), Some("keep-me")); + } + + #[test] + fn merge_memory_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = + merge_hermes_memory_config(&mut config, &json!({ "memoryCharLimit": 99 })).unwrap_err(); + assert!(err.contains("memory.memory_char_limit")); + let err = merge_hermes_memory_config(&mut config, &json!({ "userCharLimit": 200001 })) + .unwrap_err(); + assert!(err.contains("memory.user_char_limit")); + let err = + merge_hermes_memory_config(&mut config, &json!({ "nudgeInterval": -1 })).unwrap_err(); + assert!(err.contains("memory.nudge_interval")); + let err = + merge_hermes_memory_config(&mut config, &json!({ "nudgeInterval": 1001 })).unwrap_err(); + assert!(err.contains("memory.nudge_interval")); + } +} + #[cfg(test)] mod hermes_channel_tests { use super::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 37bb081..507a706 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -263,6 +263,8 @@ pub fn run() { hermes::hermes_compression_config_save, hermes::hermes_tool_loop_guardrails_config_read, hermes::hermes_tool_loop_guardrails_config_save, + hermes::hermes_memory_config_read, + hermes::hermes_memory_config_save, hermes::hermes_lazy_deps_features, hermes::hermes_lazy_deps_status, hermes::hermes_lazy_deps_ensure, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 7eba00f..5b866dc 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -34,6 +34,14 @@ const TOOL_GUARDRAILS_DEFAULTS = { hardStopNoProgress: 5, } +const MEMORY_DEFAULTS = { + memoryEnabled: true, + userProfileEnabled: true, + memoryCharLimit: 2200, + userCharLimit: 1375, + nudgeInterval: 10, +} + const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none'] export function render() { @@ -44,21 +52,25 @@ export function render() { let runtimeValues = { ...SESSION_RUNTIME_DEFAULTS } let compressionValues = { ...COMPRESSION_DEFAULTS } let toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS } + let memoryValues = { ...MEMORY_DEFAULTS } let loading = true let runtimeLoading = true let compressionLoading = true let toolGuardrailsLoading = true + let memoryLoading = true let saving = false let runtimeSaving = false let compressionSaving = false let toolGuardrailsSaving = false + let memorySaving = false let error = null let runtimeError = null let compressionError = null let toolGuardrailsError = null + let memoryError = null function esc(value) { - return String(value || '') + return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') @@ -66,7 +78,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving } function option(labelKey, value, selected) { @@ -83,7 +95,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving return `
@@ -131,7 +143,7 @@ export function render() { } function renderCompressionPanel() { - const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving + const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving return `
@@ -181,7 +193,7 @@ export function render() { } function renderToolGuardrailsPanel() { - const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving + const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving return `
@@ -242,6 +254,52 @@ export function render() { ` } + function renderMemoryPanel() { + const disabled = loading || saving || memoryLoading || memorySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving + return ` +
+
+
+
${t('engine.hermesMemoryConfigTitle')}
+
${t('engine.hermesMemoryConfigDesc')}
+
+
+ ${memorySaving ? t('engine.hermesConfigStatusSaving') : memoryLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesMemoryConfigStatusReady')} + +
+
+
+ ${renderError(memoryError)} +
+ + +
+
+ + + +
+
${t('engine.hermesMemoryConfigFootnote')}
+
+
+ ` + } + function draw() { el.innerHTML = `
@@ -259,6 +317,7 @@ export function render() { ${renderRuntimePanel()} ${renderCompressionPanel()} ${renderToolGuardrailsPanel()} + ${renderMemoryPanel()}
@@ -281,6 +340,7 @@ export function render() { el.querySelector('#hm-runtime-save')?.addEventListener('click', saveRuntime) el.querySelector('#hm-compression-save')?.addEventListener('click', saveCompression) el.querySelector('#hm-tool-guardrails-save')?.addEventListener('click', saveToolGuardrails) + el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory) } async function loadRaw() { @@ -303,15 +363,22 @@ export function render() { toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS, ...(data?.values || {}) } } + async function loadMemory() { + const data = await api.hermesMemoryConfigRead() + memoryValues = { ...MEMORY_DEFAULTS, ...(data?.values || {}) } + } + async function load() { loading = true runtimeLoading = true compressionLoading = true toolGuardrailsLoading = true + memoryLoading = true error = null runtimeError = null compressionError = null toolGuardrailsError = null + memoryError = null draw() try { await loadRaw() @@ -344,6 +411,14 @@ export function render() { toolGuardrailsLoading = false draw() } + try { + await loadMemory() + } catch (err) { + memoryError = humanizeError(err, t('engine.hermesMemoryConfigLoadFailed') || 'Load memory config failed') + } finally { + memoryLoading = false + draw() + } } async function refreshRawAfterStructuredSave() { @@ -374,6 +449,9 @@ export function render() { try { await loadToolGuardrails() } catch {} + try { + await loadMemory() + } catch {} } catch (err) { error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed') toast(error, 'error') @@ -474,6 +552,35 @@ export function render() { } } + async function saveMemory() { + const form = { + memoryEnabled: !!el.querySelector('#hm-memory-enabled')?.checked, + userProfileEnabled: !!el.querySelector('#hm-memory-user-profile-enabled')?.checked, + memoryCharLimit: el.querySelector('#hm-memory-char-limit')?.value || '2200', + userCharLimit: el.querySelector('#hm-memory-user-char-limit')?.value || '1375', + nudgeInterval: el.querySelector('#hm-memory-nudge-interval')?.value || '10', + } + memorySaving = true + memoryError = null + draw() + try { + const result = await api.hermesMemoryConfigSave(form) + memoryValues = { ...MEMORY_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesMemoryConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + memoryError = humanizeError(err, t('engine.hermesMemoryConfigSaveFailed') || 'Save memory config failed') + toast(memoryError, 'error') + } finally { + memorySaving = false + draw() + } + } + draw() load() return el diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index eae42e2..2339221 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -6761,6 +6761,10 @@ body[data-active-engine="hermes"][data-theme="dark"] { grid-template-columns: repeat(3, minmax(160px, 1fr)); margin-top: 12px; } +[data-engine="hermes"] .hm-config-memory-grid { + grid-template-columns: repeat(3, minmax(160px, 1fr)); + margin-top: 18px; +} [data-engine="hermes"] .hm-config-subtitle { margin-top: 20px; color: var(--hm-text-secondary); @@ -6893,6 +6897,7 @@ body[data-active-engine="hermes"][data-theme="dark"] { [data-engine="hermes"] .hm-config-runtime-grid, [data-engine="hermes"] .hm-config-compression-grid, [data-engine="hermes"] .hm-config-guardrails-grid, + [data-engine="hermes"] .hm-config-memory-grid, [data-engine="hermes"] .hm-config-check-grid { grid-template-columns: 1fr; } diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 7e34871..d77a4a3 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -515,6 +515,8 @@ export const api = { hermesCompressionConfigSave: (form) => invoke('hermes_compression_config_save', { form }), hermesToolLoopGuardrailsConfigRead: () => invoke('hermes_tool_loop_guardrails_config_read'), hermesToolLoopGuardrailsConfigSave: (form) => invoke('hermes_tool_loop_guardrails_config_save', { form }), + hermesMemoryConfigRead: () => invoke('hermes_memory_config_read'), + hermesMemoryConfigSave: (form) => invoke('hermes_memory_config_save', { form }), hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000), hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }), hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 4cfc1c5..119d562 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -530,6 +530,19 @@ export default { hermesToolGuardrailsHardStopSameToolFailure: _('同工具失败停止', 'Same-tool failure stop', '同工具失敗停止'), hermesToolGuardrailsHardStopNoProgress: _('无进展停止', 'No-progress stop', '無進展停止'), hermesToolGuardrailsFootnote: _('默认只提示不拦截,适合交互式使用。硬停止更适合 cron、无人值守和长时间后台任务。', 'By default Hermes only warns and does not block, which fits interactive use. Hard stops are better for cron, unattended, and long-running background jobs.', '預設只提示不攔截,適合互動式使用。硬停止更適合 cron、無人值守和長時間背景任務。'), + hermesMemoryConfigTitle: _('持久记忆', 'Persistent memory', '持久記憶'), + hermesMemoryConfigDesc: _('控制 MEMORY.md 与 USER.md 是否注入系统提示,以及注入字符上限和定期整理提醒,避免长期上下文丢失重要偏好。', 'Control whether MEMORY.md and USER.md are injected, their character limits, and periodic review nudges so long-running sessions keep important preferences.', '控制 MEMORY.md 與 USER.md 是否注入系統提示,以及注入字元上限和定期整理提醒,避免長期上下文遺失重要偏好。'), + hermesMemoryConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesMemoryConfigSave: _('保存记忆配置', 'Save memory settings', '儲存記憶設定'), + hermesMemoryConfigSaveSuccess: _('记忆配置已保存,建议重启 Hermes Gateway 生效', 'Memory settings saved. Restart Hermes Gateway to take effect.', '記憶設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesMemoryConfigLoadFailed: _('加载记忆配置失败', 'Load memory settings failed', '載入記憶設定失敗'), + hermesMemoryConfigSaveFailed: _('保存记忆配置失败', 'Save memory settings failed', '儲存記憶設定失敗'), + hermesMemoryConfigMemoryEnabled: _('启用 Agent 记忆', 'Enable agent memory', '啟用 Agent 記憶'), + hermesMemoryConfigUserProfileEnabled: _('启用用户画像', 'Enable user profile', '啟用使用者画像'), + hermesMemoryConfigMemoryCharLimit: _('记忆字符上限', 'Memory character limit', '記憶字元上限'), + hermesMemoryConfigUserCharLimit: _('用户画像字符上限', 'User profile character limit', '使用者画像字元上限'), + hermesMemoryConfigNudgeInterval: _('整理提醒间隔', 'Review nudge interval', '整理提醒間隔'), + hermesMemoryConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭提醒。外部记忆 provider 等高级字段会保留在 raw YAML 中。', 'The nudge interval is counted in user turns. Set it to 0 to disable nudges. Advanced fields such as external memory provider are preserved in raw YAML.', '提醒間隔依使用者訊息輪數計算,設為 0 可關閉提醒。外部記憶 provider 等進階欄位會保留在 raw YAML 中。'), // Batch 1 §E: 会话导出 sessionsExport: _('导出', 'Export', '匯出'), sessionsExportSuccess: _('已导出', 'Exported', '已匯出'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 7ed8b5a..6f0145f 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -25,10 +25,27 @@ test('Hermes 配置页会暴露工具循环防护结构化配置字段', () => { } }) -test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { - const keys = new Set(extractEngineKeys().filter(key => key.includes('ToolGuardrails'))) +test('Hermes 配置页会暴露记忆结构化配置字段', () => { + for (const id of [ + 'hm-memory-save', + 'hm-memory-enabled', + 'hm-memory-user-profile-enabled', + 'hm-memory-char-limit', + 'hm-memory-user-char-limit', + 'hm-memory-nudge-interval', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) - assert.ok(keys.size > 0, '应能提取工具循环防护用到的 engine 翻译 key') +test('Hermes 配置页数值输入会保留 0 值显示', () => { + assert.doesNotMatch(source, /String\(value \|\| ''\)/, 'esc(value) 不能把合法 0 渲染为空字符串') +}) + +test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { + const keys = new Set(extractEngineKeys().filter(key => key.includes('ToolGuardrails') || key.includes('MemoryConfig'))) + + assert.ok(keys.size > 0, '应能提取新增结构化配置用到的 engine 翻译 key') for (const key of keys) { assert.notEqual(t(key), key, `${key} 缺少运行时翻译`) } diff --git a/tests/hermes-memory-config.test.js b/tests/hermes-memory-config.test.js new file mode 100644 index 0000000..b91fda1 --- /dev/null +++ b/tests/hermes-memory-config.test.js @@ -0,0 +1,84 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesMemoryConfigValues, + mergeHermesMemoryConfig, +} from '../scripts/dev-api.js' + +test('Hermes 记忆配置读取会提供上游默认值', () => { + const values = buildHermesMemoryConfigValues({}) + + assert.deepEqual(values, { + memoryEnabled: true, + userProfileEnabled: true, + memoryCharLimit: 2200, + userCharLimit: 1375, + nudgeInterval: 10, + }) +}) + +test('Hermes 记忆配置读取会回显 YAML 中的记忆字段', () => { + const values = buildHermesMemoryConfigValues({ + memory: { + memory_enabled: false, + user_profile_enabled: true, + memory_char_limit: 3200, + user_char_limit: 1800, + nudge_interval: 12, + }, + }) + + assert.equal(values.memoryEnabled, false) + assert.equal(values.userProfileEnabled, true) + assert.equal(values.memoryCharLimit, 3200) + assert.equal(values.userCharLimit, 1800) + assert.equal(values.nudgeInterval, 12) +}) + +test('Hermes 记忆配置保存会保留无关 YAML 并写入 snake_case 字段', () => { + const next = mergeHermesMemoryConfig({ + model: { provider: 'anthropic' }, + memory: { + memory_enabled: true, + provider: 'honcho', + custom_flag: 'keep-me', + }, + streaming: { enabled: true }, + }, { + memoryEnabled: false, + userProfileEnabled: false, + memoryCharLimit: '2600', + userCharLimit: '1500', + nudgeInterval: '0', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.memory.memory_enabled, false) + assert.equal(next.memory.user_profile_enabled, false) + assert.equal(next.memory.memory_char_limit, 2600) + assert.equal(next.memory.user_char_limit, 1500) + assert.equal(next.memory.nudge_interval, 0) + assert.equal(next.memory.provider, 'honcho') + assert.equal(next.memory.custom_flag, 'keep-me') +}) + +test('Hermes 记忆配置保存会拒绝越界字符上限和提醒间隔', () => { + assert.throws( + () => mergeHermesMemoryConfig({}, { memoryCharLimit: '99' }), + /memory\.memory_char_limit/, + ) + assert.throws( + () => mergeHermesMemoryConfig({}, { userCharLimit: '200001' }), + /memory\.user_char_limit/, + ) + assert.throws( + () => mergeHermesMemoryConfig({}, { nudgeInterval: '-1' }), + /memory\.nudge_interval/, + ) + assert.throws( + () => mergeHermesMemoryConfig({}, { nudgeInterval: '1001' }), + /memory\.nudge_interval/, + ) +})