@a-t-h-i/bot-lobby 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +70 -79
  2. package/package.json +4 -2
  3. package/prompts/master.md +3 -2
  4. package/prompts/qa.md +33 -11
  5. package/prompts/reviewer.md +5 -2
  6. package/prompts/worker.md +5 -2
  7. package/src/ask/relay.ts +1 -2
  8. package/src/ask/tool.ts +4 -6
  9. package/src/ask/types.ts +16 -0
  10. package/src/ask/web.ts +63 -0
  11. package/src/index.ts +7 -1
  12. package/src/lobby/ask.ts +4 -5
  13. package/src/lobby/blocks.ts +57 -0
  14. package/src/lobby/feed.ts +1 -1
  15. package/src/lobby/host.ts +178 -0
  16. package/src/lobby/keys.ts +10 -41
  17. package/src/lobby/prompt-hub.ts +30 -77
  18. package/src/lobby/prompts.ts +8 -12
  19. package/src/lobby/runtime.ts +49 -310
  20. package/src/lobby/service.ts +54 -134
  21. package/src/lobby/task-rows.ts +28 -0
  22. package/src/pi/commands.ts +14 -20
  23. package/src/pi/model-settings.ts +112 -0
  24. package/src/pi/start-task.ts +1 -1
  25. package/src/pi/tools.ts +6 -7
  26. package/src/pi/ui.ts +0 -9
  27. package/src/schemas/configuration.ts +9 -33
  28. package/src/state/attachments.ts +82 -0
  29. package/src/webui/api/index.ts +17 -15
  30. package/src/webui/api/lobby.ts +4 -3
  31. package/src/webui/api/planner.ts +6 -4
  32. package/src/webui/api/quickfix.ts +4 -3
  33. package/src/webui/api/sessions.ts +9 -7
  34. package/src/webui/api/settings.ts +20 -8
  35. package/src/webui/api/status.ts +4 -15
  36. package/src/webui/api/tasks.ts +11 -15
  37. package/src/webui/command.ts +35 -19
  38. package/src/webui/dev/fake-service.ts +1 -2
  39. package/src/webui/dev/fixtures.ts +2 -5
  40. package/src/webui/events.ts +1 -1
  41. package/src/webui/protocol.ts +22 -11
  42. package/src/webui/server.ts +42 -1
  43. package/src/webui/uploads.ts +68 -0
  44. package/webui/dist/assets/index-B1PnCyl2.css +2 -0
  45. package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
  46. package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  47. package/webui/dist/build.json +142 -110
  48. package/webui/dist/index.html +4 -3
  49. package/src/ask/dialog.ts +0 -193
  50. package/src/ask/image.ts +0 -202
  51. package/src/ask/png.ts +0 -179
  52. package/src/ask/state.ts +0 -175
  53. package/src/ask/view.ts +0 -163
  54. package/src/lobby/layout.ts +0 -496
  55. package/src/lobby/markdown.ts +0 -121
  56. package/src/lobby/mini.ts +0 -167
  57. package/src/lobby/tabs/excalidraw.ts +0 -95
  58. package/src/lobby/tabs/git.ts +0 -162
  59. package/src/lobby/tabs/home.ts +0 -544
  60. package/src/lobby/tabs/issues.ts +0 -73
  61. package/src/lobby/tabs/knowledge.ts +0 -135
  62. package/src/lobby/tabs/metrics.ts +0 -300
  63. package/src/lobby/tabs/plan.ts +0 -262
  64. package/src/lobby/tabs/quickfix.ts +0 -136
  65. package/src/lobby/tabs/tasks.ts +0 -412
  66. package/src/lobby/theme.ts +0 -30
  67. package/src/lobby/view.ts +0 -3018
  68. package/src/pi/settings-ui.ts +0 -708
  69. package/src/width.ts +0 -102
  70. package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
  71. package/webui/dist/assets/index-E6MPcbdC.js +0 -80
