@a-t-h-i/bot-lobby 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +264 -68
- package/package.json +5 -2
- package/prompts/panel.md +13 -4
- package/prompts/planner.md +31 -13
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +55 -0
- package/src/lobby/layout.ts +208 -4
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +135 -17
- package/src/lobby/runtime.ts +353 -66
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +225 -59
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +128 -51
- package/src/lobby/tabs/quickfix.ts +43 -18
- package/src/lobby/tabs/tasks.ts +221 -54
- package/src/lobby/view.ts +1303 -143
- package/src/pi/commands.ts +35 -7
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +43 -4
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
package/src/lobby/planner.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
|
|
|
22
22
|
import { truncate } from "../text.ts";
|
|
23
23
|
import type { LobbyFeed } from "./feed.ts";
|
|
24
24
|
import type { QuickFixProfile } from "./quickfix.ts";
|
|
25
|
+
import { MAX_QUESTIONS, type AskResult } from "./ask.ts";
|
|
25
26
|
|
|
26
27
|
/** Seats and the oracle read the repository to ask informed questions; they never edit. */
|
|
27
28
|
export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
@@ -41,10 +42,21 @@ const MEMBER_SEATS: Record<PanelMember, string> = {
|
|
|
41
42
|
researcher: "You are RESEARCH: facts outside the repository — libraries and versions, standards, APIs and documentation, known pitfalls and prior art. Use the web tools when you have them and cite a URL for every external claim; ask the user to choose where the options you found really differ.",
|
|
42
43
|
};
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
/** One answer the asker offers: a short label and what choosing it means. */
|
|
46
|
+
export interface PanelOption {
|
|
47
|
+
label: string;
|
|
48
|
+
description: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A question as a seat or the oracle wrote it, with the options it offers (recommended first). */
|
|
52
|
+
export interface AskedQuestion {
|
|
53
|
+
text: string;
|
|
54
|
+
options: PanelOption[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PanelQuestion extends AskedQuestion {
|
|
45
58
|
/** DEV, DESIGN, QA, RESEARCH or ORACLE. */
|
|
46
59
|
from: string;
|
|
47
|
-
text: string;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export interface PlannerMessage {
|
|
@@ -59,14 +71,14 @@ export interface PlannerMessage {
|
|
|
59
71
|
export interface PlannerReply {
|
|
60
72
|
status: "grilling" | "ready";
|
|
61
73
|
title?: string;
|
|
62
|
-
questions:
|
|
74
|
+
questions: AskedQuestion[];
|
|
63
75
|
plan?: string;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/** A seat's reply: whether its domain is settled, its questions and what the plan must respect. */
|
|
67
79
|
export interface MemberReply {
|
|
68
80
|
status: "open" | "ready";
|
|
69
|
-
questions:
|
|
81
|
+
questions: AskedQuestion[];
|
|
70
82
|
notes: string[];
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -104,6 +116,8 @@ export interface PlannerDeps {
|
|
|
104
116
|
feed?: LobbyFeed;
|
|
105
117
|
runProcess?: ProcessRunner;
|
|
106
118
|
onChange?: () => void;
|
|
119
|
+
/** Called when a round ends (answered, failed or stopped), so the lobby can put the questions to the user. */
|
|
120
|
+
onRound?: (session: PlanningSession) => void;
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
/** Split a reply into its top-level `## Section` bodies, keyed by lower-case title. */
|
|
@@ -137,6 +151,64 @@ function listItems(body: string | undefined): string[] {
|
|
|
137
151
|
return items;
|
|
138
152
|
}
|
|
139
153
|
|
|
154
|
+
/** `Label — what it means` (or `Label: …`, `Label - …`); bold markers dropped. */
|
|
155
|
+
export function parseOption(text: string): PanelOption {
|
|
156
|
+
const flat = text.replace(/\*\*/g, "").trim();
|
|
157
|
+
const split = /^(.*?)\s+(?:—|–|-)\s+(.+)$/.exec(flat) ?? /^([^:]{1,60}):\s+(.+)$/.exec(flat);
|
|
158
|
+
return split ? { label: split[1]!.trim(), description: split[2]!.trim() } : { label: flat, description: "" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Inline `a) … b) …` choices, when a question carries its options in its own text. */
|
|
162
|
+
function inlineOptions(question: AskedQuestion): AskedQuestion {
|
|
163
|
+
if (question.options.length > 0) return question;
|
|
164
|
+
const parts = question.text.split(/\s(?=[a-d]\)\s)/);
|
|
165
|
+
if (parts.length < 3) return question;
|
|
166
|
+
const options = parts.slice(1).map((part) => parseOption(part.replace(/^[a-d]\)\s+/, "").replace(/[;,.]\s*$/, "")));
|
|
167
|
+
return { text: parts[0]!.trim(), options };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Numbered or bulleted questions, each with the options indented beneath it
|
|
172
|
+
* (` - Users table (Recommended) — follows the user`); continuation lines
|
|
173
|
+
* join the question or the option they follow.
|
|
174
|
+
*/
|
|
175
|
+
export function questionItems(body: string | undefined): AskedQuestion[] {
|
|
176
|
+
if (!body) return [];
|
|
177
|
+
const items: AskedQuestion[] = [];
|
|
178
|
+
let current: AskedQuestion | undefined;
|
|
179
|
+
for (const line of body.split("\n")) {
|
|
180
|
+
const match = /^(\s*)(?:\d+[.)]|[-*])\s+(.*\S)\s*$/.exec(line);
|
|
181
|
+
if (match && (match[1]!.length === 0 || !current)) {
|
|
182
|
+
current = { text: match[2]!.replace(/\*\*/g, ""), options: [] };
|
|
183
|
+
items.push(current);
|
|
184
|
+
} else if (match && current) {
|
|
185
|
+
current.options.push(parseOption(match[2]!));
|
|
186
|
+
} else if (line.trim() && current) {
|
|
187
|
+
const last = current.options.at(-1);
|
|
188
|
+
if (last) last.description = `${last.description} ${line.trim()}`.trim();
|
|
189
|
+
else current.text = `${current.text} ${line.trim()}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return items.map(inlineOptions);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The round's questions for the user. The oracle chooses them from its own
|
|
197
|
+
* and the seats' (tagged with the seat each serves) and decides the rest; only
|
|
198
|
+
* when its part failed do the seats' own questions go through. Never more than
|
|
199
|
+
* `MAX_QUESTIONS`, so a round is answered in one questionnaire.
|
|
200
|
+
*/
|
|
201
|
+
export function roundQuestions(reply: PlannerReply | undefined, seatQuestions: readonly PanelQuestion[]): PanelQuestion[] {
|
|
202
|
+
const chosen = reply ? reply.questions.map((question) => tagged(question, ORACLE_LABEL)) : [...seatQuestions];
|
|
203
|
+
return chosen.slice(0, MAX_QUESTIONS);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A leading `[SEAT]` tag names who asked; the oracle uses it when it relays a seat. */
|
|
207
|
+
function tagged(question: AskedQuestion, fallback: string): PanelQuestion {
|
|
208
|
+
const tag = /^\[(DEV|DESIGN|QA|RESEARCH|ORACLE)\]\s*/i.exec(question.text);
|
|
209
|
+
return tag ? { ...question, from: tag[1]!.toUpperCase(), text: question.text.slice(tag[0].length) } : { ...question, from: fallback };
|
|
210
|
+
}
|
|
211
|
+
|
|
140
212
|
/**
|
|
141
213
|
* Read the oracle's reply. Missing sections degrade gracefully: no status
|
|
142
214
|
* reads as grilling, and a reply with no sections at all becomes one question
|
|
@@ -147,8 +219,8 @@ export function parsePlannerReply(text: string): PlannerReply {
|
|
|
147
219
|
const status = /\bready\b/i.test(parts.get("status") ?? "") ? "ready" : "grilling";
|
|
148
220
|
const title = parts.get("title")?.split("\n").find((line) => line.trim())?.replace(/^[#*\s]+|[*\s]+$/g, "");
|
|
149
221
|
const plan = parts.get("plan") ?? parts.get("draft plan");
|
|
150
|
-
let questions =
|
|
151
|
-
if (parts.size === 0 && text.trim()) questions = [text.trim()];
|
|
222
|
+
let questions = questionItems(parts.get("questions"));
|
|
223
|
+
if (parts.size === 0 && text.trim()) questions = [{ text: text.trim(), options: [] }];
|
|
152
224
|
return { status, questions, ...(title ? { title } : {}), ...(plan ? { plan } : {}) };
|
|
153
225
|
}
|
|
154
226
|
|
|
@@ -156,13 +228,17 @@ export function parsePlannerReply(text: string): PlannerReply {
|
|
|
156
228
|
export function parseMemberReply(text: string): MemberReply {
|
|
157
229
|
const parts = sections(text);
|
|
158
230
|
const ready = /\bready\b/i.test(parts.get("status") ?? "");
|
|
159
|
-
let questions = ready ? [] :
|
|
160
|
-
if (parts.size === 0 && text.trim()) questions = [text.trim()];
|
|
231
|
+
let questions = ready ? [] : questionItems(parts.get("questions"));
|
|
232
|
+
if (parts.size === 0 && text.trim()) questions = [{ text: text.trim(), options: [] }];
|
|
161
233
|
return { status: ready ? "ready" : "open", questions, notes: listItems(parts.get("notes")) };
|
|
162
234
|
}
|
|
163
235
|
|
|
236
|
+
function optionLines(options: readonly PanelOption[]): string[] {
|
|
237
|
+
return options.map((option) => ` - ${option.label}${option.description ? ` — ${option.description}` : ""}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
164
240
|
function questionLine(question: PanelQuestion, index: number): string {
|
|
165
|
-
return `${index + 1}. [${question.from}] ${question.text}
|
|
241
|
+
return [`${index + 1}. [${question.from}] ${question.text}`, ...optionLines(question.options)].join("\n");
|
|
166
242
|
}
|
|
167
243
|
|
|
168
244
|
/** The conversation, the source issue and the current draft: what every seat and the oracle read. */
|
|
@@ -196,17 +272,30 @@ export function panelSection(outcomes: readonly MemberOutcome[]): string {
|
|
|
196
272
|
const { status, questions, notes } = outcome.reply;
|
|
197
273
|
return [
|
|
198
274
|
`### ${label} — ${status === "ready" ? "READY" : "OPEN"}`,
|
|
199
|
-
questions.length > 0 ? `Questions
|
|
275
|
+
questions.length > 0 ? `Questions for the user:\n${questions.map((question) => [`- ${question.text}`, ...optionLines(question.options)].join("\n")).join("\n")}` : "",
|
|
200
276
|
notes.length > 0 ? `Notes:\n${notes.map((note) => `- ${note}`).join("\n")}` : "",
|
|
201
277
|
].filter(Boolean).join("\n");
|
|
202
278
|
});
|
|
203
279
|
return ["## Panel this round", "", ...blocks].join("\n\n");
|
|
204
280
|
}
|
|
205
281
|
|
|
282
|
+
/** A comment the user left on one line of the draft plan. */
|
|
283
|
+
export interface LineComment {
|
|
284
|
+
/** The line as shown, without styling. */
|
|
285
|
+
line: string;
|
|
286
|
+
text: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Line comments as part of the user's next turn. */
|
|
290
|
+
export function commentBlock(comments: readonly LineComment[]): string {
|
|
291
|
+
if (comments.length === 0) return "";
|
|
292
|
+
return ["Comments on the draft plan:", ...comments.map((comment) => `- On "${comment.line.replace(/\s+/g, " ").trim()}": ${comment.text.trim()}`)].join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
206
295
|
/** What the panel said in a round, as the conversation shows it. */
|
|
207
296
|
export function plannerSays(ready: boolean, questions: readonly PanelQuestion[]): string {
|
|
208
|
-
if (ready) return "The panel agrees the plan is clear.
|
|
209
|
-
if (questions.length === 0) return "No open questions this round.
|
|
297
|
+
if (ready) return "The panel agrees the plan is clear. Save it as a pending task, or keep refining.";
|
|
298
|
+
if (questions.length === 0) return "No open questions this round. Save the draft, or add detail.";
|
|
210
299
|
return questions.map(questionLine).join("\n");
|
|
211
300
|
}
|
|
212
301
|
|
|
@@ -250,6 +339,10 @@ export class PlanningSession {
|
|
|
250
339
|
error?: string;
|
|
251
340
|
turns = 0;
|
|
252
341
|
saved?: PlannedTask;
|
|
342
|
+
/** Questionnaires answered so far for this round's questions, so stopping one resumes where it left off. */
|
|
343
|
+
answered: AskResult[] = [];
|
|
344
|
+
/** Comments on draft lines, sent with the user's next turn. */
|
|
345
|
+
lineComments: LineComment[] = [];
|
|
253
346
|
private controller?: AbortController;
|
|
254
347
|
private readonly deps: PlannerDeps;
|
|
255
348
|
private readonly memberNotes = new Map<PanelMember, string[]>();
|
|
@@ -276,15 +369,36 @@ export class PlanningSession {
|
|
|
276
369
|
return this.seats.has(member);
|
|
277
370
|
}
|
|
278
371
|
|
|
279
|
-
/** Add the user's message (the idea, or answers) and run a round. */
|
|
372
|
+
/** Add the user's message (the idea, or answers), with any line comments, and run a round. */
|
|
280
373
|
async send(text: string): Promise<void> {
|
|
281
|
-
const body = text.trim();
|
|
374
|
+
const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
|
|
282
375
|
if (!body) return;
|
|
283
376
|
if (this.busy) throw new Error("the panel is still thinking");
|
|
377
|
+
this.lineComments = [];
|
|
284
378
|
this.messages = [...this.messages, { role: "you", text: body, at: Date.now() }];
|
|
285
379
|
await this.turn();
|
|
286
380
|
}
|
|
287
381
|
|
|
382
|
+
/** The round's questions still wait for answers. */
|
|
383
|
+
get awaitingAnswers(): boolean {
|
|
384
|
+
return !this.busy && this.questions.length > 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Comment on one line of the draft. While questions wait for answers the
|
|
389
|
+
* comment rides along with them; otherwise it goes to the panel now.
|
|
390
|
+
* Returns whether a round started.
|
|
391
|
+
*/
|
|
392
|
+
commentOnLine(line: string, text: string): boolean {
|
|
393
|
+
const body = text.trim();
|
|
394
|
+
if (!body || !line.trim()) return false;
|
|
395
|
+
this.lineComments = [...this.lineComments, { line: line.trim(), text: body }];
|
|
396
|
+
this.deps.onChange?.();
|
|
397
|
+
if (this.busy || this.awaitingAnswers) return false;
|
|
398
|
+
void this.send("");
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
288
402
|
/** Start from the seed alone (an issue) without a user message. */
|
|
289
403
|
async open(): Promise<void> {
|
|
290
404
|
if (this.busy || this.messages.length > 0) return;
|
|
@@ -396,6 +510,7 @@ export class PlanningSession {
|
|
|
396
510
|
this.status = "thinking";
|
|
397
511
|
this.error = undefined;
|
|
398
512
|
this.turns += 1;
|
|
513
|
+
this.answered = [];
|
|
399
514
|
this.members = PANEL_MEMBERS.filter((member) => this.seats.has(member)).map((member) => ({ member, status: "thinking", step: "reading the conversation" }));
|
|
400
515
|
this.step = this.members.length > 0 ? "waiting for the panel" : "reading the conversation";
|
|
401
516
|
this.deps.onChange?.();
|
|
@@ -422,14 +537,17 @@ export class PlanningSession {
|
|
|
422
537
|
this.controller = undefined;
|
|
423
538
|
if (this.error) this.deps.feed?.log(ORACLE_LABEL, `planning round failed — ${this.error.split("\n")[0]}`, "error");
|
|
424
539
|
this.deps.onChange?.();
|
|
540
|
+
this.deps.onRound?.(this);
|
|
425
541
|
}
|
|
542
|
+
// Comments left on draft lines during the round go to the panel now, unless questions wait (they ride with the answers).
|
|
543
|
+
if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0) await this.send("");
|
|
426
544
|
}
|
|
427
545
|
|
|
428
|
-
/**
|
|
546
|
+
/** The oracle's reply sets the round's questions (chosen from the seats' and its own), verdict and draft. */
|
|
429
547
|
private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome): void {
|
|
430
548
|
const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
|
|
431
|
-
const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((
|
|
432
|
-
const questions =
|
|
549
|
+
const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
|
|
550
|
+
const questions = roundQuestions(reply, seatQuestions);
|
|
433
551
|
const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
|
|
434
552
|
const ready = Boolean(reply && reply.status === "ready" && seatsReady);
|
|
435
553
|
if (reply) {
|