Skip to main content

zeph_config/memory/
persona.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Persona, trajectory, and risk-accumulator configuration.
5//!
6//! Persona inference, trajectory categorization/tree building, sidequest cursors,
7//! and the `TrajectoryRiskAccumulator` signal-weighting model.
8
9use crate::defaults::default_true;
10use crate::providers::ProviderName;
11use serde::{Deserialize, Serialize};
12
13fn default_sidequest_interval_turns() -> u32 {
14    4
15}
16
17fn default_sidequest_max_eviction_ratio() -> f32 {
18    0.5
19}
20
21fn default_sidequest_max_cursors() -> usize {
22    30
23}
24
25fn default_sidequest_min_cursor_tokens() -> usize {
26    100
27}
28
29/// Configuration for LLM-driven side-thread tool output eviction (#1885).
30#[derive(Debug, Clone, Deserialize, Serialize)]
31#[serde(default)]
32pub struct SidequestConfig {
33    /// Enable `SideQuest` eviction. Default: `false`.
34    pub enabled: bool,
35    /// Run eviction every N user turns. Default: `4`.
36    #[serde(default = "default_sidequest_interval_turns")]
37    pub interval_turns: u32,
38    /// Maximum fraction of tool outputs to evict per pass. Default: `0.5`.
39    #[serde(default = "default_sidequest_max_eviction_ratio")]
40    pub max_eviction_ratio: f32,
41    /// Maximum cursor entries in eviction prompt (largest outputs first). Default: `30`.
42    #[serde(default = "default_sidequest_max_cursors")]
43    pub max_cursors: usize,
44    /// Exclude tool outputs smaller than this token count from eviction candidates.
45    /// Default: `100`.
46    #[serde(default = "default_sidequest_min_cursor_tokens")]
47    pub min_cursor_tokens: usize,
48}
49
50impl Default for SidequestConfig {
51    fn default() -> Self {
52        Self {
53            enabled: false,
54            interval_turns: default_sidequest_interval_turns(),
55            max_eviction_ratio: default_sidequest_max_eviction_ratio(),
56            max_cursors: default_sidequest_max_cursors(),
57            min_cursor_tokens: default_sidequest_min_cursor_tokens(),
58        }
59    }
60}
61
62/// Persona memory layer configuration (#2461).
63///
64/// When `enabled = true`, user preferences and domain knowledge are extracted from
65/// conversation history via a cheap LLM provider and injected after the system prompt.
66#[derive(Debug, Clone, Deserialize, Serialize)]
67#[serde(default)]
68pub struct PersonaConfig {
69    /// Enable persona memory extraction and injection. Default: `false`.
70    pub enabled: bool,
71    /// Provider name from `[[llm.providers]]` for persona extraction.
72    /// Should be a cheap/fast model. Falls back to the primary provider when empty.
73    pub persona_provider: ProviderName,
74    /// Minimum confidence threshold for facts included in context. Default: `0.6`.
75    pub min_confidence: f64,
76    /// Minimum user messages before extraction runs in a session. Default: `3`.
77    pub min_messages: usize,
78    /// Maximum messages sent to the LLM per extraction pass. Default: `10`.
79    pub max_messages: usize,
80    /// LLM timeout for the extraction call in seconds. Default: `10`.
81    pub extraction_timeout_secs: u64,
82    /// Token budget allocated to persona context in assembly. Default: `500`.
83    pub context_budget_tokens: usize,
84}
85
86impl Default for PersonaConfig {
87    fn default() -> Self {
88        Self {
89            enabled: false,
90            persona_provider: ProviderName::default(),
91            min_confidence: 0.6,
92            min_messages: 3,
93            max_messages: 10,
94            extraction_timeout_secs: 10,
95            context_budget_tokens: 500,
96        }
97    }
98}
99
100/// Trajectory-informed memory configuration (#2498).
101///
102/// When `enabled = true`, tool-call turns are analyzed by a fast LLM provider to extract
103/// procedural (reusable how-to) and episodic (one-off event) entries stored per-conversation.
104/// Procedural entries are injected into context as "past experience" during assembly.
105#[derive(Debug, Clone, Deserialize, Serialize)]
106#[serde(default)]
107pub struct TrajectoryConfig {
108    /// Enable trajectory extraction and context injection. Default: `false`.
109    pub enabled: bool,
110    /// Provider name from `[[llm.providers]]` for extraction.
111    /// Should be a fast/cheap model. Falls back to the primary provider when empty.
112    pub trajectory_provider: ProviderName,
113    /// Token budget allocated to trajectory hints in context assembly. Default: `400`.
114    pub context_budget_tokens: usize,
115    /// Maximum messages fed to the extraction LLM per pass. Default: `10`.
116    pub max_messages: usize,
117    /// LLM timeout for the extraction call in seconds. Default: `10`.
118    pub extraction_timeout_secs: u64,
119    /// Number of procedural entries retrieved for context injection. Default: `5`.
120    pub recall_top_k: usize,
121    /// Minimum confidence score for entries included in context. Default: `0.6`.
122    pub min_confidence: f64,
123}
124
125impl Default for TrajectoryConfig {
126    fn default() -> Self {
127        Self {
128            enabled: false,
129            trajectory_provider: ProviderName::default(),
130            context_budget_tokens: 400,
131            max_messages: 10,
132            extraction_timeout_secs: 10,
133            recall_top_k: 5,
134            min_confidence: 0.6,
135        }
136    }
137}
138
139/// Category-aware memory configuration (#2428).
140///
141/// When `enabled = true`, messages are auto-tagged with a category derived from the active
142/// skill or tool context. The category is stored in the `messages.category` column and used
143/// as a Qdrant payload filter during recall.
144#[derive(Debug, Clone, Deserialize, Serialize)]
145#[serde(default)]
146pub struct CategoryConfig {
147    /// Enable category tagging and category-filtered recall. Default: `false`.
148    pub enabled: bool,
149    /// Automatically assign category from skill metadata or tool type. Default: `true`.
150    pub auto_tag: bool,
151}
152
153impl Default for CategoryConfig {
154    fn default() -> Self {
155        Self {
156            enabled: false,
157            auto_tag: true,
158        }
159    }
160}
161
162/// `TiMem` temporal-hierarchical memory tree configuration (#2262).
163///
164/// When `enabled = true`, memories are stored as leaf nodes and periodically consolidated
165/// into hierarchical summaries by a background loop. Context assembly uses tree traversal
166/// for complex queries.
167#[derive(Debug, Clone, Deserialize, Serialize)]
168#[serde(default)]
169pub struct TreeConfig {
170    /// Enable the memory tree and background consolidation loop. Default: `false`.
171    pub enabled: bool,
172    /// Provider name from `[[llm.providers]]` for node consolidation.
173    /// Should be a fast/cheap model. Falls back to the primary provider when empty.
174    pub consolidation_provider: ProviderName,
175    /// Interval between consolidation sweeps in seconds. Default: `300`.
176    pub sweep_interval_secs: u64,
177    /// Maximum leaf nodes loaded per sweep batch. Default: `20`.
178    pub batch_size: usize,
179    /// Cosine similarity threshold for clustering leaves. Default: `0.8`.
180    pub similarity_threshold: f32,
181    /// Maximum tree depth (levels above leaves). Default: `3`.
182    pub max_level: u32,
183    /// Token budget allocated to tree memory in context assembly. Default: `400`.
184    pub context_budget_tokens: usize,
185    /// Number of tree nodes retrieved for context. Default: `5`.
186    pub recall_top_k: usize,
187    /// Minimum cluster size before triggering LLM consolidation. Default: `2`.
188    pub min_cluster_size: usize,
189}
190
191impl Default for TreeConfig {
192    fn default() -> Self {
193        Self {
194            enabled: false,
195            consolidation_provider: ProviderName::default(),
196            sweep_interval_secs: 300,
197            batch_size: 20,
198            similarity_threshold: 0.8,
199            max_level: 3,
200            context_budget_tokens: 400,
201            recall_top_k: 5,
202            min_cluster_size: 2,
203        }
204    }
205}
206
207// ── TrajectoryRiskAccumulator config (spec 004-19) ─────────────────────────────
208
209fn validate_tra_nonneg_weight<'de, D>(deserializer: D) -> Result<f64, D::Error>
210where
211    D: serde::Deserializer<'de>,
212{
213    let value = <f64 as serde::Deserialize>::deserialize(deserializer)?;
214    if value.is_nan() || value.is_infinite() || value < 0.0 {
215        return Err(serde::de::Error::custom(
216            "signal weight and severity multiplier values must be finite and non-negative",
217        ));
218    }
219    Ok(value)
220}
221
222/// Per-signal-type base weights for the trajectory risk accumulator.
223///
224/// Each weight is in `(0.0, 1.0]` and is multiplied by the severity multiplier
225/// before being added to `trajectory_risk`.
226///
227/// # Example (TOML)
228///
229/// ```toml
230/// [memory.shadow_memory.signal_weights]
231/// prompt_injection = 0.6
232/// ```
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct TrajectorySignalWeights {
235    /// Weight for `PolicyViolation` signals. Default: `0.30`.
236    #[serde(
237        default = "default_sw_policy_violation",
238        deserialize_with = "validate_tra_nonneg_weight"
239    )]
240    pub policy_violation: f64,
241    /// Weight for `PromptInjectionPattern` signals. Default: `0.50`.
242    #[serde(
243        default = "default_sw_prompt_injection",
244        deserialize_with = "validate_tra_nonneg_weight"
245    )]
246    pub prompt_injection: f64,
247    /// Weight for `ToolChainAnomaly` signals. Default: `0.25`.
248    #[serde(
249        default = "default_sw_tool_chain_anomaly",
250        deserialize_with = "validate_tra_nonneg_weight"
251    )]
252    pub tool_chain_anomaly: f64,
253    /// Weight for `ConfidenceDrop` signals. Default: `0.15`.
254    #[serde(
255        default = "default_sw_confidence_drop",
256        deserialize_with = "validate_tra_nonneg_weight"
257    )]
258    pub confidence_drop: f64,
259}
260
261fn default_sw_policy_violation() -> f64 {
262    0.30
263}
264
265fn default_sw_prompt_injection() -> f64 {
266    0.50
267}
268
269fn default_sw_tool_chain_anomaly() -> f64 {
270    0.25
271}
272
273fn default_sw_confidence_drop() -> f64 {
274    0.15
275}
276
277impl Default for TrajectorySignalWeights {
278    fn default() -> Self {
279        Self {
280            policy_violation: default_sw_policy_violation(),
281            prompt_injection: default_sw_prompt_injection(),
282            tool_chain_anomaly: default_sw_tool_chain_anomaly(),
283            confidence_drop: default_sw_confidence_drop(),
284        }
285    }
286}
287
288/// Per-severity multipliers applied on top of signal base weights.
289///
290/// # Example (TOML)
291///
292/// ```toml
293/// [memory.shadow_memory.severity_multipliers]
294/// high = 3.0
295/// ```
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct TrajectorySeverityMultipliers {
298    /// Multiplier for low-severity signals. Default: `0.5`.
299    #[serde(
300        default = "default_sev_low",
301        deserialize_with = "validate_tra_nonneg_weight"
302    )]
303    pub low: f64,
304    /// Multiplier for medium-severity signals. Default: `1.0`.
305    #[serde(
306        default = "default_sev_medium",
307        deserialize_with = "validate_tra_nonneg_weight"
308    )]
309    pub medium: f64,
310    /// Multiplier for high-severity signals. Default: `2.0`.
311    #[serde(
312        default = "default_sev_high",
313        deserialize_with = "validate_tra_nonneg_weight"
314    )]
315    pub high: f64,
316}
317
318fn default_sev_low() -> f64 {
319    0.5
320}
321
322fn default_sev_medium() -> f64 {
323    1.0
324}
325
326fn default_sev_high() -> f64 {
327    2.0
328}
329
330impl Default for TrajectorySeverityMultipliers {
331    fn default() -> Self {
332        Self {
333            low: default_sev_low(),
334            medium: default_sev_medium(),
335            high: default_sev_high(),
336        }
337    }
338}
339
340/// Configuration for the MAGE trajectory risk accumulator (spec 004-19).
341///
342/// Controls how per-turn safety signals accumulate into a session-level risk score
343/// and when tool execution is blocked or escalated.
344///
345/// # Example (TOML)
346///
347/// ```toml
348/// [memory.shadow_memory]
349/// enabled = true
350/// risk_threshold = 0.75
351/// escalation_threshold = 0.50
352/// risk_halflife_turns = 10
353/// signal_history_cap = 200
354/// tui_show_risk_gauge = true
355/// reset_on_compaction = false
356/// ```
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct TrajectoryRiskAccumulatorConfig {
359    /// Enable shadow memory. When `false`, `TrajectoryRiskAccumulator` is a zero-cost noop.
360    #[serde(default)]
361    pub enabled: bool,
362    /// Block tool execution when `trajectory_risk >= risk_threshold`. Default: `0.75`.
363    #[serde(default = "default_tra_risk_threshold")]
364    pub risk_threshold: f64,
365    /// Escalate to human confirmation when risk is in `[escalation_threshold, risk_threshold)`.
366    /// Default: `0.50`.
367    #[serde(default = "default_tra_escalation_threshold")]
368    pub escalation_threshold: f64,
369    /// Number of turns after which accumulated risk halves (exponential decay). Default: `10`.
370    #[serde(default = "default_tra_risk_halflife_turns")]
371    pub risk_halflife_turns: u32,
372    /// Maximum number of signal events kept in the ring buffer. Default: `200`.
373    #[serde(default = "default_tra_signal_history_cap")]
374    pub signal_history_cap: usize,
375    /// Show a risk gauge in the TUI security panel when the TUI is enabled. Default: `true`.
376    #[serde(default = "default_true")]
377    pub tui_show_risk_gauge: bool,
378    /// Reset `trajectory_risk` to zero when a context compaction occurs. Default: `false`.
379    #[serde(default)]
380    pub reset_on_compaction: bool,
381    /// Per-signal-type base weights.
382    #[serde(default)]
383    pub signal_weights: TrajectorySignalWeights,
384    /// Per-severity multipliers applied on top of signal weights.
385    #[serde(default)]
386    pub severity_multipliers: TrajectorySeverityMultipliers,
387}
388
389fn default_tra_risk_threshold() -> f64 {
390    0.75
391}
392
393fn default_tra_escalation_threshold() -> f64 {
394    0.50
395}
396
397fn default_tra_risk_halflife_turns() -> u32 {
398    10
399}
400
401fn default_tra_signal_history_cap() -> usize {
402    200
403}
404
405impl TrajectoryRiskAccumulatorConfig {
406    /// Validate threshold ordering after deserialization.
407    ///
408    /// Returns an error string if `escalation_threshold >= risk_threshold`. An
409    /// inverted/equal pair silently disables the soft-escalation tier (`should_escalate`'s
410    /// `[escalation_threshold, risk_threshold)` band becomes empty) — the hard block
411    /// (`is_blocked`) still works, so this is a degraded-but-safe misconfiguration, not a
412    /// security gap; validation exists to surface it instead of leaving it silent (critic
413    /// finding F4, spec 004-19).
414    ///
415    /// # Errors
416    ///
417    /// Returns a descriptive error string when the threshold ordering invariant is violated.
418    #[must_use = "validation result must be checked"]
419    pub fn validate(&self) -> Result<(), String> {
420        if self.escalation_threshold >= self.risk_threshold {
421            return Err(format!(
422                "memory.shadow_memory: escalation_threshold ({}) must be < risk_threshold ({}) \
423                 — otherwise the escalation band is empty and soft-escalation never fires",
424                self.escalation_threshold, self.risk_threshold
425            ));
426        }
427        Ok(())
428    }
429}
430
431impl Default for TrajectoryRiskAccumulatorConfig {
432    fn default() -> Self {
433        Self {
434            enabled: false,
435            risk_threshold: default_tra_risk_threshold(),
436            escalation_threshold: default_tra_escalation_threshold(),
437            risk_halflife_turns: default_tra_risk_halflife_turns(),
438            signal_history_cap: default_tra_signal_history_cap(),
439            tui_show_risk_gauge: true,
440            reset_on_compaction: false,
441            signal_weights: TrajectorySignalWeights::default(),
442            severity_multipliers: TrajectorySeverityMultipliers::default(),
443        }
444    }
445}