Files
Maven/cmd/mavweb/main.go
T
kami 49f089d8a6 Read the work calendar as a notification signal, not a mailbox (#126)
Maven does not get a work credential. A corp mail or calendar session living on
the homelab ties the box's blast radius to the employer's data, which is the
thing this task exists to refuse. What she reads instead is the signal: an
Android notification-listener on the phone relays meeting notifications over
wg/LAN to POST /api/ambient, and the ones that clearly describe a meeting become
calendar events at source=ambient:notif, confidence 0.6.

The provenance is the point. A notification is evidence about a meeting, not a
reading of a calendar, so it is never indistinguishable from one: it is stored
below full confidence, store.CalendarEvents keeps the source and confidence on
every row it returns, and the query path hedges — "похоже, Планёрка @ 14:00" for
a relayed event, plain text for a CalDAV read.

The parse is deliberately conservative (internal/calendar/ambient.go). It needs
a real clock reading and a summary that is not just that clock reading;
otherwise it stores nothing at all. A bare hour is not a time, an unread count
is not a time, and "срок 2026.08.15" does not offer 08:15 as a meeting — loose
digits in a notification are far more often a badge or a date, and a mailbox of
noise rendered as invented meetings is worse than a gap.

The ingest is off unless configured: no -ambient-token, no route registered. The
token is a shared secret compared in constant time, because the poster is a
background Android service and WebAuthn has no answer for one. The endpoint is
write-only, accepts one shape of write, and cannot read anything back out.
Reposts of the same notification dedupe against the latest fact for that
key+source, the same append-only discipline cmd/mavcaldav follows.

Not shipped: the Android relay app itself, which is a separate artifact and a
device, not Go in this repo.
2026-08-01 02:04:06 +04:00

1332 lines
46 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"
"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/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 voice.html
var voiceHTML string
//go:embed ecosystem.html
var ecosystemHTML string
//go:embed morning.html
var morningHTML string
// ── Ethos Workstation Shell ──
//
// Two template pieces that wrap every page:
// {{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: "Reminders", URL: "/reminders", Key: "reminders"},
{Label: "Routines", URL: "/routines", Key: "routines"},
{Label: "Morning", URL: "/morning", Key: "morning"},
},
},
{
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: "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(`<div class=sidebar-section>`)
b.WriteString(`<div class=sidebar-label>`)
b.WriteString(sec.Label)
b.WriteString(`</div>`)
for _, p := range sec.Pages {
cls := ""
if p.Key == active {
cls = ` class="active"`
}
b.WriteString(`<a href="`)
b.WriteString(p.URL)
b.WriteString(`"`)
b.WriteString(cls)
b.WriteString(`><span class=icon>`)
b.WriteString(pageIcon(p.Key))
b.WriteString(`</span><span>`)
b.WriteString(p.Label)
b.WriteString(`</span></a>`)
}
b.WriteString(`</div>`)
}
return template.HTML(b.String())
}
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
func pageIcon(key string) string {
switch key {
case "dash":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "history":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
case "trace":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
case "notifications":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
case "reminders":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
case "routines":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
case "morning":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
case "chat":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
case "voice":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
case "ecosystem":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "tools":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
case "passkey":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
default:
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
}
}
// pageTitle returns the human-readable page title for the given key.
func pageTitle(key string) string {
switch key {
case "dash":
return "Dashboard"
case "history":
return "History"
case "trace":
return "Rule Trace"
case "notifications":
return "Notifications"
case "reminders":
return "Reminders"
case "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 "passkey":
return "Passkey"
default:
return key
}
}
// shellTopHTML opens the shell and renders the top bar + sidebar.
// Usage: {{template "shellTop" "<page-key>"}}
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#14110D">
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{sidebarHTML .}}
</aside>
<main class=content>
{{end}}`
// shellBottomHTML closes the content area, inspector, and shell.
// Usage: {{template "shellBottom"}}
const shellBottomHTML = `{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{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))
// 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=<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, /api/revert, /api/chat) 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
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
}
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)
}))
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
handleWS(w, r, *voiceAddr)
})
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
handlePTT(w, r, *voiceAddr)
})
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)
})
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
handleMorning(w, r, core)
})
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
handleEcosystem(w, r, ecoURLsCfg)
})
// ----- 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 {
if *requireStepUp {
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
} else {
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits 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 (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)
})
// 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 /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/signal none — appends a presence fact, no argv, no act
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
// is itself only reachable inside the deploy
//
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
// otherwise fail-open unless -require-stepup, which denies.
//
// 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)
})
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) {
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
return m
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
const toolsHTML = `{{template "shellTop" "tools"}}
<h1>Tools</h1>
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
<label><input type=checkbox name=destructive> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</section>
{{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"}}
<h1>Routines</h1>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
{{range .Proposed}}<tr>
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=accept>
<button class=btn>accept</button></form></td>
<td><form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
</section>
{{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 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
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil {
log.Printf("notifications template: %v", err)
}
}
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
return
}
ctx := r.Context()
reminders, err := core.ListReminders(ctx, 50)
if err != nil {
log.Printf("reminders: %v", err)
http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
log.Printf("reminders template: %v", err)
}
}
// 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 creates the recurring reminder for a proposal, then marks the
// proposal accepted and links the reminder to it. Weekly patterns get a cron
// expression; any other interval fires once.
//
// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
// When the tick loop learns to read accepted proposals directly, both callers
// should hand off to one place in core instead of each building a reminder.
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
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := morningTmpl.Execute(w, status); err != nil {
log.Printf("morning render: %v", err)
}
}
func handleVoice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := voiceTmpl.Execute(w, nil); err != nil {
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
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := toolsTmpl.Execute(w, struct {
Msg string
Proposed []ipc.Tool
Enabled []ipc.Tool
}{msg, proposed, enabled}); 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) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", 405)
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"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
{{else}}
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
</div>
<form method=post action=/api/chat class=chat-form>
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
<button class=btn>send</button>
</form>
</section>
<script>
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
{{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
}
reply, err := core.Chat(r.Context(), 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
}