From 062d4252efb30dad35ccfb90b10f4c31281154a8 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 15:57:58 +0400 Subject: [PATCH] Tell her what she can actually do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context block now lists her real capabilities: reminders, notes and facts (write and recall), and the calendar — all three are code paths in mavend today. Weather, telegram and shell acts are listed only when the config actually has them, because offering something she cannot do is worse than staying quiet about it. Also drops the pronouns from the optional name/city line. The block's own "ты" is Maven, so "тебя зовут" read as her name and "его" would have shown her the third-person form she must never use about him. They are plain labels now. Vikunja #394. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ --- cmd/mavend/main.go | 18 ++++++++----- internal/persona/persona.go | 46 +++++++++++++++++++++++++++++--- internal/persona/persona_test.go | 22 +++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) 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)