@a-t-h-i/bot-lobby 0.3.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. */
@@ -50,8 +59,10 @@ export interface ExpressionGap {
50
59
  max: number;
51
60
  }
52
61
 
53
- /** Agent slots rest 20-30 s between expressions. */
62
+ /** Idle agent slots rest 20-30 s between expressions. */
54
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 };
55
66
  /** The oracle is the scene's centrepiece: it blinks and emotes every 6-12 s. */
56
67
  export const ORACLE_GAP: ExpressionGap = { min: 6_000, max: 12_000 };
57
68
 
@@ -60,15 +71,19 @@ export function nextGap(rng: () => number, gap: ExpressionGap = SLOT_GAP): numbe
60
71
  return gap.min + Math.round(unit(rng) * (gap.max - gap.min));
61
72
  }
62
73
 
63
- /** Whether the next expression blinks (~65%) or emotes (~35%). */
64
- export function pickEvent(rng: () => number): "blink" | "emote" {
65
- return unit(rng) < BLINK_CHANCE ? "blink" : "emote";
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";
77
+ }
78
+
79
+ function drawVariant(rng: () => number): number {
80
+ return Math.floor(unit(rng) * (VARIANT_SPAN - 1));
66
81
  }
67
82
 
68
83
  /** A freshly rested expression that fires for the first time after one random gap. */
69
84
  export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
70
85
  const start = Number.isFinite(now) ? now : 0;
71
- return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME };
86
+ return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME, variant: 0 };
72
87
  }
73
88
 
74
89
  /** True while `now` is inside a playing blink or emote. */
@@ -90,17 +105,33 @@ export function advanceExpression(
90
105
  now: number,
91
106
  rng: () => number,
92
107
  gap: ExpressionGap = SLOT_GAP,
108
+ blinkChance = BLINK_CHANCE,
93
109
  ): ExpressionState {
94
110
  if (!Number.isFinite(now)) return state;
95
111
  if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
96
112
  if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
97
- return play(now, rng, gap);
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);
98
119
  }
99
120
 
100
- function play(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
101
- const blink = pickEvent(rng) === "blink";
102
- const until = now + (blink ? BLINK_MS : EMOTE_MS);
103
- return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
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) };
104
135
  }
105
136
 
106
137
  /** One sub-step of a playing expression; the oracle's blink and glance step through these. */
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { TaskState } from "../schemas/task.ts";
2
+ import { slotEmote } from "./kaomoji.ts";
2
3
 
3
4
  /**
4
5
  * Pure ASCII art data for the scene-based zen panel. No logic lives here: the
@@ -258,33 +259,22 @@ const FACE_FRAMES: Record<SlotState, readonly string[]> = {
258
259
  failed: ["(>_<)", "(x_x)", "(T_T)", "(;_;)"],
259
260
  };
260
261
 
261
- /** Kaomoji emotes for the large scene: working nervous, done happy, failed scared. */
262
- const EMOTE_FACES: Record<SlotState, readonly string[]> = {
263
- working: ["(٥↼_↼)", "(●´⌓`●)"],
264
- idle: ["(-.-)", "(u.u)"],
265
- done: ["(✿^‿^)", "(っ˘з(˘⌣˘ )"],
266
- failed: ["ಥ_ಥ", "(〒﹏〒)"],
267
- };
268
-
269
- /** QA's success emotes are a flex and a victory dance rather than a smile. */
270
- const QA_DONE_FACES: readonly string[] = ["ᕙ( • ‿ • )ᕗ", "ᕕ( ᐛ )ᕗ"];
271
-
272
262
  /** Every compact face is five columns, so a centered column never shifts between frames. */
273
263
  export const FACE_WIDTH = 5;
274
264
 
275
265
  function buildSlotFrames(id: SlotId): Record<SlotState, readonly string[][]> {
276
266
  const frames = {} as Record<SlotState, readonly string[][]>;
277
267
  for (const state of SLOT_STATES) {
278
- const emotes = id === "qa" && state === "done" ? QA_DONE_FACES : EMOTE_FACES[state];
279
- frames[state] = [...FACE_FRAMES[state].slice(0, 2), ...emotes].map((face) => [face]);
268
+ frames[state] = [...FACE_FRAMES[state].slice(0, 2), ...slotEmote(id, { status: state }, 0)].map((face) => [face]);
280
269
  }
281
270
  return frames;
282
271
  }
283
272
 
284
273
  /**
285
274
  * Animated faces per slot: one row each. Rest (0) and blink (1) stay the five-column
286
- * ASCII eyes; the emote frames (2+) are status-aware kaomoji, wider than five columns,
287
- * so the scene centres each frame inside its fixed slot cell.
275
+ * ASCII eyes; the emote frames (2+) are the slot's default kaomoji for the status
276
+ * (open, blink, action). The live scene swaps in a situation-aware emote per
277
+ * expression (see kaomoji.ts); either way the scene centres each frame in its cell.
288
278
  */
289
279
  export const SLOT_FRAMES: Record<SlotId, Record<SlotState, readonly string[][]>> = {
290
280
  dev: buildSlotFrames("dev"),