@a-t-h-i/bot-lobby 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,12 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { Domain } from "../schemas/agent.ts";
4
- import type { BotLobbyConfig } from "../schemas/configuration.ts";
4
+ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
5
5
  import type { AgentRun, ResearchResult } from "../schemas/findings.ts";
6
- import { runAgent, type AgentRequest } from "../execution/agent-runner.ts";
6
+ import { runAgent, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
7
7
  import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
8
8
  import { writeFileEnsured } from "../knowledge/store.ts";
9
9
  import { isResearchResultUsable, parseResearchResult, validateResearchResult } from "../roles/researcher.ts";
10
- import { resolveModel } from "./master.ts";
11
10
 
12
11
  export interface ResearchOutcome {
13
12
  result: ResearchResult;
@@ -25,6 +24,7 @@ export interface ResearchRequest {
25
24
  cwd: string;
26
25
  taskDir: string;
27
26
  config: BotLobbyConfig;
27
+ profile?: ProfileResolver;
28
28
  signal?: AbortSignal;
29
29
  onUpdate?: (run: AgentRun) => void;
30
30
  }
@@ -75,6 +75,7 @@ function toOutcome(run: AgentRun, domain: Domain): ResearchOutcome {
75
75
 
76
76
  /** Run the researcher for one domain and persist its report for audit. */
77
77
  export async function runResearch(request: ResearchRequest, run: ProcessRunner = spawnPiProcess): Promise<ResearchOutcome> {
78
+ const profile = profileFor(request.config, request.profile, request.domain, "researcher");
78
79
  const agentRun = await runAgent(
79
80
  {
80
81
  taskId: request.taskId,
@@ -82,13 +83,13 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
82
83
  role: "researcher",
83
84
  instruction: request.instruction,
84
85
  context: researchContext(request),
85
- model: resolveModel(request.config, request.domain),
86
- thinking: request.config.agents[request.domain].thinking,
87
- timeoutMs: request.config.workflow.agentTimeoutMs,
86
+ model: profile.model,
87
+ thinking: profile.thinking,
88
+ timeoutMs: profile.timeoutMs,
88
89
  cwd: request.cwd,
89
90
  signal: request.signal,
90
91
  onUpdate: request.onUpdate,
91
- retries: request.config.workflow.maxAgentRetries,
92
+ ...watchdogOptions(request.config.workflow),
92
93
  },
93
94
  run,
94
95
  );
@@ -61,3 +61,48 @@ export function oracleActivityWord(toolName: string, args?: unknown): string {
61
61
  const action = orchestrateAction(args);
62
62
  return (action && ORACLE_ACTION_WORDS[action]) || FALLBACK_WORD;
63
63
  }
64
+
65
+ const DETAIL_CHARS = 28;
66
+
67
+ function clip(text: string): string {
68
+ const flat = text.replace(/\s+/g, " ").trim();
69
+ return flat.length > DETAIL_CHARS ? `${flat.slice(0, DETAIL_CHARS - 1)}…` : flat;
70
+ }
71
+
72
+ function field(args: unknown, ...names: string[]): string | undefined {
73
+ if (!args || typeof args !== "object") return undefined;
74
+ for (const name of names) {
75
+ const value = (args as Record<string, unknown>)[name];
76
+ if (typeof value === "string" && value.trim()) return value;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Last path segment, so `src/pi/zen.ts` reads `zen.ts`. */
82
+ function baseName(path: string): string {
83
+ const parts = path.replace(/[\\/]+$/, "").split(/[\\/]/);
84
+ return parts.at(-1) || path;
85
+ }
86
+
87
+ /**
88
+ * A short target for what a tool call touches: the file for read/edit/write,
89
+ * the command head for bash, the pattern for grep, the query for web search.
90
+ * Pure; undefined when the arguments name nothing useful.
91
+ */
92
+ export function activityDetail(toolName: string, args?: unknown): string | undefined {
93
+ const tool = toolName.trim().toLowerCase();
94
+ if (tool === "read" || tool === "edit" || tool === "write" || tool === "ls") {
95
+ const path = field(args, "path", "file_path", "file");
96
+ return path ? clip(baseName(path)) : undefined;
97
+ }
98
+ if (tool === "bash") {
99
+ const command = field(args, "command", "cmd");
100
+ return command ? clip(command) : undefined;
101
+ }
102
+ if (tool === "grep" || tool === "find") {
103
+ const pattern = field(args, "pattern", "query", "glob");
104
+ return pattern ? clip(`"${pattern}"`) : undefined;
105
+ }
106
+ const query = field(args, "query", "url", "path");
107
+ return query ? clip(query) : undefined;
108
+ }
@@ -2,7 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
4
  import { TERMINAL_STATES, createTask, taskRequest, type Task } from "../schemas/task.ts";
5
- import { detectProjectRoot, globalConfigPath, loadConfig, readDataRoots } from "../state/project.ts";
5
+ import { detectProjectRoot, globalConfigPath, loadConfig, readDataRoots, readRawConfig } from "../state/project.ts";
6
+ import { hasScoutThinking, SCOUT_THINKING } from "../schemas/configuration.ts";
6
7
  import {
7
8
  activeTask,
8
9
  claimTask,
@@ -24,6 +25,9 @@ import { applyApprovalChoice, describeTask, describeOversizedKnowledge, type App
24
25
  import { shortTitle } from "../text.ts";
25
26
  import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
26
27
  import { applyMasterModel, openSettings } from "./settings-ui.ts";
28
+ import { modelRef, thinkingMismatches } from "./model-support.ts";
29
+ import { describeRun, runFromLog } from "./run-summary.ts";
30
+ import { modelLookup } from "./tools.ts";
27
31
 
28
32
  const HELP = [
29
33
  "/bot-lobby <request> Start a task through the workflow",
@@ -33,6 +37,7 @@ const HELP = [
33
37
  "/bot-lobby cancel [taskId] Abandon a task",
34
38
  "/bot-lobby approve|amend <text>|decline Answer the current proposal",
35
39
  "/bot-lobby knowledge Show persistent knowledge files",
40
+ "/bot-lobby runs [taskId] Recent subagent runs: time, turns, tokens, cost, model",
36
41
  "/bot-lobby settings Edit per-agent model/thinking/instructions",
37
42
  "/bot-lobby config Show effective configuration",
38
43
  "/bot-lobby minimize|restore Hide or restore bot-lobby for this session (ctrl+shift+m)",
@@ -40,7 +45,7 @@ const HELP = [
40
45
  ].join("\n");
41
46
 
42
47
  /** Subcommands only win when no free-form text follows (so tasks still start). */
43
- const SUBCOMMANDS = new Set(["status", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim"]);
48
+ const SUBCOMMANDS = new Set(["status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim"]);
44
49
 
45
50
  function isTaskId(value: string | undefined): boolean {
46
51
  return Boolean(value && /^TASK-/.test(value));
@@ -118,6 +123,20 @@ function showStatus(ctx: ExtensionCommandContext, configDir: string, taskId?: st
118
123
  ctx.ui.notify(task ? `${describeTask(task)}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
119
124
  }
120
125
 
126
+ /** Recent runs of the active (or named) task, newest last, so slow models are easy to spot. */
127
+ export function runsReport(task: Task | undefined, limit = 20): string {
128
+ if (!task) return "No bot-lobby task found.";
129
+ const entries = (task.runLog ?? []).slice(-limit);
130
+ if (entries.length === 0) return `${task.id}: no finished subagent runs yet.`;
131
+ return [`${task.id} — last ${entries.length} run${entries.length === 1 ? "" : "s"}:`, ...entries.map((entry) => describeRun(runFromLog(entry, task.id)))].join("\n");
132
+ }
133
+
134
+ function showRuns(ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
135
+ const root = detectProjectRoot(ctx.cwd, configDir);
136
+ const task = selectTask(root, configDir, taskId, ctx.sessionManager.getSessionId()) ?? (taskId ? undefined : ownerlessTask(root, configDir));
137
+ ctx.ui.notify(runsReport(task), task ? "info" : "warning");
138
+ }
139
+
121
140
  function showTasks(ctx: ExtensionCommandContext, configDir: string): void {
122
141
  const root = detectProjectRoot(ctx.cwd, configDir);
123
142
  const { tasks, corrupted } = taskHealth(root, configDir);
@@ -208,7 +227,11 @@ function showKnowledge(ctx: ExtensionCommandContext, configDir: string): void {
208
227
  }
209
228
 
210
229
  function showConfig(ctx: ExtensionCommandContext): void {
211
- ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(loadConfig(), null, 2)}`, "info");
230
+ const config = loadConfig();
231
+ const warnings = thinkingMismatches(config, modelLookup(ctx), ctx.model ? modelRef(ctx.model) : undefined);
232
+ if (hasScoutThinking(readRawConfig())) warnings.push(`Scout: thinking is fixed at "${SCOUT_THINKING}"; the scout.thinking value in the file is ignored.`);
233
+ const notes = warnings.length > 0 ? `\n\nWarnings:\n${warnings.map((line) => `- ${line}`).join("\n")}` : "";
234
+ ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(config, null, 2)}${notes}`, warnings.length > 0 ? "warning" : "info");
212
235
  }
213
236
 
214
237
  export function registerCommands(pi: ExtensionAPI, configDir: string): void {
@@ -231,6 +254,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
231
254
  return showStatus(ctx, configDir, rest[0]);
232
255
  case "tasks":
233
256
  return showTasks(ctx, configDir);
257
+ case "runs":
258
+ return showRuns(ctx, configDir, rest[0]);
234
259
  case "pause":
235
260
  return setPaused(ctx, configDir, true, rest[0]);
236
261
  case "resume":
@@ -20,12 +20,19 @@ export const FAST_TICK_MS = 120;
20
20
  export const REST_FRAME = 0;
21
21
  export const BLINK_FRAME = 1;
22
22
  export const EMOTE_FRAME = 2;
23
- /** Emote frame range: `EMOTE_FRAME .. EMOTE_FRAME + EMOTE_FRAMES - 1`; an emote steps through them. */
24
- export const EMOTE_FRAMES = 2;
23
+ /**
24
+ * Emote frame range: `EMOTE_FRAME .. EMOTE_FRAME + EMOTE_FRAMES - 1`; an emote
25
+ * steps through them: the open face, a blink, then its action (see kaomoji.ts).
26
+ */
27
+ export const EMOTE_FRAMES = 4;
25
28
  /** Each emote frame is held this long, so an emote steps through its frames. */
26
29
  export const EMOTE_STEP_MS = Math.floor(EMOTE_MS / EMOTE_FRAMES);
27
30
 
28
31
  const BLINK_CHANCE = 0.65;
32
+ /** Working agents emote as often as they blink. */
33
+ export const WORKING_BLINK_CHANCE = 0.5;
34
+ /** Upper bound (exclusive) of an expression's variant. */
35
+ const VARIANT_SPAN = 2 ** 30;
29
36
 
30
37
  export interface ExpressionState {
31
38
  /** Earliest `now` the next expression may start. */
@@ -36,6 +43,8 @@ export interface ExpressionState {
36
43
  startedAt: number;
37
44
  /** Frame index for the art: 0 rest, 1 blink, 2+ emote. */
38
45
  frame: number;
46
+ /** Drawn once per expression; picks which face an emote shows (see kaomoji.ts). */
47
+ variant: number;
39
48
  }
40
49
 
41
50
  /** A random draw clamped to `[0, 1]`, so a non-finite source value can never leak. */
@@ -44,20 +53,37 @@ function unit(rng: () => number): number {
44
53
  return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
45
54
  }
46
55
 
47
- /** Random resting gap before the next expression, in `[BLINK_MIN_MS, BLINK_MAX_MS]`. */
48
- export function nextGap(rng: () => number): number {
49
- return BLINK_MIN_MS + Math.round(unit(rng) * (BLINK_MAX_MS - BLINK_MIN_MS));
56
+ /** Resting window between expressions, milliseconds. */
57
+ export interface ExpressionGap {
58
+ min: number;
59
+ max: number;
60
+ }
61
+
62
+ /** Idle agent slots rest 20-30 s between expressions. */
63
+ export const SLOT_GAP: ExpressionGap = { min: BLINK_MIN_MS, max: BLINK_MAX_MS };
64
+ /** Working agents are livelier: 8-15 s between expressions. */
65
+ export const WORKING_GAP: ExpressionGap = { min: 8_000, max: 15_000 };
66
+ /** The oracle is the scene's centrepiece: it blinks and emotes every 6-12 s. */
67
+ export const ORACLE_GAP: ExpressionGap = { min: 6_000, max: 12_000 };
68
+
69
+ /** Random resting gap before the next expression, inside `gap` (the slot window by default). */
70
+ export function nextGap(rng: () => number, gap: ExpressionGap = SLOT_GAP): number {
71
+ return gap.min + Math.round(unit(rng) * (gap.max - gap.min));
72
+ }
73
+
74
+ /** Whether the next expression blinks (~65% by default) or emotes. */
75
+ export function pickEvent(rng: () => number, blinkChance = BLINK_CHANCE): "blink" | "emote" {
76
+ return unit(rng) < blinkChance ? "blink" : "emote";
50
77
  }
51
78
 
52
- /** Whether the next expression blinks (~65%) or emotes (~35%). */
53
- export function pickEvent(rng: () => number): "blink" | "emote" {
54
- return unit(rng) < BLINK_CHANCE ? "blink" : "emote";
79
+ function drawVariant(rng: () => number): number {
80
+ return Math.floor(unit(rng) * (VARIANT_SPAN - 1));
55
81
  }
56
82
 
57
83
  /** A freshly rested expression that fires for the first time after one random gap. */
58
- export function createExpression(now: number, rng: () => number): ExpressionState {
84
+ export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
59
85
  const start = Number.isFinite(now) ? now : 0;
60
- return { nextAt: start + nextGap(rng), until: start, startedAt: start, frame: REST_FRAME };
86
+ return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME, variant: 0 };
61
87
  }
62
88
 
63
89
  /** True while `now` is inside a playing blink or emote. */
@@ -74,17 +100,59 @@ export function anyPlaying(states: readonly ExpressionState[], now: number): boo
74
100
  * Advance one sprite to `now`: start a blink or emote once the gap has elapsed,
75
101
  * drop back to the rest frame when it ends, and change nothing in between.
76
102
  */
77
- export function advanceExpression(state: ExpressionState, now: number, rng: () => number): ExpressionState {
103
+ export function advanceExpression(
104
+ state: ExpressionState,
105
+ now: number,
106
+ rng: () => number,
107
+ gap: ExpressionGap = SLOT_GAP,
108
+ blinkChance = BLINK_CHANCE,
109
+ ): ExpressionState {
78
110
  if (!Number.isFinite(now)) return state;
79
111
  if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
80
112
  if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
81
- return play(now, rng);
113
+ return play(now, rng, gap, blinkChance);
114
+ }
115
+
116
+ function play(now: number, rng: () => number, gap: ExpressionGap, blinkChance: number): ExpressionState {
117
+ const blink = pickEvent(rng, blinkChance) === "blink";
118
+ return blink ? startBlink(now, rng, gap) : triggerEmote(now, rng, gap);
119
+ }
120
+
121
+ function startBlink(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
122
+ const until = now + BLINK_MS;
123
+ return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: BLINK_FRAME, variant: drawVariant(rng) };
124
+ }
125
+
126
+ /**
127
+ * Start an emote right now, whatever the sprite was doing: the reaction when a
128
+ * slot's situation changes (it starts, finishes, fails, stalls, waits or
129
+ * receives a file). The next scheduled expression follows one gap later.
130
+ */
131
+ export function triggerEmote(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
132
+ const start = Number.isFinite(now) ? now : 0;
133
+ const until = start + EMOTE_MS;
134
+ return { nextAt: until + nextGap(rng, gap), until, startedAt: start, frame: EMOTE_FRAME, variant: drawVariant(rng) };
82
135
  }
83
136
 
84
- function play(now: number, rng: () => number): ExpressionState {
85
- const blink = pickEvent(rng) === "blink";
86
- const until = now + (blink ? BLINK_MS : EMOTE_MS);
87
- return { nextAt: until + nextGap(rng), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
137
+ /** One sub-step of a playing expression; the oracle's blink and glance step through these. */
138
+ export const PHASE_MS = FAST_TICK_MS;
139
+
140
+ /** Sub-steps elapsed in the playing expression, 0 while resting or on a bad clock. */
141
+ export function expressionPhase(state: ExpressionState, now: number): number {
142
+ if (!isPlaying(state, now)) return 0;
143
+ const elapsed = now - state.startedAt;
144
+ return Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / PHASE_MS) : 0;
145
+ }
146
+
147
+ /** How long the oracle lip-syncs after it says something new, and one mouth shape's hold. */
148
+ export const TALK_MS = 1800;
149
+ export const TALK_STEP_MS = FAST_TICK_MS;
150
+
151
+ /** Mouth-shape index while the oracle is still talking about what it said at `since`, else undefined. */
152
+ export function talkFrame(since: number | undefined, now: number): number | undefined {
153
+ if (since === undefined || !Number.isFinite(since) || !Number.isFinite(now)) return undefined;
154
+ const elapsed = now - since;
155
+ return elapsed >= 0 && elapsed < TALK_MS ? Math.floor(elapsed / TALK_STEP_MS) : undefined;
88
156
  }
89
157
 
90
158
  /** Emote frame for `now`: one step per EMOTE_STEP_MS, clamped to the last frame. */
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The agents' kaomoji: a pure catalogue of emotes and the rules that pick one.
3
+ *
4
+ * Every emote is a tiny animation: the open face, the same face blinking (same
5
+ * width, so nothing shifts), and an optional action frame (a table flip, a
6
+ * sparkle, a bow). Each agent has its own personality set for every emotion,
7
+ * and a shared pool of emotions is open to all of them:
8
+ *
9
+ * - DEV, the hacker: shades, flexing, coffee, and table flips on failure
10
+ * - DESIGN, the artist: sparkles, flowers, hearts and little flourishes
11
+ * - RESEARCH, the bookworm: note-taking, pointing, shrugging, deal-with-it glasses
12
+ * - QA, the inspector: side-eyes and squints, then a flex and a victory dance
13
+ *
14
+ * Nothing here reads a clock or a random source: the caller passes a `variant`
15
+ * drawn once per expression, so the same variant always picks the same face.
16
+ * Every face fits a slot cell and avoids combining marks, right-to-left
17
+ * scripts and emoji-presentation glyphs (enforced by tests).
18
+ */
19
+ import type { SlotId, SlotState } from "./mascot-art.ts";
20
+
21
+ export const EMOTIONS = [
22
+ "happy",
23
+ "proud",
24
+ "love",
25
+ "excited",
26
+ "focused",
27
+ "curious",
28
+ "thinking",
29
+ "nervous",
30
+ "confused",
31
+ "sleepy",
32
+ "sad",
33
+ "angry",
34
+ "waiting",
35
+ "surprised",
36
+ "grateful",
37
+ ] as const;
38
+ export type Emotion = (typeof EMOTIONS)[number];
39
+
40
+ /** One emote: the open face, the same face blinking, and an optional action frame. */
41
+ export interface Emote {
42
+ open: string;
43
+ blink: string;
44
+ action?: string;
45
+ }
46
+
47
+ const e = (open: string, blink: string, action?: string): Emote => (action ? { open, blink, action } : { open, blink });
48
+
49
+ /** Emotions every agent can draw on. */
50
+ export const SHARED_EMOTES: Record<Emotion, readonly Emote[]> = {
51
+ happy: [e("(^▽^)", "(-▽-)"), e("(^ω^)", "(-ω-)"), e("ヽ(•‿•)ノ", "ヽ(-‿-)ノ"), e("(*^‿^*)", "(*-‿-*)"), e("(◕‿◕)", "(-‿-)")],
52
+ proud: [e("( ̄^ ̄)ゞ", "(-^-)ゞ"), e("(´▽`)b", "(-▽-)b"), e("(  ̄ー ̄)", "( -ー-)"), e("(•ᴗ•)ง", "(-ᴗ-)ง")],
53
+ love: [e("(♡‿♡)", "(-‿-)", "(♡‿♡)♡"), e("(っ˘з(˘⌣˘ )", "(っ-з(-⌣- )"), e("(´ε` )♡", "(-ε- )♡"), e("(◍•ᴗ•◍)♡", "(◍-ᴗ-◍)♡")],
54
+ excited: [e("\\(^o^)/", "\\(-o-)/"), e("ヽ(^▽^)ノ", "ヽ(-▽-)ノ"), e("(ノ◕ヮ◕)ノ", "(ノ-ヮ-)ノ", "(ノ◕ヮ◕)ノ*:・゚✧"), e("o(≧▽≦)o", "o(-▽-)o")],
55
+ focused: [e("(•_•)", "(-_-)"), e("(ง •_•)ง", "(ง -_-)ง"), e("(`・ω・´)", "(`-ω-´)"), e("( ._.)φ", "( -_-)φ")],
56
+ curious: [e("(・・ )?", "(-- )?"), e("(⊙_⊙)?", "(-_-)?"), e("(◔_◔)", "(-_-)"), e("(゚∀゚)?", "(-∀-)?")],
57
+ thinking: [e("( ̄~ ̄;)", "(-~-;)"), e("( ˘_˘ )", "( -_- )"), e("(・・;)ゞ", "(--;)ゞ"), e("(¬‿¬ )", "(-‿- )")],
58
+ nervous: [e("(;・∀・)", "(;-∀-)"), e("(⊙﹏⊙)", "(-﹏-)"), e("(●´⌓`●)", "(●-⌓-●)"), e("(°□°;)", "(-□-;)")],
59
+ confused: [e("(・_・ヾ", "(-_-ヾ"), e("(⊙_☉)", "(-_-)"), e("(゜-゜)", "(---)"), e("(・・?)", "(--?)")],
60
+ sleepy: [e("(-_-) zzZ", "(-.-) zzZ"), e("( ̄o ̄) zzZZ", "(-o-) zzZZ"), e("(∪。∪)。zz", "(-。-)。zz"), e("( ˘ω˘ )スヤァ", "( -ω- )スヤァ")],
61
+ sad: [e("(ಥ_ಥ)", "(-_-)"), e("(╥﹏╥)", "(-﹏-)"), e("(´;ω;`)", "(-;ω;-)"), e("(〒﹏〒)", "(-﹏-)"), e("(T_T)", "(-_-)")],
62
+ angry: [e("(╬ಠ益ಠ)", "(╬-益-)"), e("(ノಠ益ಠ)ノ", "(ノ-益-)ノ", "(ノಠ益ಠ)ノ彡┻━┻"), e("ヽ(`Д´)ノ", "ヽ(-Д-)ノ"), e("(¬_¬\")", "(-_-\")")],
63
+ waiting: [e("(´・_・`)", "(´-_-`)"), e("( ˘・_・˘)", "( ˘-_-˘)"), e("(っ´ω`c)", "(っ-ω-c)"), e("(・_・)…", "(-_-)…")],
64
+ surprised: [e("(°o°)", "(-o-)"), e("Σ(°△°|||)", "Σ(-△-|||)"), e("(゚Д゚)", "(-Д-)"), e("w(°o°)w", "w(-o-)w")],
65
+ grateful: [e("(人´∀`)", "(人-∀-)"), e("(◍•ᴗ•◍)", "(◍-ᴗ-◍)"), e("m(_ _)m", "m(- -)m"), e("(ᵔᴥᵔ)", "(-ᴥ-)"), e("(*´∀`*)", "(*-∀-*)")],
66
+ };
67
+
68
+ /** DEV, the hacker: shades, flexes, coffee and code brackets; flips tables when things break. */
69
+ const DEV: Record<Emotion, readonly Emote[]> = {
70
+ happy: [e("(⌐■_■)", "(⌐-_-)"), e("(^_^)b", "(-_-)b"), e("</>(^▽^)", "</>(-▽-)"), e("( •_•)c[_]", "( -_-)c[_]")],
71
+ proud: [e("(⌐■_■)b", "(⌐-_-)b"), e("ᕦ(ò_óˇ)ᕤ", "ᕦ(-_-ˇ)ᕤ"), e("( •_•)>⌐■-■", "( -_-)>⌐■-■", "(⌐■_■)"), e("b( ̄▽ ̄)d", "b(-▽-)d")],
72
+ love: [e("(♡_♡)</>", "(-_-)</>"), e("(´∀`)♡{ }", "(-∀-)♡{ }"), e("(˘▽˘)c[_]", "(-▽-)c[_]"), e("(◍•_•◍)♡", "(◍-_-◍)♡")],
73
+ excited: [e("ᕦ(ò_ó)ᕤ", "ᕦ(-_-)ᕤ"), e("(ノ≧∀≦)ノ", "(ノ-∀-)ノ", "(ノ≧∀≦)ノ</>"), e("\\(•_•)/", "\\(-_-)/"), e("(⌐■▽■)", "(⌐-▽-)")],
74
+ focused: [e("(•_•)>_", "(-_-)>_"), e("[•_•]$_", "[-_-]$_"), e("(ง'-')ง", "(ง- -)ง"), e("(⌐■_■)>_", "(⌐-_-)>_")],
75
+ curious: [e("(•_•)?</>", "(-_-)?</>"), e("(・_・)ノ{ }", "(-_-)ノ{ }"), e("(°_°)>_", "(-_-)>_"), e("( ⊙_⊙)</>", "( -_-)</>")],
76
+ thinking: [e("( ̄_ ̄)c[_]", "(-_-)c[_]"), e("( •_•)…{ }", "( -_-)…{ }"), e("(¬_¬)>_", "(-_-)>_"), e("(°~°)</>", "(-~-)</>")],
77
+ nervous: [e("(°_°;)>_", "(-_-;)>_"), e("(・_・;)$_", "(-_-;)$_"), e("(⊙_⊙;)", "(-_-;)"), e("(゚_゚;)c[_]", "(-_-;)c[_]")],
78
+ confused: [e("(・_・)?{ }", "(-_-)?{ }"), e("(°_°)?>_", "(-_-)?>_"), e("(⊙_☉)</>", "(-_-)</>"), e("(゜_゜)$?", "(-_-)$?")],
79
+ sleepy: [e("(-_-)c[_] z", "(-.-)c[_] z"), e("( ̄o ̄)>_ zz", "(-o-)>_ zz"), e("(∪_∪)</> z", "(-_-)</> z"), e("( ˘_˘ )$_ z", "( -_- )$_ z")],
80
+ sad: [e("(╥_╥)>_", "(-_-)>_"), e("(T_T)</>", "(-_-)</>"), e("(ಥ_ಥ)c[_]", "(-_-)c[_]"), e("(´;_;`)$_", "(-;_;-)$_")],
81
+ angry: [e("(╯°□°)╯", "(╯-□-)╯", "(╯°□°)╯︵ ┻━┻"), e("(ノಠ益ಠ)ノ", "(ノ-益-)ノ", "(ノಠ益ಠ)ノ彡┻━┻"), e("(╬ò_ó)>_", "(╬-_-)>_"), e("┻━┻ ︵ (°□°)", "┻━┻ ︵ (-□-)")],
82
+ waiting: [e("(•_•)c[_]", "(-_-)c[_]"), e("(´・_・`)>_", "(´-_-`)>_"), e("( ˘_˘)</>…", "( -_-)</>…"), e("(・_・)$_…", "(-_-)$_…")],
83
+ surprised: [e("(°□°)>_", "(-□-)>_"), e("Σ(°_°)</>", "Σ(-_-)</>"), e("(゚Д゚)$_", "(-Д-)$_"), e("(⊙_⊙)!{ }", "(-_-)!{ }")],
84
+ grateful: [e("(⌐■_■)b", "(⌐-_-)b"), e("m(_ _)m</>", "m(- -)m</>"), e("(^_^)c[_]", "(-_-)c[_]"), e("(人•_•)", "(人-_-)")],
85
+ };
86
+
87
+ /** DESIGN, the artist: sparkles, flowers, hearts and a little flourish on most things. */
88
+ const DESIGN: Record<Emotion, readonly Emote[]> = {
89
+ happy: [e("✧(◕‿◕✿)", "✧(-‿-✿)"), e("(✿^‿^)", "(✿-‿-)"), e("(◕ᴗ◕✿)", "(-ᴗ-✿)"), e("❀(^▽^)❀", "❀(-▽-)❀")],
90
+ proud: [e("(✿◠‿◠)", "(✿-‿-)"), e("✧(˘▽˘)✧", "✧(-▽-)✧"), e("( ̄▽ ̄)✿", "(-▽-)✿"), e("(◕‿◕)b✧", "(-‿-)b✧")],
91
+ love: [e("(♡‿♡)✿", "(-‿-)✿"), e("(っ˘з˘)♡✧", "(っ-з-)♡✧"), e("(◍♡‿♡◍)", "(◍-‿-◍)"), e("✿(´ε`)♡", "✿(-ε-)♡")],
92
+ excited: [e("(ノ◕ヮ◕)ノ", "(ノ-ヮ-)ノ", "(ノ◕ヮ◕)ノ*:・゚✧"), e("✧*(^▽^)*✧", "✧*(-▽-)*✧"), e("☆(≧▽≦)☆", "☆(-▽-)☆"), e("♪(^∇^)♪", "♪(-∇-)♪")],
93
+ focused: [e("(•‿•)~✎", "(-‿-)~✎"), e("( ˘_˘)~✿", "( -_-)~✿"), e("(◕_◕)✎", "(-_-)✎"), e("(・ω・)~♪", "(-ω-)~♪")],
94
+ curious: [e("(◕_◕)?✧", "(-_-)?✧"), e("(・・ )✿?", "(-- )✿?"), e("(°‿°)?", "(-‿-)?"), e("✧(◔_◔)", "✧(-_-)")],
95
+ thinking: [e("( ˘‿˘)~✎", "( -‿-)~✎"), e("( ̄~ ̄)✿", "(-~-)✿"), e("(◕~◕)…", "(-~-)…"), e("(・・)~♪", "(--)~♪")],
96
+ nervous: [e("(◕﹏◕✿)", "(-﹏-✿)"), e("(°‿°;)✎", "(-‿-;)✎"), e("(・_・;)✿", "(-_-;)✿"), e("(⊙‿⊙;)", "(-‿-;)")],
97
+ confused: [e("(◕_◕)?~", "(-_-)?~"), e("(・・?)✿", "(--?)✿"), e("(゜‿゜)?", "(-‿-)?"), e("(°_°)✎?", "(-_-)✎?")],
98
+ sleepy: [e("(-‿-)✿ zz", "(-.-)✿ zz"), e("( ˘ω˘ )✿z", "( -ω- )✿z"), e("(∪‿∪)♪ z", "(-‿-)♪ z"), e("( ̄‿ ̄)zzZ", "(-‿-)zzZ")],
99
+ sad: [e("(╥﹏╥)✿", "(-﹏-)✿"), e("(ಥ‿ಥ)", "(-‿-)"), e("(´;ω;`)✎", "(-;ω;-)✎"), e("(◕︵◕)", "(-︵-)")],
100
+ angry: [e("(╬◣_◢)", "(╬-_-)"), e("(ノ`Д´)ノ✎", "(ノ-Д-)ノ✎"), e("(¬_¬)✿", "(-_-)✿"), e("(ಠ‿ಠ)✎", "(-‿-)✎")],
101
+ waiting: [e("(っ´ω`c)✿", "(っ-ω-c)✿"), e("( ˘‿˘)~♪", "( -‿-)~♪"), e("(・‿・)…✿", "(-‿-)…✿"), e("(´・‿・`)", "(´-‿-`)")],
102
+ surprised: [e("(°o°)✿", "(-o-)✿"), e("✧(⊙‿⊙)✧", "✧(-‿-)✧"), e("(゚∀゚)!✎", "(-∀-)!✎"), e("Σ(◕o◕)", "Σ(-o-)")],
103
+ grateful: [e("(人◕‿◕)", "(人-‿-)"), e("(◍•ᴗ•◍)✿", "(◍-ᴗ-◍)✿"), e("✿m(_ _)m", "✿m(- -)m"), e("(*´▽`*)✧", "(*-▽-*)✧")],
104
+ };
105
+
106
+ /** RESEARCH, the bookworm: takes notes, points at things, shrugs at the unknown. */
107
+ const RESEARCH: Record<Emotion, readonly Emote[]> = {
108
+ happy: [e("φ(^▽^)", "φ(-▽-)"), e("(^_^)[ ]", "(-_-)[ ]"), e("(•‿•)φ", "(-‿-)φ"), e("( ˘▽˘)ノ", "( -▽-)ノ")],
109
+ proud: [e("( •_•)>⌐■-■", "( -_-)>⌐■-■", "(⌐■_■)"), e("( ̄^ ̄)φ", "(-^-)φ"), e("(•ᴗ•)b[ ]", "(-ᴗ-)b[ ]"), e("(  ̄ー ̄)φ", "( -ー-)φ")],
110
+ love: [e("(♡_♡)[ ]", "(-_-)[ ]"), e("φ(´∀`)♡", "φ(-∀-)♡"), e("(◍•_•◍)φ", "(◍-_-◍)φ"), e("(˘ε˘)♡[ ]", "(-ε-)♡[ ]")],
111
+ excited: [e("(σ・・)σ!", "(σ--)σ!"), e("φ(≧▽≦)", "φ(-▽-)"), e("\\(°o°)/", "\\(-o-)/"), e("(ノ°▽°)ノ[ ]", "(ノ-▽-)ノ[ ]")],
112
+ focused: [e("φ(..)", "φ(--)", "φ(..;)"), e("( ._.)φ__", "( -_-)φ__"), e("(・_・)[ ]", "(-_-)[ ]"), e("( •_•)φ", "( -_-)φ")],
113
+ curious: [e("(σ・・)σ", "(σ--)σ"), e("(・・ )?[ ]", "(-- )?[ ]"), e("(⊙_⊙)φ?", "(-_-)φ?"), e("(°_°)…?", "(-_-)…?")],
114
+ thinking: [e("φ( ̄~ ̄)", "φ(-~-)"), e("( ˘_˘ )φ", "( -_- )φ"), e("(・・;)[ ]", "(--;)[ ]"), e("(¬_¬)φ…", "(-_-)φ…")],
115
+ nervous: [e("φ(°_°;)", "φ(-_-;)"), e("(・_・;)[?]", "(-_-;)[?]"), e("(⊙﹏⊙)φ", "(-﹏-)φ"), e("(°□°;)[ ]", "(-□-;)[ ]")],
116
+ confused: [e("¯\\_(ツ)_/¯", "¯\\_(ー)_/¯"), e("(・_・ヾ[?]", "(-_-ヾ[?]"), e("(⊙_☉)φ", "(-_-)φ"), e("(゜-゜)?[ ]", "(---)?[ ]")],
117
+ sleepy: [e("(-_-)[ ] zz", "(-.-)[ ] zz"), e("φ( ̄o ̄) z", "φ(-o-) z"), e("(∪_∪)φ zz", "(-_-)φ zz"), e("( ˘ω˘ )[ ]z", "( -ω- )[ ]z")],
118
+ sad: [e("(ಥ_ಥ)φ", "(-_-)φ"), e("(╥_╥)[ ]", "(-_-)[ ]"), e("(´;ω;`)φ", "(-;ω;-)φ"), e("(T_T)[?]", "(-_-)[?]")],
119
+ angry: [e("(ಠ_ಠ)φ", "(-_-)φ"), e("(╬ಠ_ಠ)[ ]", "(╬-_-)[ ]"), e("ヽ(`Д´)ノφ", "ヽ(-Д-)ノφ"), e("(¬_¬\")[ ]", "(-_-\")[ ]")],
120
+ waiting: [e("(´・_・`)φ", "(´-_-`)φ"), e("( ˘・_・˘)[ ]", "( ˘-_-˘)[ ]"), e("φ(・_・)…", "φ(-_-)…"), e("(っ´ω`c)φ", "(っ-ω-c)φ")],
121
+ surprised: [e("Σ(°o°)φ", "Σ(-o-)φ"), e("(゚Д゚)[!]", "(-Д-)[!]"), e("(⊙_⊙)!φ", "(-_-)!φ"), e("w(°o°)w[ ]", "w(-o-)w[ ]")],
122
+ grateful: [e("(人´∀`)φ", "(人-∀-)φ"), e("m(_ _)m[ ]", "m(- -)m[ ]"), e("(ᵔᴥᵔ)φ", "(-ᴥ-)φ"), e("(◍•ᴗ•◍)[ ]", "(◍-ᴗ-◍)[ ]")],
123
+ };
124
+
125
+ /** QA, the inspector: side-eyes and squints at everything, then flexes and dances on a pass. */
126
+ const QA: Record<Emotion, readonly Emote[]> = {
127
+ happy: [e("(•‿•)✓", "(-‿-)✓"), e("( ^_^)[✓]", "( -_-)[✓]"), e("(ᵔ.ᵔ)✓", "(-.-)✓"), e("(^_^)✓", "(-_-)✓")],
128
+ proud: [e("ᕙ( • ‿ • )ᕗ", "ᕙ( - ‿ - )ᕗ"), e("ᕦ(•_•)ᕤ✓", "ᕦ(-_-)ᕤ✓"), e("( ̄^ ̄)✓", "(-^-)✓"), e("( •_•)b✓", "( -_-)b✓")],
129
+ love: [e("(♡_♡)✓", "(-_-)✓"), e("(´∀`)♡[✓]", "(-∀-)♡[✓]"), e("(◍•_•◍)✓", "(◍-_-◍)✓"), e("(˘ε˘)♡✓", "(-ε-)♡✓")],
130
+ excited: [e("ᕕ( ᐛ )ᕗ", "ᕕ( - )ᕗ"), e("\\(•_•)/✓", "\\(-_-)/✓"), e("(ノ≧▽≦)ノ✓", "(ノ-▽-)ノ✓"), e("ᕙ(⇀‸↼)ᕗ", "ᕙ(-‸-)ᕗ")],
131
+ focused: [e("(ಠ_ಠ)", "(−_−)"), e("(¬_¬)", "(-_-)"), e("(눈_눈)", "(-_-)"), e("(•_•)⊙", "(-_-)⊙")],
132
+ curious: [e("(ಠ_ಠ)?", "(−_−)?"), e("(¬_¬)?[ ]", "(-_-)?[ ]"), e("(⊙_⊙)⊙", "(-_-)⊙"), e("(・_・)?✓", "(-_-)?✓")],
133
+ thinking: [e("(¬‿¬)[ ]", "(-‿-)[ ]"), e("(ಠ~ಠ)", "(−~−)"), e("( ̄_ ̄)[?]", "(-_-)[?]"), e("( ˘_˘ )✓?", "( -_- )✓?")],
134
+ nervous: [e("(ಠ_ಠ;)", "(−_−;)"), e("(°_°;)[ ]", "(-_-;)[ ]"), e("(⊙﹏⊙)✓?", "(-﹏-)✓?"), e("(・_・;)✗?", "(-_-;)✗?")],
135
+ confused: [e("(ಠ_ಠ)?!", "(−_−)?!"), e("(・_・ヾ[?]", "(-_-ヾ[?]"), e("(⊙_☉)✗", "(-_-)✗"), e("(゜-゜)[✗]", "(---)[✗]")],
136
+ sleepy: [e("(-_-)[ ] zz", "(-.-)[ ] zz"), e("( ̄o ̄)✓ zz", "(-o-)✓ zz"), e("(∪_∪)⊙ z", "(-_-)⊙ z"), e("( ˘ω˘ )✓z", "( -ω- )✓z")],
137
+ sad: [e("(ಥ_ಥ)✗", "(-_-)✗"), e("(╥﹏╥)[✗]", "(-﹏-)[✗]"), e("(´;ω;`)✗", "(-;ω;-)✗"), e("(T_T)✗", "(-_-)✗")],
138
+ angry: [e("(ಠ益ಠ)", "(−益−)"), e("(╬ಠ_ಠ)✗", "(╬−_−)✗"), e("(ノಠ益ಠ)ノ", "(ノ−益−)ノ", "(ノಠ益ಠ)ノ[✗]"), e("(¬_¬\")✗", "(-_-\")✗")],
139
+ waiting: [e("(¬_¬)…", "(-_-)…"), e("(´・_・`)[ ]", "(´-_-`)[ ]"), e("(ಠ_ಠ)…", "(−_−)…"), e("( ˘_˘)✓…", "( -_-)✓…")],
140
+ surprised: [e("(ಠ_ಠ)!", "(−_−)!"), e("Σ(°_°)✗", "Σ(-_-)✗"), e("(⊙_⊙)[!]", "(-_-)[!]"), e("(゚Д゚)✓!", "(-Д-)✓!")],
141
+ grateful: [e("(人•_•)✓", "(人-_-)✓"), e("m(_ _)m✓", "m(- -)m✓"), e("(ᵔ.ᵔ)b", "(-.-)b"), e("(◍•ᴗ•◍)✓", "(◍-ᴗ-◍)✓")],
142
+ };
143
+
144
+ /** Personality sets per agent column. */
145
+ export const PERSONALITY_EMOTES: Record<SlotId, Record<Emotion, readonly Emote[]>> = {
146
+ dev: DEV,
147
+ design: DESIGN,
148
+ research: RESEARCH,
149
+ qa: QA,
150
+ };
151
+
152
+ /** Frames per emote: open, blink, then the action (or the open face again), held one step each. */
153
+ export const EMOTE_STEPS = 4;
154
+
155
+ /** The four frames an emote steps through: open, blink, action, action. */
156
+ export function emoteFrames(emote: Emote): readonly string[] {
157
+ const finish = emote.action ?? emote.open;
158
+ return [emote.open, emote.blink, finish, finish];
159
+ }
160
+
161
+ /** What a slot is going through, as far as its face is concerned. */
162
+ export interface Situation {
163
+ status: SlotState;
164
+ /** Live one-word activity while working (reading, editing, running, thinking, ...). */
165
+ activity?: string;
166
+ flag?: "waiting" | "quiet" | "retry";
167
+ /** The run was asked to wrap up early. */
168
+ wrappedUp?: boolean;
169
+ /** A file was just handed to this agent. */
170
+ handover?: boolean;
171
+ }
172
+
173
+ const ACTIVITY_EMOTIONS: Record<string, readonly Emotion[]> = {
174
+ reading: ["curious", "focused"],
175
+ searching: ["curious", "thinking"],
176
+ editing: ["focused", "excited", "proud"],
177
+ running: ["nervous", "focused"],
178
+ thinking: ["thinking", "curious"],
179
+ writing: ["focused", "proud"],
180
+ researching: ["curious", "thinking"],
181
+ };
182
+
183
+ /** The emotions that fit a situation, most fitting first. */
184
+ export function emotionsFor(situation: Situation): readonly Emotion[] {
185
+ switch (situation.status) {
186
+ case "idle":
187
+ return ["sleepy", "waiting", "happy"];
188
+ case "done":
189
+ return situation.wrappedUp ? ["nervous", "proud"] : ["happy", "proud", "love", "excited"];
190
+ case "failed":
191
+ return ["sad", "angry", "confused", "surprised"];
192
+ default:
193
+ break;
194
+ }
195
+ if (situation.flag === "waiting") return ["waiting"];
196
+ if (situation.flag === "quiet") return ["confused", "sleepy"];
197
+ if (situation.flag === "retry") return ["nervous", "sad"];
198
+ if (situation.handover) return ["grateful", "happy"];
199
+ if (situation.wrappedUp) return ["nervous", "surprised"];
200
+ return ACTIVITY_EMOTIONS[situation.activity ?? ""] ?? ["focused", "excited", "happy"];
201
+ }
202
+
203
+ /** Share of picks drawn from the agent's own personality set (out of 10). */
204
+ const PERSONALITY_SHARE = 6;
205
+
206
+ function positive(variant: number): number {
207
+ return Number.isFinite(variant) ? Math.abs(Math.floor(variant)) : 0;
208
+ }
209
+
210
+ /**
211
+ * The emote for one expression: the emotion from the situation, then a face
212
+ * from the agent's personality (about 60%) or the shared pool, all chosen by
213
+ * the expression's `variant`.
214
+ */
215
+ export function pickEmote(slot: SlotId, situation: Situation, variant: number): Emote {
216
+ const seed = positive(variant);
217
+ const emotions = emotionsFor(situation);
218
+ const emotion = emotions[seed % emotions.length]!;
219
+ const own = (Math.floor(seed / 7) % 10) < PERSONALITY_SHARE;
220
+ const pool = own ? PERSONALITY_EMOTES[slot][emotion] : SHARED_EMOTES[emotion];
221
+ return pool[Math.floor(seed / 71) % pool.length]!;
222
+ }
223
+
224
+ /** The frames of the emote a slot plays for this expression. */
225
+ export function slotEmote(slot: SlotId, situation: Situation, variant: number): readonly string[] {
226
+ return emoteFrames(pickEmote(slot, situation, variant));
227
+ }