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"}} +
| item | status |
|---|---|
| {{.Label}} | +{{if .Done}}done{{else}}missing{{end}} | +