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

+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: [] });