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