@a-t-h-i/bot-lobby 0.6.1 → 0.6.2
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 +177 -834
- package/package.json +1 -1
- package/prompts/master.md +17 -0
- package/prompts/panel.md +3 -1
- package/prompts/planner.md +14 -0
- package/prompts/quickfix.md +3 -1
- package/prompts/scout.md +3 -0
- package/prompts/worker.md +3 -0
- package/src/classifier/answers.ts +110 -0
- package/src/classifier/classifier.ts +171 -0
- package/src/classifier/client.ts +239 -0
- package/src/classifier/effort.ts +143 -0
- package/src/classifier/files.ts +427 -0
- package/src/classifier/hosts.ts +157 -0
- package/src/classifier/instance.ts +98 -0
- package/src/classifier/limits.ts +37 -0
- package/src/classifier/seats.ts +102 -0
- package/src/classifier/tools.ts +58 -0
- package/src/classifier/triage.ts +203 -0
- package/src/execution/agent-runner.ts +5 -0
- package/src/index.ts +6 -0
- package/src/lobby/planner.ts +305 -27
- package/src/lobby/quickfix.ts +120 -8
- package/src/lobby/runtime.ts +12 -1
- package/src/lobby/tabs/metrics.ts +28 -1
- package/src/lobby/tabs/plan.ts +27 -4
- package/src/lobby/tabs/quickfix.ts +5 -1
- package/src/lobby/view.ts +28 -4
- package/src/master/master.ts +95 -29
- package/src/pi/commands.ts +4 -2
- package/src/pi/events.ts +11 -2
- package/src/pi/run-summary.ts +3 -1
- package/src/pi/settings-ui.ts +138 -2
- package/src/pi/start-task.ts +4 -0
- package/src/pi/tools.ts +7 -2
- package/src/schemas/configuration.ts +125 -1
- package/src/schemas/findings.ts +4 -0
- package/src/schemas/task.ts +22 -0
- package/src/state/metrics.ts +74 -1
- package/src/workflow/workflow.ts +49 -1
package/src/lobby/quickfix.ts
CHANGED
|
@@ -10,13 +10,18 @@ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } fr
|
|
|
10
10
|
import { describeToolCall } from "../pi/activity.ts";
|
|
11
11
|
import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
|
|
12
12
|
import type { LobbyFeed } from "./feed.ts";
|
|
13
|
+
import type { FileHinter } from "../classifier/files.ts";
|
|
14
|
+
import type { Classifier } from "../classifier/classifier.ts";
|
|
15
|
+
import { quickFixSize } from "../classifier/triage.ts";
|
|
16
|
+
import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
|
|
13
17
|
|
|
14
18
|
/** A quick fix edits code, so it gets the full coding tool set. */
|
|
15
19
|
export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
16
20
|
|
|
17
21
|
export const QUICK_FIX_SOURCE = "QUICK FIX";
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
/** `held`: the classifier judged the request a task, not a quick fix; it waits for you (r runs it anyway, t makes it a task). */
|
|
24
|
+
export type QuickFixStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout" | "held";
|
|
20
25
|
|
|
21
26
|
export interface QuickFixStep {
|
|
22
27
|
at: number;
|
|
@@ -39,6 +44,13 @@ export interface QuickFixJob {
|
|
|
39
44
|
report?: string;
|
|
40
45
|
error?: string;
|
|
41
46
|
usage?: { input: number; output: number; cost: number; turns: number };
|
|
47
|
+
/** Why the job was held, or what became of it. */
|
|
48
|
+
note?: string;
|
|
49
|
+
/** Run even if the classifier judges it large (r on a held job). */
|
|
50
|
+
force?: boolean;
|
|
51
|
+
/** The classifier routed it down (`trivial 0.88: p/big · low → p/cheap · low`); cleared when it re-ran on the configured profile. */
|
|
52
|
+
route?: string;
|
|
53
|
+
routedFrom?: string;
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export interface QuickFixProfile {
|
|
@@ -61,6 +73,12 @@ export interface QuickFixDeps {
|
|
|
61
73
|
/** Called after every change so the lobby can repaint. */
|
|
62
74
|
onChange?: () => void;
|
|
63
75
|
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
76
|
+
/** Likely files for the prompt, and the lookup tool, while the classifier's file hints are on. */
|
|
77
|
+
hints?: FileHinter;
|
|
78
|
+
/** Holds a request it judges large (a task, not a quick fix) instead of spending a run on it. */
|
|
79
|
+
classifier?: Classifier;
|
|
80
|
+
/** Lowers thinking or the model for a request the classifier judges simple or trivial. */
|
|
81
|
+
effort?: EffortRouter;
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
export const MAX_JOBS = 30;
|
|
@@ -120,6 +138,30 @@ export class QuickFixQueue {
|
|
|
120
138
|
return true;
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
/** Run a held job anyway: it goes back in the queue and is not sized again. */
|
|
142
|
+
runAnyway(id: string): boolean {
|
|
143
|
+
const job = this.jobs.find((entry) => entry.id === id);
|
|
144
|
+
if (!job || job.status !== "held") return false;
|
|
145
|
+
job.status = "queued";
|
|
146
|
+
job.force = true;
|
|
147
|
+
delete job.note;
|
|
148
|
+
delete job.startedAt;
|
|
149
|
+
delete job.finishedAt;
|
|
150
|
+
this.changed();
|
|
151
|
+
this.pump();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A held job that became a task: it leaves the queue with a note. */
|
|
156
|
+
movedToTask(id: string): boolean {
|
|
157
|
+
const job = this.jobs.find((entry) => entry.id === id);
|
|
158
|
+
if (!job || job.status !== "held") return false;
|
|
159
|
+
job.status = "cancelled";
|
|
160
|
+
job.note = "started as a task in a new session";
|
|
161
|
+
this.changed();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
123
165
|
/** Abort everything (session shutdown). */
|
|
124
166
|
cancelAll(): void {
|
|
125
167
|
for (const job of this.jobs) if (job.status === "queued") job.status = "cancelled";
|
|
@@ -162,17 +204,23 @@ export class QuickFixQueue {
|
|
|
162
204
|
job.startedAt = Date.now();
|
|
163
205
|
job.thinking = profile.thinking;
|
|
164
206
|
if (profile.model) job.model = profile.model;
|
|
165
|
-
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
166
207
|
this.changed();
|
|
208
|
+
const held = job.force ? undefined : await this.tooLarge(job, controller.signal);
|
|
209
|
+
if (held) {
|
|
210
|
+
this.controllers.delete(job.id);
|
|
211
|
+
return this.hold(job, held);
|
|
212
|
+
}
|
|
213
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
167
214
|
try {
|
|
168
|
-
const
|
|
215
|
+
const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
|
|
216
|
+
const attempt = (model: string | undefined, thinking: string) => runPiAgent(
|
|
169
217
|
{
|
|
170
218
|
cwd: this.deps.cwd,
|
|
171
|
-
task: job.prompt,
|
|
219
|
+
task: likely ? `${job.prompt}\n\n${likely}` : job.prompt,
|
|
172
220
|
systemPrompt: quickFixPrompt(profile.instructions),
|
|
173
|
-
tools: QUICK_FIX_TOOLS,
|
|
174
|
-
model
|
|
175
|
-
thinking
|
|
221
|
+
tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
|
|
222
|
+
model,
|
|
223
|
+
thinking,
|
|
176
224
|
timeoutMs: profile.timeoutMs,
|
|
177
225
|
signal: controller.signal,
|
|
178
226
|
stallTimeoutMs: this.deps.stallTimeoutMs,
|
|
@@ -181,6 +229,27 @@ export class QuickFixQueue {
|
|
|
181
229
|
},
|
|
182
230
|
this.deps.runProcess ?? spawnPiProcess,
|
|
183
231
|
);
|
|
232
|
+
let result;
|
|
233
|
+
if (route) {
|
|
234
|
+
job.route = routeLabel(route);
|
|
235
|
+
job.routedFrom = profileLabel(route.from);
|
|
236
|
+
job.thinking = route.thinking;
|
|
237
|
+
if (route.model) job.model = route.model;
|
|
238
|
+
this.changed();
|
|
239
|
+
result = await attempt(route.model, route.thinking);
|
|
240
|
+
if (fellShort(result) && !controller.signal.aborted) {
|
|
241
|
+
// The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
|
|
242
|
+
appendMetrics(this.deps.root, this.deps.configDir, [{ ...quickFixMetric({ ...job, status: result.status, finishedAt: Date.now(), usage: result.usage, ...(result.model ? { model: result.model } : {}) }), id: `${job.id}-routed-${job.startedAt}` }]);
|
|
243
|
+
this.addStep(job, "the routed attempt fell short; running again on the configured model and thinking", Date.now());
|
|
244
|
+
delete job.route;
|
|
245
|
+
delete job.routedFrom;
|
|
246
|
+
job.thinking = profile.thinking;
|
|
247
|
+
if (profile.model) job.model = profile.model;
|
|
248
|
+
result = await attempt(profile.model, profile.thinking);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
result = await attempt(profile.model, profile.thinking);
|
|
252
|
+
}
|
|
184
253
|
job.status = result.status;
|
|
185
254
|
job.report = result.output.trim() || undefined;
|
|
186
255
|
job.error = result.error;
|
|
@@ -195,6 +264,48 @@ export class QuickFixQueue {
|
|
|
195
264
|
}
|
|
196
265
|
}
|
|
197
266
|
|
|
267
|
+
/** Where the classifier routes this request, or undefined. */
|
|
268
|
+
private async route(prompt: string, profile: QuickFixProfile, signal: AbortSignal): Promise<EffortRoute | undefined> {
|
|
269
|
+
try {
|
|
270
|
+
return await this.deps.effort?.route(prompt, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }, { signal });
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Why a request is too large for a quick fix, when the classifier is sure; undefined runs it. */
|
|
277
|
+
private async tooLarge(job: QuickFixJob, signal: AbortSignal): Promise<string | undefined> {
|
|
278
|
+
const jev = this.deps.classifier;
|
|
279
|
+
if (!jev?.enabled("triage")) return undefined;
|
|
280
|
+
try {
|
|
281
|
+
const sized = await quickFixSize(jev, job.prompt, signal);
|
|
282
|
+
if (!sized || sized.size !== "large" || sized.confidence < jev.config.thresholds.quickFixLargeAt) return undefined;
|
|
283
|
+
return `looks like a task (large, ${sized.confidence.toFixed(2)})`;
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Hold a job instead of running it; nothing ran, so nothing lands in the metrics. */
|
|
290
|
+
private hold(job: QuickFixJob, note: string): void {
|
|
291
|
+
job.status = "held";
|
|
292
|
+
job.note = note;
|
|
293
|
+
job.finishedAt = Date.now();
|
|
294
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `held: ${jobTitle(job)} — ${note}; r runs it anyway, t makes it a task`, "warning", job.finishedAt);
|
|
295
|
+
this.deps.notify?.(`bot-lobby quick fix held: ${jobTitle(job)} ${note}`, "warning");
|
|
296
|
+
this.changed();
|
|
297
|
+
this.pump();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The Likely files block for a prompt, or "" (no hints, nothing stands out, out of time). */
|
|
301
|
+
private async likely(prompt: string, signal: AbortSignal): Promise<string> {
|
|
302
|
+
try {
|
|
303
|
+
return (await this.deps.hints?.block(prompt, signal)) ?? "";
|
|
304
|
+
} catch {
|
|
305
|
+
return "";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
198
309
|
private finish(job: QuickFixJob): void {
|
|
199
310
|
job.finishedAt = Date.now();
|
|
200
311
|
for (const step of job.steps) step.pending = false;
|
|
@@ -217,11 +328,12 @@ export function quickFixMetric(job: QuickFixJob): MetricRecord {
|
|
|
217
328
|
agent: QUICK_FIX_SOURCE,
|
|
218
329
|
...(job.model ? { model: job.model } : {}),
|
|
219
330
|
...(job.thinking ? { thinking: job.thinking } : {}),
|
|
220
|
-
status: job.status === "queued" || job.status === "running" ? "failed" : job.status,
|
|
331
|
+
status: job.status === "queued" || job.status === "running" ? "failed" : job.status === "held" ? "cancelled" : job.status,
|
|
221
332
|
startedAt: new Date(started).toISOString(),
|
|
222
333
|
durationMs: Math.max(0, (job.finishedAt ?? started) - started),
|
|
223
334
|
turns: job.usage?.turns || job.turns || undefined,
|
|
224
335
|
tools: job.tools,
|
|
225
336
|
...(job.usage ? { input: job.usage.input, output: job.usage.output, cost: job.usage.cost } : {}),
|
|
337
|
+
...(job.routedFrom ? { routedFrom: job.routedFrom } : {}),
|
|
226
338
|
};
|
|
227
339
|
}
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts
|
|
|
18
18
|
import { livePresence } from "../state/presence.ts";
|
|
19
19
|
import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
|
|
20
20
|
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
21
|
-
import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
21
|
+
import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
22
22
|
import { describeToolCall } from "../pi/activity.ts";
|
|
23
23
|
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
24
24
|
import { panelLines } from "../pi/zen.ts";
|
|
@@ -29,6 +29,8 @@ import { modelLookup } from "../pi/tools.ts";
|
|
|
29
29
|
import { startPlannedTask, startTask } from "../pi/start-task.ts";
|
|
30
30
|
import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
|
|
31
31
|
import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
|
|
32
|
+
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
33
|
+
import { checkThinking } from "../pi/model-support.ts";
|
|
32
34
|
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
33
35
|
import { SessionChats } from "./session-files.ts";
|
|
34
36
|
import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
|
|
@@ -415,6 +417,10 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
|
|
|
415
417
|
profile: () => lobbyProfile(state, "planner"),
|
|
416
418
|
memberProfile: (member) => seatProfile(state, member),
|
|
417
419
|
panel: seats ?? config.lobby.planningPanel,
|
|
420
|
+
maxRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
421
|
+
classifier: classifier(),
|
|
422
|
+
hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
|
|
423
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(state.ctx)(model), thinking).level),
|
|
418
424
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
419
425
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
420
426
|
feed: lobbyFeed,
|
|
@@ -513,6 +519,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
513
519
|
plans: () => listPlannedTasks(state.root, state.configDir),
|
|
514
520
|
comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
|
|
515
521
|
metrics: () => readMetrics(state.root, state.configDir),
|
|
522
|
+
classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
|
|
516
523
|
toOracle: (text) => toOracle(state, text),
|
|
517
524
|
comment: (taskId, text) => addComment(state, taskId, text),
|
|
518
525
|
startPlanned: (plan) => startPlanned(state, plan),
|
|
@@ -524,6 +531,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
524
531
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
525
532
|
answerPanel: () => answerPanel(state),
|
|
526
533
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
534
|
+
planningRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
527
535
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
528
536
|
panels: () => loadConfig().lobby.panels,
|
|
529
537
|
savePanels: (panels) => savePanels(panels),
|
|
@@ -728,6 +736,9 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
728
736
|
notify: (message, level) => {
|
|
729
737
|
if (!state.visible) ctx.ui.notify(message, level);
|
|
730
738
|
},
|
|
739
|
+
hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
|
|
740
|
+
classifier: classifier(),
|
|
741
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
731
742
|
});
|
|
732
743
|
runtime = state;
|
|
733
744
|
lobbyFeed.clear();
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { shortDuration } from "../../text.ts";
|
|
10
10
|
import { tokens } from "../../pi/run-summary.ts";
|
|
11
|
-
import type { GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
|
|
11
|
+
import type { ClassifierSummary, GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
|
|
12
12
|
import { bar, beside, bold, box, fill, fit, meter, notePane, paint, selectRow, sparkline, stackedBar, windowStart, wrap, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
13
13
|
import { sourceColor } from "./home.ts";
|
|
14
14
|
|
|
@@ -25,6 +25,8 @@ export interface MetricsTabInput {
|
|
|
25
25
|
query?: string;
|
|
26
26
|
/** Filled with where the table landed, for the wheel. */
|
|
27
27
|
panes?: PaneLayout;
|
|
28
|
+
/** What the classifier did and spared, when it has run. */
|
|
29
|
+
classifier?: ClassifierSummary;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
interface Column {
|
|
@@ -230,6 +232,27 @@ export function timeShare(records: readonly MetricRecord[], taskTimes: readonly
|
|
|
230
232
|
}
|
|
231
233
|
|
|
232
234
|
/** The metrics dashboard: KPI tiles, the charts, and the full table filling what is left. */
|
|
235
|
+
/** Milliseconds as `180 ms` or `1.2s`: classifier calls are short. */
|
|
236
|
+
function millis(ms: number): string {
|
|
237
|
+
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The classifier's box: its calls and speed, then what its decisions spared. */
|
|
241
|
+
export function classifierLines(summary: ClassifierSummary, width: number, theme?: LobbyTheme): string[] {
|
|
242
|
+
const inner = width - 4;
|
|
243
|
+
const purposes = Object.entries(summary.byPurpose).sort((a, b) => b[1] - a[1]).map(([purpose, count]) => `${purpose} ${count}`).join(" · ");
|
|
244
|
+
const calls = summary.calls > 0
|
|
245
|
+
? [`${summary.calls} call${summary.calls === 1 ? "" : "s"}`, `${percent(summary.ok, summary.calls)} ok`, `p50 ${millis(summary.p50Ms)}`, `p90 ${millis(summary.p90Ms)}`, summary.input > 0 ? `${tokens(summary.input)} tokens read` : "", purposes].filter(Boolean).join(paint(theme, "dim", " · "))
|
|
246
|
+
: paint(theme, "dim", "no calls recorded");
|
|
247
|
+
const spared = [
|
|
248
|
+
`${summary.seatRunsSkipped} seat run${summary.seatRunsSkipped === 1 ? "" : "s"} skipped`,
|
|
249
|
+
`${summary.questionsAnswered} question${summary.questionsAnswered === 1 ? "" : "s"} answered`,
|
|
250
|
+
`${summary.quickFixesHeld} quick fix${summary.quickFixesHeld === 1 ? "" : "es"} held`,
|
|
251
|
+
summary.routed > 0 ? `${summary.routed} run${summary.routed === 1 ? "" : "s"} routed down, ${percent(summary.routedOk, summary.routed)} ok` : "no runs routed",
|
|
252
|
+
].join(paint(theme, "dim", " · "));
|
|
253
|
+
return box(width, 4, [fit(calls, inner), fit(`${paint(theme, "success", "spared")} ${spared}`, inner)], { title: "Classifier (Jev)", theme });
|
|
254
|
+
}
|
|
255
|
+
|
|
233
256
|
export function renderMetrics(input: MetricsTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
234
257
|
if (height <= 0) return [];
|
|
235
258
|
if (input.records.length === 0) {
|
|
@@ -242,6 +265,10 @@ export function renderMetrics(input: MetricsTabInput, width: number, height: num
|
|
|
242
265
|
sections.push(...tileRow(input, width, theme));
|
|
243
266
|
room -= 4;
|
|
244
267
|
}
|
|
268
|
+
if (input.classifier && room >= 14) {
|
|
269
|
+
sections.push(...classifierLines(input.classifier, width, theme));
|
|
270
|
+
room -= 4;
|
|
271
|
+
}
|
|
245
272
|
const chartHeight = Math.min(input.groups.length + 2, 8);
|
|
246
273
|
if (room - chartHeight >= 9) {
|
|
247
274
|
if (width >= 100) {
|
package/src/lobby/tabs/plan.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* said the plan must respect.
|
|
8
8
|
*/
|
|
9
9
|
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
10
|
-
import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed } from "../planner.ts";
|
|
10
|
+
import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed, type RoundMode } from "../planner.ts";
|
|
11
11
|
import type { PlannedTask } from "../../state/backlog.ts";
|
|
12
12
|
import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
13
13
|
import { sourceColor, speakerLine, tailWindow, youLines } from "./home.ts";
|
|
@@ -23,6 +23,10 @@ export interface SeatView {
|
|
|
23
23
|
ready: boolean;
|
|
24
24
|
/** `provider/model · thinking` the seat runs on. */
|
|
25
25
|
profile?: string;
|
|
26
|
+
/** The classifier left it out of the latest round: how likely it judged the seat needed. */
|
|
27
|
+
satOut?: number;
|
|
28
|
+
/** Seated with a key press: it sits every round, whatever the classifier says. */
|
|
29
|
+
pinned?: boolean;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface PlanView {
|
|
@@ -35,6 +39,10 @@ export interface PlanView {
|
|
|
35
39
|
step?: string;
|
|
36
40
|
error?: string;
|
|
37
41
|
turns: number;
|
|
42
|
+
/** Rounds before the oracle finalizes alone; 0 = unlimited. */
|
|
43
|
+
limit?: number;
|
|
44
|
+
/** How the next round will run under the limit. */
|
|
45
|
+
nextMode?: RoundMode;
|
|
38
46
|
seed?: PlannerSeed;
|
|
39
47
|
saved?: PlannedTask;
|
|
40
48
|
title?: string;
|
|
@@ -79,6 +87,8 @@ export interface PlanTabInput {
|
|
|
79
87
|
panes?: PaneLayout;
|
|
80
88
|
/** The key that saves the plan, as the status line names it. */
|
|
81
89
|
saveKey?: string;
|
|
90
|
+
/** The planning round limit before a session exists; 0 = unlimited. */
|
|
91
|
+
limit?: number;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
export const PLAN_COLUMNS_MIN = 100;
|
|
@@ -91,19 +101,28 @@ function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[]
|
|
|
91
101
|
const model = seat.label === ORACLE_LABEL ? input.profile : seat.seated ? seat.profile ?? "" : "not seated";
|
|
92
102
|
return fit(`${name}${paint(theme, seat.seated ? "muted" : "dim", model)}`, width);
|
|
93
103
|
});
|
|
94
|
-
|
|
104
|
+
const bound = input.limit && input.limit > 0 ? ` (at most ${input.limit} rounds; the last one the oracle settles alone)` : "";
|
|
105
|
+
return [...wrap(bold(theme, `Describe a task below and the panel questions you until the plan is clear${bound}.`), width), "", ...rows];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `round 3/5`, `final round 5/5` or `round 6 · revising` under a limit; `round 3` without one. */
|
|
109
|
+
export function roundLabel(turns: number, limit = 0): string {
|
|
110
|
+
if (limit <= 0) return `round ${turns}`;
|
|
111
|
+
if (turns > limit) return `round ${turns} · past the limit, revising`;
|
|
112
|
+
return turns === limit ? `final round ${turns}/${limit}` : `round ${turns}/${limit}`;
|
|
95
113
|
}
|
|
96
114
|
|
|
97
115
|
function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): string {
|
|
98
116
|
const parts: string[] = [];
|
|
99
117
|
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
100
|
-
if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))}
|
|
118
|
+
if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} ${roundLabel(view.turns, view.limit)}`);
|
|
101
119
|
else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — r retries`));
|
|
102
120
|
else if (view.reply?.status === "ready") parts.push(paint(theme, "success", `✓ ready — ${input.saveKey ?? "Ctrl+S"} saves it`));
|
|
103
121
|
else if (view.awaitingAnswers) parts.push(paint(theme, "warning", `● ${count(view.questions.length, "question")} — enter ${view.answeredChunks > 0 ? "resumes" : "answers them"}`));
|
|
104
122
|
if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${count(view.lineComments.length, "comment")} to send`));
|
|
105
123
|
if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
|
|
106
|
-
if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim",
|
|
124
|
+
if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", roundLabel(view.turns, view.limit)));
|
|
125
|
+
if (!view.busy && view.nextMode === "final") parts.push(paint(theme, "warning", "the next round is the last: the oracle settles the rest"));
|
|
107
126
|
return parts.join(paint(theme, "dim", " · "));
|
|
108
127
|
}
|
|
109
128
|
|
|
@@ -111,6 +130,7 @@ function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): st
|
|
|
111
130
|
function seatCell(seat: SeatView, tick: number, theme?: LobbyTheme): string {
|
|
112
131
|
const name = paint(theme, seat.seated ? sourceColor(seat.label) : "dim", seat.label);
|
|
113
132
|
if (!seat.seated) return `${name} ${paint(theme, "dim", "off")}`;
|
|
133
|
+
if (seat.satOut !== undefined && seat.status !== "thinking") return `${name} ${paint(theme, "dim", `sat out · ${seat.satOut.toFixed(2)}`)}`;
|
|
114
134
|
if (seat.status === "thinking") return `${name} ${paint(theme, "accent", spinner(tick))} ${paint(theme, "muted", seat.step ?? "thinking")}`;
|
|
115
135
|
if (seat.status === "failed") return `${name} ${paint(theme, "error", "✗ failed — r retries")}`;
|
|
116
136
|
if (seat.status === "idle") return `${name} ${paint(theme, "dim", "·")}`;
|
|
@@ -159,6 +179,9 @@ export function conversationLines(view: PlanView, width: number, theme?: LobbyTh
|
|
|
159
179
|
}
|
|
160
180
|
lines.push(speakerLine("panel", width, theme, time));
|
|
161
181
|
lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width).map((line) => (line ? ` ${line}` : ""))));
|
|
182
|
+
for (const entry of message.decided ?? []) {
|
|
183
|
+
lines.push(...wrapHanging(` ${paint(theme, "success", "✓")} `, `${paint(theme, sourceColor(entry.from), `[${entry.from}]`)} ${entry.question} → ${bold(theme, entry.answer)} ${paint(theme, "dim", `· decided by the classifier (${entry.probability.toFixed(2)}); comment on the plan to overrule`)}`, width));
|
|
184
|
+
}
|
|
162
185
|
}
|
|
163
186
|
if (query && lines.length === 0) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${query}".`), width);
|
|
164
187
|
return lines;
|
|
@@ -31,6 +31,7 @@ const STATUS_COLORS: Record<QuickFixJob["status"], "accent" | "success" | "error
|
|
|
31
31
|
failed: "error",
|
|
32
32
|
timeout: "error",
|
|
33
33
|
cancelled: "warning",
|
|
34
|
+
held: "warning",
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
function mark(job: QuickFixJob, tick: number, theme?: LobbyTheme): string {
|
|
@@ -38,6 +39,7 @@ function mark(job: QuickFixJob, tick: number, theme?: LobbyTheme): string {
|
|
|
38
39
|
if (job.status === "queued") return paint(theme, "muted", "…");
|
|
39
40
|
if (job.status === "success") return paint(theme, "success", "✓");
|
|
40
41
|
if (job.status === "cancelled") return paint(theme, "warning", "·");
|
|
42
|
+
if (job.status === "held") return paint(theme, "warning", "‖");
|
|
41
43
|
return paint(theme, "error", "✗");
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -60,8 +62,10 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
|
|
|
60
62
|
job.usage?.cost ? `$${job.usage.cost.toFixed(2)}` : "",
|
|
61
63
|
].filter(Boolean);
|
|
62
64
|
const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...wrap(bold(theme, job.prompt), width)];
|
|
65
|
+
if (job.route) lines.push("", ...wrap(paint(theme, "dim", `↓ routed ${job.route}`), width));
|
|
66
|
+
if (job.note) lines.push("", ...wrap(paint(theme, "warning", job.status === "held" ? `‖ Held: ${job.note}. r runs it anyway · t starts it as a task in a new session.` : job.note), width));
|
|
63
67
|
lines.push("", rule(width, "Steps", theme));
|
|
64
|
-
if (job.steps.length === 0) lines.push(paint(theme, "dim", job.status === "queued" ? "Waiting for the quick fix ahead of it." : job.status === "running" ? `${spinner(tick)} starting…` : "No tool calls."));
|
|
68
|
+
if (job.steps.length === 0) lines.push(paint(theme, "dim", job.status === "queued" ? "Waiting for the quick fix ahead of it." : job.status === "running" ? `${spinner(tick)} starting…` : job.status === "held" ? "Not started." : "No tool calls."));
|
|
65
69
|
for (const step of job.steps) {
|
|
66
70
|
const lead = `${paint(theme, "dim", clock(step.at))} ${step.pending && job.status === "running" ? paint(theme, "accent", spinner(tick)) : paint(theme, "dim", "·")} `;
|
|
67
71
|
lines.push(...wrapHanging(lead, step.pending && job.status === "running" ? `${step.text}…` : paint(theme, "muted", step.text), width));
|
package/src/lobby/view.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
|
14
14
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
15
15
|
import type { PlannedTask } from "../state/backlog.ts";
|
|
16
16
|
import type { PlanComment } from "../state/comments.ts";
|
|
17
|
-
import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
|
|
17
|
+
import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
|
|
18
18
|
import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
|
|
19
19
|
import type { ChatEntry, LobbyFeed } from "./feed.ts";
|
|
20
20
|
import type { BackgroundSession } from "./sessions.ts";
|
|
@@ -133,6 +133,8 @@ export interface LobbyHost {
|
|
|
133
133
|
plans(): PlannedTask[];
|
|
134
134
|
comments(taskId: string): PlanComment[];
|
|
135
135
|
metrics(): MetricRecord[];
|
|
136
|
+
/** Classifier calls (kind `classifier`), kept out of the agent tables. */
|
|
137
|
+
classifierMetrics?(): MetricRecord[];
|
|
136
138
|
/** Send text to the oracle, or start a task when none is active; returns a notice. */
|
|
137
139
|
toOracle(text: string): string | undefined;
|
|
138
140
|
comment(taskId: string, text: string): string;
|
|
@@ -148,6 +150,8 @@ export interface LobbyHost {
|
|
|
148
150
|
answerPanel(): Promise<string>;
|
|
149
151
|
/** The seats a new session starts with, from settings. */
|
|
150
152
|
defaultPanel(): readonly PanelMember[];
|
|
153
|
+
/** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
|
|
154
|
+
planningRounds?(): number;
|
|
151
155
|
/** `model · thinking` a panel seat runs on. */
|
|
152
156
|
seatLabel(member: PanelMember): string;
|
|
153
157
|
issues: IssuesState;
|
|
@@ -336,12 +340,13 @@ export class LobbyView implements Component, Focusable {
|
|
|
336
340
|
|
|
337
341
|
/** The Tasks tab lists archived tasks too (v). */
|
|
338
342
|
showArchived = false;
|
|
339
|
-
private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
|
|
343
|
+
private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; at: number } = {
|
|
340
344
|
tasks: [],
|
|
341
345
|
plans: [],
|
|
342
346
|
archived: [],
|
|
343
347
|
comments: new Map(),
|
|
344
348
|
metrics: [],
|
|
349
|
+
classifierMetrics: [],
|
|
345
350
|
at: 0,
|
|
346
351
|
};
|
|
347
352
|
|
|
@@ -431,6 +436,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
431
436
|
archived: this.showArchived ? this.host.archivedTasks() : [],
|
|
432
437
|
comments: new Map(),
|
|
433
438
|
metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
|
|
439
|
+
classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
|
|
434
440
|
at: now,
|
|
435
441
|
};
|
|
436
442
|
if (this.tab === "tasks") {
|
|
@@ -998,6 +1004,17 @@ export class LobbyView implements Component, Focusable {
|
|
|
998
1004
|
return true;
|
|
999
1005
|
}
|
|
1000
1006
|
if (data === "m") return this.openSettings("quickfix"), true;
|
|
1007
|
+
if (data === "r" || data === "t") {
|
|
1008
|
+
const job = this.fixJobs()[this.fixSelected];
|
|
1009
|
+
// Only a held job takes r and t; otherwise the key resumes typing like any other.
|
|
1010
|
+
if (!job || job.status !== "held") return false;
|
|
1011
|
+
if (data === "r" && this.host.quickfix.runAnyway(job.id)) this.say(`running ${job.id} anyway`);
|
|
1012
|
+
if (data === "t") {
|
|
1013
|
+
this.startInNewSession({ request: job.prompt });
|
|
1014
|
+
this.host.quickfix.movedToTask(job.id);
|
|
1015
|
+
}
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1001
1018
|
return false;
|
|
1002
1019
|
case "issues":
|
|
1003
1020
|
return this.issuesCommand(data, enter, escape);
|
|
@@ -1433,7 +1450,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
1433
1450
|
seated = !seats.delete(seat);
|
|
1434
1451
|
if (seated) seats.add(seat);
|
|
1435
1452
|
}
|
|
1436
|
-
this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins" : "leaves
|
|
1453
|
+
this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins the panel from the next round and sits every round" : "leaves the panel from the next round"}`);
|
|
1437
1454
|
return true;
|
|
1438
1455
|
}
|
|
1439
1456
|
if (data === "x" || (escape && session?.busy)) {
|
|
@@ -1901,6 +1918,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
1901
1918
|
break;
|
|
1902
1919
|
case "quickfix":
|
|
1903
1920
|
if (this.host.quickfix.jobs.length > 0) keys.push(["↑↓", "select"], ["x", "cancel"]);
|
|
1921
|
+
if (this.fixJobs()[this.fixSelected]?.status === "held") keys.push(["r", "run anyway"], ["t", "as a task"]);
|
|
1904
1922
|
keys.push(["m", "model"]);
|
|
1905
1923
|
break;
|
|
1906
1924
|
case "issues":
|
|
@@ -1971,6 +1989,7 @@ export class LobbyView implements Component, Focusable {
|
|
|
1971
1989
|
...(session ? { session: this.planView(session) } : {}),
|
|
1972
1990
|
profile: this.host.profileLabel("planner"),
|
|
1973
1991
|
seats: this.seatViews(undefined),
|
|
1992
|
+
limit: this.host.planningRounds?.() ?? 0,
|
|
1974
1993
|
offset: this.planOffset,
|
|
1975
1994
|
focus: this.planFocus,
|
|
1976
1995
|
draftOffset: this.planDraftOffset,
|
|
@@ -2003,7 +2022,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
2003
2022
|
const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
|
|
2004
2023
|
this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
|
|
2005
2024
|
const taskTimes = taskTimesByModel(this.data.tasks, records);
|
|
2006
|
-
|
|
2025
|
+
const jev = summarizeClassifier(this.data.classifierMetrics, records);
|
|
2026
|
+
return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
|
|
2007
2027
|
}
|
|
2008
2028
|
}
|
|
2009
2029
|
}
|
|
@@ -2034,6 +2054,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
2034
2054
|
questions: session?.questions.filter((question) => question.from === MEMBER_LABELS[member]).length ?? 0,
|
|
2035
2055
|
ready: state?.reply?.status === "ready",
|
|
2036
2056
|
profile: this.host.seatLabel(member),
|
|
2057
|
+
...(!state && session?.satOut.has(member) ? { satOut: session.satOut.get(member)! } : {}),
|
|
2058
|
+
...(session?.pins.has(member) ? { pinned: true } : {}),
|
|
2037
2059
|
};
|
|
2038
2060
|
});
|
|
2039
2061
|
return [oracle, ...members];
|
|
@@ -2050,6 +2072,8 @@ export class LobbyView implements Component, Focusable {
|
|
|
2050
2072
|
...(session.step ? { step: session.step } : {}),
|
|
2051
2073
|
...(session.error ? { error: session.error } : {}),
|
|
2052
2074
|
turns: session.turns,
|
|
2075
|
+
limit: session.limit,
|
|
2076
|
+
nextMode: session.nextMode,
|
|
2053
2077
|
...(session.seed ? { seed: session.seed } : {}),
|
|
2054
2078
|
...(session.saved ? { saved: session.saved } : {}),
|
|
2055
2079
|
...(session.title ? { title: session.title } : {}),
|