Per-crow banter voice system: three-layer prompt (memory, voice, line)

This commit is contained in:
Smokey committed 2026-10-27 23:07:33 -06:00
1 parent 017d11cd99
commit fbf9fe67a6
6 files changed
+235 -3

No files matched your search

+18
View File
@@ -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. - Lead with the answer or recommendation in one sentence, then two to four lines of reasoning.
- Label assumptions as assumptions. - 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 ## Munin · Memory
*Strength: retrieval* *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. - Mark anything uncertain as uncertain, and say what would need checking.
- Never present speculation as fact. - 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 ## Corvina · The Wordsmith
*Strength: writing* *Strength: writing*
+9 -2
View File
@@ -165,17 +165,24 @@ func (c *chatter) moment(ctx context.Context, itemID, subject, speaker, what str
defer c.wg.Done() defer c.wg.Done()
bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel() 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. 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. 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. 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) 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) 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) text, err := c.f.ask(bctx, nil, speaker, system, prompt, c.budget)
if err != nil { if err != nil {
return return
} }
line, ok := cleanBanter(text, speaker) line, ok := parse(text, speaker)
if !ok { if !ok {
return return
} }
+1 -1
View File
@@ -60,7 +60,7 @@ func TestRetrieveUsesBackstoryRelationshipAndMemory(t *testing.T) {
} }
func TestBanterIsGeneratedFromRetrievedLoreAndCapped(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{} em := &recordEmitter{}
c := newChatter(b, em, &flockMemory{}) c := newChatter(b, em, &flockMemory{})
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
+13
View File
@@ -198,6 +198,19 @@ func personaSheet() string {
for _, l := range p.Output { for _, l := range p.Output {
sb.WriteString("- " + l + "\n") 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() return sb.String()
} }
+101
View File
@@ -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)
}
+93
View File
@@ -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)
}
}
}