@a-t-h-i/bot-lobby 0.6.7 → 0.6.9
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 +182 -16
- package/package.json +5 -1
- package/prompts/master.md +15 -0
- package/prompts/panel.md +4 -0
- package/prompts/planner.md +5 -0
- package/prompts/pr-review.md +62 -0
- package/prompts/splitter.md +58 -0
- package/src/ask/dialog.ts +19 -5
- package/src/ask/state.ts +8 -2
- package/src/ask/tool.ts +3 -1
- package/src/ask/view.ts +6 -3
- package/src/classifier/instance.ts +6 -0
- package/src/classifier/knowledge.ts +158 -0
- package/src/classifier/review.ts +146 -0
- package/src/excalidraw/check.ts +31 -0
- package/src/excalidraw/client.ts +266 -0
- package/src/excalidraw/room.ts +118 -0
- package/src/excalidraw/scene.ts +746 -0
- package/src/excalidraw/sessions.ts +302 -0
- package/src/excalidraw/tools.ts +223 -0
- package/src/execution/agent-runner.ts +2 -0
- package/src/execution/pi-runner.ts +9 -3
- package/src/execution/workspace.ts +185 -0
- package/src/index.ts +3 -0
- package/src/knowledge/edit.ts +109 -0
- package/src/knowledge/notes.ts +143 -0
- package/src/knowledge/selector.ts +1 -1
- package/src/knowledge/store.ts +16 -2
- package/src/lobby/ask.ts +42 -21
- package/src/lobby/issues.ts +1 -1
- package/src/lobby/knowledge.ts +179 -0
- package/src/lobby/layout.ts +95 -18
- package/src/lobby/planner.ts +205 -8
- package/src/lobby/pr-review.ts +360 -0
- package/src/lobby/pulls.ts +250 -0
- package/src/lobby/quickfix.ts +2 -0
- package/src/lobby/runtime.ts +121 -13
- package/src/lobby/split.ts +230 -0
- package/src/lobby/tabs/excalidraw.ts +95 -0
- package/src/lobby/tabs/git.ts +162 -0
- package/src/lobby/tabs/knowledge.ts +135 -0
- package/src/lobby/tabs/plan.ts +3 -1
- package/src/lobby/tabs/tasks.ts +11 -2
- package/src/lobby/view.ts +611 -32
- package/src/master/master.ts +39 -13
- package/src/pi/commands.ts +12 -7
- package/src/pi/model-support.ts +9 -0
- package/src/pi/plan-checklist.ts +42 -0
- package/src/pi/route.ts +2 -1
- package/src/pi/settings-ui.ts +41 -1
- package/src/pi/start-flags.ts +14 -0
- package/src/pi/start-task.ts +56 -5
- package/src/pi/tools.ts +4 -2
- package/src/schemas/configuration.ts +32 -3
- package/src/schemas/task.ts +15 -0
- package/src/state/backlog.ts +27 -4
- package/src/state/metrics.ts +2 -2
- package/src/state/persistence.ts +5 -5
- package/src/text.ts +38 -0
- package/src/workflow/workflow.ts +11 -0
package/src/lobby/runtime.ts
CHANGED
|
@@ -21,9 +21,12 @@ import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state
|
|
|
21
21
|
import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
22
22
|
import { describeToolCall } from "../pi/activity.ts";
|
|
23
23
|
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, taskSnapshot } from "../pi/ui.ts";
|
|
24
|
-
import {
|
|
24
|
+
import { taskName } from "../text.ts";
|
|
25
|
+
import { describeWorkspace, type WorkspaceInfo } from "../execution/workspace.ts";
|
|
26
|
+
import { basename } from "node:path";
|
|
27
|
+
import { stripStartFlags } from "../pi/start-flags.ts";
|
|
25
28
|
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
26
|
-
import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
|
|
29
|
+
import { modelRef, resolveLobbyProfile, resolvePanelProfile, resolveReviewProfile } from "../pi/model-support.ts";
|
|
27
30
|
import { modelLookup } from "../pi/tools.ts";
|
|
28
31
|
import { startPlannedTask } from "../pi/start-task.ts";
|
|
29
32
|
import { pendingRequest, setQuickFixHandoff, startRequest } from "../pi/route.ts";
|
|
@@ -34,11 +37,16 @@ import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
|
34
37
|
import { checkThinking } from "../pi/model-support.ts";
|
|
35
38
|
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
36
39
|
import { SessionChats } from "./session-files.ts";
|
|
37
|
-
import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
|
|
40
|
+
import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
|
|
38
41
|
import { jobTitle, QuickFixQueue } from "./quickfix.ts";
|
|
39
42
|
import { miniLine, type MiniInput } from "./mini.ts";
|
|
40
43
|
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
41
44
|
import { execCommand, IssuesState } from "./issues.ts";
|
|
45
|
+
import { PullsState } from "./pulls.ts";
|
|
46
|
+
import { PullReviews } from "./pr-review.ts";
|
|
47
|
+
import { KnowledgeBook } from "./knowledge.ts";
|
|
48
|
+
import { ExcalidrawBook } from "../excalidraw/sessions.ts";
|
|
49
|
+
import { checkSession } from "../excalidraw/check.ts";
|
|
42
50
|
import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
|
|
43
51
|
import { lobbyTheme } from "./theme.ts";
|
|
44
52
|
import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
|
|
@@ -61,6 +69,10 @@ interface Runtime {
|
|
|
61
69
|
quickfix: QuickFixQueue;
|
|
62
70
|
planner?: PlanningSession;
|
|
63
71
|
issues: IssuesState;
|
|
72
|
+
pulls: PullsState;
|
|
73
|
+
reviews: PullReviews;
|
|
74
|
+
knowledge: KnowledgeBook;
|
|
75
|
+
excalidraw: ExcalidrawBook;
|
|
64
76
|
unsubscribeFeed?: () => void;
|
|
65
77
|
/** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
|
|
66
78
|
asker?: Asker;
|
|
@@ -70,6 +82,9 @@ interface Runtime {
|
|
|
70
82
|
mouse: boolean;
|
|
71
83
|
/** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
|
|
72
84
|
inSettings: boolean;
|
|
85
|
+
/** The repository (or folder) and branch the lobby's title shows, and whether git is being asked now. */
|
|
86
|
+
workspace: WorkspaceInfo;
|
|
87
|
+
readingWorkspace: boolean;
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
let runtime: Runtime | undefined;
|
|
@@ -265,7 +280,8 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
|
|
|
265
280
|
const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
|
|
266
281
|
if (starting) return `${plan.id} is already starting in ${starting.name}`;
|
|
267
282
|
}
|
|
268
|
-
|
|
283
|
+
// A request may lead with flags (`--worktree add login`); the session is named after what follows them.
|
|
284
|
+
const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
|
|
269
285
|
try {
|
|
270
286
|
const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
271
287
|
lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
|
|
@@ -445,6 +461,15 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
|
|
|
445
461
|
return `deleted ${taskId} for good`;
|
|
446
462
|
}
|
|
447
463
|
|
|
464
|
+
/** What a pull request review runs on: QA's model, thinking and time limit. */
|
|
465
|
+
function reviewProfile(state: Runtime) {
|
|
466
|
+
return resolveReviewProfile(loadConfig(), {
|
|
467
|
+
lookup: modelLookup(state.ctx),
|
|
468
|
+
sessionModel: sessionModel(state.ctx),
|
|
469
|
+
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
448
473
|
function seatProfile(state: Runtime, member: PanelMember) {
|
|
449
474
|
return resolvePanelProfile(loadConfig(), member, {
|
|
450
475
|
lookup: modelLookup(state.ctx),
|
|
@@ -485,6 +510,26 @@ function panelAsker(state: Runtime): Asker {
|
|
|
485
510
|
return state.asker ?? askUser;
|
|
486
511
|
}
|
|
487
512
|
|
|
513
|
+
/**
|
|
514
|
+
* Save the plan as a pending task. A plan with many steps is first split by
|
|
515
|
+
* the oracle into up to five tasks, which the user takes, changes or declines
|
|
516
|
+
* in a questionnaire; the split's questions open over the lobby like the
|
|
517
|
+
* panel's. Returns a notice for the lobby.
|
|
518
|
+
*/
|
|
519
|
+
export async function savePlan(state: Runtime | undefined = runtime): Promise<string> {
|
|
520
|
+
const session = state?.planner;
|
|
521
|
+
if (!state || !session) return "no planning session";
|
|
522
|
+
if (state.asking) return "a questionnaire is already open";
|
|
523
|
+
state.asking = true;
|
|
524
|
+
try {
|
|
525
|
+
const asker = panelAsker(state);
|
|
526
|
+
return await session.saveWithSplit((questions, signal) => asker(questions, state.ctx, signal), { splitAbove: loadConfig().lobby.splitPlanAbove });
|
|
527
|
+
} finally {
|
|
528
|
+
state.asking = false;
|
|
529
|
+
rerender();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
488
533
|
/**
|
|
489
534
|
* The oracle puts the round's questions to the user, one at a time; answered
|
|
490
535
|
* questionnaires are kept if the user stops, so the next call resumes there.
|
|
@@ -514,7 +559,7 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
514
559
|
session.answered = [];
|
|
515
560
|
return "nothing was answered — the questions stay open";
|
|
516
561
|
}
|
|
517
|
-
void session.send(message);
|
|
562
|
+
void session.send(message, settledQuestions(session.questions, session.answered));
|
|
518
563
|
return "answers sent — the panel is on the next round";
|
|
519
564
|
} catch (error) {
|
|
520
565
|
return `could not put the questions: ${(error as Error).message}`;
|
|
@@ -524,6 +569,22 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
524
569
|
}
|
|
525
570
|
}
|
|
526
571
|
|
|
572
|
+
/** Read the repository name and branch again, off the render path; the title repaints when they changed. */
|
|
573
|
+
function refreshWorkspace(state: Runtime): void {
|
|
574
|
+
if (state.readingWorkspace) return;
|
|
575
|
+
state.readingWorkspace = true;
|
|
576
|
+
describeWorkspace(state.ctx.cwd)
|
|
577
|
+
.then((info) => {
|
|
578
|
+
if (runtime !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
|
|
579
|
+
state.workspace = info;
|
|
580
|
+
rerender();
|
|
581
|
+
})
|
|
582
|
+
.catch(() => {})
|
|
583
|
+
.finally(() => {
|
|
584
|
+
state.readingWorkspace = false;
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
527
588
|
function host(state: Runtime, tui: TUI): LobbyHost {
|
|
528
589
|
return {
|
|
529
590
|
rows: () => tui.terminal.rows,
|
|
@@ -550,6 +611,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
550
611
|
planner: () => state.planner,
|
|
551
612
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
552
613
|
answerPanel: () => answerPanel(state),
|
|
614
|
+
savePlan: () => savePlan(state),
|
|
553
615
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
554
616
|
planningRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
555
617
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
@@ -562,10 +624,18 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
562
624
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
563
625
|
},
|
|
564
626
|
issues: state.issues,
|
|
627
|
+
pulls: state.pulls,
|
|
628
|
+
reviews: state.reviews,
|
|
629
|
+
knowledge: state.knowledge,
|
|
630
|
+
excalidraw: state.excalidraw,
|
|
631
|
+
checkExcalidraw: (session) => checkSession(session.link, session.name),
|
|
632
|
+
editText: (title, text) => editText(state, title, text),
|
|
565
633
|
profileLabel: (kind) => {
|
|
566
634
|
const profile = lobbyProfile(state, kind);
|
|
567
635
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
568
636
|
},
|
|
637
|
+
workspace: () => state.workspace,
|
|
638
|
+
refreshWorkspace: () => refreshWorkspace(state),
|
|
569
639
|
sessionName: () => state.pi.getSessionName(),
|
|
570
640
|
sessions: () => backgroundSessions(),
|
|
571
641
|
startSession: (start) => startSession(state, start),
|
|
@@ -588,20 +658,20 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
588
658
|
}
|
|
589
659
|
|
|
590
660
|
/**
|
|
591
|
-
*
|
|
592
|
-
* lobby
|
|
593
|
-
* not flash between them
|
|
661
|
+
* Run something pi draws itself (its settings menus, its multi-line editor)
|
|
662
|
+
* with the lobby stepped aside for the whole visit, not only for each dialog,
|
|
663
|
+
* so it does not flash between them; the lobby rereads the config after.
|
|
594
664
|
*/
|
|
595
|
-
async function
|
|
596
|
-
if (state.inSettings) return;
|
|
665
|
+
async function whileAside<T>(state: Runtime, run: () => Promise<T>, failure: string): Promise<T | undefined> {
|
|
666
|
+
if (state.inSettings) return undefined;
|
|
597
667
|
state.inSettings = true;
|
|
598
668
|
setMouse(state, false);
|
|
599
669
|
state.handle?.setHidden(true);
|
|
600
670
|
try {
|
|
601
|
-
|
|
602
|
-
else await openSettings(state.pi, state.ctx);
|
|
671
|
+
return await run();
|
|
603
672
|
} catch (error) {
|
|
604
|
-
state.ctx.ui.notify(`bot-lobby:
|
|
673
|
+
state.ctx.ui.notify(`bot-lobby: ${failure} — ${(error as Error).message}`, "warning");
|
|
674
|
+
return undefined;
|
|
605
675
|
} finally {
|
|
606
676
|
state.inSettings = false;
|
|
607
677
|
state.asideForPrompt = false;
|
|
@@ -615,6 +685,16 @@ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Pr
|
|
|
615
685
|
}
|
|
616
686
|
}
|
|
617
687
|
|
|
688
|
+
/** bot-lobby's settings (or one agent's entry) from inside the lobby. */
|
|
689
|
+
async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
|
|
690
|
+
await whileAside(state, () => (entry ? openEntrySettings(state.pi, state.ctx, entry) : openSettings(state.pi, state.ctx)), "settings failed");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** A text edited in pi's multi-line editor (Enter saves, Shift+Enter is a new line); undefined when cancelled. */
|
|
694
|
+
async function editText(state: Runtime, title: string, text: string): Promise<string | undefined> {
|
|
695
|
+
return whileAside(state, () => state.ctx.ui.editor(title, text), "the editor failed");
|
|
696
|
+
}
|
|
697
|
+
|
|
618
698
|
/** Remember which panes show, keeping every other setting as the file has it now. */
|
|
619
699
|
function savePanels(panels: Record<LobbyPanel, boolean>): void {
|
|
620
700
|
try {
|
|
@@ -718,6 +798,7 @@ function shutdown(): void {
|
|
|
718
798
|
setMouse(state, false);
|
|
719
799
|
state.unsubscribeFeed?.();
|
|
720
800
|
state.quickfix.cancelAll();
|
|
801
|
+
state.reviews.cancelAll();
|
|
721
802
|
state.planner?.cancel();
|
|
722
803
|
state.view?.dispose();
|
|
723
804
|
state.handle?.hide();
|
|
@@ -759,9 +840,36 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
759
840
|
asking: false,
|
|
760
841
|
mouse: false,
|
|
761
842
|
inSettings: false,
|
|
843
|
+
workspace: { name: basename(ctx.cwd) || ctx.cwd },
|
|
844
|
+
readingWorkspace: false,
|
|
762
845
|
quickfix: undefined as unknown as QuickFixQueue,
|
|
763
846
|
issues: new IssuesState(execCommand, ctx.cwd, rerender),
|
|
847
|
+
pulls: new PullsState(execCommand, ctx.cwd, rerender),
|
|
848
|
+
reviews: undefined as unknown as PullReviews,
|
|
849
|
+
knowledge: new KnowledgeBook({
|
|
850
|
+
root,
|
|
851
|
+
configDir,
|
|
852
|
+
threshold: () => loadConfig().knowledge.compactionThreshold,
|
|
853
|
+
backups: () => loadConfig().knowledge.backupCount,
|
|
854
|
+
sessionId: () => ctx.sessionManager.getSessionId(),
|
|
855
|
+
}),
|
|
856
|
+
excalidraw: new ExcalidrawBook({ root }),
|
|
764
857
|
};
|
|
858
|
+
state.reviews = new PullReviews({
|
|
859
|
+
cwd: ctx.cwd,
|
|
860
|
+
root,
|
|
861
|
+
configDir,
|
|
862
|
+
exec: execCommand,
|
|
863
|
+
profile: () => reviewProfile(state),
|
|
864
|
+
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
865
|
+
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
866
|
+
feed: lobbyFeed,
|
|
867
|
+
onChange: rerender,
|
|
868
|
+
notify: (message, level) => {
|
|
869
|
+
if (!state.visible) ctx.ui.notify(message, level);
|
|
870
|
+
},
|
|
871
|
+
classifier: classifier(),
|
|
872
|
+
});
|
|
765
873
|
state.quickfix = new QuickFixQueue({
|
|
766
874
|
cwd: ctx.cwd,
|
|
767
875
|
root,
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splitting a long plan into tasks. When the user saves a plan with many
|
|
3
|
+
* steps, the oracle proposes how to cut it into two to five tasks, the user
|
|
4
|
+
* decides (keep it whole, take the split, or say what to change), and each part
|
|
5
|
+
* is saved as a pending task that knows its siblings.
|
|
6
|
+
*
|
|
7
|
+
* The oracle decides where the lines go; the engine enforces the rest: at
|
|
8
|
+
* most `MAX_SPLIT_TASKS` tasks, every step of the plan in exactly one of them,
|
|
9
|
+
* and a task only ever building on earlier ones. A proposal that breaks a rule
|
|
10
|
+
* is sent back once with the problems, and never reaches the user. Each part's
|
|
11
|
+
* brief is composed here from the plan as written (its objective, decisions,
|
|
12
|
+
* assumptions and risks stay whole, its steps section holds only that part's
|
|
13
|
+
* steps), so nothing the user agreed can be lost in a model's retelling.
|
|
14
|
+
*/
|
|
15
|
+
import { loadPrompt } from "../prompts/loader.ts";
|
|
16
|
+
import { planStepBlocks } from "../pi/plan-checklist.ts";
|
|
17
|
+
import type { SplitInfo } from "../state/backlog.ts";
|
|
18
|
+
import type { AskQuestion } from "../ask/types.ts";
|
|
19
|
+
|
|
20
|
+
/** The most tasks a plan is split into. */
|
|
21
|
+
export const MAX_SPLIT_TASKS = 5;
|
|
22
|
+
/** Times the user may ask the oracle to change a split before it is final. */
|
|
23
|
+
export const MAX_SPLIT_REVISIONS = 3;
|
|
24
|
+
|
|
25
|
+
export interface SplitTask {
|
|
26
|
+
title: string;
|
|
27
|
+
goal: string;
|
|
28
|
+
/** The plan's steps (numbered from 1) this task does. */
|
|
29
|
+
covers: number[];
|
|
30
|
+
/** Earlier tasks (numbered from 1) it builds on. */
|
|
31
|
+
after: number[];
|
|
32
|
+
done: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SplitProposal {
|
|
36
|
+
tasks: SplitTask[];
|
|
37
|
+
/** What the oracle tells the user about the split. */
|
|
38
|
+
note?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function splitPrompt(instructions?: string): string {
|
|
42
|
+
const custom = instructions?.trim();
|
|
43
|
+
return custom ? `${loadPrompt("splitter.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("splitter.md");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `3, 5-6` as [3, 5, 6]; `none` (or nothing) as []. Ranges are bounded, so a stray `1-999999` cannot fill memory. */
|
|
47
|
+
export function parseNumbers(text: string): number[] {
|
|
48
|
+
const found = new Set<number>();
|
|
49
|
+
for (const match of text.matchAll(/(\d+)(?:\s*[-–—]\s*(\d+))?/g)) {
|
|
50
|
+
const first = Number(match[1]);
|
|
51
|
+
const last = match[2] ? Number(match[2]) : first;
|
|
52
|
+
for (let number = first; number <= Math.min(last, first + 200); number += 1) found.add(number);
|
|
53
|
+
}
|
|
54
|
+
return [...found].sort((a, b) => a - b);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read the oracle's reply into a proposal; throws a one-line reason when it has no tasks at all. */
|
|
58
|
+
export function parseSplit(text: string): SplitProposal {
|
|
59
|
+
const [head = "", ...after] = text.split(/^##\s+Note\s*$/im);
|
|
60
|
+
const note = after.join("\n").trim().replace(/\s+/g, " ") || undefined;
|
|
61
|
+
const parts = head.split(/^###\s+\d+[.):]?\s*/m).slice(1);
|
|
62
|
+
if (parts.length === 0) throw new Error("no tasks found: write each as `### 1. Title` with Goal, Covers, After and Done when");
|
|
63
|
+
const tasks = parts.map((part): SplitTask => {
|
|
64
|
+
const lines = part.split("\n");
|
|
65
|
+
const title = (lines[0] ?? "").replace(/[*_`#]/g, "").trim();
|
|
66
|
+
const value = (key: string) => new RegExp(`^\\s*[-*]?\\s*\\**${key}\\**\\s*:\\**\\s*(.*)$`, "im").exec(part)?.[1]?.trim() ?? "";
|
|
67
|
+
const doneAt = lines.findIndex((line) => /^\s*\**done when\**\s*:/i.test(line));
|
|
68
|
+
const inline = doneAt < 0 ? undefined : /:\**\s*(\S.*)$/.exec(lines[doneAt]!)?.[1];
|
|
69
|
+
const bullets = doneAt < 0 ? [] : lines.slice(doneAt + 1).filter((line) => /^\s*[-*]\s+\S/.test(line)).map((line) => line.replace(/^\s*[-*]\s+/, "").trim());
|
|
70
|
+
const done = [...(inline ? [inline] : []), ...bullets];
|
|
71
|
+
return { title, goal: value("goal"), covers: parseNumbers(value("covers")), after: parseNumbers(value("after")), done };
|
|
72
|
+
});
|
|
73
|
+
return { tasks, ...(note ? { note } : {}) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every rule a proposal breaks, in words the oracle can act on; empty when it is sound. */
|
|
77
|
+
export function splitProblems(proposal: SplitProposal, stepCount: number, max = MAX_SPLIT_TASKS): string[] {
|
|
78
|
+
const problems: string[] = [];
|
|
79
|
+
const { tasks } = proposal;
|
|
80
|
+
if (tasks.length < 2) problems.push("a split needs at least 2 tasks");
|
|
81
|
+
if (tasks.length > max) problems.push(`a plan is split into at most ${max} tasks, not ${tasks.length}`);
|
|
82
|
+
const owner = new Map<number, number>();
|
|
83
|
+
tasks.forEach((task, index) => {
|
|
84
|
+
const number = index + 1;
|
|
85
|
+
if (!task.title) problems.push(`task ${number} has no title`);
|
|
86
|
+
if (task.covers.length === 0) problems.push(`task ${number} covers no steps`);
|
|
87
|
+
for (const step of task.covers) {
|
|
88
|
+
if (step < 1 || step > stepCount) problems.push(`task ${number} covers step ${step}, but the plan has steps 1 to ${stepCount}`);
|
|
89
|
+
else if (owner.has(step)) problems.push(`step ${step} is in both task ${owner.get(step)} and task ${number}`);
|
|
90
|
+
else owner.set(step, number);
|
|
91
|
+
}
|
|
92
|
+
for (const earlier of task.after) if (earlier >= number || earlier < 1) problems.push(`task ${number} cannot come after task ${earlier}: list the tasks in the order they can be done, each building only on earlier ones`);
|
|
93
|
+
});
|
|
94
|
+
const missing = Array.from({ length: stepCount }, (_, index) => index + 1).filter((step) => !owner.has(step));
|
|
95
|
+
if (missing.length > 0) problems.push(`step${missing.length === 1 ? "" : "s"} ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} in no task: every step belongs to exactly one`);
|
|
96
|
+
return [...new Set(problems)];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `1-3, 5` for step numbers. */
|
|
100
|
+
export function rangeWords(numbers: readonly number[]): string {
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
for (let index = 0; index < numbers.length; ) {
|
|
103
|
+
let end = index;
|
|
104
|
+
while (end + 1 < numbers.length && numbers[end + 1] === numbers[end]! + 1) end += 1;
|
|
105
|
+
parts.push(end > index ? `${numbers[index]}-${numbers[end]}` : String(numbers[index]));
|
|
106
|
+
index = end + 1;
|
|
107
|
+
}
|
|
108
|
+
return parts.join(", ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What the oracle is asked: the plan, its steps as numbered here, and, on a revision, the split so far with what the user wants changed. */
|
|
112
|
+
export function splitRequest(input: { title: string; plan: string; steps: readonly string[]; max?: number; revision?: { previous: SplitProposal; words: string }; rejected?: readonly string[] }): string {
|
|
113
|
+
const max = input.max ?? MAX_SPLIT_TASKS;
|
|
114
|
+
const lines = [
|
|
115
|
+
`Split this plan into 2 to ${max} tasks: ${input.title}`,
|
|
116
|
+
"",
|
|
117
|
+
`The plan has ${input.steps.length} steps. They are numbered here in the order of its Steps section; use these numbers in Covers.`,
|
|
118
|
+
...input.steps.map((step, index) => `${index + 1}. ${step.replace(/\s+/g, " ").trim()}`),
|
|
119
|
+
"",
|
|
120
|
+
"## The plan",
|
|
121
|
+
"",
|
|
122
|
+
input.plan.trim(),
|
|
123
|
+
];
|
|
124
|
+
if (input.revision) {
|
|
125
|
+
lines.push("", "## The split so far", "", proposalText(input.revision.previous), "", `## What the user wants changed`, "", input.revision.words.trim(), "", "Apply exactly that and keep the rest of the split as it is.");
|
|
126
|
+
}
|
|
127
|
+
if (input.rejected && input.rejected.length > 0) {
|
|
128
|
+
lines.push("", "## Your last split was rejected", "", ...input.rejected.map((problem) => `- ${problem}`), "", "Answer again in the required format with a split that breaks none of these.");
|
|
129
|
+
}
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A proposal as the oracle's own format, so a revision starts from what it (and the user) saw. */
|
|
134
|
+
export function proposalText(proposal: SplitProposal): string {
|
|
135
|
+
return [
|
|
136
|
+
"## Tasks",
|
|
137
|
+
...proposal.tasks.flatMap((task, index) => [
|
|
138
|
+
`### ${index + 1}. ${task.title}`,
|
|
139
|
+
`Goal: ${task.goal}`,
|
|
140
|
+
`Covers: ${rangeWords(task.covers)}`,
|
|
141
|
+
`After: ${task.after.length > 0 ? rangeWords(task.after) : "none"}`,
|
|
142
|
+
...(task.done.length > 0 ? ["Done when:", ...task.done.map((point) => `- ${point}`)] : []),
|
|
143
|
+
"",
|
|
144
|
+
]),
|
|
145
|
+
...(proposal.note ? ["## Note", proposal.note] : []),
|
|
146
|
+
].join("\n").trimEnd();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Markdown for the questionnaire's preview: each task with the steps it takes. */
|
|
150
|
+
export function splitPreview(proposal: SplitProposal, steps: readonly string[]): string {
|
|
151
|
+
const clip = (text: string) => {
|
|
152
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
153
|
+
return flat.length > 110 ? `${flat.slice(0, 109)}…` : flat;
|
|
154
|
+
};
|
|
155
|
+
return proposal.tasks.map((task, index) => [
|
|
156
|
+
`**${index + 1}. ${task.title}** · step${task.covers.length === 1 ? "" : "s"} ${rangeWords(task.covers)}${task.after.length > 0 ? ` · after ${rangeWords(task.after)}` : ""}`,
|
|
157
|
+
...(task.goal ? [`_${clip(task.goal)}_`] : []),
|
|
158
|
+
...task.covers.map((step) => `- ${step}. ${clip(steps[step - 1] ?? "")}`),
|
|
159
|
+
].join("\n")).join("\n\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The question the user answers: take the split, keep the plan whole, or write what to change. */
|
|
163
|
+
export function splitQuestion(input: { stepCount: number; proposal: SplitProposal; steps: readonly string[]; revisions: number }): AskQuestion {
|
|
164
|
+
const count = input.proposal.tasks.length;
|
|
165
|
+
const last = input.revisions >= MAX_SPLIT_REVISIONS;
|
|
166
|
+
return {
|
|
167
|
+
header: "Split plan",
|
|
168
|
+
question: [
|
|
169
|
+
`This plan has **${input.stepCount} steps**. The oracle suggests splitting it into **${count} tasks**, each finished and reviewed on its own.`,
|
|
170
|
+
...(input.proposal.note ? [input.proposal.note] : []),
|
|
171
|
+
last ? "This is the last revision: take it, or keep the plan whole." : "Type what to change (for example *merge 2 and 3*) to revise it.",
|
|
172
|
+
].join("\n\n"),
|
|
173
|
+
options: [
|
|
174
|
+
{ label: `${splitLabel(count)} (Recommended)`, description: "Each part is saved as its own pending task, in order, knowing the others.", preview: splitPreview(input.proposal, input.steps) },
|
|
175
|
+
{ label: "Keep it as one task", description: `One task with all ${input.stepCount} steps.` },
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const KEEP_WHOLE = "Keep it as one task";
|
|
181
|
+
|
|
182
|
+
export function splitLabel(count: number): string {
|
|
183
|
+
return `Split into ${count} tasks`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Ask whether to save a plan as one task after the oracle could not split it. */
|
|
187
|
+
export function splitFailedQuestion(reason: string): AskQuestion {
|
|
188
|
+
return {
|
|
189
|
+
header: "Split plan",
|
|
190
|
+
question: `The oracle could not split this plan (${reason.replace(/\s+/g, " ").trim()}). Save it as one task?`,
|
|
191
|
+
options: [
|
|
192
|
+
{ label: "Save it as one task", description: "All the steps in one pending task." },
|
|
193
|
+
{ label: "Not now", description: "Save nothing; ctrl+s asks again." },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The original plan with its Steps section holding only `covers` (renumbered); undefined when the plan's steps are not a list. */
|
|
199
|
+
function planWithSteps(plan: string, covers: readonly number[], stepCount: number): string | undefined {
|
|
200
|
+
const found = planStepBlocks(plan);
|
|
201
|
+
if (!found || found.blocks.length !== stepCount) return undefined;
|
|
202
|
+
const lines = plan.split("\n");
|
|
203
|
+
const kept = covers.map((step, index) => found.blocks[step - 1]!.replace(/^(\s*)\d+([.)])/, (_all, indent: string, mark: string) => `${indent}${index + 1}${mark}`));
|
|
204
|
+
const spacer = found.to > found.from && !(lines[found.to - 1] ?? "").trim() ? [""] : [];
|
|
205
|
+
return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...lines.slice(found.to)].join("\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** One part's brief: where it sits, what it delivers, then the plan as written with only its own steps. */
|
|
209
|
+
export function partBrief(input: { plan: string; steps: readonly string[]; proposal: SplitProposal; index: number }): string {
|
|
210
|
+
const { proposal, index } = input;
|
|
211
|
+
const task = proposal.tasks[index]!;
|
|
212
|
+
const of = proposal.tasks.length;
|
|
213
|
+
const head = [
|
|
214
|
+
`### Part ${index + 1} of ${of} — ${task.title}`,
|
|
215
|
+
...(task.goal ? [`**Goal of this part:** ${task.goal}`] : []),
|
|
216
|
+
...(task.after.length > 0 ? [`**Builds on:** ${task.after.map((part) => `part ${part} — ${proposal.tasks[part - 1]?.title ?? ""}`).join("; ")} (finish that first).`] : []),
|
|
217
|
+
...(task.done.length > 0 ? ["**Done when:**", ...task.done.map((point) => `- ${point}`)] : []),
|
|
218
|
+
].join("\n");
|
|
219
|
+
const own = planWithSteps(input.plan, task.covers, input.steps.length);
|
|
220
|
+
if (own) return `${head}\n\n${own.trim()}`;
|
|
221
|
+
// Steps that are not a list: the whole plan, with this part's steps named on top.
|
|
222
|
+
const named = task.covers.map((step, position) => `${position + 1}. ${(input.steps[step - 1] ?? "").replace(/\s+/g, " ").trim()}`);
|
|
223
|
+
return `${head}\n\n**Steps of this part (do only these; the plan below is the whole plan, for context):**\n${named.join("\n")}\n\n${input.plan.trim()}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** What each saved part knows of the others. */
|
|
227
|
+
export function partInfo(proposal: SplitProposal, index: number, group: string): SplitInfo {
|
|
228
|
+
const task = proposal.tasks[index]!;
|
|
229
|
+
return { group, part: index + 1, of: proposal.tasks.length, titles: proposal.tasks.map((entry) => entry.title), after: [...task.after] };
|
|
230
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Excalidraw tab: the shared sessions the user has added (five at most),
|
|
3
|
+
* on the left, and the picked one on the right — its link, whether agents may
|
|
4
|
+
* draw in it, the last check of it, and every agent it can be assigned to as a
|
|
5
|
+
* checklist. Assigned agents get the Excalidraw tools and join the room under
|
|
6
|
+
* their own name.
|
|
7
|
+
*/
|
|
8
|
+
import { AGENT_LABELS, EXCALIDRAW_AGENTS, MAX_SESSIONS, type ExcalidrawSession } from "../../excalidraw/sessions.ts";
|
|
9
|
+
import { bold, columns, fill, paint, rule, selectRow, spinner, split, spread, windowStart, wrap, type LobbyTheme } from "../layout.ts";
|
|
10
|
+
|
|
11
|
+
/** What the last check of a session found. */
|
|
12
|
+
export interface SessionCheck {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExcalidrawTabInput {
|
|
18
|
+
sessions: readonly ExcalidrawSession[];
|
|
19
|
+
selected: number;
|
|
20
|
+
focus: "list" | "detail";
|
|
21
|
+
/** The agent under the cursor in the checklist. */
|
|
22
|
+
agent: number;
|
|
23
|
+
checks: ReadonlyMap<string, SessionCheck>;
|
|
24
|
+
/** Ids of the sessions being checked now. */
|
|
25
|
+
checking: ReadonlySet<string>;
|
|
26
|
+
tick: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const EXCALIDRAW_COLUMNS_MIN = 90;
|
|
30
|
+
|
|
31
|
+
function agentCount(session: ExcalidrawSession): string {
|
|
32
|
+
const count = session.agents.length;
|
|
33
|
+
return count === 0 ? "no agents" : `${count} agent${count === 1 ? "" : "s"}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The sessions, the selected one in view. */
|
|
37
|
+
export function sessionRows(input: ExcalidrawTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
38
|
+
const rows = input.sessions.map((session, index) => {
|
|
39
|
+
const selected = index === input.selected;
|
|
40
|
+
const mark = input.checking.has(session.id) ? paint(theme, "accent", spinner(input.tick)) : input.checks.get(session.id)?.ok === false ? paint(theme, "warning", "!") : input.checks.has(session.id) ? paint(theme, "success", "✓") : " ";
|
|
41
|
+
const facts = paint(theme, session.agents.length > 0 ? "accent" : "dim", agentCount(session));
|
|
42
|
+
return selectRow(theme, spread(`${mark} ${selected ? bold(theme, session.name) : session.name}`, facts, width - 2), width, selected, input.focus === "list");
|
|
43
|
+
});
|
|
44
|
+
return rows.slice(windowStart(input.selected, rows.length, height), windowStart(input.selected, rows.length, height) + height);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function emptyLines(width: number, theme?: LobbyTheme): string[] {
|
|
48
|
+
return [
|
|
49
|
+
...wrap(paint(theme, "dim", "No sessions yet. A session is a live Excalidraw room that you and your agents draw in together."), width),
|
|
50
|
+
"",
|
|
51
|
+
...wrap(`${paint(theme, "accent", "a")} add one: in Excalidraw, Share → Live collaboration → Start session, then paste the link`, width),
|
|
52
|
+
...wrap(`${paint(theme, "accent", "n")} or make a new room here and open its link in Excalidraw`, width),
|
|
53
|
+
"",
|
|
54
|
+
...wrap(paint(theme, "dim", `Up to ${MAX_SESSIONS} sessions; each can be assigned to one agent or several.`), width),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The picked session: its link and state, then the agents it can be assigned to. */
|
|
59
|
+
export function detailLines(input: ExcalidrawTabInput, width: number, theme?: LobbyTheme): string[] {
|
|
60
|
+
const session = input.sessions[input.selected];
|
|
61
|
+
if (!session) return [];
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
lines.push(...wrap(paint(theme, "muted", session.link), width));
|
|
64
|
+
lines.push(paint(theme, session.contribute ? "success" : "warning", session.contribute ? "agents may draw here (w: look only)" : "agents may only look (w: let them draw)"));
|
|
65
|
+
const check = input.checks.get(session.id);
|
|
66
|
+
if (input.checking.has(session.id)) lines.push(paint(theme, "accent", `${spinner(input.tick)} checking the room…`));
|
|
67
|
+
else if (check) lines.push(...wrap(paint(theme, check.ok ? "success" : "warning", `${check.ok ? "✓" : "!"} ${check.text}`), width));
|
|
68
|
+
else lines.push(paint(theme, "dim", "t checks that the room can be reached"));
|
|
69
|
+
lines.push("", rule(width, "Assigned to", theme, agentCount(session)));
|
|
70
|
+
EXCALIDRAW_AGENTS.forEach((agent, index) => {
|
|
71
|
+
const on = session.agents.includes(agent);
|
|
72
|
+
const picked = input.focus === "detail" && index === input.agent;
|
|
73
|
+
const box = on ? paint(theme, "success", "[x]") : paint(theme, "dim", "[ ]");
|
|
74
|
+
const cursor = picked ? paint(theme, "accent", "▸") : " ";
|
|
75
|
+
const label = on ? AGENT_LABELS[agent] : paint(theme, "muted", AGENT_LABELS[agent]);
|
|
76
|
+
lines.push(selectRow(theme, `${cursor} ${box} ${picked ? bold(theme, label) : label}`, width, picked, true));
|
|
77
|
+
});
|
|
78
|
+
lines.push("", ...wrap(paint(theme, "dim", "Assigned agents read the board and draw on it with excalidraw_read and excalidraw_draw, and appear in the room under their own name."), width));
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderExcalidraw(input: ExcalidrawTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
|
|
83
|
+
if (height <= 0) return [];
|
|
84
|
+
const count = `${input.sessions.length}/${MAX_SESSIONS}`;
|
|
85
|
+
if (input.sessions.length === 0) return fill([rule(width, "Excalidraw", theme, count), ...emptyLines(width, theme)], height, width);
|
|
86
|
+
const selected = Math.min(Math.max(0, input.selected), input.sessions.length - 1);
|
|
87
|
+
const view = { ...input, selected };
|
|
88
|
+
const wide = width >= EXCALIDRAW_COLUMNS_MIN;
|
|
89
|
+
const [listWidth, detailWidth] = wide ? split(width, 0.34, 3, 44) : [width, width];
|
|
90
|
+
const listPane = fill([rule(listWidth, "Sessions", theme, count), ...sessionRows(view, listWidth, height - 1, theme)], height);
|
|
91
|
+
const session = input.sessions[selected]!;
|
|
92
|
+
const detailPane = fill([rule(detailWidth, `${session.name}${input.focus === "detail" ? " ◂" : ""}`, theme), ...detailLines(view, detailWidth, theme)], height);
|
|
93
|
+
if (!wide) return fill(input.focus === "detail" ? detailPane : listPane, height, width);
|
|
94
|
+
return fill(columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme), height, width);
|
|
95
|
+
}
|