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 the activities that // recur on a given weekday, Overall the ones that recur at all. type Profile struct { Since time.Time Until time.Time Weekly map[time.Weekday][]Activity All []Activity } // 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) for wd, m := range weekly { if acts := harvest(m); len(acts) > 0 { p.Weekly[wd] = acts } } 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 — accusative, as "по вторникам" and "в среду" both need it read // back. Index is time.Weekday. var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"} // activityRU glosses the loop's known fact keys. An unknown key is read back // verbatim: it is what the store holds, and inventing a Russian phrase for a key // maven does not recognise would be putting words in his mouth. var activityRU = map[string]string{ "water": "пьёшь воду", "meal": "ешь", "sleep": "спишь", "break": "делаешь перерыв", "shower": "принимаешь душ", "walk": "гуляешь", "pills": "пьёшь витамины", "workout": "тренируешься", } // FormatWeekdayRU reads back what he usually does on a given weekday. // Second person singular and informal, as she speaks TO him. func (p Profile) FormatWeekdayRU(wd time.Weekday) string { acts := p.Weekly[wd] day := weekdayRU[int(wd)%7] if len(acts) == 0 { return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day) } return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) } // 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] }