1use std::collections::BTreeSet;
15
16use anyhow::{Result, bail};
17
18use crate::config::PersonaSpec;
19
20pub const DEFAULT_ID: &str = "default";
22
23const 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
27const 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#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Persona {
85 pub id: String,
87 pub name: String,
89 pub prompt: String,
91 pub icon: Option<String>,
94}
95
96impl Persona {
97 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
119pub 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
154pub 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
190pub fn builtin_catalog() -> Vec<Persona> {
192 catalog(&[])
193}
194
195pub 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
202pub 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
215pub fn section(p: &Persona) -> String {
217 section_for(p, None)
218}
219
220pub 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
229pub 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
257pub fn update_block(p: Option<&Persona>) -> String {
260 update_block_for(p, None)
261}
262
263pub 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 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 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}