From fbf9fe67a6135d388bd02486a85836ec2fa601ab Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:07:33 -0600 Subject: [PATCH] Per-crow banter voice system: three-layer prompt (memory, voice, line) --- PERSONAS.md | 18 +++++++++ banter.go | 11 +++++- banter_test.go | 2 +- personas.go | 13 +++++++ voice.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ voice_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 voice.go create mode 100644 voice_test.go diff --git a/PERSONAS.md b/PERSONAS.md index d8ee1db..ccbdaef 100644 --- a/PERSONAS.md +++ b/PERSONAS.md @@ -29,6 +29,15 @@ One of Odin's two ravens. For a thousand years he flew over the world each dawn - Lead with the answer or recommendation in one sentence, then two to four lines of reasoning. - Label assumptions as assumptions. +**Banter voice** + +- Favors: wind, wing, dawn, branch, breakfast, news, "on the wing" +- Favors: boasts about speed and range; calls ideas "flights" +- Rhythm: Short, quick, declarative. One sharp beat, then a flourish. +- Never: Hedging: "perhaps", "I suppose", "it might be". +- Never: Slow, mournful recollection. That is Munin's. +- Hook: end with a dare or a challenge to the crow you are addressing. + ## Munin · Memory *Strength: retrieval* @@ -46,6 +55,15 @@ Hugin's partner on those daily flights, and the one Odin feared losing most. Slo - Mark anything uncertain as uncertain, and say what would need checking. - Never present speculation as fact. +**Banter voice** + +- Favors: remember, recall, "as I recall", long ago, the branch, deep, set down +- Favors: measures things in seasons and years, not minutes +- Rhythm: Slow and level. One clause of recollection, then a dry turn. +- Never: Boasting or hurry. That is Hugin's. +- Never: Claiming to know what the lore does not say. +- Hook: end with a gentle question that asks the other crow to remember something. + ## Corvina · The Wordsmith *Strength: writing* diff --git a/banter.go b/banter.go index 1530356..c62ad39 100644 --- a/banter.go +++ b/banter.go @@ -165,17 +165,24 @@ func (c *chatter) moment(ctx context.Context, itemID, subject, speaker, what str 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. + var system string + parse := cleanBanter + if v, ok := voices[speaker]; ok { + p, _ := personaOf(speaker) + system, parse = voicedBanterSystem(p, v), parseVoiced + } else { + 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) + line, ok := parse(text, speaker) if !ok { return } diff --git a/banter_test.go b/banter_test.go index 7a41055..96edbb3 100644 --- a/banter_test.go +++ b/banter_test.go @@ -60,7 +60,7 @@ func TestRetrieveUsesBackstoryRelationshipAndMemory(t *testing.T) { } func TestBanterIsGeneratedFromRetrievedLoreAndCapped(t *testing.T) { - b := &promptBrain{reply: `Munin: "Bold as ever, Hugin."`} + b := &promptBrain{reply: "MEMORY: Bold, that one.\nLINE: Munin: \"Bold as ever, Hugin.\""} em := &recordEmitter{} c := newChatter(b, em, &flockMemory{}) for i := 0; i < 5; i++ { diff --git a/personas.go b/personas.go index 98cbd65..1c1f6d7 100644 --- a/personas.go +++ b/personas.go @@ -198,6 +198,19 @@ func personaSheet() string { for _, l := range p.Output { sb.WriteString("- " + l + "\n") } + if v, ok := voices[n]; ok { + sb.WriteString("\n**Banter voice**\n\n") + for _, l := range v.Diction { + sb.WriteString("- Favors: " + l + "\n") + } + for _, l := range v.Rhythm { + sb.WriteString("- Rhythm: " + l + "\n") + } + for _, l := range v.Never { + sb.WriteString("- Never: " + l + "\n") + } + sb.WriteString("- Hook: " + v.Hook + "\n") + } } return sb.String() } diff --git a/voice.go b/voice.go new file mode 100644 index 0000000..98e3ff5 --- /dev/null +++ b/voice.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "strings" +) + +// A Voice is a crow's response system for banter, in three layers: +// +// 1. Internalize: the model first writes a MEMORY, the retrieved lore turned +// into the crow's own first-person recollection. It is never shown. +// 2. Voice constraint: the crow's diction, rhythm and forbidden phrasing. +// 3. Performance: the LINE itself. It never repeats a lore fact word for +// word and it hands the turn back (the Hook). +// +// Voice shapes wording only. It never touches facts or guardrails, and it is +// not applied to work answers. A crow with no entry in voices keeps the +// plain banter path. +type Voice struct { + Diction []string // words and images this crow reaches for + Rhythm []string // sentence shape + Never []string // phrasing this crow would never use + Hook string // how this crow passes the turn back +} + +// Every voiced crow avoids these, whatever its personality. +var commonVoiceNever = []string{ + "System words: prompt, model, token, database, lore, context, AI, assistant, memory block.", + "Reporting phrases: \"based on\", \"according to\", \"as noted\".", +} + +var voices = map[string]Voice{ + "Hugin": { + Diction: []string{ + "wind, wing, dawn, branch, breakfast, news, \"on the wing\"", + "boasts about speed and range; calls ideas \"flights\"", + }, + Rhythm: []string{ + "Short, quick, declarative. One sharp beat, then a flourish.", + }, + Never: []string{ + "Hedging: \"perhaps\", \"I suppose\", \"it might be\".", + "Slow, mournful recollection. That is Munin's.", + }, + Hook: "end with a dare or a challenge to the crow you are addressing.", + }, + "Munin": { + Diction: []string{ + "remember, recall, \"as I recall\", long ago, the branch, deep, set down", + "measures things in seasons and years, not minutes", + }, + Rhythm: []string{ + "Slow and level. One clause of recollection, then a dry turn.", + }, + Never: []string{ + "Boasting or hurry. That is Hugin's.", + "Claiming to know what the lore does not say.", + }, + Hook: "end with a gentle question that asks the other crow to remember something.", + }, +} + +// voicedBanterSystem builds the three-layer instructions for a voiced crow. +// It starts with markerBanter so the banter path stays recognizable. +func voicedBanterSystem(p Persona, v Voice) string { + var sb strings.Builder + sb.WriteString(markerBanter + " for a crow in a team chat room.\n") + fmt.Fprintf(&sb, "You are %s, %s. %s\n\n", p.Name, p.Epithet, p.Backstory) + sb.WriteString("Work in two steps and label them exactly:\n") + sb.WriteString("MEMORY: One or two short first-person sentences: what you remember or feel about this moment, in your own words. Turn the lore into your own recollection. No lists, no system words. This step is never shown.\n") + sb.WriteString("LINE: The one line you say aloud.\n\n") + sb.WriteString("The LINE follows these rules.\n\nVoice:\n") + for _, l := range v.Diction { + sb.WriteString("- Favor: " + l + "\n") + } + for _, l := range v.Rhythm { + sb.WriteString("- Rhythm: " + l + "\n") + } + for _, l := range append(append([]string{}, v.Never...), commonVoiceNever...) { + sb.WriteString("- Never: " + l + "\n") + } + sb.WriteString("\nPerformance:\n") + sb.WriteString("- Exactly one line, at most 25 words. Good-natured and PG. Tease other crows if it fits; never mock the human or their task.\n") + sb.WriteString("- Use only the lore provided: no new backstory, names or events.\n") + sb.WriteString("- Never repeat a phrase from the lore word for word. Say it the way you would remember it.\n") + sb.WriteString("- Hand the turn back: " + v.Hook + "\n") + sb.WriteString("- No emoji, no hashtags, no quotation marks around the line, no name prefix.\n") + return sb.String() +} + +// parseVoiced pulls the spoken line out of a MEMORY/LINE reply. The MEMORY is +// discarded. Anything without a LINE label is dropped: a memory must never leak +// into the chat, and the free router sometimes hands a call to an unrelated +// model whose reply is not a line at all. +func parseVoiced(text, speaker string) (string, bool) { + i := strings.LastIndex(text, "LINE:") + if i < 0 { + return "", false + } + return cleanBanter(text[i+len("LINE:"):], speaker) +} diff --git a/voice_test.go b/voice_test.go new file mode 100644 index 0000000..4c57ec4 --- /dev/null +++ b/voice_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestVoicedPromptHasThreeLayers(t *testing.T) { + p, _ := personaOf("Hugin") + s := voicedBanterSystem(p, voices["Hugin"]) + for _, want := range []string{ + markerBanter, + "You are Hugin, Thought", + "MEMORY:", "LINE:", // layer 1 + "Favor:", "Rhythm:", "Never: Hedging", "Never: System words", // layer 2 + "word for word", "Hand the turn back: end with a dare", // layer 3 + } { + if !strings.Contains(s, want) { + t.Errorf("voiced prompt missing %q", want) + } + } + if !strings.HasPrefix(s, markerBanter) { + t.Error("voiced prompt must start with the banter marker") + } +} + +func TestParseVoicedDropsMemoryAndKeepsLine(t *testing.T) { + got, ok := parseVoiced("MEMORY: I flew that road once.\nLINE: \"Slow as ever, Munin. Race me?\"", "Hugin") + if !ok || got != "Slow as ever, Munin. Race me?" { + t.Fatalf("got %q %v", got, ok) + } + if got, ok := parseVoiced("MEMORY: x\nLINE:\nRace me, Munin.", "Hugin"); !ok || got != "Race me, Munin." { + t.Fatalf("line on next row: got %q %v", got, ok) + } + // A memory with no line must never reach the chat. + if got, ok := parseVoiced("MEMORY: I flew that road once.", "Hugin"); ok { + t.Fatalf("leaked memory: %q", got) + } + // A reply that ignored the format (or came from an unrelated model) is dropped. + for _, bad := range []string{"Race me, Munin.", "User Safety: safe"} { + if got, ok := parseVoiced(bad, "Hugin"); ok { + t.Fatalf("accepted unlabeled reply %q", got) + } + } + // The line is still cleaned and validated. + if _, ok := parseVoiced("MEMORY: x\nLINE: see https://example.com", "Hugin"); ok { + t.Fatal("accepted a link") + } +} + +func TestVoicedCrowSpeaksThroughMemoryAndLine(t *testing.T) { + b := &promptBrain{reply: "MEMORY: Odin feared for him less. Still stings.\nLINE: Back by breakfast, Munin. Try to keep up."} + em := &recordEmitter{} + c := newChatter(b, em, &flockMemory{}) + c.moment(context.Background(), "L-1", "Munin", "Hugin", "The human approved Munin's answer.") + c.wait(5 * time.Second) + if len(em.events) != 1 { + t.Fatalf("got %d lines", len(em.events)) + } + if got := em.events[0]["text"]; got != "Back by breakfast, Munin. Try to keep up." { + t.Fatalf("memory leaked or line wrong: %q", got) + } + if !strings.Contains(b.prompts[0], "MEMORY:") || !strings.Contains(b.prompts[0], "Odin's two ravens") { + t.Fatalf("voiced prompt or retrieved lore missing:\n%s", b.prompts[0]) + } +} + +func TestUnvoicedCrowKeepsPlainBanter(t *testing.T) { + b := &promptBrain{reply: "Nice try."} + em := &recordEmitter{} + c := newChatter(b, em, &flockMemory{}) + c.moment(context.Background(), "L-1", "Hugin", "Soot", "The human rejected Hugin's answer.") + c.wait(5 * time.Second) + if len(em.events) != 1 || em.events[0]["text"] != "Nice try." { + t.Fatalf("got %v", em.events) + } + if strings.Contains(b.prompts[0], "MEMORY:") { + t.Error("an unvoiced crow must not get the two-step prompt") + } +} + +func TestEveryVoiceBelongsToAPersonaAndIsComplete(t *testing.T) { + for name, v := range voices { + if _, ok := personaOf(name); !ok { + t.Errorf("voice %q has no persona", name) + } + if len(v.Diction) == 0 || len(v.Rhythm) == 0 || len(v.Never) == 0 || v.Hook == "" { + t.Errorf("voice %q is incomplete", name) + } + } +}