From 6bab68e96d43bc102c66cea2ecf33d4294266a5c Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 22:12:10 +0400 Subject: [PATCH] reminders: add ListReminders IPC + /reminders web page Store layer: ListReminders returns the n most recent reminders (newest first). IPC: new MethodListReminders wired through server, client, and lockedAPI. Web: /reminders page with table of created time, fire time, status badge, and payload text; empty state with prompt to ask maven for a reminder. Sidebar entry under Automation. --- cmd/mavend/main.go | 1 + cmd/mavweb/main.go | 304 ++++++++++++++++++++++++++++++------ cmd/mavweb/reminders.html | 17 ++ internal/ipc/api.go | 1 + internal/ipc/client.go | 8 + internal/ipc/server.go | 38 +++++ internal/ipc/wire.go | 1 + internal/store/reminders.go | 20 +++ 8 files changed, 338 insertions(+), 52 deletions(-) create mode 100644 cmd/mavweb/reminders.html diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 695e615..579c5a4 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -109,6 +109,7 @@ func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time. func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) { return ipc.Presence{}, errLocked } func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { return 0, errLocked } func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error { return errLocked } +func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) { return nil, errLocked } func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { return 0, errLocked } func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { return errLocked } func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { return nil, errLocked } diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 9d8725f..19b2f1c 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -54,33 +54,210 @@ var traceHTML string //go:embed notifications.html var notificationsHTML string -// navHTML — the shared site nav, parsed into every server-rendered template -// alongside ui.css so the pages read as one app. Invoke as -// {{template "nav" ""}}; the argument highlights the current link. -const navHTML = `{{define "nav"}}{{end}}` +//go:embed reminders.html +var remindersHTML string + +// ── Ethos Workstation Shell ── +// +// Two template pieces that wrap every page: +// {{template "shellTop" ""}} ← opens , topbar, sidebar, content +// {{template "shellBottom"}} ← closes content, inspector, +// +// The page-key argument highlights the active sidebar link and sets breadcrumbs. + +// sidebarSections maps sidebar section → page entries {label, url, icon} +var sidebarSections = []struct { + Label string + Pages []struct{ Label, URL, Key string } +}{ + { + Label: "Workspace", + Pages: []struct{ Label, URL, Key string }{ + {Label: "Dashboard", URL: "/dash", Key: "dash"}, + }, + }, + { + Label: "Infrastructure", + Pages: []struct{ Label, URL, Key string }{ + {Label: "History", URL: "/history", Key: "history"}, + }, + }, + { + Label: "Automation", + Pages: []struct{ Label, URL, Key string }{ + {Label: "Rule Trace", URL: "/trace", Key: "trace"}, + {Label: "Notifications", URL: "/notifications", Key: "notifications"}, + {Label: "Reminders", URL: "/reminders", Key: "reminders"}, + }, + }, + { + Label: "AI", + Pages: []struct{ Label, URL, Key string }{ + {Label: "Voice", URL: "/", Key: "voice"}, + }, + }, + { + Label: "Settings", + Pages: []struct{ Label, URL, Key string }{ + {Label: "Tools", URL: "/tools", Key: "tools"}, + {Label: "Passkey", URL: "/auth/passkey", Key: "passkey"}, + }, + }, +} + +func sidebarActive(url, key string, activeKey string) string { + if key == activeKey { + return `class="active"` + } + return "" +} + +// sidebarHTML renders the sidebar navigation given the active page key. +func sidebarHTML(active string) template.HTML { + var b strings.Builder + for _, sec := range sidebarSections { + b.WriteString(``) + } + return template.HTML(b.String()) +} + +// pageIcon returns a lucide-style inline SVG icon path for the given page. +func pageIcon(key string) string { + switch key { + case "dash": + return `` + case "history": + return `` + case "trace": + return `` + case "notifications": + return `` + case "reminders": + return `` + case "voice": + return `` + case "tools": + return `` + case "passkey": + return `` + default: + return `` + } +} + +// pageTitle returns the human-readable page title for the given key. +func pageTitle(key string) string { + switch key { + case "dash": + return "Dashboard" + case "history": + return "History" + case "trace": + return "Rule Trace" + case "notifications": + return "Notifications" + case "reminders": + return "Reminders" + case "voice": + return "Voice" + case "tools": + return "Tools" + case "passkey": + return "Passkey" + default: + return key + } +} + +// shellTopHTML opens the shell and renders the top bar + sidebar. +// Usage: {{template "shellTop" ""}} +const shellTopHTML = `{{define "shellTop"}} + +maven · {{pageTitle .}} + +
+
+ +
+
+ + Search + Ctrl+/ +
+ + + + +
+
+
+ +
+{{end}}` + +// shellBottomHTML closes the content area, inspector, and shell. +// Usage: {{template "shellBottom"}} +const shellBottomHTML = `{{define "shellBottom"}} +
+ +
+
+ +{{end}}` + +// shellFuncs returns the FuncMap shared by every server-rendered page template. +func shellFuncs() template.FuncMap { + return template.FuncMap{ + "pageTitle": pageTitle, + "sidebarHTML": sidebarHTML, + "ago": func(t time.Time) string { + if t.IsZero() { + return "never" + } + return time.Since(t).Round(time.Second).String() + " ago" + }, + "connected": func() bool { return true }, // if page renders, core was available + } +} // dashTmpl — the monitoring read surface, server-rendered from dash.html; // a small fetch loop refreshes the tables in place. html/template escapes the // user text in facts/nudges. Read-only: browses the append-only store via // CoreAPI, never writes — the store IS the audit trail, this just shows it. -var dashTmpl = template.Must(template.New("dash").Funcs(template.FuncMap{ - "ago": func(t time.Time) string { - // A zero timestamp (no presence signal yet, fresh DB) would make - // time.Since saturate to ~292y (MaxInt64) and render as garbage. - if t.IsZero() { - return "never" - } - return time.Since(t).Round(time.Second).String() + " ago" - }, -}).Parse(navHTML + dashHTML)) +var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML)) func noCache(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -158,6 +335,9 @@ func main() { mux.HandleFunc("/notifications", func(w http.ResponseWriter, r *http.Request) { handleNotifications(w, r, core) }) + mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) { + handleReminders(w, r, core) + }) // ----- passkey (WebAuthn) endpoints ----- // Wired when both -core and a configured origin are present. The origin // must match the browser's view of mavweb (e.g. https://maven.kvmx.ru). @@ -367,22 +547,19 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { // toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form // POSTs back to /tools to enable a proposal. html/template escapes tool names + // utterances (they came from voice STT — untrusted text). -var toolsTmpl = template.Must(template.New("tools").Funcs(template.FuncMap{ - "join": strings.Join, -}).Parse(navHTML + toolsHTML)) +var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap { + m := shellFuncs() + m["join"] = strings.Join + return m +}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML)) -const toolsHTML = ` - -maven · tools - -{{template "nav" "tools"}} -
-

