| 1 | package 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 | |
| 13 | import ( |
| 14 | "fmt" |
| 15 | "net/url" |
| 16 | "os" |
| 17 | "regexp" |
| 18 | "time" |
| 19 | |
| 20 | "lchat/internal/e2ee" |
| 21 | ) |
| 22 | |
| 23 | const charterVersion = 1 |
| 24 | |
| 25 | // Charter is the decrypted shape of one member bot. |
| 26 | type 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). |
| 52 | type 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 | |
| 61 | var 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). |
| 69 | func 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 | |
| 78 | func (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. |
| 109 | func (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)"). |
| 124 | func (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. |
| 136 | func 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). |
| 169 | type charterEntry struct { |
| 170 | ID string |
| 171 | Charter *Charter |
| 172 | } |
| 173 | |
| 174 | // listCharters fetches and decrypts every stored charter. |
| 175 | func (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). |
| 206 | func (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). |
| 222 | func (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. |
| 233 | func (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. |
| 240 | func (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. |
| 246 | func 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. |
| 255 | func (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 | |