@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Knowledge tab's side of the disk: every agent's knowledge files, one
|
|
3
|
+
* file read as entries with the user's notes on them, and the edits the user
|
|
4
|
+
* can make — replace, add or delete an entry, edit the whole file — plus
|
|
5
|
+
* notes. Every write goes through the compactor's path, which archives the
|
|
6
|
+
* previous version first (`archive/<Agent>/…bak`), so an edit can be undone by
|
|
7
|
+
* hand. An entry is found again by its exact text; when the file changed on
|
|
8
|
+
* disk since the tab drew it, the edit is refused with a line saying so.
|
|
9
|
+
*/
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { compactKnowledgeFile } from "../knowledge/compactor.ts";
|
|
12
|
+
import { findEntry, insertAfter, parseEntries, removeEntry, replaceEntry, type KnowledgeEntry } from "../knowledge/edit.ts";
|
|
13
|
+
import { addNote, moveNotes, readNotes, removeNote, removeNotesOn, type KnowledgeNote } from "../knowledge/notes.ts";
|
|
14
|
+
import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
|
|
15
|
+
import { readFirstExisting } from "../knowledge/store.ts";
|
|
16
|
+
import { dataRoot, readDataRoots } from "../state/project.ts";
|
|
17
|
+
|
|
18
|
+
/** What each knowledge file is, in words. */
|
|
19
|
+
export const FILE_LABELS: Record<string, string> = {
|
|
20
|
+
"knowledge.md": "Knowledge",
|
|
21
|
+
"decisions.md": "Decisions",
|
|
22
|
+
"completed-tasks.md": "Completed tasks",
|
|
23
|
+
"standards.md": "Standards",
|
|
24
|
+
"design-language.md": "Design language",
|
|
25
|
+
"engineering-standards.md": "Engineering standards",
|
|
26
|
+
"testing-standards.md": "Testing standards",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Who the agents are, as the lobby names them. */
|
|
30
|
+
export const AGENT_LABELS: Record<KnowledgeAgent, string> = { master: "Master (oracle)", designer: "Designer", backend: "Backend", qa: "QA" };
|
|
31
|
+
|
|
32
|
+
export interface KnowledgeFileInfo {
|
|
33
|
+
agent: KnowledgeAgent;
|
|
34
|
+
file: string;
|
|
35
|
+
label: string;
|
|
36
|
+
chars: number;
|
|
37
|
+
/** Past the compaction threshold: the Master should compact it. */
|
|
38
|
+
over: boolean;
|
|
39
|
+
notes: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KnowledgeView extends KnowledgeFileInfo {
|
|
43
|
+
content: string;
|
|
44
|
+
entries: KnowledgeEntry[];
|
|
45
|
+
/** Notes on entries this file has. */
|
|
46
|
+
attached: KnowledgeNote[];
|
|
47
|
+
/** Notes whose entry has changed or gone: kept, shown apart. */
|
|
48
|
+
detached: KnowledgeNote[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An entry picked in the tab: its text, and which of several identical ones it is. */
|
|
52
|
+
export interface EntryRef {
|
|
53
|
+
text: string;
|
|
54
|
+
occurrence: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface KnowledgeDeps {
|
|
58
|
+
root: string;
|
|
59
|
+
configDir: string;
|
|
60
|
+
/** Characters past which a file is over its compaction threshold. */
|
|
61
|
+
threshold: () => number;
|
|
62
|
+
/** Backups of a file kept in the archive. */
|
|
63
|
+
backups: () => number;
|
|
64
|
+
sessionId?: () => string | undefined;
|
|
65
|
+
now?: () => Date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const CHANGED = "that entry changed on disk since it was drawn — r reloads the file";
|
|
69
|
+
|
|
70
|
+
export class KnowledgeBook {
|
|
71
|
+
private readonly deps: KnowledgeDeps;
|
|
72
|
+
|
|
73
|
+
constructor(deps: KnowledgeDeps) {
|
|
74
|
+
this.deps = deps;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private get data(): string {
|
|
78
|
+
return dataRoot(this.deps.root, this.deps.configDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private read(agent: KnowledgeAgent, file: string): string {
|
|
82
|
+
return readFirstExisting(readDataRoots(this.deps.root, this.deps.configDir).map((root) => join(knowledgeDir(root, agent), file)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every agent's files in the order the tab lists them, with their size and notes. */
|
|
86
|
+
files(): KnowledgeFileInfo[] {
|
|
87
|
+
const notes = readNotes(this.data);
|
|
88
|
+
return (Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[]).flatMap((agent) =>
|
|
89
|
+
KNOWLEDGE_FILES[agent].map((file) => {
|
|
90
|
+
const chars = this.read(agent, file).length;
|
|
91
|
+
return { agent, file, label: FILE_LABELS[file] ?? file, chars, over: chars > this.deps.threshold(), notes: notes.filter((note) => note.agent === agent && note.file === file).length };
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One file as entries with their notes. */
|
|
97
|
+
open(agent: KnowledgeAgent, file: string): KnowledgeView {
|
|
98
|
+
const content = this.read(agent, file);
|
|
99
|
+
const entries = parseEntries(content);
|
|
100
|
+
const notes = readNotes(this.data).filter((note) => note.agent === agent && note.file === file);
|
|
101
|
+
const known = new Set(entries.map((entry) => entry.text));
|
|
102
|
+
return {
|
|
103
|
+
agent,
|
|
104
|
+
file,
|
|
105
|
+
label: FILE_LABELS[file] ?? file,
|
|
106
|
+
chars: content.length,
|
|
107
|
+
over: content.length > this.deps.threshold(),
|
|
108
|
+
notes: notes.length,
|
|
109
|
+
content,
|
|
110
|
+
entries,
|
|
111
|
+
attached: notes.filter((note) => known.has(note.entry)),
|
|
112
|
+
detached: notes.filter((note) => !known.has(note.entry)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Write a file's new content with the previous one archived; the notice says where it went. */
|
|
117
|
+
private write(agent: KnowledgeAgent, file: string, content: string): string {
|
|
118
|
+
compactKnowledgeFile({ dataRoot: this.data, agent, file, content, backupCount: this.deps.backups(), ...(this.deps.now ? { now: this.deps.now() } : {}) });
|
|
119
|
+
return `saved ${agent}/${file} — the version before is in archive/${AGENT_DIR_NAMES[agent]}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Replace one entry with `text`, exactly as written; its notes move with it. */
|
|
123
|
+
edit(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
|
|
124
|
+
const content = this.read(agent, file);
|
|
125
|
+
const entry = findEntry(content, ref.text, ref.occurrence);
|
|
126
|
+
if (!entry) return CHANGED;
|
|
127
|
+
if (!text.trim()) return "nothing to save — d d deletes an entry, esc cancels";
|
|
128
|
+
const next = replaceEntry(content, entry, text);
|
|
129
|
+
if (next.trim() === content.trim()) return "nothing changed";
|
|
130
|
+
const notice = this.write(agent, file, next);
|
|
131
|
+
const moved = moveNotes(this.data, agent, file, entry.text, text.replace(/\s+$/, ""), this.deps.now?.());
|
|
132
|
+
return `${notice}${moved > 0 ? `; ${moved} note${moved === 1 ? "" : "s"} stayed with it` : ""}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Add a bullet after an entry (at the end when there is none). */
|
|
136
|
+
add(agent: KnowledgeAgent, file: string, after: EntryRef | undefined, text: string): string {
|
|
137
|
+
const content = this.read(agent, file);
|
|
138
|
+
const entry = after ? findEntry(content, after.text, after.occurrence) : undefined;
|
|
139
|
+
if (after && !entry) return CHANGED;
|
|
140
|
+
const next = insertAfter(content, entry, text);
|
|
141
|
+
if (next === content) return "nothing to add";
|
|
142
|
+
return this.write(agent, file, next);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Delete an entry, and the notes on it. */
|
|
146
|
+
remove(agent: KnowledgeAgent, file: string, ref: EntryRef): string {
|
|
147
|
+
const content = this.read(agent, file);
|
|
148
|
+
const entry = findEntry(content, ref.text, ref.occurrence);
|
|
149
|
+
if (!entry) return CHANGED;
|
|
150
|
+
const notice = this.write(agent, file, removeEntry(content, entry));
|
|
151
|
+
const gone = removeNotesOn(this.data, agent, file, entry.text, this.deps.now?.());
|
|
152
|
+
return `${notice}${gone > 0 ? `; its ${gone} note${gone === 1 ? "" : "s"} went with it` : ""}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Replace a whole file (edited in pi's editor). */
|
|
156
|
+
replaceFile(agent: KnowledgeAgent, file: string, text: string): string {
|
|
157
|
+
const content = this.read(agent, file);
|
|
158
|
+
if (text.trim() === content.trim()) return "nothing changed";
|
|
159
|
+
return this.write(agent, file, text);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Leave a note on an entry: every agent that reads this knowledge reads it under the entry. */
|
|
163
|
+
comment(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
|
|
164
|
+
const entry = findEntry(this.read(agent, file), ref.text, ref.occurrence);
|
|
165
|
+
if (!entry) return CHANGED;
|
|
166
|
+
try {
|
|
167
|
+
addNote(this.data, { agent, file, entry: entry.text, text, ...(this.deps.sessionId?.() ? { by: this.deps.sessionId!()! } : {}) }, this.deps.now?.());
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return (error as Error).message;
|
|
170
|
+
}
|
|
171
|
+
return "note saved — every agent reads it under this entry";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Take a note back. */
|
|
175
|
+
unnote(id: string): string {
|
|
176
|
+
removeNote(this.data, id, this.deps.now?.());
|
|
177
|
+
return "note removed";
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/lobby/layout.ts
CHANGED
|
@@ -91,6 +91,9 @@ export function wrapHanging(lead: string, text: string, width: number): string[]
|
|
|
91
91
|
return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** The mark before a git branch name, in the lobby's title and a task's details. */
|
|
95
|
+
export const BRANCH_GLYPH = "⎇";
|
|
96
|
+
|
|
94
97
|
/** `── Title ───────` spanning `width`; `right` sits at the far end when it fits. */
|
|
95
98
|
export function rule(width: number, title = "", theme?: LobbyTheme, right = "", color: LobbyColor = "borderMuted"): string {
|
|
96
99
|
if (width <= 0) return "";
|
|
@@ -268,21 +271,84 @@ export function scrollThumb(total: number, rows: number, start: number): { from:
|
|
|
268
271
|
return { from, to: from + size };
|
|
269
272
|
}
|
|
270
273
|
|
|
271
|
-
/**
|
|
272
|
-
export
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
274
|
+
/** Lines a page moves a pane: its rows less one, so a line of context carries over. */
|
|
275
|
+
export function pageStep(rows: number): number {
|
|
276
|
+
return Math.max(1, rows - 1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** How many pages a pane showing `rows` lines at a time holds out of `total`. */
|
|
280
|
+
export function pageCount(total: number, rows: number): number {
|
|
281
|
+
return total <= rows ? 1 : 1 + Math.ceil((total - rows) / pageStep(rows));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The page a pane showing lines from `start` is on, counted so that the top is
|
|
286
|
+
* page 1 and the bottom the last, and a page's worth of scrolling from either
|
|
287
|
+
* end lands exactly on the next number: a log that opens at its newest line
|
|
288
|
+
* and a detail that opens at its first both step through the same numbers.
|
|
289
|
+
*/
|
|
290
|
+
export function pageOf(total: number, rows: number, start: number): number {
|
|
291
|
+
const end = Math.max(0, total - rows);
|
|
292
|
+
const behind = Math.ceil(Math.max(0, end - Math.max(0, start)) / pageStep(rows));
|
|
293
|
+
return Math.max(1, pageCount(total, rows) - behind);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** One piece of the pager: a button, where the pane is, or what sits between them. */
|
|
297
|
+
export interface PagerPart {
|
|
298
|
+
text: string;
|
|
299
|
+
role: "up" | "down" | "where" | "between";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Fewest columns of border kept either side of the pager, beside its two spaces. */
|
|
303
|
+
const PAGER_MARGIN = 3;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The pager in a scrolling pane's bottom border, as words a newcomer can read:
|
|
307
|
+
* `▲ prev · page 2/5 · next ▼`, shortening to `▲ prev · 2/5 · next ▼` and
|
|
308
|
+
* `▲ 2/5 ▼` as the pane narrows, and to nothing when even that will not fit
|
|
309
|
+
* in `room` columns. The page number is padded to the width of the page count,
|
|
310
|
+
* so the buttons stay where they are as it changes and a click can be traced
|
|
311
|
+
* from the pane's size alone.
|
|
312
|
+
*/
|
|
313
|
+
export function pager(total: number, rows: number, start: number, room: number): PagerPart[] | undefined {
|
|
314
|
+
const pages = pageCount(total, rows);
|
|
315
|
+
const count = String(pages);
|
|
316
|
+
const of = `${String(pageOf(total, rows, start)).padStart(count.length)}/${pages}`;
|
|
317
|
+
const between = (text: string): PagerPart => ({ text, role: "between" });
|
|
318
|
+
const up = (text: string): PagerPart => ({ text, role: "up" });
|
|
319
|
+
const down = (text: string): PagerPart => ({ text, role: "down" });
|
|
320
|
+
const where = (text: string): PagerPart => ({ text, role: "where" });
|
|
321
|
+
const variants: PagerPart[][] = [
|
|
322
|
+
[up("▲ prev"), between(" · "), where(`page ${of}`), between(" · "), down("next ▼")],
|
|
323
|
+
[up("▲ prev"), between(" · "), where(of), between(" · "), down("next ▼")],
|
|
324
|
+
[up("▲"), between(" "), where(of), between(" "), down("▼")],
|
|
325
|
+
];
|
|
326
|
+
return variants.find((parts) => parts.reduce((sum, part) => sum + textWidth(part.text), 0) <= room);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Columns a box of `width` leaves for the pager's text: two spaces around it and a margin of border on each side. */
|
|
330
|
+
function pagerRoom(width: number): number {
|
|
331
|
+
return width - 2 - 2 - PAGER_MARGIN;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Which button a column of a scrollable pane's bottom border is: -1 (a page
|
|
336
|
+
* up) or 1 (a page down), or undefined. The buttons take their words and the
|
|
337
|
+
* space beside them, so a click a little off still lands.
|
|
338
|
+
*/
|
|
339
|
+
export function pagerButton(width: number, column: number, total: number, rows: number): -1 | 1 | undefined {
|
|
340
|
+
const parts = pager(total, rows, 0, pagerRoom(width));
|
|
341
|
+
if (!parts) return undefined;
|
|
342
|
+
const size = parts.reduce((sum, part) => sum + textWidth(part.text), 0);
|
|
343
|
+
// The text ends one space before the corner: `… next ▼ ╯`.
|
|
344
|
+
const origin = width - 2 - size;
|
|
345
|
+
let at = origin;
|
|
346
|
+
for (const part of parts) {
|
|
347
|
+
const from = part.role === "up" ? origin - 1 : at;
|
|
348
|
+
const to = part.role === "down" ? width - 1 : at + textWidth(part.text);
|
|
349
|
+
if (column >= from && column < to) return part.role === "up" ? -1 : part.role === "down" ? 1 : undefined;
|
|
350
|
+
at += textWidth(part.text);
|
|
351
|
+
}
|
|
286
352
|
return undefined;
|
|
287
353
|
}
|
|
288
354
|
|
|
@@ -309,9 +375,20 @@ export function box(width: number, height: number, content: readonly string[], o
|
|
|
309
375
|
const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
|
|
310
376
|
const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
|
|
311
377
|
const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
|
|
312
|
-
// A pane with more lines than rows carries
|
|
313
|
-
const
|
|
314
|
-
const
|
|
378
|
+
// A pane with more lines than rows carries a pager in its bottom border; `pagerButton` finds its buttons again for clicks.
|
|
379
|
+
const parts = thumb && options.scroll ? pager(options.scroll.total, height - 2, options.scroll.start, pagerRoom(width)) : undefined;
|
|
380
|
+
const atTop = (options.scroll?.start ?? 0) <= 0;
|
|
381
|
+
const atEnd = options.scroll ? options.scroll.start >= options.scroll.total - (height - 2) : true;
|
|
382
|
+
const paintPart = (part: PagerPart): string => {
|
|
383
|
+
if (part.role === "between") return paint(theme, "dim", part.text);
|
|
384
|
+
if (part.role === "where") return paint(theme, focused ? "text" : "muted", part.text);
|
|
385
|
+
// A button that cannot go further is dimmed: the pane is at that end.
|
|
386
|
+
if (part.role === "up" ? atTop : atEnd) return paint(theme, "dim", part.text);
|
|
387
|
+
return bold(theme, paint(theme, focused ? "accent" : "text", part.text));
|
|
388
|
+
};
|
|
389
|
+
const pagerText = parts ? ` ${parts.map(paintPart).join("")} ` : "";
|
|
390
|
+
const pagerWidth = parts ? parts.reduce((sum, part) => sum + textWidth(part.text), 0) + 2 : 0;
|
|
391
|
+
const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - pagerWidth))}${pagerText}${edge("╯")}`;
|
|
315
392
|
return [top, ...rows, bottom];
|
|
316
393
|
}
|
|
317
394
|
|
package/src/lobby/planner.ts
CHANGED
|
@@ -22,14 +22,18 @@ import { loadPrompt } from "../prompts/loader.ts";
|
|
|
22
22
|
import { compilePrompt } from "../prompts/compiler.ts";
|
|
23
23
|
import { withFallback } from "../execution/fallback.ts";
|
|
24
24
|
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
25
|
+
import { grantOption, type Grant } from "../excalidraw/sessions.ts";
|
|
25
26
|
import { describeToolCall } from "../pi/activity.ts";
|
|
26
27
|
import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
|
|
27
28
|
import { savePlannedTask, type IssueRef, type PlannedTask } from "../state/backlog.ts";
|
|
29
|
+
import { planSteps } from "../pi/plan-checklist.ts";
|
|
30
|
+
import { KEEP_WHOLE, MAX_SPLIT_REVISIONS, partBrief, partInfo, parseSplit, splitFailedQuestion, splitLabel, splitProblems, splitPrompt, splitQuestion, splitRequest, type SplitProposal } from "./split.ts";
|
|
31
|
+
import type { AskQuestion, AskResult } from "../ask/types.ts";
|
|
28
32
|
import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
|
|
29
33
|
import { truncate } from "../text.ts";
|
|
30
34
|
import type { LobbyFeed } from "./feed.ts";
|
|
31
35
|
import type { QuickFixProfile } from "./quickfix.ts";
|
|
32
|
-
import { MAX_QUESTIONS
|
|
36
|
+
import { MAX_QUESTIONS } from "./ask.ts";
|
|
33
37
|
import type { Classifier } from "../classifier/classifier.ts";
|
|
34
38
|
import { chooseSeats } from "../classifier/seats.ts";
|
|
35
39
|
import { autoAnswer, type AutoAnswer } from "../classifier/answers.ts";
|
|
@@ -71,6 +75,13 @@ export interface PanelQuestion extends AskedQuestion {
|
|
|
71
75
|
from: string;
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/** One of the panel's questions as the user's reply settled it: answered, or left open (`answer` absent). */
|
|
79
|
+
export interface SettledQuestion {
|
|
80
|
+
from: string;
|
|
81
|
+
question: string;
|
|
82
|
+
answer?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
export interface PlannerMessage {
|
|
75
86
|
role: "you" | "planner";
|
|
76
87
|
text: string;
|
|
@@ -79,6 +90,8 @@ export interface PlannerMessage {
|
|
|
79
90
|
questions?: PanelQuestion[];
|
|
80
91
|
/** Questions the classifier answered with their recommended option instead of asking. */
|
|
81
92
|
decided?: AutoAnswer[];
|
|
93
|
+
/** On the user's reply: which of the questions before it were answered, so they are never asked again. */
|
|
94
|
+
settled?: SettledQuestion[];
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
/** The oracle's reply: its verdict, title, own questions and the draft plan. */
|
|
@@ -337,6 +350,8 @@ export function plannerTranscript(messages: readonly PlannerMessage[], seed?: Pl
|
|
|
337
350
|
lines.push(message.role === "you" ? "### User" : "### Panel", "", body, "");
|
|
338
351
|
if (message.decided && message.decided.length > 0) lines.push(decidedBlock(message.decided), "");
|
|
339
352
|
}
|
|
353
|
+
const settled = messages.flatMap((message) => message.settled ?? []);
|
|
354
|
+
if (settled.length > 0) lines.push(settledBlock(settled), "");
|
|
340
355
|
if (draft) lines.push("## The oracle's current draft plan", "", truncate(draft, 8000), "");
|
|
341
356
|
lines.push(closing);
|
|
342
357
|
return lines.join("\n");
|
|
@@ -356,6 +371,55 @@ export function decidedBlock(decided: readonly AutoAnswer[]): string {
|
|
|
356
371
|
].join("\n");
|
|
357
372
|
}
|
|
358
373
|
|
|
374
|
+
/** One line of at most `max` characters, for the activity log. */
|
|
375
|
+
function brief(text: string, max: number): string {
|
|
376
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
377
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Two questions this alike (by shared words) ask the same thing. */
|
|
381
|
+
const SAME_QUESTION = 0.8;
|
|
382
|
+
|
|
383
|
+
/** Words that carry no topic: a rewording changes them without changing the question. */
|
|
384
|
+
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"]);
|
|
385
|
+
|
|
386
|
+
function questionWords(text: string): Set<string> {
|
|
387
|
+
const words = text.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").split(/\s+/).filter(Boolean);
|
|
388
|
+
const content = words.filter((word) => !FILLER.has(word));
|
|
389
|
+
return new Set(content.length > 0 ? content : words);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Whether two questions ask the same thing: the same topic words in any order, or nearly all of them. */
|
|
393
|
+
export function sameQuestion(a: string, b: string): boolean {
|
|
394
|
+
const left = questionWords(a);
|
|
395
|
+
const right = questionWords(b);
|
|
396
|
+
if (left.size === 0 || right.size === 0) return false;
|
|
397
|
+
let shared = 0;
|
|
398
|
+
for (const word of left) if (right.has(word)) shared += 1;
|
|
399
|
+
if (shared === left.size && shared === right.size) return true;
|
|
400
|
+
return Math.min(left.size, right.size) >= 3 && shared / (left.size + right.size - shared) >= SAME_QUESTION;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Split a round's questions into those still to ask and the repeats of ones
|
|
405
|
+
* the user already settled (answered, or left for the oracle to decide): the
|
|
406
|
+
* user never gets the same question twice, whatever the models write.
|
|
407
|
+
*/
|
|
408
|
+
export function withoutSettled(questions: readonly PanelQuestion[], settled: readonly SettledQuestion[]): { asked: PanelQuestion[]; repeats: PanelQuestion[] } {
|
|
409
|
+
const asked: PanelQuestion[] = [];
|
|
410
|
+
const repeats: PanelQuestion[] = [];
|
|
411
|
+
for (const question of questions) (settled.some((entry) => sameQuestion(entry.question, question.text)) ? repeats : asked).push(question);
|
|
412
|
+
return { asked, repeats };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** What the user settled, as every seat and the oracle read it: closed questions, never to be asked again. */
|
|
416
|
+
export function settledBlock(settled: readonly SettledQuestion[]): string {
|
|
417
|
+
return [
|
|
418
|
+
"## Already settled with the user (closed: never ask these again, in any wording)",
|
|
419
|
+
...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)"}`),
|
|
420
|
+
].join("\n");
|
|
421
|
+
}
|
|
422
|
+
|
|
359
423
|
/** The seats' output for the oracle: each seat's status, questions and notes, or its failure. */
|
|
360
424
|
export function panelSection(outcomes: readonly MemberOutcome[]): string {
|
|
361
425
|
if (outcomes.length === 0) return "## Panel this round\n\nNo domain seats this round; you are planning alone.";
|
|
@@ -439,6 +503,8 @@ export class PlanningSession {
|
|
|
439
503
|
saved?: PlannedTask;
|
|
440
504
|
/** Questionnaires answered so far for this round's questions, so stopping one resumes where it left off. */
|
|
441
505
|
answered: AskResult[] = [];
|
|
506
|
+
/** The tasks the plan was saved as when it was split (`saved` is the first). */
|
|
507
|
+
savedParts: PlannedTask[] = [];
|
|
442
508
|
/** Comments on draft lines, sent with the user's next turn. */
|
|
443
509
|
lineComments: LineComment[] = [];
|
|
444
510
|
/** How the latest round ran under the round limit. */
|
|
@@ -498,21 +564,30 @@ export class PlanningSession {
|
|
|
498
564
|
return this.seats.has(member);
|
|
499
565
|
}
|
|
500
566
|
|
|
501
|
-
/**
|
|
502
|
-
|
|
567
|
+
/**
|
|
568
|
+
* Add the user's message (the idea, or answers), with any line comments, and
|
|
569
|
+
* run a round. `settled` says which of the open questions the message
|
|
570
|
+
* answered (or left), so the panel is never allowed to ask them again.
|
|
571
|
+
*/
|
|
572
|
+
async send(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
|
|
503
573
|
this.autoContinued = false;
|
|
504
|
-
await this.post(text);
|
|
574
|
+
await this.post(text, settled);
|
|
505
575
|
}
|
|
506
576
|
|
|
507
|
-
private async post(text: string): Promise<void> {
|
|
577
|
+
private async post(text: string, settled?: readonly SettledQuestion[]): Promise<void> {
|
|
508
578
|
const body = [text.trim(), commentBlock(this.lineComments)].filter(Boolean).join("\n\n");
|
|
509
579
|
if (!body) return;
|
|
510
580
|
if (this.busy) throw new Error("the panel is still thinking");
|
|
511
581
|
this.lineComments = [];
|
|
512
|
-
this.messages = [...this.messages, { role: "you", text: body, at: Date.now() }];
|
|
582
|
+
this.messages = [...this.messages, { role: "you", text: body, at: Date.now(), ...(settled && settled.length > 0 ? { settled: [...settled] } : {}) }];
|
|
513
583
|
await this.turn();
|
|
514
584
|
}
|
|
515
585
|
|
|
586
|
+
/** Every question the user has settled so far, oldest first. */
|
|
587
|
+
get settled(): SettledQuestion[] {
|
|
588
|
+
return this.messages.flatMap((message) => message.settled ?? []);
|
|
589
|
+
}
|
|
590
|
+
|
|
516
591
|
/** The round's questions still wait for answers. */
|
|
517
592
|
get awaitingAnswers(): boolean {
|
|
518
593
|
return !this.busy && this.questions.length > 0;
|
|
@@ -574,6 +649,121 @@ export class PlanningSession {
|
|
|
574
649
|
return this.saved;
|
|
575
650
|
}
|
|
576
651
|
|
|
652
|
+
/** Save each part of a split plan as its own pending task, in order, each knowing the others. */
|
|
653
|
+
saveParts(proposal: SplitProposal, now = new Date()): PlannedTask[] {
|
|
654
|
+
const plan = this.reply?.plan;
|
|
655
|
+
if (!plan) throw new Error("there is no draft plan to save yet");
|
|
656
|
+
const steps = planSteps(plan);
|
|
657
|
+
const group = `SPLIT-${now.getTime().toString(36)}`;
|
|
658
|
+
// Part 1 is saved last of all timestamps' newest, so the newest-first pending list reads in part order.
|
|
659
|
+
const parts = proposal.tasks.map((task, index) => savePlannedTask(this.deps.root, this.deps.configDir, {
|
|
660
|
+
title: task.title,
|
|
661
|
+
brief: partBrief({ plan, steps, proposal, index }),
|
|
662
|
+
...(this.seed ? { issue: this.seed.issue } : {}),
|
|
663
|
+
split: partInfo(proposal, index, group),
|
|
664
|
+
}, new Date(now.getTime() + (proposal.tasks.length - index))));
|
|
665
|
+
this.savedParts = parts;
|
|
666
|
+
this.saved = parts[0];
|
|
667
|
+
this.deps.feed?.log(ORACLE_LABEL, `split the plan into ${parts.length} tasks: ${parts.map((part) => part.id).join(", ")}`, "success");
|
|
668
|
+
this.deps.onChange?.();
|
|
669
|
+
return parts;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The oracle's proposal for splitting the plan: run, read, and checked
|
|
674
|
+
* against the rules (once more with the problems when it breaks one).
|
|
675
|
+
* `feedback` is the user's change request on an earlier proposal. A string
|
|
676
|
+
* is the reason there is no proposal.
|
|
677
|
+
*/
|
|
678
|
+
private async proposeSplit(plan: string, steps: readonly string[], feedback: { previous: SplitProposal; words: string } | undefined, signal: AbortSignal): Promise<SplitProposal | string> {
|
|
679
|
+
const profile = this.deps.profile();
|
|
680
|
+
const title = this.title ?? "the plan";
|
|
681
|
+
let rejected: string[] = [];
|
|
682
|
+
let reason = "";
|
|
683
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
684
|
+
this.step = attempt === 0 ? "deciding how to split the plan" : "fixing the split";
|
|
685
|
+
this.deps.onChange?.();
|
|
686
|
+
const lead = await this.run(ORACLE_LABEL, "planner", profile, {
|
|
687
|
+
task: splitRequest({ title, plan, steps, ...(feedback ? { revision: feedback } : {}), ...(rejected.length > 0 ? { rejected } : {}) }),
|
|
688
|
+
systemPrompt: splitPrompt(profile.instructions),
|
|
689
|
+
tools: this.tools(PLANNER_TOOLS),
|
|
690
|
+
...grantOption("planner"),
|
|
691
|
+
}, signal, (step) => (this.step = step));
|
|
692
|
+
if (signal.aborted) throw new Error("stopped");
|
|
693
|
+
if (lead.status !== "success") return lead.error ?? lead.status;
|
|
694
|
+
try {
|
|
695
|
+
const proposal = parseSplit(lead.output);
|
|
696
|
+
rejected = splitProblems(proposal, steps.length);
|
|
697
|
+
if (rejected.length === 0) return proposal;
|
|
698
|
+
reason = rejected[0]!;
|
|
699
|
+
} catch (error) {
|
|
700
|
+
rejected = [(error as Error).message];
|
|
701
|
+
reason = rejected[0]!;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return reason;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Save the plan as a pending task; when it has more steps than
|
|
709
|
+
* `splitAbove`, first let the oracle propose splitting it into up to five
|
|
710
|
+
* tasks and let the user decide: take it, keep the plan whole, or say what to
|
|
711
|
+
* change (the oracle revises, a few times). Nothing the user leaves open is
|
|
712
|
+
* decided for them: a questionnaire they put away saves nothing. Returns the
|
|
713
|
+
* notice for the lobby.
|
|
714
|
+
*/
|
|
715
|
+
async saveWithSplit(ask: (questions: AskQuestion[], signal: AbortSignal) => Promise<AskResult>, options: { splitAbove: number }): Promise<string> {
|
|
716
|
+
const plan = this.reply?.plan;
|
|
717
|
+
if (!plan) throw new Error("there is no draft plan to save yet");
|
|
718
|
+
const steps = planSteps(plan);
|
|
719
|
+
const unagreed = this.reply?.status === "ready" ? "" : " (the panel had not agreed yet)";
|
|
720
|
+
const whole = () => `saved ${this.save().id} to the pending tasks — start it from the Tasks tab${unagreed}`;
|
|
721
|
+
if (options.splitAbove <= 0 || steps.length <= options.splitAbove) return whole();
|
|
722
|
+
if (this.busy) return "the panel is still working — save again once it is done, and the oracle will look at splitting this plan";
|
|
723
|
+
const controller = new AbortController();
|
|
724
|
+
this.controller = controller;
|
|
725
|
+
this.status = "thinking";
|
|
726
|
+
this.error = undefined;
|
|
727
|
+
this.deps.onChange?.();
|
|
728
|
+
try {
|
|
729
|
+
let proposal = await this.proposeSplit(plan, steps, undefined, controller.signal);
|
|
730
|
+
if (typeof proposal === "string") {
|
|
731
|
+
const reason = proposal;
|
|
732
|
+
this.deps.feed?.log(ORACLE_LABEL, `could not split the plan — ${reason.split("\n")[0]}`, "warning");
|
|
733
|
+
this.step = "waiting for you";
|
|
734
|
+
const answer = (await ask([splitFailedQuestion(reason)], controller.signal)).answers[0];
|
|
735
|
+
if (controller.signal.aborted) throw new Error("stopped");
|
|
736
|
+
return answer?.answer === "Save it as one task" ? whole() : "the plan was not saved — ctrl+s tries again";
|
|
737
|
+
}
|
|
738
|
+
for (let revisions = 0; ; revisions += 1) {
|
|
739
|
+
this.step = "waiting for you";
|
|
740
|
+
this.deps.onChange?.();
|
|
741
|
+
const result = await ask([splitQuestion({ stepCount: steps.length, proposal, steps, revisions })], controller.signal);
|
|
742
|
+
// A stopped questionnaire comes back empty too; it was stopped, not left open.
|
|
743
|
+
if (controller.signal.aborted) throw new Error("stopped");
|
|
744
|
+
const answer = result.answers[0];
|
|
745
|
+
if (!answer) return "the split question was left open, so nothing was saved — ctrl+s asks again";
|
|
746
|
+
if (answer.kind === "option") {
|
|
747
|
+
if (answer.answer === KEEP_WHOLE) return whole();
|
|
748
|
+
const parts = this.saveParts(proposal);
|
|
749
|
+
return `split into ${parts.length} tasks — ${parts.map((part) => part.id).join(", ")}; start them from the Tasks tab, in order${unagreed}`;
|
|
750
|
+
}
|
|
751
|
+
if (revisions >= MAX_SPLIT_REVISIONS) return `that was the last revision, and nothing was saved — ctrl+s asks again and the oracle starts over`;
|
|
752
|
+
const revised = await this.proposeSplit(plan, steps, { previous: proposal, words: answer.answer ?? "" }, controller.signal);
|
|
753
|
+
if (typeof revised === "string") return `the oracle could not apply that (${revised.split("\n")[0]}) — nothing was saved; ctrl+s asks again`;
|
|
754
|
+
proposal = revised;
|
|
755
|
+
}
|
|
756
|
+
} catch (error) {
|
|
757
|
+
if (controller.signal.aborted) return "stopped — nothing was saved";
|
|
758
|
+
return `could not save the plan: ${(error as Error).message}`;
|
|
759
|
+
} finally {
|
|
760
|
+
this.status = "idle";
|
|
761
|
+
this.step = undefined;
|
|
762
|
+
this.controller = undefined;
|
|
763
|
+
this.deps.onChange?.();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
577
767
|
private stepKey(who: string): string {
|
|
578
768
|
return `plan-${who}-${this.attempts}`;
|
|
579
769
|
}
|
|
@@ -590,7 +780,7 @@ export class PlanningSession {
|
|
|
590
780
|
this.deps.onChange?.();
|
|
591
781
|
}
|
|
592
782
|
|
|
593
|
-
private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[] }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
|
|
783
|
+
private async run(label: string, kind: "planner" | "panel", profile: QuickFixProfile, request: { task: string; systemPrompt: string; tools: readonly string[]; excalidraw?: Grant }, signal: AbortSignal, setStep: (step: string) => void, routedFrom?: string): Promise<RunOutcome> {
|
|
594
784
|
const startedAt = Date.now();
|
|
595
785
|
let outcome: RunOutcome;
|
|
596
786
|
try {
|
|
@@ -658,6 +848,8 @@ export class PlanningSession {
|
|
|
658
848
|
task: `${transcript}\n\nYou are ${label} on the planning panel${roundNote(this.turns, this.limit)}: ask your seat's open questions, or declare READY.`,
|
|
659
849
|
systemPrompt: memberPrompt(member, profile.instructions),
|
|
660
850
|
tools: this.tools(member === "researcher" ? RESEARCH_PANEL_TOOLS : PLANNER_TOOLS),
|
|
851
|
+
// A panel seat is the agent of its own kind: DEV is Backend, DESIGN the Designer.
|
|
852
|
+
...grantOption(member),
|
|
661
853
|
};
|
|
662
854
|
const route = effort ? this.deps.effort?.plan(effort, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }) : undefined;
|
|
663
855
|
let outcome = await this.run(label, "panel", route ? { ...profile, model: route.model, thinking: route.thinking } : profile, request, signal, (step) => (state.step = step), route ? profileLabel(route.from) : undefined);
|
|
@@ -686,6 +878,8 @@ export class PlanningSession {
|
|
|
686
878
|
this.turns += 1;
|
|
687
879
|
this.attempts += 1;
|
|
688
880
|
this.answered = [];
|
|
881
|
+
// The user's reply answers whatever was open: a round that then fails or is stopped must not put those questions to them again.
|
|
882
|
+
this.questions = [];
|
|
689
883
|
const limit = this.limit;
|
|
690
884
|
const mode = roundMode(this.turns, limit);
|
|
691
885
|
this.mode = mode;
|
|
@@ -712,6 +906,7 @@ export class PlanningSession {
|
|
|
712
906
|
task: `${transcript}\n\n${panelSection(outcomes)}\n\n${oracleClosing(mode, this.turns, limit)}`,
|
|
713
907
|
systemPrompt: plannerPrompt(profile.instructions),
|
|
714
908
|
tools: this.tools(PLANNER_TOOLS),
|
|
909
|
+
...grantOption("planner"),
|
|
715
910
|
}, controller.signal, (step) => (this.step = step));
|
|
716
911
|
if (controller.signal.aborted) throw new Error("stopped");
|
|
717
912
|
this.finishRound(outcomes, lead, mode);
|
|
@@ -824,7 +1019,9 @@ export class PlanningSession {
|
|
|
824
1019
|
private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome, mode: RoundMode): void {
|
|
825
1020
|
const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
|
|
826
1021
|
const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
|
|
827
|
-
|
|
1022
|
+
const { asked, repeats } = withoutSettled(roundQuestions(reply, seatQuestions), this.settled);
|
|
1023
|
+
let questions = asked;
|
|
1024
|
+
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");
|
|
828
1025
|
const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
|
|
829
1026
|
let ready = Boolean(reply && reply.status === "ready" && seatsReady);
|
|
830
1027
|
if (reply) {
|