feat(hermes): add curator maintenance controls

This commit is contained in:
晴天
2026-05-27 01:59:06 +08:00
parent ec0f7ec64a
commit d2236afc2d
8 changed files with 601 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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<Value, String> {
}))
}
#[tauri::command]
pub fn hermes_curator_config_read() -> Result<Value, String> {
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<Value, String> {
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<Value, String> {
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};

View File

@@ -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,

View File

@@ -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 `
<div class="hm-panel hm-config-runtime-panel hm-config-curator-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${t('engine.hermesCuratorConfigTitle')}</div>
<div class="hm-channel-panel-desc">${t('engine.hermesCuratorConfigDesc')}</div>
</div>
<div class="hm-panel-actions">
<span class="hm-muted">${curatorSaving ? t('engine.hermesConfigStatusSaving') : curatorLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesCuratorConfigStatusReady')}</span>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-curator-config-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesCuratorConfigSave')}</button>
</div>
</div>
<div class="hm-panel-body">
${renderError(curatorError)}
<div class="hm-config-check-grid">
<label class="hm-channel-check">
<input id="hm-curator-enabled" type="checkbox" ${curatorValues.curatorEnabled ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesCuratorConfigEnabled')}</span>
</label>
<label class="hm-channel-check">
<input id="hm-curator-backup-enabled" type="checkbox" ${curatorValues.curatorBackupEnabled ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${t('engine.hermesCuratorConfigBackupEnabled')}</span>
</label>
</div>
<div class="hm-config-runtime-grid hm-config-curator-grid">
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesCuratorConfigIntervalHours')}</span>
<input id="hm-curator-interval-hours" class="hm-input" type="number" inputmode="numeric" min="1" max="87600" step="1" value="${esc(curatorValues.curatorIntervalHours)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesCuratorConfigMinIdleHours')}</span>
<input id="hm-curator-min-idle-hours" class="hm-input" type="number" inputmode="numeric" min="0" max="87600" step="1" value="${esc(curatorValues.curatorMinIdleHours)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesCuratorConfigStaleAfterDays')}</span>
<input id="hm-curator-stale-after-days" class="hm-input" type="number" inputmode="numeric" min="1" max="36500" step="1" value="${esc(curatorValues.curatorStaleAfterDays)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesCuratorConfigArchiveAfterDays')}</span>
<input id="hm-curator-archive-after-days" class="hm-input" type="number" inputmode="numeric" min="1" max="36500" step="1" value="${esc(curatorValues.curatorArchiveAfterDays)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesCuratorConfigBackupKeep')}</span>
<input id="hm-curator-backup-keep" class="hm-input" type="number" inputmode="numeric" min="0" max="1000" step="1" value="${esc(curatorValues.curatorBackupKeep)}" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-channel-footnote">${t('engine.hermesCuratorConfigFootnote')}</div>
</div>
</div>
`
}
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 || '{}',

View File

@@ -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'),

View File

@@ -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'),

View File

@@ -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',

View File

@@ -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/,
)
})