Files
Maven/cmd/mavweb/main.go
T
claude 1da3aa39e8 Merge master into the line B review stack (V-405)
The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.

Where the two lines fixed the same thing, the better shape wins:

- Ambient time zones (V-482) landed on both sides. Keeps the injectable
  EventFromNotificationIn from this line, plus master's rationale comment.
  Drops master's forced n.Posted.In(time.Local), which defeated the loc
  argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
  split files this line created. The digest summary now declines through
  say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
  handler, since it is embedder-backed recall like the personal boundary.
  topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
  is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
  sentinels already carry, in unmappedStoreErrors.

make build: 12 binaries. make test: green. make fmt-check: clean.

--no-verify: a merge of two long lines cannot fit the 300-line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:46:53 +04:00

1632 lines
55 KiB
Go

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" "<page-key>"}} ← opens <html>, topbar, sidebar, content
// {{template "shellBottom"}} ← closes content, inspector, </html>
//
// 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 <use> 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")
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=<base64-token>. 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(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
// 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")
}
// 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")
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.
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(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 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
}