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
140/// Resolve the active persona from the loaded config (env > config > default).
141///
142/// This is the one entry point runtime consumers use (`ctx_read` mode
143/// resolution, `ctx_url_read` compression/trimming, sensitivity enforcement,
144/// MCP instructions). Resolution is cheap for built-ins (env read + match);
145/// only custom personas touch disk — same cost profile as
146/// [`Config::tool_profile_effective`](super::config::Config::tool_profile_effective),
147/// which resolves the persona on every call today.
148#[must_use]
149pub fn active() -> Persona {
150    Persona::resolve(&super::config::Config::load())
151}
152
153impl Persona {
154    /// The built-in `coding` persona — reproduces today's default behavior so
155    /// existing installs see no change.
156    #[must_use]
157    pub fn coding() -> Self {
158        Persona {
159            name: "coding".to_string(),
160            description: "Software engineering on a code repository (default).".to_string(),
161            tool_profile: ToolProfile::Power,
162            default_read_mode: "auto".to_string(),
163            compressor: "identity".to_string(),
164            chunker: "lines".to_string(),
165            intent_taxonomy: super::intent_engine::TaskType::all()
166                .iter()
167                .map(|t| t.as_str().to_string())
168                .collect(),
169            sensitivity_floor: SensitivityLevel::Public,
170        }
171    }
172
173    /// Built-in presets by name (`sales` is an alias of `lead-gen`).
174    #[must_use]
175    pub fn builtin(name: &str) -> Option<Self> {
176        match name.to_ascii_lowercase().as_str() {
177            "coding" => Some(Self::coding()),
178            "research" => Some(Self::research()),
179            "lead-gen" | "lead_gen" | "sales" => Some(Self::lead_gen()),
180            "support" => Some(Self::support()),
181            "data-analysis" | "data_analysis" => Some(Self::data_analysis()),
182            _ => None,
183        }
184    }
185
186    /// Names of the built-in presets (sorted, canonical names only).
187    #[must_use]
188    pub fn builtin_names() -> Vec<String> {
189        vec![
190            "coding".to_string(),
191            "data-analysis".to_string(),
192            "lead-gen".to_string(),
193            "research".to_string(),
194            "support".to_string(),
195        ]
196    }
197
198    /// `research`: reading the web/docs and synthesizing cited findings.
199    #[must_use]
200    pub fn research() -> Self {
201        Persona {
202            name: "research".to_string(),
203            description: "Web/document research with cited synthesis.".to_string(),
204            tool_profile: ToolProfile::Standard,
205            default_read_mode: "map".to_string(),
206            compressor: "markdown".to_string(),
207            chunker: "paragraph".to_string(),
208            intent_taxonomy: labels(&["explore", "summarize", "compare", "cite", "synthesize"]),
209            sensitivity_floor: SensitivityLevel::Public,
210        }
211    }
212
213    /// `lead-gen` (alias `sales`): prospecting + enriching sales leads.
214    #[must_use]
215    pub fn lead_gen() -> Self {
216        Persona {
217            name: "lead-gen".to_string(),
218            description: "Outbound sales lead research + enrichment.".to_string(),
219            tool_profile: ToolProfile::Custom(labels(&[
220                "ctx_read",
221                "ctx_search",
222                "ctx_url_read",
223                "ctx_knowledge",
224                "ctx_session",
225            ])),
226            default_read_mode: "map".to_string(),
227            compressor: "prose".to_string(),
228            chunker: "paragraph".to_string(),
229            intent_taxonomy: labels(&["prospect", "qualify", "enrich", "outreach"]),
230            sensitivity_floor: SensitivityLevel::Confidential,
231        }
232    }
233
234    /// `support`: customer-support triage and resolution.
235    #[must_use]
236    pub fn support() -> Self {
237        Persona {
238            name: "support".to_string(),
239            description: "Customer-support triage, diagnosis, resolution.".to_string(),
240            tool_profile: ToolProfile::Standard,
241            default_read_mode: "auto".to_string(),
242            compressor: "prose".to_string(),
243            chunker: "paragraph".to_string(),
244            intent_taxonomy: labels(&["triage", "diagnose", "resolve", "escalate", "document"]),
245            sensitivity_floor: SensitivityLevel::Internal,
246        }
247    }
248
249    /// `data-analysis`: structured-data ingestion and reporting.
250    #[must_use]
251    pub fn data_analysis() -> Self {
252        Persona {
253            name: "data-analysis".to_string(),
254            description: "Structured-data ingestion, analysis, reporting.".to_string(),
255            tool_profile: ToolProfile::Standard,
256            default_read_mode: "map".to_string(),
257            compressor: "identity".to_string(),
258            chunker: "lines".to_string(),
259            intent_taxonomy: labels(&["ingest", "clean", "analyze", "visualize", "report"]),
260            sensitivity_floor: SensitivityLevel::Internal,
261        }
262    }
263
264    /// Resolve the active persona for this config.
265    ///
266    /// Priority: `LEAN_CTX_PERSONA` env > config `persona` > [`DEFAULT_PERSONA`].
267    /// A name is resolved against built-ins first, then a `<personas_dir>/<name>.toml`
268    /// file. Unknown/invalid names fall back to `coding` (never an error at a
269    /// call site — selection is best-effort).
270    #[must_use]
271    pub fn resolve(cfg: &super::config::Config) -> Self {
272        let name = std::env::var("LEAN_CTX_PERSONA")
273            .ok()
274            .map(|s| s.trim().to_string())
275            .filter(|s| !s.is_empty())
276            .or_else(|| cfg.persona.clone())
277            .unwrap_or_else(|| DEFAULT_PERSONA.to_string());
278
279        if let Some(p) = Self::builtin(&name) {
280            return p;
281        }
282        match load_from_dir(&name) {
283            Ok(Some(p)) => p,
284            Ok(None) => {
285                tracing::warn!("persona '{name}' not found; falling back to coding");
286                Self::coding()
287            }
288            Err(e) => {
289                tracing::warn!("failed to load persona '{name}': {e}; falling back to coding");
290                Self::coding()
291            }
292        }
293    }
294
295    /// The effective tool surface: an explicit tool-profile setting (env/config)
296    /// always wins (backward compatible); otherwise the persona supplies it.
297    #[must_use]
298    pub fn effective_tool_profile(&self, cfg: &super::config::Config) -> ToolProfile {
299        if tool_profile_is_explicit(cfg) {
300            ToolProfile::from_config(cfg)
301        } else {
302            self.tool_profile.clone()
303        }
304    }
305
306    /// The persona's `ctx_read` mode override, used when the caller passes no
307    /// explicit `mode` and no context policy pack pins a default. `"auto"`
308    /// (the `coding` default) means "no opinion" — the profile/auto selection
309    /// decides, exactly as before personas existed.
310    #[must_use]
311    pub fn read_mode_override(&self) -> Option<String> {
312        let mode = self.default_read_mode.trim();
313        if mode.is_empty() || mode.eq_ignore_ascii_case("auto") {
314            None
315        } else {
316            Some(mode.to_string())
317        }
318    }
319
320    /// Domain prompt block for the MCP instructions (persona-spec-v1:
321    /// "vocabulary + intent list"). Empty for the `coding` default so existing
322    /// installs stay byte-identical (#498 prompt-cache stability). For any
323    /// other persona the block is a deterministic function of the persona —
324    /// stable across sessions, so provider prompt caching still applies.
325    #[must_use]
326    pub fn prompt_block(&self) -> String {
327        if self.name == DEFAULT_PERSONA {
328            return String::new();
329        }
330        let mut out = format!("PERSONA: {}", self.name);
331        let desc = self.description.trim();
332        if !desc.is_empty() {
333            out.push_str(&format!(" — {desc}"));
334        }
335        if !self.intent_taxonomy.is_empty() {
336            out.push_str(&format!("\nINTENTS: {}", self.intent_taxonomy.join(", ")));
337        }
338        out.push_str(&format!(
339            "\nDEFAULTS: read mode {}; sensitivity floor {}",
340            self.default_read_mode,
341            self.sensitivity_floor.as_str()
342        ));
343        out.push('\n');
344        out
345    }
346}
347
348/// Whether the user explicitly pinned a tool profile (vs. leaving it to the
349/// persona default).
350fn tool_profile_is_explicit(cfg: &super::config::Config) -> bool {
351    std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
352        || cfg.tool_profile.is_some()
353        || !cfg.tools_enabled.is_empty()
354}
355
356/// Root directory holding `<name>.toml` persona files. `LEAN_CTX_PERSONAS_DIR`
357/// overrides the default so containers/CI/tests can isolate it.
358#[must_use]
359pub fn personas_dir() -> PathBuf {
360    if let Some(dir) = std::env::var_os("LEAN_CTX_PERSONAS_DIR")
361        && !dir.is_empty()
362    {
363        return PathBuf::from(dir);
364    }
365    // #594: resolve through the unified config base (matches `config.toml`),
366    // adopting any copy older builds left under `dirs::config_dir()`.
367    crate::core::paths::config_dir_member("personas")
368        .unwrap_or_else(|_| PathBuf::from("~/.config/lean-ctx/personas"))
369}
370
371/// Load a persona from `<personas_dir>/<name>.toml`. `Ok(None)` if absent.
372pub fn load_from_dir(name: &str) -> Result<Option<Persona>, PersonaError> {
373    let path = personas_dir().join(format!("{name}.toml"));
374    if !path.is_file() {
375        return Ok(None);
376    }
377    let text = std::fs::read_to_string(&path).map_err(|source| PersonaError::Io {
378        path: path.clone(),
379        source,
380    })?;
381    Ok(Some(PersonaSpec::from_toml(&text)?.into_persona()))
382}
383
384/// All persona names available on this instance (built-ins + discovered files).
385#[must_use]
386pub fn list_personas() -> Vec<String> {
387    let mut names = Persona::builtin_names();
388    if let Ok(entries) = std::fs::read_dir(personas_dir()) {
389        for entry in entries.flatten() {
390            let path = entry.path();
391            if path.extension().and_then(|e| e.to_str()) == Some("toml")
392                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
393                && !names.iter().any(|n| n == stem)
394            {
395                names.push(stem.to_string());
396            }
397        }
398    }
399    names.sort();
400    names
401}
402
403#[cfg(test)]
404pub mod tests {
405    use super::*;
406
407    #[test]
408    fn coding_preset_matches_today_defaults() {
409        let p = Persona::coding();
410        assert_eq!(p.name, "coding");
411        assert_eq!(p.tool_profile, ToolProfile::Power);
412        assert_eq!(p.default_read_mode, "auto");
413        assert_eq!(p.sensitivity_floor, SensitivityLevel::Public);
414        assert!(p.intent_taxonomy.contains(&"generate".to_string()));
415    }
416
417    #[test]
418    fn spec_parses_and_resolves_custom_tool_surface() {
419        let spec = PersonaSpec::from_toml(
420            r#"
421name = "lead-gen"
422description = "Sales lead research"
423tool_profile = "custom"
424tools = ["ctx_read", "ctx_search", "ctx_url_read"]
425default_read_mode = "map"
426compressor = "whitespace"
427chunker = "paragraph"
428sensitivity_floor = "confidential"
429intent_taxonomy = ["prospect", "qualify", "enrich"]
430"#,
431        )
432        .unwrap();
433        let persona = spec.into_persona();
434        assert_eq!(
435            persona.tool_profile,
436            ToolProfile::Custom(vec![
437                "ctx_read".into(),
438                "ctx_search".into(),
439                "ctx_url_read".into(),
440            ])
441        );
442        assert_eq!(persona.default_read_mode, "map");
443        assert_eq!(persona.compressor, "whitespace");
444        assert_eq!(persona.sensitivity_floor, SensitivityLevel::Confidential);
445        // A custom persona genuinely changes the tool surface.
446        assert!(persona.tool_profile.is_tool_enabled("ctx_url_read"));
447        assert!(!persona.tool_profile.is_tool_enabled("ctx_refactor"));
448    }
449
450    #[test]
451    fn builtin_presets_are_shipped_and_resolvable() {
452        let names = Persona::builtin_names();
453        for expected in ["coding", "research", "lead-gen", "support", "data-analysis"] {
454            assert!(
455                names.contains(&expected.to_string()),
456                "missing preset {expected}"
457            );
458            assert!(
459                Persona::builtin(expected).is_some(),
460                "unresolvable preset {expected}"
461            );
462        }
463        // `sales` is an alias of lead-gen.
464        assert_eq!(Persona::builtin("sales").unwrap().name, "lead-gen");
465    }
466
467    #[test]
468    fn intent_taxonomy_varies_by_persona() {
469        let coding = Persona::coding().intent_taxonomy;
470        let research = Persona::research().intent_taxonomy;
471        let lead = Persona::lead_gen().intent_taxonomy;
472        assert_ne!(coding, research);
473        assert_ne!(coding, lead);
474        assert!(research.contains(&"synthesize".to_string()));
475        assert!(lead.contains(&"prospect".to_string()));
476    }
477
478    #[test]
479    fn presets_change_tool_surface() {
480        // lead-gen exposes web research tools, not refactoring tools.
481        let lead = Persona::lead_gen();
482        assert!(lead.tool_profile.is_tool_enabled("ctx_url_read"));
483        assert!(!lead.tool_profile.is_tool_enabled("ctx_refactor"));
484    }
485
486    #[test]
487    fn custom_profile_without_tools_is_rejected() {
488        let err = PersonaSpec::from_toml("name = \"x\"\ntool_profile = \"custom\"\n").unwrap_err();
489        assert!(matches!(err, PersonaError::Validation(_)));
490    }
491
492    #[test]
493    fn read_mode_override_treats_auto_as_no_opinion() {
494        // coding declares "auto" → the profile/auto selection stays in charge.
495        assert_eq!(Persona::coding().read_mode_override(), None);
496        assert_eq!(Persona::support().read_mode_override(), None);
497        // Domain personas with a real declaration override the default.
498        assert_eq!(
499            Persona::research().read_mode_override(),
500            Some("map".to_string())
501        );
502        assert_eq!(
503            Persona::lead_gen().read_mode_override(),
504            Some("map".to_string())
505        );
506        // Whitespace/empty declarations never produce a bogus mode.
507        let mut p = Persona::coding();
508        p.default_read_mode = "  ".to_string();
509        assert_eq!(p.read_mode_override(), None);
510    }
511
512    #[test]
513    fn prompt_block_is_empty_for_coding_and_carries_domain_vocabulary() {
514        // #498: the default persona must not perturb the instruction bytes.
515        assert_eq!(Persona::coding().prompt_block(), "");
516
517        let block = Persona::research().prompt_block();
518        assert!(block.contains("PERSONA: research"), "{block}");
519        assert!(
520            block.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
521            "{block}"
522        );
523        assert!(block.contains("read mode map"), "{block}");
524
525        let lead = Persona::lead_gen().prompt_block();
526        assert!(lead.contains("sensitivity floor confidential"), "{lead}");
527    }
528
529    #[test]
530    fn active_honours_env_selection() {
531        let _guard = crate::core::data_dir::test_env_lock();
532        crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
533        let p = active();
534        crate::test_env::remove_var("LEAN_CTX_PERSONA");
535        assert_eq!(p.name, "research");
536    }
537
538    #[test]
539    fn loader_reads_persona_file_and_selection_picks_it() {
540        let _env_lock = crate::core::data_dir::test_env_lock();
541        let dir = tempfile::tempdir().unwrap();
542        std::fs::write(
543            dir.path().join("research.toml"),
544            "name = \"research\"\ntool_profile = \"standard\"\ndefault_read_mode = \"map\"\n",
545        )
546        .unwrap();
547        crate::test_env::set_var("LEAN_CTX_PERSONAS_DIR", dir.path());
548
549        let loaded = load_from_dir("research").unwrap().unwrap();
550        assert_eq!(loaded.name, "research");
551        assert_eq!(loaded.tool_profile, ToolProfile::Standard);
552
553        let names = list_personas();
554        assert!(names.contains(&"research".to_string()));
555        assert!(names.contains(&"coding".to_string()));
556
557        crate::test_env::remove_var("LEAN_CTX_PERSONAS_DIR");
558    }
559}