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
@@ -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
@@ -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: [] });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user