package bot // Charters — the data model of member bots (docs/member-bots.md). A charter // is everything a member's bot IS: a persona (name + glyph), a schedule, a // task (prompt + tools + budgets), and where its output goes. It is stored as // an instance-key-encrypted blob in the server's member_bots table — exactly // the member-app storage pattern (owner-gated, opaque to the server) — and // run by the charter host, one designated identity in the existing sidecar. // No new E2EE identity, no minting endpoint, no supervisor: the "bot" the // member meets is a persona the host posts under, into a dedicated thread. // (Option B of the identity-model decision in issue #432.) import ( "fmt" "net/url" "os" "regexp" "time" "lchat/internal/e2ee" ) const charterVersion = 1 // Charter is the decrypted shape of one member bot. type Charter struct { V int `json:"v"` // Name is the slug ("morning-news"); Title is the persona — the name the // bot posts under and the dedicated thread's title ("Morning News"). Name string `json:"name"` Title string `json:"title"` Glyph string `json:"glyph"` // Schedule is when the bot acts; Task is what it does. Schedule ScheduleSpec `json:"schedule"` Task TaskSpec `json:"task"` // Thread is the dedicated thread (owner + host) the bot posts into, // created by the builder at birth. Thread string `json:"thread"` // Owner is the member the bot belongs to; By their display name; BuiltBy // the host identity that built it. Ownership is enforced in the sidecar // (where plaintext lives), like member apps. Owner string `json:"owner"` By string `json:"by,omitempty"` BuiltBy string `json:"builtBy,omitempty"` // Paused stops the schedule without deleting the charter (zero value = // running, so a plain create is live). Paused bool `json:"paused,omitempty"` } // ScheduleSpec is when a charter fires: exactly one of At (daily wall-clock // "HH:MM", with optional IANA TZ, default UTC) or Every (a Go duration). type ScheduleSpec struct { At string `json:"at,omitempty"` TZ string `json:"tz,omitempty"` Every string `json:"every,omitempty"` loc *time.Location every time.Duration } var charterAtRe = regexp.MustCompile(`^([01]?\d|2[0-3]):([0-5]\d)$`) // minEvery floors interval schedules — the cost valve: every interval is a // standing scheduled model run, so the floor is what stops "every second" // from being expressible (validation is the capability boundary, like the // recipe palette). 15 minutes in production; tests and demo canaries lower it // via LCHAT_BOT_MIN_EVERY (never set in production units — the same pattern // as LCHAT_BOT_ALLOW_PRIVATE_FETCH). func minEvery() time.Duration { if v := os.Getenv("LCHAT_BOT_MIN_EVERY"); v != "" { if d, err := time.ParseDuration(v); err == nil && d > 0 { return d } } return 15 * time.Minute } func (s *ScheduleSpec) validate() error { if (s.At == "") == (s.Every == "") { return fmt.Errorf("schedule needs exactly one of at / every") } if s.At != "" { if !charterAtRe.MatchString(s.At) { return fmt.Errorf("bad at %q (want HH:MM)", s.At) } s.loc = time.UTC if s.TZ != "" { loc, err := time.LoadLocation(s.TZ) if err != nil { return fmt.Errorf("bad tz %q", s.TZ) } s.loc = loc } return nil } if s.TZ != "" { return fmt.Errorf("tz belongs to at-schedules") } d, err := time.ParseDuration(s.Every) if err != nil || d < minEvery() { return fmt.Errorf("bad every %q (min %s)", s.Every, minEvery()) } s.every = d return nil } // next computes when the schedule should fire, strictly after now. Pure — the // clock is a parameter, so tests pin the maths. validate must have run. func (s *ScheduleSpec) next(now time.Time) time.Time { if s.At != "" { var h, m int fmt.Sscanf(s.At, "%d:%d", &h, &m) local := now.In(s.loc) next := time.Date(local.Year(), local.Month(), local.Day(), h, m, 0, 0, s.loc) if !next.After(now) { next = next.AddDate(0, 0, 1) } return next } return now.Add(s.every) } // Summary is the schedule in words, for chat ("daily at 08:00 (Europe/Dublin)"). func (s *ScheduleSpec) Summary() string { if s.At != "" { if s.TZ != "" { return fmt.Sprintf("daily at %s (%s)", s.At, s.TZ) } return fmt.Sprintf("daily at %s UTC", s.At) } return "every " + s.Every } // validateCharter checks a decoded charter end to end (and compiles its // schedule). Fail closed: a charter that doesn't validate never runs. func validateCharter(c *Charter) error { if c == nil { return fmt.Errorf("no charter") } if c.V != charterVersion { return fmt.Errorf("unknown charter version %d", c.V) } if !appNameRe.MatchString(c.Name) { return fmt.Errorf("bad bot name %q", c.Name) } if c.Title == "" || len(c.Title) > 40 { return fmt.Errorf("title must be 1-40 characters") } if c.Glyph == "" || len(c.Glyph) > 16 { return fmt.Errorf("glyph must be one emoji") } if c.Owner == "" { return fmt.Errorf("charter has no owner") } if err := c.Schedule.validate(); err != nil { return err } return validateTaskSpec(0, &c.Task) } // ---- charter store access (the member_bots endpoints) ---------------------- // // Charters ride the same ordinary member endpoints as everything else the // sidecar does; the server never sees inside a blob. // charterEntry is one stored charter, decrypted. Entries whose blobs don't // decrypt or validate are skipped by the lister (fail closed per charter, not // per list — one corrupt row must not kill everyone's bots). type charterEntry struct { ID string Charter *Charter } // listCharters fetches and decrypts every stored charter. func (r *Runtime) listCharters(instKey []byte) ([]charterEntry, error) { var list struct { Bots []struct { ID string `json:"id"` Blob string `json:"blob"` } `json:"bots"` } if err := r.get("/api/member-bots", &list); err != nil { return nil, err } var out []charterEntry for _, b := range list.Bots { var c Charter if err := e2ee.DecryptJSON(instKey, b.Blob, &c); err != nil { r.logf("charter %s: undecryptable — skipped", b.ID) continue } if err := validateCharter(&c); err != nil { r.logf("charter %s: %v — skipped", b.ID, err) continue } out = append(out, charterEntry{ID: b.ID, Charter: &c}) } // Every read refreshes the thread→charter map that makes each bot's home // thread conversational (runtime.go) — connect, reconcile, ticks, builds // and removals all pass through here, so the map never goes stale long. r.setCharterHomes(out) return out, nil } // fetchCharter loads one charter by id (nil if absent/undecryptable/invalid). func (r *Runtime) fetchCharter(id string, instKey []byte) (*Charter, error) { entries, err := r.listCharters(instKey) if err != nil { return nil, err } for _, e := range entries { if e.ID == id { return e.Charter, nil } } return nil, nil } // putCharter encrypts and stores a charter, recording the owner so it's // theirs to manage even though the host stored it (the agent-sets-owner // pattern, memberbots.go). func (r *Runtime) putCharter(id string, c *Charter, instKey []byte) error { blob, err := e2ee.EncryptJSON(instKey, c) if err != nil { return err } return r.put("/api/member-bots/"+id, map[string]string{ "token": r.Session.Token, "blob": blob, "owner_id": c.Owner, }) } // deleteCharter removes a stored charter. func (r *Runtime) deleteCharter(id string) error { return r.del("/api/member-bots/" + id + "?token=" + url.QueryEscape(r.Session.Token)) } // nudgeInstanceKey asks online instance-key holders to wrap the key to anyone // missing it (this bot included) — the same nudge a keyless browser sends. // Best-effort: the grant lands whenever a holder's tab answers. func (r *Runtime) nudgeInstanceKey() { _ = r.post("/api/instance-key/nudge?token="+url.QueryEscape(r.Session.Token), map[string]any{}, nil) } // charterNames returns the set of taken bot slugs, for uniqueAppName-style // de-duping at build time. func charterNames(entries []charterEntry) map[string]bool { names := make(map[string]bool, len(entries)) for _, e := range entries { names[e.Charter.Name] = true } return names } // summaryLine is the one-line description of a charter for chat. func (c *Charter) summaryLine() string { src := "" if c.Task.Fetch != nil && len(c.Task.Fetch.Allow) > 0 { src = fmt.Sprintf(", reading %d source(s)", len(c.Task.Fetch.Allow)) } return fmt.Sprintf("%s %s — %s%s", c.Title, c.Glyph, c.Schedule.Summary(), src) }