package memory import ( "fmt" "sort" "strings" "time" ) // Behavioural memory — "what do I usually do?" (Vikunja #254). // // The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md // asks for an LLM to write a behaviour profile daily and store it as a fact; // this does not do that, on purpose. A 1.7B asked to summarise a year of habits // will produce fluent claims about the owner's life that no row in the store // supports, and a wrong claim about him is the most expensive kind of wrong // maven can be. Counting distinct days per weekday is verifiable, cheap enough // to run on the question, and cannot invent a habit he does not have. // // Recomputed on read rather than cached as a fact for the same reason the store // is append-only: a cached profile can disagree with the rows it came from, and // then there are two truths. The plan's step 5 ("profile updates on fact write") // exists to keep a cache fresh; there is no cache, so a new fact is already in // the next answer. // // It is also read-only and unprompted-free. The plan's step 4 — a morning // dispatcher nudge proposing the day — is deliberately NOT here: maven is not a // nag, and proposing plans at 08:00 every day is the definition of one. Pattern // inference that leads to a routine the owner accepts already exists in // internal/pattern with the proposal queue on /routines; that is the sanctioned // path from "she noticed" to "she acts", and it goes through him. // Observation — one thing the owner was recorded doing, reduced to what a habit // needs: when, and what. Facts arrive as store/ipc rows; the caller maps them // so this package stays free of both. type Observation struct { At time.Time Key string Kind string // "self" | "env" | "config" } // Activity — one recurring thing, as counted. Days is the number of DISTINCT // days it was observed on, which is the number that decides whether something // is a habit; Count can be inflated by one busy day. // // TypicalAt is the median time of day it happens at, rounded to the minute — a // median and not a mean, so one 03:00 outlier does not move "он обычно пьёт // воду утром" into the night. type Activity struct { Key string Days int Count int TypicalAt time.Duration } // Profile — the counted behaviour model. // // Weekly holds only the activities that DISTINGUISH a weekday: things he does // on Tuesdays and not on most other days. Everyday holds the ones that recur // across the week, and All holds both. The split exists because the two answer // different questions, and conflating them produced the failure that named // this: asked what he does on Saturdays, maven replied "ты пьёшь воду". type Profile struct { Since time.Time Until time.Time Weekly map[time.Weekday][]Activity Everyday []Activity All []Activity } // EverydaySpan — the number of weekdays an activity must be a habit on before // it stops counting as characteristic of any one of them. Six of seven, not // five: a weekday-only rhythm spans exactly five, and "по будням ты // тренируешься" is a real answer about Tuesday. Six days a week is not. const EverydaySpan = 6 // MinHabitDays — how many distinct days an activity must appear on before maven // will call it usual. Two is the smallest number that can distinguish a habit // from a one-off; below that she says she does not know yet, which is true. const MinHabitDays = 2 // nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not // behaviour. Calendar events carry the day in the key so they can never repeat; // cooldown and quiet rows are maven's own tuning state, not his habits. var nonBehaviouralKeyPrefixes = []string{ "calendar_event_", "cooldown:", "quiet", "behavior_profile", } // BuildProfile counts habits out of observations. now bounds the window's upper // end and supplies the location every day boundary is taken in — a habit is // "on Tuesdays" in the owner's timezone or it is nothing. // // Only self-facts count. An env row is the world (weather, a relayed meeting), // and a config row is maven's own state; neither says anything about what he // usually does. func BuildProfile(obs []Observation, now time.Time) Profile { loc := now.Location() p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}} type bucket struct { days map[string]struct{} count int mins []int } // key → bucket, and (weekday, key) → bucket. all := map[string]*bucket{} weekly := map[time.Weekday]map[string]*bucket{} for _, o := range obs { if o.Kind != "self" { continue } key := strings.TrimSpace(o.Key) if key == "" || nonBehavioural(key) { continue } at := o.At.In(loc) if at.IsZero() || at.After(now) { continue } if p.Since.IsZero() || at.Before(p.Since) { p.Since = at } day := at.Format("2006-01-02") minute := at.Hour()*60 + at.Minute() bump := func(m map[string]*bucket) { b := m[key] if b == nil { b = &bucket{days: map[string]struct{}{}} m[key] = b } b.days[day] = struct{}{} b.count++ b.mins = append(b.mins, minute) } bump(all) wd := at.Weekday() if weekly[wd] == nil { weekly[wd] = map[string]*bucket{} } bump(weekly[wd]) } harvest := func(m map[string]*bucket) []Activity { var out []Activity for key, b := range m { if len(b.days) < MinHabitDays { continue } out = append(out, Activity{ Key: key, Days: len(b.days), Count: b.count, TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute, }) } // Most-established first, then earliest in the day, then by key so the // same history always reads back the same way. sort.Slice(out, func(i, j int) bool { if out[i].Days != out[j].Days { return out[i].Days > out[j].Days } if out[i].TypicalAt != out[j].TypicalAt { return out[i].TypicalAt < out[j].TypicalAt } return out[i].Key < out[j].Key }) return out } p.All = harvest(all) // How many weekdays each key is a habit on. An activity that recurs on // most days of the week is a daily habit, and naming it as an answer to // "что я обычно делаю по субботам?" is a non-answer: "ты пьёшь воду" is // true of Saturday and of every other day, so it says nothing about // Saturday. Those are held in Everyday and read back separately. span := map[string]int{} harvested := map[time.Weekday][]Activity{} for wd, m := range weekly { acts := harvest(m) harvested[wd] = acts for _, a := range acts { span[a.Key]++ } } for wd, acts := range harvested { var distinct []Activity for _, a := range acts { if span[a.Key] >= EverydaySpan { continue } distinct = append(distinct, a) } if len(distinct) > 0 { p.Weekly[wd] = distinct } } for _, a := range p.All { if span[a.Key] >= EverydaySpan { p.Everyday = append(p.Everyday, a) } } return p } func nonBehavioural(key string) bool { for _, p := range nonBehaviouralKeyPrefixes { if strings.HasPrefix(key, p) { return true } } return false } // medianInt — the middle value, averaging the two middles on an even count. func medianInt(xs []int) int { if len(xs) == 0 { return 0 } s := make([]int, len(xs)) copy(s, xs) sort.Ints(s) mid := len(s) / 2 if len(s)%2 == 1 { return s[mid] } return (s[mid-1] + s[mid]) / 2 } // weekdayRU and activityRU are loaded from the embedded behavior_ru.json; // see behavior_ru.go. // FormatWeekdayRU reads back what DISTINGUISHES a given weekday. // Second person singular and informal, as she speaks TO him. // // When nothing distinguishes it, she says so and names the daily habits as // daily habits instead of passing them off as an answer about that day. The // previous version had no such distinction and answered "что я делаю по // субботам?" with "ты пьёшь воду" — true, useless, and phrased as if Saturday // were the reason. func (p Profile) FormatWeekdayRU(wd time.Weekday) string { day := weekdayRU[int(wd)%7] acts := p.Weekly[wd] if len(acts) > 0 { return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) } if len(p.Everyday) > 0 { return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", day, joinActivities(p.Everyday)) } return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day) } // FormatOverallRU reads back the habits that hold across the whole week. func (p Profile) FormatOverallRU() string { if len(p.All) == 0 { return "я ещё не набрала достаточно записей, чтобы говорить о привычках." } return fmt.Sprintf("обычно ты %s.", joinActivities(p.All)) } // maxRecited bounds a spoken profile. A list of fifteen habits read aloud is // not an answer; the most established few are. const maxRecited = 5 func joinActivities(acts []Activity) string { if len(acts) > maxRecited { acts = acts[:maxRecited] } parts := make([]string, len(acts)) for i, a := range acts { gloss, ok := activityRU[a.Key] if !ok { gloss = a.Key } parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) } if len(parts) == 1 { return parts[0] } return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1] }