@a-t-h-i/bot-lobby 0.6.6 → 0.6.8

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.
Files changed (58) hide show
  1. package/README.md +183 -11
  2. package/package.json +1 -1
  3. package/prompts/master.md +21 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +23 -7
  9. package/src/ask/state.ts +18 -3
  10. package/src/ask/tool.ts +9 -2
  11. package/src/ask/view.ts +8 -4
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/execution/agent-runner.ts +23 -1
  16. package/src/execution/fallback.ts +75 -0
  17. package/src/execution/workspace.ts +185 -0
  18. package/src/knowledge/edit.ts +109 -0
  19. package/src/knowledge/notes.ts +143 -0
  20. package/src/knowledge/selector.ts +1 -1
  21. package/src/knowledge/store.ts +16 -2
  22. package/src/lobby/ask.ts +42 -21
  23. package/src/lobby/feed.ts +7 -0
  24. package/src/lobby/issues.ts +1 -1
  25. package/src/lobby/knowledge.ts +179 -0
  26. package/src/lobby/layout.ts +99 -1
  27. package/src/lobby/mini.ts +159 -0
  28. package/src/lobby/planner.ts +209 -10
  29. package/src/lobby/pr-review.ts +360 -0
  30. package/src/lobby/pulls.ts +250 -0
  31. package/src/lobby/quickfix.ts +17 -3
  32. package/src/lobby/runtime.ts +142 -17
  33. package/src/lobby/split.ts +230 -0
  34. package/src/lobby/tabs/git.ts +162 -0
  35. package/src/lobby/tabs/knowledge.ts +135 -0
  36. package/src/lobby/tabs/plan.ts +3 -1
  37. package/src/lobby/tabs/tasks.ts +11 -2
  38. package/src/lobby/view.ts +450 -35
  39. package/src/master/master.ts +40 -14
  40. package/src/master/research.ts +1 -0
  41. package/src/pi/commands.ts +12 -7
  42. package/src/pi/events.ts +2 -0
  43. package/src/pi/master-fallback.ts +61 -0
  44. package/src/pi/model-support.ts +27 -3
  45. package/src/pi/plan-checklist.ts +42 -0
  46. package/src/pi/route.ts +2 -1
  47. package/src/pi/settings-ui.ts +104 -11
  48. package/src/pi/start-flags.ts +14 -0
  49. package/src/pi/start-task.ts +56 -5
  50. package/src/pi/tools.ts +4 -2
  51. package/src/schemas/configuration.ts +74 -11
  52. package/src/schemas/findings.ts +2 -0
  53. package/src/schemas/task.ts +15 -0
  54. package/src/state/backlog.ts +27 -4
  55. package/src/state/metrics.ts +2 -2
  56. package/src/state/persistence.ts +5 -5
  57. package/src/text.ts +38 -0
  58. package/src/workflow/workflow.ts +23 -2
@@ -20,15 +20,19 @@
20
20
  */
21
21
  import { loadPrompt } from "../prompts/loader.ts";
22
22
  import { compilePrompt } from "../prompts/compiler.ts";
23
+ import { withFallback } from "../execution/fallback.ts";
23
24
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
24
25
  import { describeToolCall } from "../pi/activity.ts";
25
26
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
26
27
  import { savePlannedTask, type IssueRef, type PlannedTask } from "../state/backlog.ts";
28
+ import { planSteps } from "../pi/plan-checklist.ts";
29
+ import { KEEP_WHOLE, MAX_SPLIT_REVISIONS, partBrief, partInfo, parseSplit, splitFailedQuestion, splitLabel, splitProblems, splitPrompt, splitQuestion, splitRequest, type SplitProposal } from "./split.ts";
30
+ import type { AskQuestion, AskResult } from "../ask/types.ts";
27
31
  import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
28
32
  import { truncate } from "../text.ts";
29
33
  import type { LobbyFeed } from "./feed.ts";
30
34
  import type { QuickFixProfile } from "./quickfix.ts";
31
- import { MAX_QUESTIONS, type AskResult } from "./ask.ts";
35
+ import { MAX_QUESTIONS } from "./ask.ts";
32
36
  import type { Classifier } from "../classifier/classifier.ts";
33
37
  import { chooseSeats } from "../classifier/seats.ts";
