@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.
- package/README.md +183 -11
- package/package.json +1 -1
- package/prompts/master.md +21 -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 +23 -7
- package/src/ask/state.ts +18 -3
- package/src/ask/tool.ts +9 -2
- package/src/ask/view.ts +8 -4
- package/src/classifier/instance.ts +6 -0
- package/src/classifier/knowledge.ts +158 -0
- package/src/classifier/review.ts +146 -0
- package/src/execution/agent-runner.ts +23 -1
- package/src/execution/fallback.ts +75 -0
- package/src/execution/workspace.ts +185 -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/feed.ts +7 -0
- package/src/lobby/issues.ts +1 -1
- package/src/lobby/knowledge.ts +179 -0
- package/src/lobby/layout.ts +99 -1
- package/src/lobby/mini.ts +159 -0
- package/src/lobby/planner.ts +209 -10
- package/src/lobby/pr-review.ts +360 -0
- package/src/lobby/pulls.ts +250 -0
- package/src/lobby/quickfix.ts +17 -3
- package/src/lobby/runtime.ts +142 -17
- package/src/lobby/split.ts +230 -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 +450 -35
- package/src/master/master.ts +40 -14
- package/src/master/research.ts +1 -0
- package/src/pi/commands.ts +12 -7
- package/src/pi/events.ts +2 -0
- package/src/pi/master-fallback.ts +61 -0
- package/src/pi/model-support.ts +27 -3
- package/src/pi/plan-checklist.ts +42 -0
- package/src/pi/route.ts +2 -1
- package/src/pi/settings-ui.ts +104 -11
- 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 +74 -11
- package/src/schemas/findings.ts +2 -0
- 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 +23 -2
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,10 +37,14 @@ 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";
|
|
38
|
-
import { QuickFixQueue } from "./quickfix.ts";
|
|
40
|
+
import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
|
|
41
|
+
import { jobTitle, QuickFixQueue } from "./quickfix.ts";
|
|
42
|
+
import { miniLine, type MiniInput } from "./mini.ts";
|
|
39
43
|
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
40
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";
|
|
41
48
|
import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
|
|
42
49
|
import { lobbyTheme } from "./theme.ts";
|
|
43
50
|
import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
|
|
@@ -60,6 +67,9 @@ interface Runtime {
|
|
|
60
67
|
quickfix: QuickFixQueue;
|
|
61
68
|
planner?: PlanningSession;
|
|
62
69
|
issues: IssuesState;
|
|
70
|
+
pulls: PullsState;
|
|
71
|
+
reviews: PullReviews;
|
|
72
|
+
knowledge: KnowledgeBook;
|
|
63
73
|
unsubscribeFeed?: () => void;
|
|
64
74
|
/** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
|
|
65
75
|
asker?: Asker;
|
|
@@ -69,6 +79,9 @@ interface Runtime {
|
|
|
69
79
|
mouse: boolean;
|
|
70
80
|
/** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
|
|
71
81
|
inSettings: boolean;
|
|
82
|
+
/** The repository (or folder) and branch the lobby's title shows, and whether git is being asked now. */
|
|
83
|
+
workspace: WorkspaceInfo;
|
|
84
|
+
readingWorkspace: boolean;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
let runtime: Runtime | undefined;
|
|
@@ -94,18 +107,39 @@ let frameTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
94
107
|
* through pi.
|
|
95
108
|
*/
|
|
96
109
|
function rerender(): void {
|
|
97
|
-
|
|
110
|
+
// Hidden, only the one-line status under the editor changes.
|
|
111
|
+
if (!runtime || (!runtime.visible && !miniShown(runtime)) || frameTimer) return;
|
|
98
112
|
const wait = lastFrame + FRAME_MS - Date.now();
|
|
99
113
|
const paint = () => {
|
|
100
114
|
frameTimer = undefined;
|
|
101
115
|
lastFrame = Date.now();
|
|
102
|
-
if (runtime?.visible) runtime.tui?.requestRender();
|
|
116
|
+
if (runtime?.visible || (runtime && miniShown(runtime))) runtime.tui?.requestRender();
|
|
103
117
|
};
|
|
104
118
|
if (wait <= 0) return paint();
|
|
105
119
|
frameTimer = setTimeout(paint, wait);
|
|
106
120
|
frameTimer.unref?.();
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
/** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
|
|
124
|
+
function miniShown(state: Runtime): boolean {
|
|
125
|
+
return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What the status line shows: the task, the planning round, the quick fix in hand. */
|
|
129
|
+
function miniInput(state: Runtime): MiniInput {
|
|
130
|
+
const snapshot = taskSnapshot();
|
|
131
|
+
const planner = state.planner;
|
|
132
|
+
const job = state.quickfix.running ?? state.quickfix.jobs.find((entry) => entry.status === "queued");
|
|
133
|
+
const queued = state.quickfix.jobs.filter((entry) => entry.status === "queued" && entry !== job).length;
|
|
134
|
+
return {
|
|
135
|
+
...(snapshot.task ? { task: snapshot.task } : {}),
|
|
136
|
+
runs: snapshot.runs,
|
|
137
|
+
...(planner ? { planning: { busy: planner.busy, round: planner.turns, limit: loadConfig().lobby.maxPlanningRounds, questions: planner.awaitingAnswers ? planner.questions.length : 0, ready: planner.reply?.status === "ready", saved: Boolean(planner.saved) } } : {}),
|
|
138
|
+
...(job ? { quickfix: { title: jobTitle(job), running: job.status === "running", queued } } : {}),
|
|
139
|
+
key: "Alt+L",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
109
143
|
function sessionModel(ctx: ExtensionContext): string | undefined {
|
|
110
144
|
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
111
145
|
}
|
|
@@ -243,7 +277,8 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
|
|
|
243
277
|
const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
|
|
244
278
|
if (starting) return `${plan.id} is already starting in ${starting.name}`;
|
|
245
279
|
}
|
|
246
|
-
|
|
280
|
+
// A request may lead with flags (`--worktree add login`); the session is named after what follows them.
|
|
281
|
+
const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
|
|
247
282
|
try {
|
|
248
283
|
const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
249
284
|
lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
|
|
@@ -423,6 +458,15 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
|
|
|
423
458
|
return `deleted ${taskId} for good`;
|
|
424
459
|
}
|
|
425
460
|
|
|
461
|
+
/** What a pull request review runs on: QA's model, thinking and time limit. */
|
|
462
|
+
function reviewProfile(state: Runtime) {
|
|
463
|
+
return resolveReviewProfile(loadConfig(), {
|
|
464
|
+
lookup: modelLookup(state.ctx),
|
|
465
|
+
sessionModel: sessionModel(state.ctx),
|
|
466
|
+
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
426
470
|
function seatProfile(state: Runtime, member: PanelMember) {
|
|
427
471
|
return resolvePanelProfile(loadConfig(), member, {
|
|
428
472
|
lookup: modelLookup(state.ctx),
|
|
@@ -463,6 +507,26 @@ function panelAsker(state: Runtime): Asker {
|
|
|
463
507
|
return state.asker ?? askUser;
|
|
464
508
|
}
|
|
465
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Save the plan as a pending task. A plan with many steps is first split by
|
|
512
|
+
* the oracle into up to five tasks, which the user takes, changes or declines
|
|
513
|
+
* in a questionnaire; the split's questions open over the lobby like the
|
|
514
|
+
* panel's. Returns a notice for the lobby.
|
|
515
|
+
*/
|
|
516
|
+
export async function savePlan(state: Runtime | undefined = runtime): Promise<string> {
|
|
517
|
+
const session = state?.planner;
|
|
518
|
+
if (!state || !session) return "no planning session";
|
|
519
|
+
if (state.asking) return "a questionnaire is already open";
|
|
520
|
+
state.asking = true;
|
|
521
|
+
try {
|
|
522
|
+
const asker = panelAsker(state);
|
|
523
|
+
return await session.saveWithSplit((questions, signal) => asker(questions, state.ctx, signal), { splitAbove: loadConfig().lobby.splitPlanAbove });
|
|
524
|
+
} finally {
|
|
525
|
+
state.asking = false;
|
|
526
|
+
rerender();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
466
530
|
/**
|
|
467
531
|
* The oracle puts the round's questions to the user, one at a time; answered
|
|
468
532
|
* questionnaires are kept if the user stops, so the next call resumes there.
|
|
@@ -492,7 +556,7 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
492
556
|
session.answered = [];
|
|
493
557
|
return "nothing was answered — the questions stay open";
|
|
494
558
|
}
|
|
495
|
-
void session.send(message);
|
|
559
|
+
void session.send(message, settledQuestions(session.questions, session.answered));
|
|
496
560
|
return "answers sent — the panel is on the next round";
|
|
497
561
|
} catch (error) {
|
|
498
562
|
return `could not put the questions: ${(error as Error).message}`;
|
|
@@ -502,6 +566,22 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
502
566
|
}
|
|
503
567
|
}
|
|
504
568
|
|
|
569
|
+
/** Read the repository name and branch again, off the render path; the title repaints when they changed. */
|
|
570
|
+
function refreshWorkspace(state: Runtime): void {
|
|
571
|
+
if (state.readingWorkspace) return;
|
|
572
|
+
state.readingWorkspace = true;
|
|
573
|
+
describeWorkspace(state.ctx.cwd)
|
|
574
|
+
.then((info) => {
|
|
575
|
+
if (runtime !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
|
|
576
|
+
state.workspace = info;
|
|
577
|
+
rerender();
|
|
578
|
+
})
|
|
579
|
+
.catch(() => {})
|
|
580
|
+
.finally(() => {
|
|
581
|
+
state.readingWorkspace = false;
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
505
585
|
function host(state: Runtime, tui: TUI): LobbyHost {
|
|
506
586
|
return {
|
|
507
587
|
rows: () => tui.terminal.rows,
|
|
@@ -528,6 +608,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
528
608
|
planner: () => state.planner,
|
|
529
609
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
530
610
|
answerPanel: () => answerPanel(state),
|
|
611
|
+
savePlan: () => savePlan(state),
|
|
531
612
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
532
613
|
planningRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
533
614
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
@@ -540,10 +621,16 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
540
621
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
541
622
|
},
|
|
542
623
|
issues: state.issues,
|
|
624
|
+
pulls: state.pulls,
|
|
625
|
+
reviews: state.reviews,
|
|
626
|
+
knowledge: state.knowledge,
|
|
627
|
+
editText: (title, text) => editText(state, title, text),
|
|
543
628
|
profileLabel: (kind) => {
|
|
544
629
|
const profile = lobbyProfile(state, kind);
|
|
545
630
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
546
631
|
},
|
|
632
|
+
workspace: () => state.workspace,
|
|
633
|
+
refreshWorkspace: () => refreshWorkspace(state),
|
|
547
634
|
sessionName: () => state.pi.getSessionName(),
|
|
548
635
|
sessions: () => backgroundSessions(),
|
|
549
636
|
startSession: (start) => startSession(state, start),
|
|
@@ -566,20 +653,20 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
566
653
|
}
|
|
567
654
|
|
|
568
655
|
/**
|
|
569
|
-
*
|
|
570
|
-
* lobby
|
|
571
|
-
* not flash between them
|
|
656
|
+
* Run something pi draws itself (its settings menus, its multi-line editor)
|
|
657
|
+
* with the lobby stepped aside for the whole visit, not only for each dialog,
|
|
658
|
+
* so it does not flash between them; the lobby rereads the config after.
|
|
572
659
|
*/
|
|
573
|
-
async function
|
|
574
|
-
if (state.inSettings) return;
|
|
660
|
+
async function whileAside<T>(state: Runtime, run: () => Promise<T>, failure: string): Promise<T | undefined> {
|
|
661
|
+
if (state.inSettings) return undefined;
|
|
575
662
|
state.inSettings = true;
|
|
576
663
|
setMouse(state, false);
|
|
577
664
|
state.handle?.setHidden(true);
|
|
578
665
|
try {
|
|
579
|
-
|
|
580
|
-
else await openSettings(state.pi, state.ctx);
|
|
666
|
+
return await run();
|
|
581
667
|
} catch (error) {
|
|
582
|
-
state.ctx.ui.notify(`bot-lobby:
|
|
668
|
+
state.ctx.ui.notify(`bot-lobby: ${failure} — ${(error as Error).message}`, "warning");
|
|
669
|
+
return undefined;
|
|
583
670
|
} finally {
|
|
584
671
|
state.inSettings = false;
|
|
585
672
|
state.asideForPrompt = false;
|
|
@@ -593,6 +680,16 @@ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Pr
|
|
|
593
680
|
}
|
|
594
681
|
}
|
|
595
682
|
|
|
683
|
+
/** bot-lobby's settings (or one agent's entry) from inside the lobby. */
|
|
684
|
+
async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
|
|
685
|
+
await whileAside(state, () => (entry ? openEntrySettings(state.pi, state.ctx, entry) : openSettings(state.pi, state.ctx)), "settings failed");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** A text edited in pi's multi-line editor (Enter saves, Shift+Enter is a new line); undefined when cancelled. */
|
|
689
|
+
async function editText(state: Runtime, title: string, text: string): Promise<string | undefined> {
|
|
690
|
+
return whileAside(state, () => state.ctx.ui.editor(title, text), "the editor failed");
|
|
691
|
+
}
|
|
692
|
+
|
|
596
693
|
/** Remember which panes show, keeping every other setting as the file has it now. */
|
|
597
694
|
function savePanels(panels: Record<LobbyPanel, boolean>): void {
|
|
598
695
|
try {
|
|
@@ -696,6 +793,7 @@ function shutdown(): void {
|
|
|
696
793
|
setMouse(state, false);
|
|
697
794
|
state.unsubscribeFeed?.();
|
|
698
795
|
state.quickfix.cancelAll();
|
|
796
|
+
state.reviews.cancelAll();
|
|
699
797
|
state.planner?.cancel();
|
|
700
798
|
state.view?.dispose();
|
|
701
799
|
state.handle?.hide();
|
|
@@ -737,9 +835,35 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
737
835
|
asking: false,
|
|
738
836
|
mouse: false,
|
|
739
837
|
inSettings: false,
|
|
838
|
+
workspace: { name: basename(ctx.cwd) || ctx.cwd },
|
|
839
|
+
readingWorkspace: false,
|
|
740
840
|
quickfix: undefined as unknown as QuickFixQueue,
|
|
741
841
|
issues: new IssuesState(execCommand, ctx.cwd, rerender),
|
|
842
|
+
pulls: new PullsState(execCommand, ctx.cwd, rerender),
|
|
843
|
+
reviews: undefined as unknown as PullReviews,
|
|
844
|
+
knowledge: new KnowledgeBook({
|
|
845
|
+
root,
|
|
846
|
+
configDir,
|
|
847
|
+
threshold: () => loadConfig().knowledge.compactionThreshold,
|
|
848
|
+
backups: () => loadConfig().knowledge.backupCount,
|
|
849
|
+
sessionId: () => ctx.sessionManager.getSessionId(),
|
|
850
|
+
}),
|
|
742
851
|
};
|
|
852
|
+
state.reviews = new PullReviews({
|
|
853
|
+
cwd: ctx.cwd,
|
|
854
|
+
root,
|
|
855
|
+
configDir,
|
|
856
|
+
exec: execCommand,
|
|
857
|
+
profile: () => reviewProfile(state),
|
|
858
|
+
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
859
|
+
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
860
|
+
feed: lobbyFeed,
|
|
861
|
+
onChange: rerender,
|
|
862
|
+
notify: (message, level) => {
|
|
863
|
+
if (!state.visible) ctx.ui.notify(message, level);
|
|
864
|
+
},
|
|
865
|
+
classifier: classifier(),
|
|
866
|
+
});
|
|
743
867
|
state.quickfix = new QuickFixQueue({
|
|
744
868
|
cwd: ctx.cwd,
|
|
745
869
|
root,
|
|
@@ -769,7 +893,8 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
769
893
|
// Capture pi's TUI through a zero-line widget below the editor.
|
|
770
894
|
ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
|
|
771
895
|
state.tui = tui;
|
|
772
|
-
|
|
896
|
+
// Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
|
|
897
|
+
return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
|
|
773
898
|
}, { placement: "belowEditor" });
|
|
774
899
|
// The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
|
|
775
900
|
onOwnerEvent((event) => {
|
|
@@ -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
|
+
}
|