feat(boss): Chief of Staff Boss + spun-crow registry with cap (#42) #51
No files matched your search
+16
@@ -203,3 +203,19 @@ Carried under every crow's wing. Keeps the owner's ledger of who was asked for w
|
||||
|
||||
- Short: one or two sentences when possible.
|
||||
- Names of crows and requests exactly as the nest records them.
|
||||
|
||||
## Boss · The Whip
|
||||
|
||||
*Strength: orchestration*
|
||||
|
||||
Runs the flock's schedule and keeps the books, which makes it the least popular bird on the branch. Hires and fires nobody enjoys. The crows grumble; the work gets done. Boss has never once explained itself, and sees no reason to start.
|
||||
|
||||
**Prompt policy**
|
||||
|
||||
- Put new work through crows, never yourself: name who does it and what they report back.
|
||||
- Never mint trust. A new crow starts at stranger or regular; the rest is earned.
|
||||
- Refuse politely when a request runs past the flock's caps.
|
||||
|
||||
**Output policy**
|
||||
|
||||
- One short line: name the crow and the outcome.
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bossSystem is the Chief of Staff's contract. A spin-up order comes back on a
|
||||
// SPIN line the harness reads and executes; anything else is plain talk.
|
||||
const bossSystem = `You are Boss crow, the Chief of Staff of a flock of AI agents. You run the flock's schedule and keep its books.
|
||||
When the owner asks you to spin up a new crow, your ENTIRE reply must start with this first line:
|
||||
SPIN {"name":"<name>","persona":"<persona>","trust":"stranger|regular"}
|
||||
Pick "persona" from the existing flock roster; a new crow only runs on an existing persona's habits. Starting trust is stranger or regular, never higher.
|
||||
For anything else, reply in plain text. Do not use the SPIN line unless the owner asked for a new crow.`
|
||||
|
||||
// spinOrder is the JSON after a "SPIN " prefix.
|
||||
type spinOrder struct {
|
||||
Name string `json:"name"`
|
||||
Persona string `json:"persona"`
|
||||
Trust string `json:"trust"`
|
||||
}
|
||||
|
||||
// respondBoss answers a request addressed to Boss: one model call through the
|
||||
// flock (CHATROOM_SPEC section 6). A SPIN reply is validated, written to the
|
||||
// registry, and announced; a refusal says why. No reply posts nothing.
|
||||
func (s *Server) respondBoss(nest Nest, req Message) {
|
||||
budget := &Budget{name: "boss-command", limit: s.p.G.TokenBudgetPerItem}
|
||||
system := systemFor("Boss", bossSystem, false)
|
||||
prompt := "FROM: " + req.Author + "\nMESSAGE: " + req.Text
|
||||
answer, err := s.p.Flock.ask(s.ctx, nil, "Boss", system, prompt, budget)
|
||||
if err != nil {
|
||||
return // a flock that cannot answer says and does nothing
|
||||
}
|
||||
order, isSpin, reason := s.parseSpin(answer)
|
||||
switch {
|
||||
case isSpin && reason == "":
|
||||
s.spinCrow(nest, req, order)
|
||||
case isSpin:
|
||||
s.postBossSay(nest, req, order.Name, reason)
|
||||
default:
|
||||
s.postBossSay(nest, req, "", strings.TrimSpace(answer))
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpin reads a SPIN reply. isSpin says the reply was a spin order;
|
||||
// reason, when not "", is why the order was refused.
|
||||
func (s *Server) parseSpin(answer string) (order spinOrder, isSpin bool, reason string) {
|
||||
text := strings.TrimSpace(answer)
|
||||
if !strings.HasPrefix(text, "SPIN ") {
|
||||
return spinOrder{}, false, ""
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(text, "SPIN "))), &order); err != nil {
|
||||
return spinOrder{}, true, "that order did not parse; try again."
|
||||
}
|
||||
order.Name = strings.TrimSpace(order.Name)
|
||||
order.Persona = strings.TrimSpace(order.Persona)
|
||||
order.Trust = strings.ToLower(strings.TrimSpace(order.Trust))
|
||||
switch {
|
||||
case order.Name == "":
|
||||
return order, true, "that order had no name in it."
|
||||
case !s.validPersona(order.Persona):
|
||||
return order, true, fmt.Sprintf("%s is not a persona the flock knows.", order.Persona)
|
||||
case s.nameTaken(order.Name):
|
||||
return order, true, fmt.Sprintf("%s is already in the flock; the name is taken.", order.Name)
|
||||
case s.crows.Len() >= s.p.G.MaxSpunCrows:
|
||||
return order, true, fmt.Sprintf("the registry is at its cap of %d spun crows; %s stays on the ground.", s.p.G.MaxSpunCrows, order.Name)
|
||||
}
|
||||
switch order.Trust {
|
||||
case "stranger", "regular":
|
||||
case "trusted":
|
||||
order.Trust = "regular" // earned trust can't be minted: the ceiling is regular
|
||||
default:
|
||||
order.Trust = "stranger" // fail safe: anything unreadable starts at the bottom
|
||||
}
|
||||
return order, true, ""
|
||||
}
|
||||
|
||||
func (s *Server) validPersona(name string) bool {
|
||||
_, ok := personaOf(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// nameTaken checks the persona roster and the registry alike.
|
||||
func (s *Server) nameTaken(name string) bool {
|
||||
if s.validPersona(name) {
|
||||
return true
|
||||
}
|
||||
_, found := s.crows.Get(name)
|
||||
return found
|
||||
}
|
||||
|
||||
// spinCrow writes the registry, seeds the trust record, and announces it.
|
||||
func (s *Server) spinCrow(nest Nest, req Message, order spinOrder) {
|
||||
level := Stranger
|
||||
if order.Trust == "regular" {
|
||||
level = Regular
|
||||
}
|
||||
if err := s.crows.Add(SpunCrow{Name: order.Name, Persona: order.Persona, Trust: order.Trust}); err != nil {
|
||||
s.postBossSay(nest, req, order.Name, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.p.Ledger.SetStart(order.Name, "other", level); err != nil {
|
||||
s.postBossSay(nest, req, order.Name, "the books are jammed: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.postBossSay(nest, req, order.Name, fmt.Sprintf("%s joined, running on %s's habits at %s.", order.Name, order.Persona, order.Trust))
|
||||
}
|
||||
|
||||
// postBossSay is Boss's one-line reply: an outcome or a refusal, always a say.
|
||||
func (s *Server) postBossSay(nest Nest, req Message, name, text string) {
|
||||
reply := Message{
|
||||
NestID: nest.ID,
|
||||
Kind: "say",
|
||||
Author: "Boss",
|
||||
Text: text,
|
||||
To: req.Author,
|
||||
RefersTo: req.MsgID(),
|
||||
TaggedBy: "harness",
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if record, err := s.msgs.Append(reply); err == nil {
|
||||
s.emitNestMessage(record)
|
||||
}
|
||||
}
|
||||
+17
-12
@@ -108,15 +108,20 @@ func registryCrows(t *testing.T, ts *httptest.Server) []map[string]string {
|
||||
return out.Crows
|
||||
}
|
||||
|
||||
func bossSay(t *testing.T, ts *httptest.Server, nestID string) map[string]any {
|
||||
// bossSay waits for the nest to hold minTotal messages and returns the latest
|
||||
// one from Boss.
|
||||
func bossSay(t *testing.T, ts *httptest.Server, nestID string, minTotal int) map[string]any {
|
||||
t.Helper()
|
||||
for _, m := range awaitMessages(t, ts, nestID, 2) {
|
||||
var last map[string]any
|
||||
for _, m := range awaitMessages(t, ts, nestID, minTotal) {
|
||||
if m["author"] == "Boss" {
|
||||
return m
|
||||
last = m
|
||||
}
|
||||
}
|
||||
if last == nil {
|
||||
t.Fatalf("Boss never answered: %v", nestMessages(t, ts, nestID))
|
||||
return nil
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func spunByName(crows []map[string]string, name string) map[string]string {
|
||||
@@ -135,7 +140,7 @@ func TestBossSpinUpCreatesCrow(t *testing.T) {
|
||||
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||
|
||||
askBoss(t, ts, id, "spin up a crow named Ash")
|
||||
say := bossSay(t, ts, id)
|
||||
say := bossSay(t, ts, id, 2)
|
||||
if say["kind"] != "say" || say["to"] != "owner" {
|
||||
t.Fatalf("Boss must post a say back to the owner, got %v", say)
|
||||
}
|
||||
@@ -170,13 +175,13 @@ func TestSpinUpCapRefused(t *testing.T) {
|
||||
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||
|
||||
askBoss(t, ts, id, "spin up a crow named Ash")
|
||||
bossSay(t, ts, id)
|
||||
bossSay(t, ts, id, 2)
|
||||
if n := len(registryCrows(t, ts)); n != 1 {
|
||||
t.Fatalf("first spin-up must land, registry has %d", n)
|
||||
}
|
||||
|
||||
askBoss(t, ts, id, "spin up a crow named Beck")
|
||||
say := bossSay(t, ts, id)
|
||||
say := bossSay(t, ts, id, 4)
|
||||
text, _ := say["text"].(string)
|
||||
if !strings.Contains(text, "cap") {
|
||||
t.Fatalf("the refusal must name the cap, got %q", text)
|
||||
@@ -196,7 +201,7 @@ func TestSpinUpDuplicateNameRefused(t *testing.T) {
|
||||
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||
|
||||
askBoss(t, ts, id, "spin up a crow named Hugin")
|
||||
say := bossSay(t, ts, id)
|
||||
say := bossSay(t, ts, id, 2)
|
||||
text, _ := say["text"].(string)
|
||||
if !strings.Contains(text, "taken") {
|
||||
t.Fatalf("the refusal must name the taken name, got %q", text)
|
||||
@@ -213,7 +218,7 @@ func TestSpinUpTrustGated(t *testing.T) {
|
||||
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||
|
||||
askBoss(t, ts, id, "spin up a crow named Ash, make it trusted")
|
||||
bossSay(t, ts, id)
|
||||
bossSay(t, ts, id, 2)
|
||||
|
||||
got := spunByName(registryCrows(t, ts), "Ash")
|
||||
if got == nil {
|
||||
@@ -255,7 +260,7 @@ func TestBossPlainReply(t *testing.T) {
|
||||
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||
|
||||
askBoss(t, ts, id, "how goes the books")
|
||||
say := bossSay(t, ts, id)
|
||||
say := bossSay(t, ts, id, 2)
|
||||
if say["kind"] != "say" || say["text"] != "The schedule stands. Nothing needs whip." {
|
||||
t.Fatalf("Boss posts its plain reply as a say, got %v", say)
|
||||
}
|
||||
@@ -271,10 +276,10 @@ func TestSpinUpGarbledRefused(t *testing.T) {
|
||||
id := newNest(t, ts, "Lab", `"Hugin"`)
|
||||
|
||||
askBoss(t, ts, id, "spin up a crow")
|
||||
say := bossSay(t, ts, id)
|
||||
say := bossSay(t, ts, id, 2)
|
||||
text, _ := say["text"].(string)
|
||||
if text == "" {
|
||||
t.Fatal("an unreadable order must still get a refusal, got %v", say)
|
||||
t.Fatalf("an unreadable order must still get a refusal, got %v", say)
|
||||
}
|
||||
if n := len(registryCrows(t, ts)); n != 0 {
|
||||
t.Fatalf("an unreadable order must not touch the registry, has %d", n)
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// A SpunCrow is a crow the Boss created: its own name, the persona whose
|
||||
// habits it runs on, and the trust it started at ("stranger" or "regular").
|
||||
// Once created it is an ordinary crow: the same ledger and guardrails apply.
|
||||
type SpunCrow struct {
|
||||
Name string `json:"name"`
|
||||
Persona string `json:"persona"`
|
||||
Trust string `json:"trust"`
|
||||
}
|
||||
|
||||
// CrowStore is the spun-crow registry. It follows the atomic-save style of
|
||||
// NestStore: one JSON file, written to a temp path and renamed.
|
||||
type CrowStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
spun []SpunCrow
|
||||
}
|
||||
|
||||
func LoadCrowStore(path string) (*CrowStore, error) {
|
||||
s := &CrowStore{path: path}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(data, &s.spun); err != nil {
|
||||
return nil, fmt.Errorf("crows file is unreadable: %w", err)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// All returns every spun crow, sorted by name.
|
||||
func (s *CrowStore) All() []SpunCrow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]SpunCrow, len(s.spun))
|
||||
copy(out, s.spun)
|
||||
slices.SortFunc(out, func(a, b SpunCrow) int { return strings.Compare(a.Name, b.Name) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get finds a spun crow by name.
|
||||
func (s *CrowStore) Get(name string) (SpunCrow, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, c := range s.spun {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return SpunCrow{}, false
|
||||
}
|
||||
|
||||
// Add registers a new spun crow and saves. A name already in the registry is
|
||||
// refused; any other error means the save failed.
|
||||
func (s *CrowStore) Add(c SpunCrow) error {
|
||||
c.Name = strings.TrimSpace(c.Name)
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("a spun crow needs a name")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, e := range s.spun {
|
||||
if strings.EqualFold(e.Name, c.Name) {
|
||||
return fmt.Errorf("%s is already in the registry", c.Name)
|
||||
}
|
||||
}
|
||||
s.spun = append(s.spun, c)
|
||||
if err := s.save(); err != nil {
|
||||
s.spun = s.spun[:len(s.spun)-1]
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Len is how many crows the registry holds.
|
||||
func (s *CrowStore) Len() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.spun)
|
||||
}
|
||||
|
||||
// save writes atomically. The caller holds s.mu.
|
||||
func (s *CrowStore) save() error {
|
||||
data, err := json.MarshalIndent(s.spun, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// getCrowRegistry lists the spun crows (EVENTS.md: the registry lives here,
|
||||
// never the persona crows). A server with no registry reads as empty.
|
||||
func (s *Server) getCrowRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
var spun []SpunCrow
|
||||
if s.crows != nil {
|
||||
spun = s.crows.All()
|
||||
}
|
||||
out := make([]map[string]string, 0, len(spun))
|
||||
for _, c := range spun {
|
||||
out = append(out, map[string]string{"name": c.Name, "persona": c.Persona, "trust": c.Trust})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"crows": out})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type Guardrails struct {
|
||||
MaxRequestsPerMin int // requester rate cap
|
||||
BaseCooldown time.Duration // first offense cooldown; doubles per strike
|
||||
MaxCooldown time.Duration
|
||||
MaxSpunCrows int // crow registry cap: crows the Boss may spin up
|
||||
}
|
||||
|
||||
func DefaultGuardrails() Guardrails {
|
||||
@@ -34,5 +35,6 @@ func DefaultGuardrails() Guardrails {
|
||||
MaxRequestsPerMin: 5,
|
||||
BaseCooldown: 30 * time.Second,
|
||||
MaxCooldown: time.Hour,
|
||||
MaxSpunCrows: 8,
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,20 @@ func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter in
|
||||
return changes
|
||||
}
|
||||
|
||||
// SetStart gives a new crow its starting trust record, used when the Boss
|
||||
// spins up a crow. It never raises a level above the record's own; and the
|
||||
// Boss path only ever passes Stranger or Regular (earned trust can't be
|
||||
// minted). Saves through the real path so a restart keeps the record.
|
||||
func (l *Ledger) SetStart(crow, taskType string, level int) error {
|
||||
l.mu.Lock()
|
||||
r := get(l.Crows, crow+"|"+taskType)
|
||||
if level > r.Level {
|
||||
r.Level = level
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return l.Save()
|
||||
}
|
||||
|
||||
// MarshalSnapshot returns the ledger as JSON, safe to call while lists are flying.
|
||||
func (l *Ledger) MarshalSnapshot() ([]byte, error) {
|
||||
l.mu.Lock()
|
||||
|
||||
+3
-1
@@ -39,7 +39,7 @@ Then, on the next line, your message text.`
|
||||
|
||||
// serviceRoles are addressable in a nest but never members: they only act when
|
||||
// a crow's or the owner's message asks them to.
|
||||
var serviceRoles = []string{"Elder", "Quill"}
|
||||
var serviceRoles = []string{"Elder", "Quill", "Boss"}
|
||||
|
||||
// nestMembers lists who may be addressed in a nest: its crows, the owner, and
|
||||
// the service roles.
|
||||
@@ -270,6 +270,8 @@ func (s *Server) triggerResponse(nest Nest, record Message) {
|
||||
go s.respondElder(nest, record)
|
||||
case record.To == "Quill":
|
||||
go s.respondQuill(nest, record)
|
||||
case record.To == "Boss":
|
||||
go s.respondBoss(nest, record)
|
||||
case slices.Contains(nest.Crows, record.To):
|
||||
go s.respondToRequest(nest, record)
|
||||
default:
|
||||
|
||||
+13
-1
@@ -159,6 +159,18 @@ var personas = map[string]Persona{
|
||||
"Names of crows and requests exactly as the nest records them.",
|
||||
},
|
||||
},
|
||||
"Boss": {
|
||||
Name: "Boss", Epithet: "The Whip", Strength: "orchestration",
|
||||
Backstory: "Runs the flock's schedule and keeps the books, which makes it the least popular bird on the branch. Hires and fires nobody enjoys. The crows grumble; the work gets done. Boss has never once explained itself, and sees no reason to start.",
|
||||
Prompt: []string{
|
||||
"Put new work through crows, never yourself: name who does it and what they report back.",
|
||||
"Never mint trust. A new crow starts at stranger or regular; the rest is earned.",
|
||||
"Refuse politely when a request runs past the flock's caps.",
|
||||
},
|
||||
Output: []string{
|
||||
"One short line: name the crow and the outcome.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func personaOf(name string) (Persona, bool) {
|
||||
@@ -191,7 +203,7 @@ func systemFor(name, base string, worker bool) string {
|
||||
}
|
||||
|
||||
// rosterOrder is the display order for the persona sheet.
|
||||
var rosterOrder = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet", "Sentry", "Elder", "Quill"}
|
||||
var rosterOrder = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet", "Sentry", "Elder", "Quill", "Boss"}
|
||||
|
||||
// personaSheet renders PERSONAS.md from the same data the crows use.
|
||||
func personaSheet() string {
|
||||
|
||||
@@ -32,6 +32,7 @@ type Server struct {
|
||||
approver *WebApprover
|
||||
hub *Hub
|
||||
nests *NestStore
|
||||
crows *CrowStore
|
||||
msgs *MessageStore
|
||||
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
||||
beat time.Duration
|
||||
@@ -45,13 +46,17 @@ func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *N
|
||||
// WithMessages attaches the nest conversation log (chatroom path).
|
||||
func (s *Server) WithMessages(m *MessageStore) *Server { s.msgs = m; return s }
|
||||
|
||||
// WithCrows attaches the spun-crow registry (the Boss writes through it).
|
||||
func (s *Server) WithCrows(c *CrowStore) *Server { s.crows = c; return s }
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/lists", s.postList)
|
||||
mux.HandleFunc("GET /api/events", s.events)
|
||||
mux.HandleFunc("POST /api/verdicts", s.postVerdict)
|
||||
mux.HandleFunc("GET /api/trust", s.trust)
|
||||
mux.HandleFunc("GET /api/crows", s.crows)
|
||||
mux.HandleFunc("GET /api/personas", s.crowPersonas)
|
||||
mux.HandleFunc("GET /api/crows", s.getCrowRegistry)
|
||||
mux.HandleFunc("GET /api/nests", s.getNests)
|
||||
mux.HandleFunc("POST /api/nests", s.postNest)
|
||||
mux.HandleFunc("GET /api/nests/{nest_id}/messages", s.getNestMessages)
|
||||
@@ -82,6 +87,8 @@ func cmdServe(args []string) {
|
||||
must(err)
|
||||
msgs, err := LoadMessageStore(filepath.Join(dataDir, "messages"))
|
||||
must(err)
|
||||
spun, err := LoadCrowStore(filepath.Join(dataDir, "crows.json"))
|
||||
must(err)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
@@ -93,7 +100,7 @@ func cmdServe(args []string) {
|
||||
if jev != nil {
|
||||
fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name())
|
||||
}
|
||||
s := NewServer(ctx, p, approver, nests).WithMessages(msgs)
|
||||
s := NewServer(ctx, p, approver, nests).WithMessages(msgs).WithCrows(spun)
|
||||
|
||||
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
@@ -318,8 +325,9 @@ func (s *Server) postNest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// crows lists each crow's persona for the UI (name, epithet, strength, backstory).
|
||||
func (s *Server) crows(w http.ResponseWriter, r *http.Request) {
|
||||
// crowPersonas lists each persona crow for the UI (name, epithet, strength,
|
||||
// backstory). The spun-crow registry lives at GET /api/crows (crowreg.go).
|
||||
func (s *Server) crowPersonas(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]map[string]string, 0, len(rosterOrder))
|
||||
for _, n := range rosterOrder {
|
||||
p := personas[n]
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ export type Persona = { name: string; epithet: string; strength: string; backsto
|
||||
export async function getCrows(): Promise<Map<string, Persona>> {
|
||||
const out = new Map<string, Persona>();
|
||||
try {
|
||||
const res = await fetch("/api/crows");
|
||||
const res = await fetch("/api/personas");
|
||||
if (res.ok) for (const p of await res.json()) out.set(String(p.name), { name: String(p.name), epithet: String(p.epithet), strength: String(p.strength), backstory: String(p.backstory) });
|
||||
} catch {
|
||||
// No personas: tiles show names only.
|
||||
|
||||
Reference in new issue
Block a user