eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The `lchat bot` subcommand family: the Go bot sidecar (docs/bots.md §
2// Direction: a Go sidecar). Distinct from `add-bot` (a router-box control
3// command that stands up the Node sidecar): `lchat bot ...` IS the sidecar,
4// run wherever the operator wants the bot's keys to live — which need not be
5// the server box, since it only ever dials out.
6package main
7
8import (
9 "context"
10 "flag"
11 "fmt"
12 "log"
13 "os"
14 "strings"
15
16 "lchat/internal/bot"
17)
18
19func runBotCmd(args []string) {
20 if len(args) < 1 {
21 fmt.Fprintln(os.Stderr, `usage: lchat bot <command>
22
23commands:
24 provision claim an agent invite and write the bot's session file
25 run run the live sidecar: connect each identity and follow its recipe
26 (command floor + model + connected-services; the standard pair zero-config)`)
27 os.Exit(2)
28 }
29 switch args[0] {
30 case "provision":
31 runBotProvision(args[1:])
32 case "announce":
33 // Retired: the server announces its own changelog now (announce.go) —
34 // admins get a push and read the notes in the Admin app's update stream.
35 die("bot announce: retired — the server announces release notes itself; see docs/bots.md")
36 case "run":
37 runBotRun(args[1:])
38 default:
39 die("bot: unknown command %q", args[0])
40 }
41}
42
43// runBotRun is the live runtime (agent/runtime.mjs's core in Go): connect over
44// WebSocket as one or more provisioned identities and run each one's recipe
45// (docs/bot-recipes.md) — behaviour as data from a bounded trigger→action
46// palette. A bot with no recipe file gets the default: answer the
47// deterministic command floor when addressed, exactly what the Node sidecar
48// does with no ANTHROPIC_API_KEY. The model layer plugs into the same seam
49// next (docs/bots.md).
50//
51// One process, many bots: each session file on the command line becomes one
52// identity with its own connection and recipe (<session>.recipe.json), so a
53// single `lchat bot run` replaces a whole sidecar's worth of Node.
54func runBotRun(args []string) {
55 fs := flag.NewFlagSet("bot run", flag.ExitOnError)
56 base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)")
57 _ = fs.Parse(reorderFlagsFirst(fs, args))
58
59 if *base == "" {
60 die("bot run: set -base (or LCHAT_BASE) to your instance URL")
61 }
62 // Each identity pairs a session path with the one-time invite that can
63 // mint it: a fresh sidecar self-provisions on first boot (#806) instead of
64 // crash-looping until an operator runs `bot provision` by hand — the
65 // behavior add-bot and the activator co-spawn were written to rely on.
66 type botIdentity struct {
67 path string
68 invite string // one-time agent invite; consulted only when the session is absent
69 name string // identity name stamped into a self-provisioned session
70 }
71 var ids []botIdentity
72 if args := fs.Args(); len(args) > 0 {
73 for _, p := range args {
74 ids = append(ids, botIdentity{path: p})
75 }
76 if len(ids) == 1 {
77 // A lone hand-run session may still self-provision, matching the
78 // Node reference sidecar (it redeems LCHAT_INVITE on first boot).
79 ids[0].invite = os.Getenv("LCHAT_INVITE")
80 ids[0].name = envOr("LCHAT_AGENT_NAME", "Claude")
81 }
82 } else {
83 // No sessions on the command line: source them from the env the sidecar
84 // unit provides, running whichever identities are set — so the generic
85 // lchat-bot@.service template needs no per-host args, and a full pair or an
86 // Eleven-only box both work. Falls back to a lone session.json for a
87 // hand-run bot with nothing set. (LCHAT_ANNOUNCER_SESSION stays listed so
88 // a pre-retirement EnvironmentFile still resolves; the session is then
89 // skipped by name below — and never self-provisioned: the identity is
90 // retired.)
91 for _, src := range []struct{ session, invite, name string }{
92 {"LCHAT_SESSION", "LCHAT_INVITE", "Claude"},
93 {"LCHAT_ELEVEN_SESSION", "LCHAT_ELEVEN_INVITE", "Eleven"},
94 {"LCHAT_ANNOUNCER_SESSION", "", "Announcer"},
95 } {
96 if p := strings.TrimSpace(os.Getenv(src.session)); p != "" {
97 var invite string
98 if src.invite != "" {
99 invite = os.Getenv(src.invite)
100 }
101 ids = append(ids, botIdentity{path: p, invite: invite, name: src.name})
102 }
103 }
104 if len(ids) == 0 {
105 ids = []botIdentity{{path: "session.json", invite: os.Getenv("LCHAT_INVITE"), name: envOr("LCHAT_AGENT_NAME", "Claude")}}
106 }
107 }
108 var engines []*bot.Engine
109 for _, id := range ids {
110 sess, err := bot.EnsureSession(strings.TrimRight(*base, "/"), id.path, id.invite, id.name, log.Printf)
111 if err != nil {
112 die("bot run: %v", err)
113 }
114 if sess == nil {
115 die("bot run: no session at %s — provision it first (lchat bot provision, or set the matching invite env var)", id.path)
116 }
117 path := id.path
118 if sess.Name == "Announcer" {
119 // The Announcer is retired (the server announces its own changelog —
120 // announce.go). Running this identity under the default message recipe
121 // would turn the old write-only bot into a responder, so skip it; the
122 // operator should remove the member from the roster when convenient.
123 log.Printf("bot run: %s: the Announcer is retired — skipping this identity (remove it from the roster; see docs/bots.md)", path)
124 continue
125 }
126 // A broken recipe stops the bot (fail closed) — silently falling back
127 // to the default would run behaviour the operator didn't write.
128 recipe, err := bot.LoadRecipe(bot.RecipePathFor(path))
129 if err != nil {
130 die("bot run: %v", err)
131 }
132 if recipe == nil {
133 // No recipe file → the built-in default, so the standard pair
134 // (Claude / Eleven) deploys with zero config.
135 recipe = bot.DefaultRecipe()
136 } else {
137 log.Printf("bot run: %s follows %s%s", sess.Name, bot.RecipePathFor(path),
138 map[bool]string{true: " (watches every message)", false: ""}[recipe.Watches()])
139 }
140 eng := &bot.Engine{
141 Base: strings.TrimRight(*base, "/"),
142 Session: sess,
143 SessionPath: path,
144 Recipe: recipe,
145 // With a key the bots upgrade to conversational AI (and badge AI);
146 // without one they run the deterministic floor and badge BOT.
147 AIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")),
148 Logf: log.Printf,
149 }
150 engines = append(engines, eng)
151 }
152 // Elect the CHARTER HOST — the one identity that runs member bots
153 // (docs/member-bots.md). Exactly one per sidecar, or every charter would
154 // fire once per identity. Eleven when present (the builder members
155 // already ask for things); otherwise the first identity.
156 host := 0
157 for i, eng := range engines {
158 if eng.Session.Name == "Eleven" {
159 host = i
160 break
161 }
162 }
163 for i, eng := range engines {
164 eng.Charters = i == host
165 if eng.Charters {
166 log.Printf("bot run: %s hosts member bots", eng.Session.Name)
167 }
168 go func(eng *bot.Engine) {
169 if err := eng.Run(context.Background()); err != nil {
170 log.Printf("bot run: %s: %v", eng.Session.Name, err)
171 }
172 }(eng)
173 }
174 log.Printf("bot run: %d identity(ies) connecting to %s", len(engines), *base)
175 select {} // run until killed; each runtime reconnects on its own
176}
177
178func runBotProvision(args []string) {
179 fs := flag.NewFlagSet("bot provision", flag.ExitOnError)
180 base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL, e.g. https://chat.example.com (env: LCHAT_BASE)")
181 session := fs.String("session", envOr("LCHAT_SESSION", "session.json"), "session file to write (env: LCHAT_SESSION)")
182 name := fs.String("name", envOr("LCHAT_AGENT_NAME", "Claude"), "bot name for the session file (env: LCHAT_AGENT_NAME)")
183 _ = fs.Parse(reorderFlagsFirst(fs, args))
184
185 if *base == "" {
186 die("bot provision: set -base (or LCHAT_BASE) to your instance URL")
187 }
188 // The invite fragment is a secret, so read it from the environment only (a
189 // flag would leak it into the process table + shell history) — same rule as
190 // ANTHROPIC_API_KEY in add-bot.
191 fragment := strings.TrimSpace(os.Getenv("LCHAT_INVITE"))
192 if fragment == "" {
193 die("bot provision: set LCHAT_INVITE to the #fragment of a /join/... link (mint an agent invite in the admin console)")
194 }
195 if existing, err := bot.LoadSession(*session); err != nil {
196 die("bot provision: %v", err)
197 } else if existing != nil {
198 die("bot provision: already provisioned (%s) — delete it to re-provision", *session)
199 }
200
201 s, err := bot.Provision(*base, fragment, *name)
202 if err != nil {
203 die("bot provision: %v", err)
204 }
205 if err := s.Save(*session); err != nil {
206 die("bot provision: %v", err)
207 }
208 fmt.Printf("Provisioned %q. Session written to %s — it holds the bot's private key; keep it secret, back it up.\n", *name, *session)
209 fmt.Println("Now add the bot to a group, or start a DM with it from the app.")
210}
211