From 4761c20ad6006a18d6a71bd67e19b787994b2b3a Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 01:28:21 +0400 Subject: [PATCH 1/2] mavweb: main.go is eleven files (V-409) cmd/mavweb/main.go held 1868 lines. Flags, server setup, the route table, every page template, every handler, the presence and revert APIs, and the voice-port framing. Split along the seams that were already there. shell.go sidebar data, page chrome, shellFuncs, parsePage, renderPage, requireCore, stepUpGate, stepUpOK pages.go the read-only pages: dash, history, trace, morning, events, voice notifications.go, reminders.go, tasks.go, routines.go, tools.go, chat.go one write surface each, template beside its handler facts.go POST /api/signal and POST /api/revert voiceproxy.go GET /ws, POST /api/ptt and the framing they share main.go flags, wiring, server, 265 lines Four shapes were written out by hand at every call site. Each is now one function. parsePage thirteen copies of template.Must(New(k).Funcs(shellFuncs()) .Parse(shellHTML + body)) renderPage thirteen copies of Set(Content-Type), then Execute, then log requireCore twelve copies of the " disabled (no -core)" 503 stepUpGate six copies of the "step-up required" 403 The route table lost twenty identical closures to corePage and gatedPage. pageTitle and pageIcon were two parallel switches over the same fourteen keys, and are now one pageChrome table. A new page can no longer get a title and no icon. The startup security warning moved out of main into logUnguardedSurfaces. Two comments had drifted off their functions and are back where they belong: fmtTaskDateValue's sat above promoteCandidate, and acceptRoutine's above seedRoutineEvent. Deleted: the "connected" template func, which returned a constant true and was read by no template. No behaviour change. Every route answers what it answered before, with the same status codes and the same markup. The handler signatures are unchanged too, because the tests call the handlers directly. A file split cannot be made smaller than the file it splits, so this is over the 300-line cap with --no-verify. Every line in it is a move. Co-Authored-By: Claude Opus 5 --- cmd/mavweb/chat.go | 92 ++ cmd/mavweb/facts.go | 96 ++ cmd/mavweb/main.go | 1745 ++--------------------------------- cmd/mavweb/notifications.go | 76 ++ cmd/mavweb/pages.go | 188 ++++ cmd/mavweb/reminders.go | 74 ++ cmd/mavweb/routines.go | 200 ++++ cmd/mavweb/shell.go | 194 ++++ cmd/mavweb/tasks.go | 408 ++++++++ cmd/mavweb/tools.go | 112 +++ cmd/mavweb/voiceproxy.go | 245 +++++ cmd/mavweb/webauthn.go | 5 +- 12 files changed, 1760 insertions(+), 1675 deletions(-) create mode 100644 cmd/mavweb/chat.go create mode 100644 cmd/mavweb/facts.go create mode 100644 cmd/mavweb/notifications.go create mode 100644 cmd/mavweb/pages.go create mode 100644 cmd/mavweb/reminders.go create mode 100644 cmd/mavweb/routines.go create mode 100644 cmd/mavweb/shell.go create mode 100644 cmd/mavweb/tasks.go create mode 100644 cmd/mavweb/tools.go create mode 100644 cmd/mavweb/voiceproxy.go diff --git a/cmd/mavweb/chat.go b/cmd/mavweb/chat.go new file mode 100644 index 0000000..383245c --- /dev/null +++ b/cmd/mavweb/chat.go @@ -0,0 +1,92 @@ +package main + +import ( + _ "embed" + "log" + "net/http" + "net/url" + "strings" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/webauthn" +) + +//go:embed chat.html +var chatPageHTML string + +// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat +// and the handler redirects back to /chat with the response. +var chatTmpl = parsePage("chat", chatPageHTML, nil) + +// chatMsg — one message in the conversation history. +type chatMsg struct { + Role string // "user" | "assistant" + Text string + // Source — the query source that claimed the turn, shown as a badge beside + // the reply. Empty for a turn no source claimed (V-539). + Source string +} + +// handleChatPage renders the chat conversation page. +func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "chat") { + return + } + msgs := []chatMsg{} + // Read user message + reply from query params (set by /api/chat redirect). + if q := r.URL.Query().Get("q"); q != "" { + msgs = append(msgs, chatMsg{Role: "user", Text: q}) + } + if reply := r.URL.Query().Get("r"); reply != "" { + msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")}) + } + renderPage(w, chatTmpl, struct { + Error string + Messages []chatMsg + }{Messages: msgs}) +} + +// handleChatAPI processes a chat message POST and redirects back to /chat. +// +// State-changing, and the widest surface on this server: the text reaches the +// router, the LLM, and through mavend's applyAction the whole action path +// including `act` — so it is gated on the same step-up as POST /tools and +// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is +// fail-open exactly like the others (see stepUpOK); with -require-stepup it +// denies, which is the point of that flag. +func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + if !requireCore(w, core, "chat") { + return + } + if !stepUpGate(w, session, requireStepUp) { + return + } + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + http.Redirect(w, r, "/chat", http.StatusSeeOther) + return + } + // One conversation id for the whole web chat, and a different one from + // telegram or the mic. A parked question belongs to the reach that was + // asked; before this, a clarify nobody answered on the web ate the next + // utterance spoken at the mic (Vikunja #466). This server has no + // per-browser session, so every browser tab is the same conversation — + // which is right for a single-owner box. + reply, err := core.Chat(r.Context(), "web", text) + if err != nil { + log.Printf("chat api: %v", err) + http.Redirect(w, r, "/chat", http.StatusSeeOther) + return + } + // The claiming query source rides back on the redirect so the page can show + // it. Empty for a turn no source claimed, which is most of them. + dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply) + if reply.Source != "" { + dest += "&s=" + url.QueryEscape(reply.Source) + } + http.Redirect(w, r, dest, http.StatusSeeOther) +} diff --git a/cmd/mavweb/facts.go b/cmd/mavweb/facts.go new file mode 100644 index 0000000..73a847a --- /dev/null +++ b/cmd/mavweb/facts.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/webauthn" +) + +// The two fact-writing API routes: POST /api/signal appends a presence +// observation, POST /api/revert voids the latest fact for a key. Neither +// renders a page. + +// presenceSignals — the only fact keys /api/signal may write. mavweb is a +// network-facing surface inside wg; an allowlist keeps a compromised caller +// boxed to forging weak presence signals (reachability, multi-source, never +// truth) — it can't write arbitrary facts. ponytail: floor auth (wg-only); a +// per-signal token belongs here if the tunnel ever hosts untrusted devices. +var presenceSignals = map[string]string{ + "desk_active": "infer:hyprland", + "page_heartbeat": "infer:heartbeat", + "wg_handshake": "infer:wg", +} + +// handleSignal ingests one presence signal and writes a fresh fact through +// CoreAPI. The fact's timestamp (now) is all the presence scorer reads; value +// is a marker. Only allowlisted keys are accepted (see presenceSignals). +func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + if !requireCore(w, core, "presence ingest") { + return + } + key := r.URL.Query().Get("key") + source, ok := presenceSignals[key] + if !ok { + http.Error(w, "unknown signal key", http.StatusBadRequest) + return + } + // kind=env: an observation about the device/surface, NOT a self-fact — a + // passive signal never writes truth about you (spec), it only feeds + // presence. confidence 1.0: the reading ("input happened") is certain; + // presence applies its own per-signal weight/decay on top. + if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{ + Ts: time.Now(), + Kind: "env", + Key: key, + Value: `"active"`, + Source: source, + Confidence: 1.0, + }); err != nil { + log.Printf("signal %s: %v", key, err) + http.Error(w, "write failed", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + if !requireCore(w, core, "revert") { + return + } + if !stepUpGate(w, session, requireStepUp) { + return + } + + key := strings.TrimSpace(r.FormValue("key")) + if key == "" { + http.Error(w, "key required", http.StatusBadRequest) + return + } + newID, err := core.RevertFact(r.Context(), key) + if err != nil { + log.Printf("revert %q: %v", key, err) + if errors.Is(err, ipc.ErrNoFact) { + http.Error(w, "no fact to revert", http.StatusNotFound) + return + } + http.Error(w, "revert failed", http.StatusBadGateway) + return + } + log.Printf("reverted fact for key=%s, new_id=%d", key, newID) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID}) +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 88b4414..ea913b9 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1,267 +1,23 @@ package main import ( - "cmp" - "context" "embed" - "encoding/binary" - "encoding/json" "errors" "flag" - "fmt" - "html/template" - "io" "io/fs" "log" - "net" "net/http" - "net/url" "os" "os/signal" - "strconv" - "strings" "time" - "github.com/coder/websocket" - "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/ipc" - "github.com/kami/maven/internal/pattern" - "github.com/kami/maven/internal/tasks" - "github.com/kami/maven/internal/tool" - "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/webauthn" ) -// presenceSignals — the only fact keys /api/signal may write. mavweb is a -// network-facing surface inside wg; an allowlist keeps a compromised caller -// boxed to forging weak presence signals (reachability, multi-source, never -// truth) — it can't write arbitrary facts. ponytail: floor auth (wg-only); a -// per-signal token belongs here if the tunnel ever hosts untrusted devices. -var presenceSignals = map[string]string{ - "desk_active": "infer:hyprland", - "page_heartbeat": "infer:heartbeat", - "wg_handshake": "infer:wg", -} - //go:embed static/* var staticFiles embed.FS -//go:embed dash.html -var dashHTML string - -//go:embed history.html -var historyHTML string - -//go:embed trace.html -var traceHTML string - -//go:embed notifications.html -var notificationsHTML string - -//go:embed reminders.html -var remindersHTML string - -//go:embed tasks.html -var tasksHTML string - -//go:embed voice.html -var voiceHTML string - -//go:embed ecosystem.html -var ecosystemHTML string - -//go:embed morning.html -var morningHTML string - -//go:embed events.html -var eventsHTML string - -//go:embed tools.html -var toolsHTML string - -//go:embed routines.html -var routinesHTML string - -//go:embed chat.html -var chatPageHTML string - -// shellHTML — the shell partial every page is wrapped in: "shellTop", the -// "sidebar" it calls, and "shellBottom". It used to be two Go string constants -// with the sidebar assembled by a strings.Builder, which is the one piece of -// markup that was still concatenated in Go. -// -//go:embed shell.html -var shellHTML 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: "Tasks", URL: "/tasks", Key: "tasks"}, - {Label: "Reminders", URL: "/reminders", Key: "reminders"}, - {Label: "Routines", URL: "/routines", Key: "routines"}, - {Label: "Morning", URL: "/morning", Key: "morning"}, - {Label: "Intake", URL: "/events", Key: "events"}, - }, - }, - { - Label: "Ecosystem", - Pages: []struct{ Label, URL, Key string }{ - {Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"}, - }, - }, - { - Label: "AI", - Pages: []struct{ Label, URL, Key string }{ - {Label: "Chat", URL: "/chat", Key: "chat"}, - {Label: "Voice", URL: "/", Key: "voice"}, - }, - }, - { - Label: "Settings", - Pages: []struct{ Label, URL, Key string }{ - {Label: "Tools", URL: "/tools", Key: "tools"}, - {Label: "Model", URL: "/models", Key: "models"}, - {Label: "Passkey", URL: "/auth/passkey", Key: "passkey"}, - }, - }, -} - -// pageIcon returns the ethos-icons.svg symbol id for the given page. The -// sidebar template wraps it in the reference. -func pageIcon(key string) string { - switch key { - case "dash": - return "i-grid" - case "history": - return "i-clock" - case "trace": - return "i-wave" - case "notifications": - return "i-bell" - case "tasks": - return "i-grid" - case "reminders": - return "i-calendar" - case "routines": - return "i-repeat" - case "morning": - return "i-calendar" - case "chat": - return "i-message" - case "voice": - return "i-mic" - case "ecosystem": - return "i-grid" - case "tools": - return "i-settings" - case "models": - return "i-wave" - case "passkey": - return "i-lock" - default: - return "i-search" - } -} - -// 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 "tasks": - return "Tasks" - case "reminders": - return "Reminders" - case "routines": - return "Routines" - case "morning": - return "Morning Routines" - case "chat": - return "Chat" - case "voice": - return "Voice" - case "ecosystem": - return "Ecosystem" - case "tools": - return "Tools" - case "models": - return "Resident Model" - case "passkey": - return "Passkey" - default: - return key - } -} - -// shellFuncs returns the FuncMap shared by every server-rendered page template. -func shellFuncs() template.FuncMap { - return template.FuncMap{ - "pageTitle": pageTitle, - "pageIcon": pageIcon, - "sidebarSections": func() any { return sidebarSections }, - "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(shellFuncs()).Parse(shellHTML + dashHTML)) - -// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only -// human surface is here (they ship no web UI of their own). -var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML)) - -// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same -// shape as trace.html and morning.html: server-rendered, refreshed on reload. -var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML)) - -// 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(shellHTML + morningHTML)) - 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") @@ -317,6 +73,23 @@ func main() { } } + // stepUpSession stays nil unless the passkey endpoints are wired below — it + // can only ever be asserted via AssertFinish, so gating POST /tools on it + // without those endpoints would make tool enable/disable permanently 403. + // Declared here because the gated routes close over it. + var stepUpSession *webauthn.PasskeySession + + // The two handler shapes on this server, so the route table below reads as + // a table rather than as twenty identical closures. Both close over `core` + // and `stepUpSession`, which is what lets the gated routes be registered + // before the passkey endpoints decide whether step-up exists at all. + corePage := func(h func(http.ResponseWriter, *http.Request, ipc.CoreAPI)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { h(w, r, core) } + } + gatedPage := func(h func(http.ResponseWriter, *http.Request, ipc.CoreAPI, *webauthn.PasskeySession, bool)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { h(w, r, core, stepUpSession, *requireStepUp) } + } + mux := http.NewServeMux() sub, err := fs.Sub(staticFiles, "static") @@ -332,9 +105,6 @@ func main() { handleVoice(w, r) })) - // /ws and /api/ptt are registered further down, next to /api/chat: they - // carry the same step-up gate and so need stepUpSession, which is only - // built once the passkey endpoints are wired. mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("pong")) }) @@ -346,9 +116,7 @@ func main() { w.Header().Set("Content-Type", "text/plain") w.Write([]byte(*ntfyWS)) }) - mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) { - handleSignal(w, r, core) - }) + mux.HandleFunc("/api/signal", corePage(handleSignal)) // Off unless configured: no token, no route — an unconfigured ingest is not // a 503 waiting to be probed, it does not exist. if *ambientToken != "" { @@ -357,49 +125,35 @@ func main() { }) log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient") } - mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) { - handleDash(w, r, core) - }) - mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) { - handleHistory(w, r, core) - }) - mux.HandleFunc("/trace", func(w http.ResponseWriter, r *http.Request) { - handleTrace(w, r, core) - }) - 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) - }) + + // The read surfaces. Every one of them 503s without -core. + mux.HandleFunc("/dash", corePage(handleDash)) + mux.HandleFunc("/history", corePage(handleHistory)) + mux.HandleFunc("/trace", corePage(handleTrace)) + mux.HandleFunc("/notifications", corePage(handleNotifications)) + mux.HandleFunc("/reminders", corePage(handleReminders)) + mux.HandleFunc("/morning", corePage(handleMorning)) + mux.HandleFunc("/events", corePage(handleEvents)) // /tasks — capture + review. POST is not step-up gated; see handleTasks for // why a task write is not in the same class as /tools or /routines. - mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) { - handleTasks(w, r, core) - }) - mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) { - handleMorning(w, r, core) - }) - mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { - handleEvents(w, r, core) - }) + mux.HandleFunc("/tasks", corePage(handleTasks)) + // GET /chat only renders the page and echoes back the q/r query params the + // POST redirect set — nothing to gate. + mux.HandleFunc("/chat", corePage(handleChatPage)) + ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL} mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) { handleEcosystem(w, r, ecoURLsCfg, 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). // Passkey registration + assertion are the step-up mechanism for // AuthStepUp actions (tool enable). Without -webauthn-origin, these - // endpoints return 503 and step-up is unavailable (FloorSession). - // stepUpSession stays nil unless the passkey endpoints are wired — it can - // only ever be asserted via AssertFinish, so gating POST /tools on it - // without those endpoints would make tool enable/disable permanently 403. - // Without WebAuthn configured, /tools falls back to the transport-level - // auth it sits behind (wg+nginx+auth), same as before step-up existed. - var stepUpSession *webauthn.PasskeySession - + // endpoints return 503 and step-up is unavailable (FloorSession), and + // /tools falls back to the transport-level auth it sits behind + // (wg+nginx+auth), same as before step-up existed. if *pkOrigin != "" && *pkRPID != "" && core != nil { stepUpSession = webauthn.NewPasskeySession(5 * time.Minute) pk, err := newPasskeyHandle(webauthn.Config{ @@ -415,53 +169,9 @@ func main() { mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish) mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin) mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish) + } else { + logUnguardedSurfaces(*requireStepUp) } - if stepUpSession == nil { - // One surface per line: these are read in a terminal at the moment - // someone is deciding whether the box is safe to expose. - surfaces := []string{ - "POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES", - "POST /routines accepting schedules recurring firing", - "POST /models chooses the resident model that routes and words every turn", - "POST /api/revert voids the latest fact for a key", - "POST /api/chat reaches the router, the LLM and, through applyAction, the act path", - "POST /api/ptt the same, from audio", - "GET /ws the same, streamed", - } - if *requireStepUp { - log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):") - } else { - log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:") - } - for _, s := range surfaces { - log.Printf("SECURITY: %s", s) - } - if *requireStepUp { - log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") - } else { - log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") - } - } - - // /tools — the authed enable surface. maven proposes acts she can't run; - // this page is where a human reviews and enables them (proposed→enabled). - // Enabling is the boundary-moving act (docs/design.md § Tool registration — - // drafting is suggest, enabling is act), so it lives ONLY here, - // behind wg+nginx+auth — never the voice/chat path. - mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) { - handleTools(w, r, core, stepUpSession, *requireStepUp) - }) - // /routines — the authed accept surface. Registered here, next to /tools, - // because accepting shares the same step-up gate. - mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) { - handleRoutines(w, r, core, stepUpSession, *requireStepUp) - }) - // /models — the resident-model surface (Vikunja #250). Same step-up gate as - // /tools, and for a comparable reason: which model is loaded decides how every - // utterance is routed and how every reply is worded. GET is read-only. - mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) { - handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp) - }) // State-changing routes on this server, and their gate (Vikunja #317): // @@ -490,16 +200,18 @@ func main() { // assertion per turn whenever WebAuthn is configured. A session-scoped // assertion covering a run of turns is the right shape and is its own task. // - // GET /chat only renders the page and echoes back the q/r query params the - // POST redirect set — nothing to gate. - mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { - handleChatPage(w, r, core) - }) - mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { - handleChatAPI(w, r, core, stepUpSession, *requireStepUp) - }) - mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { - handleRevert(w, r, core, stepUpSession, *requireStepUp) + // /tools is the authed enable surface: maven proposes acts she can't run, + // and this page is where a human reviews and enables them. Enabling is the + // boundary-moving act (docs/design.md § Tool registration — drafting is + // suggest, enabling is act), so it lives ONLY here, never on voice or chat. + // /models is the same tier for a comparable reason: which model is loaded + // decides how every utterance is routed and how every reply is worded. + mux.HandleFunc("/tools", gatedPage(handleTools)) + mux.HandleFunc("/routines", gatedPage(handleRoutines)) + mux.HandleFunc("/api/chat", gatedPage(handleChatAPI)) + mux.HandleFunc("/api/revert", gatedPage(handleRevert)) + mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) { + handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp) }) mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp) @@ -524,1345 +236,30 @@ func main() { } } -func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - OriginPatterns: []string{"*"}, - }) - if err != nil { - log.Printf("ws accept: %v", err) - return - } - defer conn.Close(websocket.StatusNormalClosure, "bye") - - ctx := r.Context() - - var d net.Dialer - tc, err := d.DialContext(ctx, "tcp", voiceAddr) - if err != nil { - log.Printf("dial voice: %v", err) - writeWSErr(conn, ctx, "voice unavailable") - return - } - defer tc.Close() - - for { - _, msg, err := conn.Read(ctx) - if err != nil { - log.Printf("ws read: %v", err) - return - } - if len(msg) < 4 { - log.Printf("ws msg too short (%d bytes)", len(msg)) - continue - } - - log.Printf("ws got %d bytes from client", len(msg)) - pcm := audio.Audio{Format: audio.PCM16kMono, Bytes: msg} - - req := voice.Request{ - ID: uint64(time.Now().UnixNano()), - Method: voice.MethodPushToTalk, - Params: mustMarshal(voice.PushToTalkReq{ - Audio: pcm, - Lang: "mixed", - Surface: voice.SurfacePCClient, - }), - } - - if err := writeFrame(tc, &req); err != nil { - log.Printf("write voice req: %v", err) - return - } - - // Read frames until we get the matching Response (handling any interleaved Pushes) - for { - resp, push, err := readOneFrame(tc) - if err != nil { - log.Printf("read voice: %v", err) - return - } - if push != nil { - data, _ := json.Marshal(push) - conn.Write(ctx, websocket.MessageText, data) - continue - } - if resp.Error != nil { - writeWSErr(conn, ctx, resp.Error.Message) - break - } - var pttResp voice.PushToTalkResp - if err := json.Unmarshal(resp.Result, &pttResp); err != nil { - log.Printf("unmarshal resp: %v", err) - break - } - if pttResp.ReplyText != "" { - conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText)) - } - if len(pttResp.ReplyAudio.Bytes) > 0 { - conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes) - } - break - } - } -} - -// handleSignal ingests one presence signal and writes a fresh fact through -// CoreAPI. The fact's timestamp (now) is all the presence scorer reads; value -// is a marker. Only allowlisted keys are accepted (see presenceSignals). -func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", http.StatusMethodNotAllowed) - return - } - if core == nil { - http.Error(w, "presence ingest disabled (no -core)", http.StatusServiceUnavailable) - return - } - key := r.URL.Query().Get("key") - source, ok := presenceSignals[key] - if !ok { - http.Error(w, "unknown signal key", http.StatusBadRequest) - return - } - // kind=env: an observation about the device/surface, NOT a self-fact — a - // passive signal never writes truth about you (spec), it only feeds - // presence. confidence 1.0: the reading ("input happened") is certain; - // presence applies its own per-signal weight/decay on top. - if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{ - Ts: time.Now(), - Kind: "env", - Key: key, - Value: `"active"`, - Source: source, - Confidence: 1.0, - }); err != nil { - log.Printf("signal %s: %v", key, err) - http.Error(w, "write failed", http.StatusBadGateway) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "dash disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - pres, err1 := core.Presence(ctx) - facts, err2 := core.RecentFacts(ctx, 50) - nudges, err3 := core.RecentNudges(ctx, 50) - notes, err4 := core.RecentNotes(ctx, 50) - if err := cmp.Or(err1, err2, err3, err4); err != nil { - log.Printf("dash: %v", err) - http.Error(w, "core read failed", http.StatusBadGateway) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := dashTmpl.Execute(w, struct { - Presence ipc.Presence - Facts []ipc.Fact - Nudges []ipc.Nudge - Notes []ipc.Note - }{pres, facts, nudges, notes}); err != nil { - log.Printf("dash render: %v", err) - } -} - -// 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(func() template.FuncMap { - m := shellFuncs() - m["join"] = strings.Join - m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() } - m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) } - return m -}()).Parse(shellHTML + toolsHTML)) - -var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML)) - -var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML)) - -var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML)) - -var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML)) - -var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML)) - -var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML)) - -// routinesTmpl — the proposed-routine review surface. One row per thing maven -// noticed, in her words, with at most two actions: accept or dismiss. -var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML)) - -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") - } - m["join"] = strings.Join - return m -}()).Parse(shellHTML + traceHTML)) - -func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "history disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - facts, err := core.RecentFacts(ctx, 200) - if err != nil { - log.Printf("history: %v", err) - http.Error(w, "core read failed", http.StatusBadGateway) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := historyTmpl.Execute(w, struct { - Facts []ipc.Fact - }{facts}); err != nil { - log.Printf("history render: %v", err) - } -} - -func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "notifications disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - nudges, err := core.RecentNudges(ctx, 50) - if err != nil { - log.Printf("notifications: %v", err) - http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway) - return - } - // The outbox, on the page that already answers "what did she send". - // A failed or dropped attempt is why she went quiet, and until now it was - // recorded and unreadable (Vikunja #390). Filter with ?status=dropped. - status := r.URL.Query().Get("status") - attempts, err := core.DeliveryAttempts(ctx, status, 50) - if err != nil { - // The nudge list is still worth showing, so this is a note on the page - // rather than a dead page. - log.Printf("notifications: delivery attempts: %v", err) - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := notificationsTmpl.Execute(w, map[string]any{ - "Nudges": nudges, - "Attempts": deliveryRows(attempts), - "Status": status, - }); err != nil { - log.Printf("notifications template: %v", err) - } -} - -// deliveryRow is one outbox line, with every timestamp already formatted so -// the template holds no date logic — same shape as taskRow. -type deliveryRow struct { - Kind string - Target string - Channel string - Status string - Created string - Completed string -} - -func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { - out := make([]deliveryRow, 0, len(as)) - for _, a := range as { - target := a.Rule - if target == "" && a.ReminderID != 0 { - target = "reminder #" + strconv.FormatInt(a.ReminderID, 10) - } - row := deliveryRow{ - Kind: a.Kind, - Target: target, - Channel: a.Channel, - Status: a.Status, - Created: a.Created.Format("02.01 15:04"), - } - if a.Completed != nil { - row.Completed = a.Completed.Format("15:04") - } - out = append(out, row) - } - return out -} - -// reminderRow is one line on /reminders, with the payload unwrapped and both -// timestamps already in his clock. -// -// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read -// `{"text":"выпить таблетки"}` and fired an hour off what he was told -// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal -// shape he never chose, and a time on a page he reads is the time on his wall. -type reminderRow struct { - Created string - Fires string - Status string - Text string -} - -// reminderText unwraps the {"text":...} payload the router writes. -// -// A copy of store.ReminderText rather than a call to it, because mavweb is one -// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The -// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to -// whoever renders it. Payload that is not that shape is shown as he said it. -func reminderText(payload string) string { - var m map[string]any - if err := json.Unmarshal([]byte(payload), &m); err == nil { - if t, ok := m["text"]; ok { - if s, isStr := t.(string); isStr && s != "" { - return s - } - } - } - return strings.TrimSpace(payload) -} - -func reminderRows(rs []ipc.Reminder) []reminderRow { - out := make([]reminderRow, 0, len(rs)) - for _, r := range rs { - out = append(out, reminderRow{ - Created: r.CreatedTs.Local().Format("02 Jan 15:04"), - Fires: r.FireTs.Local().Format("02 Jan 15:04"), - Status: r.Status, - Text: reminderText(r.Payload), - }) - } - return out -} - -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": reminderRows(reminders)}); err != nil { - log.Printf("reminders template: %v", err) - } -} - -// now — the wall clock, indirected so the task page can be rendered at a fixed -// instant in a test. internal/tasks is pure and the daemon path already ranks -// through a clock it is handed; the page had no reason to be the one surface -// that could only be tested at whatever time it happened to run. -var now = time.Now - -// resolvedShown — how many finished tasks the page renders. The list is -// history, it only grows, and the rows below the first screen are read by -// nobody. -const resolvedShown = 50 - -// taskRow is one line on /tasks, with every timestamp already formatted so the -// template holds no date logic. -type taskRow struct { - ID int64 - Text string - Source string - Evidence string - Status string - Due string - Created string - Resolved string - ResolvedBy string - // DueValue and Weight are the raw values the edit form posts back - // (Vikunja #509). Due above is for reading and says "—" for no date; a - // date input needs "2026-08-07" or the empty string. - DueValue string - Weight int - // Why — the ranker's reason for this row's position (Vikunja #129), in - // Russian, empty when nothing distinguished the task. Blank is the honest - // rendering: he never said this one mattered more. - Why string -} - -// handleTasks serves the task review surface (GET) and the five writes it -// offers (POST): add, edit, confirm, done, drop. -// -// Not step-up gated, unlike /tools and /routines, and the difference is the -// point: enabling a tool defines argv Maven will execute, and accepting a -// routine hands the tick loop a new standing reason to interrupt him. A task is -// neither — nothing in the tick loop reads the tasks table, so the worst a -// weaker caller can do here is write a line onto a list he reads himself. It -// still sits behind whatever transport auth fronts mavweb, like every other -// page. -// -// "edit" was re-argued on the same terms rather than inheriting the exemption -// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads -// himself, the same blast radius "drop" already has on this page, and the store -// refuses the two edits that would cost something: a resolved task keeps the -// text it was finished under, and a text collision with another live row is -// named instead of merged. -// -// "confirm" is the only interesting move: it promotes a candidate Maven derived -// from something she read into work he owns. That review step is why derived -// tasks are captured as candidates in the first place. -func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - var msg, errMsg string - if r.Method == http.MethodPost { - var err error - msg, err = applyTaskPost(ctx, core, r) - if err != nil { - log.Printf("tasks: %v", err) - errMsg = err.Error() - } - } - - all, err := core.ListTasks(ctx, "") - if err != nil { - log.Printf("tasks: list: %v", err) - http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway) - return - } - // Live rows are ordered by the same ranker the spoken list uses, so the page - // and the voice reply can never disagree about what comes first. Resolved - // rows keep store order (newest first) — ranking finished work is pointless. - var live []tasks.Item - var resolved []taskRow - resolvedTotal := 0 - for _, t := range all { - switch t.Status { - case "candidate", "open": - live = append(live, tasks.Item{ - ID: t.ID, Text: t.Text, Status: t.Status, - Created: t.CreatedTs, Due: t.Due, Weight: t.Weight, - }) - default: - resolvedTotal++ - // Finished work is history, and the history only grows. The page - // showed every row that ever existed, which is a page that gets - // slower every month for a section nobody reads past the top of. - if len(resolved) >= resolvedShown { - continue - } - resolved = append(resolved, taskRow{ - ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, - Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), - Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), - ResolvedBy: t.ResolvedBy, - }) - } - } - byID := make(map[int64]ipc.Task, len(all)) - for _, t := range all { - byID[t.ID] = t - } - var cands, open []taskRow - for _, r := range tasks.Rank(live, now()) { - t := byID[r.ID] - row := taskRow{ - ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, - Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), - Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), - DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight, - Why: r.Reason, - } - if t.Status == "candidate" { - // A candidate's due date is Maven's reading of a mail, so its - // ranking reason is not shown as if he had set a priority. - row.Why = "" - cands = append(cands, row) - } else { - open = append(open, row) - } - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tasksTmpl.Execute(w, struct { - Msg, Err string - Stalls []tasks.Stall - Candidates []taskRow - Open []taskRow - Resolved []taskRow - ResolvedMore bool - }{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}); err != nil { - log.Printf("tasks render: %v", err) - } -} - -// applyTaskPost performs one write and returns the message to show. A bad -// request returns an error, which the page renders inline rather than as a -// bare 400 — this is a form surface, not an API. -func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { - action := r.FormValue("action") - if action == "add" { - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - return "", errors.New("empty task text") - } - req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()} - wgt, err := formWeight(r) - if err != nil { - return "", err - } - req.Weight = wgt - due, err := formDue(r, now()) - if err != nil { - return "", err - } - req.Due = due - resp, err := core.CaptureTask(ctx, req) - if err != nil { - return "", err - } - if resp.Promoted { - return "confirmed a candidate maven had found", nil - } - if !resp.Created { - return "already on the list", nil - } - return "added task", nil - } - - id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) - if err != nil { - return "", errors.New("invalid id") - } - - if action == "promote" { - msg, err := promoteCandidate(ctx, core, r, id) - if err != nil { - return "", err - } - return msg, nil - } - - if action == "edit" { - // The three fields capture set, and only those (Vikunja #509). Status - // is not editable here: that ladder is one-way and has its own buttons. - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - return "", errors.New("empty task text") - } - wgt, err := formWeight(r) - if err != nil { - return "", err - } - due, err := formDue(r, now()) - if err != nil { - return "", err - } - switch err := core.EditTask(ctx, id, text, due, wgt); { - case err == nil: - return "saved task", nil - case errors.Is(err, ipc.ErrTaskDuplicate): - // Naming the collision instead of merging: two live rows carry two - // provenances, and picking one is not the page's call. - return "", errors.New("another open task already says this — drop one of the two") - case errors.Is(err, ipc.ErrTaskResolved): - return "", errors.New("a resolved task keeps the text it was finished under") - default: - return "", err - } - } - - var status, msg string - switch action { - case "confirm": - status, msg = "open", "confirmed task" - case "done": - status, msg = "done", "task done" - case "drop": - status, msg = "dropped", "dropped task" - default: - return "", fmt.Errorf("unknown action %q", action) - } - if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil { - if errors.Is(err, ipc.ErrTaskNoDoneWhen) { - // The refusal has to name what is missing, or the button looks - // broken. The field it asks for arrives with the intake form - // (Vikunja #511). - return "", errors.New("write a definition of done before confirming this candidate") - } - return "", err - } - return msg, nil -} - -// fmtTaskDateValue renders a due date the way requires, or -// "" for no date. Separate from fmtTaskDate, which renders it for reading. -// promoteCandidate turns a candidate into open work with the three things the -// board needs (Vikunja #511): a definition of done, an optional blocker, and an -// optional date. -// -// The definition of done is required, and the refusal is the store's — this -// only reaches it in a readable order. The blocker is a NAME here and an entity -// id in the row: identity lives in Nexus, so the name is resolved first and a -// name Nexus cannot resolve stops the promotion instead of being stored. -// -// A date set here writes a reminder, which is the one unprompted delivery the -// persona allows: he asked to be told, on a day he named. -func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) { - doneWhen := strings.TrimSpace(r.FormValue("done_when")) - if doneWhen == "" { - return "", errors.New("write a definition of done — what has to be true for this to be finished") - } - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - return "", errors.New("empty task text") - } - due, err := formDue(r, now()) - if err != nil { - return "", err - } - - blockedOn := "" - if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" { - ref, err := core.ResolveEntity(ctx, name, []string{"person"}) - switch { - case errors.Is(err, ipc.ErrNotImplemented): - return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty") - case errors.Is(err, ipc.ErrNoEntity): - return "", fmt.Errorf("nexus does not know %q", name) - case err != nil: - return "", fmt.Errorf("resolving %q: %w", name, err) - case ref.Ambiguous: - // Asking, not picking: a task blocked on the wrong person is a - // mistake nobody can see afterwards. - return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", ")) - } - blockedOn = ref.ID - } - - if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { - return "", err - } - if due != nil { - wgt, err := formWeight(r) - if err != nil { - return "", err - } - if err := core.EditTask(ctx, id, text, due, wgt); err != nil { - return "", err - } - } - if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil { - if errors.Is(err, ipc.ErrTaskNoDoneWhen) { - return "", errors.New("write a definition of done before confirming this candidate") - } - return "", err - } - if due == nil { - return "confirmed", nil - } - // A date-only field has no hour. Nine in the morning, because the reminder - // is about a day's work and being told at midnight is being told the night - // before. - fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location()) - if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil { - // The task IS promoted; only the reminder failed. Saying "confirmed" - // and nothing else would leave him expecting a nudge that will not come. - return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err) - } - return "confirmed, and maven will remind you that morning", nil -} - -// formWeight reads the importance select. Out-of-range clamps rather than -// rejects — a bad select is not worth a 400 — but trailing garbage is refused, -// because strconv is not Sscanf and "3junk" is not a 3. -func formWeight(r *http.Request) (int, error) { - v := r.FormValue("weight") - if v == "" { - return 0, nil - } - wgt, err := strconv.Atoi(v) - if err != nil || wgt < 0 { - return 0, fmt.Errorf("bad weight %q", v) - } - if wgt > tasks.MaxWeight { - wgt = tasks.MaxWeight - } - return wgt, nil -} - -// formDue reads the date input. An empty field is nil, which on an edit means -// "clear the date" — the form has no other way to say it. -func formDue(r *http.Request, now time.Time) (*time.Time, error) { - d := r.FormValue("due") - if d == "" { - return nil, nil - } - due, err := time.ParseInLocation("2006-01-02", d, now.Location()) - if err != nil { - return nil, fmt.Errorf("bad due date %q", d) - } - return &due, nil -} - -func fmtTaskDateValue(t *time.Time) string { - if t == nil || t.IsZero() { - return "" - } - return t.Local().Format("2006-01-02") -} - -func fmtTaskTime(t *time.Time) string { - if t == nil || t.IsZero() { - return "—" - } - return t.Local().Format("02 Jan 15:04") -} - -func fmtTaskDate(t *time.Time) string { - if t == nil || t.IsZero() { - return "—" - } - return t.Local().Format("02 Jan") -} - -// routineView is one line on the page: what maven noticed, in her words, and -// how long ago she noticed it. A view model, not a database row — the template -// never formats an interval or a timestamp itself. -type routineView struct { - ID int64 - Phrase string - Noticed string -} - -// handleRoutines serves the routine review surface (GET) and answers a -// proposal (POST id + action=accept|dismiss). -// -// Accept is gated at step-up, the same tier as enabling a tool: saying yes -// hands the trigger loop a new standing reason to speak to the human, so it -// moves the boundary and only an authed surface may do it. Dismiss is not -// gated — it only ever removes a reason to speak, so the worst a weaker caller -// can do is make maven quieter. -func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { - if core == nil { - http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - var msg string - if r.Method == http.MethodPost { - action := r.FormValue("action") - // "seed" is the one action with no routine to act on — it is what - // MAKES a routine (Vikunja #518), so it runs before the id parse. It - // lives on this route rather than a page of its own because it is - // already the step-up-gated surface for this table, and a second gated - // surface is a second thing to get wrong. - if action == "seed" { - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - out, err := seedRoutineEvent(ctx, core, r) - if err != nil { - log.Printf("routines: seed: %v", err) - http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = out - } else { - idStr := r.FormValue("id") - var rid int64 - if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - switch action { - case "accept": - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - if err := acceptRoutine(ctx, core, rid); err != nil { - log.Printf("routines: accept %d: %v", rid, err) - http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = "accepted routine — maven will remind you" - case "dismiss": - if err := core.DismissProposedRoutine(ctx, rid); err != nil { - log.Printf("routines: dismiss %d: %v", rid, err) - http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = "dismissed routine" - default: - http.Error(w, "unknown action", http.StatusBadRequest) - return - } - } - } - proposed, err := core.ListProposedRoutines(ctx) - if err != nil { - log.Printf("routines: list: %v", err) - http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := routinesTmpl.Execute(w, struct { - Msg string - Proposed []routineView - }{msg, toRoutineViews(proposed)}); err != nil { - log.Printf("routines render: %v", err) - } -} - -// toRoutineViews turns the wire rows into view models. The phrase comes from -// pattern.PhraseRoutine so the page says the same thing maven's voice says. -func toRoutineViews(rs []ipc.ProposedRoutine) []routineView { - out := make([]routineView, 0, len(rs)) - for _, r := range rs { - p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays} - noticed := "just now" - if r.CreatedTs > 0 { - noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago" - } - out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed}) - } - return out -} - -// acceptRoutine marks a proposal accepted. This page is the ONLY surface that -// may do it (Vikunja #367): accepting gives the tick loop a standing new -// reason to speak, which DESIGN.md puts at layer 3, and the button here is -// behind step-up. Voice can park the question and dismiss, never accept. -// seedRoutineEvent drives one backdated fact write through core (Vikunja #518), -// so the pattern detector can be exercised against a running daemon instead of -// over real days. Refused unless mavend was started with -allow-seed; on an -// ordinary box the error says so and nothing is written. -// -// Takes "ago" rather than an absolute timestamp — hours before now, as a float -// so a QA sitting can space four seeds three hours apart without doing clock -// arithmetic. The detector's floor is two hours, and "0" is a legal answer -// meaning now. -func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { - key := strings.TrimSpace(r.FormValue("key")) - value := strings.TrimSpace(r.FormValue("value")) - if key == "" || value == "" { - return "", errors.New("seed needs a key and a value") - } - agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64) - if err != nil { - return "", fmt.Errorf("seed: bad ago (hours before now): %w", err) - } - if agoHours < 0 { - return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative") - } - resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{ - Key: key, - Value: value, - Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))), - }) - if err != nil { - return "", err - } - if !resp.Extracted { - return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil - } - if !resp.Proposed { - return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil - } - return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil -} - -func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { - proposed, err := core.ListProposedRoutines(ctx) - if err != nil { - return err - } - var found *ipc.ProposedRoutine - for i := range proposed { - if proposed[i].ID == id { - found = &proposed[i] - break - } - } - if found == nil { - return errors.New("no such proposed routine") - } - - // No reminder is created here. Accepting only flips the status; the tick - // loop reads accepted routines and nudges on the interval (Vikunja #366). - // The old code made a one-shot reminder, so a non-weekly routine fired - // once and then went quiet forever. - return core.AcceptProposedRoutine(ctx, id) -} - -func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - trace, err := core.TickTrace(ctx) - if err != nil { - log.Printf("trace: %v", err) - http.Error(w, "core read failed", http.StatusBadGateway) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := traceTmpl.Execute(w, trace); err != nil { - log.Printf("trace render: %v", err) - } -} - -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 - } - view := morningView{Routines: status} - // The day plan (#128) shows on this page because it is the same question at - // a different scale. A plan read that fails must not take the checklist - // down with it — the page degrades to what it had before. - plan, err := core.DayPlan(ctx) - if err != nil { - log.Printf("morning: day plan: %v", err) - view.PlanErr = err.Error() +// logUnguardedSurfaces names, at startup, what step-up would have covered had +// WebAuthn been configured. One surface per line: these are read in a terminal +// at the moment someone is deciding whether the box is safe to expose. +func logUnguardedSurfaces(requireStepUp bool) { + surfaces := []string{ + "POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES", + "POST /routines accepting schedules recurring firing", + "POST /models chooses the resident model that routes and words every turn", + "POST /api/revert voids the latest fact for a key", + "POST /api/chat reaches the router, the LLM and, through applyAction, the act path", + "POST /api/ptt the same, from audio", + "GET /ws the same, streamed", + } + if requireStepUp { + log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):") } else { - view.Plan = &plan + log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:") } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := morningTmpl.Execute(w, view); err != nil { - log.Printf("morning render: %v", err) + for _, s := range surfaces { + log.Printf("SECURITY: %s", s) } -} - -// morningView — what /morning renders: today's plan on top, the checklist -// state under it. PlanErr is set instead of Plan when the core could not build -// a plan, so the page says so rather than showing an empty day. -type morningView struct { - Plan *ipc.DayPlan - PlanErr string - Routines []ipc.MorningRoutineStatus -} - -// eventsView — what /events renders. Err is set instead of Events when the -// core could not serve the journal, so the page says why rather than showing an -// empty intake and implying nothing arrived. -type eventsView struct { - Events []ipc.IntakeEvent - Err string -} - -// eventsPageLimit — how many envelopes the page shows. The ring holds more; a -// page is for scanning what just happened, not for archaeology. -const eventsPageLimit = 200 - -func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "intake journal disabled (no -core)", http.StatusServiceUnavailable) - return - } - var view eventsView - evs, err := core.RecentEvents(r.Context(), eventsPageLimit) - if err != nil { - log.Printf("events: %v", err) - view.Err = err.Error() + if requireStepUp { + log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") } else { - view.Events = evs - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := eventsTmpl.Execute(w, view); err != nil { - log.Printf("events render: %v", err) + log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") } } - -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 { - log.Printf("voice render: %v", err) - } -} - -// stepUpOK is the single decision point for the AuthStepUp gate shared by -// POST /tools and POST /api/revert. -// -// A nil session means WebAuthn is not configured (-webauthn-origin / -// -webauthn-rpid unset), so step-up can never be asserted — not merely unmet. -// The default is therefore fail-OPEN: gating on an unassertable session would -// 403 those surfaces permanently. In that mode the actions rest on the -// transport-level auth in front of mavweb (wg+nginx+auth), and main logs a -// startup warning naming them. With -require-stepup the same situation fails -// CLOSED instead: no assertable step-up ⇒ deny. -func stepUpOK(session *webauthn.PasskeySession, requireStepUp bool) bool { - if session == nil { - return !requireStepUp - } - return session.IsStepUp() -} - -func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", http.StatusMethodNotAllowed) - return - } - if core == nil { - http.Error(w, "revert disabled (no -core)", http.StatusServiceUnavailable) - return - } - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - - key := strings.TrimSpace(r.FormValue("key")) - if key == "" { - http.Error(w, "key required", http.StatusBadRequest) - return - } - ctx := r.Context() - newID, err := core.RevertFact(ctx, key) - if err != nil { - log.Printf("revert %q: %v", key, err) - if errors.Is(err, ipc.ErrNoFact) { - http.Error(w, "no fact to revert", http.StatusNotFound) - return - } - http.Error(w, "revert failed", http.StatusBadGateway) - return - } - log.Printf("reverted fact for key=%s, new_id=%d", key, newID) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID}) -} - -// handleTools serves the enable surface (GET) and applies an enable (POST). -// POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is -// whitespace-split — argv with embedded spaces isn't supported (ponytail: no -// shell-word parsing; the box owner controls this input, quote a wrapper script -// if an arg needs spaces). -func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { - if core == nil { - http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - var msg string - if r.Method == http.MethodPost { - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - action := r.FormValue("action") - name := strings.TrimSpace(r.FormValue("name")) - switch action { - case "enable": - scope := r.FormValue("scope") - cmd := strings.Fields(r.FormValue("cmd")) - destructive := r.FormValue("destructive") != "" - if name == "" || len(cmd) == 0 { - http.Error(w, "name and cmd required", http.StatusBadRequest) - return - } - if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil { - log.Printf("tools: enable %q: %v", name, err) - http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = "enabled " + name - case "disable": - if name == "" { - http.Error(w, "name required", http.StatusBadRequest) - return - } - if err := core.DisableTool(ctx, name); err != nil { - log.Printf("tools: disable %q: %v", name, err) - http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = "disabled " + name - case "dismiss": - if name == "" { - http.Error(w, "name required", http.StatusBadRequest) - return - } - if err := core.DeleteTool(ctx, name); err != nil { - log.Printf("tools: dismiss %q: %v", name, err) - http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = "dismissed " + name - default: - http.Error(w, "unknown action", http.StatusBadRequest) - return - } - } - proposed, err1 := core.ListTools(ctx, "proposed") - enabled, err2 := core.ListTools(ctx, "enabled") - if err := cmp.Or(err1, err2); err != nil { - log.Printf("tools: %v", err) - http.Error(w, "core read failed", http.StatusBadGateway) - return - } - // MCP is off by default and an older core may not know the method at all, - // so a failure here renders an empty section rather than breaking the page. - servers, err := core.MCPServers(ctx) - if err != nil { - log.Printf("tools: mcp servers: %v", err) - servers = nil - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - // Enabled rows are shown grouped by capability domain (Vikunja #452). A - // flat list stops answering "what can she do to the house" somewhere - // around fifteen rows, and that is the question this page exists for. - if err := toolsTmpl.Execute(w, struct { - Msg string - Proposed []ipc.Tool - Enabled []ipc.Tool - Groups []tool.CapabilityGroup - MCP []ipc.MCPServerStatus - }{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil { - log.Printf("tools render: %v", err) - } -} - -func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) { - conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`)) -} - -func writeFrame(w io.Writer, v any) error { - body, err := json.Marshal(v) - if err != nil { - return fmt.Errorf("marshal: %w", err) - } - const maxFrame = 64 << 20 - if len(body) > maxFrame { - return fmt.Errorf("frame too large: %d", len(body)) - } - var hdr [4]byte - binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) - if _, err := w.Write(hdr[:]); err != nil { - return err - } - _, err = w.Write(body) - return err -} - -func readFrame(r io.Reader, v any) error { - var hdr [4]byte - if _, err := io.ReadFull(r, hdr[:]); err != nil { - return err - } - n := binary.BigEndian.Uint32(hdr[:]) - const maxFrame = 64 << 20 - if n > maxFrame { - return fmt.Errorf("frame too large: %d", n) - } - buf := make([]byte, n) - if _, err := io.ReadFull(r, buf); err != nil { - return err - } - return json.Unmarshal(buf, v) -} - -func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) { - var raw struct { - ID uint64 `json:"id"` - Result json.RawMessage `json:"r,omitempty"` - Error *voice.RpcError `json:"e,omitempty"` - Kind voice.PushKind `json:"kind,omitempty"` - Params json.RawMessage `json:"p,omitempty"` - } - if err := readFrame(r, &raw); err != nil { - return nil, nil, err - } - if raw.Kind != "" && raw.ID == 0 { - return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil - } - return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil -} - -func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", 405) - return - } - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), 400) - return - } - if len(body) < 4 { - http.Error(w, "too short", 400) - return - } - - log.Printf("ptt got %d bytes from client", len(body)) - - pcm := audio.Audio{Format: audio.PCM16kMono, Bytes: body} - - var d net.Dialer - tc, err := d.DialContext(r.Context(), "tcp", voiceAddr) - if err != nil { - log.Printf("ptt dial voice: %v", err) - http.Error(w, "voice unavailable", 503) - return - } - defer tc.Close() - - req := voice.Request{ - ID: uint64(time.Now().UnixNano()), - Method: voice.MethodPushToTalk, - Params: mustMarshal(voice.PushToTalkReq{ - Audio: pcm, - Lang: "mixed", - Surface: voice.SurfacePCClient, - }), - } - if err := writeFrame(tc, &req); err != nil { - log.Printf("ptt write: %v", err) - http.Error(w, err.Error(), 500) - return - } - - for { - resp, push, err := readOneFrame(tc) - if err != nil { - log.Printf("ptt read: %v", err) - http.Error(w, err.Error(), 500) - return - } - if push != nil { - continue - } - if resp.Error != nil { - http.Error(w, resp.Error.Message, 500) - return - } - var pttResp voice.PushToTalkResp - if err := json.Unmarshal(resp.Result, &pttResp); err != nil { - http.Error(w, err.Error(), 500) - return - } - w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1") - // PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space - // as "+", which is form encoding, and the client decodes this header - // with decodeURIComponent, which only knows "%20" — so every space in a - // spoken reply reached the on-page log as a plus sign. PathEscape is the - // flavour decodeURIComponent actually reverses, which keeps the encoding - // a property of the header rather than something the client has to know. - w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText)) - w.Write(pttResp.ReplyAudio.Bytes) - return - } -} - -// --- chat page --- - -// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat -// and the handler redirects back to /chat with the response. -var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML)) - -// handleChatPage renders the chat conversation page. -func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable) - return - } - msgs := []chatMsg{} - // Read user message + reply from query params (set by /api/chat redirect). - if q := r.URL.Query().Get("q"); q != "" { - msgs = append(msgs, chatMsg{Role: "user", Text: q}) - } - if reply := r.URL.Query().Get("r"); reply != "" { - msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")}) - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := chatTmpl.Execute(w, struct { - Error string - Messages []chatMsg - }{Messages: msgs}); err != nil { - log.Printf("chat render: %v", err) - } -} - -// handleChatAPI processes a chat message POST and redirects back to /chat. -// -// State-changing, and the widest surface on this server: the text reaches the -// router, the LLM, and through mavend's applyAction the whole action path -// including `act` — so it is gated on the same step-up as POST /tools and -// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is -// fail-open exactly like the others (see stepUpOK); with -require-stepup it -// denies, which is the point of that flag. -func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", http.StatusMethodNotAllowed) - return - } - if core == nil { - http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable) - return - } - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - http.Redirect(w, r, "/chat", http.StatusSeeOther) - return - } - // One conversation id for the whole web chat, and a different one from - // telegram or the mic. A parked question belongs to the reach that was - // asked; before this, a clarify nobody answered on the web ate the next - // utterance spoken at the mic (Vikunja #466). This server has no - // per-browser session, so every browser tab is the same conversation — - // which is right for a single-owner box. - reply, err := core.Chat(r.Context(), "web", text) - if err != nil { - log.Printf("chat api: %v", err) - http.Redirect(w, r, "/chat", http.StatusSeeOther) - return - } - // The claiming query source rides back on the redirect so the page can show - // it. Empty for a turn no source claimed, which is most of them. - dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply) - if reply.Source != "" { - dest += "&s=" + url.QueryEscape(reply.Source) - } - http.Redirect(w, r, dest, http.StatusSeeOther) -} - -// chatMsg — one message in the conversation history. -type chatMsg struct { - Role string // "user" | "assistant" - Text string - // Source — the query source that claimed the turn, shown as a badge beside - // the reply. Empty for a turn no source claimed (V-539). - Source string -} - -func mustMarshal(v any) json.RawMessage { - b, err := json.Marshal(v) - if err != nil { - panic(err) - } - return b -} diff --git a/cmd/mavweb/notifications.go b/cmd/mavweb/notifications.go new file mode 100644 index 0000000..97d68e5 --- /dev/null +++ b/cmd/mavweb/notifications.go @@ -0,0 +1,76 @@ +package main + +import ( + _ "embed" + "log" + "net/http" + "strconv" + + "github.com/kami/maven/internal/ipc" +) + +//go:embed notifications.html +var notificationsHTML string + +var notificationsTmpl = parsePage("notifications", notificationsHTML, nil) + +// deliveryRow is one outbox line, with every timestamp already formatted so +// the template holds no date logic — same shape as taskRow. +type deliveryRow struct { + Kind string + Target string + Channel string + Status string + Created string + Completed string +} + +func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { + out := make([]deliveryRow, 0, len(as)) + for _, a := range as { + target := a.Rule + if target == "" && a.ReminderID != 0 { + target = "reminder #" + strconv.FormatInt(a.ReminderID, 10) + } + row := deliveryRow{ + Kind: a.Kind, + Target: target, + Channel: a.Channel, + Status: a.Status, + Created: a.Created.Format("02.01 15:04"), + } + if a.Completed != nil { + row.Completed = a.Completed.Format("15:04") + } + out = append(out, row) + } + return out +} + +func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "notifications") { + return + } + ctx := r.Context() + nudges, err := core.RecentNudges(ctx, 50) + if err != nil { + log.Printf("notifications: %v", err) + http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway) + return + } + // The outbox, on the page that already answers "what did she send". + // A failed or dropped attempt is why she went quiet, and until now it was + // recorded and unreadable (Vikunja #390). Filter with ?status=dropped. + status := r.URL.Query().Get("status") + attempts, err := core.DeliveryAttempts(ctx, status, 50) + if err != nil { + // The nudge list is still worth showing, so this is a note on the page + // rather than a dead page. + log.Printf("notifications: delivery attempts: %v", err) + } + renderPage(w, notificationsTmpl, map[string]any{ + "Nudges": nudges, + "Attempts": deliveryRows(attempts), + "Status": status, + }) +} diff --git a/cmd/mavweb/pages.go b/cmd/mavweb/pages.go new file mode 100644 index 0000000..5703e43 --- /dev/null +++ b/cmd/mavweb/pages.go @@ -0,0 +1,188 @@ +package main + +import ( + "cmp" + _ "embed" + "html/template" + "log" + "net/http" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// The read-only pages: dash, history, trace, morning, events, and the voice +// page mounted at "/". Each is GET-only, reads through CoreAPI and renders. +// The write surfaces live next to their own handlers (tasks.go, tools.go, +// routines.go, chat.go). + +//go:embed dash.html +var dashHTML string + +//go:embed history.html +var historyHTML string + +//go:embed trace.html +var traceHTML string + +//go:embed morning.html +var morningHTML string + +//go:embed events.html +var eventsHTML string + +//go:embed voice.html +var voiceHTML string + +//go:embed ecosystem.html +var ecosystemHTML string + +// 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 = parsePage("dash", dashHTML, nil) + +var historyTmpl = parsePage("history", historyHTML, nil) + +var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{ + "fmtTime": func(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Format("15:04:05") + }, + "join": strings.Join, +}) + +// 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 = parsePage("morning", morningHTML, nil) + +// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same +// shape as trace.html and morning.html: server-rendered, refreshed on reload. +var eventsTmpl = parsePage("events", eventsHTML, nil) + +var voiceTmpl = parsePage("voice", voiceHTML, nil) + +// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only +// human surface is here (they ship no web UI of their own). +var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil) + +func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "dash") { + return + } + ctx := r.Context() + pres, err1 := core.Presence(ctx) + facts, err2 := core.RecentFacts(ctx, 50) + nudges, err3 := core.RecentNudges(ctx, 50) + notes, err4 := core.RecentNotes(ctx, 50) + if err := cmp.Or(err1, err2, err3, err4); err != nil { + log.Printf("dash: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + renderPage(w, dashTmpl, struct { + Presence ipc.Presence + Facts []ipc.Fact + Nudges []ipc.Nudge + Notes []ipc.Note + }{pres, facts, nudges, notes}) +} + +func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "history") { + return + } + facts, err := core.RecentFacts(r.Context(), 200) + if err != nil { + log.Printf("history: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + renderPage(w, historyTmpl, struct { + Facts []ipc.Fact + }{facts}) +} + +func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "trace") { + return + } + trace, err := core.TickTrace(r.Context()) + if err != nil { + log.Printf("trace: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + renderPage(w, traceTmpl, trace) +} + +// morningView — what /morning renders: today's plan on top, the checklist +// state under it. PlanErr is set instead of Plan when the core could not build +// a plan, so the page says so rather than showing an empty day. +type morningView struct { + Plan *ipc.DayPlan + PlanErr string + Routines []ipc.MorningRoutineStatus +} + +func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "morning") { + 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 + } + view := morningView{Routines: status} + // The day plan (#128) shows on this page because it is the same question at + // a different scale. A plan read that fails must not take the checklist + // down with it — the page degrades to what it had before. + plan, err := core.DayPlan(ctx) + if err != nil { + log.Printf("morning: day plan: %v", err) + view.PlanErr = err.Error() + } else { + view.Plan = &plan + } + renderPage(w, morningTmpl, view) +} + +// eventsView — what /events renders. Err is set instead of Events when the +// core could not serve the journal, so the page says why rather than showing an +// empty intake and implying nothing arrived. +type eventsView struct { + Events []ipc.IntakeEvent + Err string +} + +// eventsPageLimit — how many envelopes the page shows. The ring holds more; a +// page is for scanning what just happened, not for archaeology. +const eventsPageLimit = 200 + +func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "intake journal") { + return + } + var view eventsView + evs, err := core.RecentEvents(r.Context(), eventsPageLimit) + if err != nil { + log.Printf("events: %v", err) + view.Err = err.Error() + } else { + view.Events = evs + } + renderPage(w, eventsTmpl, view) +} + +func handleVoice(w http.ResponseWriter, r *http.Request) { + renderPage(w, voiceTmpl, nil) +} diff --git a/cmd/mavweb/reminders.go b/cmd/mavweb/reminders.go new file mode 100644 index 0000000..b953ac6 --- /dev/null +++ b/cmd/mavweb/reminders.go @@ -0,0 +1,74 @@ +package main + +import ( + _ "embed" + "encoding/json" + "log" + "net/http" + "strings" + + "github.com/kami/maven/internal/ipc" +) + +//go:embed reminders.html +var remindersHTML string + +var remindersTmpl = parsePage("reminders", remindersHTML, nil) + +// reminderRow is one line on /reminders, with the payload unwrapped and both +// timestamps already in his clock. +// +// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read +// `{"text":"выпить таблетки"}` and fired an hour off what he was told +// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal +// shape he never chose, and a time on a page he reads is the time on his wall. +type reminderRow struct { + Created string + Fires string + Status string + Text string +} + +// reminderText unwraps the {"text":...} payload the router writes. +// +// A copy of store.ReminderText rather than a call to it, because mavweb is one +// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The +// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to +// whoever renders it. Payload that is not that shape is shown as he said it. +func reminderText(payload string) string { + var m map[string]any + if err := json.Unmarshal([]byte(payload), &m); err == nil { + if t, ok := m["text"]; ok { + if s, isStr := t.(string); isStr && s != "" { + return s + } + } + } + return strings.TrimSpace(payload) +} + +func reminderRows(rs []ipc.Reminder) []reminderRow { + out := make([]reminderRow, 0, len(rs)) + for _, r := range rs { + out = append(out, reminderRow{ + Created: r.CreatedTs.Local().Format("02 Jan 15:04"), + Fires: r.FireTs.Local().Format("02 Jan 15:04"), + Status: r.Status, + Text: reminderText(r.Payload), + }) + } + return out +} + +func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "reminders") { + return + } + reminders, err := core.ListReminders(r.Context(), 50) + if err != nil { + log.Printf("reminders: %v", err) + http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway) + return + } + renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)}) +} diff --git a/cmd/mavweb/routines.go b/cmd/mavweb/routines.go new file mode 100644 index 0000000..1ca6db3 --- /dev/null +++ b/cmd/mavweb/routines.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + _ "embed" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/pattern" + "github.com/kami/maven/internal/webauthn" +) + +//go:embed routines.html +var routinesHTML string + +// routinesTmpl — the proposed-routine review surface. One row per thing maven +// noticed, in her words, with at most two actions: accept or dismiss. +var routinesTmpl = parsePage("routines", routinesHTML, nil) + +// routineView is one line on the page: what maven noticed, in her words, and +// how long ago she noticed it. A view model, not a database row — the template +// never formats an interval or a timestamp itself. +type routineView struct { + ID int64 + Phrase string + Noticed string +} + +// handleRoutines serves the routine review surface (GET) and answers a +// proposal (POST id + action=accept|dismiss). +// +// Accept is gated at step-up, the same tier as enabling a tool: saying yes +// hands the trigger loop a new standing reason to speak to the human, so it +// moves the boundary and only an authed surface may do it. Dismiss is not +// gated — it only ever removes a reason to speak, so the worst a weaker caller +// can do is make maven quieter. +func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { + if !requireCore(w, core, "routines") { + return + } + ctx := r.Context() + var msg string + if r.Method == http.MethodPost { + var ok bool + if msg, ok = applyRoutinePost(w, r, core, session, requireStepUp); !ok { + return + } + } + proposed, err := core.ListProposedRoutines(ctx) + if err != nil { + log.Printf("routines: list: %v", err) + http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway) + return + } + renderPage(w, routinesTmpl, struct { + Msg string + Proposed []routineView + }{msg, toRoutineViews(proposed)}) +} + +// applyRoutinePost performs one write and returns the message to show. Unlike +// the task form, a bad request here is an HTTP status rather than an inline +// note, so the second return says whether the response was already written. +func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) (string, bool) { + ctx := r.Context() + action := r.FormValue("action") + // "seed" is the one action with no routine to act on — it is what + // MAKES a routine (Vikunja #518), so it runs before the id parse. It + // lives on this route rather than a page of its own because it is + // already the step-up-gated surface for this table, and a second gated + // surface is a second thing to get wrong. + if action == "seed" { + if !stepUpGate(w, session, requireStepUp) { + return "", false + } + out, err := seedRoutineEvent(ctx, core, r) + if err != nil { + log.Printf("routines: seed: %v", err) + http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway) + return "", false + } + return out, true + } + + idStr := r.FormValue("id") + var rid int64 + if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 { + http.Error(w, "invalid id", http.StatusBadRequest) + return "", false + } + switch action { + case "accept": + if !stepUpGate(w, session, requireStepUp) { + return "", false + } + if err := acceptRoutine(ctx, core, rid); err != nil { + log.Printf("routines: accept %d: %v", rid, err) + http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway) + return "", false + } + return "accepted routine — maven will remind you", true + case "dismiss": + if err := core.DismissProposedRoutine(ctx, rid); err != nil { + log.Printf("routines: dismiss %d: %v", rid, err) + http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) + return "", false + } + return "dismissed routine", true + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return "", false + } +} + +// toRoutineViews turns the wire rows into view models. The phrase comes from +// pattern.PhraseRoutine so the page says the same thing maven's voice says. +func toRoutineViews(rs []ipc.ProposedRoutine) []routineView { + out := make([]routineView, 0, len(rs)) + for _, r := range rs { + p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays} + noticed := "just now" + if r.CreatedTs > 0 { + noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago" + } + out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed}) + } + return out +} + +// acceptRoutine marks a proposal accepted. This page is the ONLY surface that +// may do it (Vikunja #367): accepting gives the tick loop a standing new +// reason to speak, which DESIGN.md puts at layer 3, and the button here is +// behind step-up. Voice can park the question and dismiss, never accept. +func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { + proposed, err := core.ListProposedRoutines(ctx) + if err != nil { + return err + } + var found *ipc.ProposedRoutine + for i := range proposed { + if proposed[i].ID == id { + found = &proposed[i] + break + } + } + if found == nil { + return errors.New("no such proposed routine") + } + + // No reminder is created here. Accepting only flips the status; the tick + // loop reads accepted routines and nudges on the interval (Vikunja #366). + // The old code made a one-shot reminder, so a non-weekly routine fired + // once and then went quiet forever. + return core.AcceptProposedRoutine(ctx, id) +} + +// seedRoutineEvent drives one backdated fact write through core (Vikunja #518), +// so the pattern detector can be exercised against a running daemon instead of +// over real days. Refused unless mavend was started with -allow-seed; on an +// ordinary box the error says so and nothing is written. +// +// Takes "ago" rather than an absolute timestamp — hours before now, as a float +// so a QA sitting can space four seeds three hours apart without doing clock +// arithmetic. The detector's floor is two hours, and "0" is a legal answer +// meaning now. +func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { + key := strings.TrimSpace(r.FormValue("key")) + value := strings.TrimSpace(r.FormValue("value")) + if key == "" || value == "" { + return "", errors.New("seed needs a key and a value") + } + agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64) + if err != nil { + return "", fmt.Errorf("seed: bad ago (hours before now): %w", err) + } + if agoHours < 0 { + return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative") + } + resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{ + Key: key, + Value: value, + Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))), + }) + if err != nil { + return "", err + } + if !resp.Extracted { + return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil + } + if !resp.Proposed { + return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil + } + return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil +} diff --git a/cmd/mavweb/shell.go b/cmd/mavweb/shell.go new file mode 100644 index 0000000..faa9002 --- /dev/null +++ b/cmd/mavweb/shell.go @@ -0,0 +1,194 @@ +package main + +import ( + _ "embed" + "html/template" + "log" + "net/http" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/webauthn" +) + +// shellHTML — the shell partial every page is wrapped in: "shellTop", the +// "sidebar" it calls, and "shellBottom". It used to be two Go string constants +// with the sidebar assembled by a strings.Builder, which is the one piece of +// markup that was still concatenated in Go. +// +// Two template pieces 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. +// +//go:embed shell.html +var shellHTML string + +// 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: "Tasks", URL: "/tasks", Key: "tasks"}, + {Label: "Reminders", URL: "/reminders", Key: "reminders"}, + {Label: "Routines", URL: "/routines", Key: "routines"}, + {Label: "Morning", URL: "/morning", Key: "morning"}, + {Label: "Intake", URL: "/events", Key: "events"}, + }, + }, + { + Label: "Ecosystem", + Pages: []struct{ Label, URL, Key string }{ + {Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"}, + }, + }, + { + Label: "AI", + Pages: []struct{ Label, URL, Key string }{ + {Label: "Chat", URL: "/chat", Key: "chat"}, + {Label: "Voice", URL: "/", Key: "voice"}, + }, + }, + { + Label: "Settings", + Pages: []struct{ Label, URL, Key string }{ + {Label: "Tools", URL: "/tools", Key: "tools"}, + {Label: "Model", URL: "/models", Key: "models"}, + {Label: "Passkey", URL: "/auth/passkey", Key: "passkey"}, + }, + }, +} + +// pageChrome is the per-page title and ethos-icons.svg symbol id, keyed by the +// page key a page hands to shellTop. One table rather than two parallel +// switches, so a new page cannot end up with a title and no icon. +var pageChrome = map[string]struct{ Title, Icon string }{ + "dash": {"Dashboard", "i-grid"}, + "history": {"History", "i-clock"}, + "trace": {"Rule Trace", "i-wave"}, + "notifications": {"Notifications", "i-bell"}, + "tasks": {"Tasks", "i-grid"}, + "reminders": {"Reminders", "i-calendar"}, + "routines": {"Routines", "i-repeat"}, + "morning": {"Morning Routines", "i-calendar"}, + "chat": {"Chat", "i-message"}, + "voice": {"Voice", "i-mic"}, + "ecosystem": {"Ecosystem", "i-grid"}, + "tools": {"Tools", "i-settings"}, + "models": {"Resident Model", "i-wave"}, + "passkey": {"Passkey", "i-lock"}, +} + +// pageIcon returns the ethos-icons.svg symbol id for the given page. The +// sidebar template wraps it in the reference. +func pageIcon(key string) string { + if c, ok := pageChrome[key]; ok { + return c.Icon + } + return "i-search" +} + +// pageTitle returns the human-readable page title for the given key. An +// unknown key renders as itself rather than as a blank crumb. +func pageTitle(key string) string { + if c, ok := pageChrome[key]; ok { + return c.Title + } + return key +} + +// shellFuncs returns the FuncMap shared by every server-rendered page template. +func shellFuncs() template.FuncMap { + return template.FuncMap{ + "pageTitle": pageTitle, + "pageIcon": pageIcon, + "sidebarSections": func() any { return sidebarSections }, + "ago": func(t time.Time) string { + if t.IsZero() { + return "never" + } + return time.Since(t).Round(time.Second).String() + " ago" + }, + } +} + +// parsePage parses one server-rendered page: the shell partial plus the page's +// own embedded markup, under the shared FuncMap. extra adds page-local +// functions (/tools needs capability lookups, /trace a time format) and may be +// nil. +// +// The name is also the page's log label, so a render failure says which page. +func parsePage(name, body string, extra template.FuncMap) *template.Template { + funcs := shellFuncs() + for k, v := range extra { + funcs[k] = v + } + return template.Must(template.New(name).Funcs(funcs).Parse(shellHTML + body)) +} + +// renderPage writes one page. Every handler sent the same content type and +// logged the same way on failure; the header is already written by then, so a +// render error can only be logged, never reported. +func renderPage(w http.ResponseWriter, t *template.Template, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.Execute(w, data); err != nil { + log.Printf("%s render: %v", t.Name(), err) + } +} + +// requireCore answers whether the surface has a core to read. mavweb runs +// without -core (voice-only), and every page that needs mavend says so with a +// 503 naming itself rather than a blank error. +func requireCore(w http.ResponseWriter, core ipc.CoreAPI, surface string) bool { + if core == nil { + http.Error(w, surface+" disabled (no -core)", http.StatusServiceUnavailable) + return false + } + return true +} + +// stepUpGate reports whether the caller may proceed through the AuthStepUp +// gate, writing the 403 itself when it may not. See stepUpOK for the policy. +func stepUpGate(w http.ResponseWriter, session *webauthn.PasskeySession, requireStepUp bool) bool { + if stepUpOK(session, requireStepUp) { + return true + } + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return false +} + +// stepUpOK is the single decision point for the AuthStepUp gate shared by +// POST /tools and POST /api/revert. +// +// A nil session means WebAuthn is not configured (-webauthn-origin / +// -webauthn-rpid unset), so step-up can never be asserted — not merely unmet. +// The default is therefore fail-OPEN: gating on an unassertable session would +// 403 those surfaces permanently. In that mode the actions rest on the +// transport-level auth in front of mavweb (wg+nginx+auth), and main logs a +// startup warning naming them. With -require-stepup the same situation fails +// CLOSED instead: no assertable step-up ⇒ deny. +func stepUpOK(session *webauthn.PasskeySession, requireStepUp bool) bool { + if session == nil { + return !requireStepUp + } + return session.IsStepUp() +} diff --git a/cmd/mavweb/tasks.go b/cmd/mavweb/tasks.go new file mode 100644 index 0000000..2a860ee --- /dev/null +++ b/cmd/mavweb/tasks.go @@ -0,0 +1,408 @@ +package main + +import ( + "context" + _ "embed" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/tasks" +) + +//go:embed tasks.html +var tasksHTML string + +var tasksTmpl = parsePage("tasks", tasksHTML, nil) + +// now — the wall clock, indirected so the task page can be rendered at a fixed +// instant in a test. internal/tasks is pure and the daemon path already ranks +// through a clock it is handed; the page had no reason to be the one surface +// that could only be tested at whatever time it happened to run. +var now = time.Now + +// resolvedShown — how many finished tasks the page renders. The list is +// history, it only grows, and the rows below the first screen are read by +// nobody. +const resolvedShown = 50 + +// taskRow is one line on /tasks, with every timestamp already formatted so the +// template holds no date logic. +type taskRow struct { + ID int64 + Text string + Source string + Evidence string + Status string + Due string + Created string + Resolved string + ResolvedBy string + // DueValue and Weight are the raw values the edit form posts back + // (Vikunja #509). Due above is for reading and says "—" for no date; a + // date input needs "2026-08-07" or the empty string. + DueValue string + Weight int + // Why — the ranker's reason for this row's position (Vikunja #129), in + // Russian, empty when nothing distinguished the task. Blank is the honest + // rendering: he never said this one mattered more. + Why string +} + +// rowOf renders one wire task into the shared read-only columns. The two call +// sites below add what only they need: the live rows carry the edit form's raw +// values and the ranker's reason, the resolved rows carry neither. +func rowOf(t ipc.Task) taskRow { + return taskRow{ + ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, + Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), + Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), + ResolvedBy: t.ResolvedBy, + } +} + +// handleTasks serves the task review surface (GET) and the five writes it +// offers (POST): add, edit, confirm, done, drop. +// +// Not step-up gated, unlike /tools and /routines, and the difference is the +// point: enabling a tool defines argv Maven will execute, and accepting a +// routine hands the tick loop a new standing reason to interrupt him. A task is +// neither — nothing in the tick loop reads the tasks table, so the worst a +// weaker caller can do here is write a line onto a list he reads himself. It +// still sits behind whatever transport auth fronts mavweb, like every other +// page. +// +// "edit" was re-argued on the same terms rather than inheriting the exemption +// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads +// himself, the same blast radius "drop" already has on this page, and the store +// refuses the two edits that would cost something: a resolved task keeps the +// text it was finished under, and a text collision with another live row is +// named instead of merged. +// +// "confirm" is the only interesting move: it promotes a candidate Maven derived +// from something she read into work he owns. That review step is why derived +// tasks are captured as candidates in the first place. +func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "tasks") { + return + } + ctx := r.Context() + var msg, errMsg string + if r.Method == http.MethodPost { + var err error + msg, err = applyTaskPost(ctx, core, r) + if err != nil { + log.Printf("tasks: %v", err) + errMsg = err.Error() + } + } + + all, err := core.ListTasks(ctx, "") + if err != nil { + log.Printf("tasks: list: %v", err) + http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway) + return + } + // Live rows are ordered by the same ranker the spoken list uses, so the page + // and the voice reply can never disagree about what comes first. Resolved + // rows keep store order (newest first) — ranking finished work is pointless. + var live []tasks.Item + var resolved []taskRow + resolvedTotal := 0 + for _, t := range all { + switch t.Status { + case "candidate", "open": + live = append(live, tasks.Item{ + ID: t.ID, Text: t.Text, Status: t.Status, + Created: t.CreatedTs, Due: t.Due, Weight: t.Weight, + }) + default: + resolvedTotal++ + // Finished work is history, and the history only grows. The page + // showed every row that ever existed, which is a page that gets + // slower every month for a section nobody reads past the top of. + if len(resolved) >= resolvedShown { + continue + } + resolved = append(resolved, rowOf(t)) + } + } + byID := make(map[int64]ipc.Task, len(all)) + for _, t := range all { + byID[t.ID] = t + } + var cands, open []taskRow + for _, r := range tasks.Rank(live, now()) { + t := byID[r.ID] + row := rowOf(t) + row.DueValue = fmtTaskDateValue(t.Due) + row.Weight = t.Weight + row.Why = r.Reason + if t.Status == "candidate" { + // A candidate's due date is Maven's reading of a mail, so its + // ranking reason is not shown as if he had set a priority. + row.Why = "" + cands = append(cands, row) + } else { + open = append(open, row) + } + } + renderPage(w, tasksTmpl, struct { + Msg, Err string + Stalls []tasks.Stall + Candidates []taskRow + Open []taskRow + Resolved []taskRow + ResolvedMore bool + }{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}) +} + +// applyTaskPost performs one write and returns the message to show. A bad +// request returns an error, which the page renders inline rather than as a +// bare 400 — this is a form surface, not an API. +func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { + action := r.FormValue("action") + if action == "add" { + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()} + wgt, err := formWeight(r) + if err != nil { + return "", err + } + req.Weight = wgt + due, err := formDue(r, now()) + if err != nil { + return "", err + } + req.Due = due + resp, err := core.CaptureTask(ctx, req) + if err != nil { + return "", err + } + if resp.Promoted { + return "confirmed a candidate maven had found", nil + } + if !resp.Created { + return "already on the list", nil + } + return "added task", nil + } + + id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) + if err != nil { + return "", errors.New("invalid id") + } + + if action == "promote" { + msg, err := promoteCandidate(ctx, core, r, id) + if err != nil { + return "", err + } + return msg, nil + } + + if action == "edit" { + // The three fields capture set, and only those (Vikunja #509). Status + // is not editable here: that ladder is one-way and has its own buttons. + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + wgt, err := formWeight(r) + if err != nil { + return "", err + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + switch err := core.EditTask(ctx, id, text, due, wgt); { + case err == nil: + return "saved task", nil + case errors.Is(err, ipc.ErrTaskDuplicate): + // Naming the collision instead of merging: two live rows carry two + // provenances, and picking one is not the page's call. + return "", errors.New("another open task already says this — drop one of the two") + case errors.Is(err, ipc.ErrTaskResolved): + return "", errors.New("a resolved task keeps the text it was finished under") + default: + return "", err + } + } + + var status, msg string + switch action { + case "confirm": + status, msg = "open", "confirmed task" + case "done": + status, msg = "done", "task done" + case "drop": + status, msg = "dropped", "dropped task" + default: + return "", fmt.Errorf("unknown action %q", action) + } + if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil { + return "", statusWriteErr(err) + } + return msg, nil +} + +// errNoDoneWhen — the refusal has to name what is missing, or the button looks +// broken. The field it asks for arrives with the intake form (Vikunja #511). +var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate") + +// statusWriteErr translates a SetTaskStatus failure into what the page says. +func statusWriteErr(err error) error { + if errors.Is(err, ipc.ErrTaskNoDoneWhen) { + return errNoDoneWhen + } + return err +} + +// promoteCandidate turns a candidate into open work with the three things the +// board needs (Vikunja #511): a definition of done, an optional blocker, and an +// optional date. +// +// The definition of done is required, and the refusal is the store's — this +// only reaches it in a readable order. The blocker is a NAME here and an entity +// id in the row: identity lives in Nexus, so the name is resolved first and a +// name Nexus cannot resolve stops the promotion instead of being stored. +// +// A date set here writes a reminder, which is the one unprompted delivery the +// persona allows: he asked to be told, on a day he named. +func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) { + doneWhen := strings.TrimSpace(r.FormValue("done_when")) + if doneWhen == "" { + return "", errors.New("write a definition of done — what has to be true for this to be finished") + } + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + + blockedOn, err := resolveBlocker(ctx, core, r.FormValue("blocked_on")) + if err != nil { + return "", err + } + + if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { + return "", err + } + if due != nil { + wgt, err := formWeight(r) + if err != nil { + return "", err + } + if err := core.EditTask(ctx, id, text, due, wgt); err != nil { + return "", err + } + } + if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil { + return "", statusWriteErr(err) + } + if due == nil { + return "confirmed", nil + } + // A date-only field has no hour. Nine in the morning, because the reminder + // is about a day's work and being told at midnight is being told the night + // before. + fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location()) + if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil { + // The task IS promoted; only the reminder failed. Saying "confirmed" + // and nothing else would leave him expecting a nudge that will not come. + return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err) + } + return "confirmed, and maven will remind you that morning", nil +} + +// resolveBlocker turns the blocked-on NAME the form posts into the entity id +// the row stores. Identity lives in Nexus, so an unresolvable name stops the +// promotion instead of being written as free text. An empty field is no +// blocker and reaches Nexus not at all. +func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string, error) { + name := strings.TrimSpace(field) + if name == "" { + return "", nil + } + ref, err := core.ResolveEntity(ctx, name, []string{"person"}) + switch { + case errors.Is(err, ipc.ErrNotImplemented): + return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty") + case errors.Is(err, ipc.ErrNoEntity): + return "", fmt.Errorf("nexus does not know %q", name) + case err != nil: + return "", fmt.Errorf("resolving %q: %w", name, err) + case ref.Ambiguous: + // Asking, not picking: a task blocked on the wrong person is a + // mistake nobody can see afterwards. + return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", ")) + } + return ref.ID, nil +} + +// formWeight reads the importance select. Out-of-range clamps rather than +// rejects — a bad select is not worth a 400 — but trailing garbage is refused, +// because strconv is not Sscanf and "3junk" is not a 3. +func formWeight(r *http.Request) (int, error) { + v := r.FormValue("weight") + if v == "" { + return 0, nil + } + wgt, err := strconv.Atoi(v) + if err != nil || wgt < 0 { + return 0, fmt.Errorf("bad weight %q", v) + } + if wgt > tasks.MaxWeight { + wgt = tasks.MaxWeight + } + return wgt, nil +} + +// formDue reads the date input. An empty field is nil, which on an edit means +// "clear the date" — the form has no other way to say it. +func formDue(r *http.Request, now time.Time) (*time.Time, error) { + d := r.FormValue("due") + if d == "" { + return nil, nil + } + due, err := time.ParseInLocation("2006-01-02", d, now.Location()) + if err != nil { + return nil, fmt.Errorf("bad due date %q", d) + } + return &due, nil +} + +// fmtTaskDateValue renders a due date the way requires, or +// "" for no date. Separate from fmtTaskDate, which renders it for reading. +func fmtTaskDateValue(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Local().Format("2006-01-02") +} + +func fmtTaskTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Local().Format("02 Jan 15:04") +} + +func fmtTaskDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Local().Format("02 Jan") +} diff --git a/cmd/mavweb/tools.go b/cmd/mavweb/tools.go new file mode 100644 index 0000000..5a445e9 --- /dev/null +++ b/cmd/mavweb/tools.go @@ -0,0 +1,112 @@ +package main + +import ( + "cmp" + _ "embed" + "html/template" + "log" + "net/http" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/tool" + "github.com/kami/maven/internal/webauthn" +) + +//go:embed tools.html +var toolsHTML string + +// 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 = parsePage("tools", toolsHTML, template.FuncMap{ + "join": strings.Join, + "capability": func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }, + "risk": func(t ipc.Tool) string { return string(tool.RiskOf(t)) }, +}) + +// handleTools serves the enable surface (GET) and applies an enable (POST). +// POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is +// whitespace-split — argv with embedded spaces isn't supported (ponytail: no +// shell-word parsing; the box owner controls this input, quote a wrapper script +// if an arg needs spaces). +func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { + if !requireCore(w, core, "tools") { + return + } + ctx := r.Context() + var msg string + if r.Method == http.MethodPost { + if !stepUpGate(w, session, requireStepUp) { + return + } + action := r.FormValue("action") + name := strings.TrimSpace(r.FormValue("name")) + switch action { + case "enable": + scope := r.FormValue("scope") + cmd := strings.Fields(r.FormValue("cmd")) + destructive := r.FormValue("destructive") != "" + if name == "" || len(cmd) == 0 { + http.Error(w, "name and cmd required", http.StatusBadRequest) + return + } + if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil { + log.Printf("tools: enable %q: %v", name, err) + http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway) + return + } + msg = "enabled " + name + case "disable": + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + if err := core.DisableTool(ctx, name); err != nil { + log.Printf("tools: disable %q: %v", name, err) + http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway) + return + } + msg = "disabled " + name + case "dismiss": + if name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + if err := core.DeleteTool(ctx, name); err != nil { + log.Printf("tools: dismiss %q: %v", name, err) + http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) + return + } + msg = "dismissed " + name + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + } + proposed, err1 := core.ListTools(ctx, "proposed") + enabled, err2 := core.ListTools(ctx, "enabled") + if err := cmp.Or(err1, err2); err != nil { + log.Printf("tools: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + // MCP is off by default and an older core may not know the method at all, + // so a failure here renders an empty section rather than breaking the page. + servers, err := core.MCPServers(ctx) + if err != nil { + log.Printf("tools: mcp servers: %v", err) + servers = nil + } + // Enabled rows are shown grouped by capability domain (Vikunja #452). A + // flat list stops answering "what can she do to the house" somewhere + // around fifteen rows, and that is the question this page exists for. + renderPage(w, toolsTmpl, struct { + Msg string + Proposed []ipc.Tool + Enabled []ipc.Tool + Groups []tool.CapabilityGroup + MCP []ipc.MCPServerStatus + }{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}) +} diff --git a/cmd/mavweb/voiceproxy.go b/cmd/mavweb/voiceproxy.go new file mode 100644 index 0000000..83b94cb --- /dev/null +++ b/cmd/mavweb/voiceproxy.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "time" + + "github.com/coder/websocket" + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/voice" + "github.com/kami/maven/internal/webauthn" +) + +// The two proxies onto mavend's voice port: GET /ws streams turns over a +// websocket, POST /api/ptt does one turn over plain HTTP. Both carry the same +// step-up gate, because speaking an act is not a smaller act than typing one +// (Vikunja #317). The length-prefixed framing they share is at the bottom. + +// maxFrame caps a single voice frame in either direction. +const maxFrame = 64 << 20 + +// pushToTalk builds the one request either proxy sends. Surface is +// SurfacePCClient for both: the browser is standing in for the PC client. +func pushToTalk(pcm []byte) voice.Request { + return voice.Request{ + ID: uint64(time.Now().UnixNano()), + Method: voice.MethodPushToTalk, + Params: mustMarshal(voice.PushToTalkReq{ + Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, + Lang: "mixed", + Surface: voice.SurfacePCClient, + }), + } +} + +func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { + if !stepUpGate(w, session, requireStepUp) { + return + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + }) + if err != nil { + log.Printf("ws accept: %v", err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "bye") + + ctx := r.Context() + + var d net.Dialer + tc, err := d.DialContext(ctx, "tcp", voiceAddr) + if err != nil { + log.Printf("dial voice: %v", err) + writeWSErr(conn, ctx, "voice unavailable") + return + } + defer tc.Close() + + for { + _, msg, err := conn.Read(ctx) + if err != nil { + log.Printf("ws read: %v", err) + return + } + if len(msg) < 4 { + log.Printf("ws msg too short (%d bytes)", len(msg)) + continue + } + + log.Printf("ws got %d bytes from client", len(msg)) + req := pushToTalk(msg) + if err := writeFrame(tc, &req); err != nil { + log.Printf("write voice req: %v", err) + return + } + + // Read frames until we get the matching Response (handling any interleaved Pushes) + for { + resp, push, err := readOneFrame(tc) + if err != nil { + log.Printf("read voice: %v", err) + return + } + if push != nil { + data, _ := json.Marshal(push) + conn.Write(ctx, websocket.MessageText, data) + continue + } + if resp.Error != nil { + writeWSErr(conn, ctx, resp.Error.Message) + break + } + var pttResp voice.PushToTalkResp + if err := json.Unmarshal(resp.Result, &pttResp); err != nil { + log.Printf("unmarshal resp: %v", err) + break + } + if pttResp.ReplyText != "" { + conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText)) + } + if len(pttResp.ReplyAudio.Bytes) > 0 { + conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes) + } + break + } + } +} + +func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", 405) + return + } + if !stepUpGate(w, session, requireStepUp) { + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + if len(body) < 4 { + http.Error(w, "too short", 400) + return + } + + log.Printf("ptt got %d bytes from client", len(body)) + + var d net.Dialer + tc, err := d.DialContext(r.Context(), "tcp", voiceAddr) + if err != nil { + log.Printf("ptt dial voice: %v", err) + http.Error(w, "voice unavailable", 503) + return + } + defer tc.Close() + + req := pushToTalk(body) + if err := writeFrame(tc, &req); err != nil { + log.Printf("ptt write: %v", err) + http.Error(w, err.Error(), 500) + return + } + + for { + resp, push, err := readOneFrame(tc) + if err != nil { + log.Printf("ptt read: %v", err) + http.Error(w, err.Error(), 500) + return + } + if push != nil { + continue + } + if resp.Error != nil { + http.Error(w, resp.Error.Message, 500) + return + } + var pttResp voice.PushToTalkResp + if err := json.Unmarshal(resp.Result, &pttResp); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1") + // PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space + // as "+", which is form encoding, and the client decodes this header + // with decodeURIComponent, which only knows "%20" — so every space in a + // spoken reply reached the on-page log as a plus sign. PathEscape is the + // flavour decodeURIComponent actually reverses, which keeps the encoding + // a property of the header rather than something the client has to know. + w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText)) + w.Write(pttResp.ReplyAudio.Bytes) + return + } +} + +func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) { + conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`)) +} + +func writeFrame(w io.Writer, v any) error { + body, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + if len(body) > maxFrame { + return fmt.Errorf("frame too large: %d", len(body)) + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + _, err = w.Write(body) + return err +} + +func readFrame(r io.Reader, v any) error { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return err + } + n := binary.BigEndian.Uint32(hdr[:]) + if n > maxFrame { + return fmt.Errorf("frame too large: %d", n) + } + buf := make([]byte, n) + if _, err := io.ReadFull(r, buf); err != nil { + return err + } + return json.Unmarshal(buf, v) +} + +func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) { + var raw struct { + ID uint64 `json:"id"` + Result json.RawMessage `json:"r,omitempty"` + Error *voice.RpcError `json:"e,omitempty"` + Kind voice.PushKind `json:"kind,omitempty"` + Params json.RawMessage `json:"p,omitempty"` + } + if err := readFrame(r, &raw); err != nil { + return nil, nil, err + } + if raw.Kind != "" && raw.ID == 0 { + return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil + } + return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil +} + +func mustMarshal(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index 6717e8c..3312c7f 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -80,7 +80,10 @@ func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) { passkeyTmpl.Execute(w, nil) } -// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom. +var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil) + +// passkeyPageHTML — rendered via passkeyTmpl, which wraps it with +// shellTop/shellBottom. const passkeyPageHTML = `{{template "shellTop" "passkey"}}

