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 // ── 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"}, }, }, } func sidebarActive(url, key string, activeKey string) string { if key == activeKey { return `class="active"` } return "" } // sidebarHTML renders the sidebar navigation given the active page key. func sidebarHTML(active string) template.HTML { var b strings.Builder for _, sec := range sidebarSections { b.WriteString(``) } return template.HTML(b.String()) } // pageIcon returns an ethos-icons.svg reference for the given page. func pageIcon(key string) string { switch key { case "dash": return `` case "history": return `` case "trace": return `` case "notifications": return `` case "tasks": return `` case "reminders": return `` case "routines": return `` case "morning": return `` case "chat": return `` case "voice": return `` case "ecosystem": return `` case "tools": return `` case "models": return `` case "passkey": return `` default: return `` } } // pageTitle returns the human-readable page title for the given key. func pageTitle(key string) string { switch key { case "dash": return "Dashboard" case "history": return "History" case "trace": return "Rule Trace" case "notifications": return "Notifications" case "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 } } // shellTopHTML opens the shell and renders the top bar + sidebar. // Usage: {{template "shellTop" ""}} const shellTopHTML = `{{define "shellTop"}} maven · {{pageTitle .}}
Search Ctrl+/
{{end}}` // shellBottomHTML closes the content area, inspector, and shell. // Usage: {{template "shellBottom"}} const shellBottomHTML = `{{define "shellBottom"}}
{{end}}` // shellFuncs returns the FuncMap shared by every server-rendered page template. func shellFuncs() template.FuncMap { return template.FuncMap{ "pageTitle": pageTitle, "sidebarHTML": sidebarHTML, "ago": func(t time.Time) string { if t.IsZero() { return "never" } return time.Since(t).Round(time.Second).String() + " ago" }, "connected": func() bool { return true }, // if page renders, core was available } } // dashTmpl — the monitoring read surface, server-rendered from dash.html; // a small fetch loop refreshes the tables in place. html/template escapes the // user text in facts/nudges. Read-only: browses the append-only store via // CoreAPI, never writes — the store IS the audit trail, this just shows it. var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML)) // 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(shellTopHTML + ecosystemHTML + shellBottomHTML)) // 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(shellTopHTML + eventsHTML + shellBottomHTML)) // morningTmpl — read-only view of today's checklist state per configured // morning routine (internal/morning). Same shape as trace.html: a plain // server-rendered page, refreshed on reload — no live-update loop, since // checklist state changes on the scale of minutes, not seconds. var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML)) func noCache(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") h.ServeHTTP(w, r) }) } func main() { addr := flag.String("addr", "127.0.0.1:9200", "HTTP listen address (loopback by default; pass e.g. \":9200\" or a LAN IP deliberately for wider exposure — POST /chat and /routines are state-changing)") voiceAddr := flag.String("voice", "127.0.0.1:9100", "voice server TCP addr (host:port)") // ntfyWS: the ntfy WebSocket subscribe URL the PWA connects to for in-app // nudge delivery, e.g. wss://ntfy.kvmx.ru/maven/ws?auth=. The // client subscribes directly (lowest overhead — mavweb isn't in the path); // we only serve it the URL so the deny-all auth token stays deployment // config, never baked into the static JS. Empty ⇒ /api/ntfy returns 204 and // the PWA skips subscription (voice-only, as before). ntfyWS := flag.String("ntfy", "", "ntfy WebSocket subscribe URL served to the PWA (e.g. wss://host/topic/ws?auth=...)") // coreSock: mavend's IPC socket. When set, /api/signal writes presence // facts through CoreAPI (page heartbeat from the PWA, desk_active from a PC // script). Empty ⇒ /api/signal returns 503 and presence stays unfed. coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)") pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)") pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)") requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)") nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)") praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)") hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)") // Shared secret for POST /api/ambient, the notification-relay ingest that // reads the work calendar as a signal instead of holding a work credential // (see ambient.go). Empty ⇒ the route is not registered at all. ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)") flag.Parse() var core ipc.CoreAPI // swapConn — a second connection, for /models and nothing else. A model swap // is a multi-minute IPC call and ipc.Client serialises everything on one // mutex, so sharing the connection would freeze every other page for the // length of the load. See handleModels. var swapConn modelController if *coreSock != "" { c, err := ipc.DialWait(*coreSock, 60*time.Second) if err != nil { log.Fatalf("dial core %s: %v", *coreSock, err) } defer c.Close() core = c if sc, err := ipc.Dial(*coreSock); err != nil { log.Printf("models: second core connection failed (%v) — /models will share the main one and a swap will block the other pages", err) } else { defer sc.Close() swapConn = sc } } mux := http.NewServeMux() sub, err := fs.Sub(staticFiles, "static") if err != nil { log.Fatalf("static fs: %v", err) } staticHandler := noCache(http.FileServer(http.FS(sub))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { staticHandler.ServeHTTP(w, r) return } 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")) }) mux.HandleFunc("/api/ntfy", func(w http.ResponseWriter, r *http.Request) { if *ntfyWS == "" { w.WriteHeader(http.StatusNoContent) // not configured → PWA skips return } 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) }) // Off unless configured: no token, no route — an unconfigured ingest is not // a 503 waiting to be probed, it does not exist. if *ambientToken != "" { mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) { handleAmbient(w, r, core, *ambientToken) }) 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) }) // /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) }) 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 if *pkOrigin != "" && *pkRPID != "" && core != nil { stepUpSession = webauthn.NewPasskeySession(5 * time.Minute) pk, err := newPasskeyHandle(webauthn.Config{ Origin: *pkOrigin, RPID: *pkRPID, RPName: "maven", }, core, *pkFile, stepUpSession) if err != nil { log.Fatalf("passkey store: %v", err) } mux.HandleFunc("/auth/passkey", pk.Page) mux.HandleFunc("/auth/webauthn/register/begin", pk.RegisterBegin) mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish) mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin) mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish) } 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): // // POST /tools step-up — defines argv that internal/tool executes // POST /routines step-up — accepting schedules recurring firing // POST /models step-up — replaces the model that routes and phrases // POST /api/revert step-up — voids the latest fact for a key // POST /api/chat step-up — reaches the router, LLM and the act path // POST /api/ptt step-up — audio into runTurn, so the same router, // LLM and act path as /api/chat // GET /ws step-up — same, streamed // POST /api/signal none — appends a presence fact, no argv, no act // POST /api/ambient shared secret — notification relay, constant-time // token compare, poster is a phone service // and not a browser, so step-up cannot apply // // "step-up" means stepUpOK: asserted passkey when WebAuthn is configured, // otherwise fail-open unless -require-stepup, which denies. // // /api/ptt and /ws used to be ungated, justified by mavend's voice port // being reachable only inside the deploy. That argument does not hold: // mavweb is the thing proxying into it from outside. Speaking "выключи // свет" is not a smaller act than typing it (Vikunja #317). // // The gate here is per-request, which costs the hands-free case a passkey // 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) }) mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp) }) mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) { handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp) }) srv := &http.Server{Addr: *addr, Handler: mux} go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig log.Println("shutting down...") srv.Close() }() log.Printf("mavweb listening on %s, voice → %s", *addr, *voiceAddr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } } 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(shellTopHTML + toolsHTML + shellBottomHTML)) const toolsHTML = `{{template "shellTop" "tools"}}

