eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3// Charters — the data model of member bots (docs/member-bots.md). A charter
4// is everything a member's bot IS: a persona (name + glyph), a schedule, a
5// task (prompt + tools + budgets), and where its output goes. It is stored as
6// an instance-key-encrypted blob in the server's member_bots table — exactly
7// the member-app storage pattern (owner-gated, opaque to the server) — and
8// run by the charter host, one designated identity in the existing sidecar.
9// No new E2EE identity, no minting endpoint, no supervisor: the "bot" the
10// member meets is a persona the host posts under, into a dedicated thread.
11// (Option B of the identity-model decision in issue #432.)
12
13import (
14 "fmt"
15 "net/url"
16 "os"
17 "regexp"
18 "time"
19
20 "lchat/internal/e2ee"
21)
22
23const charterVersion = 1
24
25// Charter is the decrypted shape of one member bot.
26type Charter struct {
27 V int `json:"v"`
28 // Name is the slug ("morning-news"); Title is the persona — the name the
29 // bot posts under and the dedicated thread's title ("Morning News").
30 Name string `json:"name"`
31 Title string `json:"title"`
32 Glyph string `json:"glyph"`
33 // Schedule is when the bot acts; Task is what it does.
34 Schedule ScheduleSpec `json:"schedule"`
35 Task TaskSpec `json:"task"`
36 // Thread is the dedicated thread (owner + host) the bot posts into,
37 // created by the builder at birth.
38 Thread string `json:"thread"`
39 // Owner is the member the bot belongs to; By their display name; BuiltBy
40 // the host identity that built it. Ownership is enforced in the sidecar
41 // (where plaintext lives), like member apps.
42 Owner string `json:"owner"`
43 By string `json:"by,omitempty"`
44 BuiltBy string `json:"builtBy,omitempty"`
45 // Paused stops the schedule without deleting the charter (zero value =
46 // running, so a plain create is live).
47 Paused bool `json:"paused,omitempty"`
48}
49
50// ScheduleSpec is when a charter fires: exactly one of At (daily wall-clock
51// "HH:MM", with optional IANA TZ, default UTC) or Every (a Go duration).
52type ScheduleSpec struct {
53 At string `json:"at,omitempty"`
54 TZ string `json:"tz,omitempty"`
55 Every string `json:"every,omitempty"`
56
57 loc *time.Location
58 every time.Duration
59}
60
61var charterAtRe = regexp.MustCompile(`^([01]?\d|2[0-3]):([0-5]\d)$`)
62
63// minEvery floors interval schedules — the cost valve: every interval is a
64// standing scheduled model run, so the floor is what stops "every second"
65// from being expressible (validation is the capability boundary, like the
66// recipe palette). 15 minutes in production; tests and demo canaries lower it
67// via LCHAT_BOT_MIN_EVERY (never set in production units — the same pattern
68// as LCHAT_BOT_ALLOW_PRIVATE_FETCH).
69func minEvery() time.Duration {
70 if v := os.Getenv("LCHAT_BOT_MIN_EVERY"); v != "" {
71 if d, err := time.ParseDuration(v); err == nil && d > 0 {
72 return d
73 }
74 }
75 return 15 * time.Minute
76}
77
78func (s *ScheduleSpec) validate() error {
79 if (s.At == "") == (s.Every == "") {
80 return fmt.Errorf("schedule needs exactly one of at / every")
81 }
82 if s.At != "" {
83 if !charterAtRe.MatchString(s.At) {
84 return fmt.Errorf("bad at %q (want HH:MM)", s.At)
85 }
86 s.loc = time.UTC
87 if s.TZ != "" {
88 loc, err := time.LoadLocation(s.TZ)
89 if err != nil {
90 return fmt.Errorf("bad tz %q", s.TZ)
91 }
92 s.loc = loc
93 }
94 return nil
95 }
96 if s.TZ != "" {
97 return fmt.Errorf("tz belongs to at-schedules")
98 }
99 d, err := time.ParseDuration(s.Every)
100 if err != nil || d < minEvery() {
101 return fmt.Errorf("bad every %q (min %s)", s.Every, minEvery())
102 }
103 s.every = d
104 return nil
105}
106
107// next computes when the schedule should fire, strictly after now. Pure — the
108// clock is a parameter, so tests pin the maths. validate must have run.
109func (s *ScheduleSpec) next(now time.Time) time.Time {
110 if s.At != "" {
111 var h, m int
112 fmt.Sscanf(s.At, "%d:%d", &h, &m)
113 local := now.In(s.loc)
114 next := time.Date(local.Year(), local.Month(), local.Day(), h, m, 0, 0, s.loc)
115 if !next.After(now) {
116 next = next.AddDate(0, 0, 1)
117 }
118 return next
119 }
120 return now.Add(s.every)
121}
122
123// Summary is the schedule in words, for chat ("daily at 08:00 (Europe/Dublin)").
124func (s *ScheduleSpec) Summary() string {
125 if s.At != "" {
126 if s.TZ != "" {
127 return fmt.Sprintf("daily at %s (%s)", s.At, s.TZ)
128 }
129 return fmt.Sprintf("daily at %s UTC", s.At)
130 }
131 return "every " + s.Every
132}
133
134// validateCharter checks a decoded charter end to end (and compiles its
135// schedule). Fail closed: a charter that doesn't validate never runs.
136func validateCharter(c *Charter) error {
137 if c == nil {
138 return fmt.Errorf("no charter")
139 }
140 if c.V != charterVersion {
141 return fmt.Errorf("unknown charter version %d", c.V)
142 }
143 if !appNameRe.MatchString(c.Name) {
144 return fmt.Errorf("bad bot name %q", c.Name)
145 }
146 if c.Title == "" || len(c.Title) > 40 {
147 return fmt.Errorf("title must be 1-40 characters")
148 }
149 if c.Glyph == "" || len(c.Glyph) > 16 {
150 return fmt.Errorf("glyph must be one emoji")
151 }
152 if c.Owner == "" {
153 return fmt.Errorf("charter has no owner")
154 }
155 if err := c.Schedule.validate(); err != nil {
156 return err
157 }
158 return validateTaskSpec(0, &c.Task)
159}
160
161// ---- charter store access (the member_bots endpoints) ----------------------
162//
163// Charters ride the same ordinary member endpoints as everything else the
164// sidecar does; the server never sees inside a blob.
165
166// charterEntry is one stored charter, decrypted. Entries whose blobs don't
167// decrypt or validate are skipped by the lister (fail closed per charter, not
168// per list — one corrupt row must not kill everyone's bots).
169type charterEntry struct {
170 ID string
171 Charter *Charter
172}
173
174// listCharters fetches and decrypts every stored charter.
175func (r *Runtime) listCharters(instKey []byte) ([]charterEntry, error) {
176 var list struct {
177 Bots []struct {
178 ID string `json:"id"`
179 Blob string `json:"blob"`
180 } `json:"bots"`
181 }
182 if err := r.get("/api/member-bots", &list); err != nil {
183 return nil, err
184 }
185 var out []charterEntry
186 for _, b := range list.Bots {
187 var c Charter
188 if err := e2ee.DecryptJSON(instKey, b.Blob, &c); err != nil {
189 r.logf("charter %s: undecryptable — skipped", b.ID)
190 continue
191 }
192 if err := validateCharter(&c); err != nil {
193 r.logf("charter %s: %v — skipped", b.ID, err)
194 continue
195 }
196 out = append(out, charterEntry{ID: b.ID, Charter: &c})
197 }
198 // Every read refreshes the thread→charter map that makes each bot's home
199 // thread conversational (runtime.go) — connect, reconcile, ticks, builds
200 // and removals all pass through here, so the map never goes stale long.
201 r.setCharterHomes(out)
202 return out, nil
203}
204
205// fetchCharter loads one charter by id (nil if absent/undecryptable/invalid).
206func (r *Runtime) fetchCharter(id string, instKey []byte) (*Charter, error) {
207 entries, err := r.listCharters(instKey)
208 if err != nil {
209 return nil, err
210 }
211 for _, e := range entries {
212 if e.ID == id {
213 return e.Charter, nil
214 }
215 }
216 return nil, nil
217}
218
219// putCharter encrypts and stores a charter, recording the owner so it's
220// theirs to manage even though the host stored it (the agent-sets-owner
221// pattern, memberbots.go).
222func (r *Runtime) putCharter(id string, c *Charter, instKey []byte) error {
223 blob, err := e2ee.EncryptJSON(instKey, c)
224 if err != nil {
225 return err
226 }
227 return r.put("/api/member-bots/"+id, map[string]string{
228 "token": r.Session.Token, "blob": blob, "owner_id": c.Owner,
229 })
230}
231
232// deleteCharter removes a stored charter.
233func (r *Runtime) deleteCharter(id string) error {
234 return r.del("/api/member-bots/" + id + "?token=" + url.QueryEscape(r.Session.Token))
235}
236
237// nudgeInstanceKey asks online instance-key holders to wrap the key to anyone
238// missing it (this bot included) — the same nudge a keyless browser sends.
239// Best-effort: the grant lands whenever a holder's tab answers.
240func (r *Runtime) nudgeInstanceKey() {
241 _ = r.post("/api/instance-key/nudge?token="+url.QueryEscape(r.Session.Token), map[string]any{}, nil)
242}
243
244// charterNames returns the set of taken bot slugs, for uniqueAppName-style
245// de-duping at build time.
246func charterNames(entries []charterEntry) map[string]bool {
247 names := make(map[string]bool, len(entries))
248 for _, e := range entries {
249 names[e.Charter.Name] = true
250 }
251 return names
252}
253
254// summaryLine is the one-line description of a charter for chat.
255func (c *Charter) summaryLine() string {
256 src := ""
257 if c.Task.Fetch != nil && len(c.Task.Fetch.Allow) > 0 {
258 src = fmt.Sprintf(", reading %d source(s)", len(c.Task.Fetch.Allow))
259 }
260 return fmt.Sprintf("%s %s — %s%s", c.Title, c.Glyph, c.Schedule.Summary(), src)
261}
262