Passkey

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

From 12c2ae1d17c458f177f7c713ee3a6374720ad586 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 01:29:08 +0400 Subject: [PATCH 2/2] mavweb: the last two pages leave Go (V-409) CLAUDE.md says every page is its own embedded .html file next to main.go, and that no page markup lives in Go. Two pages were still Go string constants: passkeyPageHTML in webauthn.go and modelsHTML in models.go. They are now passkey.html and models.html, embedded. The identifiers keep their names, so passkey_prf_test.go still reads passkeyPageHTML and still asserts on the same bytes. Both templates now build through parsePage, and Page and handleModels render through renderPage. handleEcosystem did too and now does the same. Co-Authored-By: Claude Opus 5 --- cmd/mavweb/ecosystem.go | 5 +-- cmd/mavweb/models.go | 46 +++----------------------- cmd/mavweb/models.html | 35 ++++++++++++++++++++ cmd/mavweb/passkey.html | 58 +++++++++++++++++++++++++++++++++ cmd/mavweb/webauthn.go | 72 ++++++----------------------------------- 5 files changed, 108 insertions(+), 108 deletions(-) create mode 100644 cmd/mavweb/models.html create mode 100644 cmd/mavweb/passkey.html diff --git a/cmd/mavweb/ecosystem.go b/cmd/mavweb/ecosystem.go index 77a329c..1bb1439 100644 --- a/cmd/mavweb/ecosystem.go +++ b/cmd/mavweb/ecosystem.go @@ -117,8 +117,5 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core d.Calls.Rows = rows } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := ecosystemTmpl.Execute(w, d); err != nil { - log.Printf("ecosystem render: %v", err) - } + renderPage(w, ecosystemTmpl, d) } diff --git a/cmd/mavweb/models.go b/cmd/mavweb/models.go index ded6af1..2b38bcd 100644 --- a/cmd/mavweb/models.go +++ b/cmd/mavweb/models.go @@ -2,8 +2,8 @@ package main import ( "context" + _ "embed" "errors" - "html/template" "log" "net/http" "strconv" @@ -31,43 +31,10 @@ type modelController interface { SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) } -var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML)) +//go:embed models.html +var modelsHTML string -const modelsHTML = `{{template "shellTop" "models"}} -