Tools

enabling requires step-up — assert a passkey first.

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

proposed {{len .Proposed}}

{{if .Proposed}}

maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an mcp: scope came from an MCP server and already knows what it calls — check the command, then enable.

{{range .Proposed}}{{end}}
namecapabilityscopefrom utteranceenable as
{{.Name}}{{capability .}}{{.Scope}}{{.Utterance}}
{{else}}
no proposed tools
maven will propose tools here when she needs help running an action
{{end}}

enabled {{len .Enabled}}

{{if .Enabled}}

grouped by capability domain. The dotted id is scope.domain.action — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.

{{range .Groups}}

{{.Prefix}} {{len .Tools}}

{{range .Tools}}{{end}}
capabilitynamecommandrisk
{{capability .}}{{.Name}}{{join .Cmd " "}} {{$r := risk .}}{{if eq $r "irreversible"}}irreversible{{else if eq $r "destructive"}}destructive{{else}}safe{{end}}
{{end}} {{else}}
no tools enabled
enable proposed tools above, or ask maven to configure one
{{end}}

MCP servers {{len .MCP}}

{{if .MCP}}

servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A stdio target is a process on this box; an http one on a loopback or LAN address is inside the network, so treat its tools accordingly.

{{range .MCP}}{{end}}
nametransporttargetstatetools
{{.Name}}{{.Transport}}{{.Target}} {{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}down{{if .Err}} — {{.Err}}{{end}}{{end}} {{.Tools}}
{{else}}
no MCP servers configured
add an mcp.servers block to mavend.json to let her use an external tool server
{{end}}
{{template "shellBottom"}}` // routinesHTML — proposed routine review surface. One row per thing maven // noticed, in her words, with at most two actions: accept or dismiss. const routinesHTML = `{{template "shellTop" "routines"}}

Routines

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

noticed {{len .Proposed}}

{{if .Proposed}}
{{range .Proposed}}{{end}}
maven noticedwhen
{{.Phrase}}{{.Noticed}}
{{else}}
no proposed routines
maven will propose routines here when she detects a recurring pattern
{{end}}
{{template "shellBottom"}}` var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML)) var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML)) var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML)) var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML)) var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML)) var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML)) var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML)) var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap { m := shellFuncs() m["fmtTime"] = func(t *time.Time) string { if t == nil || t.IsZero() { return "—" } return t.Format("15:04:05") } m["join"] = strings.Join return m }()).Parse(shellTopHTML + traceHTML + shellBottomHTML)) 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 // 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 four writes it // offers (POST): add, 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. // // "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), 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 Candidates []taskRow Open []taskRow Resolved []taskRow ResolvedMore bool }{msg, errMsg, 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()} // Importance is his, stated on the form. Out-of-range values are // clamped rather than rejected — a bad select is not worth a 400. if v := r.FormValue("weight"); v != "" { // strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a // form value is not a place to accept trailing garbage. wgt, err := strconv.Atoi(v) if err != nil || wgt < 0 { return "", fmt.Errorf("bad weight %q", v) } if wgt > tasks.MaxWeight { wgt = tasks.MaxWeight } req.Weight = wgt } if d := r.FormValue("due"); d != "" { due, err := time.ParseInLocation("2006-01-02", d, now().Location()) if err != nil { return "", fmt.Errorf("bad due date %q", d) } 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") } 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 "", err } return msg, nil } 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") } // routineRow is one line on the page: what maven noticed, in her words, and // how long ago she noticed it. type routineRow 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") 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 []routineRow }{msg, routineRows(proposed)}); err != nil { log.Printf("routines render: %v", err) } } // routineRows turns the wire rows into display rows. The phrase comes from // pattern.PhraseRoutine so the page says the same thing maven's voice says. func routineRows(rs []ipc.ProposedRoutine) []routineRow { out := make([]routineRow, 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, routineRow{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) } 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() } else { view.Plan = &plan } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := morningTmpl.Execute(w, view); err != nil { log.Printf("morning render: %v", err) } } // 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() } 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) } } 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") w.Header().Set("X-Reply-Text", url.QueryEscape(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(shellTopHTML + chatPageHTML + shellBottomHTML)) const chatPageHTML = `{{template "shellTop" "chat"}}

Chat

{{if .Error}}
{{.Error}}
{{end}}
{{range .Messages}}
{{if eq .Role "user"}}you{{else}}maven{{end}}: {{.Text}}
{{else}}
start a conversation
{{end}}
{{template "shellBottom"}}` // 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 r := r.URL.Query().Get("r"); r != "" { msgs = append(msgs, chatMsg{Role: "assistant", Text: r}) } 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 } http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther) } // chatMsg — one message in the conversation history. type chatMsg struct { Role string // "user" | "assistant" Text string } func mustMarshal(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil { panic(err) } return b }