@a-t-h-i/bot-lobby 0.6.5 → 0.6.6
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 +71 -5
- package/package.json +1 -1
- package/prompts/global.md +18 -0
- package/prompts/master.md +71 -2
- package/prompts/worker.md +7 -2
- package/src/ask/dialog.ts +11 -1
- package/src/index.ts +1 -1
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/keys.ts +0 -1
- package/src/lobby/runtime.ts +2 -22
- package/src/lobby/tabs/home.ts +6 -42
- package/src/lobby/tabs/tasks.ts +1 -1
- package/src/lobby/view.ts +9 -23
- package/src/pi/activity.ts +1 -33
- package/src/pi/events.ts +1 -16
- package/src/pi/plan-checklist.ts +305 -0
- package/src/pi/run-summary.ts +12 -3
- package/src/pi/settings-ui.ts +2 -2
- package/src/pi/tools.ts +5 -4
- package/src/pi/ui.ts +18 -284
- package/src/roles/worker.ts +1 -1
- package/src/schemas/configuration.ts +8 -6
- package/src/workflow/brief.ts +59 -0
- package/src/workflow/workflow.ts +3 -0
- package/src/pi/expressions.ts +0 -169
- package/src/pi/kaomoji.ts +0 -227
- package/src/pi/mascot-art.ts +0 -359
- package/src/pi/zen-large.ts +0 -699
- package/src/pi/zen-metrics.ts +0 -130
- package/src/pi/zen.ts +0 -659
package/src/pi/ui.ts
CHANGED
|
@@ -1,32 +1,10 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext
|
|
2
|
-
import { Key
|
|
3
|
-
import { clip } from "../width.ts";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
4
3
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
5
|
-
import {
|
|
4
|
+
import type { Task } from "../schemas/task.ts";
|
|
6
5
|
import { activeTask } from "../state/persistence.ts";
|
|
7
6
|
import { detectProjectRoot } from "../state/project.ts";
|
|
8
|
-
import {
|
|
9
|
-
advanceExpression,
|
|
10
|
-
anyPlaying,
|
|
11
|
-
createExpression,
|
|
12
|
-
expressionPhase,
|
|
13
|
-
FAST_TICK_MS,
|
|
14
|
-
ORACLE_GAP,
|
|
15
|
-
SLOT_GAP,
|
|
16
|
-
talkFrame,
|
|
17
|
-
triggerEmote,
|
|
18
|
-
WORKING_BLINK_CHANCE,
|
|
19
|
-
WORKING_GAP,
|
|
20
|
-
type ExpressionGap,
|
|
21
|
-
type ExpressionState,
|
|
22
|
-
} from "./expressions.ts";
|
|
23
|
-
import { ORACLE_THINKING } from "./activity.ts";
|
|
24
|
-
import { SLOT_IDS, type SlotId } from "./mascot-art.ts";
|
|
25
|
-
import { slotSituations } from "./zen-metrics.ts";
|
|
26
|
-
import type { Situation } from "./kaomoji.ts";
|
|
27
|
-
import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
|
|
28
|
-
import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
|
|
29
|
-
import { budgetClock } from "../state/budget.ts";
|
|
7
|
+
import { isSubagentProcess, isQuiet, toggleQuiet } from "./quiet.ts";
|
|
30
8
|
|
|
31
9
|
export const STATUS_KEY = "bot-lobby";
|
|
32
10
|
|
|
@@ -44,44 +22,14 @@ export function statusText(task: Task | undefined, minimized = false): string {
|
|
|
44
22
|
return `bot-lobby ${task.id} · ${task.paused ? `${task.state} (paused)` : task.state} · ${mode}`;
|
|
45
23
|
}
|
|
46
24
|
|
|
47
|
-
let zenOn = false;
|
|
48
|
-
|
|
49
25
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* so the checklist replays after a reload.
|
|
53
|
-
* keys the widget's render cache.
|
|
26
|
+
* The session's task state. `live` holds the runs reported in this session;
|
|
27
|
+
* `runs` is the task's persisted worker records overlaid by the live copies,
|
|
28
|
+
* so the checklist replays after a reload.
|
|
54
29
|
*/
|
|
55
30
|
let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
|
|
56
|
-
/** Where the widget's task lives, so its time budget can be read. */
|
|
57
|
-
let zenPaths: { root: string; configDir: string } | undefined;
|
|
58
|
-
let zenVersion = 0;
|
|
59
|
-
|
|
60
|
-
/** Latest master tool activity; the oracle's speech bubble shows it. */
|
|
61
|
-
let oracleActivity: string | undefined;
|
|
62
|
-
|
|
63
|
-
/** The mounted widget, so run and activity updates can repaint without waiting a tick. */
|
|
64
|
-
let mountedWidget: { refresh(): void } | undefined;
|
|
65
|
-
|
|
66
|
-
function touch(): void {
|
|
67
|
-
zenVersion += 1;
|
|
68
|
-
mountedWidget?.refresh();
|
|
69
|
-
}
|
|
70
31
|
|
|
71
|
-
/**
|
|
72
|
-
export function setOracleActivity(activity: string | undefined, now = Date.now()): void {
|
|
73
|
-
if (activity === oracleActivity) return;
|
|
74
|
-
oracleActivity = activity;
|
|
75
|
-
// The oracle speaks when it names something new, including handing you the turn;
|
|
76
|
-
// quietly going back to "thinking" between tool calls is not worth a word.
|
|
77
|
-
if (activity !== ORACLE_THINKING) oracleSpokeAt = now;
|
|
78
|
-
touch();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** When the oracle last said something new; its mouth lip-syncs for `TALK_MS` after. */
|
|
82
|
-
let oracleSpokeAt: number | undefined;
|
|
83
|
-
|
|
84
|
-
/** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
|
|
32
|
+
/** Upper bound on retained runs so a long task cannot grow the task state without limit. */
|
|
85
33
|
export const MAX_RETAINED_RUNS = 128;
|
|
86
34
|
|
|
87
35
|
/**
|
|
@@ -123,10 +71,9 @@ function slim(runs: readonly AgentRun[]): AgentRun[] {
|
|
|
123
71
|
|
|
124
72
|
function setZenState(task: Task | undefined, live: AgentRun[]): void {
|
|
125
73
|
zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
|
|
126
|
-
touch();
|
|
127
74
|
}
|
|
128
75
|
|
|
129
|
-
/** Per-session standard-pi mode: the
|
|
76
|
+
/** Per-session standard-pi mode: the Master prompt is hidden but ownership stays. */
|
|
130
77
|
let minimized = false;
|
|
131
78
|
|
|
132
79
|
export function isMinimized(): boolean {
|
|
@@ -153,206 +100,8 @@ export function toggleMinimized(ctx: ExtensionContext, configDir: string): void
|
|
|
153
100
|
ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
|
|
154
101
|
}
|
|
155
102
|
|
|
156
|
-
export const LIVE_TICK_MS = 250;
|
|
157
|
-
export const IDLE_TICK_MS = 1000;
|
|
158
|
-
|
|
159
|
-
/** Fast frames while an agent works or the task is live; slow frames when it is quiet. */
|
|
160
|
-
function isLive(): boolean {
|
|
161
|
-
if (zenState.runs.some((run) => run.status === "running")) return true;
|
|
162
|
-
const task = zenState.task;
|
|
163
|
-
return Boolean(task && !TERMINAL_STATES.includes(task.state) && !task.paused);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function liveTickDelay(): number {
|
|
167
|
-
return isLive() ? LIVE_TICK_MS : IDLE_TICK_MS;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Tick delay for the zen clock: fastest while an expression plays or the oracle talks, so no step is skipped. */
|
|
171
|
-
export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean, talking = false): number {
|
|
172
|
-
if (talking || anyPlaying(states, now)) return FAST_TICK_MS;
|
|
173
|
-
return live ? LIVE_TICK_MS : IDLE_TICK_MS;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Every sprite with its own expression schedule. */
|
|
177
|
-
type ExpressionKey = keyof ExpressionFrames;
|
|
178
|
-
const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
|
|
179
|
-
|
|
180
|
-
/** The oracle keeps its own schedule; working agents are livelier than idle ones. */
|
|
181
|
-
function gapFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): ExpressionGap {
|
|
182
|
-
if (key === "oracle") return ORACLE_GAP;
|
|
183
|
-
return situations?.[key].status === "working" ? WORKING_GAP : SLOT_GAP;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function blinkChanceFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): number | undefined {
|
|
187
|
-
return key !== "oracle" && situations?.[key].status === "working" ? WORKING_BLINK_CHANCE : undefined;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** A compact fingerprint of what a slot is going through; a change may earn a reaction. */
|
|
191
|
-
export function situationKey(situation: Situation): string {
|
|
192
|
-
return [situation.status, situation.flag ?? "", situation.handover ? "handover" : "", situation.wrappedUp ? "wrapped" : ""].join("|");
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Whether a slot's change of situation deserves an immediate emote: it started
|
|
197
|
-
* work, finished, failed, got flagged (waiting, quiet, retrying) or received a
|
|
198
|
-
* file. Going idle, or a flag clearing, passes quietly.
|
|
199
|
-
*/
|
|
200
|
-
export function isReaction(previous: string | undefined, next: Situation): boolean {
|
|
201
|
-
if (previous === undefined || previous === situationKey(next)) return false;
|
|
202
|
-
if (next.status === "idle") return false;
|
|
203
|
-
if (next.status !== "working") return true;
|
|
204
|
-
return !previous.startsWith("working|") || next.flag !== undefined || next.handover === true;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* The animated zen scene: the tick, each sprite's expression schedule and the
|
|
209
|
-
* reactions to what the agents go through. The widget and the lobby's home tab
|
|
210
|
-
* each own one and advance it from their own clock.
|
|
211
|
-
*/
|
|
212
|
-
export class ZenScene {
|
|
213
|
-
tick = 0;
|
|
214
|
-
private readonly expressions: Record<ExpressionKey, ExpressionState>;
|
|
215
|
-
private readonly situations: Partial<Record<SlotId, string>> = {};
|
|
216
|
-
private readonly rng: () => number;
|
|
217
|
-
private cache: { key: string; theme: Theme | undefined; lines: string[] } | undefined;
|
|
218
|
-
|
|
219
|
-
constructor(rng: () => number = Math.random, now = Date.now()) {
|
|
220
|
-
this.rng = rng;
|
|
221
|
-
const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng, gapFor(key))] as const);
|
|
222
|
-
this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** One clock step: expressions advance and agents react to their new situations. */
|
|
226
|
-
advance(now = Date.now()): void {
|
|
227
|
-
this.tick += 1;
|
|
228
|
-
const situations = slotSituations(zenState.runs, now);
|
|
229
|
-
for (const key of EXPRESSION_KEYS) {
|
|
230
|
-
this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key, situations), blinkChanceFor(key, situations));
|
|
231
|
-
}
|
|
232
|
-
for (const id of SLOT_IDS) {
|
|
233
|
-
const situation = situations[id];
|
|
234
|
-
if (isReaction(this.situations[id], situation)) this.expressions[id] = triggerEmote(now, this.rng, gapFor(id, situations));
|
|
235
|
-
this.situations[id] = situationKey(situation);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** The delay until the next step: fast while an expression plays or the oracle talks. */
|
|
240
|
-
delay(now = Date.now()): number {
|
|
241
|
-
return expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
private motion(now: number): OracleMotion {
|
|
245
|
-
return { phase: expressionPhase(this.expressions.oracle, now), talk: talkFrame(oracleSpokeAt, now) };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* The scene lines for the current zen state. They only change with the tick,
|
|
250
|
-
* an expression frame, the widget state or the elapsed second, so any other
|
|
251
|
-
* repaint (typing, streaming output) reuses the last lines.
|
|
252
|
-
*/
|
|
253
|
-
lines(width: number, rows: number, theme: Theme | undefined, now = Date.now(), still = false): string[] {
|
|
254
|
-
const expressions = Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame])) as Partial<Record<ExpressionKey, number>>;
|
|
255
|
-
const variants = Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant])) as Partial<Record<SlotId, number>>;
|
|
256
|
-
const quiet = isQuiet();
|
|
257
|
-
const frameKey = [...EXPRESSION_KEYS.map((key) => expressions[key] ?? 0), ...SLOT_IDS.map((id) => variants[id] ?? 0)].join(",");
|
|
258
|
-
const motion = this.motion(now);
|
|
259
|
-
const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}|${still}`;
|
|
260
|
-
if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
|
|
261
|
-
const time = zenState.task && zenPaths ? budgetClock(zenPaths.root, zenPaths.configDir, zenState.task.id, now) : undefined;
|
|
262
|
-
const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still, ...(time ? { time } : {}) };
|
|
263
|
-
const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => clip(line, width));
|
|
264
|
-
this.cache = { key, theme, lines };
|
|
265
|
-
return lines;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
invalidate(): void {
|
|
269
|
-
this.cache = undefined;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** The zen state as the lobby reads it: the session's active task and its runs. */
|
|
274
|
-
export function zenSnapshot(): { task: Task | undefined; runs: readonly AgentRun[]; version: number; oracleActivity: string | undefined } {
|
|
275
|
-
return { task: zenState.task, runs: zenState.runs, version: zenVersion, oracleActivity };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** Zen scene + plan checklist shown above the editor while a task is active and the lobby is closed. */
|
|
279
|
-
class ZenWidget implements Component {
|
|
280
|
-
private readonly scene: ZenScene;
|
|
281
|
-
private delay = liveTickDelay();
|
|
282
|
-
private timer: ReturnType<typeof setInterval>;
|
|
283
|
-
private disposed = false;
|
|
284
|
-
private readonly tui: TUI;
|
|
285
|
-
private readonly theme: () => Theme;
|
|
286
|
-
|
|
287
|
-
constructor(tui: TUI, theme: () => Theme, rng: () => number = Math.random) {
|
|
288
|
-
this.tui = tui;
|
|
289
|
-
this.theme = theme;
|
|
290
|
-
this.scene = new ZenScene(rng);
|
|
291
|
-
this.timer = setInterval(() => this.advance(), this.delay);
|
|
292
|
-
mountedWidget = this;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** Repaint now: state changed between ticks. */
|
|
296
|
-
refresh(): void {
|
|
297
|
-
if (!this.disposed) this.tui.requestRender();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
private advance(): void {
|
|
301
|
-
if (this.disposed) return;
|
|
302
|
-
const now = Date.now();
|
|
303
|
-
this.scene.advance(now);
|
|
304
|
-
this.retime(now);
|
|
305
|
-
this.tui.requestRender();
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** One interval, retimed when work starts or stops or an expression plays. */
|
|
309
|
-
private retime(now: number): void {
|
|
310
|
-
const delay = this.scene.delay(now);
|
|
311
|
-
if (delay === this.delay) return;
|
|
312
|
-
this.delay = delay;
|
|
313
|
-
clearInterval(this.timer);
|
|
314
|
-
this.timer = setInterval(() => this.advance(), delay);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
render(width: number): string[] {
|
|
318
|
-
return this.scene.lines(width, this.tui.terminal.rows, this.theme());
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
invalidate(): void {
|
|
322
|
-
this.scene.invalidate();
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
dispose(): void {
|
|
326
|
-
this.disposed = true;
|
|
327
|
-
clearInterval(this.timer);
|
|
328
|
-
if (mountedWidget === this) mountedWidget = undefined;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** True while the full-screen lobby is showing; the small widget then stays unmounted. */
|
|
333
|
-
let widgetSuppressed: () => boolean = () => false;
|
|
334
|
-
let widgetMounted = false;
|
|
335
|
-
|
|
336
|
-
/** The lobby registers how to tell whether it covers the screen. */
|
|
337
|
-
export function setWidgetSuppressor(check: () => boolean): void {
|
|
338
|
-
widgetSuppressed = check;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function unmountWidget(ctx: ExtensionContext): void {
|
|
342
|
-
if (!widgetMounted) return;
|
|
343
|
-
widgetMounted = false;
|
|
344
|
-
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function leaveZen(ctx: ExtensionContext): void {
|
|
348
|
-
if (!zenOn) return;
|
|
349
|
-
zenOn = false;
|
|
350
|
-
ctx.ui.setWorkingVisible(true);
|
|
351
|
-
ctx.ui.setWorkingIndicator();
|
|
352
|
-
}
|
|
353
|
-
|
|
354
103
|
/**
|
|
355
|
-
* Refresh the footer
|
|
104
|
+
* Refresh the footer to match the task on disk. Runs are merged into the
|
|
356
105
|
* retained set for the same task because each `orchestrate` call reports only its
|
|
357
106
|
* own agents: without retention the qa/reviewer call that follows the workers would
|
|
358
107
|
* evict their successes and the checklist would reset. A new task starts clean.
|
|
@@ -360,28 +109,12 @@ function leaveZen(ctx: ExtensionContext): void {
|
|
|
360
109
|
export function applyStatus(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[] = []): void {
|
|
361
110
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
362
111
|
const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
|
|
363
|
-
zenPaths = { root, configDir };
|
|
364
112
|
const sameTask = zenState.task?.id === task?.id;
|
|
365
113
|
setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
|
|
366
114
|
ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
|
|
367
|
-
const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
|
|
368
|
-
if (!active) {
|
|
369
|
-
leaveZen(ctx);
|
|
370
|
-
widgetMounted = false;
|
|
371
|
-
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
zenOn = true;
|
|
375
|
-
ctx.ui.setWorkingVisible(false);
|
|
376
|
-
ctx.ui.setWorkingIndicator({ frames: [] });
|
|
377
|
-
if (widgetSuppressed()) return unmountWidget(ctx);
|
|
378
|
-
if (!widgetMounted) {
|
|
379
|
-
widgetMounted = true;
|
|
380
|
-
ctx.ui.setWidget(STATUS_KEY, (tui) => new ZenWidget(tui, () => ctx.ui.theme));
|
|
381
|
-
}
|
|
382
115
|
}
|
|
383
116
|
|
|
384
|
-
/** The session's active task as
|
|
117
|
+
/** The session's active task as last loaded (undefined when none or minimized). */
|
|
385
118
|
export function currentZenTask(): Task | undefined {
|
|
386
119
|
return zenState.task;
|
|
387
120
|
}
|
|
@@ -397,7 +130,7 @@ export function onRunUpdates(listener: ((runs: readonly AgentRun[]) => void) | u
|
|
|
397
130
|
* Streamed run updates (start, every activity change, finish) from an in-flight
|
|
398
131
|
* `orchestrate` call. The task on disk does not change mid-call, so this only
|
|
399
132
|
* merges the runs and repaints; `applyStatus` rereads the task once the call ends.
|
|
400
|
-
* Before any task is loaded it falls back to `applyStatus` so the
|
|
133
|
+
* Before any task is loaded it falls back to `applyStatus` so the status appears.
|
|
401
134
|
*/
|
|
402
135
|
export function reportRuns(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[]): void {
|
|
403
136
|
runListener?.(runs);
|
|
@@ -409,12 +142,8 @@ export function reportRuns(ctx: ExtensionContext, root: string, configDir: strin
|
|
|
409
142
|
}
|
|
410
143
|
|
|
411
144
|
export function clearStatus(ctx: ExtensionContext): void {
|
|
412
|
-
leaveZen(ctx);
|
|
413
|
-
widgetMounted = false;
|
|
414
|
-
oracleActivity = undefined;
|
|
415
145
|
zenState = { task: undefined, live: [], runs: [] };
|
|
416
146
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
417
|
-
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
418
147
|
}
|
|
419
148
|
|
|
420
149
|
/** Register `alt+t`, the reveal-on-demand toggle for built-in tool rows. */
|
|
@@ -425,7 +154,7 @@ export function registerRevealShortcut(pi: ExtensionAPI, configDir: string): voi
|
|
|
425
154
|
handler: (ctx) => revealTools(ctx, configDir),
|
|
426
155
|
});
|
|
427
156
|
pi.registerShortcut(Key.ctrlShift("m"), {
|
|
428
|
-
description: "bot-lobby: minimize or restore
|
|
157
|
+
description: "bot-lobby: minimize or restore bot-lobby for this session",
|
|
429
158
|
handler: (ctx) => toggleMinimized(ctx, configDir),
|
|
430
159
|
});
|
|
431
160
|
}
|
|
@@ -443,3 +172,8 @@ function revealTools(ctx: ExtensionContext, configDir: string): void {
|
|
|
443
172
|
"info",
|
|
444
173
|
);
|
|
445
174
|
}
|
|
175
|
+
|
|
176
|
+
/** The session's active task and its runs, as the lobby reads them. */
|
|
177
|
+
export function taskSnapshot(): { task: Task | undefined; runs: readonly AgentRun[] } {
|
|
178
|
+
return { task: zenState.task, runs: zenState.runs };
|
|
179
|
+
}
|
package/src/roles/worker.ts
CHANGED
|
@@ -16,7 +16,7 @@ export const workerSpec: RoleSpec = {
|
|
|
16
16
|
contract: [
|
|
17
17
|
"### Output contract",
|
|
18
18
|
"Respond with exactly these sections and nothing else: `## Completed`, `## Files Changed`,",
|
|
19
|
-
"`## Verification`, `## Notes`, `## Blockers`, `## Dependencies Needed`, `## Architecture Changes`,",
|
|
19
|
+
"`## Verification`, `## Brief Check` (one `- criterion — met|not met — evidence` line per \"Done when\" item of your brief), `## Notes`, `## Blockers`, `## Dependencies Needed`, `## Architecture Changes`,",
|
|
20
20
|
"`## Knowledge Proposals` (`- knowledge: ...`, `- standard: ...`, or `- decision: ...`).",
|
|
21
21
|
"`## Files Changed` entries are `- \\`path\\` — change`.",
|
|
22
22
|
"`## Verification` entries are `- command — result`.",
|
|
@@ -72,6 +72,8 @@ export interface WorkflowConfig {
|
|
|
72
72
|
taskBudgetMinutes: number;
|
|
73
73
|
/** Small, clear, low-risk requests take the fast track (no scouts, proposal or plan; QA only when tests are needed); false puts every task on the full workflow. */
|
|
74
74
|
fastTrack: boolean;
|
|
75
|
+
/** A delegation that names nothing concrete, or is long without done criteria, is sent back to the oracle once for a fuller brief (sending it again unchanged goes through). */
|
|
76
|
+
briefCheck: boolean;
|
|
75
77
|
/** A new request that one agent can do alone goes to the quick-fix agent once the oracle confirms; false makes every request a task. */
|
|
76
78
|
routeQuickFixes: boolean;
|
|
77
79
|
}
|
|
@@ -92,12 +94,10 @@ export function isPanelMember(value: string): value is PanelMember {
|
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/**
|
|
95
|
-
* Lobby panes that can be shown or hidden: the
|
|
96
|
-
*
|
|
97
|
-
* animations show above pi's editor while the lobby is hidden), the
|
|
98
|
-
* conversation, the activity log and thinking.
|
|
97
|
+
* Lobby panes that can be shown or hidden: the conversation, the activity log
|
|
98
|
+
* and thinking.
|
|
99
99
|
*/
|
|
100
|
-
export const LOBBY_PANELS = ["
|
|
100
|
+
export const LOBBY_PANELS = ["conversation", "activity", "thinking"] as const;
|
|
101
101
|
export type LobbyPanel = (typeof LOBBY_PANELS)[number];
|
|
102
102
|
|
|
103
103
|
/** The full-screen lobby. */
|
|
@@ -214,6 +214,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
214
214
|
freshContext: true,
|
|
215
215
|
taskBudgetMinutes: 0,
|
|
216
216
|
fastTrack: true,
|
|
217
|
+
briefCheck: true,
|
|
217
218
|
routeQuickFixes: true,
|
|
218
219
|
},
|
|
219
220
|
knowledge: {
|
|
@@ -227,7 +228,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
227
228
|
planningPanel: [...PANEL_MEMBERS],
|
|
228
229
|
autoAsk: true,
|
|
229
230
|
issues: false,
|
|
230
|
-
panels: {
|
|
231
|
+
panels: { conversation: true, activity: true, thinking: true },
|
|
231
232
|
keys: {},
|
|
232
233
|
mouse: true,
|
|
233
234
|
maxPlanningRounds: 5,
|
|
@@ -353,6 +354,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
|
|
|
353
354
|
const workflow = { ...DEFAULT_CONFIG.workflow, ...(src.workflow as Partial<WorkflowConfig> | undefined) };
|
|
354
355
|
workflow.freshContext = workflow.freshContext !== false;
|
|
355
356
|
workflow.fastTrack = workflow.fastTrack !== false;
|
|
357
|
+
workflow.briefCheck = workflow.briefCheck !== false;
|
|
356
358
|
workflow.routeQuickFixes = workflow.routeQuickFixes !== false;
|
|
357
359
|
workflow.taskBudgetMinutes = typeof workflow.taskBudgetMinutes === "number" && workflow.taskBudgetMinutes > 0 ? Math.min(24 * 60, Math.round(workflow.taskBudgetMinutes)) : 0;
|
|
358
360
|
const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cheap check that a delegation is a brief a small model can act on: it
|
|
3
|
+
* points at something concrete, and a longer one says when it is done. It only
|
|
4
|
+
* ever asks the master to add detail once: repeating the same instruction
|
|
5
|
+
* unchanged is accepted, so a short task that really is complete never gets
|
|
6
|
+
* stuck behind it. Pure apart from that one-entry-per-step memory.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Longest instruction that only has to point at something concrete. */
|
|
10
|
+
export const SHORT_BRIEF_CHARS = 200;
|
|
11
|
+
|
|
12
|
+
/** A path, a file name with an extension, a `code` span, a quoted string or a "Files" label. */
|
|
13
|
+
const ANCHOR = /(?:[\w.-]+\/[\w./-]+)|(?:\b[\w-]+\.[a-z]{1,5}\b)|`[^`\n]+`|"[^"\n]{2,}"|\*\*files?\*\*|\bfiles?:/i;
|
|
14
|
+
/** An identifier in camelCase, PascalCase or snake_case (case matters, so it is kept apart). */
|
|
15
|
+
const IDENTIFIER = /\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b|\b[a-z]+_[a-z_]+\b/;
|
|
16
|
+
/** Wording that states when the step is finished or how it is checked. */
|
|
17
|
+
const CRITERIA = /done when|definition of done|acceptance|success criteri|verif|must (?:pass|print|return|show|exist|render|display|work)|should (?:pass|print|return|show|exist|render|display|work)|expect|check that|ensure/i;
|
|
18
|
+
|
|
19
|
+
/** What a delegation is missing, one sentence each; empty when it is fine. */
|
|
20
|
+
export function briefProblems(instruction: string): string[] {
|
|
21
|
+
const text = instruction.replace(/\s+/g, " ").trim();
|
|
22
|
+
const problems: string[] = [];
|
|
23
|
+
if (!ANCHOR.test(text) && !IDENTIFIER.test(text)) problems.push("it names no file, path, function, identifier or exact string, so the agent must guess where and what to change");
|
|
24
|
+
if (text.length > SHORT_BRIEF_CHARS && !CRITERIA.test(text)) problems.push("it has no \"Done when\" criteria (observable checks, or commands and what they should show), so the agent cannot tell when it has finished");
|
|
25
|
+
return problems;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lastRejected = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
const normalize = (text: string) => text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The error to answer a delegation with, or undefined to let it through. The
|
|
34
|
+
* first time a step's brief falls short it is sent back; sending the same
|
|
35
|
+
* text again means the master has judged it complete.
|
|
36
|
+
*/
|
|
37
|
+
export function briefRejection(taskId: string, domain: string, instruction: string): string | undefined {
|
|
38
|
+
const key = `${taskId}:${domain}`;
|
|
39
|
+
const problems = briefProblems(instruction);
|
|
40
|
+
if (problems.length === 0) {
|
|
41
|
+
lastRejected.delete(key);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
if (lastRejected.get(key) === normalize(instruction)) {
|
|
45
|
+
lastRejected.delete(key);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
lastRejected.set(key, normalize(instruction));
|
|
49
|
+
return [
|
|
50
|
+
`The ${domain} brief was not sent: ${problems.join("; ")}.`,
|
|
51
|
+
"The agent may be a smaller, literal model that assumes nothing. Rewrite the brief as: Goal, Files, numbered What to do (exact names, shapes, values), Contracts, Constraints, Done when, If stuck.",
|
|
52
|
+
"If it really is complete as written, send the same instruction again and it goes through.",
|
|
53
|
+
].join(" ");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Forget what was rejected (tests). */
|
|
57
|
+
export function resetBriefs(): void {
|
|
58
|
+
lastRejected.clear();
|
|
59
|
+
}
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecis
|
|
|
49
49
|
import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
|
|
50
50
|
import { tail, truncate } from "../text.ts";
|
|
51
51
|
import { isAutoMode } from "../state/auto.ts";
|
|
52
|
+
import { briefRejection } from "./brief.ts";
|
|
52
53
|
import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
|
|
53
54
|
import { pingApproval } from "../pi/notify.ts";
|
|
54
55
|
import { describeRun, runLogEntry } from "../pi/run-summary.ts";
|
|
@@ -877,6 +878,8 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
877
878
|
}
|
|
878
879
|
if (!starting) requireState(task, ["planning", "implementing", "reviewing"]);
|
|
879
880
|
const assignments = parseAssignments(params);
|
|
881
|
+
const short = !deps.config.workflow.briefCheck ? [] : assignments.map((entry) => briefRejection(task.id, entry.domain, entry.instruction)).filter(Boolean);
|
|
882
|
+
if (short.length > 0) throw new Error(short.join("\n"));
|
|
880
883
|
for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
|
|
881
884
|
for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
|
|
882
885
|
// Under a time budget every step is given its share before any starts; a spent budget starts none.
|
package/src/pi/expressions.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Caller-driven expression schedule for the zen sprites.
|
|
3
|
-
*
|
|
4
|
-
* Pure and deterministic: `now` and the random source are always injected, so
|
|
5
|
-
* the art, layout and metrics stay free of `Date.now`/`Math.random`. Each slot
|
|
6
|
-
* and the oracle keeps its own state, holds its rest frame between events and,
|
|
7
|
-
* after a random 20-30 s gap, plays a short blink or a longer emote.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** Resting gap before the next expression, milliseconds. */
|
|
11
|
-
export const BLINK_MIN_MS = 20_000;
|
|
12
|
-
export const BLINK_MAX_MS = 30_000;
|
|
13
|
-
/** How long one blink and one emote are held. */
|
|
14
|
-
export const BLINK_MS = 500;
|
|
15
|
-
export const EMOTE_MS = 2000;
|
|
16
|
-
/** Fast clock while an expression plays, short enough that a blink is never skipped. */
|
|
17
|
-
export const FAST_TICK_MS = 120;
|
|
18
|
-
|
|
19
|
-
/** Art frame indexes: 0 rests, 1 blinks, `2 ..` are emotes. */
|
|
20
|
-
export const REST_FRAME = 0;
|
|
21
|
-
export const BLINK_FRAME = 1;
|
|
22
|
-
export const EMOTE_FRAME = 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;
|
|
28
|
-
/** Each emote frame is held this long, so an emote steps through its frames. */
|
|
29
|
-
export const EMOTE_STEP_MS = Math.floor(EMOTE_MS / EMOTE_FRAMES);
|
|
30
|
-
|
|
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;
|
|
36
|
-
|
|
37
|
-
export interface ExpressionState {
|
|
38
|
-
/** Earliest `now` the next expression may start. */
|
|
39
|
-
nextAt: number;
|
|
40
|
-
/** When the playing expression ends; not after `now` while resting. */
|
|
41
|
-
until: number;
|
|
42
|
-
/** When the current expression started; drives emote frame stepping. */
|
|
43
|
-
startedAt: number;
|
|
44
|
-
/** Frame index for the art: 0 rest, 1 blink, 2+ emote. */
|
|
45
|
-
frame: number;
|
|
46
|
-
/** Drawn once per expression; picks which face an emote shows (see kaomoji.ts). */
|
|
47
|
-
variant: number;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** A random draw clamped to `[0, 1]`, so a non-finite source value can never leak. */
|
|
51
|
-
function unit(rng: () => number): number {
|
|
52
|
-
const value = rng();
|
|
53
|
-
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
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";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function drawVariant(rng: () => number): number {
|
|
80
|
-
return Math.floor(unit(rng) * (VARIANT_SPAN - 1));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** A freshly rested expression that fires for the first time after one random gap. */
|
|
84
|
-
export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
|
|
85
|
-
const start = Number.isFinite(now) ? now : 0;
|
|
86
|
-
return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME, variant: 0 };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** True while `now` is inside a playing blink or emote. */
|
|
90
|
-
export function isPlaying(state: ExpressionState, now: number): boolean {
|
|
91
|
-
return Number.isFinite(now) && now < state.until;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** True when any state is mid-expression; the caller uses it to retime its clock. */
|
|
95
|
-
export function anyPlaying(states: readonly ExpressionState[], now: number): boolean {
|
|
96
|
-
return states.some((state) => isPlaying(state, now));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Advance one sprite to `now`: start a blink or emote once the gap has elapsed,
|
|
101
|
-
* drop back to the rest frame when it ends, and change nothing in between.
|
|
102
|
-
*/
|
|
103
|
-
export function advanceExpression(
|
|
104
|
-
state: ExpressionState,
|
|
105
|
-
now: number,
|
|
106
|
-
rng: () => number,
|
|
107
|
-
gap: ExpressionGap = SLOT_GAP,
|
|
108
|
-
blinkChance = BLINK_CHANCE,
|
|
109
|
-
): ExpressionState {
|
|
110
|
-
if (!Number.isFinite(now)) return state;
|
|
111
|
-
if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
|
|
112
|
-
if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
|
|
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) };
|
|
135
|
-
}
|
|
136
|
-
|
|
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;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Emote frame for `now`: one step per EMOTE_STEP_MS, clamped to the last frame. */
|
|
159
|
-
function emoteFrameAt(startedAt: number, now: number): number {
|
|
160
|
-
const elapsed = now - startedAt;
|
|
161
|
-
const step = Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / EMOTE_STEP_MS) : 0;
|
|
162
|
-
return EMOTE_FRAME + Math.min(EMOTE_FRAMES - 1, Math.max(0, step));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Advance an in-flight emote's frame, keeping the same object when it has not changed. */
|
|
166
|
-
function steppedEmote(state: ExpressionState, now: number): ExpressionState {
|
|
167
|
-
const frame = emoteFrameAt(state.startedAt, now);
|
|
168
|
-
return frame === state.frame ? state : { ...state, frame };
|
|
169
|
-
}
|