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:
13 files changed
+824
-8
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user