@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.
@@ -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
- export interface PanelQuestion {
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: string[];
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: string[];
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 = listItems(parts.get("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 ? [] : listItems(parts.get("questions"));
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 asked of the user:\n${questions.map((question) => `- ${question}`).join("\n")}` : "",
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. Press s to save it as a pending task, or keep refining.";
209
- if (questions.length === 0) return "No open questions this round. Press s to save the draft, or add detail.";
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
- /** Merge the seats' and the oracle's answers into the round's questions, verdict and draft. */
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((text) => ({ from: MEMBER_LABELS[outcome.member], text })));
432
- const questions = [...(reply?.questions ?? []).map((text) => ({ from: ORACLE_LABEL, text })), ...seatQuestions];
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) {