diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 99f3c74..b1f69cd 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -606,14 +606,20 @@ func run(args []string) error { // city, the free-text persona string) out of the config. Everything here may // be empty — the context block is correct without any of it. func personaFacts(cfg *config.Config) persona.Facts { + f := persona.Facts{ + // Telegram lives outside the voice block, so it counts either way. + Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "", + } if cfg.Voice == nil { - return persona.Facts{} - } - return persona.Facts{ - OwnerName: cfg.Voice.OwnerName, - City: cfg.Voice.City, - Static: cfg.Voice.Persona, + return f } + f.OwnerName = cfg.Voice.OwnerName + f.City = cfg.Voice.City + f.Static = cfg.Voice.Persona + // Same test wireVoice uses to pick the real provider over the stub. + f.Weather = cfg.Voice.Weather != nil && cfg.Voice.Weather.Provider == "open-meteo" + f.Tools = len(cfg.Voice.Tools) > 0 + return f } // contextBlockFn returns the per-turn renderer of the shared context block. diff --git a/internal/persona/persona.go b/internal/persona/persona.go index 365dd13..4af029e 100644 --- a/internal/persona/persona.go +++ b/internal/persona/persona.go @@ -25,6 +25,13 @@ 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 } var ruWeekdays = [...]string{"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"} @@ -57,23 +64,56 @@ func (f Facts) Block(now time.Time) string { ruWeekdays[int(now.Weekday())], now.Day(), ruMonths[int(now.Month())-1], 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{ + "ставить напоминания", + "записывать заметки и факты и отвечать по ним", + "смотреть календарь", + } + 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 + "." + return "Имя владельца: " + name + ". Город: " + city + "." case name != "": - return "Его зовут " + name + "." + return "Имя владельца: " + name + "." case city != "": - return "Он в городе " + city + "." + return "Город: " + city + "." } return "" } diff --git a/internal/persona/persona_test.go b/internal/persona/persona_test.go index a6a2fb2..ccf411e 100644 --- a/internal/persona/persona_test.go +++ b/internal/persona/persona_test.go @@ -37,6 +37,28 @@ func TestBlockRendersTimePerTurn(t *testing.T) { } } +// She may only offer what this deployment actually has. +func TestCapabilitiesAreConfigGated(t *testing.T) { + bare := Facts{}.Block(ref) + for _, want := range []string{"напоминания", "заметки", "календарь"} { + if !strings.Contains(bare, want) { + t.Errorf("block missing always-on capability %q:\n%s", want, bare) + } + } + for _, unwanted := range []string{"погоду", "телеграм", "команды"} { + if strings.Contains(bare, unwanted) { + t.Errorf("block offers unconfigured %q:\n%s", unwanted, bare) + } + } + + full := Facts{Weather: true, Telegram: true, Tools: true}.Block(ref) + for _, want := range []string{"погоду", "телеграм", "команды"} { + if !strings.Contains(full, want) { + t.Errorf("block missing configured capability %q:\n%s", want, full) + } + } +} + func TestPrependNilIsSafe(t *testing.T) { if got := Prepend(nil, "PROMPT"); got != "PROMPT" { t.Errorf("Prepend(nil) = %q", got)