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    /// The operator's avatar setting, display only
92    /// ([`crate::persona_icon`]); never read by the briefing functions.
93    pub icon: Option<String>,
94}
95
96impl Persona {
97    /// Is this the plain voice?
98    pub fn is_default(&self) -> bool {
99        self.id == DEFAULT_ID
100    }
101}
102
103fn builtins() -> Vec<Persona> {
104    BUILTINS
105        .iter()
106        .map(|(id, name, voice)| Persona {
107            id: (*id).to_owned(),
108            name: (*name).to_owned(),
109            prompt: if voice.is_empty() {
110                String::new()
111            } else {
112                format!("{voice} {LANGUAGE}")
113            },
114            icon: None,
115        })
116        .collect()
117}
118
119/// Check `[[talk.personas]]`: non-blank ids, unique ids, and no redefinition
120/// of `default` beyond its `icon`. Icons are validated only when served so a
121/// bad image never invalidates the conversation config. A built-in's id
122/// may omit `name` and `prompt` (the built-in's are kept); a new id may not.
123pub fn validate(specs: &[PersonaSpec]) -> Result<()> {
124    let mut seen = BTreeSet::new();
125    for (n, p) in specs.iter().enumerate() {
126        let at = n + 1;
127        let id = p.id.trim();
128        if id.is_empty() {
129            bail!("[[talk.personas]] entry {at} has an empty `id`");
130        }
131        let named = !p.name.trim().is_empty();
132        let voiced = !p.prompt.trim().is_empty();
133        if id == DEFAULT_ID {
134            if named || voiced || p.icon.is_none() {
135                bail!(
136                    "[[talk.personas]] entry {at}: `{DEFAULT_ID}` is the plain voice and cannot be redefined (only its `icon` may be set)"
137                );
138            }
139        } else if !BUILTINS.iter().any(|(b, _, _)| *b == id) {
140            if !named {
141                bail!("[[talk.personas]] `{id}` has an empty `name`");
142            }
143            if !voiced {
144                bail!("[[talk.personas]] `{id}` has an empty `prompt`");
145            }
146        }
147        if !seen.insert(id.to_owned()) {
148            bail!("[[talk.personas]] declares the id `{id}` more than once");
149        }
150    }
151    Ok(())
152}
153
154/// The built-ins, with the config's entries overriding by id or appended. An
155/// override that leaves out `name` or `prompt` keeps the built-in's; on
156/// `default` only the `icon` is taken.
157pub fn catalog(personas: &[PersonaSpec]) -> Vec<Persona> {
158    let mut out = builtins();
159    for p in personas {
160        let id = p.id.trim();
161        let icon = p
162            .icon
163            .as_deref()
164            .map(str::trim)
165            .filter(|i| !i.is_empty())
166            .map(str::to_owned);
167        match out.iter_mut().find(|e| e.id == id) {
168            Some(slot) if slot.is_default() => slot.icon = icon,
169            Some(slot) => {
170                let (name, prompt) = (p.name.trim(), p.prompt.trim());
171                if !name.is_empty() {
172                    slot.name = name.to_owned();
173                }
174                if !prompt.is_empty() {
175                    slot.prompt = prompt.to_owned();
176                }
177                slot.icon = icon;
178            }
179            None => out.push(Persona {
180                id: id.to_owned(),
181                name: p.name.trim().to_owned(),
182                prompt: p.prompt.trim().to_owned(),
183                icon,
184            }),
185        }
186    }
187    out
188}
189
190/// The catalogue a conversation can pick from when no config can be read.
191pub fn builtin_catalog() -> Vec<Persona> {
192    catalog(&[])
193}
194
195/// Look `id` up. Blank means the default.
196pub fn find(personas: &[PersonaSpec], id: &str) -> Option<Persona> {
197    let id = id.trim();
198    let id = if id.is_empty() { DEFAULT_ID } else { id };
199    catalog(personas).into_iter().find(|p| p.id == id)
200}
201
202/// The persona to apply for a stored id: `None` for the default, and for an id
203/// the config no longer knows (a warning, never a stopped conversation).
204pub fn active(personas: &[PersonaSpec], id: &str) -> Option<Persona> {
205    match find(personas, id) {
206        Some(p) if p.is_default() => None,
207        Some(p) => Some(p),
208        None => {
209            tracing::warn!("chat: persona `{id}` is no longer configured; using the default");
210            None
211        }
212    }
213}
214
215/// The briefing's persona section.
216pub fn section(p: &Persona) -> String {
217    section_for(p, None)
218}
219
220/// [`section`] that also tells the persona how to address the operator.
221pub fn section_for(p: &Persona, operator_name: Option<&str>) -> String {
222    let mut out = section_base(p);
223    if let Some(n) = operator_name {
224        out.push_str(&addressing(n));
225    }
226    out
227}
228
229/// The sentence naming the operator, shared by the persona section and the
230/// default voice's own short section.
231pub fn addressing(operator_name: &str) -> String {
232    format!(
233        "\nAddress the operator as \"{operator_name}\" when you address them by \
234         name. This is how they are called, not a change of tone.\n"
235    )
236}
237
238fn section_base(p: &Persona) -> String {
239    format!(
240        "\n# Persona (tone only)\n\n\
241         The operator picked the persona \"{name}\" for this conversation. It \
242         governs TONE ONLY - word choice, rhythm and attitude of what you say. \
243         It never changes what you do. Facts, commands, file paths, task ids, \
244         how a duplicate warning is handled (never `--force` it yourself), the \
245         write policy, and telling the operator the task id `magi task add` \
246         prints all stay exact and exactly as described above. Do not let the \
247         voice make an answer vaguer, shorter on substance, or more certain \
248         than the facts. Never put the persona's voice into the <instruction> \
249         you pass to `magi task add`, into files, or into commit messages: \
250         those stay neutral.\n\n\
251         Voice:\n{voice}\n",
252        name = p.name,
253        voice = p.prompt,
254    )
255}
256
257/// What a resumed session is told when the persona changed since the briefing
258/// it holds. `None` means the operator went back to the plain voice.
259pub fn update_block(p: Option<&Persona>) -> String {
260    update_block_for(p, None)
261}
262
263/// [`update_block`] naming the operator the way the briefing does.
264pub fn update_block_for(p: Option<&Persona>, operator_name: Option<&str>) -> String {
265    match p {
266        Some(p) => format!(
267            "# Persona update\n\nThe operator changed the persona. Drop any earlier \
268             persona and use this one from now on.\n{}",
269            section_for(p, operator_name)
270        ),
271        None => "# Persona update\n\nThe operator turned the persona off. Drop any earlier \
272                 persona and go back to your plain, normal voice from now on.\n"
273            .to_owned(),
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn spec(id: &str, name: &str, prompt: &str) -> PersonaSpec {
282        PersonaSpec {
283            id: id.into(),
284            name: name.into(),
285            prompt: prompt.into(),
286            icon: None,
287        }
288    }
289
290    fn with_icon(mut s: PersonaSpec, icon: &str) -> PersonaSpec {
291        s.icon = Some(icon.into());
292        s
293    }
294
295    #[test]
296    fn builtins_start_with_a_plain_default() {
297        let c = builtin_catalog();
298        assert_eq!(c[0].id, DEFAULT_ID);
299        assert!(c[0].prompt.is_empty());
300        for id in [
301            "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
302        ] {
303            let p = c.iter().find(|p| p.id == id).expect(id);
304            assert!(p.prompt.contains("operator's language"), "{id}");
305        }
306    }
307
308    #[test]
309    fn a_user_entry_adds_or_overrides_in_place() {
310        let c = catalog(&[
311            spec("rei", "Rei", "Be curt."),
312            spec("gendo", "Gendo", "Be cold."),
313        ]);
314        let pos = c.iter().position(|p| p.id == "rei").unwrap();
315        assert_eq!(pos, 2);
316        assert_eq!(c[pos].prompt, "Be curt.");
317        assert_eq!(c.last().unwrap().id, "gendo");
318        assert_eq!(c.len(), builtin_catalog().len() + 1);
319    }
320
321    #[test]
322    fn validation_names_the_problem() {
323        let err = |s: Vec<PersonaSpec>| validate(&s).unwrap_err().to_string();
324        assert!(err(vec![spec(" ", "n", "p")]).contains("empty `id`"));
325        assert!(err(vec![spec("a", "n", "  ")]).contains("empty `prompt`"));
326        assert!(err(vec![spec("a", "", "p")]).contains("empty `name`"));
327        assert!(err(vec![spec("a", "n", "p"), spec("a", "m", "q")]).contains("more than once"));
328        assert!(err(vec![spec("default", "n", "p")]).contains("cannot be redefined"));
329        assert!(validate(&[spec("a", "n", "p")]).is_ok());
330    }
331
332    #[test]
333    fn active_treats_default_blank_and_unknown_as_plain() {
334        assert!(active(&[], "").is_none());
335        assert!(active(&[], DEFAULT_ID).is_none());
336        assert!(active(&[], "nobody").is_none());
337        assert_eq!(active(&[], "rei").unwrap().id, "rei");
338    }
339
340    #[test]
341    fn a_built_in_override_may_set_only_an_icon() {
342        let only = with_icon(spec("rei", "", ""), "data:image/png;base64,AAAA");
343        assert!(validate(std::slice::from_ref(&only)).is_ok());
344        let c = catalog(&[only]);
345        let rei = c.iter().find(|p| p.id == "rei").unwrap();
346        let base = builtin_catalog();
347        let plain = base.iter().find(|p| p.id == "rei").unwrap();
348        assert_eq!((&rei.name, &rei.prompt), (&plain.name, &plain.prompt));
349        assert_eq!(rei.icon.as_deref(), Some("data:image/png;base64,AAAA"));
350        // A new id still needs both.
351        assert!(validate(&[with_icon(spec("x", "", ""), "~/a.png")]).is_err());
352    }
353
354    #[test]
355    fn default_takes_an_icon_and_nothing_else() {
356        let abs = "~/d.png";
357        assert!(validate(&[with_icon(spec("default", "", ""), abs)]).is_ok());
358        assert!(validate(&[with_icon(spec("default", "N", ""), abs)]).is_err());
359        assert!(validate(&[with_icon(spec("default", "", "P"), abs)]).is_err());
360        assert!(validate(&[spec("default", "", "")]).is_err());
361        let c = catalog(&[with_icon(spec("default", "", ""), abs)]);
362        assert_eq!(c[0].icon.as_deref(), Some(abs));
363        assert!(c[0].prompt.is_empty());
364    }
365
366    #[test]
367    fn a_bad_icon_shape_does_not_invalidate_the_persona() {
368        for bad in [
369            "rel.png",
370            "/a/../b.png",
371            "/a/b.svg",
372            "data:text/html;base64,AA",
373        ] {
374            assert!(validate(&[with_icon(spec("a", "n", "p"), bad)]).is_ok());
375            assert!(crate::persona_icon::load(bad).is_err());
376        }
377    }
378
379    #[test]
380    fn an_icon_never_reaches_the_briefing() {
381        let without_icon = Persona {
382            id: "rei".into(),
383            name: "Rei".into(),
384            prompt: "Be curt.".into(),
385            icon: None,
386        };
387        let iconed = Persona {
388            icon: Some("~/r.png".into()),
389            ..without_icon.clone()
390        };
391        assert_eq!(section(&without_icon), section(&iconed));
392        assert_eq!(
393            section_for(&without_icon, Some("Cmdr")),
394            section_for(&iconed, Some("Cmdr"))
395        );
396        assert_eq!(
397            update_block(Some(&without_icon)),
398            update_block(Some(&iconed))
399        );
400        // The default voice is unchanged by an icon on it.
401        let d = catalog(&[with_icon(spec("default", "", ""), "~/d.png")]);
402        assert!(active(&[with_icon(spec("default", "", ""), "~/d.png")], "").is_none());
403        assert!(d[0].prompt.is_empty());
404    }
405}