@a-t-h-i/bot-lobby 0.6.13 → 0.6.15
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 +70 -79
- package/package.json +4 -2
- package/prompts/master.md +3 -2
- package/prompts/qa.md +33 -11
- package/prompts/reviewer.md +5 -2
- package/prompts/worker.md +5 -2
- package/src/ask/relay.ts +1 -2
- package/src/ask/tool.ts +4 -6
- package/src/ask/types.ts +16 -0
- package/src/ask/web.ts +63 -0
- package/src/index.ts +7 -1
- package/src/lobby/ask.ts +4 -5
- package/src/lobby/blocks.ts +57 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +178 -0
- package/src/lobby/keys.ts +10 -41
- package/src/lobby/prompt-hub.ts +30 -77
- package/src/lobby/prompts.ts +8 -12
- package/src/lobby/runtime.ts +49 -310
- package/src/lobby/service.ts +54 -134
- package/src/lobby/task-rows.ts +28 -0
- package/src/pi/commands.ts +14 -20
- package/src/pi/model-settings.ts +112 -0
- package/src/pi/start-task.ts +1 -1
- package/src/pi/tools.ts +6 -7
- package/src/pi/ui.ts +0 -9
- package/src/schemas/configuration.ts +9 -33
- package/src/state/attachments.ts +82 -0
- package/src/webui/api/index.ts +17 -15
- package/src/webui/api/lobby.ts +4 -3
- package/src/webui/api/planner.ts +6 -4
- package/src/webui/api/quickfix.ts +4 -3
- package/src/webui/api/sessions.ts +9 -7
- package/src/webui/api/settings.ts +20 -8
- package/src/webui/api/status.ts +4 -15
- package/src/webui/api/tasks.ts +11 -15
- package/src/webui/command.ts +35 -19
- package/src/webui/dev/fake-service.ts +1 -2
- package/src/webui/dev/fixtures.ts +2 -5
- package/src/webui/events.ts +1 -1
- package/src/webui/protocol.ts +22 -11
- package/src/webui/server.ts +42 -1
- package/src/webui/uploads.ts +68 -0
- package/webui/dist/assets/index-B1PnCyl2.css +2 -0
- package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
- package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/webui/dist/build.json +142 -110
- package/webui/dist/index.html +4 -3
- package/src/ask/dialog.ts +0 -193
- package/src/ask/image.ts +0 -202
- package/src/ask/png.ts +0 -179
- package/src/ask/state.ts +0 -175
- package/src/ask/view.ts +0 -163
- package/src/lobby/layout.ts +0 -496
- package/src/lobby/markdown.ts +0 -121
- package/src/lobby/mini.ts +0 -167
- package/src/lobby/tabs/excalidraw.ts +0 -95
- package/src/lobby/tabs/git.ts +0 -162
- package/src/lobby/tabs/home.ts +0 -544
- package/src/lobby/tabs/issues.ts +0 -73
- package/src/lobby/tabs/knowledge.ts +0 -135
- package/src/lobby/tabs/metrics.ts +0 -300
- package/src/lobby/tabs/plan.ts +0 -262
- package/src/lobby/tabs/quickfix.ts +0 -136
- package/src/lobby/tabs/tasks.ts +0 -412
- package/src/lobby/theme.ts +0 -30
- package/src/lobby/view.ts +0 -3018
- package/src/pi/settings-ui.ts +0 -708
- package/src/width.ts +0 -102
- package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
- package/webui/dist/assets/index-E6MPcbdC.js +0 -80
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Knowledge tab: everything each agent knows about the project — its
|
|
3
|
-
* knowledge, standards, decisions and completed tasks — with the file on the
|
|
4
|
-
* left and its entries on the right, one of them picked. The picked entry can
|
|
5
|
-
* be edited, deleted or commented on; the notes sit right under the entries
|
|
6
|
-
* they are about, as every agent reads them.
|
|
7
|
-
*/
|
|
8
|
-
import type { KnowledgeNote } from "../../knowledge/notes.ts";
|
|
9
|
-
import { AGENT_DIR_NAMES, type KnowledgeAgent } from "../../knowledge/paths.ts";
|
|
10
|
-
import { AGENT_LABELS, type KnowledgeFileInfo, type KnowledgeView } from "../knowledge.ts";
|
|
11
|
-
import { bold, columns, fill, markdownLines, paint, rule, selectRow, spread, split, windowStart, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
|
|
12
|
-
|
|
13
|
-
export interface KnowledgeLayout {
|
|
14
|
-
/** The first line of the entries shown, as the last frame drew them. */
|
|
15
|
-
offset: number;
|
|
16
|
-
/** How many lines the entries take, and how many the pane shows. */
|
|
17
|
-
total: number;
|
|
18
|
-
rows: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface KnowledgeTabInput {
|
|
22
|
-
files: readonly KnowledgeFileInfo[];
|
|
23
|
-
selected: number;
|
|
24
|
-
/** The selected file, read. */
|
|
25
|
-
view?: KnowledgeView;
|
|
26
|
-
/** The picked entry, an index into the file's entries. */
|
|
27
|
-
cursor: number;
|
|
28
|
-
focus: "list" | "detail";
|
|
29
|
-
/** The first line to show, unless the picked entry needs another. */
|
|
30
|
-
offset: number;
|
|
31
|
-
layout: KnowledgeLayout;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export const KNOWLEDGE_COLUMNS_MIN = 90;
|
|
35
|
-
|
|
36
|
-
/** `812`, `1.2k`, `24k`. */
|
|
37
|
-
export function sizeWords(chars: number): string {
|
|
38
|
-
if (chars < 1000) return String(chars);
|
|
39
|
-
const thousands = chars / 1000;
|
|
40
|
-
return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1)}k`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function fileRow(info: KnowledgeFileInfo, width: number, selected: boolean, focused: boolean, theme?: LobbyTheme): string {
|
|
44
|
-
const facts = [
|
|
45
|
-
info.over ? paint(theme, "warning", `${sizeWords(info.chars)} · over`) : paint(theme, "dim", sizeWords(info.chars)),
|
|
46
|
-
info.notes > 0 ? paint(theme, "accent", `✎ ${info.notes}`) : "",
|
|
47
|
-
].filter(Boolean).join(" ");
|
|
48
|
-
return selectRow(theme, spread(selected ? bold(theme, info.label) : info.label, facts, width - 2), width, selected, focused);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** The files, grouped by agent, the selected one in view. */
|
|
52
|
-
export function listLines(files: readonly KnowledgeFileInfo[], selected: number, width: number, height: number, focused: boolean, theme?: LobbyTheme): string[] {
|
|
53
|
-
const lines: string[] = [];
|
|
54
|
-
let at = 0;
|
|
55
|
-
let previous: KnowledgeAgent | undefined;
|
|
56
|
-
files.forEach((info, index) => {
|
|
57
|
-
if (info.agent !== previous) {
|
|
58
|
-
previous = info.agent;
|
|
59
|
-
if (lines.length > 0) lines.push("");
|
|
60
|
-
const notes = files.filter((file) => file.agent === info.agent).reduce((total, file) => total + file.notes, 0);
|
|
61
|
-
lines.push(rule(width, AGENT_LABELS[info.agent], theme, notes > 0 ? `✎ ${notes}` : ""));
|
|
62
|
-
}
|
|
63
|
-
if (index === selected) at = lines.length;
|
|
64
|
-
lines.push(fileRow(info, width, index === selected, focused, theme));
|
|
65
|
-
});
|
|
66
|
-
return lines.slice(windowStart(at, lines.length, height));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** One note under its entry. */
|
|
70
|
-
function noteLines(note: KnowledgeNote, width: number, theme?: LobbyTheme, about?: string): string[] {
|
|
71
|
-
const lead = paint(theme, "accent", " ✎ ");
|
|
72
|
-
const text = about ? `about “${about.replace(/\s+/g, " ").trim().slice(0, 50)}”: ${note.text}` : note.text;
|
|
73
|
-
return wrapHanging(lead, paint(theme, "muted", text), width);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The entries of the open file, each drawn once, with where the picked one sits. */
|
|
77
|
-
export function entryLines(view: KnowledgeView, cursor: number, width: number, focused: boolean, theme?: LobbyTheme): { lines: string[]; from: number; to: number } {
|
|
78
|
-
const lines: string[] = [];
|
|
79
|
-
let from = 0;
|
|
80
|
-
let to = 0;
|
|
81
|
-
let previousEnd = 0;
|
|
82
|
-
view.entries.forEach((entry, index) => {
|
|
83
|
-
if (index > 0 && entry.start > previousEnd) lines.push("");
|
|
84
|
-
previousEnd = entry.end;
|
|
85
|
-
const picked = index === cursor;
|
|
86
|
-
const body = markdownLines(entry.text, Math.max(8, width - 2), theme);
|
|
87
|
-
if (picked) from = lines.length;
|
|
88
|
-
body.forEach((line, row) => {
|
|
89
|
-
const mark = picked ? paint(theme, "accent", row === 0 ? "▸ " : " ") : " ";
|
|
90
|
-
lines.push(picked ? selectRow(theme, `${mark}${line}`, width, true, focused) : `${mark}${line}`);
|
|
91
|
-
});
|
|
92
|
-
for (const note of view.attached.filter((candidate) => candidate.entry === entry.text)) lines.push(...noteLines(note, width, theme));
|
|
93
|
-
if (picked) to = lines.length;
|
|
94
|
-
});
|
|
95
|
-
if (view.detached.length > 0) {
|
|
96
|
-
lines.push("", rule(width, "Notes on entries that changed", theme, String(view.detached.length)));
|
|
97
|
-
for (const note of view.detached) lines.push(...noteLines(note, width, theme, note.entry));
|
|
98
|
-
}
|
|
99
|
-
return { lines, from, to };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function renderKnowledge(input: KnowledgeTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
103
|
-
if (height <= 0) return [];
|
|
104
|
-
if (input.files.length === 0) return fill([rule(width, "Knowledge", theme), ...wrap(paint(theme, "dim", "No knowledge files yet."), width)], height, width);
|
|
105
|
-
const selected = Math.min(Math.max(0, input.selected), input.files.length - 1);
|
|
106
|
-
const wide = width >= KNOWLEDGE_COLUMNS_MIN;
|
|
107
|
-
const [listWidth, detailWidth] = wide ? split(width, 0.34, 3, 44) : [width, width];
|
|
108
|
-
const total = input.files.reduce((sum, info) => sum + info.notes, 0);
|
|
109
|
-
const listPane = fill([rule(listWidth, "Knowledge", theme, total > 0 ? `✎ ${total}` : ""), ...listLines(input.files, selected, listWidth, height - 1, input.focus === "list", theme)], height);
|
|
110
|
-
const info = input.files[selected]!;
|
|
111
|
-
const view = input.view && input.view.agent === info.agent && input.view.file === info.file ? input.view : undefined;
|
|
112
|
-
const title = `${AGENT_DIR_NAMES[info.agent]} · ${info.file}${input.focus === "detail" ? " ◂" : ""}`;
|
|
113
|
-
const head: string[] = [rule(detailWidth, title, theme, view ? `${view.entries.length} entr${view.entries.length === 1 ? "y" : "ies"} · ${sizeWords(view.chars)} chars` : "")];
|
|
114
|
-
if (view?.over) head.push(...wrap(paint(theme, "warning", `over the compaction threshold — ask the oracle to compact ${info.file}`), detailWidth));
|
|
115
|
-
const rows = Math.max(1, height - head.length);
|
|
116
|
-
let body: string[];
|
|
117
|
-
if (!view) body = [paint(theme, "dim", "reading…")];
|
|
118
|
-
else if (view.entries.length === 0 && view.detached.length === 0) body = wrap(paint(theme, "dim", "Nothing here yet. n adds the first entry."), detailWidth);
|
|
119
|
-
else {
|
|
120
|
-
const drawn = entryLines(view, Math.min(input.cursor, Math.max(0, view.entries.length - 1)), detailWidth, input.focus === "detail", theme);
|
|
121
|
-
let start = Math.max(0, Math.min(input.offset, Math.max(0, drawn.lines.length - rows)));
|
|
122
|
-
// The picked entry stays in view (an entry taller than the pane shows its first lines).
|
|
123
|
-
if (input.focus === "detail" && view.entries.length > 0) {
|
|
124
|
-
if (drawn.from < start) start = drawn.from;
|
|
125
|
-
else if (drawn.to > start + rows) start = Math.min(drawn.from, drawn.to - rows);
|
|
126
|
-
}
|
|
127
|
-
input.layout.offset = start;
|
|
128
|
-
input.layout.total = drawn.lines.length;
|
|
129
|
-
input.layout.rows = rows;
|
|
130
|
-
body = drawn.lines.slice(start);
|
|
131
|
-
}
|
|
132
|
-
const detailPane = fill([...head, ...body], height);
|
|
133
|
-
if (!wide) return fill(input.focus === "detail" ? detailPane : listPane, height, width);
|
|
134
|
-
return fill(columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme), height, width);
|
|
135
|
-
}
|
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Metrics tab: how each model performs at each thinking level, as a
|
|
3
|
-
* dashboard — a row of stat tiles, average run time as bars (one hue),
|
|
4
|
-
* success rate as meters with a status icon, where the time goes by agent as
|
|
5
|
-
* a stacked bar with a legend, and the full table underneath (columns drop
|
|
6
|
-
* from the right as the terminal narrows). It covers the Master, every
|
|
7
|
-
* subagent role, quick fixes and planning rounds.
|
|
8
|
-
*/
|
|
9
|
-
import { shortDuration } from "../../text.ts";
|
|
10
|
-
import { tokens } from "../../pi/run-summary.ts";
|
|
11
|
-
import type { ClassifierSummary, GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
|
|
12
|
-
import { bar, beside, bold, box, fill, fit, meter, notePane, paint, selectRow, sparkline, stackedBar, windowStart, wrap, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
13
|
-
import { sourceColor } from "./home.ts";
|
|
14
|
-
import { filterRecords } from "../models/metrics.ts";
|
|
15
|
-
|
|
16
|
-
export { filterRecords };
|
|
17
|
-
|
|
18
|
-
export interface MetricsTabInput {
|
|
19
|
-
groups: readonly MetricGroup[];
|
|
20
|
-
/** Completed tasks' request-to-done time by the oracle's model and thinking. */
|
|
21
|
-
taskTimes?: readonly TaskTimeGroup[];
|
|
22
|
-
records: readonly MetricRecord[];
|
|
23
|
-
stats: TaskStats;
|
|
24
|
-
by: GroupBy;
|
|
25
|
-
sort: SortKey;
|
|
26
|
-
selected: number;
|
|
27
|
-
/** The search in force; records and groups are already narrowed by it. */
|
|
28
|
-
query?: string;
|
|
29
|
-
/** Filled with where the table landed, for the wheel. */
|
|
30
|
-
panes?: PaneLayout;
|
|
31
|
-
/** What the classifier did and spared, when it has run. */
|
|
32
|
-
classifier?: ClassifierSummary;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface Column {
|
|
36
|
-
title: string;
|
|
37
|
-
width: number;
|
|
38
|
-
align: "left" | "right";
|
|
39
|
-
cell: (group: MetricGroup) => string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function percent(part: number, whole: number): string {
|
|
43
|
-
return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function duration(ms: number): string {
|
|
47
|
-
return ms > 0 ? shortDuration(ms) : "—";
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function money(value: number): string {
|
|
51
|
-
if (value <= 0) return "—";
|
|
52
|
-
return value < 0.01 ? "<$0.01" : `$${value.toFixed(2)}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const KIND_LABELS: Record<string, string> = {
|
|
56
|
-
master: "master",
|
|
57
|
-
scout: "scout",
|
|
58
|
-
worker: "worker",
|
|
59
|
-
reviewer: "QA gate",
|
|
60
|
-
researcher: "research",
|
|
61
|
-
quickfix: "quick fix",
|
|
62
|
-
planner: "oracle (plan)",
|
|
63
|
-
panel: "panel",
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/** Columns in priority order; the table keeps as many as the width allows. */
|
|
67
|
-
const COLUMNS: readonly Column[] = [
|
|
68
|
-
{ title: "Runs", width: 5, align: "right", cell: (g) => String(g.runs) },
|
|
69
|
-
{ title: "OK", width: 5, align: "right", cell: (g) => percent(g.successes, g.runs) },
|
|
70
|
-
{ title: "Avg", width: 8, align: "right", cell: (g) => duration(g.avgMs) },
|
|
71
|
-
{ title: "p50", width: 8, align: "right", cell: (g) => duration(g.p50Ms) },
|
|
72
|
-
{ title: "p90", width: 8, align: "right", cell: (g) => duration(g.p90Ms) },
|
|
73
|
-
{ title: "Turns", width: 6, align: "right", cell: (g) => (g.avgTurns > 0 ? g.avgTurns.toFixed(1) : "—") },
|
|
74
|
-
{ title: "Tools", width: 6, align: "right", cell: (g) => (g.avgTools > 0 ? g.avgTools.toFixed(1) : "—") },
|
|
75
|
-
{ title: "Tokens", width: 7, align: "right", cell: (g) => (g.avgTokens > 0 ? tokens(g.avgTokens) : "—") },
|
|
76
|
-
{ title: "tok/s", width: 6, align: "right", cell: (g) => (g.tokensPerSecond > 0 ? g.tokensPerSecond.toFixed(0) : "—") },
|
|
77
|
-
{ title: "$/run", width: 7, align: "right", cell: (g) => money(g.avgCost) },
|
|
78
|
-
{ title: "$ total", width: 8, align: "right", cell: (g) => money(g.totalCost) },
|
|
79
|
-
{ title: "Stalls", width: 6, align: "right", cell: (g) => (g.timeouts > 0 ? String(g.timeouts) : "—") },
|
|
80
|
-
];
|
|
81
|
-
|
|
82
|
-
const MODEL_MIN = 30;
|
|
83
|
-
const THINK_WIDTH = 7;
|
|
84
|
-
const AGENTS_WIDTH = 14;
|
|
85
|
-
|
|
86
|
-
function align(text: string, width: number, side: "left" | "right"): string {
|
|
87
|
-
const cut = fit(text, width).trimEnd();
|
|
88
|
-
return side === "right" ? cut.padStart(width) : fit(cut, width);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The columns that fit: model, thinking and agents first, then metrics in priority order. */
|
|
92
|
-
export function fittedColumns(width: number): Column[] {
|
|
93
|
-
let used = MODEL_MIN + 1 + THINK_WIDTH + 1 + AGENTS_WIDTH;
|
|
94
|
-
const kept: Column[] = [];
|
|
95
|
-
for (const column of COLUMNS) {
|
|
96
|
-
if (used + 1 + column.width > width) break;
|
|
97
|
-
kept.push(column);
|
|
98
|
-
used += 1 + column.width;
|
|
99
|
-
}
|
|
100
|
-
return kept;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function agentsCell(group: MetricGroup, by: GroupBy): string {
|
|
104
|
-
const kinds = group.kinds.map((kind) => KIND_LABELS[kind] ?? kind);
|
|
105
|
-
return by === "model-kind" ? kinds[0] ?? "" : kinds.join(", ");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function tableLines(input: MetricsTabInput, width: number, theme?: LobbyTheme): { header: string; rows: string[] } {
|
|
109
|
-
const kept = fittedColumns(width);
|
|
110
|
-
const fixed = kept.reduce((sum, column) => sum + 1 + column.width, 0);
|
|
111
|
-
const modelWidth = Math.max(MODEL_MIN, width - fixed - 1 - THINK_WIDTH - 1 - AGENTS_WIDTH);
|
|
112
|
-
const head = [fit("Model", modelWidth), fit("Think", THINK_WIDTH), fit(input.by === "model-kind" ? "Agent" : "Agents", AGENTS_WIDTH), ...kept.map((column) => align(column.title, column.width, column.align))].join(" ");
|
|
113
|
-
const rows = input.groups.map((group, index) => {
|
|
114
|
-
const cells = [
|
|
115
|
-
fit(group.model, modelWidth),
|
|
116
|
-
fit(group.thinking, THINK_WIDTH),
|
|
117
|
-
fit(agentsCell(group, input.by), AGENTS_WIDTH),
|
|
118
|
-
...kept.map((column) => align(column.cell(group), column.width, column.align)),
|
|
119
|
-
].join(" ");
|
|
120
|
-
return selectRow(theme, index === input.selected ? bold(theme, cells) : cells, width, index === input.selected);
|
|
121
|
-
});
|
|
122
|
-
return { header: bold(theme, paint(theme, "muted", fit(head, width))), rows };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** A stat tile: the label in its border, the value large, one line of context. */
|
|
126
|
-
function tile(width: number, label: string, value: string, sub: string, theme?: LobbyTheme): string[] {
|
|
127
|
-
return box(width, 4, [bold(theme, paint(theme, "accent", value)), paint(theme, "dim", sub)], { title: label, theme });
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function timed(records: readonly MetricRecord[]): MetricRecord[] {
|
|
131
|
-
return records.filter((record) => record.status !== "cancelled" && record.durationMs > 0);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The KPI row: runs (with recent run times), success, average run, cost, tasks. */
|
|
135
|
-
export function tileRow(input: MetricsTabInput, width: number, theme?: LobbyTheme): string[] {
|
|
136
|
-
const { records, stats } = input;
|
|
137
|
-
const done = timed(records);
|
|
138
|
-
const ok = records.filter((record) => record.status === "success").length;
|
|
139
|
-
const stalls = records.filter((record) => record.stalled || record.status === "timeout").length;
|
|
140
|
-
const cost = records.reduce((sum, record) => sum + (record.cost ?? 0), 0);
|
|
141
|
-
const avg = done.length > 0 ? done.reduce((sum, record) => sum + record.durationMs, 0) / done.length : 0;
|
|
142
|
-
const sorted = done.map((record) => record.durationMs).sort((a, b) => a - b);
|
|
143
|
-
const p90 = sorted.length > 0 ? sorted[Math.min(sorted.length - 1, Math.ceil(0.9 * sorted.length) - 1)]! : 0;
|
|
144
|
-
const count = width >= 100 ? 5 : width >= 60 ? 3 : 2;
|
|
145
|
-
const tileWidth = Math.floor((width - (count - 1)) / count);
|
|
146
|
-
const rate = records.length > 0 ? ok / records.length : 0;
|
|
147
|
-
const tiles = [
|
|
148
|
-
tile(tileWidth, "Runs", String(records.length), sparkline(done.map((record) => record.durationMs), tileWidth - 4) || "no runs yet", theme),
|
|
149
|
-
tile(tileWidth, "Success", records.length > 0 ? `${status(rate).icon} ${Math.round(rate * 100)}%` : "—", `${records.length - ok} failed${stalls > 0 ? ` · ${stalls} stalled` : ""}`, theme),
|
|
150
|
-
tile(tileWidth, "Avg run", duration(avg), `p90 ${duration(p90)}`, theme),
|
|
151
|
-
tile(tileWidth, "Cost", money(cost), records.length > 0 ? `${money(cost / records.length)} per run` : "—", theme),
|
|
152
|
-
tile(tileWidth, "Tasks", `${stats.completed} done`, stats.avgCompleteMs > 0 ? `avg ${shortDuration(stats.avgCompleteMs)} · ${stats.active} active` : `${stats.active} active`, theme),
|
|
153
|
-
].slice(0, count);
|
|
154
|
-
return beside(tiles);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Success thresholds, each with an icon so the state never rests on colour alone. */
|
|
158
|
-
function status(rate: number): { icon: string; color: "success" | "warning" | "error"; word: string } {
|
|
159
|
-
if (rate >= 0.9) return { icon: "✓", color: "success", word: "healthy" };
|
|
160
|
-
if (rate >= 0.7) return { icon: "!", color: "warning", word: "shaky" };
|
|
161
|
-
return { icon: "✗", color: "error", word: "failing" };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Room for `model · thinking` beside a chart's marks: about a third of it, 10 to 26 columns. */
|
|
165
|
-
function chartLabelWidth(inner: number): number {
|
|
166
|
-
return Math.min(26, Math.max(10, Math.floor(inner * 0.34)));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function groupLabel(group: MetricGroup, by: GroupBy): string {
|
|
170
|
-
const agent = by === "model-kind" ? ` · ${agentsCell(group, by)}` : "";
|
|
171
|
-
return `${group.model} · ${group.thinking}${agent}`;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Average run time per model and thinking level: one hue, longest first, value and p90 beside each bar. */
|
|
175
|
-
export function timeBars(groups: readonly MetricGroup[], by: GroupBy, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
176
|
-
const inner = width - 4;
|
|
177
|
-
const rows = [...groups].filter((group) => group.avgMs > 0).sort((a, b) => b.avgMs - a.avgMs).slice(0, Math.max(0, height - 2));
|
|
178
|
-
const labelWidth = chartLabelWidth(inner);
|
|
179
|
-
// The p90 goes first when room runs out.
|
|
180
|
-
const withP90 = inner - labelWidth >= 34;
|
|
181
|
-
const valueWidth = withP90 ? 19 : 7;
|
|
182
|
-
const barWidth = Math.max(4, inner - labelWidth - valueWidth - 2);
|
|
183
|
-
const max = Math.max(...rows.map((group) => group.avgMs), 1);
|
|
184
|
-
const lines = rows.map((group) => {
|
|
185
|
-
const value = `${duration(group.avgMs).padStart(7)}${withP90 ? ` ${paint(theme, "dim", `p90 ${duration(group.p90Ms)}`)}` : ""}`;
|
|
186
|
-
return `${paint(theme, "muted", fit(groupLabel(group, by), labelWidth))} ${fit(paint(theme, "accent", bar(group.avgMs, max, barWidth)), barWidth)} ${value}`;
|
|
187
|
-
});
|
|
188
|
-
return box(width, height, lines.length > 0 ? lines : [paint(theme, "dim", "No timed runs yet.")], { title: "Average run time", right: "per model · thinking", theme });
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Success rate per model and thinking level: a meter, an icon and the number. */
|
|
192
|
-
export function successMeters(groups: readonly MetricGroup[], by: GroupBy, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
193
|
-
const inner = width - 4;
|
|
194
|
-
const rows = [...groups].sort((a, b) => a.successes / a.runs - b.successes / b.runs).slice(0, Math.max(0, height - 2));
|
|
195
|
-
const labelWidth = chartLabelWidth(inner);
|
|
196
|
-
const meterWidth = Math.max(4, inner - labelWidth - 12);
|
|
197
|
-
const lines = rows.map((group) => {
|
|
198
|
-
const rate = group.runs > 0 ? group.successes / group.runs : 0;
|
|
199
|
-
const state = status(rate);
|
|
200
|
-
const paintFill = (text: string) => paint(theme, state.color, text);
|
|
201
|
-
const paintTrack = (text: string) => paint(theme, "borderMuted", text);
|
|
202
|
-
return `${paint(theme, "muted", fit(groupLabel(group, by), labelWidth))} ${meter(rate, meterWidth, paintFill, paintTrack)} ${paint(theme, state.color, state.icon)} ${`${Math.round(rate * 100)}%`.padStart(4)} ${paint(theme, "dim", `${group.runs}`)}`;
|
|
203
|
-
});
|
|
204
|
-
return box(width, height, lines.length > 0 ? lines : [paint(theme, "dim", "No runs yet.")], { title: "Success rate", right: "✓ ≥90% · ! ≥70% · ✗ below", theme });
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Where the time goes: each agent's share of all run time, then how long a task takes per oracle model. */
|
|
208
|
-
export function timeShare(records: readonly MetricRecord[], taskTimes: readonly TaskTimeGroup[] | undefined, width: number, theme?: LobbyTheme): string[] {
|
|
209
|
-
const inner = width - 4;
|
|
210
|
-
const byAgent = new Map<string, { n: number; ms: number }>();
|
|
211
|
-
for (const record of timed(records)) {
|
|
212
|
-
const entry = byAgent.get(record.agent) ?? { n: 0, ms: 0 };
|
|
213
|
-
entry.n += 1;
|
|
214
|
-
entry.ms += record.durationMs;
|
|
215
|
-
byAgent.set(record.agent, entry);
|
|
216
|
-
}
|
|
217
|
-
const agents = [...byAgent.entries()].sort((a, b) => b[1].ms - a[1].ms);
|
|
218
|
-
const total = agents.reduce((sum, [, entry]) => sum + entry.ms, 0);
|
|
219
|
-
const lines: string[] = [];
|
|
220
|
-
if (total > 0) {
|
|
221
|
-
lines.push(stackedBar(agents.map(([agent, entry]) => ({ value: entry.ms, paint: (text: string) => paint(theme, sourceColor(agent), text) })), inner));
|
|
222
|
-
const legend = agents.map(([agent, entry]) => `${paint(theme, sourceColor(agent), "■")} ${agent} ${Math.round((entry.ms / total) * 100)}% ${paint(theme, "dim", `avg ${shortDuration(entry.ms / entry.n)} ×${entry.n}`)}`);
|
|
223
|
-
lines.push(...wrap(legend.join(" "), inner));
|
|
224
|
-
} else lines.push(paint(theme, "dim", "No timed runs yet."));
|
|
225
|
-
if (taskTimes && taskTimes.length > 0) {
|
|
226
|
-
const max = Math.max(...taskTimes.map((group) => group.avgMs), 1);
|
|
227
|
-
const labelWidth = chartLabelWidth(inner);
|
|
228
|
-
const barWidth = Math.max(4, inner - labelWidth - 18);
|
|
229
|
-
lines.push("", bold(theme, "Request to done, by the oracle's model"));
|
|
230
|
-
for (const group of taskTimes) {
|
|
231
|
-
lines.push(`${paint(theme, "muted", fit(`${group.model} · ${group.thinking}`, labelWidth))} ${fit(paint(theme, "accent", bar(group.avgMs, max, barWidth)), barWidth)} ${shortDuration(group.avgMs).padStart(7)} ${paint(theme, "dim", `×${group.tasks}`)}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return box(width, lines.length + 2, lines, { title: "Where the time goes", right: "share of run time by agent", theme });
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** The metrics dashboard: KPI tiles, the charts, and the full table filling what is left. */
|
|
238
|
-
/** Milliseconds as `180 ms` or `1.2s`: classifier calls are short. */
|
|
239
|
-
function millis(ms: number): string {
|
|
240
|
-
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** The classifier's box: its calls and speed, then what its decisions spared. */
|
|
244
|
-
export function classifierLines(summary: ClassifierSummary, width: number, theme?: LobbyTheme): string[] {
|
|
245
|
-
const inner = width - 4;
|
|
246
|
-
const purposes = Object.entries(summary.byPurpose).sort((a, b) => b[1] - a[1]).map(([purpose, count]) => `${purpose} ${count}`).join(" · ");
|
|
247
|
-
const calls = summary.calls > 0
|
|
248
|
-
? [`${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", " · "))
|
|
249
|
-
: paint(theme, "dim", "no calls recorded");
|
|
250
|
-
const spared = [
|
|
251
|
-
`${summary.seatRunsSkipped} seat run${summary.seatRunsSkipped === 1 ? "" : "s"} skipped`,
|
|
252
|
-
`${summary.questionsAnswered} question${summary.questionsAnswered === 1 ? "" : "s"} answered`,
|
|
253
|
-
`${summary.quickFixesHeld} quick fix${summary.quickFixesHeld === 1 ? "" : "es"} held`,
|
|
254
|
-
summary.routed > 0 ? `${summary.routed} run${summary.routed === 1 ? "" : "s"} routed down, ${percent(summary.routedOk, summary.routed)} ok` : "no runs routed",
|
|
255
|
-
].join(paint(theme, "dim", " · "));
|
|
256
|
-
return box(width, 4, [fit(calls, inner), fit(`${paint(theme, "success", "spared")} ${spared}`, inner)], { title: "Classifier (Jev)", theme });
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export function renderMetrics(input: MetricsTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
260
|
-
if (height <= 0) return [];
|
|
261
|
-
if (input.records.length === 0) {
|
|
262
|
-
const empty = input.query ? `No run matches "${input.query}".` : "No runs recorded yet. Every Master turn, subagent run, quick fix and planning round lands here with its model, thinking level, time, tokens and cost.";
|
|
263
|
-
return fill([...(height >= 8 ? tileRow(input, width, theme) : []), ...box(width, Math.max(3, height - (height >= 8 ? 4 : 0)), [paint(theme, "dim", empty)], { title: "Model performance", theme })], height, width);
|
|
264
|
-
}
|
|
265
|
-
const sections: string[] = [];
|
|
266
|
-
let room = height;
|
|
267
|
-
if (room >= 16) {
|
|
268
|
-
sections.push(...tileRow(input, width, theme));
|
|
269
|
-
room -= 4;
|
|
270
|
-
}
|
|
271
|
-
if (input.classifier && room >= 14) {
|
|
272
|
-
sections.push(...classifierLines(input.classifier, width, theme));
|
|
273
|
-
room -= 4;
|
|
274
|
-
}
|
|
275
|
-
const chartHeight = Math.min(input.groups.length + 2, 8);
|
|
276
|
-
if (room - chartHeight >= 9) {
|
|
277
|
-
if (width >= 100) {
|
|
278
|
-
const left = Math.floor((width - 1) / 2);
|
|
279
|
-
sections.push(...beside([timeBars(input.groups, input.by, left, chartHeight, theme), successMeters(input.groups, input.by, width - 1 - left, chartHeight, theme)]));
|
|
280
|
-
room -= chartHeight;
|
|
281
|
-
} else {
|
|
282
|
-
sections.push(...timeBars(input.groups, input.by, width, chartHeight, theme));
|
|
283
|
-
room -= chartHeight;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const share = timeShare(input.records, input.taskTimes, width, theme);
|
|
287
|
-
if (room - share.length >= 6) {
|
|
288
|
-
sections.push(...share);
|
|
289
|
-
room -= share.length;
|
|
290
|
-
}
|
|
291
|
-
const table = tableLines(input, width - 4, theme);
|
|
292
|
-
const tableRows = Math.max(0, room - 3);
|
|
293
|
-
const start = windowStart(input.selected, table.rows.length, tableRows);
|
|
294
|
-
const grouping = input.by === "model" ? "by model · thinking" : "by model · thinking · agent";
|
|
295
|
-
notePane(input.panes, "table", sections.length, 0, width, room, table.rows.length);
|
|
296
|
-
sections.push(...box(width, room, [table.header, ...table.rows.slice(start, start + tableRows)], { title: "All models", right: `${grouping} · sorted by ${input.sort} · g s`, scroll: { total: table.rows.length + 1, start }, theme }));
|
|
297
|
-
return fill(sections, height, width);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|