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

+14
View File
@@ -75,3 +75,17 @@ export async function createNest(name: string, crows: string[]): Promise<Nest> {
if (!res.ok) throw new Error(String(body.message ?? `Couldn't build that nest (${res.status}).`));
return { nestId: String(body.nest_id), name: String(body.name), crows: (body.crows ?? []).map(String) };
}
// ---------- personas ----------
export type Persona = { name: string; epithet: string; strength: string; backstory: string };
export async function getCrows(): Promise<Map<string, Persona>> {
const out = new Map<string, Persona>();
try {
const res = await fetch("/api/crows");
if (res.ok) for (const p of await res.json()) out.set(String(p.name), { name: String(p.name), epithet: String(p.epithet), strength: String(p.strength), backstory: String(p.backstory) });
} catch {
// No personas: tiles show names only.
}
return out;
}
+34 -3
View File
@@ -1,5 +1,5 @@
import "./style.css";
import { createNest, getNests, getTrust, listEvents, postList, postVerdict, type Nest, type TrustLevels } from "./api";
import { createNest, getCrows, getNests, getTrust, listEvents, postList, postVerdict, type Nest, type Persona, type TrustLevels } from "./api";
import { crowAvatar, svgCrow, type CrowView, type Role } from "./crow";
// ---------- page ----------
@@ -35,6 +35,7 @@ type Tile = { view: CrowView; el: HTMLDivElement; update: HTMLParagraphElement;
const tiles = new Map<string, Tile>();
let trust: TrustLevels = new Map();
let seq = 0;
let personas = new Map<string, Persona>();
function roleOf(name: string): Role {
return name === "Sentry" ? "sentry" : name === "Elder" ? "elder" : "crow";
@@ -47,7 +48,10 @@ function tile(name: string): Tile {
const box = el("div", `tile role-${roleOf(name)}`);
const text = el("div", "tile-text");
const update = el("p", "update", name === "Sentry" ? "On watch" : "Perched");
text.append(el("p", "tile-name", name), update);
const nameLine = el("p", "tile-name", name);
const epithet = el("span", "epithet");
nameLine.append(epithet);
text.append(nameLine, update);
const meta = el("div", "tile-meta");
const time = el("time", "tile-time");
const count = el("span", "tile-count");
@@ -56,10 +60,20 @@ function tile(name: string): Tile {
roster.append(box);
t = { view, el: box, update, time, count, pending: new Set() };
tiles.set(name, t);
applyPersona(name);
}
return t;
}
// Epithet beside the name; the backstory on hover.
function applyPersona(name: string) {
const t = tiles.get(name);
const p = personas.get(name);
if (!t || !p) return;
t.el.querySelector(".epithet")!.textContent = ` · ${p.epithet}`;
t.el.title = p.backstory;
}
function crow(name: string): CrowView {
return tile(name).view;
}
@@ -185,7 +199,7 @@ function post(author: string, parts: Node[], ts: string | undefined, nestId: str
}
const head = el("div", "msg-head");
head.append(el("span", "author", author === "you" ? requesterInput.value.trim() || "You" : author));
const tag = ROLE_TAG[roleOf(author)];
const tag = personas.get(author)?.epithet ?? ROLE_TAG[roleOf(author)];
if (author !== "you" && tag) head.append(el("span", "tag", tag));
const time = el("time", "", fmtTime(ts));
head.append(time);
@@ -206,6 +220,16 @@ function note(text: string, nestId: string, kind = "") {
append(li, nestId);
}
// Banter: a small aside between crows, kept visually apart from the work.
function aside(speaker: string, to: string, text: string, nestId: string) {
const li = el("li", "msg banter");
li.append(crowAvatar(roleOf(speaker)));
const body = el("p", "banter-line");
body.append(el("span", "banter-who", `${speaker} to ${to}`), el("span", "banter-text", text));
li.append(body);
append(li, nestId);
}
function re(text: string) {
return el("p", "re", `re: ${text}`);
}
@@ -389,6 +413,11 @@ const handlers: Record<string, (d: Payload) => void> = {
update("Sentry", `Skipped ${q(it?.text ?? d.item_id)}`, d.ts, "bad");
},
"crow.banter": (d) => {
const it = items.get(d.item_id);
aside(d.crow, d.to, d.text, it?.nest ?? flyingNest ?? current);
},
"requester.pranked": (d) => prank(d.message, d.cooldown_s),
"list.finished": (d) => {
@@ -517,6 +546,8 @@ nestForm.addEventListener("submit", async (e) => {
// ---------- start: load the nests, open # todo, the Sentry says hello ----------
crow("Sentry");
(async () => {
personas = await getCrows();
for (const name of tiles.keys()) applyPersona(name);
const data = await getNests();
flockNames = data.roster;
if (!data.nests.some((n) => n.nestId === "todo")) data.nests.unshift({ nestId: "todo", name: "todo", crows: [] });
+9
View File
@@ -123,6 +123,7 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
.tile.has-pending { background: rgb(155 47 34 / 0.06); }
.tile-text { min-width: 0; }
.tile-name { margin: 0; font: 400 16px/1.15 var(--display); }
.epithet { font: italic 500 14px/1 var(--body); color: var(--muted); }
.update {
margin: 3px 0 0; font: italic 500 14px/1.3 var(--body); color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
@@ -224,6 +225,14 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
.reaction.good, .reaction.auto { color: var(--ink); }
.reaction.bad { color: var(--red); }
/* Banter: a quiet aside between crows, smaller and set in from the work. */
.msg.banter { grid-template-columns: 26px 1fr; gap: 0 10px; padding-top: 4px; padding-bottom: 8px; padding-left: 82px; }
.msg.banter .avatar { width: 26px; height: 24px; margin-top: 1px; }
.banter-line { margin: 0; font-size: 15px; }
.banter-who { display: block; font: 400 13px/1.2 var(--display); color: var(--muted); }
.banter-text { font-style: italic; }
@media (max-width: 759px) { .msg.banter { padding-left: 60px; } }
.msg.system { display: block; padding: 12px 28px; text-align: center; color: var(--muted); font-style: italic; font-size: 15px; }
.msg.system p { margin: 0; }
.msg.system.sparkle p::before {