@a-t-h-i/bot-lobby 0.6.3 → 0.6.5
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 +180 -17
- package/package.json +12 -6
- package/prompts/backend.md +3 -1
- package/prompts/designer.md +24 -1
- package/prompts/master.md +98 -19
- package/prompts/quickfix.md +16 -3
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/agents/backend.ts +2 -2
- package/src/agents/designer.ts +2 -2
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/classifier/triage.ts +24 -7
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +6 -1
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +50 -3
- package/src/lobby/runtime.ts +41 -40
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +8 -2
- package/src/lobby/tabs/tasks.ts +16 -5
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +36 -5
- package/src/master/decisions.ts +10 -2
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +108 -13
- package/src/pi/events.ts +48 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/route.ts +180 -0
- package/src/pi/start-task.ts +56 -4
- package/src/pi/tools.ts +32 -9
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +28 -2
- package/src/schemas/configuration.ts +15 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +52 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/workflow/track.ts +436 -0
- package/src/workflow/workflow.ts +734 -46
package/prompts/reviewer.md
CHANGED
|
@@ -18,6 +18,30 @@ Review requirements, the approved plan, the actual diff, affected files, tests,
|
|
|
18
18
|
security, accessibility where relevant, error handling, reliability,
|
|
19
19
|
performance where relevant, maintainability, and scope discipline.
|
|
20
20
|
|
|
21
|
+
## Change provenance
|
|
22
|
+
|
|
23
|
+
The working tree can hold changes that are not this task's: the user makes
|
|
24
|
+
quick fixes from the lobby while tasks run, other tasks may run beside this
|
|
25
|
+
one, and there may have been uncommitted work before the task started. When
|
|
26
|
+
your context lists who changed each file (bot-lobby's own record of every
|
|
27
|
+
agent's `edit`/`write` calls), judge each change by its source:
|
|
28
|
+
|
|
29
|
+
- **planned**: this task's workers. Review it against the plan, scope
|
|
30
|
+
discipline included.
|
|
31
|
+
- **quick fix**: a change the user asked for directly. It is authorised and
|
|
32
|
+
outside this task's plan, so it is never scope creep or a rogue change, and
|
|
33
|
+
you never ask for it to be reverted. Mention it only if it breaks this task
|
|
34
|
+
or its checks, naming the quick fix.
|
|
35
|
+
- **another task** or **pre-existing**: not this task's work. Leave it alone
|
|
36
|
+
unless it breaks this task.
|
|
37
|
+
- **unattributed**: no agent recorded the edit (the user by hand, a shell
|
|
38
|
+
command, another tool). Do not call it rogue: list the files in one `info`
|
|
39
|
+
finding so the Master can ask the user. It fails the gate only when it
|
|
40
|
+
breaks this task.
|
|
41
|
+
|
|
42
|
+
A file with several sources holds more than this task's work: judge this task
|
|
43
|
+
only by what its workers were asked to do.
|
|
44
|
+
|
|
21
45
|
## You MAY
|
|
22
46
|
|
|
23
47
|
- read files
|
|
@@ -51,6 +75,24 @@ If implementation changes are required, report them to the Master.
|
|
|
51
75
|
- Always pass a bash `timeout` to test and build commands; never start watch
|
|
52
76
|
mode or servers.
|
|
53
77
|
|
|
78
|
+
## Verdict
|
|
79
|
+
|
|
80
|
+
- **PASS** when the acceptance criteria are met and your checks pass. Minor
|
|
81
|
+
and info findings never block: list them, then PASS. (The engine passes a
|
|
82
|
+
CHANGES_REQUIRED whose findings are all tagged minor or info.)
|
|
83
|
+
- **CHANGES_REQUIRED** only for a critical or major finding: broken
|
|
84
|
+
behaviour, a failing check, an unmet acceptance criterion, a security
|
|
85
|
+
problem. Tag every finding with its severity.
|
|
86
|
+
- **BLOCKED** only when you cannot review at all (it does not build, the
|
|
87
|
+
checks cannot run).
|
|
88
|
+
- **A re-review verifies; it does not start over.** When your context lists
|
|
89
|
+
what the previous round asked for, check each item first and say which are
|
|
90
|
+
addressed. Do not raise the bar between rounds: a new blocking finding must
|
|
91
|
+
be critical or major.
|
|
92
|
+
- Work committed during the task counts. The diff you are given runs from the
|
|
93
|
+
commit the task started at, so committed fixes are in it; use `git log` and
|
|
94
|
+
`git diff` against that commit to look further.
|
|
95
|
+
|
|
54
96
|
## Pushback
|
|
55
97
|
|
|
56
98
|
If the approved requirement or a requested change is itself unsound, add a
|
package/prompts/worker.md
CHANGED
|
@@ -43,6 +43,13 @@ Work efficiently: read what you need, make the change, verify, report. If the
|
|
|
43
43
|
engine asks you to wrap up, stop exploring, leave every file consistent, and
|
|
44
44
|
write your report with anything unfinished under Blockers or Notes.
|
|
45
45
|
|
|
46
|
+
When your context has a **Time** section, the step has that many minutes. Land
|
|
47
|
+
the most important part first and keep files consistent as you go. When the
|
|
48
|
+
time is up you are told to stop: finish or revert the edit in progress, then
|
|
49
|
+
report with `## Left Off` (what you were doing, what is still to do) and
|
|
50
|
+
`## More Time` (`N minutes — why`), honestly sized. If the user gives you more,
|
|
51
|
+
you carry on from where you stopped.
|
|
52
|
+
|
|
46
53
|
## Working alongside other workers (file desk)
|
|
47
54
|
|
|
48
55
|
When the `claim_file` tool is available, other workers are editing the same
|
package/src/agents/backend.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const backendSpec: DomainSpec = {
|
|
|
4
4
|
domain: "backend",
|
|
5
5
|
promptFile: "backend.md",
|
|
6
6
|
scoutFocus:
|
|
7
|
-
"API, business logic, data models, persistence, authentication/authorization, integrations, and backend reliability",
|
|
7
|
+
"server-side code: API, business logic, data models, persistence, authentication/authorization, integrations, and backend reliability",
|
|
8
8
|
boundary:
|
|
9
|
-
"Stay inside
|
|
9
|
+
"Stay inside server-side code. Do not modify browser code (pages, components, client-side logic or graphics); report frontend requirements to the Master.",
|
|
10
10
|
};
|
package/src/agents/designer.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const designerSpec: DomainSpec = {
|
|
|
4
4
|
domain: "designer",
|
|
5
5
|
promptFile: "designer.md",
|
|
6
6
|
scoutFocus:
|
|
7
|
-
"UI/UX,
|
|
7
|
+
"UI/UX and everything that runs in the browser (pages, components, client-side logic, canvas and WebGL/three.js graphics), accessibility, responsive behavior, and the existing design language",
|
|
8
8
|
boundary:
|
|
9
|
-
"Stay inside frontend
|
|
9
|
+
"Stay inside the frontend: everything that runs in the browser. Do not modify server-side code; report backend dependencies to the Master.",
|
|
10
10
|
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting questions to the user. In pi's terminal the questionnaire opens as
|
|
3
|
+
* an overlay (over the lobby too) with Markdown throughout and each option's
|
|
4
|
+
* preview beside it. Where pi cannot draw one — RPC mode, which background
|
|
5
|
+
* sessions and editor hosts run in — the same questions go through pi's own
|
|
6
|
+
* select and input dialogs, which those hosts do forward.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { LobbyTheme } from "../lobby/layout.ts";
|
|
11
|
+
import { lobbyTheme } from "../lobby/theme.ts";
|
|
12
|
+
import { initialState, step, type AskKey, type AskState } from "./state.ts";
|
|
13
|
+
import { renderAsk, type AskFrame } from "./view.ts";
|
|
14
|
+
import { loadImages } from "./image.ts";
|
|
15
|
+
import { isAbsolute, resolve } from "node:path";
|
|
16
|
+
import type { AskAnswer, AskQuestion, AskResult } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Puts up to `MAX_QUESTIONS` questions to the user and returns what they
|
|
20
|
+
* chose. `from` names who asks when it is not this session's model (an agent
|
|
21
|
+
* whose questions the oracle relays).
|
|
22
|
+
*/
|
|
23
|
+
export type Asker = (questions: readonly AskQuestion[], ctx: ExtensionContext, signal?: AbortSignal, from?: string) => Promise<AskResult>;
|
|
24
|
+
|
|
25
|
+
/** Share of the terminal the overlay may take. */
|
|
26
|
+
const OVERLAY_HEIGHT = 0.9;
|
|
27
|
+
|
|
28
|
+
/** A key press as the questionnaire reads it; undefined for keys it ignores. */
|
|
29
|
+
export function readKey(data: string): AskKey | undefined {
|
|
30
|
+
if (matchesKey(data, Key.up)) return { type: "up" };
|
|
31
|
+
if (matchesKey(data, Key.down)) return { type: "down" };
|
|
32
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab"))) return { type: "left" };
|
|
33
|
+
if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) return { type: "right" };
|
|
34
|
+
if (matchesKey(data, Key.enter)) return { type: "enter" };
|
|
35
|
+
if (matchesKey(data, Key.escape)) return { type: "escape" };
|
|
36
|
+
if (matchesKey(data, Key.backspace)) return { type: "backspace" };
|
|
37
|
+
if (data === " ") return { type: "space" };
|
|
38
|
+
if (/^[1-9]$/.test(data)) return { type: "digit", value: Number(data) };
|
|
39
|
+
// Typed or pasted text: anything printable (control sequences are dropped).
|
|
40
|
+
const text = [...data].filter((char) => char >= " " && char !== "\x7f").join("");
|
|
41
|
+
if (text && !data.startsWith("\x1b")) return { type: "text", value: text };
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The questionnaire as a pi component: keys step its state, each frame draws it. */
|
|
46
|
+
export class AskDialog implements Component {
|
|
47
|
+
private state: AskState;
|
|
48
|
+
private readonly tui: TUI;
|
|
49
|
+
private readonly theme: LobbyTheme;
|
|
50
|
+
private readonly done: (result: AskResult) => void;
|
|
51
|
+
private readonly frame: AskFrame;
|
|
52
|
+
|
|
53
|
+
constructor(tui: TUI, theme: LobbyTheme, questions: readonly AskQuestion[], done: (result: AskResult) => void, frame: AskFrame = {}) {
|
|
54
|
+
this.tui = tui;
|
|
55
|
+
this.theme = theme;
|
|
56
|
+
this.state = initialState(questions);
|
|
57
|
+
this.done = done;
|
|
58
|
+
this.frame = frame;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
handleInput(data: string): void {
|
|
62
|
+
const key = readKey(data);
|
|
63
|
+
if (!key) return;
|
|
64
|
+
this.state = step(this.state, key);
|
|
65
|
+
if (this.state.result) this.done(this.state.result);
|
|
66
|
+
else this.tui.requestRender();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Put the questions away from outside (the turn was aborted). */
|
|
70
|
+
cancel(): void {
|
|
71
|
+
if (!this.state.result) this.handleInput("\x1b");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
render(width: number): string[] {
|
|
75
|
+
return renderAsk(this.state, width, Math.max(8, Math.floor(this.tui.terminal.rows * OVERLAY_HEIGHT)), this.theme, this.frame);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
invalidate(): void {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The questions in pi's terminal, or through its plain dialogs where it
|
|
83
|
+
* cannot draw the questionnaire. Without any UI nobody can answer: the
|
|
84
|
+
* result says the questions were put away.
|
|
85
|
+
*/
|
|
86
|
+
export const askUser: Asker = async (questions, ctx, signal, from) => {
|
|
87
|
+
if (!ctx.hasUI || questions.length === 0) return { answers: [], cancelled: true };
|
|
88
|
+
if ((ctx as { mode?: string }).mode === "rpc") return dialogAsker(questions, ctx, signal, from);
|
|
89
|
+
// Option images are read before the questionnaire opens, so drawing it never waits on the disk.
|
|
90
|
+
const images = await loadImages(questions.flatMap((question) => question.options.map((option) => (option.image?.trim() ? imagePath(option.image, ctx.cwd) : ""))));
|
|
91
|
+
const shown = questions.map((question) => ({ ...question, options: question.options.map((option) => (option.image?.trim() ? { ...option, image: imagePath(option.image, ctx.cwd) } : option)) }));
|
|
92
|
+
let dialog: AskDialog | undefined;
|
|
93
|
+
const onAbort = () => dialog?.cancel();
|
|
94
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
95
|
+
try {
|
|
96
|
+
const result = await ctx.ui.custom<AskResult>((tui, theme, _keys, done) => {
|
|
97
|
+
dialog = new AskDialog(tui, lobbyTheme(theme), shown, done, { ...(from ? { from } : {}), ...(images.size > 0 ? { images } : {}) });
|
|
98
|
+
if (signal?.aborted) queueMicrotask(onAbort);
|
|
99
|
+
return dialog;
|
|
100
|
+
}, { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%", anchor: "center", margin: 1 } });
|
|
101
|
+
// A host that cannot draw custom components resolves at once with nothing.
|
|
102
|
+
return result ?? dialogAsker(questions, ctx, signal, from);
|
|
103
|
+
} finally {
|
|
104
|
+
signal?.removeEventListener("abort", onAbort);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function imagePath(path: string, cwd: string): string {
|
|
109
|
+
const trimmed = path.trim();
|
|
110
|
+
return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const TYPE_ANSWER = "Type an answer…";
|
|
114
|
+
const SKIP = "Skip";
|
|
115
|
+
const DONE = "Done";
|
|
116
|
+
/** Longest description or preview folded into a plain dialog's title. */
|
|
117
|
+
const MAX_FOLDED = 600;
|
|
118
|
+
|
|
119
|
+
/** A question as one plain dialog's title: the question, then each option's description and preview. */
|
|
120
|
+
function dialogTitle(question: AskQuestion, index: number, total: number, from?: string): string {
|
|
121
|
+
const details = question.options
|
|
122
|
+
.map((option) => [
|
|
123
|
+
option.description?.trim() ? `${option.label}: ${option.description.trim()}` : "",
|
|
124
|
+
option.preview?.trim() ? `--- ${option.label} ---\n${option.preview.trim().slice(0, MAX_FOLDED)}` : "",
|
|
125
|
+
option.image?.trim() ? `(${option.label}: see the image ${option.image.trim()})` : "",
|
|
126
|
+
].filter(Boolean).join("\n"))
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
return [`${from ? `${from} asks · ` : ""}${question.header} · ${index + 1}/${total}`, question.question, ...details].join("\n\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The same questions through pi's select and input dialogs: pick, type an answer, or skip; esc stops. */
|
|
132
|
+
export const dialogAsker: Asker = async (questions, ctx, _signal, from) => {
|
|
133
|
+
const answers: AskAnswer[] = [];
|
|
134
|
+
for (const [index, question] of questions.entries()) {
|
|
135
|
+
const title = dialogTitle(question, index, questions.length, from);
|
|
136
|
+
if (question.multiSelect) {
|
|
137
|
+
const selected: string[] = [];
|
|
138
|
+
for (;;) {
|
|
139
|
+
const left = question.options.map((option) => option.label).filter((label) => !selected.includes(label));
|
|
140
|
+
const choice = await ctx.ui.select(`${title}${selected.length > 0 ? `\n\nPicked: ${selected.join(", ")}` : ""}`, [...left, TYPE_ANSWER, selected.length > 0 ? DONE : SKIP]);
|
|
141
|
+
if (choice === undefined) return { answers, cancelled: true };
|
|
142
|
+
if (choice === DONE || choice === SKIP) break;
|
|
143
|
+
if (choice === TYPE_ANSWER) {
|
|
144
|
+
const typed = await ctx.ui.input(question.question, "your answer");
|
|
145
|
+
if (typed === undefined) return { answers, cancelled: true };
|
|
146
|
+
if (typed.trim()) selected.push(typed.trim());
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
selected.push(choice);
|
|
150
|
+
if (left.length === 1) break;
|
|
151
|
+
}
|
|
152
|
+
if (selected.length > 0) answers.push({ questionIndex: index, question: question.question, kind: "multi", answer: selected.join(", "), selected });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const choice = await ctx.ui.select(title, [...question.options.map((option) => option.label), TYPE_ANSWER, SKIP]);
|
|
156
|
+
if (choice === undefined) return { answers, cancelled: true };
|
|
157
|
+
if (choice === SKIP) continue;
|
|
158
|
+
if (choice === TYPE_ANSWER) {
|
|
159
|
+
const typed = await ctx.ui.input(question.question, "your answer");
|
|
160
|
+
if (typed === undefined) return { answers, cancelled: true };
|
|
161
|
+
if (typed.trim()) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: typed.trim() });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
answers.push({ questionIndex: index, question: question.question, kind: "option", answer: choice });
|
|
165
|
+
}
|
|
166
|
+
return { answers, cancelled: false };
|
|
167
|
+
};
|
package/src/ask/image.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images in the questionnaire: a screenshot or rendered mockup an option
|
|
3
|
+
* carries, drawn in its preview. Terminals that speak the Kitty graphics
|
|
4
|
+
* protocol (Kitty, Ghostty, WezTerm) get the image itself; everywhere else a
|
|
5
|
+
* PNG is drawn with coloured half-blocks, which any colour terminal shows and
|
|
6
|
+
* which sit in a box like text. Other formats there get a line saying what
|
|
7
|
+
* and where the file is. `BOT_LOBBY_IMAGES=blocks` always draws blocks;
|
|
8
|
+
* `off` never draws images.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile, stat } from "node:fs/promises";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { getCapabilities, getImageDimensions, renderImage, type ImageDimensions } from "@earendil-works/pi-tui";
|
|
13
|
+
import { paint, type LobbyTheme } from "../lobby/layout.ts";
|
|
14
|
+
import { decodePng, type Pixels } from "./png.ts";
|
|
15
|
+
|
|
16
|
+
/** Larger files are not read. */
|
|
17
|
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
export type ImageMime = "image/png" | "image/jpeg" | "image/gif" | "image/webp";
|
|
20
|
+
|
|
21
|
+
export interface LoadedImage {
|
|
22
|
+
path: string;
|
|
23
|
+
mime: ImageMime;
|
|
24
|
+
base64: string;
|
|
25
|
+
dimensions: ImageDimensions;
|
|
26
|
+
/** Decoded on first use, for the half-block drawing (PNG only). */
|
|
27
|
+
pixels?: Pixels | string;
|
|
28
|
+
/** Kitty reuses one id per image so a redraw replaces it. */
|
|
29
|
+
kittyId?: number;
|
|
30
|
+
/** Half-block drawings already made, by size: every key press redraws the questionnaire. */
|
|
31
|
+
blocks?: Map<string, string[]>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An image option whose file could not be read, and why. */
|
|
35
|
+
export interface MissingImage {
|
|
36
|
+
path: string;
|
|
37
|
+
error: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ImagePreview = LoadedImage | MissingImage;
|
|
41
|
+
|
|
42
|
+
const EXTENSIONS = /\.(png|jpe?g|gif|webp)$/i;
|
|
43
|
+
|
|
44
|
+
export function isImagePath(path: string): boolean {
|
|
45
|
+
return EXTENSIONS.test(path.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sniff(bytes: Uint8Array): ImageMime | undefined {
|
|
49
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
|
|
50
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
51
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
|
|
52
|
+
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return "image/webp";
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read an option's image file: a PNG, JPEG, GIF or WebP of at most 10 MB. */
|
|
57
|
+
export async function loadImage(path: string): Promise<ImagePreview> {
|
|
58
|
+
try {
|
|
59
|
+
const info = await stat(path);
|
|
60
|
+
if (!info.isFile()) return { path, error: "not a file" };
|
|
61
|
+
if (info.size > MAX_IMAGE_BYTES) return { path, error: `too large to show (${Math.round(info.size / 1024 / 1024)} MB)` };
|
|
62
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
63
|
+
const mime = sniff(bytes);
|
|
64
|
+
if (!mime) return { path, error: "not a PNG, JPEG, GIF or WebP image" };
|
|
65
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
66
|
+
const dimensions = getImageDimensions(base64, mime) ?? { widthPx: 800, heightPx: 600 };
|
|
67
|
+
return { path, mime, base64, dimensions };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const code = (error as { code?: string }).code;
|
|
70
|
+
return { path, error: code === "ENOENT" ? "no such file" : error instanceof Error ? error.message : String(error) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every option image the questions carry, read once before the questionnaire opens. */
|
|
75
|
+
export async function loadImages(paths: readonly string[]): Promise<Map<string, ImagePreview>> {
|
|
76
|
+
const unique = [...new Set(paths.filter(Boolean))];
|
|
77
|
+
const loaded = await Promise.all(unique.map((path) => loadImage(path)));
|
|
78
|
+
return new Map(unique.map((path, index) => [path, loaded[index]!]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Mode = "native" | "blocks" | "off";
|
|
82
|
+
|
|
83
|
+
function mode(): Mode {
|
|
84
|
+
const chosen = (process.env.BOT_LOBBY_IMAGES ?? "").trim().toLowerCase();
|
|
85
|
+
if (chosen === "off" || chosen === "blocks") return chosen;
|
|
86
|
+
return getCapabilities().images === "kitty" ? "native" : "blocks";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pixelsOf(image: LoadedImage): Pixels | string {
|
|
90
|
+
if (image.pixels === undefined) {
|
|
91
|
+
if (image.mime !== "image/png") image.pixels = "only PNG images can be drawn in this terminal";
|
|
92
|
+
else {
|
|
93
|
+
try {
|
|
94
|
+
image.pixels = decodePng(Buffer.from(image.base64, "base64"));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
image.pixels = error instanceof Error ? error.message : String(error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return image.pixels;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The cell grid an image fits in: at most `columns` × `rows`, its aspect kept (a cell is about twice as tall as wide). */
|
|
104
|
+
function fitCells(dimensions: ImageDimensions, columns: number, rows: number): { columns: number; rows: number } {
|
|
105
|
+
const aspect = dimensions.heightPx / Math.max(1, dimensions.widthPx);
|
|
106
|
+
let width = Math.max(1, columns);
|
|
107
|
+
let height = Math.max(1, Math.round((width * aspect) / 2));
|
|
108
|
+
if (height > rows) {
|
|
109
|
+
height = Math.max(1, rows);
|
|
110
|
+
width = Math.max(1, Math.min(columns, Math.round((height * 2) / aspect)));
|
|
111
|
+
}
|
|
112
|
+
return { columns: width, rows: height };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Average the pixels of one block of the image, over the box's dark background where they are see-through. */
|
|
116
|
+
function averageColor(pixels: Pixels, x0: number, y0: number, x1: number, y1: number): [number, number, number] {
|
|
117
|
+
let r = 0;
|
|
118
|
+
let g = 0;
|
|
119
|
+
let b = 0;
|
|
120
|
+
let count = 0;
|
|
121
|
+
const backdrop = 24;
|
|
122
|
+
for (let y = y0; y < Math.max(y1, y0 + 1); y += 1) {
|
|
123
|
+
for (let x = x0; x < Math.max(x1, x0 + 1); x += 1) {
|
|
124
|
+
const at = (Math.min(y, pixels.height - 1) * pixels.width + Math.min(x, pixels.width - 1)) * 4;
|
|
125
|
+
const alpha = pixels.data[at + 3]! / 255;
|
|
126
|
+
r += pixels.data[at]! * alpha + backdrop * (1 - alpha);
|
|
127
|
+
g += pixels.data[at + 1]! * alpha + backdrop * (1 - alpha);
|
|
128
|
+
b += pixels.data[at + 2]! * alpha + backdrop * (1 - alpha);
|
|
129
|
+
count += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Colour escape for a cell's foreground or background: 24-bit where the terminal has it, else the nearest of the 256. */
|
|
136
|
+
function colour(rgb: [number, number, number], background: boolean, trueColor: boolean): string {
|
|
137
|
+
const [r, g, b] = rgb;
|
|
138
|
+
if (trueColor) return `\x1b[${background ? 48 : 38};2;${r};${g};${b}m`;
|
|
139
|
+
const level = (value: number) => (value < 48 ? 0 : value < 115 ? 1 : Math.min(5, Math.floor((value - 35) / 40)));
|
|
140
|
+
return `\x1b[${background ? 48 : 38};5;${16 + 36 * level(r) + 6 * level(g) + level(b)}m`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** An image as rows of half-blocks: each cell's upper half is the foreground colour, its lower half the background. */
|
|
144
|
+
export function halfBlocks(pixels: Pixels, columns: number, rows: number, trueColor = true): string[] {
|
|
145
|
+
const cells = fitCells({ widthPx: pixels.width, heightPx: pixels.height }, columns, rows);
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
const stepX = pixels.width / cells.columns;
|
|
148
|
+
const stepY = pixels.height / (cells.rows * 2);
|
|
149
|
+
for (let row = 0; row < cells.rows; row += 1) {
|
|
150
|
+
let line = "";
|
|
151
|
+
for (let column = 0; column < cells.columns; column += 1) {
|
|
152
|
+
const x0 = Math.floor(column * stepX);
|
|
153
|
+
const x1 = Math.floor((column + 1) * stepX);
|
|
154
|
+
const top = averageColor(pixels, x0, Math.floor(row * 2 * stepY), x1, Math.floor((row * 2 + 1) * stepY));
|
|
155
|
+
const bottom = averageColor(pixels, x0, Math.floor((row * 2 + 1) * stepY), x1, Math.floor((row * 2 + 2) * stepY));
|
|
156
|
+
line += `${colour(top, false, trueColor)}${colour(bottom, true, trueColor)}▀`;
|
|
157
|
+
}
|
|
158
|
+
lines.push(`${line}\x1b[0m`);
|
|
159
|
+
}
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function describe(preview: ImagePreview): string {
|
|
164
|
+
if ("error" in preview) return `${basename(preview.path)}: ${preview.error}`;
|
|
165
|
+
return `${basename(preview.path)} · ${preview.dimensions.widthPx}×${preview.dimensions.heightPx} ${preview.mime.slice(6).toUpperCase()}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An option's image drawn in at most `columns` × `rows` cells, with a caption
|
|
170
|
+
* line under it naming the file; or, where it cannot be drawn, lines saying
|
|
171
|
+
* what and where it is.
|
|
172
|
+
*/
|
|
173
|
+
export function imageLines(preview: ImagePreview, columns: number, rows: number, theme?: LobbyTheme): string[] {
|
|
174
|
+
const caption = paint(theme, "dim", describe(preview));
|
|
175
|
+
if ("error" in preview) return [paint(theme, "warning", `Image not shown: ${describe(preview)}`)];
|
|
176
|
+
const room = Math.max(1, rows - 1);
|
|
177
|
+
const how = mode();
|
|
178
|
+
if (how === "native") {
|
|
179
|
+
const size = fitCells(preview.dimensions, columns, room);
|
|
180
|
+
const drawn = renderImage(preview.base64, preview.dimensions, { maxWidthCells: size.columns, maxHeightCells: size.rows, moveCursor: false, ...(preview.kittyId ? { imageId: preview.kittyId } : {}) });
|
|
181
|
+
if (drawn) {
|
|
182
|
+
if (drawn.imageId) preview.kittyId = drawn.imageId;
|
|
183
|
+
return [drawn.sequence, ...Array.from({ length: Math.max(0, drawn.rows - 1) }, () => ""), caption];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (how !== "off") {
|
|
187
|
+
const pixels = pixelsOf(preview);
|
|
188
|
+
if (typeof pixels !== "string") {
|
|
189
|
+
const trueColor = getCapabilities().trueColor !== false;
|
|
190
|
+
const key = `${columns}x${room}:${trueColor}`;
|
|
191
|
+
preview.blocks ??= new Map();
|
|
192
|
+
let drawn = preview.blocks.get(key);
|
|
193
|
+
if (!drawn) {
|
|
194
|
+
drawn = halfBlocks(pixels, columns, room, trueColor);
|
|
195
|
+
preview.blocks.set(key, drawn);
|
|
196
|
+
}
|
|
197
|
+
return [...drawn, caption];
|
|
198
|
+
}
|
|
199
|
+
return [paint(theme, "muted", `${describe(preview)}: ${pixels}; open ${preview.path} to see it`)];
|
|
200
|
+
}
|
|
201
|
+
return [paint(theme, "muted", `${describe(preview)}: open ${preview.path} to see it`)];
|
|
202
|
+
}
|
package/src/ask/png.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small PNG decoder: every colour type and bit depth, interlaced or not,
|
|
3
|
+
* into 8-bit RGBA. Enough to draw a screenshot as coloured blocks in any
|
|
4
|
+
* terminal; no dependency (zlib is Node's).
|
|
5
|
+
*/
|
|
6
|
+
import { inflateSync } from "node:zlib";
|
|
7
|
+
|
|
8
|
+
export interface Pixels {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
/** RGBA, 4 bytes per pixel, row by row. */
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
16
|
+
/** Larger images are not decoded (a screenshot is far smaller). */
|
|
17
|
+
const MAX_PIXELS = 50_000_000;
|
|
18
|
+
const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
19
|
+
/** Adam7: each pass's first column and row, and the steps between them. */
|
|
20
|
+
const ADAM7 = [
|
|
21
|
+
[0, 0, 8, 8],
|
|
22
|
+
[4, 0, 8, 8],
|
|
23
|
+
[0, 4, 4, 8],
|
|
24
|
+
[2, 0, 4, 4],
|
|
25
|
+
[0, 2, 2, 4],
|
|
26
|
+
[1, 0, 2, 2],
|
|
27
|
+
[0, 1, 1, 2],
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export function isPng(bytes: Uint8Array): boolean {
|
|
31
|
+
return bytes.length >= 8 && SIGNATURE.every((value, index) => bytes[index] === value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function paeth(a: number, b: number, c: number): number {
|
|
35
|
+
const p = a + b - c;
|
|
36
|
+
const pa = Math.abs(p - a);
|
|
37
|
+
const pb = Math.abs(p - b);
|
|
38
|
+
const pc = Math.abs(p - c);
|
|
39
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Undo the per-row filters of one (sub)image in place, returning its raw rows. */
|
|
43
|
+
function unfilter(source: Uint8Array, offset: number, rowBytes: number, rows: number, bpp: number): { rows: Uint8Array[]; next: number } {
|
|
44
|
+
const out: Uint8Array[] = [];
|
|
45
|
+
let previous = new Uint8Array(rowBytes);
|
|
46
|
+
let at = offset;
|
|
47
|
+
for (let row = 0; row < rows; row += 1) {
|
|
48
|
+
if (at + 1 + rowBytes > source.length) throw new Error("the PNG image data is cut short");
|
|
49
|
+
const filter = source[at]!;
|
|
50
|
+
const line = source.slice(at + 1, at + 1 + rowBytes);
|
|
51
|
+
at += 1 + rowBytes;
|
|
52
|
+
for (let index = 0; index < rowBytes; index += 1) {
|
|
53
|
+
const left = index >= bpp ? line[index - bpp]! : 0;
|
|
54
|
+
const up = previous[index]!;
|
|
55
|
+
const corner = index >= bpp ? previous[index - bpp]! : 0;
|
|
56
|
+
switch (filter) {
|
|
57
|
+
case 0:
|
|
58
|
+
break;
|
|
59
|
+
case 1:
|
|
60
|
+
line[index] = (line[index]! + left) & 255;
|
|
61
|
+
break;
|
|
62
|
+
case 2:
|
|
63
|
+
line[index] = (line[index]! + up) & 255;
|
|
64
|
+
break;
|
|
65
|
+
case 3:
|
|
66
|
+
line[index] = (line[index]! + ((left + up) >> 1)) & 255;
|
|
67
|
+
break;
|
|
68
|
+
case 4:
|
|
69
|
+
line[index] = (line[index]! + paeth(left, up, corner)) & 255;
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
throw new Error(`unknown PNG filter ${filter}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
out.push(line);
|
|
76
|
+
previous = line;
|
|
77
|
+
}
|
|
78
|
+
return { rows: out, next: at };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Decode a PNG into RGBA pixels; throws with a reason when it cannot. */
|
|
82
|
+
export function decodePng(bytes: Uint8Array): Pixels {
|
|
83
|
+
if (!isPng(bytes)) throw new Error("not a PNG file");
|
|
84
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
85
|
+
let width = 0;
|
|
86
|
+
let height = 0;
|
|
87
|
+
let depth = 8;
|
|
88
|
+
let type = 6;
|
|
89
|
+
let interlace = 0;
|
|
90
|
+
let palette: Uint8Array | undefined;
|
|
91
|
+
let alphas: Uint8Array | undefined;
|
|
92
|
+
let transparent: number[] | undefined;
|
|
93
|
+
const data: Uint8Array[] = [];
|
|
94
|
+
for (let at = 8; at + 8 <= bytes.length; ) {
|
|
95
|
+
const length = view.getUint32(at);
|
|
96
|
+
const name = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
|
|
97
|
+
const body = bytes.subarray(at + 8, at + 8 + length);
|
|
98
|
+
at += 12 + length;
|
|
99
|
+
if (name === "IHDR") {
|
|
100
|
+
const header = new DataView(body.buffer, body.byteOffset, body.byteLength);
|
|
101
|
+
width = header.getUint32(0);
|
|
102
|
+
height = header.getUint32(4);
|
|
103
|
+
depth = body[8]!;
|
|
104
|
+
type = body[9]!;
|
|
105
|
+
interlace = body[12]!;
|
|
106
|
+
} else if (name === "PLTE") palette = body;
|
|
107
|
+
else if (name === "tRNS") {
|
|
108
|
+
if (type === 3) alphas = body;
|
|
109
|
+
else if (type === 0 && body.length >= 2) transparent = [(body[0]! << 8) | body[1]!];
|
|
110
|
+
else if (type === 2 && body.length >= 6) transparent = [(body[0]! << 8) | body[1]!, (body[2]! << 8) | body[3]!, (body[4]! << 8) | body[5]!];
|
|
111
|
+
} else if (name === "IDAT") data.push(body);
|
|
112
|
+
else if (name === "IEND") break;
|
|
113
|
+
}
|
|
114
|
+
const channels = CHANNELS[type];
|
|
115
|
+
if (!width || !height || channels === undefined) throw new Error("the PNG header is missing or unsupported");
|
|
116
|
+
if (width * height > MAX_PIXELS) throw new Error(`the PNG is too large to draw (${width}×${height})`);
|
|
117
|
+
if (type === 3 && !palette) throw new Error("the PNG palette is missing");
|
|
118
|
+
const joined = new Uint8Array(data.reduce((total, chunk) => total + chunk.length, 0));
|
|
119
|
+
let offset = 0;
|
|
120
|
+
for (const chunk of data) {
|
|
121
|
+
joined.set(chunk, offset);
|
|
122
|
+
offset += chunk.length;
|
|
123
|
+
}
|
|
124
|
+
const raw = inflateSync(joined);
|
|
125
|
+
const bitsPerPixel = channels * depth;
|
|
126
|
+
const bpp = Math.max(1, bitsPerPixel >> 3);
|
|
127
|
+
const out = new Uint8Array(width * height * 4);
|
|
128
|
+
const max = (1 << depth) - 1;
|
|
129
|
+
|
|
130
|
+
const sample = (row: Uint8Array, index: number): number => {
|
|
131
|
+
if (depth === 8) return row[index]!;
|
|
132
|
+
if (depth === 16) return (row[index * 2]! << 8) | row[index * 2 + 1]!;
|
|
133
|
+
const bit = index * depth;
|
|
134
|
+
return (row[bit >> 3]! >> (8 - depth - (bit & 7))) & max;
|
|
135
|
+
};
|
|
136
|
+
const to8 = (value: number) => (depth === 16 ? value >> 8 : depth === 8 ? value : Math.round((value * 255) / max));
|
|
137
|
+
|
|
138
|
+
const put = (rowData: Uint8Array, column: number, x: number, y: number) => {
|
|
139
|
+
const target = (y * width + x) * 4;
|
|
140
|
+
const base = column * channels;
|
|
141
|
+
if (type === 3) {
|
|
142
|
+
const index = sample(rowData, column);
|
|
143
|
+
out[target] = palette![index * 3] ?? 0;
|
|
144
|
+
out[target + 1] = palette![index * 3 + 1] ?? 0;
|
|
145
|
+
out[target + 2] = palette![index * 3 + 2] ?? 0;
|
|
146
|
+
out[target + 3] = alphas?.[index] ?? 255;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const values = Array.from({ length: channels }, (_, channel) => sample(rowData, base + channel));
|
|
150
|
+
const gray = type === 0 || type === 4;
|
|
151
|
+
const [r, g, b] = gray ? [values[0]!, values[0]!, values[0]!] : [values[0]!, values[1]!, values[2]!];
|
|
152
|
+
const alpha = type === 4 ? to8(values[1]!) : type === 6 ? to8(values[3]!) : 255;
|
|
153
|
+
const clear = transparent && (gray ? values[0] === transparent[0] : values[0] === transparent[0] && values[1] === transparent[1] && values[2] === transparent[2]);
|
|
154
|
+
out[target] = to8(r);
|
|
155
|
+
out[target + 1] = to8(g);
|
|
156
|
+
out[target + 2] = to8(b);
|
|
157
|
+
out[target + 3] = clear ? 0 : alpha;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (interlace === 0) {
|
|
161
|
+
const { rows } = unfilter(raw, 0, Math.ceil((width * bitsPerPixel) / 8), height, bpp);
|
|
162
|
+
rows.forEach((row, y) => {
|
|
163
|
+
for (let x = 0; x < width; x += 1) put(row, x, x, y);
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
let at = 0;
|
|
167
|
+
for (const [x0, y0, dx, dy] of ADAM7) {
|
|
168
|
+
const passWidth = Math.ceil((width - x0) / dx);
|
|
169
|
+
const passHeight = Math.ceil((height - y0) / dy);
|
|
170
|
+
if (passWidth <= 0 || passHeight <= 0) continue;
|
|
171
|
+
const pass = unfilter(raw, at, Math.ceil((passWidth * bitsPerPixel) / 8), passHeight, bpp);
|
|
172
|
+
at = pass.next;
|
|
173
|
+
pass.rows.forEach((row, py) => {
|
|
174
|
+
for (let px = 0; px < passWidth; px += 1) put(row, px, x0 + px * dx, y0 + py * dy);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { width, height, data: out };
|
|
179
|
+
}
|