package/src/ask/state.ts DELETED
@@ -1,175 +0,0 @@
1
- /**
2
- * The questionnaire as state and keys, with no terminal in sight: each key
3
- * press is one pure step, so every path (pick, toggle, type your own answer,
4
- * move between questions, submit, cancel) is tested directly.
5
- *
6
- * Every question lists its options, then a row for the user's own answer.
7
- * Enter on an option answers a single-choice question and moves on; space
8
- * toggles options of a multi-choice one and enter moves on. Answering the
9
- * last question submits; ←/→ move between questions first. Writing your own
10
- * answer, Enter keeps it and Shift+Enter starts a new line. Esc stops typing,
11
- * or puts the questions away.
12
- */
13
- import type { AskAnswer, AskQuestion, AskResult } from "./types.ts";
14
-
15
- export type AskKey =
16
- | { type: "up" }
17
- | { type: "down" }
18
- | { type: "left" }
19
- | { type: "right" }
20
- | { type: "enter" }
21
- | { type: "space" }
22
- | { type: "escape" }
23
- | { type: "backspace" }
24
- /** Shift+Enter (or Ctrl+J): a new line in the answer being written. */
25
- | { type: "newline" }
26
- /** 1-9: pick (or toggle) that option. */
27
- | { type: "digit"; value: number }
28
- /** Typed or pasted text, only used while writing an answer. */
29
- | { type: "text"; value: string };
30
-
31
- export interface AskState {
32
- questions: readonly AskQuestion[];
33
- /** The question in view. */
34
- tab: number;
35
- /** The focused row of each question: an option index, or `options.length` for the own-answer row. */
36
- cursor: number[];
37
- /** Options picked in each question (one for a single-choice question). */
38
- picked: number[][];
39
- /** The user's own answer to each question, when they wrote one. */
40
- typed: Array<string | undefined>;
41
- /** Writing the own answer of the question in view. */
42
- editing: boolean;
43
- draft: string;
44
- /** Esc was pressed: asked whether to leave without answering; enter leaves, any other key keeps answering. */
45
- leaving?: boolean;
46
- /** Set once the user submits or puts the questions away. */
47
- result?: AskResult;
48
- }
49
-
50
- /** The questions put away, with what was answered so far (also when something outside ends them). */
51
- export function putAway(state: AskState): AskState {
52
- return { ...state, editing: false, draft: "", leaving: false, result: { answers: answersOf(state), cancelled: true } };
53
- }
54
-
55
- export function initialState(questions: readonly AskQuestion[]): AskState {
56
- return {
57
- questions,
58
- tab: 0,
59
- cursor: questions.map(() => 0),
60
- picked: questions.map(() => []),
61
- typed: questions.map(() => undefined),
62
- editing: false,
63
- draft: "",
64
- };
65
- }
66
-
67
- /** Whether a question has an answer yet. */
68
- export function isAnswered(state: AskState, index: number): boolean {
69
- return (state.picked[index]?.length ?? 0) > 0 || Boolean(state.typed[index]?.trim());
70
- }
71
-
72
- /** The answers given, in the order asked; unanswered questions are left out. */
73
- export function answersOf(state: AskState): AskAnswer[] {
74
- const answers: AskAnswer[] = [];
75
- state.questions.forEach((question, index) => {
76
- const picked = (state.picked[index] ?? []).map((option) => question.options[option]!.label);
77
- const own = state.typed[index]?.trim();
78
- if (question.multiSelect) {
79
- const selected = own ? [...picked, own] : picked;
80
- if (selected.length > 0) answers.push({ questionIndex: index, question: question.question, kind: "multi", answer: selected.join(", "), selected });
81
- } else if (own) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: own });
82
- else if (picked[0]) answers.push({ questionIndex: index, question: question.question, kind: "option", answer: picked[0] });
83
- });
84
- return answers;
85
- }
86
-
87
- function set<T>(list: readonly T[], index: number, value: T): T[] {
88
- const next = [...list];
89
- next[index] = value;
90
- return next;
91
- }
92
-
93
- /** The next question, or the submitted result after the last one. */
94
- function advance(state: AskState): AskState {
95
- if (state.tab < state.questions.length - 1) return { ...state, tab: state.tab + 1, editing: false, draft: "" };
96
- return { ...state, editing: false, draft: "", result: { answers: answersOf(state), cancelled: false } };
97
- }
98
-
99
- function choose(state: AskState, option: number): AskState {
100
- const question = state.questions[state.tab]!;
101
- if (option < 0 || option >= question.options.length) return state;
102
- if (question.multiSelect) {
103
- const picked = state.picked[state.tab] ?? [];
104
- const next = picked.includes(option) ? picked.filter((entry) => entry !== option) : [...picked, option].sort((a, b) => a - b);
105
- return { ...state, cursor: set(state.cursor, state.tab, option), picked: set(state.picked, state.tab, next) };
106
- }
107
- // One answer to a single-choice question: picking an option replaces any words of the user's own.
108
- return advance({ ...state, cursor: set(state.cursor, state.tab, option), picked: set(state.picked, state.tab, [option]), typed: set(state.typed, state.tab, undefined) });
109
- }
110
-
111
- function typing(state: AskState, key: AskKey): AskState {
112
- switch (key.type) {
113
- case "text":
114
- // Pasted lines stay lines; tabs become spaces.
115
- return { ...state, draft: state.draft + key.value.replace(/\r\n?/g, "\n").replace(/\t/g, " ") };
116
- case "newline":
117
- return { ...state, draft: `${state.draft}\n` };
118
- case "space":
119
- return { ...state, draft: `${state.draft} ` };
120
- case "digit":
121
- return { ...state, draft: state.draft + String(key.value) };
122
- case "backspace":
123
- return { ...state, draft: [...state.draft].slice(0, -1).join("") };
124
- case "escape":
125
- return { ...state, editing: false, draft: "" };
126
- case "enter": {
127
- const own = state.draft.trim();
128
- const question = state.questions[state.tab]!;
129
- const typed = set(state.typed, state.tab, own || undefined);
130
- // Your own words answer a single-choice question instead of a picked option.
131
- const picked = own && !question.multiSelect ? set(state.picked, state.tab, []) : state.picked;
132
- const next = { ...state, typed, picked, editing: false, draft: "" };
133
- return own ? advance(next) : next;
134
- }
135
- default:
136
- return state;
137
- }
138
- }
139
-
140
- /** One key press. */
141
- export function step(state: AskState, key: AskKey): AskState {
142
- if (state.result) return state;
143
- // Esc asks first: leaving the questions unanswered lets the oracle carry on without you, so it takes a second key.
144
- if (state.leaving) return key.type === "enter" || (key.type === "text" && key.value.toLowerCase() === "y") ? putAway(state) : { ...state, leaving: false };
145
- if (state.editing) return typing(state, key);
146
- const question = state.questions[state.tab];
147
- if (!question) return { ...state, result: { answers: [], cancelled: false } };
148
- const rows = question.options.length + 1;
149
- const cursor = state.cursor[state.tab] ?? 0;
150
- switch (key.type) {
151
- case "up":
152
- return { ...state, cursor: set(state.cursor, state.tab, (cursor - 1 + rows) % rows) };
153
- case "down":
154
- return { ...state, cursor: set(state.cursor, state.tab, (cursor + 1) % rows) };
155
- case "left":
156
- return state.tab > 0 ? { ...state, tab: state.tab - 1 } : state;
157
- case "right":
158
- return state.tab < state.questions.length - 1 ? { ...state, tab: state.tab + 1 } : state;
159
- case "digit":
160
- return choose(state, key.value - 1);
161
- case "space":
162
- return cursor < question.options.length && question.multiSelect ? choose(state, cursor) : state;
163
- case "enter": {
164
- if (cursor === question.options.length) return { ...state, editing: true, draft: state.typed[state.tab] ?? "" };
165
- if (!question.multiSelect) return choose(state, cursor);
166
- // A multi-choice question moves on with what is picked; with nothing picked, enter picks the focused option.
167
- if (!isAnswered(state, state.tab)) return advance(choose(state, cursor));
168
- return advance(state);
169
- }
170
- case "escape":
171
- return { ...state, leaving: true };
172
- default:
173
- return state;
174
- }
175
- }
package/src/ask/view.ts DELETED
@@ -1,163 +0,0 @@
1
- /**
2
- * The questionnaire drawn: a chip per question, the question in Markdown, its
3
- * options (each with its description in Markdown), the row for your own
4
- * answer, and — when options carry one — the focused option's preview in a
5
- * box beside them (or under them in a narrow terminal). Pure: state in, lines
6
- * out, exactly `width` columns and at most `height` rows.
7
- */
8
- import { basename } from "node:path";
9
- import { textWidth } from "../width.ts";
10
- import { beside, bold, box, fill, fit, markdownLines, paint, wrap, type LobbyTheme } from "../lobby/layout.ts";
11
- import { imageLines, type ImagePreview } from "./image.ts";
12
- import { isAnswered, type AskState } from "./state.ts";
13
- import { OWN_ANSWER, type AskQuestion } from "./types.ts";
14
-
15
- /** Terminals at least this wide show the preview beside the options. */
16
- export const SIDE_BY_SIDE_MIN = 96;
17
- /** Rows the preview box keeps at least, and at most. */
18
- const PREVIEW_MIN = 6;
19
- const PREVIEW_MAX = 40;
20
- /** A preview box needs this many inner rows to draw an image in; smaller ones name the file. */
21
- const IMAGE_MIN_ROWS = 6;
22
- /** Option descriptions sit under their label, past the marker and number. */
23
- const DESCRIPTION_INDENT = 7;
24
-
25
- const RECOMMENDED = /\s*\(recommended\)\s*$/i;
26
-
27
- function chips(state: AskState, theme?: LobbyTheme): string {
28
- if (state.questions.length === 1) return paint(theme, "muted", state.questions[0]!.header);
29
- return state.questions
30
- .map((question, index) => {
31
- const label = ` ${index + 1} ${question.header}${isAnswered(state, index) ? " ✓" : ""} `;
32
- if (index === state.tab) return bold(theme, paint(theme, "accent", `[${label.trim()}]`));
33
- return paint(theme, isAnswered(state, index) ? "success" : "muted", label.trim());
34
- })
35
- .join(paint(theme, "dim", " · "));
36
- }
37
-
38
- function optionLabel(label: string, focused: boolean, theme?: LobbyTheme): string {
39
- const recommended = RECOMMENDED.test(label);
40
- const plain = label.replace(RECOMMENDED, "");
41
- const text = focused ? bold(theme, plain) : plain;
42
- return recommended ? `${text} ${paint(theme, "success", "(Recommended)")}` : text;
43
- }
44
-
45
- /** The options and the own-answer row, each option with its description beneath. */
46
- function optionLines(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
47
- const cursor = state.cursor[state.tab] ?? 0;
48
- const picked = state.picked[state.tab] ?? [];
49
- const lines: string[] = [];
50
- question.options.forEach((option, index) => {
51
- const focused = cursor === index && !state.editing;
52
- const pointer = focused ? paint(theme, "accent", "›") : " ";
53
- const mark = question.multiSelect ? (picked.includes(index) ? "☑" : "☐") : picked.includes(index) ? "●" : "○";
54
- const lead = `${pointer} ${paint(theme, picked.includes(index) ? "accent" : "dim", mark)} ${paint(theme, "dim", `${index + 1}.`)} `;
55
- const labelLines = wrap(optionLabel(option.label, focused, theme), Math.max(1, width - textWidth(lead)));
56
- lines.push(`${lead}${labelLines[0] ?? ""}`, ...labelLines.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${line}`));
57
- if (option.description?.trim()) {
58
- const indent = " ".repeat(DESCRIPTION_INDENT);
59
- for (const line of markdownLines(option.description.trim(), Math.max(1, width - DESCRIPTION_INDENT), theme)) lines.push(line ? `${indent}${paint(theme, "muted", line)}` : "");
60
- }
61
- });
62
- const ownFocused = cursor === question.options.length;
63
- const pointer = ownFocused || state.editing ? paint(theme, "accent", "›") : " ";
64
- const own = state.typed[state.tab];
65
- const lead = `${pointer} ${paint(theme, own ? "accent" : "dim", "✎")} `;
66
- const room = Math.max(1, width - textWidth(lead));
67
- if (state.editing) {
68
- // Each line of the answer wraps on its own, so Shift+Enter shows as a new row.
69
- const draft = `${state.draft}▏`.split("\n").flatMap((line) => (line ? wrap(line, room) : [""]));
70
- lines.push(`${lead}${paint(theme, "accent", draft[0] ?? "")}`, ...draft.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${paint(theme, "accent", line)}`));
71
- } else {
72
- // A several-line answer shows on one row, its line breaks marked.
73
- const shown = own?.replace(/\s*\n\s*/g, " ⏎ ");
74
- lines.push(`${lead}${shown ? paint(theme, "accent", `“${shown}”`) : paint(theme, ownFocused ? "text" : "dim", OWN_ANSWER)}`);
75
- }
76
- return lines;
77
- }
78
-
79
- interface Preview {
80
- label: string;
81
- text: string;
82
- image?: string;
83
- }
84
-
85
- /** The preview to show: the focused option's, when any option in the question has one (Markdown or an image). */
86
- function previewOf(state: AskState, question: AskQuestion): Preview | undefined {
87
- if (!question.options.some((option) => option.preview?.trim() || option.image?.trim())) return undefined;
88
- const cursor = state.cursor[state.tab] ?? 0;
89
- const option = question.options[cursor];
90
- if (!option) return { label: OWN_ANSWER, text: "" };
91
- return { label: option.label.replace(RECOMMENDED, ""), text: option.preview?.trim() ?? "", ...(option.image?.trim() ? { image: option.image.trim() } : {}) };
92
- }
93
-
94
- /** The image part of a preview: drawn when the box has room, else named. It never takes the box's last rows from the text entirely. */
95
- function imageBlock(preview: Preview, inner: number, innerRows: number, textRows: number, frame: AskFrame, theme?: LobbyTheme): string[] {
96
- if (!preview.image) return [];
97
- const loaded = frame.images?.get(preview.image);
98
- const textRoom = textRows > 0 ? Math.min(textRows + 1, Math.max(2, Math.floor(innerRows / 3))) : 0;
99
- const rows = innerRows - textRoom;
100
- if (!loaded) return [paint(theme, "muted", `Image: ${preview.image}`)];
101
- if (rows < IMAGE_MIN_ROWS) return [paint(theme, "muted", `Image: ${basename(preview.image)} (the terminal is too small to draw it)`)];
102
- return imageLines(loaded, inner, rows, theme);
103
- }
104
-
105
- /** The preview in a box at most `rows` tall; an image keeps within `fits` rows, which the terminal surely shows. */
106
- function previewBox(preview: Preview, width: number, rows: number, theme: LobbyTheme | undefined, frame: AskFrame, fits = rows): string[] {
107
- const inner = Math.max(1, width - 4);
108
- const text = preview.text ? markdownLines(preview.text, inner, theme) : [];
109
- const image = imageBlock(preview, inner, Math.min(rows, fits) - 2, text.length, frame, theme);
110
- const joined = [...image, ...(image.length > 0 && text.length > 0 ? [""] : []), ...text];
111
- const body = joined.length > 0 ? joined : [paint(theme, "dim", "No preview for this one.")];
112
- const height = Math.max(3, Math.min(rows, body.length + 2));
113
- const more = body.length > height - 2 ? `${body.length - (height - 2)} more lines` : "";
114
- return box(width, height, body, { title: `Preview · ${preview.label}`, ...(more ? { right: more } : {}), theme });
115
- }
116
-
117
- function hints(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
118
- if (state.leaving) return wrap(paint(theme, "warning", "Leave without answering? The oracle will not guess for you. enter leaves · any other key keeps answering"), width);
119
- const parts = state.editing
120
- ? ["enter keep it", "shift+enter new line", "esc back to the options"]
121
- : [
122
- "↑↓ move",
123
- question.multiSelect ? "space pick · enter next" : "enter choose",
124
- `1-${question.options.length} pick`,
125
- ...(state.questions.length > 1 ? ["←→ questions"] : []),
126
- "esc leave",
127
- ];
128
- return wrap(paint(theme, "dim", parts.join(" · ")), width);
129
- }
130
-
131
- /** Around the questions: who is asking (an agent the oracle relays for), and the option images, read beforehand. */
132
- export interface AskFrame {
133
- from?: string;
134
- images?: ReadonlyMap<string, ImagePreview>;
135
- }
136
-
137
- /** The whole questionnaire in a box, `width` wide and at most `height` rows. */
138
- export function renderAsk(state: AskState, width: number, height: number, theme?: LobbyTheme, frame: AskFrame = {}): string[] {
139
- const question = state.questions[state.tab];
140
- if (!question || width < 20 || height < 6) return [];
141
- const inner = width - 4;
142
- const head = [chips(state, theme), "", ...markdownLines(question.question, inner, theme), ""];
143
- const foot = ["", ...hints(state, question, inner, theme)];
144
- const preview = previewOf(state, question);
145
- let body: string[];
146
- if (preview && inner >= SIDE_BY_SIDE_MIN) {
147
- const left = Math.floor(inner * 0.42);
148
- const options = optionLines(state, question, left, theme);
149
- const rows = Math.max(PREVIEW_MIN, Math.min(PREVIEW_MAX, height - 2 - head.length - foot.length));
150
- const side = previewBox(preview, inner - left - 2, Math.max(rows, Math.min(options.length, PREVIEW_MAX)), theme, frame, rows);
151
- body = beside([fill(options, Math.max(options.length, side.length), left), side], " ");
152
- } else {
153
- const options = optionLines(state, question, inner, theme);
154
- const room = height - 2 - head.length - foot.length - options.length - 1;
155
- body = preview && room >= 3 ? [...options, "", ...previewBox(preview, inner, Math.min(PREVIEW_MAX, room), theme, frame)] : options;
156
- }
157
- const content = [...head, ...body, ...foot];
158
- // Too tall for the terminal: the question and hints stay, the middle is cut.
159
- const fitted = content.length > height - 2 ? [...content.slice(0, height - 2 - foot.length), ...foot] : content;
160
- const count = state.questions.length > 1 ? `Question ${state.tab + 1} of ${state.questions.length}` : "Question";
161
- const title = frame.from ? `${frame.from} asks · ${count}` : count;
162
- return box(width, fitted.length + 2, fitted.map((line) => fit(line, inner)), { title, focused: true, theme });
163
- }