Crow personas and generated banter

- personas.go: backstory, prompt policy and output policy per crow;
  Hugin and Munin grounded in Grimnismal 20 and Gylfaginning 38;
  every working crow also follows a common output policy
- Personas go into each crow's instructions; Sentry keeps JSON-only
- banter.go: retrieve backstory, relationships and recent flock
  memory, then the speaking crow generates one line in character;
  max 2 per list against the list budget, never blocks work,
  unusable lines dropped
- crow.banter event, GET /api/crows, `crows personas` -> PERSONAS.md
- UI: epithets on tiles and messages, banter as quiet asides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
SmokeyandClaude Opus 5.5 committed 2026-10-27 17:31:53 -06:00
1 parent 4499291231
commit 57e9fc2fd2
13 files changed
+824 -8

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
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
}