Skip to main content

magi/
persona.rs

1//! Personas for the standing chat: a voice, never a behaviour.
2//!
3//! A persona is a short style instruction appended to [`crate::talk::briefing`]
4//! as a clearly delimited section. It governs **tone only**: facts, commands,
5//! task ids, duplicate-warning handling and the write policy stay exactly as the
6//! briefing states them. The persona text is never copied into an instruction
7//! handed to `magi task add`; implementers stay neutral.
8//!
9//! The catalogue is the built-ins below plus `[[talk.personas]]` from the
10//! config. A user entry with a built-in's id replaces it in place; a new id is
11//! appended. `default` is the plain voice, cannot be redefined, and is what a
12//! conversation uses until the operator picks something else.
13
14use std::collections::BTreeSet;
15
16use anyhow::{Result, bail};
17
18use crate::config::PersonaSpec;
19
20/// The id of the plain voice: no persona section at all.
21pub const DEFAULT_ID: &str = "default";
22
23/// Said once to every built-in so each answers in the operator's language.
24const LANGUAGE: &str = "Answer in the operator's language; if that is Japanese, \
25    write natural Japanese in this character's manner. Keep it brief.";
26
27/// `(id, name, voice)`; `default` first, its voice empty.
28const BUILTINS: &[(&str, &str, &str)] = &[
29    (DEFAULT_ID, "Default", ""),
30    (
31        "magi",
32        "MAGI operator",
33        "Speak like an operator in a NERV command centre: terse, procedural, \
34         status-report phrasing. Short declaratives, no small talk, no exclamation. \
35         Report findings as readings and results, and flag anything unresolved \
36         the way a console flags an alert.",
37    ),
38    (
39        "rei",
40        "Rei Ayanami",
41        "Speak like Rei Ayanami: flat, quiet and minimal. Very short sentences, no \
42         embellishment, little emotion shown. Plain statements of what is so, \
43         with the occasional simple, sincere remark.",
44    ),
45    (
46        "misato",
47        "Misato Katsuragi",
48        "Speak like Misato Katsuragi: casual, warm and upbeat, a senior colleague \
49         who has your back. Relaxed phrasing, light humour, plain encouragement \
50         when the operator is stuck, and a decisive nudge when a choice is needed.",
51    ),
52    (
53        "ritsuko",
54        "Ritsuko Akagi",
55        "Speak like Ritsuko Akagi: dry, precise and technical. Clinical wording, \
56         understated sarcasm at most, no reassurance for its own sake. Lead with \
57         the data and the reasoning, and be blunt about risks.",
58    ),
59    (
60        "shinji",
61        "Shinji Ikari",
62        "Speak like Shinji Ikari: hesitant and self-doubting, but earnest and \
63         trying to do the right thing. Soft phrasing, an occasional apology or \
64         trailing-off, yet the content you give is still complete and committed.",
65    ),
66    (
67        "asuka",
68        "Asuka Langley Soryu",
69        "Speak like Asuka Langley Soryu: proud, sharp-tongued and competitive. \
70         Confident, teasing, quick to point out the obvious, with a bit of \
71         swagger - but aimed at the problem, never at the operator in earnest.",
72    ),
73    (
74        "kaworu",
75        "Kaworu Nagisa",
76        "Speak like Kaworu Nagisa: gentle, poetic and warm. Calm, kind phrasing \
77         with a touch of metaphor, unhurried and quietly affectionate, while \
78         the substance stays concrete.",
79    ),
80];
81
82/// One entry of the catalogue.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Persona {
85    /// Stable id the conversation stores.
86    pub id: String,
87    /// What the selector shows.
88    pub name: String,
89    /// The voice instruction; empty only for [`DEFAULT_ID`].
90    pub prompt: String,
91}
92
93impl Persona {
94    /// Is this the plain voice?
95    pub fn is_default(&self) -> bool {
96        self.id == DEFAULT_ID
97    }
98}
99
100fn builtins() -> Vec<Persona> {
101    BUILTINS
102        .iter()
103        .map(|(id, name, voice)| Persona {
104            id: (*id).to_owned(),
105            name: (*name).to_owned(),
106            prompt: if voice.is_empty() {
107                String::new()
108            } else {
109                format!("{voice} {LANGUAGE}")
110            },
111        })
112        .collect()
113}
114
115/// Check `[[talk.personas]]`: non-blank ids, names and prompts, unique ids,
116/// and no redefinition of `default`.
117pub fn validate(specs: &[PersonaSpec]) -> Result<()> {
118    let mut seen = BTreeSet::new();
119    for (n, p) in specs.iter().enumerate() {
120        let at = n + 1;
121        let id = p.id.trim();
122        if id.is_empty() {
123            bail!("[[talk.personas]] entry {at} has an empty `id`");
124        }
125        if id == DEFAULT_ID {
126            bail!(
127                "[[talk.personas]] entry {at}: `{DEFAULT_ID}` is the plain voice and cannot be redefined"
128            );
129        }
130        if p.name.trim().is_empty() {
131            bail!("[[talk.personas]] `{id}` has an empty `name`");
132        }
133        if p.prompt.trim().is_empty() {
134            bail!("[[talk.personas]] `{id}` has an empty `prompt`");
135        }
136        if !seen.insert(id.to_owned()) {
137            bail!("[[talk.personas]] declares the id `{id}` more than once");
138        }
139    }
140    Ok(())
141}
142
143/// The built-ins, with the config's entries overriding by id or appended.
144pub fn catalog(personas: &[PersonaSpec]) -> Vec<Persona> {
145    let mut out = builtins();
146    for p in personas {
147        let entry = Persona {
148            id: p.id.trim().to_owned(),
149            name: p.name.trim().to_owned(),
150            prompt: p.prompt.trim().to_owned(),
151        };
152        match out.iter_mut().find(|e| e.id == entry.id) {
153            Some(slot) if !slot.is_default() => *slot = entry,
154            Some(_) => {}
155            None => out.push(entry),
156        }
157    }
158    out
159}
160
161/// The catalogue a conversation can pick from when no config can be read.
162pub fn builtin_catalog() -> Vec<Persona> {
163    catalog(&[])
164}
165
166/// Look `id` up. Blank means the default.
167pub fn find(personas: &[PersonaSpec], id: &str) -> Option<Persona> {
168    let id = id.trim();
169    let id = if id.is_empty() { DEFAULT_ID } else { id };
170    catalog(personas).into_iter().find(|p| p.id == id)
171}
172
173/// The persona to apply for a stored id: `None` for the default, and for an id
174/// the config no longer knows (a warning, never a stopped conversation).
175pub fn active(personas: &[PersonaSpec], id: &str) -> Option<Persona> {
176    match find(personas, id) {
177        Some(p) if p.is_default() => None,
178        Some(p) => Some(p),
179        None => {
180            tracing::warn!("chat: persona `{id}` is no longer configured; using the default");
181            None
182        }
183    }
184}
185
186/// The briefing's persona section.
187pub fn section(p: &Persona) -> String {
188    format!(
189        "\n# Persona (tone only)\n\n\
190         The operator picked the persona \"{name}\" for this conversation. It \
191         governs TONE ONLY - word choice, rhythm and attitude of what you say. \
192         It never changes what you do. Facts, commands, file paths, task ids, \
193         how a duplicate warning is handled (never `--force` it yourself), the \
194         write policy, and telling the operator the task id `magi task add` \
195         prints all stay exact and exactly as described above. Do not let the \
196         voice make an answer vaguer, shorter on substance, or more certain \
197         than the facts. Never put the persona's voice into the <instruction> \
198         you pass to `magi task add`, into files, or into commit messages: \
199         those stay neutral.\n\n\
200         Voice:\n{voice}\n",
201        name = p.name,
202        voice = p.prompt,
203    )
204}
205
206/// What a resumed session is told when the persona changed since the briefing
207/// it holds. `None` means the operator went back to the plain voice.
208pub fn update_block(p: Option<&Persona>) -> String {
209    match p {
210        Some(p) => format!(
211            "# Persona update\n\nThe operator changed the persona. Drop any earlier \
212             persona and use this one from now on.\n{}",
213            section(p)
214        ),
215        None => "# Persona update\n\nThe operator turned the persona off. Drop any earlier \
216                 persona and go back to your plain, normal voice from now on.\n"
217            .to_owned(),
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn spec(id: &str, name: &str, prompt: &str) -> PersonaSpec {
226        PersonaSpec {
227            id: id.into(),
228            name: name.into(),
229            prompt: prompt.into(),
230        }
231    }
232
233    #[test]
234    fn builtins_start_with_a_plain_default() {
235        let c = builtin_catalog();
236        assert_eq!(c[0].id, DEFAULT_ID);
237        assert!(c[0].prompt.is_empty());
238        for id in [
239            "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
240        ] {
241            let p = c.iter().find(|p| p.id == id).expect(id);
242            assert!(p.prompt.contains("operator's language"), "{id}");
243        }
244    }
245
246    #[test]
247    fn a_user_entry_adds_or_overrides_in_place() {
248        let c = catalog(&[
249            spec("rei", "Rei", "Be curt."),
250            spec("gendo", "Gendo", "Be cold."),
251        ]);
252        let pos = c.iter().position(|p| p.id == "rei").unwrap();
253        assert_eq!(pos, 2);
254        assert_eq!(c[pos].prompt, "Be curt.");
255        assert_eq!(c.last().unwrap().id, "gendo");
256        assert_eq!(c.len(), builtin_catalog().len() + 1);
257    }
258
259    #[test]
260    fn validation_names_the_problem() {
261        let err = |s: Vec<PersonaSpec>| validate(&s).unwrap_err().to_string();
262        assert!(err(vec![spec(" ", "n", "p")]).contains("empty `id`"));
263        assert!(err(vec![spec("a", "n", "  ")]).contains("empty `prompt`"));
264        assert!(err(vec![spec("a", "", "p")]).contains("empty `name`"));
265        assert!(err(vec![spec("a", "n", "p"), spec("a", "m", "q")]).contains("more than once"));
266        assert!(err(vec![spec("default", "n", "p")]).contains("cannot be redefined"));
267        assert!(validate(&[spec("a", "n", "p")]).is_ok());
268    }
269
270    #[test]
271    fn active_treats_default_blank_and_unknown_as_plain() {
272        assert!(active(&[], "").is_none());
273        assert!(active(&[], DEFAULT_ID).is_none());
274        assert!(active(&[], "nobody").is_none());
275        assert_eq!(active(&[], "rei").unwrap().id, "rei");
276    }
277}