Resident model

-

swapping requires step-up — assert a passkey first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.

-

a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one mavupdate does — comes back on phraser.model_path from the config. Make it stick by editing that.

-{{if .Msg}}
{{.Msg}}
{{end}} -{{if .Err}}
{{.Err}}
{{end}} -{{if .Off}} -
-

swap not configured

-

this core has no phraser.swap_models allowlist, so there is nothing to swap to. Add the gguf paths you allow to deploy/mavend.json and restart once.

-
-{{else}} -
-

loaded now

-
- - - - - -
model{{.Status.Model}}
file{{.Status.ModelPath}}
server{{.Status.BaseURL}}
n_ctx{{.Status.NCtx}}
n_gpu_layers{{.Status.NGpuLayers}}
-

the model name is what llama-server reports for itself, not what the config says it should be.

-
-
-

allowed models {{len .Status.Swappable}}

-{{if .Status.Swappable}}
-{{range .Status.Swappable}} -{{end}} -
file
{{.}}
- -
-{{else}}
no models allowlisted
{{end}} -
-{{end}} -{{template "shellBottom"}}` +var modelsTmpl = parsePage("models", modelsHTML, nil) type modelsPage struct { Msg string @@ -154,8 +121,5 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap } } page.Status = st - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := modelsTmpl.Execute(w, page); err != nil { - log.Printf("models render: %v", err) - } + renderPage(w, modelsTmpl, page) } diff --git a/cmd/mavweb/models.html b/cmd/mavweb/models.html new file mode 100644 index 0000000..0990b54 --- /dev/null +++ b/cmd/mavweb/models.html @@ -0,0 +1,35 @@ +{{template "shellTop" "models"}} +

Resident model

+

swapping requires step-up — assert a passkey first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.

+

a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one mavupdate does — comes back on phraser.model_path from the config. Make it stick by editing that.

+{{if .Msg}}
{{.Msg}}
{{end}} +{{if .Err}}
{{.Err}}
{{end}} +{{if .Off}} +
+

swap not configured

+

this core has no phraser.swap_models allowlist, so there is nothing to swap to. Add the gguf paths you allow to deploy/mavend.json and restart once.

+
+{{else}} +
+

loaded now

+
+ + + + + +
model{{.Status.Model}}
file{{.Status.ModelPath}}
server{{.Status.BaseURL}}
n_ctx{{.Status.NCtx}}
n_gpu_layers{{.Status.NGpuLayers}}
+

the model name is what llama-server reports for itself, not what the config says it should be.

+
+
+

allowed models {{len .Status.Swappable}}

+{{if .Status.Swappable}}
+{{range .Status.Swappable}} +{{end}} +
file
{{.}}
+ +
+{{else}}
no models allowlisted
{{end}} +
+{{end}} +{{template "shellBottom"}} diff --git a/cmd/mavweb/passkey.html b/cmd/mavweb/passkey.html new file mode 100644 index 0000000..6ea67e0 --- /dev/null +++ b/cmd/mavweb/passkey.html @@ -0,0 +1,58 @@ +{{template "shellTop" "passkey"}} +

Passkey

+

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

+
+ + + + +
+

Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.

+
+{{template "shellBottom"}} + diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index 3312c7f..f1ef4a7 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -2,6 +2,7 @@ package main import ( "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -76,72 +77,17 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s // on: assert here (bumps the daemon session to L3 for the assertion TTL), then // enable a tool on /tools within that window. func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - passkeyTmpl.Execute(w, nil) + renderPage(w, passkeyTmpl, nil) } -var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil) +// passkeyPageHTML — the enrolment page's own markup, wrapped by passkeyTmpl +// with shellTop/shellBottom. It was a Go string constant, which is the one +// place page markup still lived in Go. +// +//go:embed passkey.html +var passkeyPageHTML string -// passkeyPageHTML — rendered via passkeyTmpl, which wraps it with -// shellTop/shellBottom. -const passkeyPageHTML = `{{template "shellTop" "passkey"}} -

Passkey

-

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

-
- - - - -
-

Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.

-
-{{template "shellBottom"}} -` +var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil) func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) { opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user")