@a-t-h-i/bot-lobby 0.4.0 → 0.5.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 +140 -7
- package/package.json +1 -1
- package/prompts/master.md +12 -0
- package/prompts/panel.md +39 -0
- package/prompts/planner.md +64 -0
- package/prompts/quickfix.md +41 -0
- package/src/execution/agent-runner.ts +7 -2
- package/src/execution/pi-runner.ts +25 -0
- package/src/index.ts +3 -0
- package/src/lobby/feed.ts +253 -0
- package/src/lobby/issues.ts +227 -0
- package/src/lobby/layout.ts +174 -0
- package/src/lobby/planner.ts +474 -0
- package/src/lobby/quickfix.ts +227 -0
- package/src/lobby/runtime.ts +440 -0
- package/src/lobby/tabs/home.ts +164 -0
- package/src/lobby/tabs/issues.ts +72 -0
- package/src/lobby/tabs/metrics.ts +162 -0
- package/src/lobby/tabs/plan.ts +160 -0
- package/src/lobby/tabs/quickfix.ts +101 -0
- package/src/lobby/tabs/tasks.ts +209 -0
- package/src/lobby/view.ts +855 -0
- package/src/pi/activity.ts +120 -0
- package/src/pi/commands.ts +19 -57
- package/src/pi/events.ts +5 -2
- package/src/pi/model-support.ts +34 -0
- package/src/pi/run-summary.ts +2 -0
- package/src/pi/settings-ui.ts +4 -0
- package/src/pi/start-task.ts +63 -0
- package/src/pi/tools.ts +2 -2
- package/src/pi/ui.ts +116 -55
- package/src/schemas/configuration.ts +68 -3
- package/src/schemas/findings.ts +6 -0
- package/src/schemas/task.ts +1 -0
- package/src/state/backlog.ts +106 -0
- package/src/state/comments.ts +136 -0
- package/src/state/metrics.ts +305 -0
- package/src/workflow/workflow.ts +39 -7
package/src/pi/ui.ts
CHANGED
|
@@ -129,8 +129,17 @@ export function isMinimized(): boolean {
|
|
|
129
129
|
return minimized;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** Told when minimize changes, so the lobby can step aside too. */
|
|
133
|
+
let minimizeListener: ((value: boolean) => void) | undefined;
|
|
134
|
+
|
|
135
|
+
export function onMinimizeChange(listener: ((value: boolean) => void) | undefined): void {
|
|
136
|
+
minimizeListener = listener;
|
|
137
|
+
}
|
|
138
|
+
|
|
132
139
|
export function setMinimized(value: boolean): void {
|
|
140
|
+
if (minimized === value) return;
|
|
133
141
|
minimized = value;
|
|
142
|
+
minimizeListener?.(value);
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
/** Flip minimize/restore and refresh the footer; the session is unchanged. */
|
|
@@ -191,45 +200,27 @@ export function isReaction(previous: string | undefined, next: Situation): boole
|
|
|
191
200
|
return !previous.startsWith("working|") || next.flag !== undefined || next.handover === true;
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The animated zen scene: the tick, each sprite's expression schedule and the
|
|
205
|
+
* reactions to what the agents go through. The widget and the lobby's home tab
|
|
206
|
+
* each own one and advance it from their own clock.
|
|
207
|
+
*/
|
|
208
|
+
export class ZenScene {
|
|
209
|
+
tick = 0;
|
|
201
210
|
private readonly expressions: Record<ExpressionKey, ExpressionState>;
|
|
202
211
|
private readonly situations: Partial<Record<SlotId, string>> = {};
|
|
203
|
-
private readonly tui: TUI;
|
|
204
|
-
private readonly theme: () => Theme;
|
|
205
212
|
private readonly rng: () => number;
|
|
213
|
+
private cache: { key: string; theme: Theme | undefined; lines: string[] } | undefined;
|
|
206
214
|
|
|
207
|
-
constructor(
|
|
208
|
-
this.tui = tui;
|
|
209
|
-
this.theme = theme;
|
|
215
|
+
constructor(rng: () => number = Math.random, now = Date.now()) {
|
|
210
216
|
this.rng = rng;
|
|
211
|
-
const now = Date.now();
|
|
212
217
|
const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng, gapFor(key))] as const);
|
|
213
218
|
this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
|
|
214
|
-
this.timer = setInterval(() => this.advance(), this.delay);
|
|
215
|
-
mountedWidget = this;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Repaint now: state changed between ticks. */
|
|
219
|
-
refresh(): void {
|
|
220
|
-
if (!this.disposed) this.tui.requestRender();
|
|
221
219
|
}
|
|
222
220
|
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
/** One clock step: expressions advance and agents react to their new situations. */
|
|
222
|
+
advance(now = Date.now()): void {
|
|
225
223
|
this.tick += 1;
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
this.play(now);
|
|
228
|
-
this.retime(now);
|
|
229
|
-
this.tui.requestRender();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
private play(now: number): void {
|
|
233
224
|
const situations = slotSituations(zenState.runs, now);
|
|
234
225
|
for (const key of EXPRESSION_KEYS) {
|
|
235
226
|
this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key, situations), blinkChanceFor(key, situations));
|
|
@@ -241,39 +232,24 @@ class ZenWidget implements Component {
|
|
|
241
232
|
}
|
|
242
233
|
}
|
|
243
234
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (delay === this.delay) return;
|
|
248
|
-
this.delay = delay;
|
|
249
|
-
clearInterval(this.timer);
|
|
250
|
-
this.timer = setInterval(() => this.advance(), delay);
|
|
235
|
+
/** The delay until the next step: fast while an expression plays or the oracle talks. */
|
|
236
|
+
delay(now = Date.now()): number {
|
|
237
|
+
return expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
|
|
251
238
|
}
|
|
252
239
|
|
|
253
240
|
private motion(now: number): OracleMotion {
|
|
254
241
|
return { phase: expressionPhase(this.expressions.oracle, now), talk: talkFrame(oracleSpokeAt, now) };
|
|
255
242
|
}
|
|
256
243
|
|
|
257
|
-
private frames(): Partial<Record<ExpressionKey, number>> {
|
|
258
|
-
return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private variants(): Partial<Record<SlotId, number>> {
|
|
262
|
-
return Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant]));
|
|
263
|
-
}
|
|
264
|
-
|
|
265
244
|
/**
|
|
266
|
-
* The
|
|
267
|
-
*
|
|
268
|
-
* output) reuses the last lines
|
|
245
|
+
* The scene lines for the current zen state. They only change with the tick,
|
|
246
|
+
* an expression frame, the widget state or the elapsed second, so any other
|
|
247
|
+
* repaint (typing, streaming output) reuses the last lines.
|
|
269
248
|
*/
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
const theme = this.theme();
|
|
274
|
-
const expressions = this.frames();
|
|
249
|
+
lines(width: number, rows: number, theme: Theme | undefined, now = Date.now()): string[] {
|
|
250
|
+
const expressions = Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame])) as Partial<Record<ExpressionKey, number>>;
|
|
251
|
+
const variants = Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant])) as Partial<Record<SlotId, number>>;
|
|
275
252
|
const quiet = isQuiet();
|
|
276
|
-
const variants = this.variants();
|
|
277
253
|
const frameKey = [...EXPRESSION_KEYS.map((key) => expressions[key] ?? 0), ...SLOT_IDS.map((id) => variants[id] ?? 0)].join(",");
|
|
278
254
|
const motion = this.motion(now);
|
|
279
255
|
const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
|
|
@@ -287,6 +263,59 @@ class ZenWidget implements Component {
|
|
|
287
263
|
invalidate(): void {
|
|
288
264
|
this.cache = undefined;
|
|
289
265
|
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The zen state as the lobby reads it: the session's active task and its runs. */
|
|
269
|
+
export function zenSnapshot(): { task: Task | undefined; runs: readonly AgentRun[]; version: number; oracleActivity: string | undefined } {
|
|
270
|
+
return { task: zenState.task, runs: zenState.runs, version: zenVersion, oracleActivity };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Zen scene + plan checklist shown above the editor while a task is active and the lobby is closed. */
|
|
274
|
+
class ZenWidget implements Component {
|
|
275
|
+
private readonly scene: ZenScene;
|
|
276
|
+
private delay = liveTickDelay();
|
|
277
|
+
private timer: ReturnType<typeof setInterval>;
|
|
278
|
+
private disposed = false;
|
|
279
|
+
private readonly tui: TUI;
|
|
280
|
+
private readonly theme: () => Theme;
|
|
281
|
+
|
|
282
|
+
constructor(tui: TUI, theme: () => Theme, rng: () => number = Math.random) {
|
|
283
|
+
this.tui = tui;
|
|
284
|
+
this.theme = theme;
|
|
285
|
+
this.scene = new ZenScene(rng);
|
|
286
|
+
this.timer = setInterval(() => this.advance(), this.delay);
|
|
287
|
+
mountedWidget = this;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Repaint now: state changed between ticks. */
|
|
291
|
+
refresh(): void {
|
|
292
|
+
if (!this.disposed) this.tui.requestRender();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private advance(): void {
|
|
296
|
+
if (this.disposed) return;
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
this.scene.advance(now);
|
|
299
|
+
this.retime(now);
|
|
300
|
+
this.tui.requestRender();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** One interval, retimed when work starts or stops or an expression plays. */
|
|
304
|
+
private retime(now: number): void {
|
|
305
|
+
const delay = this.scene.delay(now);
|
|
306
|
+
if (delay === this.delay) return;
|
|
307
|
+
this.delay = delay;
|
|
308
|
+
clearInterval(this.timer);
|
|
309
|
+
this.timer = setInterval(() => this.advance(), delay);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
render(width: number): string[] {
|
|
313
|
+
return this.scene.lines(width, this.tui.terminal.rows, this.theme());
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
invalidate(): void {
|
|
317
|
+
this.scene.invalidate();
|
|
318
|
+
}
|
|
290
319
|
|
|
291
320
|
dispose(): void {
|
|
292
321
|
this.disposed = true;
|
|
@@ -295,6 +324,21 @@ class ZenWidget implements Component {
|
|
|
295
324
|
}
|
|
296
325
|
}
|
|
297
326
|
|
|
327
|
+
/** True while the full-screen lobby is showing; the small widget then stays unmounted. */
|
|
328
|
+
let widgetSuppressed: () => boolean = () => false;
|
|
329
|
+
let widgetMounted = false;
|
|
330
|
+
|
|
331
|
+
/** The lobby registers how to tell whether it covers the screen. */
|
|
332
|
+
export function setWidgetSuppressor(check: () => boolean): void {
|
|
333
|
+
widgetSuppressed = check;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function unmountWidget(ctx: ExtensionContext): void {
|
|
337
|
+
if (!widgetMounted) return;
|
|
338
|
+
widgetMounted = false;
|
|
339
|
+
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
340
|
+
}
|
|
341
|
+
|
|
298
342
|
function leaveZen(ctx: ExtensionContext): void {
|
|
299
343
|
if (!zenOn) return;
|
|
300
344
|
zenOn = false;
|
|
@@ -317,17 +361,32 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
|
|
|
317
361
|
const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
|
|
318
362
|
if (!active) {
|
|
319
363
|
leaveZen(ctx);
|
|
364
|
+
widgetMounted = false;
|
|
320
365
|
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
321
366
|
return;
|
|
322
367
|
}
|
|
368
|
+
zenOn = true;
|
|
323
369
|
ctx.ui.setWorkingVisible(false);
|
|
324
370
|
ctx.ui.setWorkingIndicator({ frames: [] });
|
|
325
|
-
if (
|
|
326
|
-
|
|
371
|
+
if (widgetSuppressed()) return unmountWidget(ctx);
|
|
372
|
+
if (!widgetMounted) {
|
|
373
|
+
widgetMounted = true;
|
|
327
374
|
ctx.ui.setWidget(STATUS_KEY, (tui) => new ZenWidget(tui, () => ctx.ui.theme));
|
|
328
375
|
}
|
|
329
376
|
}
|
|
330
377
|
|
|
378
|
+
/** The session's active task as the widget last loaded it (undefined when none or minimized). */
|
|
379
|
+
export function currentZenTask(): Task | undefined {
|
|
380
|
+
return zenState.task;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Called with every streamed run update, so the lobby's activity log can follow the agents. */
|
|
384
|
+
let runListener: ((runs: readonly AgentRun[]) => void) | undefined;
|
|
385
|
+
|
|
386
|
+
export function onRunUpdates(listener: ((runs: readonly AgentRun[]) => void) | undefined): void {
|
|
387
|
+
runListener = listener;
|
|
388
|
+
}
|
|
389
|
+
|
|
331
390
|
/**
|
|
332
391
|
* Streamed run updates (start, every activity change, finish) from an in-flight
|
|
333
392
|
* `orchestrate` call. The task on disk does not change mid-call, so this only
|
|
@@ -335,6 +394,7 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
|
|
|
335
394
|
* Before any task is loaded it falls back to `applyStatus` so the widget appears.
|
|
336
395
|
*/
|
|
337
396
|
export function reportRuns(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[]): void {
|
|
397
|
+
runListener?.(runs);
|
|
338
398
|
if (!zenState.task && !minimized) {
|
|
339
399
|
applyStatus(ctx, root, configDir, runs);
|
|
340
400
|
return;
|
|
@@ -344,6 +404,7 @@ export function reportRuns(ctx: ExtensionContext, root: string, configDir: strin
|
|
|
344
404
|
|
|
345
405
|
export function clearStatus(ctx: ExtensionContext): void {
|
|
346
406
|
leaveZen(ctx);
|
|
407
|
+
widgetMounted = false;
|
|
347
408
|
oracleActivity = undefined;
|
|
348
409
|
zenState = { task: undefined, live: [], runs: [] };
|
|
349
410
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -40,9 +40,15 @@ export interface ScoutConfig {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** Subagent kinds with their own settings entry. */
|
|
43
|
-
export const SUBAGENT_KINDS = ["designer", "backend", "qa", "scout", "researcher"] as const;
|
|
43
|
+
export const SUBAGENT_KINDS = ["designer", "backend", "qa", "scout", "researcher", "quickfix", "planner"] as const;
|
|
44
44
|
export type SubagentKind = (typeof SUBAGENT_KINDS)[number];
|
|
45
45
|
|
|
46
|
+
/** Lobby agents that run outside the workflow: direct quick fixes and the task planner. */
|
|
47
|
+
export type LobbyAgentKind = "quickfix" | "planner";
|
|
48
|
+
|
|
49
|
+
/** Settings kinds a workflow run (domain + role) can draw from. */
|
|
50
|
+
export type WorkflowProfileKind = Domain | "scout" | "researcher";
|
|
51
|
+
|
|
46
52
|
export interface WorkflowConfig {
|
|
47
53
|
maxReviewIterations: number;
|
|
48
54
|
maxParallelScouts: number;
|
|
@@ -69,13 +75,34 @@ export interface KnowledgeConfig {
|
|
|
69
75
|
scratchpadMaxChars: number;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/** Planning panel seats: each domain agent, plus the researcher, questions the user in plan mode. */
|
|
79
|
+
export const PANEL_MEMBERS = ["backend", "designer", "qa", "researcher"] as const;
|
|
80
|
+
export type PanelMember = (typeof PANEL_MEMBERS)[number];
|
|
81
|
+
|
|
82
|
+
export function isPanelMember(value: string): value is PanelMember {
|
|
83
|
+
return (PANEL_MEMBERS as readonly string[]).includes(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The full-screen lobby. */
|
|
87
|
+
export interface LobbyConfig {
|
|
88
|
+
/** Open by itself when this session starts or resumes a task. */
|
|
89
|
+
autoOpen: boolean;
|
|
90
|
+
/** Who sits on the planning panel next to the oracle, until toggled in the Plan tab. */
|
|
91
|
+
planningPanel: PanelMember[];
|
|
92
|
+
}
|
|
93
|
+
|
|
72
94
|
export interface BotLobbyConfig {
|
|
73
95
|
master: AgentModelConfig;
|
|
74
96
|
agents: Record<"designer" | "backend" | "qa", AgentModelConfig>;
|
|
75
97
|
scout: ScoutConfig;
|
|
76
98
|
researcher: AgentModelConfig;
|
|
99
|
+
/** Direct quick fixes from the lobby: no scouting, planning or review. */
|
|
100
|
+
quickFix: AgentModelConfig;
|
|
101
|
+
/** The task planner that grills the user until a plan is clear; `timeoutMs` bounds one turn. */
|
|
102
|
+
planner: AgentModelConfig;
|
|
77
103
|
workflow: WorkflowConfig;
|
|
78
104
|
knowledge: KnowledgeConfig;
|
|
105
|
+
lobby: LobbyConfig;
|
|
79
106
|
}
|
|
80
107
|
|
|
81
108
|
export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
@@ -87,6 +114,8 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
87
114
|
},
|
|
88
115
|
scout: { model: INHERIT_MODEL, timeoutMs: 8 * 60 * 1000 },
|
|
89
116
|
researcher: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
|
|
117
|
+
quickFix: { model: INHERIT_MODEL, thinking: "low", instructions: "", timeoutMs: 10 * 60 * 1000 },
|
|
118
|
+
planner: { model: INHERIT_MODEL, thinking: "high", instructions: "", timeoutMs: 5 * 60 * 1000 },
|
|
90
119
|
workflow: {
|
|
91
120
|
maxReviewIterations: 2,
|
|
92
121
|
maxParallelScouts: 3,
|
|
@@ -106,6 +135,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
|
|
|
106
135
|
scratchpadMaxParagraphs: 4,
|
|
107
136
|
scratchpadMaxChars: 2000,
|
|
108
137
|
},
|
|
138
|
+
lobby: { autoOpen: true, planningPanel: [...PANEL_MEMBERS] },
|
|
109
139
|
};
|
|
110
140
|
|
|
111
141
|
function positive(value: unknown): number | undefined {
|
|
@@ -129,6 +159,17 @@ function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig
|
|
|
129
159
|
};
|
|
130
160
|
}
|
|
131
161
|
|
|
162
|
+
function normalizeLobby(value: unknown): LobbyConfig {
|
|
163
|
+
const source = value as { autoOpen?: unknown; planningPanel?: unknown } | undefined;
|
|
164
|
+
const panel = Array.isArray(source?.planningPanel)
|
|
165
|
+
? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
|
|
166
|
+
: [...DEFAULT_CONFIG.lobby.planningPanel];
|
|
167
|
+
return {
|
|
168
|
+
autoOpen: typeof source?.autoOpen === "boolean" ? source.autoOpen : DEFAULT_CONFIG.lobby.autoOpen,
|
|
169
|
+
planningPanel: PANEL_MEMBERS.filter((member) => panel.includes(member)),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
132
173
|
/** Deep-merge user config over defaults, keeping unknown keys out. */
|
|
133
174
|
export function resolveConfig(partial: unknown): BotLobbyConfig {
|
|
134
175
|
const src = (partial ?? {}) as Record<string, unknown>;
|
|
@@ -144,8 +185,11 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
|
|
|
144
185
|
},
|
|
145
186
|
scout: normalizeScout(src.scout as Partial<ScoutConfig> | undefined),
|
|
146
187
|
researcher: normalizeAgent(DEFAULT_CONFIG.researcher, src.researcher as Partial<AgentModelConfig> | undefined),
|
|
188
|
+
quickFix: normalizeAgent(DEFAULT_CONFIG.quickFix, src.quickFix as Partial<AgentModelConfig> | undefined),
|
|
189
|
+
planner: normalizeAgent(DEFAULT_CONFIG.planner, src.planner as Partial<AgentModelConfig> | undefined),
|
|
147
190
|
workflow,
|
|
148
191
|
knowledge,
|
|
192
|
+
lobby: normalizeLobby(src.lobby),
|
|
149
193
|
};
|
|
150
194
|
}
|
|
151
195
|
|
|
@@ -157,7 +201,7 @@ export function hasScoutThinking(partial: unknown): boolean {
|
|
|
157
201
|
|
|
158
202
|
/** What one subagent run uses: model (undefined = not configured), thinking and time limit. */
|
|
159
203
|
export interface AgentProfile {
|
|
160
|
-
kind:
|
|
204
|
+
kind: WorkflowProfileKind;
|
|
161
205
|
model?: string;
|
|
162
206
|
thinking: string;
|
|
163
207
|
timeoutMs: number;
|
|
@@ -165,7 +209,7 @@ export interface AgentProfile {
|
|
|
165
209
|
}
|
|
166
210
|
|
|
167
211
|
/** The settings entry a domain/role run draws from. */
|
|
168
|
-
export function profileKind(domain: Domain, role: Role):
|
|
212
|
+
export function profileKind(domain: Domain, role: Role): WorkflowProfileKind {
|
|
169
213
|
if (role === "scout") return "scout";
|
|
170
214
|
if (role === "researcher") return "researcher";
|
|
171
215
|
return domain;
|
|
@@ -192,6 +236,27 @@ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role)
|
|
|
192
236
|
return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
|
|
193
237
|
}
|
|
194
238
|
|
|
239
|
+
/** The settings entry of a lobby agent (quick fix or planner). */
|
|
240
|
+
export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind): AgentModelConfig {
|
|
241
|
+
return kind === "quickfix" ? config.quickFix : config.planner;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
|
|
245
|
+
export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
|
|
246
|
+
const entry = lobbyAgentConfig(config, kind);
|
|
247
|
+
return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A planning panel seat's profile: the domain's (or the researcher's) model,
|
|
252
|
+
* thinking and custom instructions, bounded by the planner's per-turn limit.
|
|
253
|
+
*/
|
|
254
|
+
export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
|
|
255
|
+
const entry = member === "researcher" ? config.researcher : config.agents[member];
|
|
256
|
+
const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
|
|
257
|
+
return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions };
|
|
258
|
+
}
|
|
259
|
+
|
|
195
260
|
/** Resolves the model, thinking and time limit one subagent run uses. */
|
|
196
261
|
export type ProfileResolver = (domain: Domain, role: Role) => AgentProfile;
|
|
197
262
|
|
package/src/schemas/findings.ts
CHANGED
|
@@ -111,6 +111,10 @@ export interface AgentRun {
|
|
|
111
111
|
finishedAt?: string;
|
|
112
112
|
/** Short target of the activity in flight: a file, command head or pattern. */
|
|
113
113
|
detail?: string;
|
|
114
|
+
/** The tool call in flight in plain words (`reading users.ts`); feeds the lobby's activity log. */
|
|
115
|
+
step?: string;
|
|
116
|
+
/** The agent's latest finished thought, bounded; the lobby shows it in its thinking pane. */
|
|
117
|
+
thought?: string;
|
|
114
118
|
/** Assistant turns and tool calls so far. */
|
|
115
119
|
turns?: number;
|
|
116
120
|
tools?: number;
|
|
@@ -121,6 +125,8 @@ export interface AgentRun {
|
|
|
121
125
|
noteKind?: "info" | "warning";
|
|
122
126
|
/** Model that actually served the run. */
|
|
123
127
|
model?: string;
|
|
128
|
+
/** Thinking level the run was started with. */
|
|
129
|
+
thinking?: string;
|
|
124
130
|
/** Killed by the stall watchdog after going silent. */
|
|
125
131
|
stalled?: boolean;
|
|
126
132
|
/** Asked to wrap up before its deadline; the report may be partial. */
|
package/src/schemas/task.ts
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending tasks: plans the user worked out with the planner (optionally from a
|
|
3
|
+
* GitHub issue) and saved for later. They are not bot-lobby tasks yet, so no
|
|
4
|
+
* session owns them and the workflow never sees them; starting one creates a
|
|
5
|
+
* real task in the session that starts it. One file per entry, so two sessions
|
|
6
|
+
* saving at once cannot overwrite each other.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { writeFileEnsured } from "../knowledge/store.ts";
|
|
11
|
+
import { dataRoot } from "./project.ts";
|
|
12
|
+
import { taskSlug } from "./persistence.ts";
|
|
13
|
+
|
|
14
|
+
export interface IssueRef {
|
|
15
|
+
number: number;
|
|
16
|
+
title: string;
|
|
17
|
+
url?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PlannedTask {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
/** The agreed plan in Markdown; it becomes the task request when started. */
|
|
24
|
+
brief: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
status: "pending" | "started";
|
|
28
|
+
issue?: IssueRef;
|
|
29
|
+
/** The bot-lobby task started from this entry. */
|
|
30
|
+
startedTaskId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function backlogDir(root: string, configDir: string): string {
|
|
34
|
+
return join(dataRoot(root, configDir), "backlog");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function entryPath(root: string, configDir: string, id: string): string {
|
|
38
|
+
return join(backlogDir(root, configDir), `${id}.json`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPlannedTask(value: unknown): value is PlannedTask {
|
|
42
|
+
const entry = value as Partial<PlannedTask> | undefined;
|
|
43
|
+
return Boolean(entry && typeof entry.id === "string" && typeof entry.title === "string" && typeof entry.brief === "string");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Save a new pending task; the id is `PLAN-<slug>` with a numeric suffix when taken. */
|
|
47
|
+
export function savePlannedTask(
|
|
48
|
+
root: string,
|
|
49
|
+
configDir: string,
|
|
50
|
+
input: { title: string; brief: string; issue?: IssueRef },
|
|
51
|
+
now = new Date(),
|
|
52
|
+
): PlannedTask {
|
|
53
|
+
const title = input.title.trim() || "planned task";
|
|
54
|
+
const brief = input.brief.trim();
|
|
55
|
+
if (!brief) throw new Error("a planned task needs a plan");
|
|
56
|
+
const base = `PLAN-${taskSlug(title, 32) || now.getTime().toString(36)}`;
|
|
57
|
+
let id = base;
|
|
58
|
+
for (let n = 2; existsSync(entryPath(root, configDir, id)); n++) id = `${base}-${n}`;
|
|
59
|
+
const at = now.toISOString();
|
|
60
|
+
const entry: PlannedTask = { id, title, brief, createdAt: at, updatedAt: at, status: "pending", ...(input.issue ? { issue: input.issue } : {}) };
|
|
61
|
+
writeFileEnsured(entryPath(root, configDir, id), JSON.stringify(entry, null, 2));
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadPlannedTask(root: string, configDir: string, id: string): PlannedTask | undefined {
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(readFileSync(entryPath(root, configDir, id), "utf8")) as unknown;
|
|
68
|
+
return isPlannedTask(value) ? value : undefined;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every saved entry, pending first, newest first within each group; unreadable files are skipped. */
|
|
75
|
+
export function listPlannedTasks(root: string, configDir: string): PlannedTask[] {
|
|
76
|
+
const dir = backlogDir(root, configDir);
|
|
77
|
+
if (!existsSync(dir)) return [];
|
|
78
|
+
const entries: PlannedTask[] = [];
|
|
79
|
+
for (const file of readdirSync(dir)) {
|
|
80
|
+
if (!file.endsWith(".json")) continue;
|
|
81
|
+
const entry = loadPlannedTask(root, configDir, file.slice(0, -".json".length));
|
|
82
|
+
if (entry) entries.push(entry);
|
|
83
|
+
}
|
|
84
|
+
return entries.sort((a, b) => {
|
|
85
|
+
if (a.status !== b.status) return a.status === "pending" ? -1 : 1;
|
|
86
|
+
return b.updatedAt.localeCompare(a.updatedAt);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function markPlannedTaskStarted(root: string, configDir: string, id: string, taskId: string, now = new Date()): PlannedTask | undefined {
|
|
91
|
+
const entry = loadPlannedTask(root, configDir, id);
|
|
92
|
+
if (!entry) return undefined;
|
|
93
|
+
const next: PlannedTask = { ...entry, status: "started", startedTaskId: taskId, updatedAt: now.toISOString() };
|
|
94
|
+
writeFileEnsured(entryPath(root, configDir, id), JSON.stringify(next, null, 2));
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function discardPlannedTask(root: string, configDir: string, id: string): void {
|
|
99
|
+
rmSync(entryPath(root, configDir, id), { force: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The request a started task carries: the agreed plan, plus the issue it came from. */
|
|
103
|
+
export function plannedTaskRequest(entry: PlannedTask): string {
|
|
104
|
+
const source = entry.issue ? `\n\nFrom GitHub issue #${entry.issue.number}: ${entry.issue.title}${entry.issue.url ? ` (${entry.issue.url})` : ""}` : "";
|
|
105
|
+
return `${entry.title}\n\nAgreed plan (from the planning session):\n${entry.brief}${source}`;
|
|
106
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan comments: notes the user leaves on a task's approved plan (or its
|
|
3
|
+
* proposal) from the lobby, in any pi session. They live beside the task as an
|
|
4
|
+
* append-only JSON-lines log, never inside state.json, because the owning
|
|
5
|
+
* session rewrites state.json at the end of every workflow step and would
|
|
6
|
+
* silently drop a comment written by another session in the meantime.
|
|
7
|
+
*
|
|
8
|
+
* Each line is one event: a comment, its delivery to the owning Master, or the
|
|
9
|
+
* Master addressing it with an amended plan. Reading folds the events.
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { taskDir } from "../knowledge/paths.ts";
|
|
14
|
+
import { dataRoot } from "./project.ts";
|
|
15
|
+
|
|
16
|
+
export type CommentStatus = "open" | "delivered" | "addressed";
|
|
17
|
+
|
|
18
|
+
export interface PlanComment {
|
|
19
|
+
id: string;
|
|
20
|
+
taskId: string;
|
|
21
|
+
text: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
/** The pi session that wrote the comment, when known. */
|
|
24
|
+
by?: string;
|
|
25
|
+
status: CommentStatus;
|
|
26
|
+
deliveredAt?: string;
|
|
27
|
+
addressedAt?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type CommentEvent =
|
|
31
|
+
| { kind: "comment"; id: string; text: string; at: string; by?: string }
|
|
32
|
+
| { kind: "delivered"; id: string; at: string }
|
|
33
|
+
| { kind: "addressed"; id: string; at: string };
|
|
34
|
+
|
|
35
|
+
/** Longest comment kept; the Master gets it verbatim. */
|
|
36
|
+
export const MAX_COMMENT_CHARS = 2000;
|
|
37
|
+
|
|
38
|
+
export function commentsPath(root: string, configDir: string, taskId: string): string {
|
|
39
|
+
return join(taskDir(dataRoot(root, configDir), taskId), "comments.jsonl");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function append(path: string, event: CommentEvent): void {
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readEvents(path: string): CommentEvent[] {
|
|
48
|
+
if (!existsSync(path)) return [];
|
|
49
|
+
let text: string;
|
|
50
|
+
try {
|
|
51
|
+
text = readFileSync(path, "utf8");
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const events: CommentEvent[] = [];
|
|
56
|
+
for (const line of text.split("\n")) {
|
|
57
|
+
if (!line.trim()) continue;
|
|
58
|
+
try {
|
|
59
|
+
const event = JSON.parse(line) as CommentEvent;
|
|
60
|
+
if (event && typeof event.id === "string" && typeof event.kind === "string") events.push(event);
|
|
61
|
+
} catch {
|
|
62
|
+
// A torn line from a crashed writer is skipped, never fatal.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return events;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fold the event log into comments, oldest first. */
|
|
69
|
+
export function foldComments(taskId: string, events: readonly CommentEvent[]): PlanComment[] {
|
|
70
|
+
const byId = new Map<string, PlanComment>();
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
if (event.kind === "comment") {
|
|
73
|
+
if (byId.has(event.id)) continue;
|
|
74
|
+
byId.set(event.id, { id: event.id, taskId, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}), status: "open" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const comment = byId.get(event.id);
|
|
78
|
+
if (!comment) continue;
|
|
79
|
+
if (event.kind === "delivered" && comment.status === "open") {
|
|
80
|
+
comment.status = "delivered";
|
|
81
|
+
comment.deliveredAt = event.at;
|
|
82
|
+
} else if (event.kind === "addressed" && comment.status !== "addressed") {
|
|
83
|
+
comment.status = "addressed";
|
|
84
|
+
comment.addressedAt = event.at;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...byId.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readPlanComments(root: string, configDir: string, taskId: string): PlanComment[] {
|
|
91
|
+
return foldComments(taskId, readEvents(commentsPath(root, configDir, taskId)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Record a new comment; blank text is refused. */
|
|
95
|
+
export function addPlanComment(root: string, configDir: string, taskId: string, text: string, by?: string, now = new Date()): PlanComment {
|
|
96
|
+
const body = text.trim().slice(0, MAX_COMMENT_CHARS);
|
|
97
|
+
if (!body) throw new Error("a plan comment needs some text");
|
|
98
|
+
const at = now.toISOString();
|
|
99
|
+
const id = `C-${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
100
|
+
append(commentsPath(root, configDir, taskId), { kind: "comment", id, text: body, at, ...(by ? { by } : {}) });
|
|
101
|
+
return { id, taskId, text: body, createdAt: at, ...(by ? { by } : {}), status: "open" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function markCommentsDelivered(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
|
|
105
|
+
const path = commentsPath(root, configDir, taskId);
|
|
106
|
+
for (const id of ids) append(path, { kind: "delivered", id, at: now.toISOString() });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function markCommentsAddressed(root: string, configDir: string, taskId: string, ids: readonly string[], now = new Date()): void {
|
|
110
|
+
const path = commentsPath(root, configDir, taskId);
|
|
111
|
+
for (const id of ids) append(path, { kind: "addressed", id, at: now.toISOString() });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Comments the owning Master has not been told about yet. */
|
|
115
|
+
export function undeliveredComments(comments: readonly PlanComment[]): PlanComment[] {
|
|
116
|
+
return comments.filter((comment) => comment.status === "open");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Comments the plan does not reflect yet: new or delivered but not addressed. */
|
|
120
|
+
export function pendingComments(comments: readonly PlanComment[]): PlanComment[] {
|
|
121
|
+
return comments.filter((comment) => comment.status !== "addressed");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The message the owning Master receives for new comments: plan comments ask
|
|
126
|
+
* for an amended plan, comments before a plan exists ask for a new proposal.
|
|
127
|
+
*/
|
|
128
|
+
export function commentMessage(taskId: string, comments: readonly PlanComment[], hasPlan: boolean): string {
|
|
129
|
+
const noun = comments.length === 1 ? "a comment" : `${comments.length} comments`;
|
|
130
|
+
const target = hasPlan ? "the approved plan" : "the proposal";
|
|
131
|
+
const lines = comments.map((comment) => `- ${comment.text.replace(/\s*\n\s*/g, " ")}`);
|
|
132
|
+
const ask = hasPlan
|
|
133
|
+
? "Amend the plan to address them: call orchestrate action=plan with the full revised plan (it replaces the current one and marks these comments addressed), then continue the work. If a comment needs no change, say why."
|
|
134
|
+
: "Take them into account: revise the proposal and call orchestrate action=propose again.";
|
|
135
|
+
return [`The user left ${noun} on ${target} of ${taskId} from the lobby:`, ...lines, "", ask].join("\n");
|
|
136
|
+
}
|