34
38
  import { autoAnswer, type AutoAnswer } from "../classifier/answers.ts";
@@ -70,6 +74,13 @@ export interface PanelQuestion extends AskedQuestion {
70
74
  from: string;
71
75
  }
72
76
 
77
+ /** One of the panel's questions as the user's reply settled it: answered, or left open (`answer` absent). */
78
+ export interface SettledQuestion {
79
+ from: string;
80
+ question: string;
81
+ answer?: string;
82
+ }
83
+
73
84
  export interface PlannerMessage {
74
85
  role: "you" | "planner";
75
86
  text: string;
@@ -78,6 +89,8 @@ export interface PlannerMessage {
78
89
  questions?: PanelQuestion[];
79
90
  /** Questions the classifier answered with their recommended option instead of asking. */
80
91
  decided?: AutoAnswer[];
92
+ /** On the user's reply: which of the questions before it were answered, so they are never asked again. */
93
+ settled?: SettledQuestion[];
81
94
  }
82
95
 
83
96
  /** The oracle's reply: its verdict, title, own questions and the draft plan. */
@@ -336,6 +349,8 @@ export function plannerTranscript(messages: readonly PlannerMessage[], seed?: Pl
336
349
  lines.push(message.role === "you" ? "### User" : "### Panel", "", body, "");
337
350
  if (message.decided && message.decided.length > 0) lines.push(decidedBlock(message.decided), "");
338
351
  }
352
+ const settled = messages.flatMap((message) => message.settled ?? []);
353
+ if (settled.length > 0) lines.push(settledBlock(settled), "");
339
354
  if (draft) lines.push("## The oracle's current draft plan", "", truncate(draft, 8000), "");
340
355
  lines.push(closing);
341
356
  return lines.join("\n");
@@ -355,6 +370,55 @@ export function decidedBlock(decided: readonly AutoAnswer[]): string {
355
370
  ].join("\n");
356
371
  }
357
372
 
373
+ /** One line of at most `max` characters, for the activity log. */
374
+ function brief(text: string, max: number): string {
375
+ const flat = text.replace(/\s+/g, " ").trim();
376
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
377
+ }
378
+
379
+ /** Two questions this alike (by shared words) ask the same thing. */
380
+ const SAME_QUESTION = 0.8;
381
+
382
+ /** Words that carry no topic: a rewording changes them without changing the question. */
383
+ const FILLER = new Set(["a", "an", "the", "of", "to", "in", "on", "at", "for", "with", "and", "or", "is", "are", "be", "been", "it", "its", "this", "that", "do", "does", "did", "we", "you", "i", "should", "would", "could", "can", "will", "shall", "must", "which", "what", "how", "so", "as", "by", "from", "than", "then", "us", "our", "your"]);
384
+
385
+ function questionWords(text: string): Set<string> {
386
+ const words = text.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").split(/\s+/).filter(Boolean);
387
+ const content = words.filter((word) => !FILLER.has(word));
388
+ return new Set(content.length > 0 ? content : words);
389
+ }
390
+
391
+ /** Whether two questions ask the same thing: the same topic words in any order, or nearly all of them. */
392
+ export function sameQuestion(a: string, b: string): boolean {
393
+ const left = questionWords(a);
394
+ const right = questionWords(b);
395
+ if (left.size === 0 || right.size === 0) return false;
396
+ let shared = 0;
397
+ for (const word of left) if (right.has(word)) shared += 1;
398
+ if (shared === left.size && shared === right.size) return true;
399
+ return Math.min(left.size, right.size) >= 3 && shared / (left.size + right.size - shared) >= SAME_QUESTION;
400
+ }
401
+
402
+ /**
403
+ * Split a round's questions into those still to ask and the repeats of ones
404
+ * the user already settled (answered, or left for the oracle to decide): the
405
+ * user never gets the same question twice, whatever the models write.
406
+ */
407
+ export function withoutSettled(questions: readonly PanelQuestion[], settled: readonly SettledQuestion[]): { asked: PanelQuestion[]; repeats: PanelQuestion[] } {
408
+ const asked: PanelQuestion[] = [];
409
+ const repeats: PanelQuestion[] = [];
410
+ for (const question of questions) (settled.some((entry) => sameQuestion(entry.question, question.text)) ? repeats : asked).push(question);
411
+ return { asked, repeats };
412
+ }
413
+
414
+ /** What the user settled, as every seat and the oracle read it: closed questions, never to be asked again. */
415
+ export function settledBlock(settled: readonly SettledQuestion[]): string {
416
+ return [
417
+ "## Already settled with the user (closed: never ask these again, in any wording)",
418
+ ...settled.map((entry) => `- [${entry.from}] ${entry.question} → ${entry.answer ? entry.answer.replace(/\s*\n\s*/g, " / ") : "(left unanswered: decide it yourself with its recommended option and list it under Assumptions)"}`),
419
+ ].join("\n");
420
+ }
421
+
358
422
  /** The seats' output for the oracle: each seat's status, questions and notes, or its failure. */
