@a-t-h-i/bot-lobby 0.6.2 → 0.6.4
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 +112 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +76 -8
- package/src/lobby/layout.ts +38 -18
- package/src/lobby/markdown.ts +92 -19
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +51 -37
- package/src/lobby/session-files.ts +162 -25
- package/src/lobby/sessions.ts +21 -7
- package/src/lobby/tabs/home.ts +289 -67
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +141 -25
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +54 -12
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +11 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +9 -3
- package/src/pi/zen-large.ts +10 -10
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +22 -15
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +8 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/archive.ts +12 -3
- package/src/state/backlog.ts +13 -3
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/state/file-cache.ts +62 -0
- package/src/state/metrics.ts +63 -13
- package/src/state/persistence.ts +36 -2
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/width.ts +102 -0
- package/src/workflow/workflow.ts +592 -42
package/src/lobby/feed.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { AgentRun } from "../schemas/findings.ts";
|
|
9
9
|
import { agentName } from "../pi/run-summary.ts";
|
|
10
|
+
import { markdownBlocks } from "./markdown.ts";
|
|
10
11
|
|
|
11
12
|
export type ActivityKind = "info" | "success" | "warning" | "error";
|
|
12
13
|
|
|
@@ -43,9 +44,12 @@ export interface ChatEntry {
|
|
|
43
44
|
|
|
44
45
|
export const MAX_ACTIVITY = 400;
|
|
45
46
|
export const MAX_THOUGHTS = 40;
|
|
46
|
-
|
|
47
|
+
/** Messages the conversation keeps in memory; earlier ones are loaded from the session when scrolled to. */
|
|
48
|
+
export const MAX_CHAT = 100;
|
|
47
49
|
/** Longest thought kept; a streaming thought keeps its newest text. */
|
|
48
50
|
export const MAX_THOUGHT_TEXT = 4000;
|
|
51
|
+
/** The streaming reply kept: several panes' worth; past it, whole blocks are dropped from its start. */
|
|
52
|
+
export const MAX_REPLY_TEXT = 8000;
|
|
49
53
|
|
|
50
54
|
function bounded<T>(list: T[], max: number): T[] {
|
|
51
55
|
return list.length > max ? list.slice(-max) : list;
|
|
@@ -57,6 +61,8 @@ export class LobbyFeed {
|
|
|
57
61
|
chat: ChatEntry[] = [];
|
|
58
62
|
/** The oracle's reply while it streams; cleared when the message ends. */
|
|
59
63
|
reply = "";
|
|
64
|
+
/** Earlier messages than `chat` holds exist in the session (loaded only when scrolled back to). */
|
|
65
|
+
chatOlder = false;
|
|
60
66
|
version = 0;
|
|
61
67
|
private nextId = 1;
|
|
62
68
|
private readonly listeners = new Set<() => void>();
|
|
@@ -90,16 +96,22 @@ export class LobbyFeed {
|
|
|
90
96
|
|
|
91
97
|
/** A one-off entry that is already done (a receipt, a transition, a note). */
|
|
92
98
|
log(source: string, text: string, kind: ActivityKind = "info", at = Date.now()): void {
|
|
93
|
-
this.
|
|
99
|
+
this.pushActivity({ id: this.nextId++, at, source, text, kind, pending: false });
|
|
94
100
|
this.touch();
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
/** Open an in-flight step; `key` lets `end` settle exactly this one. */
|
|
98
104
|
begin(source: string, text: string, key?: string, at = Date.now()): void {
|
|
99
|
-
this.
|
|
105
|
+
this.pushActivity({ id: this.nextId++, at, source, text, kind: "info", pending: true, ...(key ? { key } : {}) });
|
|
100
106
|
this.touch();
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
/** Append in place (a busy agent logs many steps a second), dropping the oldest past the limit. */
|
|
110
|
+
private pushActivity(entry: ActivityEntry): void {
|
|
111
|
+
this.activity.push(entry);
|
|
112
|
+
if (this.activity.length > MAX_ACTIVITY) this.activity.splice(0, this.activity.length - MAX_ACTIVITY);
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
/** Settle the step opened with `key`; an error marks it. */
|
|
104
116
|
end(key: string, isError = false): void {
|
|
105
117
|
if (this.settle(undefined, key, isError ? "error" : undefined)) this.touch();
|
|
@@ -116,7 +128,7 @@ export class LobbyFeed {
|
|
|
116
128
|
const last = this.thoughts.at(-1);
|
|
117
129
|
if (last && last.live && last.source === source) {
|
|
118
130
|
const text = last.text + delta;
|
|
119
|
-
last.text = text.length > MAX_THOUGHT_TEXT ? text
|
|
131
|
+
last.text = text.length > MAX_THOUGHT_TEXT ? trimThought(text) : text;
|
|
120
132
|
} else {
|
|
121
133
|
this.thoughts = bounded([...this.thoughts, { id: this.nextId++, at, source, text: delta.slice(-MAX_THOUGHT_TEXT), live: true }], MAX_THOUGHTS);
|
|
122
134
|
}
|
|
@@ -146,7 +158,7 @@ export class LobbyFeed {
|
|
|
146
158
|
|
|
147
159
|
/** Stream the oracle's reply text as it arrives. */
|
|
148
160
|
replyDelta(delta: string): void {
|
|
149
|
-
this.reply = (this.reply + delta)
|
|
161
|
+
this.reply = trimReply(this.reply + delta);
|
|
150
162
|
this.touch();
|
|
151
163
|
}
|
|
152
164
|
|
|
@@ -160,13 +172,19 @@ export class LobbyFeed {
|
|
|
160
172
|
say(role: ChatRole, text: string, at = Date.now()): void {
|
|
161
173
|
const body = text.trim();
|
|
162
174
|
if (!body) return;
|
|
163
|
-
this.chat
|
|
175
|
+
this.chat.push({ id: this.nextId++, at, role, text: body });
|
|
176
|
+
if (this.chat.length > MAX_CHAT) {
|
|
177
|
+
this.chat.splice(0, this.chat.length - MAX_CHAT);
|
|
178
|
+
this.chatOlder = true;
|
|
179
|
+
}
|
|
164
180
|
this.touch();
|
|
165
181
|
}
|
|
166
182
|
|
|
167
|
-
/** Replace the conversation (seeding from the session when the lobby first opens). */
|
|
183
|
+
/** Replace the conversation (seeding from the session when the lobby first opens); only the newest `MAX_CHAT` are kept. */
|
|
168
184
|
seedChat(entries: ReadonlyArray<{ role: ChatRole; text: string; at?: number }>): void {
|
|
169
|
-
|
|
185
|
+
const kept = entries.filter((entry) => entry.text.trim());
|
|
186
|
+
this.chatOlder = kept.length > MAX_CHAT;
|
|
187
|
+
this.chat = kept.slice(-MAX_CHAT).map((entry) => ({ id: this.nextId++, at: entry.at ?? 0, role: entry.role, text: entry.text.trim() }));
|
|
170
188
|
this.touch();
|
|
171
189
|
}
|
|
172
190
|
|
|
@@ -202,6 +220,7 @@ export class LobbyFeed {
|
|
|
202
220
|
this.activity = [];
|
|
203
221
|
this.thoughts = [];
|
|
204
222
|
this.chat = [];
|
|
223
|
+
this.chatOlder = false;
|
|
205
224
|
this.reply = "";
|
|
206
225
|
this.runSteps.clear();
|
|
207
226
|
this.runThoughts.clear();
|
|
@@ -210,6 +229,38 @@ export class LobbyFeed {
|
|
|
210
229
|
}
|
|
211
230
|
}
|
|
212
231
|
|
|
232
|
+
/** A streaming thought past `MAX_THOUGHT_TEXT` cut to three quarters of it from a word's start, so its start then holds still a while. */
|
|
233
|
+
function trimThought(text: string): string {
|
|
234
|
+
const cut = text.slice(-Math.floor(MAX_THOUGHT_TEXT * 0.75));
|
|
235
|
+
const space = cut.search(/\s/);
|
|
236
|
+
return space >= 0 && space < 80 ? cut.slice(space + 1) : cut;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A streaming reply past `max` characters cut down to three quarters of that
|
|
241
|
+
* (so its start then holds still for a while) by dropping whole Markdown
|
|
242
|
+
* blocks from its start, so what is left renders as it did (a cut inside a
|
|
243
|
+
* code block would turn the rest of the reply inside out). A single block
|
|
244
|
+
* longer than that keeps its end, and its opening fence if it is code.
|
|
245
|
+
*/
|
|
246
|
+
export function trimReply(text: string, max = MAX_REPLY_TEXT): string {
|
|
247
|
+
if (text.length <= max) return text;
|
|
248
|
+
const target = Math.floor(max * 0.75);
|
|
249
|
+
const blocks = markdownBlocks(text);
|
|
250
|
+
let length = text.length;
|
|
251
|
+
let first = 0;
|
|
252
|
+
// Each dropped block takes the blank line after it too.
|
|
253
|
+
while (first < blocks.length - 1 && length > target) length -= blocks[first++]!.length + 2;
|
|
254
|
+
if (length <= target) return blocks.slice(first).join("\n\n");
|
|
255
|
+
const last = blocks.at(-1) ?? text;
|
|
256
|
+
const opener = /^ {0,3}(?:`{3,}|~{3,}).*\n/.exec(last)?.[0] ?? "";
|
|
257
|
+
const rest = last.slice(opener.length);
|
|
258
|
+
const cut = rest.slice(-(target - opener.length));
|
|
259
|
+
// From a line start, so no line (or code) is shown half cut.
|
|
260
|
+
const line = cut.indexOf("\n");
|
|
261
|
+
return `${opener}${line >= 0 && line < cut.length - 1 ? cut.slice(line + 1) : cut}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
213
264
|
/** The process-wide lobby feed; the Master session is the only writer that matters. */
|
|
214
265
|
export const lobbyFeed = new LobbyFeed();
|
|
215
266
|
|
|
@@ -240,11 +291,28 @@ export function chatText(role: "user" | "assistant", text: string): Array<{ role
|
|
|
240
291
|
return [{ role: "you", text: body }];
|
|
241
292
|
}
|
|
242
293
|
|
|
294
|
+
/** The session entry bot-lobby leaves where the oracle's context starts over (see pi/fresh-context.ts). */
|
|
295
|
+
export const CONTEXT_MARK = "bot-lobby-context";
|
|
296
|
+
|
|
297
|
+
/** What the conversation shows where a task's end cleared the oracle's context. */
|
|
298
|
+
export const CLEARED_NOTE = "context cleared · the next request starts fresh";
|
|
299
|
+
|
|
300
|
+
/** Whether a session entry is where a finished task cleared the oracle's context. */
|
|
301
|
+
export function isClearedMark(entry: unknown): boolean {
|
|
302
|
+
const record = entry as { type?: string; customType?: string; data?: { kind?: unknown } } | undefined;
|
|
303
|
+
return record?.type === "custom" && record.customType === CONTEXT_MARK && record.data?.kind === "end";
|
|
304
|
+
}
|
|
305
|
+
|
|
243
306
|
/** Session entries (`{ type: "message", message }`) as conversation entries, oldest first. */
|
|
244
307
|
export function chatFromEntries(entries: readonly unknown[], max = MAX_CHAT): Array<{ role: ChatRole; text: string; at?: number }> {
|
|
245
308
|
const chat: Array<{ role: ChatRole; text: string; at?: number }> = [];
|
|
246
309
|
for (const entry of entries) {
|
|
247
310
|
const record = entry as { type?: string; timestamp?: string; message?: { role?: string; content?: unknown } };
|
|
311
|
+
if (isClearedMark(entry)) {
|
|
312
|
+
const at = record.timestamp ? Date.parse(record.timestamp) : Number.NaN;
|
|
313
|
+
chat.push({ role: "note", text: CLEARED_NOTE, ...(Number.isFinite(at) ? { at } : {}) });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
248
316
|
if (record?.type !== "message") continue;
|
|
249
317
|
const role = record.message?.role;
|
|
250
318
|
if (role !== "user" && role !== "assistant") continue;
|
package/src/lobby/layout.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* its widths and heights explicitly and never reads a clock or the terminal,
|
|
5
5
|
* so each tab renders deterministically in tests.
|
|
6
6
|
*/
|
|
7
|
-
import { truncateToWidth,
|
|
7
|
+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
import { textWidth } from "../width.ts";
|
|
9
|
+
import type { MarkdownStyle } from "./markdown.ts";
|
|
8
10
|
|
|
9
11
|
/** Colours the lobby uses; a subset of pi's theme so tests can pass a plain stub. */
|
|
10
12
|
export type LobbyColor =
|
|
@@ -28,8 +30,8 @@ export interface LobbyTheme {
|
|
|
28
30
|
bold(text: string): string;
|
|
29
31
|
italic?(text: string): string;
|
|
30
32
|
bg?(color: "selectedBg" | "searchMatchBg" | "userMessageBg", text: string): string;
|
|
31
|
-
/** Render Markdown to styled lines; plain wrapping without it (tests). */
|
|
32
|
-
markdown?(text: string, width: number): string[];
|
|
33
|
+
/** Render Markdown to styled lines, in the look of whose words they are; plain wrapping without it (tests). */
|
|
34
|
+
markdown?(text: string, width: number, keep?: boolean, style?: MarkdownStyle): string[];
|
|
33
35
|
/** Strike text through (an abandoned task's title); plain without it (tests). */
|
|
34
36
|
strike?(text: string): string;
|
|
35
37
|
}
|
|
@@ -54,7 +56,7 @@ export function strike(theme: LobbyTheme | undefined, text: string): string {
|
|
|
54
56
|
/** `left` and `right` on one line of exactly `width` columns: the left side gives way first. */
|
|
55
57
|
export function spread(left: string, right: string, width: number): string {
|
|
56
58
|
if (!right) return fit(left, width);
|
|
57
|
-
const room = width -
|
|
59
|
+
const room = width - textWidth(right) - 1;
|
|
58
60
|
if (room < 4) return fit(left, width);
|
|
59
61
|
return `${fit(left, room)} ${right}`;
|
|
60
62
|
}
|
|
@@ -62,8 +64,8 @@ export function spread(left: string, right: string, width: number): string {
|
|
|
62
64
|
/** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
|
|
63
65
|
export function fit(text: string, width: number): string {
|
|
64
66
|
if (width <= 0) return "";
|
|
65
|
-
const cut =
|
|
66
|
-
const pad = width -
|
|
67
|
+
const cut = textWidth(text) > width ? truncateToWidth(text, width, "…") : text;
|
|
68
|
+
const pad = width - textWidth(cut);
|
|
67
69
|
return pad > 0 ? `${cut}${" ".repeat(pad)}` : cut;
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -83,7 +85,7 @@ export function wrap(text: string, width: number): string[] {
|
|
|
83
85
|
|
|
84
86
|
/** Wrap with a hanging indent: the first line carries `lead`, the rest align under it. */
|
|
85
87
|
export function wrapHanging(lead: string, text: string, width: number): string[] {
|
|
86
|
-
const indent =
|
|
88
|
+
const indent = textWidth(lead);
|
|
87
89
|
const body = wrap(text, Math.max(1, width - indent));
|
|
88
90
|
if (body.length === 0) return [lead];
|
|
89
91
|
return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
|
|
@@ -94,7 +96,7 @@ export function rule(width: number, title = "", theme?: LobbyTheme, right = "",
|
|
|
94
96
|
if (width <= 0) return "";
|
|
95
97
|
const head = title ? `── ${title} ` : "";
|
|
96
98
|
const tail = right ? ` ${right} ──` : "";
|
|
97
|
-
const fill = width -
|
|
99
|
+
const fill = width - textWidth(head) - textWidth(tail);
|
|
98
100
|
if (fill < 1) return fit(paint(theme, color, head.trimEnd() || "─".repeat(width)), width);
|
|
99
101
|
const titled = title ? `${paint(theme, color, "── ")}${bold(theme, paint(theme, "accent", title))}${paint(theme, color, " ")}` : "";
|
|
100
102
|
const ending = right ? `${paint(theme, color, " ")}${paint(theme, "muted", right)}${paint(theme, color, " ──")}` : "";
|
|
@@ -183,8 +185,8 @@ export function spinner(tick: number): string {
|
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
/** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
|
|
186
|
-
export function markdownLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
187
|
-
if (theme?.markdown) return theme.markdown(text, width);
|
|
188
|
+
export function markdownLines(text: string, width: number, theme?: LobbyTheme, keep = true, style: MarkdownStyle = "reply"): string[] {
|
|
189
|
+
if (theme?.markdown) return theme.markdown(text, width, keep, style);
|
|
188
190
|
return text.split("\n").flatMap((line) => {
|
|
189
191
|
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
190
192
|
return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
|
|
@@ -192,9 +194,9 @@ export function markdownLines(text: string, width: number, theme?: LobbyTheme):
|
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
/** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
|
|
195
|
-
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme): string[] {
|
|
196
|
-
const indent =
|
|
197
|
-
const body = markdownLines(text, Math.max(1, width - indent), theme);
|
|
197
|
+
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme, style: MarkdownStyle = "reply"): string[] {
|
|
198
|
+
const indent = textWidth(lead);
|
|
199
|
+
const body = markdownLines(text, Math.max(1, width - indent), theme, true, style);
|
|
198
200
|
if (body.length === 0) return [lead];
|
|
199
201
|
return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
|
|
200
202
|
}
|
|
@@ -210,6 +212,18 @@ export interface BoxOptions {
|
|
|
210
212
|
scroll?: { total: number; start: number };
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
/** An entry of a pane (a message, a thought, an activity line) and how many lines run from its first line to the pane's end. */
|
|
216
|
+
export interface PaneMark {
|
|
217
|
+
entry: object;
|
|
218
|
+
below: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A pane that measures what arrives below: its newest entry now, and the lines that arrived below the one marked the frame before. */
|
|
222
|
+
export interface PaneFollow {
|
|
223
|
+
mark?: PaneMark;
|
|
224
|
+
grew: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
213
227
|
/** A scrollable pane as a render laid it out: its box in body cells, and its content against its rows. */
|
|
214
228
|
export interface PaneBox {
|
|
215
229
|
top: number;
|
|
@@ -219,14 +233,20 @@ export interface PaneBox {
|
|
|
219
233
|
/** Lines of content, and rows showing them at once. */
|
|
220
234
|
total: number;
|
|
221
235
|
rows: number;
|
|
236
|
+
/**
|
|
237
|
+
* Set by panes whose total is estimated: lines that arrived below since the
|
|
238
|
+
* frame before, measured from an entry, not from the total (which moves as
|
|
239
|
+
* scrolling draws more or fewer of the entries it estimates).
|
|
240
|
+
*/
|
|
241
|
+
follow?: PaneFollow;
|
|
222
242
|
}
|
|
223
243
|
|
|
224
244
|
/** Scrollable panes by name, filled in by a tab's render so the lobby can clamp offsets and route the wheel. */
|
|
225
245
|
export type PaneLayout = Map<string, PaneBox>;
|
|
226
246
|
|
|
227
247
|
/** Record a pane that sits at `top`/`left` in the body, `width` × `height`, showing `total` lines. */
|
|
228
|
-
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number): void {
|
|
229
|
-
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2) });
|
|
248
|
+
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number, follow?: PaneFollow): void {
|
|
249
|
+
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2), ...(follow ? { follow } : {}) });
|
|
230
250
|
}
|
|
231
251
|
|
|
232
252
|
/** First line of a top-anchored pane scrolled `offset` lines down, stopping when its last line shows. */
|
|
@@ -261,11 +281,11 @@ export function box(width: number, height: number, content: readonly string[], o
|
|
|
261
281
|
const inner = width - 4;
|
|
262
282
|
const title = options.title ? ` ${options.title} ` : "";
|
|
263
283
|
const right = options.right ? ` ${options.right} ` : "";
|
|
264
|
-
let room = width - 2 -
|
|
284
|
+
let room = width - 2 - textWidth(title) - textWidth(right);
|
|
265
285
|
const shownRight = room >= 1 ? right : "";
|
|
266
|
-
room = width - 2 -
|
|
286
|
+
room = width - 2 - textWidth(title) - textWidth(shownRight);
|
|
267
287
|
const titled = room >= 1 ? title : fit(title, Math.max(0, width - 3));
|
|
268
|
-
const fillWidth = Math.max(0, width - 2 -
|
|
288
|
+
const fillWidth = Math.max(0, width - 2 - textWidth(titled) - textWidth(shownRight));
|
|
269
289
|
const paintedTitle = titled ? bold(theme, paint(theme, focused ? "accent" : "text", titled)) : "";
|
|
270
290
|
const top = `${edge("╭")}${paintedTitle}${edge("─".repeat(fillWidth))}${shownRight ? paint(theme, "dim", shownRight) : ""}${edge("╮")}`;
|
|
271
291
|
const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
|
package/src/lobby/markdown.ts
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown for the lobby: pi's own renderer (headings, emphasis, lists, code
|
|
3
3
|
* with syntax highlighting, quotes, tables) under the session's theme, with
|
|
4
|
-
* heading hashes dropped for a cleaner read. Renders are cached per
|
|
5
|
-
*
|
|
4
|
+
* heading hashes dropped for a cleaner read. Renders are cached per width and
|
|
5
|
+
* text, since the lobby repaints many times a second.
|
|
6
6
|
*/
|
|
7
7
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Markdown, stripTerminalSequences, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
8
|
+
import { Markdown, stripTerminalSequences, type DefaultTextStyle, type MarkdownOptions, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Whose words a piece of Markdown is, which sets how its plain text reads, as
|
|
12
|
+
* pi draws the same things: an agent's reply in the theme's text colour, your
|
|
13
|
+
* own words in yours (list markers and backslash escapes kept as typed), a
|
|
14
|
+
* thought dimmed and in italics.
|
|
15
|
+
*/
|
|
16
|
+
export type MarkdownStyle = "reply" | "you" | "thought";
|
|
17
|
+
|
|
18
|
+
/** The plain-text look of each style but a reply; the lobby takes them from the session's theme. */
|
|
19
|
+
export type MarkdownStyles = Partial<Record<Exclude<MarkdownStyle, "reply">, DefaultTextStyle>>;
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
/** Markdown rendered to lines at a width; `keep: false` for text seen once (a reply still streaming), so it does not crowd the cache. */
|
|
22
|
+
export type MarkdownRenderer = (text: string, width: number, keep?: boolean, style?: MarkdownStyle) => string[];
|
|
23
|
+
|
|
24
|
+
/** How pi renders what you write: your ordered-list markers and backslash escapes stay as you typed them. */
|
|
25
|
+
const YOUR_WORDS: MarkdownOptions = { preserveOrderedListMarkers: true, preserveBackslashEscapes: true };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Line breaks a model wrote out as `\n` (text escaped twice on its way here,
|
|
29
|
+
* so a whole plan arrives as one line): made real when the text has no real
|
|
30
|
+
* line break at all, outside inline code, where `\n` is usually meant.
|
|
31
|
+
*/
|
|
32
|
+
export function unescapeBreaks(text: string): string {
|
|
33
|
+
if (text.includes("\n") || !text.includes("\\n")) return text;
|
|
34
|
+
return text
|
|
35
|
+
.split(/(`[^`\n]*`)/)
|
|
36
|
+
.map((part, index) => (index % 2 === 1 ? part : part.replace(/(?:\\r)?\\n/g, "\n")))
|
|
37
|
+
.join("");
|
|
38
|
+
}
|
|
13
39
|
|
|
14
40
|
/** A heading line as pi renders it: optional styling, then `#`…`######` and a space. */
|
|
15
41
|
const HEADING_HASHES = /^((?:\x1b\[[0-9;]*m)*)#{1,6} ((?:\x1b\[[0-9;]*m)*)/;
|
|
@@ -21,28 +47,75 @@ export function tidyHeading(line: string, bold: (text: string) => string): strin
|
|
|
21
47
|
return bold(body.trimEnd());
|
|
22
48
|
}
|
|
23
49
|
|
|
50
|
+
/** Rendered Markdown kept per renderer: the most recently used survive. */
|
|
51
|
+
export const CACHE_LIMIT = 512;
|
|
52
|
+
|
|
24
53
|
/**
|
|
25
|
-
* A renderer
|
|
26
|
-
*
|
|
54
|
+
* A renderer for one Markdown theme. Renders are kept per width and text, the
|
|
55
|
+
* least recently used dropped first. pi's `getMarkdownTheme()` builds a new
|
|
56
|
+
* theme object on every call, so the theme is taken once, here: a theme
|
|
57
|
+
* switch makes a new renderer (the lobby keeps one per pi theme).
|
|
27
58
|
*/
|
|
28
|
-
export function createMarkdownRenderer(theme: ()
|
|
29
|
-
const
|
|
30
|
-
return (text, width) => {
|
|
31
|
-
const
|
|
32
|
-
let cache = caches.get(current);
|
|
33
|
-
if (!cache) {
|
|
34
|
-
cache = new Map();
|
|
35
|
-
caches.set(current, cache);
|
|
36
|
-
}
|
|
37
|
-
const key = `${width}\0${text}`;
|
|
59
|
+
export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme(), styles: MarkdownStyles = {}): MarkdownRenderer {
|
|
60
|
+
const cache = new Map<string, string[]>();
|
|
61
|
+
return (text, width, keep = true, style = "reply") => {
|
|
62
|
+
const key = `${style}\0${width}\0${text}`;
|
|
38
63
|
const hit = cache.get(key);
|
|
39
|
-
if (hit)
|
|
40
|
-
|
|
64
|
+
if (hit) {
|
|
65
|
+
// Most recently used last, so the oldest is the one dropped.
|
|
66
|
+
cache.delete(key);
|
|
67
|
+
cache.set(key, hit);
|
|
68
|
+
return hit;
|
|
69
|
+
}
|
|
70
|
+
const look = style === "reply" ? undefined : styles[style];
|
|
71
|
+
const lines = new Markdown(unescapeBreaks(text), 0, 0, theme, look, style === "you" ? YOUR_WORDS : undefined)
|
|
72
|
+
.render(Math.max(1, width))
|
|
73
|
+
.map((line) => tidyHeading(line.trimEnd(), theme.bold));
|
|
41
74
|
// Drop the blank lines pi pads the render with at either end.
|
|
42
75
|
while (lines.length > 0 && !lines[0]!.trim()) lines.shift();
|
|
43
76
|
while (lines.length > 0 && !lines.at(-1)!.trim()) lines.pop();
|
|
77
|
+
if (!keep) return lines;
|
|
44
78
|
cache.set(key, lines);
|
|
45
79
|
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!);
|
|
46
80
|
return lines;
|
|
47
81
|
};
|
|
48
82
|
}
|
|
83
|
+
|
|
84
|
+
/** A fenced code block's opening or closing line. */
|
|
85
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
86
|
+
/** A list item's first line: after a blank line it continues the list above, so blocks never split there. */
|
|
87
|
+
const LIST_ITEM = /^(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Markdown split into blocks that render, one by one, into the same lines as
|
|
91
|
+
* the whole (each block's lines, a blank line between blocks, blocks that
|
|
92
|
+
* render to nothing left out). A split falls on a blank line outside code
|
|
93
|
+
* fences that is followed by an unindented line that does not start a list
|
|
94
|
+
* item — the only places where one block can never run into the next. A
|
|
95
|
+
* streaming reply can then keep its finished blocks rendered and render only
|
|
96
|
+
* the one still being written.
|
|
97
|
+
*/
|
|
98
|
+
export function markdownBlocks(text: string): string[] {
|
|
99
|
+
const lines = text.split("\n");
|
|
100
|
+
const blocks: string[] = [];
|
|
101
|
+
let fence: string | undefined;
|
|
102
|
+
let start = 0;
|
|
103
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
104
|
+
const line = lines[index]!;
|
|
105
|
+
const marker = FENCE.exec(line)?.[1];
|
|
106
|
+
if (marker) {
|
|
107
|
+
if (!fence) fence = marker;
|
|
108
|
+
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (fence || line.trim() || index === 0) continue;
|
|
112
|
+
const next = lines[index + 1];
|
|
113
|
+
if (next === undefined || !/^\S/.test(next) || LIST_ITEM.test(next)) continue;
|
|
114
|
+
const block = lines.slice(start, index).join("\n");
|
|
115
|
+
if (block.trim()) blocks.push(block);
|
|
116
|
+
start = index + 1;
|
|
117
|
+
}
|
|
118
|
+
const last = lines.slice(start).join("\n");
|
|
119
|
+
if (last.trim()) blocks.push(last);
|
|
120
|
+
return blocks;
|
|
121
|
+
}
|
package/src/lobby/planner.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { fellShort, profileLabel, type EffortRouter, type EffortScore } from "..
|
|
|
37
37
|
|
|
38
38
|
/** Seats and the oracle read the repository to ask informed questions; they never edit. */
|
|
39
39
|
export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
40
|
-
/** RESEARCH also gets
|
|
40
|
+
/** RESEARCH also gets bot-lobby's web tools (src/web/tools.ts). */
|
|
41
41
|
export const RESEARCH_PANEL_TOOLS: readonly string[] = [...PLANNER_TOOLS, "web_search", "fetch_content", "source_check", "get_search_content"];
|
|
42
42
|
|
|
43
43
|
/** The chair of the panel, as the Plan tab, the feed and the metrics name it. */
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { loadPrompt } from "../prompts/loader.ts";
|
|
|
9
9
|
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
10
10
|
import { describeToolCall } from "../pi/activity.ts";
|
|
11
11
|
import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
|
|
12
|
+
import { appendChange, EditLog } from "../state/changes.ts";
|
|
12
13
|
import type { LobbyFeed } from "./feed.ts";
|
|
13
14
|
import type { FileHinter } from "../classifier/files.ts";
|
|
14
15
|
import type { Classifier } from "../classifier/classifier.ts";
|
|
@@ -51,6 +52,8 @@ export interface QuickFixJob {
|
|
|
51
52
|
/** The classifier routed it down (`trivial 0.88: p/big · low → p/cheap · low`); cleared when it re-ran on the configured profile. */
|
|
52
53
|
route?: string;
|
|
53
54
|
routedFrom?: string;
|
|
55
|
+
/** Files it changed with `edit`/`write`, as shown (relative to the project); the change ledger lets a running task's QA gate tell them from its own. */
|
|
56
|
+
files?: string[];
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface QuickFixProfile {
|
|
@@ -103,6 +106,7 @@ export class QuickFixQueue {
|
|
|
103
106
|
jobs: QuickFixJob[] = [];
|
|
104
107
|
private readonly deps: QuickFixDeps;
|
|
105
108
|
private readonly controllers = new Map<string, AbortController>();
|
|
109
|
+
private readonly edits = new Map<string, EditLog>();
|
|
106
110
|
private counter = 0;
|
|
107
111
|
|
|
108
112
|
constructor(deps: QuickFixDeps) {
|
|
@@ -188,6 +192,8 @@ export class QuickFixQueue {
|
|
|
188
192
|
const now = Date.now();
|
|
189
193
|
if (event.type === "tool_execution_start") {
|
|
190
194
|
job.tools += 1;
|
|
195
|
+
const edits = this.edits.get(job.id);
|
|
196
|
+
if (edits?.note(event.toolName, event.args)) job.files = edits.shown();
|
|
191
197
|
this.addStep(job, describeToolCall(event.toolName, event.args), now);
|
|
192
198
|
} else if (event.type === "turn_start") job.turns += 1;
|
|
193
199
|
else if (event.type === "thought") this.deps.feed?.thought(QUICK_FIX_SOURCE, event.text, now);
|
|
@@ -211,6 +217,7 @@ export class QuickFixQueue {
|
|
|
211
217
|
return this.hold(job, held);
|
|
212
218
|
}
|
|
213
219
|
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
220
|
+
this.edits.set(job.id, new EditLog(this.deps.cwd));
|
|
214
221
|
try {
|
|
215
222
|
const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
|
|
216
223
|
const attempt = (model: string | undefined, thinking: string) => runPiAgent(
|
|
@@ -309,6 +316,20 @@ export class QuickFixQueue {
|
|
|
309
316
|
private finish(job: QuickFixJob): void {
|
|
310
317
|
job.finishedAt = Date.now();
|
|
311
318
|
for (const step of job.steps) step.pending = false;
|
|
319
|
+
const edited = this.edits.get(job.id)?.list() ?? [];
|
|
320
|
+
this.edits.delete(job.id);
|
|
321
|
+
// Whatever it edited, even when it failed, is on record for a task's QA gate as the user's own request.
|
|
322
|
+
if (edited.length > 0) {
|
|
323
|
+
appendChange(this.deps.root, this.deps.configDir, {
|
|
324
|
+
source: "quickfix",
|
|
325
|
+
id: job.id,
|
|
326
|
+
what: jobTitle(job),
|
|
327
|
+
files: edited,
|
|
328
|
+
startedAt: new Date(job.startedAt ?? job.createdAt).toISOString(),
|
|
329
|
+
finishedAt: new Date(job.finishedAt).toISOString(),
|
|
330
|
+
status: job.status,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
312
333
|
this.deps.feed?.end(job.id, job.status !== "success");
|
|
313
334
|
const ok = job.status === "success";
|
|
314
335
|
const outcome = ok ? "done" : `${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`;
|