Skip to main content

lean_ctx/core/
persona.rs

1//! Context personas (`persona-spec-v1`).
2//!
3//! A persona is a declarative bundle that shapes the *whole* context surface for
4//! a domain — not just coding. It composes:
5//! - **tool surface** (a [`ToolProfile`]: built-in tier or custom list),
6//! - **default read-mode**,
7//! - **compressor** + **chunker** (names from the extension registry, 12.9),
8//! - **intent taxonomy** (the task labels meaningful for the domain),
9//! - **sensitivity floor** (minimum classification to enforce).
10//!
11//! Personas build on the existing tool profiles and are selectable per
12//! workspace/channel/session via config (`persona = "…"`) or the
13//! `LEAN_CTX_PERSONA` env var. The built-in `coding` persona reproduces today's
14//! default behavior; further presets are added in 12.16.
15
16use std::path::PathBuf;
17
18use serde::Deserialize;
19
20use super::sensitivity::SensitivityLevel;
21use super::tool_profiles::ToolProfile;
22
23/// A resolved persona ready to drive the pipeline.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Persona {
26    pub name: String,
27    pub description: String,
28    pub tool_profile: ToolProfile,
29    pub default_read_mode: String,
30    pub compressor: String,
31    pub chunker: String,
32    pub intent_taxonomy: Vec<String>,
33    pub sensitivity_floor: SensitivityLevel,
34}
35
36/// The on-disk / declarative form of a persona (`persona-spec-v1`).
37#[derive(Debug, Clone, Deserialize)]
38pub struct PersonaSpec {
39    pub name: String,
40    #[serde(default)]
41    pub description: String,
42    #[serde(default = "default_tool_profile")]
43    pub tool_profile: String,
44    /// Explicit tool list when `tool_profile = "custom"`.
45    #[serde(default)]
46    pub tools: Vec<String>,
47    #[serde(default = "default_read_mode")]
48    pub default_read_mode: String,
49    #[serde(default = "default_compressor")]
50    pub compressor: String,
51    #[serde(default = "default_chunker")]
52    pub chunker: String,
53    #[serde(default)]
54    pub intent_taxonomy: Vec<String>,
55    #[serde(default)]
56    pub sensitivity_floor: Option<String>,
57}
58
59fn default_tool_profile() -> String {
60    "power".to_string()
61}
62fn default_read_mode() -> String {
63    "auto".to_string()
64}
65fn default_compressor() -> String {
66    "identity".to_string()
67}
68fn default_chunker() -> String {
69    "lines".to_string()
70}
71
72fn labels(items: &[&str]) -> Vec<String> {
73    items.iter().map(|s| (*s).to_string()).collect()
74}
75
76/// Error parsing a persona spec.
77#[derive(Debug, thiserror::Error)]
78pub enum PersonaError {
79    #[error("invalid persona spec: {0}")]
80    Validation(String),
81    #[error("failed to parse persona: {0}")]
82    Parse(#[from] toml::de::Error),
83    #[error("failed to read persona at {path}: {source}")]
84    Io {
85        path: PathBuf,
86        source: std::io::Error,
87    },
88}
89
90impl PersonaSpec {
91    /// Parse a spec from TOML text.
92    pub fn from_toml(text: &str) -> Result<Self, PersonaError> {
93        let spec: Self = toml::from_str(text)?;
94        spec.validate()?;
95        Ok(spec)
96    }
97
98    fn validate(&self) -> Result<(), PersonaError> {
99        if self.name.trim().is_empty() {
100            return Err(PersonaError::Validation("name must not be empty".into()));
101        }
102        if self.tool_profile.eq_ignore_ascii_case("custom") && self.tools.is_empty() {
103            return Err(PersonaError::Validation(format!(
104                "persona '{}' uses tool_profile=custom but lists no tools",
105                self.name
106            )));
107        }
108        Ok(())
109    }
110
111    /// Resolve the declarative spec into a usable [`Persona`].
112    #[must_use]
113    pub fn into_persona(self) -> Persona {
114        let tool_profile = if self.tool_profile.eq_ignore_ascii_case("custom") {
115            ToolProfile::Custom(self.tools)
116        } else {
117            ToolProfile::parse(&self.tool_profile).unwrap_or(ToolProfile::Power)
118        };
119        let sensitivity_floor = self
120            .sensitivity_floor
121            .as_deref()
122            .and_then(SensitivityLevel::parse)
123            .unwrap_or_default();
124        Persona {
125            name: self.name,
126            description: self.description,
127            tool_profile,
128            default_read_mode: self.default_read_mode,
129            compressor: self.compressor,
130            chunker: self.chunker,
131            intent_taxonomy: self.intent_taxonomy,
132            sensitivity_floor,
133        }
134    }
135}
136
137/// The default persona name when nothing is configured.
138pub const DEFAULT_PERSONA: &str = "coding";
139
140impl Persona {
141    /// The built-in `coding` persona — reproduces today's default behavior so
142    /// existing installs see no change.
143    #[must_use]
144    pub fn coding() -> Self {
145        Persona {
146            name: "coding".to_string(),
147            description: "Software engineering on a code repository (default).".to_string(),
148            tool_profile: ToolProfile::Power,
149            default_read_mode: "auto".to_string(),
150            compressor: "identity".to_string(),
151            chunker: "lines".to_string(),
152            intent_taxonomy: super::intent_engine::TaskType::all()
153                .iter()
154                .map(|t| t.as_str().to_string())
155                .collect(),
156            sensitivity_floor: SensitivityLevel::Public,
157        }
158    }
159
160    /// Built-in presets by name (`sales` is an alias of `lead-gen`).
161    #[must_use]
162    pub fn builtin(name: &str) -> Option<Self> {
163        match name.to_ascii_lowercase().as_str() {
164            "coding" => Some(Self::coding()),
165            "research" => Some(Self::research()),
166            "lead-gen" | "lead_gen" | "sales" => Some(Self::lead_gen()),
167            "support" => Some(Self::support()),
168            "data-analysis" | "data_analysis" => Some(Self::data_analysis()),
169            _ => None,
170        }
171    }
172
173    /// Names of the built-in presets (sorted, canonical names only).
174    #[must_use]
175    pub fn builtin_names() -> Vec<String> {
176        vec![
177            "coding".to_string(),
178            "data-analysis".to_string(),
179            "lead-gen".to_string(),
180            "research".to_string(),
181            "support".to_string(),
182        ]
183    }
184
185    /// `research`: reading the web/docs and synthesizing cited findings.
186    #[must_use]
187    pub fn research() -> Self {
188        Persona {
189            name: "research".to_string(),
190            description: "Web/document research with cited synthesis.".to_string(),
191            tool_profile: ToolProfile::Standard,
192            default_read_mode: "map".to_string(),
193            compressor: "markdown".to_string(),
194            chunker: "paragraph".to_string(),
195            intent_taxonomy: labels(&["explore", "summarize", "compare", "cite", "synthesize"]),
196            sensitivity_floor: SensitivityLevel::Public,
197        }
198    }
199
200    /// `lead-gen` (alias `sales`): prospecting + enriching sales leads.
201    #[must_use]
202    pub fn lead_gen() -> Self {
203        Persona {
204            name: "lead-gen".to_string(),
205            description: "Outbound sales lead research + enrichment.".to_string(),
206            tool_profile: ToolProfile::Custom(labels(&[
207                "ctx_read",
208                "ctx_search",
209                "ctx_url_read",
210                "ctx_knowledge",
211                "ctx_semantic_search",
212                "ctx_session",
213            ])),
214            default_read_mode: "map".to_string(),
215            compressor: "prose".to_string(),
216            chunker: "paragraph".to_string(),
217            intent_taxonomy: labels(&["prospect", "qualify", "enrich", "outreach"]),
218            sensitivity_floor: SensitivityLevel::Confidential,
219        }
220    }
221
222    /// `support`: customer-support triage and resolution.
223    #[must_use]
224    pub fn support() -> Self {
225        Persona {
226            name: "support".to_string(),
227            description: "Customer-support triage, diagnosis, resolution.".to_string(),
228            tool_profile: ToolProfile::Standard,
229            default_read_mode: "auto".to_string(),
230            compressor: "prose".to_string(),
231            chunker: "paragraph".to_string(),
232            intent_taxonomy: labels(&["triage", "diagnose", "resolve", "escalate", "document"]),
233            sensitivity_floor: SensitivityLevel::Internal,
234        }
235    }
236
237    /// `data-analysis`: structured-data ingestion and reporting.
238    #[must_use]
239    pub fn data_analysis() -> Self {
240        Persona {
241            name: "data-analysis".to_string(),
242            description: "Structured-data ingestion, analysis, reporting.".to_string(),
243            tool_profile: ToolProfile::Standard,
244            default_read_mode: "map".to_string(),
245            compressor: "identity".to_string(),
246            chunker: "lines".to_string(),
247            intent_taxonomy: labels(&["ingest", "clean", "analyze", "visualize", "report"]),
248            sensitivity_floor: SensitivityLevel::Internal,
249        }
250    }
251
252    /// Resolve the active persona for this config.
253    ///
254    /// Priority: `LEAN_CTX_PERSONA` env > config `persona` > [`DEFAULT_PERSONA`].
255    /// A name is resolved against built-ins first, then a `<personas_dir>/<name>.toml`
256    /// file. Unknown/invalid names fall back to `coding` (never an error at a
257    /// call site — selection is best-effort).
258    #[must_use]
259    pub fn resolve(cfg: &super::config::Config) -> Self {
260        let name = std::env::var("LEAN_CTX_PERSONA")
261            .ok()
262            .map(|s| s.trim().to_string())
263            .filter(|s| !s.is_empty())
264            .or_else(|| cfg.persona.clone())
265            .unwrap_or_else(|| DEFAULT_PERSONA.to_string());
266
267        if let Some(p) = Self::builtin(&name) {
268            return p;
269        }
270        match load_from_dir(&name) {
271            Ok(Some(p)) => p,
272            Ok(None) => {
273                tracing::warn!("persona '{name}' not found; falling back to coding");
274                Self::coding()
275            }
276            Err(e) => {
277                tracing::warn!("failed to load persona '{name}': {e}; falling back to coding");
278                Self::coding()
279            }
280        }
281    }
282
283    /// The effective tool surface: an explicit tool-profile setting (env/config)
284    /// always wins (backward compatible); otherwise the persona supplies it.
285    #[must_use]
286    pub fn effective_tool_profile(&self, cfg: &super::config::Config) -> ToolProfile {
287        if tool_profile_is_explicit(cfg) {
288            ToolProfile::from_config(cfg)
289        } else {
290            self.tool_profile.clone()
291        }
292    }
293}
294
295/// Whether the user explicitly pinned a tool profile (vs. leaving it to the
296/// persona default).
297fn tool_profile_is_explicit(cfg: &super::config::Config) -> bool {
298    std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
299        || cfg.tool_profile.is_some()
300        || !cfg.tools_enabled.is_empty()
301}
302
303/// Root directory holding `<name>.toml` persona files. `LEAN_CTX_PERSONAS_DIR`
304/// overrides the default so containers/CI/tests can isolate it.
305#[must_use]
306pub fn personas_dir() -> PathBuf {
307    if let Some(dir) = std::env::var_os("LEAN_CTX_PERSONAS_DIR")
308        && !dir.is_empty()
309    {
310        return PathBuf::from(dir);
311    }
312    if let Some(config_dir) = dirs::config_dir() {
313        config_dir.join("lean-ctx").join("personas")
314    } else {
315        PathBuf::from("~/.config/lean-ctx/personas")
316    }
317}
318
319/// Load a persona from `<personas_dir>/<name>.toml`. `Ok(None)` if absent.
320fn load_from_dir(name: &str) -> Result<Option<Persona>, PersonaError> {
321    let path = personas_dir().join(format!("{name}.toml"));
322    if !path.is_file() {
323        return Ok(None);
324    }
325    let text = std::fs::read_to_string(&path).map_err(|source| PersonaError::Io {
326        path: path.clone(),
327        source,
328    })?;
329    Ok(Some(PersonaSpec::from_toml(&text)?.into_persona()))
330}
331
332/// All persona names available on this instance (built-ins + discovered files).
333#[must_use]
334pub fn list_personas() -> Vec<String> {
335    let mut names = Persona::builtin_names();
336    if let Ok(entries) = std::fs::read_dir(personas_dir()) {
337        for entry in entries.flatten() {
338            let path = entry.path();
339            if path.extension().and_then(|e| e.to_str()) == Some("toml")
340                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
341                && !names.iter().any(|n| n == stem)
342            {
343                names.push(stem.to_string());
344            }
345        }
346    }
347    names.sort();
348    names
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn coding_preset_matches_today_defaults() {
357        let p = Persona::coding();
358        assert_eq!(p.name, "coding");
359        assert_eq!(p.tool_profile, ToolProfile::Power);
360        assert_eq!(p.default_read_mode, "auto");
361        assert_eq!(p.sensitivity_floor, SensitivityLevel::Public);
362        assert!(p.intent_taxonomy.contains(&"generate".to_string()));
363    }
364
365    #[test]
366    fn spec_parses_and_resolves_custom_tool_surface() {
367        let spec = PersonaSpec::from_toml(
368            r#"
369name = "lead-gen"
370description = "Sales lead research"
371tool_profile = "custom"
372tools = ["ctx_read", "ctx_search", "ctx_url_read"]
373default_read_mode = "map"
374compressor = "whitespace"
375chunker = "paragraph"
376sensitivity_floor = "confidential"
377intent_taxonomy = ["prospect", "qualify", "enrich"]
378"#,
379        )
380        .unwrap();
381        let persona = spec.into_persona();
382        assert_eq!(
383            persona.tool_profile,
384            ToolProfile::Custom(vec![
385                "ctx_read".into(),
386                "ctx_search".into(),
387                "ctx_url_read".into(),
388            ])
389        );
390        assert_eq!(persona.default_read_mode, "map");
391        assert_eq!(persona.compressor, "whitespace");
392        assert_eq!(persona.sensitivity_floor, SensitivityLevel::Confidential);
393        // A custom persona genuinely changes the tool surface.
394        assert!(persona.tool_profile.is_tool_enabled("ctx_url_read"));
395        assert!(!persona.tool_profile.is_tool_enabled("ctx_refactor"));
396    }
397
398    #[test]
399    fn builtin_presets_are_shipped_and_resolvable() {
400        let names = Persona::builtin_names();
401        for expected in ["coding", "research", "lead-gen", "support", "data-analysis"] {
402            assert!(
403                names.contains(&expected.to_string()),
404                "missing preset {expected}"
405            );
406            assert!(
407                Persona::builtin(expected).is_some(),
408                "unresolvable preset {expected}"
409            );
410        }
411        // `sales` is an alias of lead-gen.
412        assert_eq!(Persona::builtin("sales").unwrap().name, "lead-gen");
413    }
414
415    #[test]
416    fn intent_taxonomy_varies_by_persona() {
417        let coding = Persona::coding().intent_taxonomy;
418        let research = Persona::research().intent_taxonomy;
419        let lead = Persona::lead_gen().intent_taxonomy;
420        assert_ne!(coding, research);
421        assert_ne!(coding, lead);
422        assert!(research.contains(&"synthesize".to_string()));
423        assert!(lead.contains(&"prospect".to_string()));
424    }
425
426    #[test]
427    fn presets_change_tool_surface() {
428        // lead-gen exposes web research tools, not refactoring tools.
429        let lead = Persona::lead_gen();
430        assert!(lead.tool_profile.is_tool_enabled("ctx_url_read"));
431        assert!(!lead.tool_profile.is_tool_enabled("ctx_refactor"));
432    }
433
434    #[test]
435    fn custom_profile_without_tools_is_rejected() {
436        let err = PersonaSpec::from_toml("name = \"x\"\ntool_profile = \"custom\"\n").unwrap_err();
437        assert!(matches!(err, PersonaError::Validation(_)));
438    }
439
440    #[test]
441    fn loader_reads_persona_file_and_selection_picks_it() {
442        let dir = tempfile::tempdir().unwrap();
443        std::fs::write(
444            dir.path().join("research.toml"),
445            "name = \"research\"\ntool_profile = \"standard\"\ndefault_read_mode = \"map\"\n",
446        )
447        .unwrap();
448        crate::test_env::set_var("LEAN_CTX_PERSONAS_DIR", dir.path());
449
450        let loaded = load_from_dir("research").unwrap().unwrap();
451        assert_eq!(loaded.name, "research");
452        assert_eq!(loaded.tool_profile, ToolProfile::Standard);
453
454        let names = list_personas();
455        assert!(names.contains(&"research".to_string()));
456        assert!(names.contains(&"coding".to_string()));
457
458        crate::test_env::remove_var("LEAN_CTX_PERSONAS_DIR");
459    }
460}