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}