// Package persona builds the one shared context block that goes in front of // every LLM system prompt: who the owner is, how to address him, and what // time it is right now. // // Why one block and not a line pasted into each prompt: there are five // prompts (nudges, action replies, chat, note queries, general knowledge) and // the "address him as ты" rule had only reached two of them. Five copies drift. // One block cannot. // // The rules here are defaults in code, not config. Maven is feminine and the // owner is a man addressed informally — that is a hard constraint of the // product, so it must hold with an empty config file. Config only ADDS // optional facts (his name, his city). package persona import ( "fmt" "strings" "time" "github.com/kami/maven/internal/lexicon" ) // Facts — the optional, deployment-specific half of the block. All fields may // be empty; the block is still correct and useful without them. type Facts struct { OwnerName string // his name, e.g. "Ками" City string // where he is, e.g. "Москва" Static string // the free-text `persona` config string, appended verbatim // The two config-gated capabilities. They are listed only when this // deployment actually has them, because a capability she names and cannot // do is worse than one she never mentions. Weather bool // an open-meteo provider is configured Telegram bool // a telegram bot token + chat id are configured Tools bool // at least one shell act is on the allowlist } // The weekday and month names are closed classes and live in internal/lexicon, // which indexes weekdays from Sunday the way time.Weekday does and months from // one. This file used to carry its own copies, making four copies of the twelve // months in the tree after cmd/mavend/ruwords.go gave up its own (Vikunja #525). // Block renders the context block for one turn. Russian even in front of the // English prompts: the rules it states are Russian grammar (ты/тебя, feminine // verbs), and a Russian rule reads best stated in Russian. // // Keep it short. It ships on every turn to a 0.8B on laptop CPU, so every // line here is latency. func (f Facts) Block(now time.Time) string { var b strings.Builder b.WriteString("Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде: \"я записала\", \"я проверила\".\n") // The address form gets its own line. It is the thing that kept getting // lost when it was buried in prose. b.WriteString("ОБРАЩЕНИЕ: владелец — мужчина, всегда на \"ты\" (ты, тебя, тебе, твой) и в единственном числе (\"выпей\", \"посмотри\"). Никогда \"вы\"/\"вас\"/\"ваш\". Никогда \"он\"/\"его\" о нём — ты говоришь ему, а не о нём. Глаголы о нём — в мужском роде (\"ты забыл\").\n") if who := f.who(); who != "" { b.WriteString(who + "\n") } b.WriteString(fmt.Sprintf("Сейчас: %s, %d %s %d, %02d:%02d (местное время).\n", lexicon.Weekday(int(now.Weekday())), now.Day(), lexicon.MonthGenitive(int(now.Month())), now.Year(), now.Hour(), now.Minute())) b.WriteString("Умеешь: " + strings.Join(f.can(), "; ") + ". Других ДЕЙСТВИЙ не умеешь — если просят такое, скажи прямо.\n") if s := strings.TrimSpace(f.Static); s != "" { b.WriteString(s + "\n") } return b.String() } // can lists what she can really do. Every entry here is a code path that // exists in the daemon today: // - reminders: IntentReminder → CoreAPI.CreateReminder, fired by the tick. // - notes and facts: IntentNote/IntentFact write, IntentQuery reads them back. // - calendar: IntentQuery answers "что у меня сегодня" from CalendarEvents. // - weather / telegram / shell acts: only when configured (see Facts). // // Nothing speculative goes in this list. A capability she offers and cannot // perform is worse than one she never mentions. func (f Facts) can() []string { c := []string{ // Talking comes first, and the closing line says "действий" rather than // "ничего", because this same block sits in front of the chat and // general-knowledge prompts. A flat "you can do nothing else" would // tell her to refuse the exact thing those two prompts are for. "разговаривать и отвечать на вопросы", "ставить напоминания", "записывать заметки и факты и отвечать по ним", "смотреть календарь", } if f.Weather { c = append(c, "говорить погоду") } if f.Telegram { c = append(c, "писать в телеграм") } if f.Tools { c = append(c, "запускать разрешённые команды на сервере") } return c } // who renders the optional name/city line, or "" when neither is configured. // // Written as labels ("Имя владельца: ..."), not as a sentence with pronouns: // the block's own "ты" is Maven, so "тебя зовут" would read as her name and // "его" would model the third-person form she must never use about him. func (f Facts) who() string { name := strings.TrimSpace(f.OwnerName) city := strings.TrimSpace(f.City) switch { case name != "" && city != "": return "Имя владельца: " + name + ". Город: " + city + "." case name != "": return "Имя владельца: " + name + "." case city != "": return "Город: " + city + "." } return "" } // Prepend puts the block in front of a system prompt. Nil-safe: a nil renderer // (tests, the stub paths) returns the prompt untouched. func Prepend(block func() string, prompt string) string { if block == nil { return prompt } s := strings.TrimSpace(block()) if s == "" { return prompt } return s + "\n\n" + prompt }