package main import ( "context" "fmt" "math/rand/v2" "slices" "strings" "sync" "time" ) // Banter is generated, retrieval first: // 1. Retrieve: lore for the crows involved (backstories, their relationship) // plus recent flock events from a short shared memory. // 2. Generate: the speaking crow's persona and only that context go to the // model, which writes one line in character. // // Guardrails: at most MaxBanterPerList calls per list, counted against the // list's token budget; banter runs beside the work and never blocks it; a // failed or unusable line is simply dropped. const ( markerBanter = "You write one line of in-character banter" MaxBanterPerList = 2 banterChance = 0.6 // not every moment gets a line banterMaxChars = 220 ) // relationships: the backstory between crows that banter draws on. var relationships = []struct { A, B, Text string }{ {"Hugin", "Munin", "Odin's two ravens, partners on daily flights for a thousand years. Hugin teases Munin for being slow; Munin reminds Hugin that Odin feared more for losing Munin."}, {"Hugin", "Soot", "Soot appointed himself Hugin's critic and pokes holes in his bold ideas. Secretly, Soot respects him."}, {"Munin", "Inky", "Close allies: Inky writes it down, Munin remembers it, and nothing is lost."}, {"Corvina", "Edgar", "A long feud over adjectives. Corvina edits Edgar's drafts; Edgar calls her the Butcher of Adverbs."}, {"Pepper", "Jet", "They race to every task. Jet thinks Pepper talks too much; Pepper thinks Jet is boring."}, {"Elder", "Hugin", "The Elder hears reports the way Odin once heard Hugin and Munin. Hugin finds this presumptuous; the Elder lets him grumble."}, {"Elder", "Munin", "The Elder hears reports the way Odin once did. Munin finds this familiar, and a little presumptuous."}, {"Sentry", "Soot", "Soot teases the Sentry for being stiff. The Sentry keeps the strike tally and does not find this funny."}, {"Sentry", "Pepper", "Pepper loves catching the Sentry in a mistake; it almost never happens."}, } // related returns the crows that share backstory with name. func related(name string) []string { var out []string for _, r := range relationships { switch name { case r.A: out = append(out, r.B) case r.B: out = append(out, r.A) } } return out } // flockMemory is the short shared memory banter can retrieve from. type flockMemory struct { mu sync.Mutex lines []string } func (m *flockMemory) add(line string) { m.mu.Lock() defer m.mu.Unlock() m.lines = append(m.lines, line) if len(m.lines) > 30 { m.lines = m.lines[len(m.lines)-30:] } } // about returns up to n recent events that mention any of the named crows. func (m *flockMemory) about(n int, names ...string) []string { m.mu.Lock() defer m.mu.Unlock() var out []string for i := len(m.lines) - 1; i >= 0 && len(out) < n; i-- { for _, name := range names { if strings.Contains(m.lines[i], name) { out = append(out, m.lines[i]) break } } } slices.Reverse(out) return out } // retrieve gathers the lore for a moment between speaker and subject. func retrieve(mem *flockMemory, speaker, subject string) []string { var lore []string for _, n := range []string{speaker, subject} { if p, ok := personaOf(n); ok { lore = append(lore, fmt.Sprintf("%s (%s): %s", p.Name, p.Epithet, p.Backstory)) } } for _, r := range relationships { if (r.A == speaker && r.B == subject) || (r.A == subject && r.B == speaker) { lore = append(lore, "Between them: "+r.Text) } } for _, e := range mem.about(4, speaker, subject) { lore = append(lore, "Recently: "+e) } return lore } // chatter is one list's banter state. type chatter struct { f *Flock mem *flockMemory em Emitter budget *Budget nest string mu sync.Mutex used int wg sync.WaitGroup roll func() float64 } type chatterKey struct{} func withChatter(ctx context.Context, c *chatter) context.Context { return context.WithValue(ctx, chatterKey{}, c) } func chatterFrom(ctx context.Context) *chatter { c, _ := ctx.Value(chatterKey{}).(*chatter) return c } // remember records a flock event; it costs nothing. func (c *chatter) remember(line string) { if c != nil { c.mem.add(line) } } // moment may produce one line of banter about subject. speaker "" means pick // a crow who shares backstory with the subject. func (c *chatter) moment(ctx context.Context, itemID, subject, speaker, what string) { if c == nil { return } if speaker == "" { rel := related(subject) if len(rel) == 0 { return } speaker = rel[rand.IntN(len(rel))] } c.mu.Lock() if c.used >= MaxBanterPerList || c.roll() > banterChance { c.mu.Unlock() return } c.used++ c.mu.Unlock() lore := retrieve(c.mem, speaker, subject) c.wg.Add(1) go func() { defer c.wg.Done() bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() system := systemFor(speaker, markerBanter+` for a crow in a team chat room. Write exactly one line, at most 25 words, in this crow's voice. Good-natured and PG. Tease other crows if it fits; never mock the human or their task. Use only the lore provided: do not invent new backstory, names or events. No emoji, no hashtags, no quotation marks around the line, no name prefix.`, false) prompt := fmt.Sprintf("LORE:\n- %s\n\nMOMENT: %s\n\nWrite %s's line, addressed to %s.", strings.Join(lore, "\n- "), what, speaker, subject) text, err := c.f.ask(bctx, nil, speaker, system, prompt, c.budget) if err != nil { return } line, ok := cleanBanter(text, speaker) if !ok { return } c.mem.add(fmt.Sprintf("%s said to %s: %s", speaker, subject, line)) c.em.Emit("crow.banter", map[string]any{"item_id": itemID, "crow": speaker, "to": subject, "text": line}) }() } // wait gives in-flight banter a moment to land before the list finishes. func (c *chatter) wait(d time.Duration) { if c == nil { return } done := make(chan struct{}) go func() { c.wg.Wait(); close(done) }() select { case <-done: case <-time.After(d): } } // cleanBanter keeps a generated line only if it is one short, plain line. func cleanBanter(text, speaker string) (string, bool) { line := strings.TrimSpace(strings.SplitN(strings.TrimSpace(text), "\n", 2)[0]) line = strings.TrimPrefix(line, speaker+":") line = strings.Trim(strings.TrimSpace(line), "\"“”'") low := strings.ToLower(line) switch { case line == "", len(line) > banterMaxChars, strings.Contains(low, "http"), strings.Contains(low, "www."): return "", false } return line, true }