use crate::{
core::{config_cache::ConfigCache, extensions::ExtensionSetting, settings_manager::agent_dir},
extensions::util::{choice_num, cycle_setting},
};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
sync::RwLock,
};
pub(super) const EXT: &str = "memory";
pub(super) const CMD: &str = "memory";
const CONFIG_SUBDIR: &str = "extensions";
pub(super) const CONFIG_FILE: &str = "memory.json";
const CONFIG_VERSION: i64 = 1;
const RECALL_TABLE: &[(f64, &str)] = &[
(0.0, "off"),
(3.0, "3"),
(5.0, "5"),
(8.0, "8"),
(12.0, "12"),
];
const EXTRACT_TABLE: &[(f64, &str)] = &[
(0.0, "off"),
(6.0, "6 turns"),
(12.0, "12 turns"),
(24.0, "24 turns"),
(48.0, "48 turns"),
];
const BUDGET_TABLE: &[(f64, &str)] = &[
(2000.0, "2k"),
(4000.0, "4k"),
(8000.0, "8k"),
(16000.0, "16k"),
];
const RECALL_KEY: &str = "recallLimit";
const EXTRACT_KEY: &str = "extractInterval";
const BUDGET_KEY: &str = "injectBudgetChars";
pub(super) const NUMERIC_DEFAULTS: &[(&str, f64)] =
&[(RECALL_KEY, 5.0), (EXTRACT_KEY, 12.0), (BUDGET_KEY, 8000.0)];
static CONFIG_LOCK: RwLock<()> = RwLock::new(());
static CONFIG_CACHE: ConfigCache = ConfigCache::new();
pub(super) fn config_path() -> PathBuf {
agent_dir().join(CONFIG_SUBDIR).join(CONFIG_FILE)
}
#[derive(Clone)]
pub(super) struct Config {
pub(super) values: BTreeMap<String, Value>,
}
impl Config {
fn uint(&self, key: &str) -> usize {
self.values
.get(key)
.and_then(|v| v.as_f64())
.filter(|v| *v >= 0.0)
.map(|v| v as usize)
.unwrap_or(0)
}
pub(super) fn recall_limit(&self) -> usize {
self.uint(RECALL_KEY)
}
pub(super) fn extract_interval(&self) -> usize {
self.uint(EXTRACT_KEY)
}
pub(super) fn inject_budget(&self) -> usize {
self.uint(BUDGET_KEY)
}
}
fn default_keys() -> Vec<String> {
NUMERIC_DEFAULTS
.iter()
.map(|(k, _)| k.to_string())
.collect()
}
pub(super) fn panel_settings(cfg: &Config) -> Vec<ExtensionSetting> {
vec![
cycle_setting(
"recall",
"Auto recall",
"How many memories to inject before each request (off disables automatic recall)",
label_for(cfg, RECALL_KEY, RECALL_TABLE),
&["off", "3", "5", "8", "12"],
),
cycle_setting(
"extract",
"Auto extract",
"Extract durable facts from the conversation every N turns (off disables extraction)",
label_for(cfg, EXTRACT_KEY, EXTRACT_TABLE),
&["off", "6 turns", "12 turns", "24 turns", "48 turns"],
),
cycle_setting(
"budget",
"Inject budget",
"Character budget for the injected memory block",
label_for(cfg, BUDGET_KEY, BUDGET_TABLE),
&["2k", "4k", "8k", "16k"],
),
]
}
fn label_for(cfg: &Config, key: &str, table: &[(f64, &str)]) -> String {
let current = cfg.uint(key) as f64;
table
.iter()
.find(|(v, _)| (*v - current).abs() < f64::EPSILON)
.map(|(_, l)| (*l).to_string())
.unwrap_or_else(|| format!("{current}"))
}
pub(super) fn apply_setting(
cfg: &mut Config,
key: &str,
value: &str,
) -> std::result::Result<(), String> {
match key {
"recall" => set_numeric(cfg, RECALL_KEY, choice_num(RECALL_TABLE, value)?),
"extract" => set_numeric(cfg, EXTRACT_KEY, choice_num(EXTRACT_TABLE, value)?),
"budget" => set_numeric(cfg, BUDGET_KEY, choice_num(BUDGET_TABLE, value)?),
other => return Err(format!("unknown setting: {other}")),
}
Ok(())
}
pub(super) fn set_numeric(cfg: &mut Config, key: &str, value: f64) {
cfg.values.insert(key.to_string(), json!(value as i64));
}
fn validated_value(value: &Value) -> Option<Value> {
let v = value.as_f64()?;
if !v.is_finite() || v < 0.0 || v.fract() != 0.0 {
return None;
}
Some(json!(v as i64))
}
pub(super) fn merge_config(from_file: Option<&Value>) -> Config {
let mut values = BTreeMap::new();
for (key, default) in NUMERIC_DEFAULTS {
let value = from_file
.and_then(|f| f.get(key))
.and_then(validated_value)
.unwrap_or_else(|| json!(*default as i64));
values.insert((*key).to_string(), value);
}
values.insert("configVersion".to_string(), json!(CONFIG_VERSION));
Config { values }
}
pub(super) fn load_config() -> Config {
let path = config_path();
let from_file = read_file_value(&path);
let cfg = merge_config(from_file.as_ref());
let should_write = match &from_file {
None => !path.exists(),
Some(file) => {
default_keys().iter().any(|k| file.get(k).is_none())
|| file.get("configVersion").and_then(Value::as_i64) != Some(CONFIG_VERSION)
}
};
if should_write {
write_config(&path, &cfg);
}
cfg
}
fn read_file_value(path: &Path) -> Option<Value> {
let _guard = CONFIG_LOCK.read().unwrap_or_else(|e| e.into_inner());
let value = CONFIG_CACHE.read(path, || {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.filter(Value::is_object)
.unwrap_or(Value::Null)
});
(!value.is_null()).then_some(value)
}
pub(super) fn write_config(path: &Path, cfg: &Config) {
let _guard = CONFIG_LOCK.write().unwrap_or_else(|e| e.into_inner());
if let Some(parent) = path.parent() {
_ = fs::create_dir_all(parent);
}
if let Ok(text) = serde_json::to_string_pretty(&cfg.values)
&& fs::write(path, format!("{text}\n")).is_ok()
{
CONFIG_CACHE.store(
path,
Value::Object(cfg.values.clone().into_iter().collect()),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::AgentDirGuard;
#[test]
fn defaults_are_complete_and_merge_validates_types() {
let cfg = merge_config(None);
assert_eq!(cfg.recall_limit(), 5);
assert_eq!(cfg.extract_interval(), 12);
assert_eq!(cfg.inject_budget(), 8000);
assert_eq!(cfg.values["configVersion"], json!(CONFIG_VERSION));
let cfg = merge_config(Some(&json!({
"recallLimit": 8,
"extractInterval": -3,
"injectBudgetChars": 2.5,
"configVersion": 1
})));
assert_eq!(cfg.recall_limit(), 8, "合法值被采用");
assert_eq!(cfg.extract_interval(), 12, "负数回退缺省");
assert_eq!(cfg.inject_budget(), 8000, "小数回退缺省");
}
#[test]
fn zero_disables_features() {
let cfg = merge_config(Some(&json!({
"recallLimit": 0,
"extractInterval": 0,
"injectBudgetChars": 0,
"configVersion": 1
})));
assert_eq!(cfg.recall_limit(), 0);
assert_eq!(cfg.extract_interval(), 0);
assert_eq!(cfg.inject_budget(), 0);
}
#[test]
fn panel_settings_cycle_through_all_choices() {
let mut cfg = merge_config(None);
let items = panel_settings(&cfg);
assert_eq!(items.len(), 3);
assert_eq!(items[0].value, "5");
assert_eq!(items[1].value, "12 turns");
assert_eq!(items[2].value, "8k");
apply_setting(&mut cfg, "recall", "off").unwrap();
apply_setting(&mut cfg, "extract", "6 turns").unwrap();
apply_setting(&mut cfg, "budget", "2k").unwrap();
assert_eq!(cfg.recall_limit(), 0);
assert_eq!(cfg.extract_interval(), 6);
assert_eq!(cfg.inject_budget(), 2000);
assert_eq!(
apply_setting(&mut cfg, "recall", "bogus"),
Err("invalid option: bogus".to_string())
);
assert_eq!(
apply_setting(&mut cfg, "nope", "1"),
Err("unknown setting: nope".to_string())
);
}
#[test]
fn config_round_trips_and_backfills_missing_keys() {
let _guard = AgentDirGuard::temp();
let cfg = load_config();
assert_eq!(cfg.recall_limit(), 5, "首次加载写回缺省");
let path = config_path();
assert!(path.exists());
std::fs::write(&path, r#"{"recallLimit": 3}"#).unwrap();
let cfg = load_config();
assert_eq!(cfg.recall_limit(), 3, "已有值被保留");
assert_eq!(cfg.extract_interval(), 12, "缺键回填缺省");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("extractInterval"), "缺键被写回: {text}");
assert!(text.contains("configVersion"));
}
#[test]
fn corrupt_config_falls_back_without_overwriting() {
let _guard = AgentDirGuard::temp();
let path = config_path();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{broken").unwrap();
let cfg = load_config();
assert_eq!(cfg.recall_limit(), 5);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{broken",
"损坏文件不覆盖(用户要能看到自己写坏了什么)"
);
}
}