tools

-

enabling requires step-up — assert a passkey first.

+const toolsHTML = `{{template "shellTop" "tools"}} +

Tools

+

enabling requires step-up — assert a passkey first.

{{if .Msg}}
{{.Msg}}
{{end}}

proposed {{len .Proposed}}

-{{if .Proposed}}

maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.

+{{if .Proposed}}

maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.

{{range .Proposed}} @@ -394,7 +571,11 @@ const toolsHTML = `{{end}}
namescopefrom utteranceenable as
{{.Name}}{{.Scope}}{{.Utterance}}
-{{else}}

none pending.

{{end}} +{{else}}
+ +
no proposed tools
+
maven will propose tools here when she needs help running an action
+
{{end}}

enabled {{len .Enabled}}

@@ -406,32 +587,33 @@ const toolsHTML = ` {{end}} -{{else}}

none enabled.

{{end}} +{{else}}
+ +
no tools enabled
+
enable proposed tools above, or ask maven to configure one
+
{{end}}
-
-` +{{template "shellBottom"}}` -var historyTmpl = template.Must(template.New("history").Parse(navHTML + historyHTML)) +var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML)) -var notificationsTmpl = template.Must(template.New("notifications").Parse(navHTML + notificationsHTML)) +var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML)) -var passkeyTmpl = template.Must(template.New("passkey").Parse(navHTML + passkeyPageHTML)) +var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML)) -var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{ - "ago": func(t time.Time) string { - if t.IsZero() { - return "never" - } - return time.Since(t).Round(time.Second).String() + " ago" - }, - "fmtTime": func(t *time.Time) string { +var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML)) + +var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap { + m := shellFuncs() + m["fmtTime"] = func(t *time.Time) string { if t == nil || t.IsZero() { return "—" } return t.Format("15:04:05") - }, - "join": strings.Join, -}).Parse(navHTML + traceHTML)) + } + m["join"] = strings.Join + return m +}()).Parse(shellTopHTML + traceHTML + shellBottomHTML)) func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { @@ -471,6 +653,24 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP } } +func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if core == nil { + http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable) + return + } + ctx := r.Context() + reminders, err := core.ListReminders(ctx, 50) + if err != nil { + log.Printf("reminders: %v", err) + http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil { + log.Printf("reminders template: %v", err) + } +} + func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable) diff --git a/cmd/mavweb/reminders.html b/cmd/mavweb/reminders.html new file mode 100644 index 0000000..195efcb --- /dev/null +++ b/cmd/mavweb/reminders.html @@ -0,0 +1,17 @@ +{{template "shellTop" "reminders"}} +

Reminders

+{{if .Reminders}}
+ +{{range .Reminders}} + + + + +{{end}}
createdfiresstatuswhat
{{.CreatedTs.Format "02 Jan 15:04"}}{{.FireTs.Format "02 Jan 15:04"}}{{.Status}}{{.Payload}}
+{{else}}
+ +
no reminders yet
+
ask maven to remind you of something
+
{{end}} +{{template "shellBottom"}} + diff --git a/internal/ipc/api.go b/internal/ipc/api.go index b58cb9e..8f33df5 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -221,6 +221,7 @@ type CoreAPI interface { Presence(ctx context.Context) (Presence, error) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) MarkReminder(ctx context.Context, id int64, status string) error + ListReminders(ctx context.Context, n int) ([]Reminder, error) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 595d6ed..8080be4 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -249,6 +249,14 @@ func (c *Client) MarkReminder(ctx context.Context, id int64, status string) erro return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil) } +func (c *Client) ListReminders(ctx context.Context, n int) ([]Reminder, error) { + var out []Reminder + if err := c.call(ctx, MethodListReminders, nReq{N: n}, &out); err != nil { + return nil, err + } + return out, nil +} + func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { var r idResp if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index f425332..845c28f 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -78,6 +78,18 @@ func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) er return mapErr(a.s.MarkReminder(ctx, id, status)) } +func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) { + rs, err := a.s.ListReminders(ctx, n) + if err != nil { + return nil, mapErr(err) + } + out := make([]Reminder, len(rs)) + for i, r := range rs { + out[i] = toReminder(r) + } + return out, nil +} + func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error { return mapErr(a.s.RescheduleReminder(ctx, id, now)) } @@ -210,6 +222,18 @@ func toTool(t store.Tool) Tool { } } +func toReminder(r store.Reminder) Reminder { + return Reminder{ + ID: r.ID, + CreatedTs: r.CreatedTs, + FireTs: r.FireTs, + NextFireTs: r.NextFireTs, + Payload: r.Payload, + Status: r.Status, + Cron: r.Cron, + } +} + func toNote(n store.Note) Note { return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score} } @@ -516,6 +540,20 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er err := api.MarkReminder(ctx, p.ID, p.Status) return marshalResult(nil), err + case MethodListReminders: + var p nReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := api.ListReminders(ctx, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []Reminder{} + } + return marshalResult(out), nil + case MethodRecordNudge: var p recordNudgeReq if err := unmarshalParams(req.Params, &p); err != nil { diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index db78c2f..c03ded7 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -20,6 +20,7 @@ const ( MethodPresence Method = "presence" MethodCreateReminder Method = "create_reminder" MethodMarkReminder Method = "mark_reminder" + MethodListReminders Method = "list_reminders" MethodRecordNudge Method = "record_nudge" MethodResolveNudge Method = "resolve_nudge" MethodRecentOutcomes Method = "recent_outcomes" diff --git a/internal/store/reminders.go b/internal/store/reminders.go index 3c031e7..2fb4447 100644 --- a/internal/store/reminders.go +++ b/internal/store/reminders.go @@ -115,6 +115,26 @@ func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error return err } +// ListReminders returns the n most recent reminders, newest first. +func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron + FROM reminders ORDER BY created_ts DESC, id DESC LIMIT ?`, n) + if err != nil { + return nil, fmt.Errorf("list reminders: %w", err) + } + defer rows.Close() + var out []Reminder + for rows.Next() { + r, err := scanReminder(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + // RescheduleReminder computes the next fire time for a recurring reminder and // updates next_fire_ts. Returns ErrReminderState if the reminder is not // recurring or not pending. If no more valid fire times exist, marks it fired.