use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use pensieve_core::tenant::TenantId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryVisibility {
#[default]
Public,
Private,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MemorySettings {
pub extraction_enabled: bool,
pub min_events: i64,
pub default_limit: usize,
pub default_expand_hops: u8,
pub ann_threshold: f64,
pub w_rrf: f64,
pub w_semantic: f64,
pub w_keyword: f64,
pub w_graph: f64,
pub w_importance: f64,
pub w_recency: f64,
pub half_life_days: f64,
pub rrf_k: f64,
pub w_reinforcement: f64,
pub reinforcement: ReinforcementSettings,
pub precedent: PrecedentSettings,
pub mmr: MmrSettings,
pub validity_gate: ValidityGateSettings,
pub schema_induction: SchemaInductionSettings,
pub dreaming: DreamingSettings,
pub hitl: super::memory_policy::HitlPolicy,
pub default_visibility: MemoryVisibility,
}
impl MemorySettings {
pub fn resolve_space(
&self,
explicit: Option<&str>,
writer_subject: Option<&str>,
) -> Option<String> {
if let Some(s) = explicit {
return Some(s.to_string());
}
match (self.default_visibility, writer_subject) {
(MemoryVisibility::Private, Some(subj)) if !subj.is_empty() => {
Some(format!("private:{subj}"))
}
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DreamingSettings {
pub enabled: bool,
pub interval_secs: u64,
pub mode: String,
pub realm_scope: Vec<String>,
pub max_tool_calls: u32,
pub wall_clock_secs: u64,
#[serde(alias = "connector_read_budget")]
pub data_source_read_budget: u32,
#[serde(alias = "connector_read_max_bytes")]
pub data_source_read_max_bytes: u64,
pub mutation_cap: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ReinforcementSettings {
pub enabled: bool,
pub hit_weight: f64,
pub miss_penalty: f64,
pub half_life_days: f64,
}
impl Default for ReinforcementSettings {
fn default() -> Self {
Self {
enabled: false,
hit_weight: pensieve_memory::REINFORCEMENT_HIT_WEIGHT,
miss_penalty: pensieve_memory::REINFORCEMENT_MISS_PENALTY,
half_life_days: pensieve_memory::HALF_LIFE_DAYS,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PrecedentSettings {
pub enabled: bool,
pub max_distance: f64,
pub memory_limit: usize,
}
impl Default for PrecedentSettings {
fn default() -> Self {
Self {
enabled: true,
max_distance: pensieve_memory::PRECEDENT_MAX_DISTANCE,
memory_limit: 20,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MmrSettings {
pub enabled: bool,
pub lambda: f64,
pub pool_multiplier: usize,
}
impl Default for MmrSettings {
fn default() -> Self {
Self {
enabled: false,
lambda: 0.7,
pool_multiplier: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ValidityGateSettings {
pub enabled: bool,
pub llm_escalation_enabled: bool,
pub min_confidence: f32,
pub min_content_chars: usize,
}
impl Default for ValidityGateSettings {
fn default() -> Self {
Self {
enabled: false,
llm_escalation_enabled: false,
min_confidence: 0.35,
min_content_chars: 12,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SchemaInductionSettings {
pub enabled: bool,
pub min_examples: u32,
pub interval_days: u32,
}
impl Default for SchemaInductionSettings {
fn default() -> Self {
Self {
enabled: false,
min_examples: 3,
interval_days: 7,
}
}
}
impl Default for DreamingSettings {
fn default() -> Self {
Self {
enabled: false,
interval_secs: 86_400,
mode: "full".into(),
realm_scope: vec![],
max_tool_calls: 100,
wall_clock_secs: 3_600,
data_source_read_budget: 25,
data_source_read_max_bytes: 4 * 1024 * 1024,
mutation_cap: 60,
}
}
}
impl Default for MemorySettings {
fn default() -> Self {
Self {
extraction_enabled: true,
min_events: 1,
default_limit: 8,
default_expand_hops: 1,
ann_threshold: 0.0,
w_rrf: pensieve_memory::W_RRF,
w_semantic: pensieve_memory::W_SEMANTIC,
w_keyword: pensieve_memory::W_KEYWORD,
w_graph: pensieve_memory::W_GRAPH,
w_importance: pensieve_memory::W_IMPORTANCE,
w_recency: pensieve_memory::W_RECENCY,
half_life_days: pensieve_memory::HALF_LIFE_DAYS,
rrf_k: pensieve_memory::RRF_K,
w_reinforcement: pensieve_memory::W_REINFORCEMENT,
reinforcement: ReinforcementSettings::default(),
precedent: PrecedentSettings::default(),
mmr: MmrSettings::default(),
validity_gate: ValidityGateSettings::default(),
schema_induction: SchemaInductionSettings::default(),
dreaming: DreamingSettings::default(),
hitl: super::memory_policy::HitlPolicy::default(),
default_visibility: MemoryVisibility::Public,
}
}
}
pub async fn load(pool: Option<&PgPool>, tenant: TenantId) -> MemorySettings {
let Some(pool) = pool else {
return MemorySettings::default();
};
let row: Option<(serde_json::Value,)> =
sqlx::query_as("SELECT settings FROM memory_settings WHERE tenant_id = $1")
.bind(tenant.as_uuid())
.fetch_optional(pool)
.await
.ok()
.flatten();
match row {
Some((v,)) => serde_json::from_value(v).unwrap_or_default(),
None => MemorySettings::default(),
}
}
pub async fn load_for(state: &super::state::AgentState) -> MemorySettings {
if let Some(pool) = state.pool.as_ref() {
return load(Some(pool), state.tenant).await;
}
if let Some(path) = state.memory_settings_path.as_ref() {
return load_local(path).await;
}
MemorySettings::default()
}
pub async fn load_local(path: &std::path::Path) -> MemorySettings {
match tokio::fs::read_to_string(path).await {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(),
Err(_) => MemorySettings::default(),
}
}
pub async fn save_local(path: &std::path::Path, s: &MemorySettings) -> anyhow::Result<()> {
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await?;
}
tokio::fs::write(path, serde_json::to_string_pretty(s)?).await?;
Ok(())
}
pub async fn save(pool: &PgPool, tenant: TenantId, s: &MemorySettings) -> anyhow::Result<()> {
let json = serde_json::to_value(s)?;
sqlx::query(
"INSERT INTO memory_settings (tenant_id, settings, updated_at) \
VALUES ($1, $2, now()) \
ON CONFLICT (tenant_id) DO UPDATE SET settings = $2, updated_at = now()",
)
.bind(tenant.as_uuid())
.bind(json)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn local_settings_file_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("memory-settings.json");
let d = load_local(&path).await;
assert!(!d.dreaming.enabled);
let mut s = MemorySettings::default();
s.dreaming.enabled = true;
s.dreaming.wall_clock_secs = 180;
s.dreaming.mutation_cap = 6;
save_local(&path, &s).await.unwrap();
let loaded = load_local(&path).await;
assert!(loaded.dreaming.enabled);
assert_eq!(loaded.dreaming.wall_clock_secs, 180);
assert_eq!(loaded.dreaming.mutation_cap, 6);
}
#[test]
fn legacy_settings_row_without_hitl_loads_default_off() {
let legacy = serde_json::json!({
"extraction_enabled": true,
"min_events": 1,
"dreaming": { "enabled": true }
});
let s: MemorySettings = serde_json::from_value(legacy).unwrap();
assert!(!s.hitl.enabled, "HITL must default off for legacy rows");
assert!(s.dreaming.enabled, "existing fields still load");
assert_eq!(
s.default_visibility,
MemoryVisibility::Public,
"legacy rows default to public visibility (no behavior change)"
);
}
#[test]
fn resolve_space_applies_tenant_default() {
let public = MemorySettings::default();
assert_eq!(public.resolve_space(None, Some("agentA")), None);
let mut private = MemorySettings::default();
private.default_visibility = MemoryVisibility::Private;
assert_eq!(
private.resolve_space(None, Some("agentA")),
Some("private:agentA".to_string())
);
assert_eq!(private.resolve_space(None, None), None);
assert_eq!(private.resolve_space(None, Some("")), None);
assert_eq!(
private.resolve_space(Some("public"), Some("agentA")),
Some("public".to_string())
);
assert_eq!(
public.resolve_space(Some("private:other"), None),
Some("private:other".to_string())
);
}
#[test]
fn visibility_serde_is_snake_case() {
assert_eq!(
serde_json::to_string(&MemoryVisibility::Private).unwrap(),
"\"private\""
);
let v: MemoryVisibility = serde_json::from_str("\"public\"").unwrap();
assert_eq!(v, MemoryVisibility::Public);
}
#[test]
fn pre_rename_dreaming_budget_keys_still_load() {
let legacy = serde_json::json!({
"dreaming": {
"enabled": true,
"connector_read_budget": 7,
"connector_read_max_bytes": 123_456
}
});
let s: MemorySettings = serde_json::from_value(legacy).unwrap();
assert_eq!(s.dreaming.data_source_read_budget, 7);
assert_eq!(s.dreaming.data_source_read_max_bytes, 123_456);
let v = serde_json::to_value(&s).unwrap();
assert!(v["dreaming"].get("connector_read_budget").is_none());
assert_eq!(v["dreaming"]["data_source_read_budget"], 7);
}
#[test]
fn legacy_settings_row_without_reinforcement_loads_default_off() {
let legacy = serde_json::json!({
"extraction_enabled": true,
"min_events": 1
});
let s: MemorySettings = serde_json::from_value(legacy).unwrap();
assert!(
!s.reinforcement.enabled,
"reinforcement must default off for legacy rows"
);
assert_eq!(s.w_reinforcement, 0.0);
}
#[test]
fn reinforcement_roundtrips_through_settings_json() {
let mut s = MemorySettings::default();
s.reinforcement.enabled = true;
s.reinforcement.hit_weight = 0.5;
s.w_reinforcement = 0.2;
let v = serde_json::to_value(&s).unwrap();
let back: MemorySettings = serde_json::from_value(v).unwrap();
assert!(back.reinforcement.enabled);
assert_eq!(back.reinforcement.hit_weight, 0.5);
assert_eq!(back.w_reinforcement, 0.2);
}
#[test]
fn legacy_settings_row_without_precedent_loads_default_on() {
let legacy = serde_json::json!({
"extraction_enabled": true,
"min_events": 1
});
let s: MemorySettings = serde_json::from_value(legacy).unwrap();
assert!(s.precedent.enabled);
assert_eq!(
s.precedent.max_distance,
pensieve_memory::PRECEDENT_MAX_DISTANCE
);
}
#[test]
fn precedent_roundtrips_through_settings_json() {
let mut s = MemorySettings::default();
s.precedent.enabled = false;
s.precedent.max_distance = 0.05;
let v = serde_json::to_value(&s).unwrap();
let back: MemorySettings = serde_json::from_value(v).unwrap();
assert!(!back.precedent.enabled);
assert_eq!(back.precedent.max_distance, 0.05);
}
#[test]
fn legacy_settings_row_without_mmr_or_validity_gate_loads_default_off() {
let legacy = serde_json::json!({
"extraction_enabled": true,
"min_events": 1
});
let s: MemorySettings = serde_json::from_value(legacy).unwrap();
assert!(!s.mmr.enabled);
assert!(!s.validity_gate.enabled);
assert!(!s.validity_gate.llm_escalation_enabled);
}
#[test]
fn mmr_roundtrips_through_settings_json() {
let mut s = MemorySettings::default();
s.mmr.enabled = true;
s.mmr.lambda = 0.5;
s.mmr.pool_multiplier = 5;
let v = serde_json::to_value(&s).unwrap();
let back: MemorySettings = serde_json::from_value(v).unwrap();
assert!(back.mmr.enabled);
assert_eq!(back.mmr.lambda, 0.5);
assert_eq!(back.mmr.pool_multiplier, 5);
}
#[test]
fn validity_gate_roundtrips_through_settings_json() {
let mut s = MemorySettings::default();
s.validity_gate.enabled = true;
s.validity_gate.llm_escalation_enabled = true;
s.validity_gate.min_confidence = 0.5;
let v = serde_json::to_value(&s).unwrap();
let back: MemorySettings = serde_json::from_value(v).unwrap();
assert!(back.validity_gate.enabled);
assert!(back.validity_gate.llm_escalation_enabled);
assert_eq!(back.validity_gate.min_confidence, 0.5);
}
#[test]
fn legacy_settings_row_without_schema_induction_loads_default_off() {
let legacy = serde_json::json!({
"extraction_enabled": true,
"min_events": 1
});
let s: MemorySettings = serde_json::from_value(legacy).unwrap();
assert!(!s.schema_induction.enabled);
assert_eq!(s.schema_induction.min_examples, 3);
assert_eq!(s.schema_induction.interval_days, 7);
}
#[test]
fn schema_induction_roundtrips_through_settings_json() {
let mut s = MemorySettings::default();
s.schema_induction.enabled = true;
s.schema_induction.min_examples = 5;
s.schema_induction.interval_days = 14;
let v = serde_json::to_value(&s).unwrap();
let back: MemorySettings = serde_json::from_value(v).unwrap();
assert!(back.schema_induction.enabled);
assert_eq!(back.schema_induction.min_examples, 5);
assert_eq!(back.schema_induction.interval_days, 14);
}
#[test]
fn hitl_roundtrips_through_settings_json() {
let mut s = MemorySettings::default();
s.hitl.enabled = true;
s.hitl.confidence_threshold = 0.8;
let v = serde_json::to_value(&s).unwrap();
let back: MemorySettings = serde_json::from_value(v).unwrap();
assert!(back.hitl.enabled);
assert_eq!(back.hitl.confidence_threshold, 0.8);
}
}