20184874b2
Backlog item #3 (20-07-2026-BACKLOG.md). A morning routine is a checklist for a daily window: several items, each evidenced by a fact key, completed in any order, checked once near the end of the window. Modelling it as four independent reminder timers would stack into exactly the kind of noise Maven is supposed not to produce, so the engine nags at most once per day per routine and only for what is actually still missing. internal/morning follows the established pure-engine pattern (loop, routine, pattern): no store, no clock of its own. Evaluate answers "what's still missing" at any point; Due decides whether to nag. The impurity — reading facts under the store lock, holding the last-nudge map across ticks — stays in the tick driver, which calls Due each tick exactly as it does for loop.Rule and routine.Routine. Completion evidence is a fact key's latest non-voided value timestamped inside today's window, so manual ("выпил воды", voice-tapped) and inferred (another daemon writing the same key) are indistinguishable and both count. Weekdays scopes which days a routine applies to, so weekday/weekend variants are two routine rows rather than a special case in the engine. Exposed read-only: a MorningStatus RPC over ipc, and a /morning page in mavweb built on the same server-rendered shape as /trace — no live-update loop, since checklist state moves on the scale of minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
1220 lines
41 KiB
Go
1220 lines
41 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/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">×</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", ":9200", "HTTP listen address")
|
|
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 (/tools POST, /api/revert) 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)")
|
|
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)
|
|
})
|
|
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("/routines", func(w http.ResponseWriter, r *http.Request) {
|
|
handleRoutines(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) and POST /api/revert 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) and POST /api/revert (voids the latest fact for a key). 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)
|
|
})
|
|
|
|
// /api/revert voids the latest fact for a key — a store mutation, so it
|
|
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
|
// WebAuthn unconfigured ⇒ transport-level auth only, same as /tools).
|
|
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)
|
|
})
|
|
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. Lists detected patterns
|
|
// awaiting human confirmation, with accept (→ reminder) and dismiss buttons.
|
|
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>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
|
{{if .Proposed}}<div class=scroll><table><tr><th>action</th><th>object</th><th>every</th><th></th></tr>
|
|
{{range .Proposed}}<tr>
|
|
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</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)
|
|
}
|
|
}
|
|
|
|
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
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 "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 []ipc.ProposedRoutine
|
|
}{msg, proposed}); err != nil {
|
|
log.Printf("routines render: %v", err)
|
|
}
|
|
}
|
|
|
|
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if core == nil {
|
|
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
|
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.
|
|
func handleChatAPI(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, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
|
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
|
|
}
|