diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 4c251da..f02d01c 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -179,6 +179,9 @@ func (l *lockedAPI) Chat(ctx context.Context, text string) (string, error) { func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { return ipc.TickTrace{}, errLocked } +func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) { + return nil, errLocked +} func run(args []string) error { cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config") @@ -341,12 +344,13 @@ func run(args []string) error { tickInterval := time.Duration(cfg.TickInterval) repeatInterval := time.Duration(cfg.RepeatInterval) autotuneInterval := time.Duration(cfg.AutotuneInterval) - tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines)) + tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines)) factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval)) coreAPI = &daemonAPI{ - CoreAPI: ipc.NewStoreAPI(st), - getTrace: tl.trace, + CoreAPI: ipc.NewStoreAPI(st), + getTrace: tl.trace, + getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) }, } if voiceW != nil && voiceW.handler != nil { api := coreAPI.(*daemonAPI) @@ -501,13 +505,14 @@ func run(args []string) error { tickInterval := time.Duration(cfg.TickInterval) repeatInterval := time.Duration(cfg.RepeatInterval) autotuneInterval := time.Duration(cfg.AutotuneInterval) - tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines)) + tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines)) factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval)) // Swap the CoreAPI from lockedAPI to the real store adapter. newAPI := &daemonAPI{ - CoreAPI: ipc.NewStoreAPI(st), - getTrace: tl.trace, + CoreAPI: ipc.NewStoreAPI(st), + getTrace: tl.trace, + getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) }, } if voiceW != nil && voiceW.handler != nil { newAPI.chatFn = voiceW.handler.handleText diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 3ebbeef..67835f5 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -23,6 +23,7 @@ import ( "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/routine" "github.com/kami/maven/internal/store" @@ -61,6 +62,11 @@ type tickLoop struct { routines []routine.Routine routineLast map[string]time.Time + // morningRoutines — daily checklists (see internal/morning). morningLast + // tracks the per-routine last-nudge day, mirroring routineLast. + morningRoutines []morning.Routine + morningLast map[string]time.Time + // digestQ — in-memory queue of eligible nudges waiting for batch flush. // populated when digestCfg != nil && digestCfg.Enabled. digestQ []QueuedNudge @@ -85,6 +91,7 @@ func newTickLoop( tickInterval, repeatInterval, autotuneInterval time.Duration, digestCfg *config.DigestConfig, routines []routine.Routine, + morningRoutines []morning.Routine, ) *tickLoop { return &tickLoop{ store: st, @@ -99,6 +106,8 @@ func newTickLoop( digestQ: nil, routines: routines, routineLast: make(map[string]time.Time), + morningRoutines: morningRoutines, + morningLast: make(map[string]time.Time), lastPhrase: make(map[string]delivery.PhrasedNudge), } } @@ -177,6 +186,11 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) { // LLM-phrased — so a routine can't hallucinate. severity comes from config. t.fireRoutines(ctx, now, state) + // morning routines: daily checklists (medicine/water/pets/...), nagged at + // most once per day per routine, and only for items still unevidenced at + // nudge time. See internal/morning for the "why not four timers" rationale. + t.fireMorningRoutines(ctx, now, state) + // reminders: gate-bypassing class. fired once, marked after a successful // delivery. a failed send leaves the reminder pending — the next tick // re-gathers and re-attempts. @@ -363,6 +377,97 @@ func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.S } } +// fireMorningRoutines checks each configured checklist against today's facts +// and dispatches a nag listing exactly what's still missing, at most once per +// routine per calendar day. Fact reads happen here (not in loop.Gatherer) +// because the item↔fact-key mapping is morning-routine-specific, not a rule +// concern — pulling it into the shared gather path would leak that mapping +// into loop's "rules declare wanted keys" contract. Bodies are literal +// operator text (item labels joined), not LLM-phrased, same rationale as +// cron routines: deterministic, can't hallucinate a checklist item. +func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) { + if len(t.morningRoutines) == 0 { + return + } + facts := t.gatherMorningFacts(ctx) + + for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) { + labels := make([]string, len(cand.Missing)) + for i, it := range cand.Missing { + labels[i] = it.Label + } + body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", ")) + pn := delivery.PhrasedNudge{ + Candidate: loop.Candidate{ + Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)}, + Severity: loop.Severity(cand.Routine.Severity), + State: state, + }, + Body: body, + Summary: body, + } + if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil { + log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err) + } + } +} + +// gatherMorningFacts reads the latest fact for every item's fact_key across +// all configured morning routines. Shared by fireMorningRoutines (nudge +// decision) and morningStatus (read-only query) so the two paths can never +// disagree about what evidence exists. +func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact { + keys := make(map[string]struct{}) + for _, r := range t.morningRoutines { + for _, it := range r.Items { + keys[it.FactKey] = struct{}{} + } + } + facts := make(map[string]store.Fact, len(keys)) + for k := range keys { + f, err := t.store.LatestFact(ctx, k) + if err == nil { + facts[k] = f + continue + } + if err != store.ErrNoFact { + log.Printf("tick: morning: latest fact %s: %v", k, err) + } + } + return facts +} + +// morningStatus is the read-only "what's missing" query the web UI (and +// eventually a voice query) calls. Pure recompute over the current facts — +// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers +// "state right now," not "should we nag." +func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus { + if len(t.morningRoutines) == 0 { + return nil + } + facts := t.gatherMorningFacts(ctx) + out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines)) + for _, r := range t.morningRoutines { + st := morning.Evaluate(r, facts, now) + done := make(map[string]bool, len(st.Completed)) + for _, it := range st.Completed { + done[it.Key] = true + } + items := make([]ipc.MorningRoutineItem, len(r.Items)) + for i, it := range r.Items { + items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]} + } + out = append(out, ipc.MorningRoutineStatus{ + Name: r.Name, + Active: st.Active, + WindowStart: r.WindowStart, + WindowEnd: r.WindowEnd, + Items: items, + }) + } + return out +} + // tune — the feedback auto-tuner's impure step. runs on a slow cadence // (autotuneInterval, see run) so it doesn't write a fact every tick. for each // rule: @@ -440,8 +545,9 @@ func (t *tickLoop) trace() *loop.TickTrace { // daemon's in-memory tick trace cache. type daemonAPI struct { ipc.CoreAPI - getTrace func() *loop.TickTrace - chatFn func(ctx context.Context, text string) string + getTrace func() *loop.TickTrace + getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus + chatFn func(ctx context.Context, text string) string } func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) { @@ -459,6 +565,13 @@ func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { return toIPCTickTrace(*trace), nil } +func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) { + if d.getMorningStatus == nil { + return nil, errors.New("mavend: morning status not available") + } + return d.getMorningStatus(ctx), nil +} + func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace { rules := make([]ipc.RuleTrace, len(t.RuleTraces)) for i, r := range t.RuleTraces { diff --git a/cmd/mavend/tick_test.go b/cmd/mavend/tick_test.go index 367eab7..337ad3c 100644 --- a/cmd/mavend/tick_test.go +++ b/cmd/mavend/tick_test.go @@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf Nudges: st, Reminders: st, }) - return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil) + return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil) } func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) { @@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) { sink := &fakeSink{} d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st}) rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}} - tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs) + tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil) // first tick: seeds, does not fire the routine. tl.tick(ctx, now) diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 8229e8d..a6ae3a9 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -63,6 +63,9 @@ var voiceHTML string //go:embed ecosystem.html var ecosystemHTML string +//go:embed morning.html +var morningHTML string + // ── Ethos Workstation Shell ── // // Two template pieces that wrap every page: @@ -95,6 +98,7 @@ var sidebarSections = []struct { {Label: "Notifications", URL: "/notifications", Key: "notifications"}, {Label: "Reminders", URL: "/reminders", Key: "reminders"}, {Label: "Routines", URL: "/routines", Key: "routines"}, + {Label: "Morning", URL: "/morning", Key: "morning"}, }, }, { @@ -169,6 +173,8 @@ func pageIcon(key string) string { return `` case "routines": return `` + case "morning": + return `` case "chat": return `` case "voice": @@ -199,6 +205,8 @@ func pageTitle(key string) string { return "Reminders" case "routines": return "Routines" + case "morning": + return "Morning Routines" case "chat": return "Chat" case "voice": @@ -291,6 +299,12 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel // human surface is here (they ship no web UI of their own). var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML)) +// morningTmpl — read-only view of today's checklist state per configured +// morning routine (internal/morning). Same shape as trace.html: a plain +// server-rendered page, refreshed on reload — no live-update loop, since +// checklist state changes on the scale of minutes, not seconds. +var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML)) + func noCache(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") @@ -384,6 +398,9 @@ func main() { mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) { handleRoutines(w, r, core) }) + mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) { + handleMorning(w, r, core) + }) ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL} mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) { handleEcosystem(w, r, ecoURLsCfg) @@ -829,6 +846,24 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } +func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if core == nil { + http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable) + return + } + ctx := r.Context() + status, err := core.MorningStatus(ctx) + if err != nil { + log.Printf("morning: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := morningTmpl.Execute(w, status); err != nil { + log.Printf("morning render: %v", err) + } +} + func handleVoice(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := voiceTmpl.Execute(w, nil); err != nil { @@ -901,9 +936,7 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi ctx := r.Context() var msg string if r.Method == http.MethodPost { - // nil session ⇒ WebAuthn not configured; step-up gate not applicable - // (see wiring in main — asserting would be impossible, not just unmet). - if session != nil && !session.IsStepUp() { + if !stepUpOK(session, requireStepUp) { http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) return } diff --git a/cmd/mavweb/morning.html b/cmd/mavweb/morning.html new file mode 100644 index 0000000..abf5ae1 --- /dev/null +++ b/cmd/mavweb/morning.html @@ -0,0 +1,23 @@ +{{template "shellTop" "morning"}} +

Morning Routines

+{{if not .}} +
no morning routines configured
+{{else}} +{{range .}} +
+
{{.Name}} + {{if .Active}}active now{{else}}outside window{{end}} + {{.WindowStart}}–{{.WindowEnd}} +
+
+ + {{range .Items}} + + + {{end}} +
itemstatus
{{.Label}}{{if .Done}}done{{else}}missing{{end}}
+
+{{end}} +{{end}} +{{template "shellBottom"}} + diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index db05b2d..45acc73 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -391,6 +391,9 @@ func (r *recordingAPI) ListReminders(_ context.Context, _ int) ([]ipc.Reminder, func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) { return ipc.TickTrace{}, nil } +func (r *recordingAPI) MorningStatus(_ context.Context) ([]ipc.MorningRoutineStatus, error) { + return nil, nil +} func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) { return 1, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 34733cd..cca49e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ import ( "github.com/kami/maven/internal/delivery/ntfysink" "github.com/kami/maven/internal/delivery/telegramsink" + "github.com/kami/maven/internal/morning" "github.com/robfig/cron/v3" ) @@ -134,6 +135,11 @@ type Config struct { // distinction from reminders (user-stated) and care rules (world-state). Routines []RoutineConfig `json:"routines,omitempty"` + // MorningRoutines — daily checklists (medicine, water, pets, ...) checked + // once near the end of a time window instead of firing one reminder per + // item. See internal/morning for the evaluation engine. Empty ⇒ disabled. + MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"` + // Praxis — the ecosystem attention-state service. When configured, maven // calls the Praxis HTTP tools API for attention listing and item lifecycle. // Maven never touches Praxis's database directly (ecosystem invariant: no @@ -181,6 +187,28 @@ type RoutineConfig struct { Severity int `json:"severity,omitempty"` } +// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt +// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are +// 0=Sunday..6=Saturday; empty means every day (set two routines under +// different names for weekday/weekend variants). +type MorningRoutineConfig struct { + Name string `json:"name"` + Weekdays []int `json:"weekdays,omitempty"` + WindowStart string `json:"window_start"` + WindowEnd string `json:"window_end"` + NudgeAt string `json:"nudge_at,omitempty"` + Severity int `json:"severity,omitempty"` + Items []MorningRoutineItemConfig `json:"items"` +} + +// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose +// presence within the window counts as completion evidence. +type MorningRoutineItemConfig struct { + Key string `json:"key"` + FactKey string `json:"fact_key"` + Label string `json:"label"` +} + // QuietHoursConfig — a recurring daily quiet-window. Times are local to the // server's wall clock. A window crossing midnight (Start > End) is handled: // "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day. @@ -456,6 +484,13 @@ func (c *Config) applyDefaults() { c.Routines[i].Severity = 1 } } + + // morning routines: same safe-floor default as cron routines. + for i := range c.MorningRoutines { + if c.MorningRoutines[i].Severity == 0 { + c.MorningRoutines[i].Severity = 1 + } + } } func (c *Config) validate() error { @@ -488,9 +523,46 @@ func (c *Config) validate() error { return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err) } } + if len(c.MorningRoutines) > 0 { + if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil { + return err + } + } return nil } +// morningRoutinesFromConfig maps the config's morning-routine blocks to the +// engine type. Shared with the daemon so config validation and daemon wiring +// can never drift on the mapping. +func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine { + out := make([]morning.Routine, len(mc)) + for i, r := range mc { + items := make([]morning.Item, len(r.Items)) + for j, it := range r.Items { + items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label} + } + weekdays := make([]time.Weekday, len(r.Weekdays)) + for j, w := range r.Weekdays { + weekdays[j] = time.Weekday(w) + } + out[i] = morning.Routine{ + Name: r.Name, + Weekdays: weekdays, + WindowStart: r.WindowStart, + WindowEnd: r.WindowEnd, + NudgeAt: r.NudgeAt, + Severity: r.Severity, + Items: items, + } + } + return out +} + +// MorningRoutinesFromConfig is the exported form daemon wiring uses. +func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine { + return morningRoutinesFromConfig(mc) +} + // DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins // over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens // a plaintext store. A configured-but-invalid key is an error (fail closed, diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 32b6ac6..88488a8 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -292,6 +292,13 @@ type CoreAPI interface { // persisted — it's a daemon-level cache). TickTrace(ctx context.Context) (TickTrace, error) + // MorningStatus returns each configured morning routine's current + // checklist state (see internal/morning): active today/now, which items + // are done, which are still missing. The store adapter returns an error + // (morning routines are daemon-config, not persisted) — same shape as + // TickTrace. + MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) + // Chat routes a text utterance through the reactive handler's core path // (router → dialogue → action → replier) and returns the reply text. // No audio or stt/tts — for text channels (mavweb, telegram). @@ -329,6 +336,22 @@ type TickTrace struct { Rules []RuleTrace `json:"rules"` } +// MorningRoutineItem — one checklist entry's current state. +type MorningRoutineItem struct { + Key string `json:"key"` + Label string `json:"label"` + Done bool `json:"done"` +} + +// MorningRoutineStatus — one routine's checklist state right now. +type MorningRoutineStatus struct { + Name string `json:"name"` + Active bool `json:"active"` + WindowStart string `json:"window_start"` + WindowEnd string `json:"window_end"` + Items []MorningRoutineItem `json:"items"` +} + // storeEncryptionKeyReq — passkey credential public key for wrapping the store // encryption key at enrollment time. Called by mavweb after RegisterFinish. type storeEncryptionKeyReq struct { diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 63005fe..4423484 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -70,6 +70,7 @@ var readOnlyMethods = map[Method]bool{ MethodListTools: true, MethodListProposedRoutines: true, MethodTickTrace: true, + MethodMorningStatus: true, } // Dial connects to a core socket at path and returns a Client. The module @@ -445,6 +446,14 @@ func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) { return t, nil } +func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { + var s []MorningRoutineStatus + if err := c.call(ctx, MethodMorningStatus, nil, &s); err != nil { + return nil, err + } + return s, nil +} + func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) { var result struct { NewID int64 `json:"new_id"` diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go index be8ef53..e793ecf 100644 --- a/internal/ipc/ipc_test.go +++ b/internal/ipc/ipc_test.go @@ -493,6 +493,9 @@ func (a *chatTestAPI) RevertFact(ctx context.Context, key string) (int64, error) func (a *chatTestAPI) TickTrace(ctx context.Context) (TickTrace, error) { return TickTrace{}, ErrUnknownMethod } +func (a *chatTestAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { + return nil, ErrUnknownMethod +} func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) { if text == "привет" { return "и тебе привет!", nil diff --git a/internal/ipc/server.go b/internal/ipc/server.go index f1f2d84..775d06b 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -207,6 +207,10 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) { return TickTrace{}, errors.New("store: tick trace not available via direct store API") } +func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { + return nil, errors.New("store: morning status not available via direct store API") +} + func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) { ts, err := a.s.ListTools(ctx, status) if err != nil { @@ -801,6 +805,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er } return marshalResult(t), nil + case MethodMorningStatus: + s, err := api.MorningStatus(ctx) + if err != nil { + return nil, err + } + return marshalResult(s), nil + case MethodAssertStepUp: if s.StepUp != nil { return marshalResult(nil), s.StepUp(ctx) diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index 7682013..695dda4 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -43,6 +43,7 @@ const ( MethodDismissProposedRoutine Method = "dismiss_proposed_routine" MethodRevertFact Method = "revert_fact" MethodTickTrace Method = "tick_trace" + MethodMorningStatus Method = "morning_status" MethodChat Method = "chat" ) diff --git a/internal/morning/morning.go b/internal/morning/morning.go new file mode 100644 index 0000000..8a94db7 --- /dev/null +++ b/internal/morning/morning.go @@ -0,0 +1,233 @@ +// Package morning is maven's morning routine engine — item #3 off the +// 2026-07-20 backlog (see Maven/20-07-2026-BACKLOG.md). +// +// A Routine is NOT four independent reminder timers. It's a checklist for a +// daily window: several Items, each evidenced by a fact key, completed in +// any order, checked once near the end of the window. Maven should be able +// to answer "what's still missing" at any point (Evaluate), and should nag +// AT MOST once per day per routine when something got skipped (Due) — never +// fire four separate item timers that stack into noise. +// +// This package is pure, like internal/loop and internal/routine: no store, +// no clock of its own. The daemon's tick driver owns the impurity (reads +// facts under the store lock, holds the last-nudge map across ticks) and +// calls Due each tick, exactly as it does for loop.Rule and routine.Routine. +package morning + +import ( + "fmt" + "time" + + "github.com/kami/maven/internal/store" +) + +// Item — one checklist entry. FactKey is the fact whose latest non-voided +// value, if timestamped within today's window, counts as completion +// evidence — manual (voice-tapped "выпил воды") and inferred (another +// daemon writing the same key) are indistinguishable and both count, per +// the backlog's "manual and inferred completion evidence" requirement. +type Item struct { + Key string + FactKey string + Label string // RU text surfaced when this item is still missing. +} + +// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local +// time and must not cross midnight (a morning routine doesn't span days). +// NudgeAt is when the engine checks for stragglers and nags once if +// anything's missing; empty defaults to WindowEnd (nag right as the window +// closes, not the moment it opens). Weekdays scopes which days this routine +// applies to — empty means every day; set it twice under different names +// for weekday/weekend variants. +type Routine struct { + Name string + Weekdays []time.Weekday + WindowStart string + WindowEnd string + NudgeAt string + Severity int + Items []Item +} + +// Status — the checklist's state right now. Active is false when the +// routine doesn't apply today (weekday) or `now` falls outside its window; +// Missing/Completed are meaningless in that case. +type Status struct { + RoutineName string + Active bool + Missing []Item + Completed []Item +} + +// Candidate — a routine that's due for its one-per-day nag: the window has +// reached NudgeAt and at least one item is still unevidenced. +type Candidate struct { + Routine Routine + Missing []Item +} + +// Validate reports the first structural problem with a routine set: missing +// name/items, an unparseable HH:MM, an inverted window, a duplicate item key +// within a routine, or an out-of-range weekday. Called at config load so a +// typo surfaces at startup, not as a silently-broken checklist at runtime. +func Validate(routines []Routine) error { + for _, r := range routines { + if r.Name == "" { + return fmt.Errorf("morning: name is required") + } + if len(r.Items) == 0 { + return fmt.Errorf("morning routine %q: at least one item is required", r.Name) + } + startH, startM, ok := parseHHMM(r.WindowStart) + if !ok { + return fmt.Errorf("morning routine %q: bad window_start %q", r.Name, r.WindowStart) + } + endH, endM, ok := parseHHMM(r.WindowEnd) + if !ok { + return fmt.Errorf("morning routine %q: bad window_end %q", r.Name, r.WindowEnd) + } + if startH*60+startM >= endH*60+endM { + return fmt.Errorf("morning routine %q: window_start must be before window_end", r.Name) + } + if r.NudgeAt != "" { + if _, _, ok := parseHHMM(r.NudgeAt); !ok { + return fmt.Errorf("morning routine %q: bad nudge_at %q", r.Name, r.NudgeAt) + } + } + for _, w := range r.Weekdays { + if w < time.Sunday || w > time.Saturday { + return fmt.Errorf("morning routine %q: bad weekday %d", r.Name, w) + } + } + seen := make(map[string]bool, len(r.Items)) + for _, it := range r.Items { + if it.Key == "" { + return fmt.Errorf("morning routine %q: item key is required", r.Name) + } + if it.FactKey == "" { + return fmt.Errorf("morning routine %q item %q: fact_key is required", r.Name, it.Key) + } + if seen[it.Key] { + return fmt.Errorf("morning routine %q: duplicate item key %q", r.Name, it.Key) + } + seen[it.Key] = true + } + } + return nil +} + +// Evaluate reports the routine's current checklist state, pure over the +// given facts snapshot and clock reading. Callable any time — the "what's +// still missing" query path — not just at nudge time. +func Evaluate(r Routine, facts map[string]store.Fact, now time.Time) Status { + st := Status{RoutineName: r.Name} + if !appliesToday(r, now) { + return st + } + start, ok1 := todayAt(r.WindowStart, now) + end, ok2 := todayAt(r.WindowEnd, now) + if !ok1 || !ok2 || now.Before(start) || !now.Before(end) { + return st + } + st.Active = true + for _, it := range r.Items { + if evidenced(it, facts, start, now) { + st.Completed = append(st.Completed, it) + } else { + st.Missing = append(st.Missing, it) + } + } + return st +} + +// Due returns the routines that have reached their nudge time today with at +// least one item still missing, and records `now` in `last` for each one +// returned so it fires at most once per calendar day. The caller owns +// `last` (the tick driver holds it across ticks, mirroring routine.Due). +func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.Time, now time.Time) []Candidate { + var out []Candidate + for _, r := range routines { + if !appliesToday(r, now) { + continue + } + start, ok := todayAt(r.WindowStart, now) + if !ok { + continue + } + nudgeAtStr := r.NudgeAt + if nudgeAtStr == "" { + nudgeAtStr = r.WindowEnd + } + nudgeAt, ok := todayAt(nudgeAtStr, now) + if !ok || now.Before(nudgeAt) { + continue + } + var missing []Item + for _, it := range r.Items { + if !evidenced(it, facts, start, now) { + missing = append(missing, it) + } + } + if len(missing) == 0 { + continue + } + if prev, seen := last[r.Name]; seen && sameDay(prev, now) { + continue + } + last[r.Name] = now + out = append(out, Candidate{Routine: r, Missing: missing}) + } + return out +} + +// evidenced reports whether item has a non-voided fact timestamped within +// [windowStart, now] — evidence from before the window opened (e.g. +// yesterday's dose) doesn't count; evidence from the future can't exist. +func evidenced(it Item, facts map[string]store.Fact, windowStart, now time.Time) bool { + f, ok := facts[it.FactKey] + if !ok || f.Ts.IsZero() { + return false + } + return !f.Ts.Before(windowStart) && !f.Ts.After(now) +} + +func appliesToday(r Routine, now time.Time) bool { + if len(r.Weekdays) == 0 { + return true + } + for _, w := range r.Weekdays { + if w == now.Weekday() { + return true + } + } + return false +} + +// todayAt resolves an "HH:MM" clock reading against now's calendar date and +// location. +func todayAt(hhmm string, now time.Time) (time.Time, bool) { + h, m, ok := parseHHMM(hhmm) + if !ok { + return time.Time{}, false + } + y, mo, d := now.Date() + return time.Date(y, mo, d, h, m, 0, 0, now.Location()), true +} + +func sameDay(a, b time.Time) bool { + ay, am, ad := a.Date() + by, bm, bd := b.Date() + return ay == by && am == bm && ad == bd +} + +func parseHHMM(s string) (hour, min int, ok bool) { + if len(s) != 5 || s[2] != ':' { + return 0, 0, false + } + h := int(s[0]-'0')*10 + int(s[1]-'0') + m := int(s[3]-'0')*10 + int(s[4]-'0') + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, 0, false + } + return h, m, true +} diff --git a/internal/morning/morning_test.go b/internal/morning/morning_test.go new file mode 100644 index 0000000..5609bff --- /dev/null +++ b/internal/morning/morning_test.go @@ -0,0 +1,184 @@ +package morning + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +func mkRoutine() Routine { + return Routine{ + Name: "weekday_morning", + Weekdays: []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday}, + WindowStart: "08:00", + WindowEnd: "11:00", + Items: []Item{ + {Key: "medicine", FactKey: "medicine", Label: "лекарство"}, + {Key: "water", FactKey: "water", Label: "вода"}, + {Key: "pets", FactKey: "pets", Label: "кот"}, + }, + } +} + +func at(h, m int) time.Time { + return time.Date(2026, 7, 20, h, m, 0, 0, time.UTC) // 2026-07-20 is a Monday +} + +func fact(ts time.Time) store.Fact { return store.Fact{Ts: ts} } + +func TestValidate(t *testing.T) { + r := mkRoutine() + if err := Validate([]Routine{r}); err != nil { + t.Fatalf("valid routine rejected: %v", err) + } + + bad := r + bad.Items = nil + if err := Validate([]Routine{bad}); err == nil { + t.Fatal("expected error for no items") + } + + bad = r + bad.WindowStart = "25:00" + if err := Validate([]Routine{bad}); err == nil { + t.Fatal("expected error for bad window_start") + } + + bad = r + bad.WindowStart, bad.WindowEnd = "11:00", "08:00" + if err := Validate([]Routine{bad}); err == nil { + t.Fatal("expected error for inverted window") + } + + bad = r + bad.Items = append(bad.Items, Item{Key: "medicine", FactKey: "x"}) + if err := Validate([]Routine{bad}); err == nil { + t.Fatal("expected error for duplicate item key") + } +} + +func TestEvaluateInactiveOutsideWindow(t *testing.T) { + r := mkRoutine() + st := Evaluate(r, nil, at(7, 59)) + if st.Active { + t.Fatal("expected inactive before window opens") + } + st = Evaluate(r, nil, at(11, 0)) + if st.Active { + t.Fatal("expected inactive at/after window closes") + } +} + +func TestEvaluateInactiveOnWrongWeekday(t *testing.T) { + r := mkRoutine() // weekdays only + saturday := time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC) + if Evaluate(r, nil, saturday).Active { + t.Fatal("expected inactive on a weekend day not in Weekdays") + } +} + +func TestEvaluateMissingAndCompleted(t *testing.T) { + r := mkRoutine() + facts := map[string]store.Fact{ + "medicine": fact(at(8, 30)), + } + st := Evaluate(r, facts, at(9, 0)) + if !st.Active { + t.Fatal("expected active within window") + } + if len(st.Completed) != 1 || st.Completed[0].Key != "medicine" { + t.Fatalf("expected medicine completed, got %+v", st.Completed) + } + if len(st.Missing) != 2 { + t.Fatalf("expected 2 missing, got %+v", st.Missing) + } +} + +func TestEvaluateEvidenceBeforeWindowDoesNotCount(t *testing.T) { + r := mkRoutine() + facts := map[string]store.Fact{ + "medicine": fact(at(7, 0)), // before window opened today + } + st := Evaluate(r, facts, at(9, 0)) + for _, it := range st.Completed { + if it.Key == "medicine" { + t.Fatal("stale (pre-window) evidence should not count as completion") + } + } +} + +func TestDueFiresOnlyAtNudgeTimeWithMissingItems(t *testing.T) { + r := mkRoutine() // NudgeAt empty -> defaults to WindowEnd (11:00) + facts := map[string]store.Fact{ + "medicine": fact(at(8, 30)), + "water": fact(at(8, 40)), + // pets missing + } + last := map[string]time.Time{} + + if out := Due([]Routine{r}, facts, last, at(9, 0)); len(out) != 0 { + t.Fatalf("expected no candidate before nudge time, got %+v", out) + } + + out := Due([]Routine{r}, facts, last, at(11, 0)) + if len(out) != 1 { + t.Fatalf("expected 1 candidate at nudge time, got %d", len(out)) + } + if len(out[0].Missing) != 1 || out[0].Missing[0].Key != "pets" { + t.Fatalf("expected only pets missing, got %+v", out[0].Missing) + } +} + +func TestDueDoesNotRepeatSameDay(t *testing.T) { + r := mkRoutine() + facts := map[string]store.Fact{} // nothing done + last := map[string]time.Time{} + + if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 1 { + t.Fatalf("expected first nudge to fire, got %d", len(out)) + } + if out := Due([]Routine{r}, facts, last, at(11, 30)); len(out) != 0 { + t.Fatalf("expected no repeat nudge same day, got %d", len(out)) + } +} + +func TestDueFiresAgainNextDay(t *testing.T) { + r := mkRoutine() + facts := map[string]store.Fact{} + last := map[string]time.Time{} + + Due([]Routine{r}, facts, last, at(11, 0)) + + tomorrow := time.Date(2026, 7, 21, 11, 0, 0, 0, time.UTC) // Tuesday + if out := Due([]Routine{r}, facts, last, tomorrow); len(out) != 1 { + t.Fatalf("expected nudge to fire again on a new day, got %d", len(out)) + } +} + +func TestDueSkipsWhenAllItemsComplete(t *testing.T) { + r := mkRoutine() + facts := map[string]store.Fact{ + "medicine": fact(at(8, 30)), + "water": fact(at(8, 40)), + "pets": fact(at(8, 50)), + } + last := map[string]time.Time{} + if out := Due([]Routine{r}, facts, last, at(11, 0)); len(out) != 0 { + t.Fatalf("expected no nudge when all items complete, got %+v", out) + } +} + +func TestDueRespectsExplicitNudgeAt(t *testing.T) { + r := mkRoutine() + r.NudgeAt = "10:00" + facts := map[string]store.Fact{} + last := map[string]time.Time{} + + if out := Due([]Routine{r}, facts, last, at(9, 30)); len(out) != 0 { + t.Fatalf("expected no candidate before explicit nudge_at, got %+v", out) + } + if out := Due([]Routine{r}, facts, last, at(10, 0)); len(out) != 1 { + t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out)) + } +}