diff --git a/README.md b/README.md index 1656d83..893a72a 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,18 @@ by Profullstack, Inc. ```bash -ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome -ssh join@profullstack.com # register your SSH key (prints instructions, disconnects) -ssh @profullstack.com # the hub as a member: saves, your own WADs, leaderboards -ssh pod@profullstack.com # your own Linux pod — members, $1/mo via CoinPay +ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome +ssh join@profullstack.com # register your SSH key (prints instructions, disconnects) +ssh @profullstack.com # the hub as a member — or finger someone else's name +ssh pod@profullstack.com # your own Linux pod — members, $1/mo via CoinPay +ssh video-@profullstack.com # join a PairUX video call as truecolor ASCII +ssh agent@profullstack.com # chat with the operator's AI agent ``` No browser, no install, no client download. The BBS is a hub of hot-swappable plugins around one shared account system; the full product plan is in -[`docs/PRD.md`](docs/PRD.md) and [`docs/pods.md`](docs/pods.md). +[`docs/PRD.md`](docs/PRD.md), [`docs/pods.md`](docs/pods.md), +[`docs/video.md`](docs/video.md), and [`docs/social.md`](docs/social.md). ## Status @@ -21,6 +24,8 @@ plugins around one shared account system; the full product plan is in | M0 — core hub (wish server, auth, plugin contract, SQLite) | ✅ | | M1 — arcade (doom-ascii + Freedoom, sandbox, saves, leaderboards) | ✅ | | Pods (`pod@`, rootless containers, CoinPay membership) | ✅ | +| Video (`video-@`, PairUX/LiveKit → ASCII streaming) | ✅ | +| `agent@` chat (configurable agent backend) + finger | ✅ | | M2 — admin console | ⬜ | | M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | ⬜ | | M4 — Files (cl1.tech SFTP workspaces) | ⬜ | diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index d36cc5c..f5bfd9b 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -37,6 +37,8 @@ import ( gossh "golang.org/x/crypto/ssh" "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/calls" + "github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" @@ -138,11 +140,18 @@ func (a *app) router() wish.Middleware { hubHandler := activeterm.Middleware()(btMw(next)) return func(s ssh.Session) { user := strings.ToLower(s.User()) + code, isVideo := calls.RouteCode(user) switch { case auth.IsJoinName(user): a.handleJoin(s) case auth.IsPodName(user): a.handlePod(s) + case isVideo: + a.handleVideo(s, code) + case user == "agent": + a.handleChat(s) + case a.handleFinger(s, user): + // fingered an existing account that isn't the caller's; done. default: hubHandler(s) } @@ -280,6 +289,80 @@ func (a *app) handlePod(s ssh.Session) { } } +// handleVideo joins a PairUX call rendered as ASCII (docs/video.md). +// `video@` prompts for a code; `video-@` joins directly. Codes are +// minted by PairUX — starting a call requires already having one. +func (a *app) handleVideo(s ssh.Session, code string) { + identity := "ssh-guest" + if fp := auth.Fingerprint(s.PublicKey()); fp != "" { + if u, found, _ := a.st.UserByFingerprint(fp); found { + identity = "ssh-" + u.Name + } + } + sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "video") + defer func() { _ = a.st.EndSession(sessID) }() + if err := calls.Handle(s, code, identity); err != nil { + wish.Println(s, "video: "+err.Error()) + } +} + +// handleChat is the agent@ surface: talk to the operator's agent. +func (a *app) handleChat(s ssh.Session) { + u := auth.User{Name: "guest-" + remoteIP(s), Kind: auth.Guest} + if fp := auth.Fingerprint(s.PublicKey()); fp != "" { + if su, found, _ := a.st.UserByFingerprint(fp); found { + u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID} + } + } + sessID, _ := a.st.RecordSession(u.StoreID, s.User(), remoteIP(s), "agent") + defer func() { _ = a.st.EndSession(sessID) }() + if err := chat.Handle(s, a.st, u); err != nil { + wish.Println(s, "chat: "+err.Error()) + } +} + +// handleFinger prints a classic finger card when someone ssh's to an +// existing account name that isn't their own (e.g. ssh anthony@host). +// Returns false when the route should fall through to the hub. +func (a *app) handleFinger(s ssh.Session, username string) bool { + if auth.IsGuestName(username) { + return false + } + u, found, err := a.st.UserByName(username) + if err != nil || !found { + return false // unclaimed name → hub (claim flow) + } + if fp := auth.Fingerprint(s.PublicKey()); fp != "" && fp == u.PubKeyFP { + return false // it's them → hub + } + + lastSeen := "never" + if t, ok, _ := a.st.LastSeen(u.ID); ok { + lastSeen = t.Local().Format("2006-01-02 15:04 MST") + } + plan := "no plan." + for _, p := range []string{ + filepath.Join(a.dataDir, "users", u.Name, ".plan"), + filepath.Join(a.dataDir, "users", u.Name, "plan.txt"), + } { + if b, err := os.ReadFile(p); err == nil { + plan = strings.TrimSpace(string(b)) + break + } + } + _, _ = a.st.RecordSession(0, s.User(), remoteIP(s), "finger") + wish.Println(s, strings.Join([]string{ + "", + " Login: " + u.Name + " Kind: " + u.Kind, + " Member since: " + u.CreatedAt.Format("2006-01-02") + " Last seen: " + lastSeen, + " Plan:", + " " + strings.ReplaceAll(plan, "\n", "\n "), + "", + }, "\n")) + _ = s.Exit(0) + return true +} + func grantPod(st store.Store, args []string) { if len(args) < 2 { fmt.Fprintln(os.Stderr, "usage: agentbbs grant-pod ") diff --git a/cmd/lkpublish/main.go b/cmd/lkpublish/main.go new file mode 100644 index 0000000..133171b --- /dev/null +++ b/cmd/lkpublish/main.go @@ -0,0 +1,68 @@ +// Command lkpublish is a dev tool: it publishes a VP8 IVF file into a +// LiveKit room so the video-@ SSH route has something to render. +// +// ffmpeg -f lavfi -i testsrc=size=320x240:rate=15 -t 60 -c:v libvpx test.ivf +// go run ./cmd/lkpublish -room test123 -file test.ivf +package main + +import ( + "flag" + "os" + "os/signal" + "time" + + "github.com/charmbracelet/log" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" +) + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func main() { + room := flag.String("room", "test123", "room/call code to publish into") + file := flag.String("file", "test.ivf", "VP8 IVF file to stream") + fps := flag.Int("fps", 15, "frame rate to pace the file at") + flag.Parse() + + url := env("LIVEKIT_URL", "ws://localhost:7880") + key := env("LIVEKIT_API_KEY", "devkey") + secret := env("LIVEKIT_API_SECRET", "secret") + + r, err := lksdk.ConnectToRoom(url, lksdk.ConnectInfo{ + APIKey: key, + APISecret: secret, + RoomName: *room, + ParticipantIdentity: "lkpublish", + }, &lksdk.RoomCallback{}) + if err != nil { + log.Fatal("connect", "err", err) + } + defer r.Disconnect() + + // Explicit frame duration: don't trust IVF timebase interpretation. + track, err := lksdk.NewLocalFileTrack(*file, + lksdk.ReaderTrackWithFrameDuration(time.Second/time.Duration(*fps))) + if err != nil { + log.Fatal("track", "err", err) + } + // Width/height/source matter: without dimensions the SFU's dynacast has + // no layer to subscribe to and pauses the track entirely. + if _, err := r.LocalParticipant.PublishTrack(track, &lksdk.TrackPublicationOptions{ + Name: "test-pattern", + Source: livekit.TrackSource_CAMERA, + VideoWidth: 320, + VideoHeight: 240, + }); err != nil { + log.Fatal("publish", "err", err) + } + log.Info("publishing", "room", *room, "file", *file) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + <-sig +} diff --git a/docs/social.md b/docs/social.md new file mode 100644 index 0000000..c39f39e --- /dev/null +++ b/docs/social.md @@ -0,0 +1,41 @@ +# Social Routes Addendum: agent@ and finger + +## agent@ — talk to the operator + +```bash +ssh agent@profullstack.com +``` + +A chat TUI: visitors talk to the operator's AI agent. Every message (both +directions) is persisted to `chat_messages`, so the operator can read +conversations later; live operator takeover arrives with the M2 admin +console. + +The agent backend is one command, configured by env: + +| Var | Meaning | +|---|---| +| `AGENTBBS_AGENT_CMD` | command per message: user text on stdin, reply on stdout (120s timeout). e.g. `claude -p`, a logicsrc/commandboard agent, any script. Unset = "message saved for the operator" mode. | + +Identity: if the visitor's SSH key matches a member, the transcript is tied +to their account; otherwise it's keyed by remote address as a guest. + +## finger — `ssh @` + +SSH'ing to an account name that exists but **isn't yours** prints a classic +finger card and disconnects: + +``` +$ ssh anthony@profullstack.com + + Login: anthony Kind: member + Member since: 2026-06-11 Last seen: 2026-06-11 11:07 UTC + Plan: + building agentbbs. +``` + +- The plan is read from the member's `~/.plan` (or `plan.txt`) in their data + directory. +- Your own name (key matches) still lands you in the hub; unclaimed names + fall through to the hub's claim flow. +- Works keyless — like real finger. diff --git a/docs/video.md b/docs/video.md new file mode 100644 index 0000000..25222b7 --- /dev/null +++ b/docs/video.md @@ -0,0 +1,64 @@ +# Video Calls Addendum (PairUX over SSH) + +Join a PairUX video call from a terminal: the platform subscribes to the +call's LiveKit room and renders participant video as **truecolor ASCII** — +each character cell is two pixels via the upper-half block (▀), the same +technique doom-ascii uses. + +## SSH routes + +| Command | What happens | +|---|---| +| `ssh video-@profullstack.com` | join call `` directly | +| `ssh video@profullstack.com` | prompted for a code | + +**Codes are minted by PairUX.** The SSH surface never creates calls — to +start one you must already have a code (create the call in PairUX first). + +## Pipeline + +``` +PairUX / LiveKit room + └─ VP8 RTP track ── PLI keyframe requests ──┐ + └─ ivfwriter remux → ffmpeg (decode + scale) → RGB24 frames + └─ half-block ANSI (internal/ascii) → bubbletea → SSH PTY +``` + +- Subscriber-only: the terminal viewer publishes nothing. +- A PLI is sent on subscribe and every 2s — without it the SFU never starts + forwarding video to a fresh subscriber, and the periodic refresh bounds + packet-loss artifacts. +- Audio is not rendered (it's a terminal); v2 could downlink Opus → local + audio out for desktop SSH clients. +- Frame size locks to the terminal geometry at join (`scale=` in ffmpeg); + ~10–15 fps at typical terminal sizes costs a few hundred KB/s of SSH + bandwidth. + +## Configuration + +Shares PairUX's env shape: + +| Var | Meaning | +|---|---| +| `AGENTBBS_LIVEKIT_URL` (or `LIVEKIT_URL` / `NEXT_PUBLIC_LIVEKIT_URL`) | LiveKit ws URL | +| `AGENTBBS_LIVEKIT_KEY` (or `LIVEKIT_API_KEY`) | API key | +| `AGENTBBS_LIVEKIT_SECRET` (or `LIVEKIT_API_SECRET`) | API secret | +| `AGENTBBS_VIDEO_DEBUG` | dump the received IVF stream to this path | + +Unconfigured hosts refuse with a clear message. + +## Dev testing + +```bash +docker run -d -p 7880:7880 -p 7881:7881 -p 7882:7882/udp \ + livekit/livekit-server --dev --bind 0.0.0.0 # devkey/secret +ffmpeg -f lavfi -i testsrc=size=320x240:rate=15 -t 900 \ + -c:v libvpx -b:v 400k -g 15 -y test.ivf +go run ./cmd/lkpublish -room demo1 -fps 15 -file test.ivf +ssh -p 2222 video-demo1@localhost +``` + +Note: `lkpublish` passes an explicit frame duration — lksdk's IVF replay +pacing can't be trusted from the file timebase alone (we measured 1fps from +a 15fps file without it). Real PairUX publishers are browsers, which pace +correctly on their own. diff --git a/go.mod b/go.mod index bbf2757..1908d13 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/profullstack/agentbbs -go 1.25.0 +go 1.26 require ( github.com/charmbracelet/bubbletea v1.3.10 @@ -9,41 +9,110 @@ require ( github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 github.com/charmbracelet/wish v1.4.7 github.com/creack/pty v1.1.24 - golang.org/x/crypto v0.37.0 + github.com/livekit/server-sdk-go/v2 v2.16.6 + github.com/pion/webrtc/v4 v4.2.15 + golang.org/x/crypto v0.50.0 modernc.org/sqlite v1.52.0 ) require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect + buf.build/go/protovalidate v1.1.2 // indirect + buf.build/go/protoyaml v0.6.0 // indirect + cel.dev/expr v0.25.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/keygen v0.5.3 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/conpty v0.1.0 // indirect github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect github.com/charmbracelet/x/input v0.3.4 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.0 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dennwc/iters v1.2.2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/frostbyte73/core v0.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gammazero/deque v1.2.1 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/cel-go v0.27.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/jxskiss/base62 v1.1.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lithammer/shortuuid/v4 v4.2.0 // indirect + github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect + github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 // indirect + github.com/livekit/protocol v1.46.0 // indirect + github.com/livekit/psrpc v0.7.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/magefile/mage v1.17.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/moby/sys/user v0.4.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/ice/v4 v4.2.7 // indirect + github.com/pion/interceptor v0.1.45 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.10.2 // indirect + github.com/pion/sctp v1.10.0 // indirect + github.com/pion/sdp/v3 v3.0.18 // indirect + github.com/pion/srtp/v3 v3.0.11 // indirect + github.com/pion/stun/v3 v3.1.5 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/turn/v5 v5.0.9 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/twitchtv/twirp v8.1.3+incompatible // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.24.0 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 5797d2a..83401ca 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,43 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0= +buf.build/go/protovalidate v1.1.2/go.mod h1:Ez3z+w4c+wG+EpW8ovgZaZPnPl2XVF6kaxgcv1NG/QE= +buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w= +buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -16,85 +48,322 @@ github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+ts github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= +github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo= +github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.0.0+incompatible h1:KgsN2RUFMNM8wChxryicn4p46BdQWpXOA1XLGBGPGAw= +github.com/docker/cli v29.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA= +github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= +github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= +github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= +github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= +github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= +github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= +github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 h1:kGSXZ2P6K0Tv/5675P4JEPZJFGHe9AecX5g2F1/+71E= +github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5/go.mod h1:RCd46PT+6sEztld6XpkCrG1xskb0u3SqxIjy4G897Ss= +github.com/livekit/protocol v1.46.0 h1:onBkn2UEIX4qboVRtbdR1KZYNUoHfxHbgg7nqJ76e5s= +github.com/livekit/protocol v1.46.0/go.mod h1:KEPIJ/ZdMFQ9tmmfv/uT9TjQEuEcZupCZBabuRGEC1k= +github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= +github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= +github.com/livekit/server-sdk-go/v2 v2.16.6 h1:NBKw5l1AAOHsHAZKzuzAGzILRmSm+4E+/YZ9ZiaqudI= +github.com/livekit/server-sdk-go/v2 v2.16.6/go.mod h1:1+duFCDFpAvHqZ6mHQe7IwjecLIBFv/keJcexuFhD+0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= +github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= +github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8= +github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4= +github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI= +github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= +github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= +github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= +github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY= +github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo= +github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= +github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg= +github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= +github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= +github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= +github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= +github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM= +github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0= +github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= +github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= diff --git a/internal/ascii/ascii.go b/internal/ascii/ascii.go new file mode 100644 index 0000000..5a37dfe --- /dev/null +++ b/internal/ascii/ascii.go @@ -0,0 +1,52 @@ +// Package ascii converts raw video frames into truecolor terminal art. +// +// Each character cell renders two vertical pixels using the upper-half block +// (▀): foreground colors the top pixel, background the bottom — the same +// technique doom-ascii uses. A WxH terminal therefore displays a Wx(2H) +// pixel image. +package ascii + +import ( + "fmt" + "strings" +) + +// FrameRGB renders a packed RGB24 frame (w*h*3 bytes, as ffmpeg's rawvideo +// rgb24 emits) sized exactly for the target cell grid: w columns, h*2 rows +// of pixels. Rows are joined with \r\n so the output is PTY-safe. +func FrameRGB(buf []byte, w, h int) string { + if len(buf) < w*h*3 || w <= 0 || h <= 0 { + return "" + } + rows := h / 2 + var b strings.Builder + b.Grow(rows * w * 40) + for row := 0; row < rows; row++ { + top := row * 2 + bot := top + 1 + for x := 0; x < w; x++ { + ti := (top*w + x) * 3 + bi := (bot*w + x) * 3 + fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀", + buf[ti], buf[ti+1], buf[ti+2], + buf[bi], buf[bi+1], buf[bi+2]) + } + b.WriteString("\x1b[0m") + if row != rows-1 { + b.WriteString("\r\n") + } + } + return b.String() +} + +// FitEven clamps a terminal geometry to an even pixel height for the +// half-block renderer and returns pixel dimensions (pw, ph) for the decoder. +func FitEven(cols, rows int) (pw, ph int) { + if cols < 8 { + cols = 8 + } + if rows < 4 { + rows = 4 + } + return cols, (rows - 1) * 2 // leave one status line +} diff --git a/internal/calls/calls.go b/internal/calls/calls.go new file mode 100644 index 0000000..792994b --- /dev/null +++ b/internal/calls/calls.go @@ -0,0 +1,213 @@ +// Package calls renders PairUX video calls as truecolor ASCII in the +// terminal: `ssh video-@host` joins directly, `ssh video@host` prompts +// for a code. Codes are minted by PairUX — the SSH surface never creates +// calls, it only joins existing ones. +package calls + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/ssh" + + "github.com/profullstack/agentbbs/internal/ascii" +) + +// RouteCode extracts the call code from an SSH username: "video" → "", +// "video-abc123" → "abc123". Second return is false when the username is +// not a video route at all. +func RouteCode(username string) (string, bool) { + u := strings.ToLower(username) + if u == "video" { + return "", true + } + if strings.HasPrefix(u, "video-") && len(u) > len("video-") { + return u[len("video-"):], true + } + return "", false +} + +// Handle runs the call UI on the session. Standalone surface: leaving the +// call ends the SSH session. +func Handle(s ssh.Session, code, identity string) error { + ptyReq, winCh, hasPty := s.Pty() + if !hasPty { + _, _ = s.Write([]byte("video calls need a terminal (ssh -t)\r\n")) + return nil + } + w, h := ptyReq.Window.Width, ptyReq.Window.Height + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + m := &model{ + code: code, + identity: identity, + cfg: ConfigFromEnv(), + width: w, + height: h, + } + p := tea.NewProgram(m, + tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen()) + + go func() { + for w := range winCh { + p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height}) + } + }() + _, err := p.Run() + if m.sess != nil { + m.sess.Close() + } + return err +} + +type frameMsg string +type statusMsg string +type joinedMsg struct { + sess *session + err error +} + +type model struct { + code string + identity string + cfg Config + + input string // code entry buffer + sess *session + frame string + status string + errMsg string + + width, height int + pw int // pixel width locked at join time (decoder output width) + joining bool +} + +var ( + vTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) + vDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + vErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) +) + +func (m *model) Init() tea.Cmd { + if m.code != "" { + return m.join() + } + return nil +} + +// join connects in the background and pumps frames/status into the program. +func (m *model) join() tea.Cmd { + m.joining = true + code := m.code + pw, ph := ascii.FitEven(m.width, m.height) + m.pw = pw // decoder output is locked to this width for the session + cfg, id := m.cfg, m.identity + return func() tea.Msg { + sess, err := join(cfg, code, id, pw, ph) + return joinedMsg{sess: sess, err: err} + } +} + +// nextFrame renders one decoded frame at the locked decoder width. +func (m *model) nextFrame() tea.Cmd { + sess, pw := m.sess, m.pw + return func() tea.Msg { + buf, ok := <-sess.Frames + if !ok { + return statusMsg("stream ended") + } + ph := (len(buf) / 3) / pw + return frameMsg(ascii.FrameRGB(buf, pw, ph)) + } +} + +func (m *model) pump() tea.Cmd { + sess := m.sess + return tea.Batch( + m.nextFrame(), + func() tea.Msg { return statusMsg(<-sess.Status) }, + ) +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case joinedMsg: + m.joining = false + if msg.err != nil { + m.errMsg = msg.err.Error() + return m, nil + } + m.sess = msg.sess + return m, m.pump() + case frameMsg: + m.frame = string(msg) + return m, m.nextFrame() + case statusMsg: + m.status = string(msg) + if m.sess != nil { + return m, func() tea.Msg { return statusMsg(<-m.sess.Status) } + } + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + if m.sess == nil && m.code == "" && msg.String() == "q" { + break // let people type codes containing q + } + return m, tea.Quit + case "esc": + return m, tea.Quit + } + // Code entry mode. + if m.sess == nil && !m.joining && m.code == "" { + switch msg.String() { + case "enter": + if strings.TrimSpace(m.input) != "" { + m.code = strings.TrimSpace(strings.ToLower(m.input)) + return m, m.join() + } + case "backspace": + if len(m.input) > 0 { + m.input = m.input[:len(m.input)-1] + } + default: + if len(msg.String()) == 1 && len(m.input) < 64 { + m.input += msg.String() + } + } + } + } + return m, nil +} + +func (m *model) View() string { + switch { + case m.errMsg != "": + return lipgloss.NewStyle().Padding(1, 2).Render( + vTitle.Render("PairUX video") + "\n\n" + + vErr.Render(m.errMsg) + "\n\n" + + vDim.Render("esc to leave")) + case m.sess == nil && !m.joining: + return lipgloss.NewStyle().Padding(1, 2).Render( + vTitle.Render("PairUX video") + "\n\n" + + "Enter your call code (from pairux.com):\n\n" + + " > " + m.input + "█\n\n" + + vDim.Render("enter join · esc leave — don't have a code? create the call in PairUX first")) + case m.joining: + return lipgloss.NewStyle().Padding(1, 2).Render( + vTitle.Render("PairUX video") + "\n\n joining " + m.code + "…") + case m.frame == "": + return lipgloss.NewStyle().Padding(1, 2).Render( + vTitle.Render("PairUX video") + "\n\n " + m.status + "\n\n" + + vDim.Render("esc to leave")) + default: + return m.frame + "\r\n" + vDim.Render(" "+m.code+" · "+m.status+" · esc to leave") + } +} diff --git a/internal/calls/livekit.go b/internal/calls/livekit.go new file mode 100644 index 0000000..557480b --- /dev/null +++ b/internal/calls/livekit.go @@ -0,0 +1,223 @@ +// LiveKit subscriber: joins a PairUX room, takes the first remote VP8 video +// track, remuxes RTP into IVF, and has ffmpeg decode + scale it into raw +// RGB24 frames sized for the terminal grid. +package calls + +import ( + "fmt" + "io" + "os" + "os/exec" + "time" + + lksdk "github.com/livekit/server-sdk-go/v2" + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media/ivfwriter" +) + +// Config is the LiveKit deployment shared with PairUX. +type Config struct { + URL, Key, Secret string +} + +// ConfigFromEnv reads AGENTBBS_LIVEKIT_* with LIVEKIT_* fallbacks, matching +// PairUX's env shape (LIVEKIT_API_KEY / LIVEKIT_API_SECRET). +func ConfigFromEnv() Config { + pick := func(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" + } + return Config{ + URL: pick("AGENTBBS_LIVEKIT_URL", "LIVEKIT_URL", "NEXT_PUBLIC_LIVEKIT_URL"), + Key: pick("AGENTBBS_LIVEKIT_KEY", "LIVEKIT_API_KEY"), + Secret: pick("AGENTBBS_LIVEKIT_SECRET", "LIVEKIT_API_SECRET"), + } +} + +func (c Config) ok() bool { return c.URL != "" && c.Key != "" && c.Secret != "" } + +// session is one live subscription: frames arrive on Frames sized pw*ph*3. +type session struct { + Frames chan []byte + Status chan string + room *lksdk.Room + ffmpeg *exec.Cmd + ivf io.WriteCloser + done chan struct{} +} + +// join connects to room `code` as a hidden-ish subscriber and starts the +// decode pipeline targeting pw x ph pixels. +func join(cfg Config, code, identity string, pw, ph int) (*session, error) { + if !cfg.ok() { + return nil, fmt.Errorf("video calls are not configured on this host (LIVEKIT_URL/API_KEY/API_SECRET)") + } + + s := &session{ + Frames: make(chan []byte, 2), + Status: make(chan string, 8), + done: make(chan struct{}), + } + + // ffmpeg: IVF (VP8) on stdin → raw RGB24 frames on stdout. + ffin, ivfW := io.Pipe() + s.ivf = ivfW + if dump := os.Getenv("AGENTBBS_VIDEO_DEBUG"); dump != "" { + if f, err := os.Create(dump); err == nil { + s.ivf = teeWriteCloser{io.MultiWriter(ivfW, f), ivfW, f} + } + } + cmd := exec.Command("ffmpeg", + "-hide_banner", "-loglevel", "error", + "-probesize", "32", "-analyzeduration", "0", + "-fflags", "nobuffer", "-flags", "low_delay", + "-f", "ivf", "-i", "pipe:0", + "-vf", fmt.Sprintf("scale=%d:%d", pw, ph), + "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1", + ) + cmd.Stdin = ffin + cmd.Stderr = os.Stderr // surfaces decode errors in the server log + out, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("ffmpeg: %w", err) + } + s.ffmpeg = cmd + + go func() { // frame pump: drop frames rather than lag + size := pw * ph * 3 + for { + buf := make([]byte, size) + if _, err := io.ReadFull(out, buf); err != nil { + close(s.Frames) + return + } + select { + case s.Frames <- buf: + default: + } + } + }() + + gotTrack := false + cb := &lksdk.RoomCallback{ + ParticipantCallback: lksdk.ParticipantCallback{ + OnTrackSubscribed: func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + if gotTrack || track.Kind() != webrtc.RTPCodecTypeVideo { + return + } + if track.Codec().MimeType != webrtc.MimeTypeVP8 { + s.status("skipping non-VP8 track from " + rp.Identity()) + return + } + gotTrack = true + s.status("video from " + rp.Identity()) + // PLI is what makes the SFU start forwarding from a + // keyframe; without it a fresh subscriber starves. + rp.WritePLI(track.SSRC()) + go s.keyframeTicker(rp, track.SSRC()) + go s.consume(track) + }, + }, + OnDisconnected: func() { s.status("disconnected") }, + } + + room, err := lksdk.ConnectToRoom(cfg.URL, lksdk.ConnectInfo{ + APIKey: cfg.Key, + APISecret: cfg.Secret, + RoomName: code, + ParticipantIdentity: identity, + }, cb) + if err != nil { + s.Close() + return nil, fmt.Errorf("livekit: %w", err) + } + s.room = room + s.status("joined " + code + " — waiting for video…") + return s, nil +} + +// keyframeTicker re-requests keyframes so late joins and packet loss recover +// quickly; a periodic full refresh is cheap at terminal resolutions. +func (s *session) keyframeTicker(rp *lksdk.RemoteParticipant, ssrc webrtc.SSRC) { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for range t.C { + select { + case <-s.done: + return + default: + } + rp.WritePLI(ssrc) + } +} + +// consume remuxes the track's RTP into the IVF pipe until it ends. +func (s *session) consume(track *webrtc.TrackRemote) { + w, err := ivfwriter.NewWith(s.ivf) + if err != nil { + s.status("ivf: " + err.Error()) + return + } + n := 0 + for { + pkt, _, err := track.ReadRTP() + if err != nil { + s.status(fmt.Sprintf("track ended after %d packets: %v", n, err)) + _ = w.Close() + return + } + if err := w.WriteRTP(pkt); err != nil { + s.status("ivf write: " + err.Error()) + return + } + n++ + if n == 1 || n%500 == 0 { + s.status(fmt.Sprintf("receiving (%d rtp packets)", n)) + } + } +} + +func (s *session) status(msg string) { + select { + case s.Status <- msg: + default: + } +} + +// teeWriteCloser mirrors the IVF stream to a debug file (AGENTBBS_VIDEO_DEBUG). +type teeWriteCloser struct { + io.Writer + pipe io.WriteCloser + file io.Closer +} + +func (t teeWriteCloser) Close() error { + _ = t.file.Close() + return t.pipe.Close() +} + +// Close tears the whole pipeline down. +func (s *session) Close() { + select { + case <-s.done: + default: + close(s.done) + } + if s.room != nil { + s.room.Disconnect() + } + if s.ivf != nil { + _ = s.ivf.Close() + } + if s.ffmpeg != nil && s.ffmpeg.Process != nil { + _ = s.ffmpeg.Process.Kill() + _ = s.ffmpeg.Wait() + } +} diff --git a/internal/chat/chat.go b/internal/chat/chat.go new file mode 100644 index 0000000..714ecd1 --- /dev/null +++ b/internal/chat/chat.go @@ -0,0 +1,177 @@ +// Package chat is the agent@ surface: talk to the operator's AI agent (or, +// once the M2 admin console lands, the operator live). +// +// The agent backend is one configurable command, AGENTBBS_AGENT_CMD: each +// user message is piped to its stdin, stdout comes back as the reply. Point +// it at anything — `claude -p`, a logicsrc/commandboard agent, a shell +// script. Unset = a polite "leave a message" mode (messages are persisted +// either way, so the operator can read them later). +package chat + +import ( + "context" + "os" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/ssh" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/store" +) + +const historyLines = 200 + +// Handle runs the chat UI; leaving ends the SSH session. +func Handle(s ssh.Session, st store.Store, user auth.User) error { + ptyReq, winCh, hasPty := s.Pty() + if !hasPty { + _, _ = s.Write([]byte("agent chat needs a terminal (ssh -t)\r\n")) + return nil + } + m := &model{ + st: st, + user: user, + width: ptyReq.Window.Width, + height: ptyReq.Window.Height, + } + if msgs, err := st.RecentChats(user.Name, 20); err == nil { + for _, c := range msgs { + m.lines = append(m.lines, render(c.Role, c.Text)) + } + if len(msgs) > 0 { + m.lines = append(m.lines, cDim.Render("— earlier conversation —")) + } + } + p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen()) + go func() { + for w := range winCh { + p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height}) + } + }() + _, err := p.Run() + return err +} + +var ( + cTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc")) + cYou = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + cAgent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c084fc")) + cDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + cErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) +) + +func render(role, text string) string { + if role == "user" { + return cYou.Render("you ") + text + } + return cAgent.Render("agent ") + text +} + +type replyMsg struct { + text string + err error +} + +type model struct { + st store.Store + user auth.User + + lines []string + input string + waiting bool + + width, height int +} + +func (m *model) Init() tea.Cmd { return nil } + +// ask pipes the message to the configured agent command. +func (m *model) ask(text string) tea.Cmd { + return func() tea.Msg { + cmdline := strings.TrimSpace(os.Getenv("AGENTBBS_AGENT_CMD")) + if cmdline == "" { + return replyMsg{text: "The operator's agent isn't wired up right now — your message is saved and a human will read it."} + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + parts := strings.Fields(cmdline) + cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) + cmd.Stdin = strings.NewReader(text) + out, err := cmd.Output() + if err != nil { + return replyMsg{err: err} + } + reply := strings.TrimSpace(string(out)) + if reply == "" { + reply = "(no reply)" + } + return replyMsg{text: reply} + } +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case replyMsg: + m.waiting = false + if msg.err != nil { + m.lines = append(m.lines, cErr.Render("agent error: "+msg.err.Error())) + return m, nil + } + _ = m.st.AddChat(m.user.StoreID, m.user.Name, "agent", msg.text) + for _, l := range strings.Split(msg.text, "\n") { + m.lines = append(m.lines, render("agent", l)) + } + if len(m.lines) > historyLines { + m.lines = m.lines[len(m.lines)-historyLines:] + } + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + text := strings.TrimSpace(m.input) + if text == "" || m.waiting { + return m, nil + } + m.input = "" + m.waiting = true + _ = m.st.AddChat(m.user.StoreID, m.user.Name, "user", text) + m.lines = append(m.lines, render("user", text)) + return m, m.ask(text) + case "backspace": + if len(m.input) > 0 { + m.input = m.input[:len(m.input)-1] + } + default: + if msg.Type == tea.KeyRunes || msg.String() == " " { + m.input += string(msg.Runes) + } + } + } + return m, nil +} + +func (m *model) View() string { + rows := m.height - 4 + if rows < 3 { + rows = 3 + } + start := 0 + if len(m.lines) > rows { + start = len(m.lines) - rows + } + body := strings.Join(m.lines[start:], "\n") + prompt := "> " + m.input + "█" + if m.waiting { + prompt = cDim.Render("agent is thinking…") + } + return lipgloss.NewStyle().Padding(0, 1).Render( + cTitle.Render("agent@ — talk to profullstack") + cDim.Render(" (esc to leave)") + "\n" + + body + "\n\n" + prompt) +} diff --git a/internal/store/store.go b/internal/store/store.go index 5216c97..3db9c21 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -35,6 +35,10 @@ type Store interface { EnsureUser(name, kind, pubkeyFP string) (User, error) // UserByFingerprint finds an account by SSH key fingerprint. UserByFingerprint(fp string) (User, bool, error) + // UserByName finds an account by exact username (no creation). + UserByName(name string) (User, bool, error) + // LastSeen reports the start of the user's most recent session. + LastSeen(userID int64) (time.Time, bool, error) RecordSession(userID int64, username, remote, route string) (int64, error) EndSession(sessionID int64) error @@ -46,9 +50,20 @@ type Store interface { PodPaidUntil(userID int64) (time.Time, bool, error) GrantPod(userID int64, until time.Time, paymentRef string) error + // Chat transcripts for the agent@ surface. + AddChat(userID int64, username, role, text string) error + RecentChats(username string, n int) ([]ChatMessage, error) + Close() error } +// ChatMessage is one line of an agent@ conversation. +type ChatMessage struct { + Role string // "user" or "agent" + Text string + At time.Time +} + // ErrKeyMismatch means a username is already registered with another key. var ErrKeyMismatch = errors.New("username registered with a different key") @@ -92,6 +107,15 @@ CREATE TABLE IF NOT EXISTS scores ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC); +CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY, + user_id INTEGER, + username TEXT NOT NULL, + role TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_messages(username, id); CREATE TABLE IF NOT EXISTS pod_subscriptions ( user_id INTEGER PRIMARY KEY REFERENCES users(id), paid_until TEXT NOT NULL, @@ -211,4 +235,65 @@ func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error return err } +func (s *sqliteStore) UserByName(name string) (User, bool, error) { + var u User + var created string + err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name). + Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created) + if err == sql.ErrNoRows { + return User{}, false, nil + } + if err != nil { + return User{}, false, err + } + u.CreatedAt, _ = time.Parse(time.RFC3339, created) + return u, true, nil +} + +func (s *sqliteStore) LastSeen(userID int64) (time.Time, bool, error) { + var at string + err := s.db.QueryRow(`SELECT started_at FROM sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID).Scan(&at) + if err == sql.ErrNoRows { + return time.Time{}, false, nil + } + if err != nil { + return time.Time{}, false, err + } + t, err := time.Parse(time.RFC3339, at) + return t, err == nil, err +} + +func (s *sqliteStore) AddChat(userID int64, username, role, text string) error { + var uid any + if userID > 0 { + uid = userID + } + _, err := s.db.Exec(`INSERT INTO chat_messages (user_id, username, role, text) VALUES (?,?,?,?)`, + uid, username, role, text) + return err +} + +func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) { + rows, err := s.db.Query(` + SELECT role, text, created_at FROM ( + SELECT id, role, text, created_at FROM chat_messages + WHERE username = ? ORDER BY id DESC LIMIT ? + ) ORDER BY id ASC`, username, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ChatMessage + for rows.Next() { + var m ChatMessage + var at string + if err := rows.Scan(&m.Role, &m.Text, &at); err != nil { + return nil, err + } + m.At, _ = time.Parse(time.RFC3339, at) + out = append(out, m) + } + return out, rows.Err() +} + func (s *sqliteStore) Close() error { return s.db.Close() } diff --git a/lkpublish b/lkpublish new file mode 100755 index 0000000..65fd74d Binary files /dev/null and b/lkpublish differ diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..f3ba969 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.26"