diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 2a7fe0d..4a5cb2e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4370,6 +4370,50 @@ export function mergeHermesSkillsConfig(config = {}, form = {}) { return next } +export function buildHermesCuratorConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const curator = root.curator && typeof root.curator === 'object' && !Array.isArray(root.curator) + ? root.curator + : {} + const backup = curator.backup && typeof curator.backup === 'object' && !Array.isArray(curator.backup) + ? curator.backup + : {} + return { + curatorEnabled: readHermesBool(curator.enabled, true), + curatorIntervalHours: parseHermesInteger(curator.interval_hours, 'curator.interval_hours', 168, 1, 87600, false), + curatorMinIdleHours: parseHermesInteger(curator.min_idle_hours, 'curator.min_idle_hours', 2, 0, 87600, false), + curatorStaleAfterDays: parseHermesInteger(curator.stale_after_days, 'curator.stale_after_days', 30, 1, 36500, false), + curatorArchiveAfterDays: parseHermesInteger(curator.archive_after_days, 'curator.archive_after_days', 90, 1, 36500, false), + curatorBackupEnabled: readHermesBool(backup.enabled, true), + curatorBackupKeep: parseHermesInteger(backup.keep, 'curator.backup.keep', 5, 0, 1000, false), + } +} + +export function mergeHermesCuratorConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesCuratorConfigValues(next) + const curator = next.curator && typeof next.curator === 'object' && !Array.isArray(next.curator) + ? mergeConfigsPreservingFields(next.curator, {}) + : {} + const backup = curator.backup && typeof curator.backup === 'object' && !Array.isArray(curator.backup) + ? mergeConfigsPreservingFields(curator.backup, {}) + : {} + + curator.enabled = formHermesBool(form, 'curatorEnabled', currentValues.curatorEnabled) + curator.interval_hours = parseHermesInteger(Object.hasOwn(form, 'curatorIntervalHours') ? form.curatorIntervalHours : currentValues.curatorIntervalHours, 'curator.interval_hours', 168, 1, 87600, true) + curator.min_idle_hours = parseHermesInteger(Object.hasOwn(form, 'curatorMinIdleHours') ? form.curatorMinIdleHours : currentValues.curatorMinIdleHours, 'curator.min_idle_hours', 2, 0, 87600, true) + curator.stale_after_days = parseHermesInteger(Object.hasOwn(form, 'curatorStaleAfterDays') ? form.curatorStaleAfterDays : currentValues.curatorStaleAfterDays, 'curator.stale_after_days', 30, 1, 36500, true) + curator.archive_after_days = parseHermesInteger(Object.hasOwn(form, 'curatorArchiveAfterDays') ? form.curatorArchiveAfterDays : currentValues.curatorArchiveAfterDays, 'curator.archive_after_days', 90, 1, 36500, true) + if (curator.archive_after_days < curator.stale_after_days) { + throw new Error('curator.archive_after_days 必须大于或等于 curator.stale_after_days') + } + backup.enabled = formHermesBool(form, 'curatorBackupEnabled', currentValues.curatorBackupEnabled) + backup.keep = parseHermesInteger(Object.hasOwn(form, 'curatorBackupKeep') ? form.curatorBackupKeep : currentValues.curatorBackupKeep, 'curator.backup.keep', 5, 0, 1000, true) + curator.backup = backup + next.curator = curator + return next +} + export function buildHermesAgentToolsetsConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const agent = root.agent && typeof root.agent === 'object' && !Array.isArray(root.agent) @@ -11972,6 +12016,27 @@ const handlers = { } }, + hermes_curator_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesCuratorConfigValues(config), + } + }, + + hermes_curator_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesCuratorConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesCuratorConfigValues(next), + } + }, + hermes_quick_commands_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ef06a73..08dc26f 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4437,6 +4437,135 @@ fn merge_hermes_skills_config(config: &mut serde_yaml::Value, form: &Value) -> R Ok(()) } +fn build_hermes_curator_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let curator = root.and_then(|map| yaml_get_mapping(map, "curator")); + let backup = curator.and_then(|map| yaml_get_mapping(map, "backup")); + + serde_json::json!({ + "curatorEnabled": curator.and_then(|map| yaml_bool_field(map, "enabled")).unwrap_or(true), + "curatorIntervalHours": curator + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "interval_hours"), 168, 1, 87600)) + .unwrap_or(168), + "curatorMinIdleHours": curator + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "min_idle_hours"), 2, 0, 87600)) + .unwrap_or(2), + "curatorStaleAfterDays": curator + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "stale_after_days"), 30, 1, 36500)) + .unwrap_or(30), + "curatorArchiveAfterDays": curator + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "archive_after_days"), 90, 1, 36500)) + .unwrap_or(90), + "curatorBackupEnabled": backup.and_then(|map| yaml_bool_field(map, "enabled")).unwrap_or(true), + "curatorBackupKeep": backup + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "keep"), 5, 0, 1000)) + .unwrap_or(5), + }) +} + +fn merge_hermes_curator_config(config: &mut serde_yaml::Value, form: &Value) -> Result<(), String> { + let current = build_hermes_curator_config_values(config); + let curator_interval_hours = validate_hermes_i64( + if form.get("curatorIntervalHours").is_some() { + form_i64(form, "curatorIntervalHours") + } else { + Some(current["curatorIntervalHours"].as_i64().unwrap_or(168)) + }, + "curator.interval_hours", + 168, + 1, + 87600, + )?; + let curator_min_idle_hours = validate_hermes_i64( + if form.get("curatorMinIdleHours").is_some() { + form_i64(form, "curatorMinIdleHours") + } else { + Some(current["curatorMinIdleHours"].as_i64().unwrap_or(2)) + }, + "curator.min_idle_hours", + 2, + 0, + 87600, + )?; + let curator_stale_after_days = validate_hermes_i64( + if form.get("curatorStaleAfterDays").is_some() { + form_i64(form, "curatorStaleAfterDays") + } else { + Some(current["curatorStaleAfterDays"].as_i64().unwrap_or(30)) + }, + "curator.stale_after_days", + 30, + 1, + 36500, + )?; + let curator_archive_after_days = validate_hermes_i64( + if form.get("curatorArchiveAfterDays").is_some() { + form_i64(form, "curatorArchiveAfterDays") + } else { + Some(current["curatorArchiveAfterDays"].as_i64().unwrap_or(90)) + }, + "curator.archive_after_days", + 90, + 1, + 36500, + )?; + if curator_archive_after_days < curator_stale_after_days { + return Err( + "curator.archive_after_days 必须大于或等于 curator.stale_after_days".to_string(), + ); + } + let curator_backup_keep = validate_hermes_i64( + if form.get("curatorBackupKeep").is_some() { + form_i64(form, "curatorBackupKeep") + } else { + Some(current["curatorBackupKeep"].as_i64().unwrap_or(5)) + }, + "curator.backup.keep", + 5, + 0, + 1000, + )?; + + let root = ensure_yaml_object(config)?; + let curator = yaml_child_object(root, "curator")?; + curator.insert( + yaml_key("enabled"), + serde_yaml::Value::Bool( + form_bool(form, "curatorEnabled") + .unwrap_or_else(|| current["curatorEnabled"].as_bool().unwrap_or(true)), + ), + ); + curator.insert( + yaml_key("interval_hours"), + serde_yaml::Value::Number(curator_interval_hours.into()), + ); + curator.insert( + yaml_key("min_idle_hours"), + serde_yaml::Value::Number(curator_min_idle_hours.into()), + ); + curator.insert( + yaml_key("stale_after_days"), + serde_yaml::Value::Number(curator_stale_after_days.into()), + ); + curator.insert( + yaml_key("archive_after_days"), + serde_yaml::Value::Number(curator_archive_after_days.into()), + ); + let backup = yaml_child_object(curator, "backup")?; + backup.insert( + yaml_key("enabled"), + serde_yaml::Value::Bool( + form_bool(form, "curatorBackupEnabled") + .unwrap_or_else(|| current["curatorBackupEnabled"].as_bool().unwrap_or(true)), + ), + ); + backup.insert( + yaml_key("keep"), + serde_yaml::Value::Number(curator_backup_keep.into()), + ); + Ok(()) +} + fn build_hermes_quick_commands_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let quick_commands = root @@ -9665,6 +9794,30 @@ pub fn hermes_skills_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_curator_config_read() -> 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_curator_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_curator_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_curator_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_curator_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_quick_commands_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -17767,6 +17920,133 @@ memory: } } +#[cfg(test)] +mod hermes_curator_config_tests { + use super::{build_hermes_curator_config_values, merge_hermes_curator_config}; + use serde_json::json; + + #[test] + fn curator_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_curator_config_values(&config); + assert_eq!(values["curatorEnabled"], true); + assert_eq!(values["curatorIntervalHours"], 168); + assert_eq!(values["curatorMinIdleHours"], 2); + assert_eq!(values["curatorStaleAfterDays"], 30); + assert_eq!(values["curatorArchiveAfterDays"], 90); + assert_eq!(values["curatorBackupEnabled"], true); + assert_eq!(values["curatorBackupKeep"], 5); + } + + #[test] + fn curator_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +curator: + enabled: false + interval_hours: 24 + min_idle_hours: 6 + stale_after_days: 14 + archive_after_days: 45 + backup: + enabled: false + keep: 9 +"#, + ) + .unwrap(); + + let values = build_hermes_curator_config_values(&config); + assert_eq!(values["curatorEnabled"], false); + assert_eq!(values["curatorIntervalHours"], 24); + assert_eq!(values["curatorMinIdleHours"], 6); + assert_eq!(values["curatorStaleAfterDays"], 14); + assert_eq!(values["curatorArchiveAfterDays"], 45); + assert_eq!(values["curatorBackupEnabled"], false); + assert_eq!(values["curatorBackupKeep"], 9); + } + + #[test] + fn merge_curator_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +curator: + enabled: true + backup: + enabled: true + custom_flag: keep-backup + custom_flag: keep-curator +skills: + external_dirs: + - ~/.agents/skills +model: + provider: anthropic +"#, + ) + .unwrap(); + + merge_hermes_curator_config( + &mut config, + &json!({ + "curatorEnabled": false, + "curatorIntervalHours": "48", + "curatorMinIdleHours": "4", + "curatorStaleAfterDays": "21", + "curatorArchiveAfterDays": "60", + "curatorBackupEnabled": false, + "curatorBackupKeep": "3", + }), + ) + .unwrap(); + + assert_eq!( + config["skills"]["external_dirs"][0].as_str(), + Some("~/.agents/skills") + ); + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["curator"]["enabled"].as_bool(), Some(false)); + assert_eq!(config["curator"]["interval_hours"].as_i64(), Some(48)); + assert_eq!(config["curator"]["min_idle_hours"].as_i64(), Some(4)); + assert_eq!(config["curator"]["stale_after_days"].as_i64(), Some(21)); + assert_eq!(config["curator"]["archive_after_days"].as_i64(), Some(60)); + assert_eq!( + config["curator"]["backup"]["enabled"].as_bool(), + Some(false) + ); + assert_eq!(config["curator"]["backup"]["keep"].as_i64(), Some(3)); + assert_eq!( + config["curator"]["backup"]["custom_flag"].as_str(), + Some("keep-backup") + ); + assert_eq!( + config["curator"]["custom_flag"].as_str(), + Some("keep-curator") + ); + } + + #[test] + fn merge_curator_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_curator_config(&mut config, &json!({ "curatorIntervalHours": 0 })) + .unwrap_err(); + assert!(err.contains("curator.interval_hours")); + let err = merge_hermes_curator_config(&mut config, &json!({ "curatorMinIdleHours": -1 })) + .unwrap_err(); + assert!(err.contains("curator.min_idle_hours")); + let err = merge_hermes_curator_config(&mut config, &json!({ "curatorBackupKeep": 1001 })) + .unwrap_err(); + assert!(err.contains("curator.backup.keep")); + let err = merge_hermes_curator_config( + &mut config, + &json!({ + "curatorStaleAfterDays": 90, + "curatorArchiveAfterDays": 30, + }), + ) + .unwrap_err(); + assert!(err.contains("curator.archive_after_days")); + } +} + #[cfg(test)] mod hermes_quick_commands_config_tests { use super::{build_hermes_quick_commands_config_values, merge_hermes_quick_commands_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ddef617..6c3c350 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -275,6 +275,8 @@ pub fn run() { hermes::hermes_memory_config_save, hermes::hermes_skills_config_read, hermes::hermes_skills_config_save, + hermes::hermes_curator_config_read, + hermes::hermes_curator_config_save, hermes::hermes_quick_commands_config_read, hermes::hermes_quick_commands_config_save, hermes::hermes_model_config_read, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 4b6168f..f68cd54 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -84,6 +84,16 @@ const SKILLS_DEFAULTS = { guardAgentCreated: false, } +const CURATOR_DEFAULTS = { + curatorEnabled: true, + curatorIntervalHours: 168, + curatorMinIdleHours: 2, + curatorStaleAfterDays: 30, + curatorArchiveAfterDays: 90, + curatorBackupEnabled: true, + curatorBackupKeep: 5, +} + const QUICK_COMMANDS_DEFAULTS = { quickCommandsJson: '{}', } @@ -345,6 +355,7 @@ export function render() { let toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS } let memoryValues = { ...MEMORY_DEFAULTS } let skillsValues = { ...SKILLS_DEFAULTS } + let curatorValues = { ...CURATOR_DEFAULTS } let quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS } let modelValues = { ...MODEL_DEFAULTS } let modelAliasesValues = { ...MODEL_ALIASES_DEFAULTS } @@ -380,6 +391,7 @@ export function render() { let toolGuardrailsLoading = true let memoryLoading = true let skillsLoading = true + let curatorLoading = true let quickCommandsLoading = true let modelLoading = true let modelAliasesLoading = true @@ -415,6 +427,7 @@ export function render() { let toolGuardrailsSaving = false let memorySaving = false let skillsSaving = false + let curatorSaving = false let quickCommandsSaving = false let modelSaving = false let modelAliasesSaving = false @@ -450,6 +463,7 @@ export function render() { let toolGuardrailsError = null let memoryError = null let skillsError = null + let curatorError = null let quickCommandsError = null let modelError = null let modelAliasesError = null @@ -485,7 +499,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || promptCachingLoading || openrouterCacheLoading || providerRoutingLoading || auxiliaryLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || curatorLoading || quickCommandsLoading || modelLoading || modelAliasesLoading || hooksLoading || providerOverridesLoading || mcpServersLoading || agentToolsetsLoading || platformToolsetsLoading || agentRuntimeLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || kanbanLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || checkpointsLoading || cronLoading || loggingLoading || approvalsLoading || privacyLoading || browserLoading || sttLoading || terminalLoading || saving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || curatorSaving || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || platformToolsetsSaving || agentRuntimeSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || kanbanSaving || streamingSaving || executionLimitsSaving || ioSafetySaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || privacySaving || browserSaving || sttSaving || terminalSaving } function option(labelKey, value, selected) { @@ -968,6 +982,60 @@ export function render() { ` } + function renderCuratorConfigPanel() { + const disabled = loading || saving || curatorLoading || curatorSaving || skillsSaving || quickCommandsSaving || providerOverridesSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving + return ` +
+
+
+
${t('engine.hermesCuratorConfigTitle')}
+
${t('engine.hermesCuratorConfigDesc')}
+
+
+ ${curatorSaving ? t('engine.hermesConfigStatusSaving') : curatorLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesCuratorConfigStatusReady')} + +
+
+
+ ${renderError(curatorError)} +
+ + +
+
+ + + + + +
+
${t('engine.hermesCuratorConfigFootnote')}
+
+
+ ` + } + function renderQuickCommandsConfigPanel() { const disabled = loading || saving || quickCommandsLoading || quickCommandsSaving || modelSaving || modelAliasesSaving || hooksSaving || providerOverridesSaving || mcpServersSaving || agentToolsetsSaving || agentRuntimeSaving || runtimeSaving || compressionSaving || promptCachingSaving || openrouterCacheSaving || providerRoutingSaving || auxiliarySaving || toolGuardrailsSaving || memorySaving || skillsSaving || streamingSaving || executionLimitsSaving || checkpointsSaving || cronSaving || loggingSaving || approvalsSaving || terminalSaving return ` @@ -2242,6 +2310,7 @@ export function render() { ${renderToolGuardrailsPanel()} ${renderMemoryPanel()} ${renderSkillsConfigPanel()} + ${renderCuratorConfigPanel()} ${renderQuickCommandsConfigPanel()} ${renderModelConfigPanel()} ${renderModelAliasesConfigPanel()} @@ -2284,6 +2353,7 @@ export function render() { el.querySelector('#hm-tool-guardrails-save')?.addEventListener('click', saveToolGuardrails) el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory) el.querySelector('#hm-skills-config-save')?.addEventListener('click', saveSkillsConfig) + el.querySelector('#hm-curator-config-save')?.addEventListener('click', saveCuratorConfig) el.querySelector('#hm-quick-commands-save')?.addEventListener('click', saveQuickCommandsConfig) el.querySelector('#hm-model-config-save')?.addEventListener('click', saveModelConfig) el.querySelector('#hm-model-aliases-save')?.addEventListener('click', saveModelAliasesConfig) @@ -2361,6 +2431,11 @@ export function render() { skillsValues = { ...SKILLS_DEFAULTS, ...(data?.values || {}) } } + async function loadCuratorConfig() { + const data = await api.hermesCuratorConfigRead() + curatorValues = { ...CURATOR_DEFAULTS, ...(data?.values || {}) } + } + async function loadQuickCommandsConfig() { const data = await api.hermesQuickCommandsConfigRead() quickCommandsValues = { ...QUICK_COMMANDS_DEFAULTS, ...(data?.values || {}) } @@ -2497,6 +2572,7 @@ export function render() { toolGuardrailsLoading = true memoryLoading = true skillsLoading = true + curatorLoading = true quickCommandsLoading = true modelLoading = true modelAliasesLoading = true @@ -2532,6 +2608,7 @@ export function render() { toolGuardrailsError = null memoryError = null skillsError = null + curatorError = null quickCommandsError = null modelError = null modelAliasesError = null @@ -2724,6 +2801,14 @@ export function render() { skillsLoading = false draw() } + try { + await loadCuratorConfig() + } catch (err) { + curatorError = humanizeError(err, t('engine.hermesCuratorConfigLoadFailed') || 'Load curator config failed') + } finally { + curatorLoading = false + draw() + } try { await loadQuickCommandsConfig() } catch (err) { @@ -2884,6 +2969,9 @@ export function render() { try { await loadSkillsConfig() } catch {} + try { + await loadCuratorConfig() + } catch {} try { await loadQuickCommandsConfig() } catch {} @@ -3229,6 +3317,37 @@ export function render() { } } + async function saveCuratorConfig() { + const form = { + curatorEnabled: !!el.querySelector('#hm-curator-enabled')?.checked, + curatorIntervalHours: el.querySelector('#hm-curator-interval-hours')?.value || '168', + curatorMinIdleHours: el.querySelector('#hm-curator-min-idle-hours')?.value || '2', + curatorStaleAfterDays: el.querySelector('#hm-curator-stale-after-days')?.value || '30', + curatorArchiveAfterDays: el.querySelector('#hm-curator-archive-after-days')?.value || '90', + curatorBackupEnabled: !!el.querySelector('#hm-curator-backup-enabled')?.checked, + curatorBackupKeep: el.querySelector('#hm-curator-backup-keep')?.value || '5', + } + curatorSaving = true + curatorError = null + draw() + try { + const result = await api.hermesCuratorConfigSave(form) + curatorValues = { ...CURATOR_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesCuratorConfigSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + curatorError = humanizeError(err, t('engine.hermesCuratorConfigSaveFailed') || 'Save curator config failed') + toast(curatorError, 'error') + } finally { + curatorSaving = false + draw() + } + } + async function saveQuickCommandsConfig() { const form = { quickCommandsJson: el.querySelector('#hm-quick-commands-json')?.value || '{}', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 45416cb..4e8809e 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -527,6 +527,8 @@ export const api = { hermesMemoryConfigSave: (form) => invoke('hermes_memory_config_save', { form }), hermesSkillsConfigRead: () => invoke('hermes_skills_config_read'), hermesSkillsConfigSave: (form) => invoke('hermes_skills_config_save', { form }), + hermesCuratorConfigRead: () => invoke('hermes_curator_config_read'), + hermesCuratorConfigSave: (form) => invoke('hermes_curator_config_save', { form }), hermesQuickCommandsConfigRead: () => invoke('hermes_quick_commands_config_read'), hermesQuickCommandsConfigSave: (form) => invoke('hermes_quick_commands_config_save', { form }), hermesModelConfigRead: () => invoke('hermes_model_config_read'), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 60bfec5..720bd8c 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -840,6 +840,21 @@ export default { hermesSkillsConfigGuardAgentCreated: _('扫描 Agent 创建的技能', 'Scan agent-created skills', '掃描 Agent 建立的技能'), hermesSkillsConfigExternalDirs: _('外部技能目录(每行一个)', 'External skill directories, one per line', '外部技能目錄(每行一個)'), hermesSkillsConfigFootnote: _('提醒间隔按用户消息轮数计算,设为 0 可关闭创建提醒。内联命令会在本机执行,仅对可信技能源开启;外部目录、disabled 和 custom flag 等字段会保留在 raw YAML 中。', 'The nudge interval is counted in user turns. Set it to 0 to disable creation nudges. Inline shell commands run on this machine, so enable them only for trusted skill sources. External dirs, disabled skills, and custom flags are preserved in raw YAML.', '提醒間隔依使用者訊息輪數計算,設為 0 可關閉建立提醒。內嵌命令會在本機執行,僅對可信技能來源開啟;外部目錄、disabled 和 custom flag 等欄位會保留在 raw YAML 中。'), + hermesCuratorConfigTitle: _('技能维护 Curator', 'Skill curator', '技能維護 Curator'), + hermesCuratorConfigDesc: _('配置 Hermes 后台整理 Agent 创建技能的周期、闲置阈值、归档阈值和备份保留数量。', 'Configure how Hermes reviews, marks stale, archives, and backs up agent-created skills in the background.', '設定 Hermes 在背景整理 Agent 建立技能的週期、閒置門檻、封存門檻和備份保留數量。'), + hermesCuratorConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesCuratorConfigSave: _('保存 Curator 配置', 'Save curator settings', '儲存 Curator 設定'), + hermesCuratorConfigSaveSuccess: _('Curator 配置已保存,建议重启 Hermes Gateway 生效', 'Curator settings saved. Restart Hermes Gateway to take effect.', 'Curator 設定已儲存,建議重啟 Hermes Gateway 生效'), + hermesCuratorConfigLoadFailed: _('加载 Curator 配置失败', 'Load curator settings failed', '載入 Curator 設定失敗'), + hermesCuratorConfigSaveFailed: _('保存 Curator 配置失败', 'Save curator settings failed', '儲存 Curator 設定失敗'), + hermesCuratorConfigEnabled: _('启用技能维护', 'Enable skill curator', '啟用技能維護'), + hermesCuratorConfigIntervalHours: _('整理间隔(小时)', 'Review interval, hours', '整理間隔(小時)'), + hermesCuratorConfigMinIdleHours: _('最少闲置时间(小时)', 'Minimum idle time, hours', '最少閒置時間(小時)'), + hermesCuratorConfigStaleAfterDays: _('标记过期天数', 'Mark stale after days', '標記過期天數'), + hermesCuratorConfigArchiveAfterDays: _('归档天数', 'Archive after days', '封存天數'), + hermesCuratorConfigBackupEnabled: _('整理前创建备份', 'Create backup before curating', '整理前建立備份'), + hermesCuratorConfigBackupKeep: _('保留备份数量', 'Backups to keep', '保留備份數量'), + hermesCuratorConfigFootnote: _('Curator 只整理 Agent 创建的技能,不会处理内置或 hub 安装技能;归档天数不能小于过期天数。custom flag 等高级字段会保留在 raw YAML 中。', 'Curator only maintains agent-created skills, never bundled or hub-installed skills. Archive days must be greater than or equal to stale days. Custom flags stay in raw YAML.', 'Curator 只整理 Agent 建立的技能,不會處理內建或 hub 安裝技能;封存天數不可小於過期天數。custom flag 等進階欄位會保留在 raw YAML 中。'), hermesQuickCommandsConfigTitle: _('快捷命令', 'Quick commands', '快捷命令'), hermesQuickCommandsConfigDesc: _('配置消息平台和 CLI 可直接触发的零 token 运维命令,例如状态检查、磁盘空间和 Gateway 重启别名。', 'Configure zero-token operations commands that messaging platforms and the CLI can trigger directly, such as status checks, disk usage, and Gateway restart aliases.', '設定訊息平台和 CLI 可直接觸發的零 token 維運命令,例如狀態檢查、磁碟空間和 Gateway 重啟別名。'), hermesQuickCommandsConfigStatusReady: _('结构化 JSON', 'structured JSON', '結構化 JSON'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index e921d4d..f626dd5 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -67,6 +67,21 @@ test('Hermes 配置页会暴露 Skills 结构化配置字段', () => { } }) +test('Hermes 配置页会暴露 Curator 结构化配置字段', () => { + for (const id of [ + 'hm-curator-config-save', + 'hm-curator-enabled', + 'hm-curator-interval-hours', + 'hm-curator-min-idle-hours', + 'hm-curator-stale-after-days', + 'hm-curator-archive-after-days', + 'hm-curator-backup-enabled', + 'hm-curator-backup-keep', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露快捷命令结构化配置字段', () => { for (const id of [ 'hm-quick-commands-save', diff --git a/tests/hermes-curator-config.test.js b/tests/hermes-curator-config.test.js new file mode 100644 index 0000000..640902a --- /dev/null +++ b/tests/hermes-curator-config.test.js @@ -0,0 +1,102 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesCuratorConfigValues, + mergeHermesCuratorConfig, +} from '../scripts/dev-api.js' + +test('Hermes Curator 配置读取会提供上游默认值', () => { + const values = buildHermesCuratorConfigValues({}) + + assert.deepEqual(values, { + curatorEnabled: true, + curatorIntervalHours: 168, + curatorMinIdleHours: 2, + curatorStaleAfterDays: 30, + curatorArchiveAfterDays: 90, + curatorBackupEnabled: true, + curatorBackupKeep: 5, + }) +}) + +test('Hermes Curator 配置读取会回显 YAML 字段', () => { + const values = buildHermesCuratorConfigValues({ + curator: { + enabled: false, + interval_hours: 24, + min_idle_hours: 6, + stale_after_days: 14, + archive_after_days: 45, + backup: { + enabled: false, + keep: 9, + }, + }, + }) + + assert.equal(values.curatorEnabled, false) + assert.equal(values.curatorIntervalHours, 24) + assert.equal(values.curatorMinIdleHours, 6) + assert.equal(values.curatorStaleAfterDays, 14) + assert.equal(values.curatorArchiveAfterDays, 45) + assert.equal(values.curatorBackupEnabled, false) + assert.equal(values.curatorBackupKeep, 9) +}) + +test('Hermes Curator 配置保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesCuratorConfig({ + curator: { + enabled: true, + backup: { + enabled: true, + custom_flag: 'keep-backup', + }, + custom_flag: 'keep-curator', + }, + skills: { external_dirs: ['~/.agents/skills'] }, + model: { provider: 'anthropic' }, + }, { + curatorEnabled: false, + curatorIntervalHours: '48', + curatorMinIdleHours: '4', + curatorStaleAfterDays: '21', + curatorArchiveAfterDays: '60', + curatorBackupEnabled: false, + curatorBackupKeep: '3', + }) + + assert.deepEqual(next.skills, { external_dirs: ['~/.agents/skills'] }) + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.equal(next.curator.enabled, false) + assert.equal(next.curator.interval_hours, 48) + assert.equal(next.curator.min_idle_hours, 4) + assert.equal(next.curator.stale_after_days, 21) + assert.equal(next.curator.archive_after_days, 60) + assert.equal(next.curator.backup.enabled, false) + assert.equal(next.curator.backup.keep, 3) + assert.equal(next.curator.backup.custom_flag, 'keep-backup') + assert.equal(next.curator.custom_flag, 'keep-curator') +}) + +test('Hermes Curator 配置保存会拒绝越界和不一致保留期', () => { + assert.throws( + () => mergeHermesCuratorConfig({}, { curatorIntervalHours: '0' }), + /curator\.interval_hours/, + ) + assert.throws( + () => mergeHermesCuratorConfig({}, { curatorMinIdleHours: '-1' }), + /curator\.min_idle_hours/, + ) + assert.throws( + () => mergeHermesCuratorConfig({}, { curatorBackupKeep: '1001' }), + /curator\.backup\.keep/, + ) + assert.throws( + () => mergeHermesCuratorConfig({}, { + curatorStaleAfterDays: '90', + curatorArchiveAfterDays: '30', + }), + /curator\.archive_after_days/, + ) +})