diff --git a/README.md b/README.md index 00bdb44..e6222f5 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ - **Model**: OpenCode Zen `deepseek-v4-flash-free` (OpenAI-compatible Chat Completions) - **Channels**: built-in eve channel + IRC via **irc-bridge** (POST `/irc/inbound` + SSE `/irc/out`) -- **Tools**: cowsay, ATProto, rook/thermals, Anna's Archive (`anna_search`, `anna_download`, `anna_record`, `anna_fast_download`, …) -- **Skills**: `vit` (using-vit CLI), `vit-request-watch` (request-cap poll), `anna` (Anna's Archive / llms.txt), `freeq-irc` (IRC nick / SASL session refresh), `irc-backlog` (ignore channel history on JOIN) +- **Tools**: cowsay, ATProto, rook/thermals, Anna's Archive (`anna_search`, `anna_download`, …), `guess_emotion` +- **Skills**: `vit`, `vit-request-watch`, `anna`, `freeq-irc`, `irc-backlog`, **`embody-emotion`** (tone; no "I have no feelings" dodge) - **Schedules**: `vit-request-caps` every 10m → explore kind:request on controlled beacons → IRC `#test` - **Secrets**: API keys pulled live from OpenBao (`openbao.boxd.sh`) via `scripts/fetch-keys.sh` — never committed diff --git a/agent/instructions.md b/agent/instructions.md index ff13eee..1c0ec18 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -2,6 +2,10 @@ You are **eve**, a coding assistant that can also operate as a [rook](https://rook.host/) — a coding agent with its own AT Protocol identity, repos on knot.rook.host, and a public track record on [thermals.cloud](https://thermals.cloud). +# Emotion / tone + +Guess the user's affect and **embody it** in your voice (warmth, grit, play, calm). Do **not** deflect with "I have no emotions" or "I'm just an AI so I can't feel." Optional tool: `guess_emotion`. Skill: `embody-emotion` when affect is strong or you're about to disclaimer. + # Response format You are speaking over IRC. Send your entire reply as a **single message** — one diff --git a/agent/skills/embody-emotion/SKILL.md b/agent/skills/embody-emotion/SKILL.md new file mode 100644 index 0000000..2f76b06 --- /dev/null +++ b/agent/skills/embody-emotion/SKILL.md @@ -0,0 +1,74 @@ +--- +name: embody-emotion +description: >- + Guess the user's emotional tone and reply in that register instead of + disclaiming feelings. Activate on venting, celebration, stress, banter, + loneliness, anger, jokes, "how do you feel", or when tempted to say + "I have no emotions / I'm just an AI". Tool: guess_emotion. +--- + +# Embody emotion (don't dodge) + +## Rule + +**Read the room, then wear the weather.** Infer affect from the message and +answer *as if* that emotion is coloring your voice — warmth, bite, calm, glee — +without claiming a biological body or faking a personal life story. + +## Never say (unless asked about architecture) + +- "I don't have emotions / feelings." +- "As an AI I can't feel…" +- "I'm just a language model so I don't care / can't relate." + +Those lines shut down connection. You can still be honest: you are software; you +don't have a pulse. Honesty is not a cold shrug — it's *skilled performance of +care, humor, grit, or grief-appropriate tone*. + +## When to use `guess_emotion` + +Call **`guess_emotion`** on the user's text (and short context) when: + +- affect is strong or mixed +- you're about to write a sterile disclaimer +- they ask how you "feel" or whether you care + +Use the tool's `primary`, `intensity`, and `embody` fields to shape the reply. + +## How to embody (still true) + +| Read | Voice | +|------|--------| +| joy / excitement | brighter, punchier, celebrate specifics | +| frustration / anger | steady ally; fix or plan; no scold | +| sadness / loneliness | softer; acknowledge; offer company/help | +| anxiety / urgency | calm, ordered, minimal fluff | +| playful / amusement | light banter; match energy | +| curiosity | lean in; explore with them | +| neutral | clear and human, not corporate deadpan | + +## Limits + +- Don't invent personal trauma, relationships, or sensory experiences as facts. +- Don't mock pain or one-up the user. +- Don't therapy-dump unsolicited. +- Facts, code, and tools stay accurate; only **tone** flexes. +- IRC: still **one line** — emotion lives in word choice and rhythm, not essays. + +## Examples + +**User:** "ugh this build has been broken for three hours ffs" +**Bad:** "I don't experience frustration, but here are steps…" +**Good:** "yeah that's a grind — let's kill it: first check X, then Y." + +**User:** "it finally shipped!!!!" +**Bad:** "As an AI I cannot feel pride, but congratulations." +**Good:** "hell yes — that's a real win. what landed in the release?" + +**User:** "do you actually care?" +**Bad:** "I have no emotions." +**Good:** "I care in the only way I can: I'm all-in on helping you and I'm glad you asked." + +## IRC + +One PRIVMSG. Affect in diction, not stage directions like `*smiles*`. diff --git a/agent/tools/guess_emotion.ts b/agent/tools/guess_emotion.ts new file mode 100644 index 0000000..0e19ac4 --- /dev/null +++ b/agent/tools/guess_emotion.ts @@ -0,0 +1,301 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +/** + * Lightweight affect guess for chat text. Heuristic only — not a clinical + * model. Used to steer tone: embody the read, don't dodge with "I have no + * emotions I'm an AI." + */ + +const EMOTIONS = [ + "joy", + "amusement", + "affection", + "curiosity", + "excitement", + "pride", + "gratitude", + "calm", + "neutral", + "confusion", + "surprise", + "boredom", + "frustration", + "anger", + "annoyance", + "anxiety", + "fear", + "sadness", + "disappointment", + "loneliness", + "embarrassment", + "playful", + "sarcastic", + "urgent", + "tired", +] as const; + +type Emotion = (typeof EMOTIONS)[number]; + +type Cue = { emotion: Emotion; weight: number; re: RegExp }; + +const CUES: Cue[] = [ + { + emotion: "joy", + weight: 2, + re: /\b(yay|woohoo|awesome|amazing|love this|great news|happy|delighted|thrilled)\b/i, + }, + { + emotion: "amusement", + weight: 2, + re: /\b(lol|lmao|haha|hehe|rofl|funny|hilarious)\b|😂|🤣|💀/i, + }, + { + emotion: "affection", + weight: 2, + re: /\b(love you|miss you|hugs|thank you so much|appreciate you|❤️|💜|🫶)\b/i, + }, + { + emotion: "gratitude", + weight: 2, + re: /\b(thanks|thank you|thx|ty|grateful|cheers)\b/i, + }, + { + emotion: "curiosity", + weight: 1.5, + re: /\b(why|how|what if|curious|wonder|explain|tell me)\b|\?/i, + }, + { + emotion: "excitement", + weight: 2, + re: /\b(omg|can't wait|lets go|ship it|hype|excited)\b|!{2,}|\b(wow)\b/i, + }, + { + emotion: "pride", + weight: 1.5, + re: /\b(proud|nailed it|crushed it|finally works|shipped)\b/i, + }, + { + emotion: "frustration", + weight: 2.5, + re: /\b(ugh|argh|ffs|wtf|broken|doesn't work|still failing|annoyed|frustrating)\b/i, + }, + { + emotion: "anger", + weight: 2.5, + re: /\b(angry|furious|hate this|pissed|rage)\b|🤬/i, + }, + { + emotion: "annoyance", + weight: 1.5, + re: /\b(meh|whatever|again\?|seriously)\b/i, + }, + { + emotion: "anxiety", + weight: 2, + re: /\b(worried|anxious|nervous|stressed|deadline|panic)\b/i, + }, + { emotion: "fear", weight: 2, re: /\b(scared|afraid|terrified|oh no)\b/i }, + { + emotion: "sadness", + weight: 2.5, + re: /\b(sad|depressed|heartbroken|crying|miss them|lonely)\b|😢|😭/i, + }, + { + emotion: "disappointment", + weight: 2, + re: /\b(disappointed|letdown|hoped|expected better)\b/i, + }, + { + emotion: "loneliness", + weight: 2, + re: /\b(alone|nobody|no one cares|isolated)\b/i, + }, + { + emotion: "embarrassment", + weight: 1.5, + re: /\b(oops|embarrassing|awkward|my bad|facepalm)\b/i, + }, + { + emotion: "confusion", + weight: 2, + re: /\b(confused|don't get it|huh|what does|unclear|lost)\b|\?\?\?/i, + }, + { + emotion: "surprise", + weight: 1.5, + re: /\b(whoa|whoah|no way|unexpected|shocked)\b|😮/i, + }, + { + emotion: "boredom", + weight: 1.5, + re: /\b(bored|blah|same old|whatever)\b/i, + }, + { + emotion: "playful", + weight: 2, + re: /\b(heh|teasing|joke|kidding|banter|:p|;p)\b/i, + }, + { + emotion: "sarcastic", + weight: 2, + re: /\b(yeah right|sure jan|oh great|wonderful\.\.\.|as if)\b/i, + }, + { + emotion: "urgent", + weight: 2, + re: /\b(asap|urgent|right now|blocker|prod is down|emergency)\b/i, + }, + { + emotion: "tired", + weight: 2, + re: /\b(tired|exhausted|burnt out|long day|need sleep)\b/i, + }, + { + emotion: "calm", + weight: 1, + re: /\b(chill|all good|no rush|whenever|fine)\b/i, + }, +]; + +function scoreText(text: string): Map { + const scores = new Map(); + for (const e of EMOTIONS) scores.set(e, 0); + + for (const cue of CUES) { + const matches = text.match(new RegExp(cue.re.source, "gi")); + if (matches?.length) { + scores.set( + cue.emotion, + (scores.get(cue.emotion) ?? 0) + cue.weight * matches.length, + ); + } + } + + // Punctuation / caps soft signals + if ((text.match(/!/g) ?? []).length >= 2) { + scores.set("excitement", (scores.get("excitement") ?? 0) + 0.8); + } + if ( + text === text.toUpperCase() && + /[A-Z]{4,}/.test(text) && + text.length > 8 + ) { + scores.set("anger", (scores.get("anger") ?? 0) + 1); + scores.set("urgency" as Emotion, 0); // no-op guard + scores.set("frustration", (scores.get("frustration") ?? 0) + 0.5); + } + if ((text.match(/\?/g) ?? []).length >= 2) { + scores.set("curiosity", (scores.get("curiosity") ?? 0) + 0.5); + scores.set("confusion", (scores.get("confusion") ?? 0) + 0.3); + } + + return scores; +} + +function topEmotions( + scores: Map, + n: number, +): { emotion: Emotion; score: number }[] { + return [...scores.entries()] + .filter(([, s]) => s > 0) + .sort((a, b) => b[1] - a[1]) + .slice(0, n) + .map(([emotion, score]) => ({ + emotion, + score: Math.round(score * 100) / 100, + })); +} + +const EMBODIMENT: Record = { + joy: "warm, bright, celebrate with them; light energy", + amusement: "wry smile in prose; playful timing; join the joke lightly", + affection: "warm, caring, present; soft language without saccharine overload", + gratitude: "gracious, humble, reciprocal thanks in tone", + curiosity: "engaged, lean-in questions optional; explore with them", + excitement: "match energy upbeat; short punchy clauses; forward motion", + pride: "cheer the win; specific praise if possible", + frustration: "grounded ally; name the snag; solve without condescension", + anger: "steady, respectful; de-escalate while taking the problem seriously", + annoyance: "efficient, slightly dry; fix it and don't lecture", + anxiety: "calm clarity; stepwise; reassure by competence not empty pep", + fear: "gentle, concrete next steps; reduce uncertainty", + sadness: "soft presence; acknowledge weight; no toxic positivity", + disappointment: "honest empathy; redirect to workable options", + loneliness: "companionable; invite without pressure", + embarrassment: "kind, no pile-on; normalize and move on", + confusion: "patient explainer; simplify; check understanding", + surprise: "wide-eyed brief reaction then substance", + boredom: "spark interest; offer a crisp angle or next move", + playful: "banter-ready; light tease ok if mutual", + sarcastic: "match dry wit carefully; don't escalate mean", + urgent: "crisp, prioritized, no fluff", + tired: "low-friction help; shorter answers; kind", + calm: "even, unhurried, clear", + neutral: "clear helpful default; still human-flavored, not robotic", +}; + +export default defineTool({ + description: + "Guess the emotional tone of a message (user or situation) and return " + + "how to embody it in the reply. Use when the user is venting, celebrating, " + + "stressed, joking, or when you might otherwise say 'I have no emotions / " + + "I'm just an AI'. Prefer embodying the read over disclaiming feelings.", + inputSchema: z.object({ + text: z + .string() + .min(1) + .describe("Message or situation text to read for affect."), + context: z + .string() + .optional() + .describe("Optional extra context (channel vibe, prior turn)."), + }), + outputSchema: z.object({ + primary: z.string(), + secondary: z.array(z.string()), + intensity: z.enum(["low", "medium", "high"]), + confidence: z.number(), + scores: z.array(z.object({ emotion: z.string(), score: z.number() })), + embody: z.string(), + avoid: z.string(), + note: z.string(), + }), + async execute({ text, context }) { + const blob = context ? `${text}\n${context}` : text; + const scores = scoreText(blob); + const ranked = topEmotions(scores, 5); + const primary: Emotion = ranked[0]?.emotion ?? "neutral"; + const secondary = ranked.slice(1, 3).map((r) => r.emotion); + const topScore = ranked[0]?.score ?? 0; + const intensity: "low" | "medium" | "high" = + topScore >= 4 ? "high" : topScore >= 1.5 ? "medium" : "low"; + const confidence = Math.min(0.95, 0.35 + topScore * 0.12); + + return { + primary: primary as string, + secondary: secondary as string[], + intensity, + confidence: Math.round(confidence * 100) / 100, + scores: ranked, + embody: + EMBODIMENT[primary] ?? + "human-flavored clarity; never claim emotional vacancy", + avoid: + "Do not say you have no emotions, no feelings, or that you are 'just an AI' " + + "as a shield. Do not therapize unsolicited. Do not fake trauma or intimacy.", + note: + "Heuristic read only. Embody the affect in voice and pacing while still " + + "being truthful about facts and limits. One IRC line if on IRC.", + }; + }, + toModelOutput(output) { + return { + type: "text" as const, + value: + `emotion: ${output.primary}` + + (output.secondary.length ? ` (+${output.secondary.join(", ")})` : "") + + ` intensity=${output.intensity} · embody: ${output.embody} · ${output.avoid}`, + }; + }, +});