@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.
- package/README.md +138 -32
- package/package.json +1 -1
- package/prompts/backend.md +46 -1
- package/prompts/designer.md +94 -15
- package/prompts/master.md +58 -1
- package/prompts/qa.md +35 -2
- package/prompts/researcher.md +6 -0
- package/prompts/reviewer.md +16 -0
- package/prompts/scout.md +11 -2
- package/prompts/worker.md +35 -2
- package/src/desk/client-extension.ts +101 -0
- package/src/desk/desk.ts +249 -0
- package/src/desk/ipc.ts +178 -0
- package/src/desk/session.ts +214 -0
- package/src/execution/agent-runner.ts +160 -14
- package/src/execution/pi-runner.ts +351 -65
- package/src/index.ts +4 -0
- package/src/master/master.ts +21 -17
- package/src/master/research.ts +8 -7
- package/src/pi/activity.ts +45 -0
- package/src/pi/commands.ts +28 -3
- package/src/pi/expressions.ts +84 -16
- package/src/pi/kaomoji.ts +227 -0
- package/src/pi/mascot-art.ts +125 -35
- package/src/pi/model-support.ts +101 -0
- package/src/pi/run-summary.ts +170 -0
- package/src/pi/settings-ui.ts +158 -60
- package/src/pi/tools.ts +50 -7
- package/src/pi/ui.ts +82 -13
- package/src/pi/zen-large.ts +158 -20
- package/src/pi/zen-metrics.ts +47 -7
- package/src/pi/zen.ts +42 -14
- package/src/roles/reviewer.ts +24 -4
- package/src/roles/worker.ts +7 -1
- package/src/schemas/configuration.ts +112 -18
- package/src/schemas/findings.ts +20 -0
- package/src/schemas/task.ts +26 -1
- package/src/state/project.ts +9 -0
- package/src/text.ts +9 -0
- package/src/workflow/workflow.ts +127 -10
package/src/pi/mascot-art.ts
CHANGED
|
@@ -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
|
|
@@ -6,7 +7,7 @@ import type { TaskState } from "../schemas/task.ts";
|
|
|
6
7
|
* that `zen.ts` and `zen-large.ts` compose. Every glyph is exactly one visible
|
|
7
8
|
* column so the art stays narrow/ambiguous-width safe: the non-ASCII glyphs are
|
|
8
9
|
* the lobby `⌂` in `BANNER_NARROW`, the panel's ◐/✓/✗ status family, and the
|
|
9
|
-
* box-drawing, bar, eye and aura glyphs of the large scene (─ │ ┌ ┐ └ ┘ ╭ ╮ ╰ ╯ ╲ ╱ ╶ ├ ┤ ┬ ┴ █ ▓ ▒ ░
|
|
10
|
+
* box-drawing, bar, eye and aura glyphs of the large scene (─ │ ┌ ┐ └ ┘ ╭ ╮ ╰ ╯ ╲ ╱ ╶ ├ ┤ ┬ ┴ █ ▓ ▒ ░ ▁-▇ ◉ ◍ ◎ ◌ ○ ◓ ◒ ✦ ✧).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -105,36 +106,135 @@ export const ORACLE_COLORS: Record<OraclePose, { color: PanelColor; bold: boolea
|
|
|
105
106
|
dormant: { color: "dim", bold: false },
|
|
106
107
|
};
|
|
107
108
|
|
|
108
|
-
/** One oracle frame: the crown orb, both
|
|
109
|
+
/** One oracle frame: the crown orb, both pupils and the seven-column mouth. */
|
|
109
110
|
export interface OracleFrame {
|
|
110
111
|
orb: string;
|
|
112
|
+
/**
|
|
113
|
+
* One-column pupil drawn inside its three-column eye window: `OPEN_EYE` takes
|
|
114
|
+
* the gaze's pupil, `CLOSED_EYE` fills the window as a lid, anything else is drawn as is.
|
|
115
|
+
*/
|
|
111
116
|
winL: string;
|
|
112
117
|
winR: string;
|
|
113
118
|
/** Exactly seven one-column glyphs; never moves the tower geometry. */
|
|
114
119
|
mouth: string;
|
|
115
120
|
}
|
|
116
121
|
|
|
122
|
+
/** Oracle expression frame indexes, matching the shared schedule (see expressions.ts). */
|
|
123
|
+
export const ORACLE_REST = 0;
|
|
124
|
+
export const ORACLE_BLINK = 1;
|
|
125
|
+
/** Both emote frames are the look-around; the second only dims the orb. */
|
|
126
|
+
export const ORACLE_LOOK = 2;
|
|
127
|
+
|
|
128
|
+
/** An open eye: its pupil comes from where the oracle is looking. */
|
|
129
|
+
export const OPEN_EYE = "◉";
|
|
130
|
+
/** A closed eye: the lid fills the whole window. */
|
|
131
|
+
export const CLOSED_EYE = "─";
|
|
132
|
+
/** A half-closed lid, mid-blink or half-asleep. */
|
|
133
|
+
export const HALF_LID = "◒";
|
|
134
|
+
/** Inner width of each eye window; the pupil moves across it. */
|
|
135
|
+
export const EYE_WIDTH = 3;
|
|
136
|
+
|
|
137
|
+
/** The oracle's straight, serious mouth: it never idles on a smile. */
|
|
138
|
+
export const ORACLE_MOUTH = " ─── ";
|
|
139
|
+
|
|
117
140
|
/**
|
|
118
|
-
* Indexed oracle expressions per pose:
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
141
|
+
* Indexed oracle expressions per pose: rest, blink, then the two emote steps.
|
|
142
|
+
* Orchestrating, the oracle keeps a straight face and its emotes are a look
|
|
143
|
+
* around the room; dormant, it sleeps, peeks one eye open and snores (o, then O).
|
|
144
|
+
* While it talks the mouth lip-syncs instead.
|
|
122
145
|
*/
|
|
123
146
|
export const ORACLE_FRAMES: Record<OraclePose, readonly OracleFrame[]> = {
|
|
124
147
|
orchestrating: [
|
|
125
|
-
{ orb: "◉", winL:
|
|
126
|
-
{ orb: "◉", winL:
|
|
127
|
-
{ orb: "◎", winL:
|
|
128
|
-
{ orb: "◍", winL:
|
|
148
|
+
{ orb: "◉", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
|
|
149
|
+
{ orb: "◉", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
|
|
150
|
+
{ orb: "◎", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
|
|
151
|
+
{ orb: "◍", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
|
|
129
152
|
],
|
|
130
153
|
dormant: [
|
|
131
|
-
{ orb: "◌", winL:
|
|
132
|
-
{ orb: "◌", winL:
|
|
133
|
-
{ orb: "○", winL:
|
|
134
|
-
{ orb: "○", winL:
|
|
154
|
+
{ orb: "◌", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
|
|
155
|
+
{ orb: "◌", winL: HALF_LID, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
|
|
156
|
+
{ orb: "○", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: " ─o─ " },
|
|
157
|
+
{ orb: "○", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: " ─O─ " },
|
|
135
158
|
],
|
|
136
159
|
};
|
|
137
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Lid positions stepped through by a blink (one per `PHASE_MS`, the last held):
|
|
163
|
+
* half-lidded, shut, shut, half-lidded. Dormant, the "blink" is a sleepy peek:
|
|
164
|
+
* the left eye cracks open, looks, and drifts shut again.
|
|
165
|
+
*/
|
|
166
|
+
export const ORACLE_BLINKS: Record<OraclePose, readonly (readonly [string, string])[]> = {
|
|
167
|
+
orchestrating: [[HALF_LID, HALF_LID], [CLOSED_EYE, CLOSED_EYE], [CLOSED_EYE, CLOSED_EYE], [HALF_LID, HALF_LID]],
|
|
168
|
+
dormant: [[HALF_LID, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [HALF_LID, CLOSED_EYE], [CLOSED_EYE, CLOSED_EYE]],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Where the pupils look. `x` moves the pupil across its three-column window
|
|
173
|
+
* (left, centre, right); `y` swaps its glyph, since the window is one row:
|
|
174
|
+
* `◓` looks up, `◉` straight ahead, `◒` down.
|
|
175
|
+
*/
|
|
176
|
+
export interface Gaze {
|
|
177
|
+
x: -1 | 0 | 1;
|
|
178
|
+
y: -1 | 0 | 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const GAZE_AHEAD: Gaze = { x: 0, y: 0 };
|
|
182
|
+
|
|
183
|
+
/** The pupil glyph for each vertical gaze. */
|
|
184
|
+
export const ORACLE_PUPILS: Record<Gaze["y"], string> = { [-1]: "◓", 0: "◉", 1: "◒" };
|
|
185
|
+
|
|
186
|
+
const look = (x: Gaze["x"], y: Gaze["y"]): Gaze => ({ x, y });
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The look-around emote, one step per `PHASE_MS` across both emote frames
|
|
190
|
+
* (~2 s): a slow scan left, up across the ceiling, right, down at the agents
|
|
191
|
+
* and back to you.
|
|
192
|
+
*/
|
|
193
|
+
export const ORACLE_LOOK_PATH: readonly Gaze[] = [
|
|
194
|
+
look(0, 0), look(-1, 0), look(-1, 0), look(-1, 0), look(-1, -1), look(-1, -1),
|
|
195
|
+
look(0, -1), look(1, -1), look(1, -1), look(1, 0), look(1, 0), look(1, 0),
|
|
196
|
+
look(1, 1), look(0, 1), look(0, 1), look(-1, 1), look(0, 0), look(0, 0),
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Idle wandering when nothing holds the oracle's attention: it keeps returning
|
|
201
|
+
* to you between glances around the room. Starts looking ahead.
|
|
202
|
+
*/
|
|
203
|
+
export const ORACLE_WANDER: readonly Gaze[] = [
|
|
204
|
+
look(0, 0), look(-1, 0), look(0, 0), look(1, -1), look(1, 0), look(0, 0),
|
|
205
|
+
look(-1, 1), look(0, -1), look(0, 0), look(1, 1), look(-1, -1), look(0, 0),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/** Ticks the eyes hold one wandering spot (~2.5 s while work is live). */
|
|
209
|
+
export const ORACLE_WANDER_TICKS = 10;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Lip-sync while the oracle talks: a voice waveform that swells and falls, with
|
|
213
|
+
* a closed-mouth breath so it reads as phrases rather than a buzz.
|
|
214
|
+
*/
|
|
215
|
+
export const ORACLE_TALK: readonly string[] = [
|
|
216
|
+
" ▁▃▅▃▁ ",
|
|
217
|
+
" ▂▆▂ ",
|
|
218
|
+
" ▂▄▇▄▂ ",
|
|
219
|
+
" ▁▂▁ ",
|
|
220
|
+
" ▃▅▂▅▃ ",
|
|
221
|
+
" ▁▂▄▂▁ ",
|
|
222
|
+
" ▁ ",
|
|
223
|
+
ORACLE_MOUTH,
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
/** The oracle's resting mood: steady by default, worried when the task is blocked. */
|
|
227
|
+
export type OracleMood = "steady" | "worried";
|
|
228
|
+
|
|
229
|
+
/** The resting mouth per mood: straight, or a tight frown when blocked. */
|
|
230
|
+
export const ORACLE_MOOD_MOUTHS: Record<OracleMood, string> = {
|
|
231
|
+
steady: ORACLE_MOUTH,
|
|
232
|
+
worried: " ╭─╮ ",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Ticks the eyes hold on one working agent before glancing to the next. */
|
|
236
|
+
export const ORACLE_GAZE_TICKS = 8;
|
|
237
|
+
|
|
138
238
|
/**
|
|
139
239
|
* The seven-column aura above the crown, stepped by the spinner tick: sparkles
|
|
140
240
|
* twinkle while the oracle orchestrates, and z's drift up while it sleeps.
|
|
@@ -159,33 +259,22 @@ const FACE_FRAMES: Record<SlotState, readonly string[]> = {
|
|
|
159
259
|
failed: ["(>_<)", "(x_x)", "(T_T)", "(;_;)"],
|
|
160
260
|
};
|
|
161
261
|
|
|
162
|
-
/** Kaomoji emotes for the large scene: working nervous, done happy, failed scared. */
|
|
163
|
-
const EMOTE_FACES: Record<SlotState, readonly string[]> = {
|
|
164
|
-
working: ["(٥↼_↼)", "(●´⌓`●)"],
|
|
165
|
-
idle: ["(-.-)", "(u.u)"],
|
|
166
|
-
done: ["(✿^‿^)", "(っ˘з(˘⌣˘ )"],
|
|
167
|
-
failed: ["ಥ_ಥ", "(〒﹏〒)"],
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
/** QA's success emotes are a flex and a victory dance rather than a smile. */
|
|
171
|
-
const QA_DONE_FACES: readonly string[] = ["ᕙ( • ‿ • )ᕗ", "ᕕ( ᐛ )ᕗ"];
|
|
172
|
-
|
|
173
262
|
/** Every compact face is five columns, so a centered column never shifts between frames. */
|
|
174
263
|
export const FACE_WIDTH = 5;
|
|
175
264
|
|
|
176
265
|
function buildSlotFrames(id: SlotId): Record<SlotState, readonly string[][]> {
|
|
177
266
|
const frames = {} as Record<SlotState, readonly string[][]>;
|
|
178
267
|
for (const state of SLOT_STATES) {
|
|
179
|
-
|
|
180
|
-
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]);
|
|
181
269
|
}
|
|
182
270
|
return frames;
|
|
183
271
|
}
|
|
184
272
|
|
|
185
273
|
/**
|
|
186
274
|
* Animated faces per slot: one row each. Rest (0) and blink (1) stay the five-column
|
|
187
|
-
* ASCII eyes; the emote frames (2+) are
|
|
188
|
-
*
|
|
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.
|
|
189
278
|
*/
|
|
190
279
|
export const SLOT_FRAMES: Record<SlotId, Record<SlotState, readonly string[][]>> = {
|
|
191
280
|
dev: buildSlotFrames("dev"),
|
|
@@ -223,8 +312,9 @@ export const COMPACT_FRAMES: Record<SlotId, Record<SlotState, readonly string[]>
|
|
|
223
312
|
/**
|
|
224
313
|
* The oracle tower: 13 columns wide, `rows` while height allows and `smallRows`
|
|
225
314
|
* when it must shrink. `{aura}` twinkles over the radiant `{orb}` crown,
|
|
226
|
-
* `{winL}`/`{winR}` are the
|
|
227
|
-
* the name over the door. The speech bubble
|
|
315
|
+
* `{winL}`/`{winR}` are the three-column eye windows the pupils move in,
|
|
316
|
+
* `{mouth}` speaks and `{door}` is the name over the door. The speech bubble
|
|
317
|
+
* hangs beside the crown.
|
|
228
318
|
*/
|
|
229
319
|
export interface TowerSpec {
|
|
230
320
|
rows: readonly string[];
|
|
@@ -244,9 +334,9 @@ export const TOWER: TowerSpec = {
|
|
|
244
334
|
" ╱ │ ╲ ",
|
|
245
335
|
" │ ",
|
|
246
336
|
"╭─────┴─────╮",
|
|
247
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
337
|
+
"│╭───╮ ╭───╮│",
|
|
338
|
+
"││{winL}│ │{winR}││",
|
|
339
|
+
"│╰───╯ ╰───╯│",
|
|
250
340
|
"│ {mouth} │",
|
|
251
341
|
"├───┬───┬───┤",
|
|
252
342
|
"│░▒▓│{door}│▓▒░│",
|
|
@@ -255,7 +345,7 @@ export const TOWER: TowerSpec = {
|
|
|
255
345
|
smallRows: [
|
|
256
346
|
" ──({orb})── ",
|
|
257
347
|
"╭─────┴─────╮",
|
|
258
|
-
"
|
|
348
|
+
"││{winL}│ │{winR}││",
|
|
259
349
|
"│░▒▓│{door}│▓▒░│",
|
|
260
350
|
"└───┴───┴───┘",
|
|
261
351
|
],
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
agentProfile,
|
|
4
|
+
isThinkingLevel,
|
|
5
|
+
THINKING_LEVELS,
|
|
6
|
+
type BotLobbyConfig,
|
|
7
|
+
type ProfileResolver,
|
|
8
|
+
type SubagentKind,
|
|
9
|
+
type ThinkingLevelName,
|
|
10
|
+
} from "../schemas/configuration.ts";
|
|
11
|
+
|
|
12
|
+
/** Find a model by `provider/id` (or bare id); undefined when the registry does not know it. */
|
|
13
|
+
export type ModelLookup = (ref: string) => Model<Api> | undefined;
|
|
14
|
+
|
|
15
|
+
export type { ProfileResolver };
|
|
16
|
+
|
|
17
|
+
/** `provider/id` for a model, the form settings store. */
|
|
18
|
+
export function modelRef(model: Pick<Model<Api>, "provider" | "id">): string {
|
|
19
|
+
return `${model.provider}/${model.id}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Thinking levels a model supports, in pi's order; every level when the model is unknown. */
|
|
23
|
+
export function supportedThinking(model: Model<Api> | undefined): ThinkingLevelName[] {
|
|
24
|
+
if (!model) return [...THINKING_LEVELS];
|
|
25
|
+
return getSupportedThinkingLevels(model).filter((level): level is ThinkingLevelName => isThinkingLevel(level));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ThinkingCheck {
|
|
29
|
+
/** The level to run at: the requested one, or pi's nearest supported level. */
|
|
30
|
+
level: string;
|
|
31
|
+
/** Set when the requested level had to change. */
|
|
32
|
+
warning?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Clamp `level` to what `model` supports, explaining any change; unknown models pass through. */
|
|
36
|
+
export function checkThinking(model: Model<Api> | undefined, level: string): ThinkingCheck {
|
|
37
|
+
if (!model || !isThinkingLevel(level)) return { level };
|
|
38
|
+
const clamped = clampThinkingLevel(model, level as ModelThinkingLevel);
|
|
39
|
+
if (clamped === level) return { level };
|
|
40
|
+
return { level: clamped, warning: `"${level}" thinking isn't supported by ${modelRef(model)} — using "${clamped}"` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LABELS: Record<SubagentKind | "master", string> = {
|
|
44
|
+
master: "Master",
|
|
45
|
+
designer: "Designer",
|
|
46
|
+
backend: "Backend",
|
|
47
|
+
qa: "QA",
|
|
48
|
+
scout: "Scout",
|
|
49
|
+
researcher: "Researcher",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function kindLabel(kind: SubagentKind | "master"): string {
|
|
53
|
+
return LABELS[kind];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ResolverOptions {
|
|
57
|
+
lookup: ModelLookup;
|
|
58
|
+
/** The session's model as `provider/id`; used only where settings name no model yet. */
|
|
59
|
+
sessionModel?: string;
|
|
60
|
+
/** Receives each distinct clamp warning once per process. */
|
|
61
|
+
warn?: (message: string) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const warned = new Set<string>();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Profiles straight from settings, with the thinking level clamped to what
|
|
68
|
+
* each model supports. A subagent whose model is still unset runs on the
|
|
69
|
+
* session's model (named explicitly on the command line) until settings pin one.
|
|
70
|
+
*/
|
|
71
|
+
export function createProfileResolver(config: BotLobbyConfig, options: ResolverOptions): ProfileResolver {
|
|
72
|
+
return (domain, role) => {
|
|
73
|
+
const profile = agentProfile(config, domain, role);
|
|
74
|
+
const model = profile.model ?? options.sessionModel;
|
|
75
|
+
const check = checkThinking(model ? options.lookup(model) : undefined, profile.thinking);
|
|
76
|
+
if (check.warning) {
|
|
77
|
+
const message = `bot-lobby: ${kindLabel(profile.kind)} — ${check.warning}. Change it in /bot-lobby settings.`;
|
|
78
|
+
if (!warned.has(message)) {
|
|
79
|
+
warned.add(message);
|
|
80
|
+
options.warn?.(message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ...profile, model, thinking: check.level };
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every configured subagent whose thinking level its model does not support, for `/bot-lobby config`. */
|
|
88
|
+
export function thinkingMismatches(config: BotLobbyConfig, lookup: ModelLookup, sessionModel?: string): string[] {
|
|
89
|
+
const entries: Array<[SubagentKind | "master", string, string]> = [
|
|
90
|
+
["master", config.master.model, config.master.thinking],
|
|
91
|
+
["designer", config.agents.designer.model, config.agents.designer.thinking],
|
|
92
|
+
["backend", config.agents.backend.model, config.agents.backend.thinking],
|
|
93
|
+
["qa", config.agents.qa.model, config.agents.qa.thinking],
|
|
94
|
+
["researcher", config.researcher.model, config.researcher.thinking],
|
|
95
|
+
];
|
|
96
|
+
return entries.flatMap(([kind, ref, level]) => {
|
|
97
|
+
const model = ref === "inherit" ? sessionModel : ref;
|
|
98
|
+
const check = checkThinking(model ? lookup(model) : undefined, level);
|
|
99
|
+
return check.warning ? [`${kindLabel(kind)}: ${check.warning}`] : [];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line descriptions of subagent runs, shared by the transcript entries,
|
|
3
|
+
* `/bot-lobby runs`, the Master's reports and the zen feed row. Pure: every
|
|
4
|
+
* clock read arrives as `now`.
|
|
5
|
+
*/
|
|
6
|
+
import type { AgentRun } from "../schemas/findings.ts";
|
|
7
|
+
import type { RunLogEntry } from "../schemas/task.ts";
|
|
8
|
+
import { shortDuration } from "../text.ts";
|
|
9
|
+
import { SLOT_LABELS, type SlotId } from "./mascot-art.ts";
|
|
10
|
+
|
|
11
|
+
/** The zen column a run belongs to; a researcher reports under RESEARCH from any domain. */
|
|
12
|
+
export function slotOf(run: Pick<AgentRun, "domain" | "role">): SlotId {
|
|
13
|
+
if (run.role === "researcher") return "research";
|
|
14
|
+
if (run.domain === "backend") return "dev";
|
|
15
|
+
return run.domain === "designer" ? "design" : "qa";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Upper-case agent name as the scene shows it (DEV, DESIGN, RESEARCH, QA). */
|
|
19
|
+
export function agentName(run: Pick<AgentRun, "domain" | "role">): string {
|
|
20
|
+
return SLOT_LABELS[slotOf(run)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Compact token count: 950, 41k, 1.2M. */
|
|
24
|
+
export function tokens(count: number): string {
|
|
25
|
+
if (!Number.isFinite(count) || count < 1000) return String(Math.max(0, Math.round(count || 0)));
|
|
26
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
27
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function durationOf(run: Pick<AgentRun, "startedAt" | "finishedAt">, now: number): number {
|
|
31
|
+
const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
|
|
32
|
+
const elapsed = end - Date.parse(run.startedAt);
|
|
33
|
+
return Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const STATUS_ICONS: Record<AgentRun["status"], string> = {
|
|
37
|
+
running: "◐",
|
|
38
|
+
success: "✓",
|
|
39
|
+
failed: "✗",
|
|
40
|
+
cancelled: "·",
|
|
41
|
+
timeout: "✗",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Why a run ended the way it did, in words, when that is not plain success. */
|
|
45
|
+
export function runFlags(run: Pick<AgentRun, "status" | "stalled" | "wrappedUp" | "attempts">): string[] {
|
|
46
|
+
const flags: string[] = [];
|
|
47
|
+
if (run.stalled) flags.push("stalled");
|
|
48
|
+
else if (run.status === "timeout") flags.push("hit its time limit");
|
|
49
|
+
else if (run.status === "failed") flags.push("failed");
|
|
50
|
+
else if (run.status === "cancelled") flags.push("cancelled");
|
|
51
|
+
if (run.wrappedUp) flags.push(run.status === "success" ? "wrapped up early — report may be partial" : "asked to wrap up");
|
|
52
|
+
if (run.attempts > 1) flags.push(`${run.attempts} attempts`);
|
|
53
|
+
return flags;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `✓ DEV worker · 3m 12s · 9 turns · 23 tools · 41k↑ 6k↓ · $0.12 · provider/model`,
|
|
58
|
+
* followed by any flags (stalled, time limit, wrapped up early, attempts).
|
|
59
|
+
*/
|
|
60
|
+
export function describeRun(run: AgentRun, now = Date.now()): string {
|
|
61
|
+
const parts = [`${STATUS_ICONS[run.status]} ${agentName(run)} ${run.role}`, shortDuration(durationOf(run, now))];
|
|
62
|
+
const turns = run.turns ?? run.usage?.turns;
|
|
63
|
+
if (turns) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
|
|
64
|
+
if (run.tools) parts.push(`${run.tools} tool${run.tools === 1 ? "" : "s"}`);
|
|
65
|
+
if (run.usage && (run.usage.input || run.usage.output)) parts.push(`${tokens(run.usage.input)}↑ ${tokens(run.usage.output)}↓`);
|
|
66
|
+
if (run.usage?.cost) parts.push(`$${run.usage.cost.toFixed(2)}`);
|
|
67
|
+
if (run.model) parts.push(run.model);
|
|
68
|
+
parts.push(...runFlags(run));
|
|
69
|
+
const line = parts.join(" · ");
|
|
70
|
+
return run.status === "success" || !run.error ? line : `${line} — ${run.error.split("\n")[0]}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The persisted form of a finished run, for `task.runLog`. */
|
|
74
|
+
export function runLogEntry(run: AgentRun): RunLogEntry {
|
|
75
|
+
return {
|
|
76
|
+
runId: run.runId,
|
|
77
|
+
domain: run.domain,
|
|
78
|
+
role: run.role,
|
|
79
|
+
status: run.status,
|
|
80
|
+
startedAt: run.startedAt,
|
|
81
|
+
...(run.finishedAt ? { finishedAt: run.finishedAt } : {}),
|
|
82
|
+
...(run.model ? { model: run.model } : {}),
|
|
83
|
+
...(run.turns ?? run.usage?.turns ? { turns: run.turns ?? run.usage?.turns } : {}),
|
|
84
|
+
...(run.tools ? { tools: run.tools } : {}),
|
|
85
|
+
...(run.usage ? { input: run.usage.input, output: run.usage.output, cost: run.usage.cost } : {}),
|
|
86
|
+
attempts: run.attempts,
|
|
87
|
+
...(run.stalled ? { stalled: true } : {}),
|
|
88
|
+
...(run.wrappedUp ? { wrappedUp: true } : {}),
|
|
89
|
+
...(run.error ? { error: run.error.split("\n")[0]!.slice(0, 200) } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A persisted entry back as a run, so `describeRun` formats both the same way. */
|
|
94
|
+
export function runFromLog(entry: RunLogEntry, taskId: string): AgentRun {
|
|
95
|
+
return {
|
|
96
|
+
runId: entry.runId,
|
|
97
|
+
taskId,
|
|
98
|
+
domain: entry.domain,
|
|
99
|
+
role: entry.role,
|
|
100
|
+
status: entry.status,
|
|
101
|
+
output: "",
|
|
102
|
+
attempts: entry.attempts ?? 1,
|
|
103
|
+
startedAt: entry.startedAt,
|
|
104
|
+
...(entry.finishedAt ? { finishedAt: entry.finishedAt } : {}),
|
|
105
|
+
...(entry.model ? { model: entry.model } : {}),
|
|
106
|
+
...(entry.turns ? { turns: entry.turns } : {}),
|
|
107
|
+
...(entry.tools ? { tools: entry.tools } : {}),
|
|
108
|
+
...(entry.input !== undefined ? { usage: { input: entry.input, output: entry.output ?? 0, cost: entry.cost ?? 0, turns: entry.turns ?? 0 } } : {}),
|
|
109
|
+
...(entry.stalled ? { stalled: true } : {}),
|
|
110
|
+
...(entry.wrappedUp ? { wrappedUp: true } : {}),
|
|
111
|
+
...(entry.error ? { error: entry.error } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Silence after which a working agent reads as "quiet" in the scene. */
|
|
116
|
+
export const QUIET_MS = 45_000;
|
|
117
|
+
|
|
118
|
+
/** How long a running agent has produced nothing, or 0 when it is not running or has no clock yet. */
|
|
119
|
+
export function quietFor(run: Pick<AgentRun, "status" | "lastEventAt" | "startedAt">, now: number): number {
|
|
120
|
+
if (run.status !== "running") return 0;
|
|
121
|
+
const last = run.lastEventAt ?? Date.parse(run.startedAt);
|
|
122
|
+
const quiet = now - last;
|
|
123
|
+
return Number.isFinite(quiet) && quiet > 0 ? quiet : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface FeedLine {
|
|
127
|
+
text: string;
|
|
128
|
+
kind: "info" | "warning";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Ticks one working agent holds the feed before it rotates to the next (~3 s at the live clock). */
|
|
132
|
+
export const FEED_ROTATE_TICKS = 12;
|
|
133
|
+
|
|
134
|
+
/** What one working agent is doing right now, with a warning when something needs attention. */
|
|
135
|
+
function liveFeed(run: AgentRun, now: number): FeedLine {
|
|
136
|
+
const name = agentName(run);
|
|
137
|
+
if (run.waitingFor) return { text: `⧗ ${name} waiting for ${run.waitingFor}${run.note ? ` · ${run.note}` : ""}`, kind: "warning" };
|
|
138
|
+
const quiet = quietFor(run, now);
|
|
139
|
+
if (quiet >= QUIET_MS) {
|
|
140
|
+
const doing = run.activity ? ` (last: ${run.activity}${run.detail ? ` ${run.detail}` : ""})` : "";
|
|
141
|
+
return { text: `! ${name} quiet for ${shortDuration(quiet)}${doing}`, kind: "warning" };
|
|
142
|
+
}
|
|
143
|
+
if (run.note && run.noteKind === "warning") return { text: `! ${name} ${run.note}`, kind: "warning" };
|
|
144
|
+
const parts = [`▸ ${name} ${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`];
|
|
145
|
+
const turns = run.turns ?? run.usage?.turns;
|
|
146
|
+
if (turns) parts.push(`turn ${turns}`);
|
|
147
|
+
if (run.tools) parts.push(`${run.tools} tools`);
|
|
148
|
+
if (run.usage && run.usage.input + run.usage.output > 0) parts.push(`${tokens(run.usage.input + run.usage.output)} tok`);
|
|
149
|
+
if (run.note) parts.push(run.note);
|
|
150
|
+
return { text: parts.join(" · "), kind: "info" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The scene's feed row: a working agent that needs attention first, otherwise
|
|
155
|
+
* the working agents in rotation, otherwise the most recent finished run.
|
|
156
|
+
* Undefined before anything has run.
|
|
157
|
+
*/
|
|
158
|
+
export function feedLine(runs: readonly AgentRun[], now: number, tick: number): FeedLine | undefined {
|
|
159
|
+
const running = runs.filter((run) => run.status === "running");
|
|
160
|
+
if (running.length > 0) {
|
|
161
|
+
const lines = running.map((run) => liveFeed(run, now));
|
|
162
|
+
const warning = lines.find((line) => line.kind === "warning");
|
|
163
|
+
if (warning) return warning;
|
|
164
|
+
const index = Math.floor(Math.max(0, tick) / FEED_ROTATE_TICKS) % lines.length;
|
|
165
|
+
return lines[index];
|
|
166
|
+
}
|
|
167
|
+
const last = [...runs].filter((run) => run.finishedAt).sort((a, b) => Date.parse(a.finishedAt!) - Date.parse(b.finishedAt!)).at(-1);
|
|
168
|
+
if (!last) return undefined;
|
|
169
|
+
return { text: describeRun(last, now), kind: last.status === "success" && !last.wrappedUp ? "info" : "warning" };
|
|
170
|
+
}
|