359
423
  export function panelSection(outcomes: readonly MemberOutcome[]): string {
360
424
  if (outcomes.length === 0) return "## Panel this round\n\nNo domain seats this round; you are planning alone.";
@@ -438,6 +502,8 @@ export class PlanningSession {
438
502
  saved?: PlannedTask;
439
503
  /** Questionnaires answered so far for this round's questions, so stopping one resumes where it left off. */
440
504
  answered: AskResult[] = [];
505
+ /** The tasks the plan was saved as when it was split (`saved` is the first). */
506
+ savedParts: PlannedTask[] = [];
441
507
  /** Comments on draft lines, sent with the user's next turn. */
442
508
  lineComments: LineComment[] = [];
443
509
  /** How the latest round ran under the round limit. */
@@ -497,21 +563,30 @@ export class PlanningSession {
497
563
  return this.seats.has(member);
498
564
  }
499
565
 
500
- /** Add the user's message (the idea, or answers), with any line comments, and run a round. */
501
- async send(text: string): Promise<void> {
566
+ /**
567
+ * Add the user's message (the idea, or answers), with any line comments, and
568
+ * run a round. `settled` says which of the open questions the message
569
+ * answered (or left), so the panel is never allowed to ask them again.
570
+ */
571
+ async send(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
502
572
  this.autoContinued = false;
503
- await this.post(text);
573
+ await this.post(text, settled);
504
574
  }
505
575
 
506
- private async post(text: string): Promise<void> {
576
+ private async post(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
507
577
  const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
508
578
  if (!body) return;
509
579
  if (this.busy) throw new Error("the panel is still thinking");
510
580
  this.lineComments = [];
511
- this.messages = [...this.messages, { role: "you", text: body, at: Date.now() }];
581
+ this.messages = [...this.messages, { role: "you", text: body, at: Date.now(), ...(settled && settled.length > 0 ? { settled: [...settled] } : {}) }];
512
582
  await this.turn();
513
583
  }
514
584
 
585
+ /** Every question the user has settled so far, oldest first. */
586
+ get settled(): SettledQuestion[] {
587
+ return this.messages.flatMap((message) => message.settled ?? []);
588
+ }
589
+
515
590
  /** The round's questions still wait for answers. */
516
591
  get awaitingAnswers(): boolean {
517
592
  return !this.busy && this.questions.length > 0;
@@ -573,6 +648,120 @@ export class PlanningSession {
573
648
  return this.saved;
574
649
  }
575
650
 
651
+ /** Save each part of a split plan as its own pending task, in order, each knowing the others. */
652
+ saveParts(proposal: SplitProposal, now = new Date()): PlannedTask[] {
653
+ const plan = this.reply?.plan;
654
+ if (!plan) throw new Error("there is no draft plan to save yet");
655
+ const steps = planSteps(plan);
656
+ const group = `SPLIT-${now.getTime().toString(36)}`;
657
+ // Part 1 is saved last of all timestamps' newest, so the newest-first pending list reads in part order.
658
+ const parts = proposal.tasks.map((task, index) => savePlannedTask(this.deps.root, this.deps.configDir, {
659
+ title: task.title,
660
+ brief: partBrief({ plan, steps, proposal, index }),
661
+ ...(this.seed ? { issue: this.seed.issue } : {}),
662
+ split: partInfo(proposal, index, group),
663
+ }, new Date(now.getTime() + (proposal.tasks.length - index))));
664
+ this.savedParts = parts;
665
+ this.saved = parts[0];
666
+ this.deps.feed?.log(ORACLE_LABEL, `split the plan into ${parts.length} tasks: ${parts.map((part) => part.id).join(", ")}`, "success");
667
+ this.deps.onChange?.();
668
+ return parts;
669
+ }
670
+
671
+ /**
672
+ * The oracle's proposal for splitting the plan: run, read, and checked
673
+ * against the rules (once more with the problems when it breaks one).
674
+ * `feedback` is the user's change request on an earlier proposal. A string
675
+ * is the reason there is no proposal.
676
+ */
677
+ private async proposeSplit(plan: string, steps: readonly string[], feedback: { previous: SplitProposal; words: string } | undefined, signal: AbortSignal): Promise<SplitProposal | string> {
678
+ const profile = this.deps.profile();
679
+ const title = this.title ?? "the plan";
680
+ let rejected: string[] = [];
681
+ let reason = "";
682
+ for (let attempt = 0; attempt < 2; attempt += 1) {
683
+ this.step = attempt === 0 ? "deciding how to split the plan" : "fixing the split";
684
+ this.deps.onChange?.();
685
+ const lead = await this.run(ORACLE_LABEL, "planner", profile, {
686
+ task: splitRequest({ title, plan, steps, ...(feedback ? { revision: feedback } : {}), ...(rejected.length > 0 ? { rejected } : {}) }),
687
+ systemPrompt: splitPrompt(profile.instructions),
688
+ tools: this.tools(PLANNER_TOOLS),
689
+ }, signal, (step) => (this.step = step));
690
+ if (signal.aborted) throw new Error("stopped");
691
+ if (lead.status !== "success") return lead.error ?? lead.status;
692
+ try {
693
+ const proposal = parseSplit(lead.output);
694
+ rejected = splitProblems(proposal, steps.length);
695
+ if (rejected.length === 0) return proposal;
696
+ reason = rejected[0]!;
697
+ } catch (error) {
698
+ rejected = [(error as Error).message];
699
+ reason = rejected[0]!;
700
+ }
701
+ }
702
+ return reason;
703
+ }
704
+
705
+ /**
706
+ * Save the plan as a pending task; when it has more steps than
707
+ * `splitAbove`, first let the oracle propose splitting it into up to five
708
+ * tasks and let the user decide: take it, keep the plan whole, or say what to
709
+ * change (the oracle revises, a few times). Nothing the user leaves open is
710
+ * decided for them: a questionnaire they put away saves nothing. Returns the
711
+ * notice for the lobby.
712
+ */
713
+ async saveWithSplit(ask: (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>, options: { splitAbove: number }): Promise<string> {
714
+ const plan = this.reply?.plan;
715
+ if (!plan) throw new Error("there is no draft plan to save yet");
716
+ const steps = planSteps(plan);
717
+ const unagreed = this.reply?.status === "ready" ? "" : " (the panel had not agreed yet)";
718
+ const whole = () => `saved ${this.save().id} to the pending tasks — start it from the Tasks tab${unagreed}`;
719
+ if (options.splitAbove <= 0 || steps.length <= options.splitAbove) return whole();
720
+ if (this.busy) return "the panel is still working — save again once it is done, and the oracle will look at splitting this plan";
721
+ const controller = new AbortController();
722
+ this.controller = controller;
723
+ this.status = "thinking";
724
+ this.error = undefined;
725
+ this.deps.onChange?.();
726
+ try {
727
+ let proposal = await this.proposeSplit(plan, steps, undefined, controller.signal);
728
+ if (typeof proposal === "string") {
729
+ const reason = proposal;
730
+ this.deps.feed?.log(ORACLE_LABEL, `could not split the plan — ${reason.split("\n")[0]}`, "warning");
731
+ this.step = "waiting for you";
732
+ const answer = (await ask([splitFailedQuestion(reason)], controller.signal)).answers[0];
733
+ if (controller.signal.aborted) throw new Error("stopped");
734
+ return answer?.answer === "Save it as one task" ? whole() : "the plan was not saved — ctrl+s tries again";
735
+ }
736
+ for (let revisions = 0; ; revisions += 1) {
737
+ this.step = "waiting for you";
738
+ this.deps.onChange?.();
739
+ const result = await ask([splitQuestion({ stepCount: steps.length, proposal, steps, revisions })], controller.signal);
740
+ // A stopped questionnaire comes back empty too; it was stopped, not left open.
741
+ if (controller.signal.aborted) throw new Error("stopped");
742
+ const answer = result.answers[0];
743
+ if (!answer) return "the split question was left open, so nothing was saved — ctrl+s asks again";
744
+ if (answer.kind === "option") {
745
+ if (answer.answer === KEEP_WHOLE) return whole();
746
+ const parts = this.saveParts(proposal);
747
+ return `split into ${parts.length} tasks — ${parts.map((part) => part.id).join(", ")}; start them from the Tasks tab, in order${unagreed}`;
748
+ }
749
+ if (revisions >= MAX_SPLIT_REVISIONS) return `that was the last revision, and nothing was saved — ctrl+s asks again and the oracle starts over`;
750
+ const revised = await this.proposeSplit(plan, steps, { previous: proposal, words: answer.answer ?? "" }, controller.signal);
751
+ if (typeof revised === "string") return `the oracle could not apply that (${revised.split("\n")[0]}) — nothing was saved; ctrl+s asks again`;
752
+ proposal = revised;
753
+ }
754
+ } catch (error) {
755
+ if (controller.signal.aborted) return "stopped — nothing was saved";
756
+ return `could not save the plan: ${(error as Error).message}`;
757
+ } finally {
758
+ this.status = "idle";
759
+ this.step = undefined;
760
+ this.controller = undefined;
761
+ this.deps.onChange?.();
762
+ }
763
+ }
764
+
576
765
  private stepKey(who: string): string {
577
766
  return `plan-${who}-${this.attempts}`;
578
767
  }
@@ -593,12 +782,12 @@ export class PlanningSession {
593
782
  const startedAt = Date.now();
594
783
  let outcome: RunOutcome;
595
784
  try {
596
- const result = await runPiAgent(
785
+ const attempt = (model: string | undefined, thinking: string) => runPiAgent(
597
786
  {
598
787
  cwd: this.deps.cwd,
599
788
  ...request,
600
- model: profile.model,
601
- thinking: profile.thinking,
789
+ model,
790
+ thinking,
602
791
  timeoutMs: profile.timeoutMs,
603
792
  signal,
604
793
  stallTimeoutMs: this.deps.stallTimeoutMs,
@@ -607,6 +796,12 @@ export class PlanningSession {
607
796
  },
608
797
  this.deps.runProcess ?? spawnPiProcess,
609
798
  );
799
+ // A model that is out of usage hands the turn to the fallback the settings name.
800
+ const { result, switchedFrom } = await withFallback(profile.model, profile.thinking, profile.fallback, attempt);
801
+ if (switchedFrom && profile.fallback) {
802
+ this.deps.feed?.log(label, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`, "warning");
803
+ profile = { ...profile, model: profile.fallback.model, thinking: profile.fallback.thinking };
804
+ }
610
805
  outcome = { status: result.status, output: result.output, ...(result.error ? { error: result.error } : {}), ...(result.model ? { model: result.model } : {}), usage: result.usage };
611
806
  } catch (error) {
612
807
  outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
@@ -679,6 +874,8 @@ export class PlanningSession {
679
874
  this.turns += 1;
680
875
  this.attempts += 1;
681
876
  this.answered = [];
877
+ // The user's reply answers whatever was open: a round that then fails or is stopped must not put those questions to them again.
878
+ this.questions = [];
682
879
  const limit = this.limit;
683
880
  const mode = roundMode(this.turns, limit);
684
881
  this.mode = mode;
@@ -817,7 +1014,9 @@ export class PlanningSession {
817
1014
  private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome, mode: RoundMode): void {
818
1015
  const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
819
1016
  const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
820
- let questions = roundQuestions(reply, seatQuestions);
1017
+ const { asked, repeats } = withoutSettled(roundQuestions(reply, seatQuestions), this.settled);
1018
+ let questions = asked;
1019
+ if (repeats.length > 0) this.deps.feed?.log(ORACLE_LABEL, `held back ${repeats.length} question${repeats.length === 1 ? "" : "s"} you already settled: ${repeats.map((question) => brief(question.text, 60)).join("; ")}`, "info");
821
1020
  const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
822
1021
  let ready = Boolean(reply && reply.status === "ready" && seatsReady);
823
1022
  if (reply) {