@a-t-h-i/bot-lobby 0.1.0
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/LICENSE +201 -0
- package/README.md +406 -0
- package/package.json +46 -0
- package/prompts/backend.md +28 -0
- package/prompts/designer.md +33 -0
- package/prompts/global.md +42 -0
- package/prompts/master.md +107 -0
- package/prompts/qa.md +26 -0
- package/prompts/researcher.md +32 -0
- package/prompts/reviewer.md +43 -0
- package/prompts/scout.md +36 -0
- package/prompts/worker.md +49 -0
- package/src/agents/backend.ts +10 -0
- package/src/agents/designer.ts +10 -0
- package/src/agents/qa.ts +10 -0
- package/src/agents/registry.ts +14 -0
- package/src/execution/agent-runner.ts +150 -0
- package/src/execution/git.ts +42 -0
- package/src/execution/pi-runner.ts +292 -0
- package/src/index.ts +13 -0
- package/src/knowledge/compactor.ts +135 -0
- package/src/knowledge/paths.ts +43 -0
- package/src/knowledge/selector.ts +82 -0
- package/src/knowledge/store.ts +111 -0
- package/src/master/decisions.ts +53 -0
- package/src/master/master.ts +298 -0
- package/src/master/research.ts +98 -0
- package/src/master/synthesis.ts +57 -0
- package/src/pi/activity.ts +60 -0
- package/src/pi/commands.ts +268 -0
- package/src/pi/events.ts +76 -0
- package/src/pi/expressions.ts +101 -0
- package/src/pi/mascot-art.ts +252 -0
- package/src/pi/notify.ts +42 -0
- package/src/pi/quiet.ts +46 -0
- package/src/pi/settings-ui.ts +258 -0
- package/src/pi/tool-renderers.ts +121 -0
- package/src/pi/tools.ts +158 -0
- package/src/pi/ui.ts +227 -0
- package/src/pi/zen-large.ts +483 -0
- package/src/pi/zen-metrics.ts +80 -0
- package/src/pi/zen.ts +460 -0
- package/src/prompts/compiler.ts +50 -0
- package/src/prompts/loader.ts +20 -0
- package/src/roles/markdown.ts +64 -0
- package/src/roles/registry.ts +16 -0
- package/src/roles/researcher.ts +83 -0
- package/src/roles/reviewer.ts +65 -0
- package/src/roles/scout.ts +61 -0
- package/src/roles/worker.ts +94 -0
- package/src/schemas/agent.ts +39 -0
- package/src/schemas/configuration.ts +107 -0
- package/src/schemas/findings.ts +110 -0
- package/src/schemas/task.ts +113 -0
- package/src/state/persistence.ts +232 -0
- package/src/state/project.ts +99 -0
- package/src/state/task-state.ts +22 -0
- package/src/text.ts +51 -0
- package/src/workflow/approvals.ts +45 -0
- package/src/workflow/transitions.ts +41 -0
- package/src/workflow/workflow.ts +771 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { TaskState } from "../schemas/task.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure ASCII art data for the scene-based zen panel. No logic lives here: the
|
|
5
|
+
* captions, status vocabulary, frame sets and tower templates are constants
|
|
6
|
+
* that `zen.ts` and `zen-large.ts` compose. Every glyph is exactly one visible
|
|
7
|
+
* column so the art stays narrow/ambiguous-width safe: the non-ASCII glyphs are
|
|
8
|
+
* the lobby `⌂` in `BANNER_NARROW`, the panel's ◐/✓/✗ status family, and the
|
|
9
|
+
* box-drawing, bar and eye glyphs of the large scene (─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ═ █ ▓ ░ ◉ ◍ ◎ ◌ ○).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One-column status glyphs, matching the glyph family the panel already uses
|
|
14
|
+
* for runs and steps (◐ running, ✓ success, ✗ failure); the slot roster below
|
|
15
|
+
* reuses them so a status never rests on colour alone.
|
|
16
|
+
*/
|
|
17
|
+
const STATUS_GLYPHS = { idle: "·", running: "◐", done: "✓", failed: "✗" } as const;
|
|
18
|
+
|
|
19
|
+
/** Braille spinner frames shared by the large scene and the compact working line. */
|
|
20
|
+
export const SPIN_FRAMES: readonly string[] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
21
|
+
|
|
22
|
+
export const BANNER_TITLE = "THE BOT LOBBY";
|
|
23
|
+
export const BANNER_NARROW = "⌂ THE BOT LOBBY";
|
|
24
|
+
|
|
25
|
+
/** One-line (<= 30 chars) caption per task state, labelling the compact strip. */
|
|
26
|
+
export const SCENE_PROPS: Record<TaskState, string> = {
|
|
27
|
+
created: "no work started yet",
|
|
28
|
+
clarifying: "clarifying the request",
|
|
29
|
+
scouting: "scouting the codebase",
|
|
30
|
+
synthesizing: "synthesizing findings",
|
|
31
|
+
awaiting_approval: "awaiting your approval",
|
|
32
|
+
planning: "writing the plan",
|
|
33
|
+
implementing: "agents at work",
|
|
34
|
+
reviewing: "reviewing the diff",
|
|
35
|
+
blocked: "blocked, needs a hand",
|
|
36
|
+
completed: "work complete",
|
|
37
|
+
abandoned: "task set aside",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/* -------------------------------------------------------------------------
|
|
42
|
+
* Slot roster and animated art for the zen scene (composed by zen-large.ts at
|
|
43
|
+
* >= LARGE_MIN_WIDTH and by zen.ts's compact strip below it).
|
|
44
|
+
* ---------------------------------------------------------------------- */
|
|
45
|
+
|
|
46
|
+
/** The four agent columns of the large scene, in the order the scene draws them. */
|
|
47
|
+
export type SlotId = "dev" | "design" | "research" | "qa";
|
|
48
|
+
|
|
49
|
+
export const SLOT_IDS: readonly SlotId[] = ["dev", "design", "research", "qa"];
|
|
50
|
+
|
|
51
|
+
/** Uppercase column captions; RESEARCH is the longest at 8 columns. */
|
|
52
|
+
export const SLOT_LABELS: Record<SlotId, string> = {
|
|
53
|
+
dev: "DEV",
|
|
54
|
+
design: "DESIGN",
|
|
55
|
+
research: "RESEARCH",
|
|
56
|
+
qa: "QA",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Status the roster reports; drives the face, the colour and the state word. */
|
|
60
|
+
export type SlotState = "working" | "idle" | "done" | "failed";
|
|
61
|
+
|
|
62
|
+
export const SLOT_STATES: readonly SlotState[] = ["working", "idle", "done", "failed"];
|
|
63
|
+
|
|
64
|
+
export const SLOT_STATE_WORDS: Record<SlotState, string> = {
|
|
65
|
+
working: "working",
|
|
66
|
+
idle: "idle",
|
|
67
|
+
done: "done",
|
|
68
|
+
failed: "failed",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** One-column status glyph sitting beside the state word, so status never rests on colour alone. */
|
|
72
|
+
export const SLOT_STATE_GLYPHS: Record<SlotState, string> = {
|
|
73
|
+
working: STATUS_GLYPHS.running,
|
|
74
|
+
idle: STATUS_GLYPHS.idle,
|
|
75
|
+
done: STATUS_GLYPHS.done,
|
|
76
|
+
failed: STATUS_GLYPHS.failed,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The slice of a panel theme's colour vocabulary this art maps onto. */
|
|
80
|
+
export type PanelColor = "accent" | "muted" | "dim" | "success" | "error" | "warning";
|
|
81
|
+
|
|
82
|
+
/** Status -> colour for the face, state word and bar fill. Working is bold as well. */
|
|
83
|
+
export const SLOT_STATE_COLORS: Record<SlotState, { color: PanelColor; bold: boolean }> = {
|
|
84
|
+
working: { color: "accent", bold: true },
|
|
85
|
+
idle: { color: "dim", bold: false },
|
|
86
|
+
done: { color: "success", bold: false },
|
|
87
|
+
failed: { color: "error", bold: false },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Ten-cell progress bar; the caller fills `cells` of it from a percentage. */
|
|
91
|
+
export const BAR = { filled: "█", empty: "░", cells: 10 } as const;
|
|
92
|
+
|
|
93
|
+
/** The master's pose: orchestrating while the task is live, dormant when paused or finished. */
|
|
94
|
+
export type OraclePose = "orchestrating" | "dormant";
|
|
95
|
+
|
|
96
|
+
export const ORACLE_POSES: readonly OraclePose[] = ["orchestrating", "dormant"];
|
|
97
|
+
|
|
98
|
+
export const ORACLE_WORDS: Record<OraclePose, string> = {
|
|
99
|
+
orchestrating: "orchestrating",
|
|
100
|
+
dormant: "dormant",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const ORACLE_COLORS: Record<OraclePose, { color: PanelColor; bold: boolean }> = {
|
|
104
|
+
orchestrating: { color: "accent", bold: true },
|
|
105
|
+
dormant: { color: "dim", bold: false },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** One oracle frame: the tower orb, both window eyes and the seven-column mouth. */
|
|
109
|
+
export interface OracleFrame {
|
|
110
|
+
orb: string;
|
|
111
|
+
winL: string;
|
|
112
|
+
winR: string;
|
|
113
|
+
/** Exactly seven one-column glyphs; never moves the tower geometry. */
|
|
114
|
+
mouth: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Indexed oracle expressions per pose: 0 rests, 1 blinks and 2-3 emote with a
|
|
119
|
+
* pulsing orb, sweeping window eyes and a moving mouth. Every glyph is one
|
|
120
|
+
* column and every mouth seven, so a frame swap never moves the tower geometry.
|
|
121
|
+
*/
|
|
122
|
+
export const ORACLE_FRAMES: Record<OraclePose, readonly OracleFrame[]> = {
|
|
123
|
+
orchestrating: [
|
|
124
|
+
{ orb: "◉", winL: "◉", winR: "◉", mouth: "═══════" },
|
|
125
|
+
{ orb: "◉", winL: "─", winR: "─", mouth: "═══════" },
|
|
126
|
+
{ orb: "◎", winL: "◍", winR: "◉", mouth: "◡◡◡◡◡◡◡" },
|
|
127
|
+
{ orb: "◍", winL: "◉", winR: "◍", mouth: "▁▂▃▂▃▂▁" },
|
|
128
|
+
],
|
|
129
|
+
dormant: [
|
|
130
|
+
{ orb: "◌", winL: "◌", winR: "◌", mouth: "═══════" },
|
|
131
|
+
{ orb: "◌", winL: "─", winR: "─", mouth: "═══════" },
|
|
132
|
+
{ orb: "○", winL: "◌", winR: "◌", mouth: "▂▂▂▂▂▂▂" },
|
|
133
|
+
{ orb: "○", winL: "─", winR: "─", mouth: "▁▁▁▁▁▁▁" },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One frame per status and expression: index 0 is the calm rest face, 1 the
|
|
139
|
+
* blink and 2-3 the emotes. Every status keeps the same frame count and one row
|
|
140
|
+
* each, so a caller-chosen index stays comparable across statuses.
|
|
141
|
+
*/
|
|
142
|
+
const FACE_FRAMES: Record<SlotState, readonly string[]> = {
|
|
143
|
+
working: ["(^_^)", "(-_-)", "(^o^)", "(^-^)"],
|
|
144
|
+
idle: ["(-_-)", "(u_u)", "(-.-)", "(u.u)"],
|
|
145
|
+
done: ["(o_o)", "(-_-)", "(^-^)", "(^_^)"],
|
|
146
|
+
failed: ["(>_<)", "(x_x)", "(T_T)", "(;_;)"],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Kaomoji emotes for the large scene: working nervous, done happy, failed scared. */
|
|
150
|
+
const EMOTE_FACES: Record<SlotState, readonly string[]> = {
|
|
151
|
+
working: ["(٥↼_↼)", "(●´⌓`●)"],
|
|
152
|
+
idle: ["(-.-)", "(u.u)"],
|
|
153
|
+
done: ["(✿^‿^)", "(っ˘з(˘⌣˘ )"],
|
|
154
|
+
failed: ["ಥ_ಥ", "(〒﹏〒)"],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** QA's success emotes are a flex and a victory dance rather than a smile. */
|
|
158
|
+
const QA_DONE_FACES: readonly string[] = ["ᕙ( • ‿ • )ᕗ", "ᕕ( ᐛ )ᕗ"];
|
|
159
|
+
|
|
160
|
+
/** Every compact face is five columns, so a centered column never shifts between frames. */
|
|
161
|
+
export const FACE_WIDTH = 5;
|
|
162
|
+
|
|
163
|
+
function buildSlotFrames(id: SlotId): Record<SlotState, readonly string[][]> {
|
|
164
|
+
const frames = {} as Record<SlotState, readonly string[][]>;
|
|
165
|
+
for (const state of SLOT_STATES) {
|
|
166
|
+
const emotes = id === "qa" && state === "done" ? QA_DONE_FACES : EMOTE_FACES[state];
|
|
167
|
+
frames[state] = [...FACE_FRAMES[state].slice(0, 2), ...emotes].map((face) => [face]);
|
|
168
|
+
}
|
|
169
|
+
return frames;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Animated faces per slot: one row each. Rest (0) and blink (1) stay the five-column
|
|
174
|
+
* ASCII eyes; the emote frames (2+) are status-aware kaomoji, wider than five columns,
|
|
175
|
+
* so the scene centres each frame inside its fixed slot cell.
|
|
176
|
+
*/
|
|
177
|
+
export const SLOT_FRAMES: Record<SlotId, Record<SlotState, readonly string[][]>> = {
|
|
178
|
+
dev: buildSlotFrames("dev"),
|
|
179
|
+
design: buildSlotFrames("design"),
|
|
180
|
+
research: buildSlotFrames("research"),
|
|
181
|
+
qa: buildSlotFrames("qa"),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Three-column accessory that marks each slot in the one-row compact strip. */
|
|
185
|
+
const SLOT_MARKS: Record<SlotId, string> = {
|
|
186
|
+
dev: "[=]",
|
|
187
|
+
design: "~,~",
|
|
188
|
+
research: "<?>",
|
|
189
|
+
qa: "<o>",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Eight columns: the slot's accessory followed by one face frame. */
|
|
193
|
+
export const COMPACT_WIDTH = 8;
|
|
194
|
+
|
|
195
|
+
function buildCompactFrames(id: SlotId): Record<SlotState, readonly string[]> {
|
|
196
|
+
const mark = SLOT_MARKS[id];
|
|
197
|
+
const frames = {} as Record<SlotState, readonly string[]>;
|
|
198
|
+
for (const state of SLOT_STATES) frames[state] = FACE_FRAMES[state].map((face) => `${mark}${face}`);
|
|
199
|
+
return frames;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Animated one-row sprites per slot and status for the compact strip. */
|
|
203
|
+
export const COMPACT_FRAMES: Record<SlotId, Record<SlotState, readonly string[]>> = {
|
|
204
|
+
dev: buildCompactFrames("dev"),
|
|
205
|
+
design: buildCompactFrames("design"),
|
|
206
|
+
research: buildCompactFrames("research"),
|
|
207
|
+
qa: buildCompactFrames("qa"),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The oracle tower: 13 columns wide, `rows` while height allows and `smallRows`
|
|
212
|
+
* when it must shrink. `{orb}` is the pulsing bead, `{winL}`/`{winR}` the two
|
|
213
|
+
* window eyes and `{door}` the name over the door.
|
|
214
|
+
*/
|
|
215
|
+
export interface TowerSpec {
|
|
216
|
+
rows: readonly string[];
|
|
217
|
+
smallRows: readonly string[];
|
|
218
|
+
tokens: { orb: string; winL: string; winR: string; door: string; mouth: string };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const TOWER_WIDTH = 13;
|
|
222
|
+
|
|
223
|
+
export const TOWER_DOOR = "ORC";
|
|
224
|
+
|
|
225
|
+
export const TOWER: TowerSpec = {
|
|
226
|
+
rows: [
|
|
227
|
+
" \\ | / ",
|
|
228
|
+
" \\|/ ",
|
|
229
|
+
" ─{orb}─ ",
|
|
230
|
+
" │ ",
|
|
231
|
+
"┌─────┴─────┐",
|
|
232
|
+
"│▓▓▓▓▓▓▓▓▓▓▓│",
|
|
233
|
+
"├───────────┤",
|
|
234
|
+
"│ ┌─┐ ┌─┐ │",
|
|
235
|
+
"│ │{winL}│ │{winR}│ │",
|
|
236
|
+
"│ └─┘ └─┘ │",
|
|
237
|
+
"├───────────┤",
|
|
238
|
+
"│ {mouth} │",
|
|
239
|
+
"├───┬───┬───┤",
|
|
240
|
+
"│▓▓▓│{door}│▓▓▓│",
|
|
241
|
+
"└───┴───┴───┘",
|
|
242
|
+
],
|
|
243
|
+
smallRows: [
|
|
244
|
+
" ─{orb}─ ",
|
|
245
|
+
"┌─────┴─────┐",
|
|
246
|
+
"│ ┌─┐ ┌─┐ │",
|
|
247
|
+
"│ │{winL}│ │{winR}│ │",
|
|
248
|
+
"│▓▓▓│{door}│▓▓▓│",
|
|
249
|
+
"└───┴───┴───┘",
|
|
250
|
+
],
|
|
251
|
+
tokens: { orb: "{orb}", winL: "{winL}", winR: "{winR}", door: "{door}", mouth: "{mouth}" },
|
|
252
|
+
};
|
package/src/pi/notify.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TaskState } from "../schemas/task.ts";
|
|
2
|
+
import { isSubagentProcess } from "./quiet.ts";
|
|
3
|
+
|
|
4
|
+
/** States worth an attention ping; every other transition stays silent. */
|
|
5
|
+
const PING_LABELS: Partial<Record<TaskState, string>> = {
|
|
6
|
+
completed: "done",
|
|
7
|
+
blocked: "blocked",
|
|
8
|
+
awaiting_approval: "needs approval",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Strip control characters so a title can never break out of the OSC payload. */
|
|
12
|
+
function sanitize(text: string): string {
|
|
13
|
+
return text.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function notice(text: string): string {
|
|
17
|
+
return `\x07\x1b]9;${sanitize(text)}\x07`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Terminal payload for one attention ping, or undefined when the state is not ping-worthy. Pure. */
|
|
21
|
+
export function formatNotice(state: TaskState, title: string): string | undefined {
|
|
22
|
+
const label = PING_LABELS[state];
|
|
23
|
+
return label ? notice(`${title || "task"} ${label}`) : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Terminal payload for a worker-requested dependency or architecture approval. Pure. */
|
|
27
|
+
export function formatApprovalNotice(domain: string, detail: string): string {
|
|
28
|
+
return notice(`${domain} approval needed: ${detail}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Emit a ping outside the TUI frame; never from a subagent process. */
|
|
32
|
+
export function ping(state: TaskState, title: string): void {
|
|
33
|
+
if (isSubagentProcess()) return;
|
|
34
|
+
const payload = formatNotice(state, title);
|
|
35
|
+
if (payload) process.stderr.write(payload);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Emit one ping when an approval is first recorded; never from a subagent. */
|
|
39
|
+
export function pingApproval(domain: string, detail: string): void {
|
|
40
|
+
if (isSubagentProcess()) return;
|
|
41
|
+
process.stderr.write(formatApprovalNotice(domain, detail));
|
|
42
|
+
}
|
package/src/pi/quiet.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet mode for the master transcript: built-in tool rows render zero lines
|
|
3
|
+
* by default and `alt+t` reveals a compact one-line summary per call. The web
|
|
4
|
+
* tools are removed from the master's active set so the researcher subagent,
|
|
5
|
+
* running in its own process, stays the only web path.
|
|
6
|
+
*
|
|
7
|
+
* bot-lobby's extension also loads inside the subagent processes it spawns
|
|
8
|
+
* (see `spawnPiProcess`). Those children receive their tools from an explicit
|
|
9
|
+
* `--tools` allowlist, so the master-only filtering and renderer overrides must
|
|
10
|
+
* never run there or research silently degrades to repository-only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Web tools owned by pi-web-access; the researcher gets them, the master does not. */
|
|
14
|
+
export const WEB_TOOL_NAMES: readonly string[] = [
|
|
15
|
+
"web_search",
|
|
16
|
+
"fetch_content",
|
|
17
|
+
"source_check",
|
|
18
|
+
"get_search_content",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
let quiet = true;
|
|
22
|
+
|
|
23
|
+
/** True while built-in tool rows consume no transcript lines. */
|
|
24
|
+
export function isQuiet(): boolean {
|
|
25
|
+
return quiet;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function setQuiet(value: boolean): void {
|
|
29
|
+
quiet = value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Flip quiet mode and return the new value. */
|
|
33
|
+
export function toggleQuiet(): boolean {
|
|
34
|
+
quiet = !quiet;
|
|
35
|
+
return quiet;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Drop the web tools from an active set, preserving every other name. */
|
|
39
|
+
export function visibleTools(active: string[]): string[] {
|
|
40
|
+
return active.filter((name) => !WEB_TOOL_NAMES.includes(name));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True inside a bot-lobby subagent process (marked in `spawnPiProcess`). */
|
|
44
|
+
export function isSubagentProcess(): boolean {
|
|
45
|
+
return process.env.BOT_LOBBY_SUBAGENT === "1";
|
|
46
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamicBorder,
|
|
3
|
+
getSelectListTheme,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
type Theme,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Container, type Component, type Focusable, fuzzyFilter, getKeybindings, Input, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { AGENT_KINDS, type AgentKind } from "../schemas/agent.ts";
|
|
10
|
+
import {
|
|
11
|
+
INHERIT_MODEL,
|
|
12
|
+
INHERIT_THINKING,
|
|
13
|
+
isThinkingLevel,
|
|
14
|
+
THINKING_LEVELS,
|
|
15
|
+
type AgentModelConfig,
|
|
16
|
+
type BotLobbyConfig,
|
|
17
|
+
} from "../schemas/configuration.ts";
|
|
18
|
+
import { globalConfigPath, loadConfig, saveConfig } from "../state/project.ts";
|
|
19
|
+
|
|
20
|
+
const CUSTOM_MODEL = "__custom__";
|
|
21
|
+
const MAX_VISIBLE = 12;
|
|
22
|
+
|
|
23
|
+
function agentLabel(agent: AgentKind): string {
|
|
24
|
+
return agent === "master" ? "Master" : `${agent[0]!.toUpperCase()}${agent.slice(1)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function agentConfig(config: BotLobbyConfig, agent: AgentKind): AgentModelConfig {
|
|
28
|
+
return agent === "master" ? config.master : config.agents[agent];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function updateAgent(agent: AgentKind, patch: Partial<AgentModelConfig>): void {
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
if (agent === "master") config.master = { ...config.master, ...patch };
|
|
34
|
+
else config.agents[agent] = { ...config.agents[agent], ...patch };
|
|
35
|
+
saveConfig(config);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Find a `provider/id` (or bare id) among the models this session can use. */
|
|
39
|
+
function findModel(ctx: ExtensionContext, ref: string) {
|
|
40
|
+
const slash = ref.indexOf("/");
|
|
41
|
+
if (slash > 0) return ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
42
|
+
return ctx.modelRegistry.getAvailable().find((model) => model.id === ref);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Apply the master model/thinking to the live session; "inherit" leaves it alone. */
|
|
46
|
+
export async function applyMasterModel(pi: ExtensionAPI, ctx: ExtensionContext, config: BotLobbyConfig): Promise<void> {
|
|
47
|
+
const { model, thinking } = config.master;
|
|
48
|
+
if (model !== INHERIT_MODEL) {
|
|
49
|
+
const found = findModel(ctx, model);
|
|
50
|
+
if (!found) ctx.ui.notify(`bot-lobby: unknown master model "${model}".`, "warning");
|
|
51
|
+
else if (!(await pi.setModel(found))) ctx.ui.notify(`bot-lobby: no auth for ${model}.`, "warning");
|
|
52
|
+
}
|
|
53
|
+
if (isThinkingLevel(thinking)) pi.setThinkingLevel(thinking);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hintText(search: boolean): string {
|
|
57
|
+
const nav = "↑↓ navigate • enter select • esc back";
|
|
58
|
+
return search ? `type to search • ${nav}` : nav;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function frame(theme: Theme, title: string, body: Component, search = false): Component {
|
|
62
|
+
const container = new Container();
|
|
63
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
64
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
65
|
+
container.addChild(body);
|
|
66
|
+
container.addChild(new Text(theme.fg("dim", hintText(search)), 1, 0));
|
|
67
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
68
|
+
return container;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fuzzy-filter picker items over label, value and description; blank queries pass through. */
|
|
72
|
+
export function filterItems(items: SelectItem[], query: string): SelectItem[] {
|
|
73
|
+
if (query.trim() === "") return items;
|
|
74
|
+
return fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function listFor(items: SelectItem[], done: (value: string | undefined) => void): SelectList {
|
|
78
|
+
const list = new SelectList(items, Math.min(items.length, MAX_VISIBLE), getSelectListTheme());
|
|
79
|
+
list.onSelect = (item) => done(item.value);
|
|
80
|
+
list.onCancel = () => done(undefined);
|
|
81
|
+
return list;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isListKey(data: string): boolean {
|
|
85
|
+
const kb = getKeybindings();
|
|
86
|
+
return kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")
|
|
87
|
+
|| kb.matches(data, "tui.select.confirm") || kb.matches(data, "tui.select.cancel");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Searchable picker: typing rebuilds the list, while arrows/enter/esc still drive it. */
|
|
91
|
+
export class SearchPicker implements Component, Focusable {
|
|
92
|
+
private readonly body = new Container();
|
|
93
|
+
private readonly input = new Input({ placeholder: "search" });
|
|
94
|
+
private readonly frame: Component;
|
|
95
|
+
private readonly items: SelectItem[];
|
|
96
|
+
private readonly done: (value: string | undefined) => void;
|
|
97
|
+
private readonly requestRender: () => void;
|
|
98
|
+
private list: SelectList;
|
|
99
|
+
private query = "";
|
|
100
|
+
|
|
101
|
+
constructor(title: string, items: SelectItem[], theme: Theme, done: (value: string | undefined) => void, requestRender: () => void) {
|
|
102
|
+
this.items = items;
|
|
103
|
+
this.done = done;
|
|
104
|
+
this.requestRender = requestRender;
|
|
105
|
+
this.list = listFor(items, done);
|
|
106
|
+
this.body.addChild(this.input);
|
|
107
|
+
this.body.addChild(this.list);
|
|
108
|
+
this.frame = frame(theme, title, this.body, true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get focused(): boolean {
|
|
112
|
+
return this.input.focused;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
set focused(value: boolean) {
|
|
116
|
+
this.input.focused = value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private refine(): void {
|
|
120
|
+
const query = this.input.getValue();
|
|
121
|
+
if (query === this.query) return;
|
|
122
|
+
this.query = query;
|
|
123
|
+
const next = filterItems(this.items, query);
|
|
124
|
+
this.body.removeChild(this.list);
|
|
125
|
+
this.list = listFor(next, this.done);
|
|
126
|
+
this.body.addChild(this.list);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
handleInput(data: string): void {
|
|
130
|
+
if (isListKey(data)) this.list.handleInput(data);
|
|
131
|
+
else {
|
|
132
|
+
this.input.handleInput(data);
|
|
133
|
+
this.refine();
|
|
134
|
+
}
|
|
135
|
+
this.requestRender();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
render(width: number): string[] {
|
|
139
|
+
return this.frame.render(width);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
invalidate(): void {
|
|
143
|
+
this.frame.invalidate();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function pickPlain(ctx: ExtensionContext, title: string, items: SelectItem[]): Promise<string | undefined> {
|
|
148
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
149
|
+
const list = listFor(items, done);
|
|
150
|
+
const container = frame(theme, title, list);
|
|
151
|
+
return {
|
|
152
|
+
render: (width: number) => container.render(width),
|
|
153
|
+
invalidate: () => container.invalidate(),
|
|
154
|
+
handleInput: (data: string) => {
|
|
155
|
+
list.handleInput(data);
|
|
156
|
+
tui.requestRender();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function pickSearch(ctx: ExtensionContext, title: string, items: SelectItem[]): Promise<string | undefined> {
|
|
163
|
+
return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
164
|
+
return new SearchPicker(title, items, theme, done, () => tui.requestRender());
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface PickOptions {
|
|
169
|
+
search?: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function pick(ctx: ExtensionContext, title: string, items: SelectItem[], options: PickOptions = {}): Promise<string | undefined> {
|
|
173
|
+
return options.search ? pickSearch(ctx, title, items) : pickPlain(ctx, title, items);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function modelItems(ctx: ExtensionContext, current: string): SelectItem[] {
|
|
177
|
+
const inherit = current === INHERIT_MODEL ? `${INHERIT_MODEL} ✓` : INHERIT_MODEL;
|
|
178
|
+
const items: SelectItem[] = [{ value: INHERIT_MODEL, label: inherit, description: "Use the session's current model" }];
|
|
179
|
+
const usable = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
|
|
180
|
+
for (const model of usable) {
|
|
181
|
+
const value = `${model.provider}/${model.id}`;
|
|
182
|
+
const label = value === current ? `${value} ✓` : value;
|
|
183
|
+
const description = model.name && model.name !== value ? model.name : undefined;
|
|
184
|
+
items.push(description ? { value, label, description } : { value, label });
|
|
185
|
+
}
|
|
186
|
+
items.push({ value: CUSTOM_MODEL, label: "custom…", description: "Type a provider/model id" });
|
|
187
|
+
return items;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function commit(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind, patch: Partial<AgentModelConfig>, detail: string): Promise<void> {
|
|
191
|
+
updateAgent(agent, patch);
|
|
192
|
+
if (agent === "master") await applyMasterModel(pi, ctx, loadConfig());
|
|
193
|
+
ctx.ui.notify(`bot-lobby: ${agent} ${detail} — saved to ${globalConfigPath()}`, "info");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function editModel(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
|
|
197
|
+
const current = agentConfig(loadConfig(), agent).model;
|
|
198
|
+
const choice = await pick(ctx, `Model — ${agentLabel(agent)}`, modelItems(ctx, current), { search: true });
|
|
199
|
+
if (choice === undefined) return;
|
|
200
|
+
const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
|
|
201
|
+
if (!typed) return;
|
|
202
|
+
await commit(pi, ctx, agent, { model: typed }, `model → ${typed}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
|
|
206
|
+
const current = agentConfig(loadConfig(), agent).thinking;
|
|
207
|
+
const items: SelectItem[] = [
|
|
208
|
+
{ value: INHERIT_THINKING, label: `${INHERIT_THINKING} (live session level)`, description: "Use the thinking level of the current session" },
|
|
209
|
+
...THINKING_LEVELS.map((level) => ({ value: level, label: level })),
|
|
210
|
+
].map((item) => (item.value === current ? { ...item, label: `${item.label} ✓` } : item));
|
|
211
|
+
const level = await pick(ctx, `Thinking — ${agentLabel(agent)}`, items);
|
|
212
|
+
if (!level) return;
|
|
213
|
+
await commit(pi, ctx, agent, { thinking: level }, `thinking → ${level}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function editInstructions(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
|
|
217
|
+
const current = agentConfig(loadConfig(), agent).instructions ?? "";
|
|
218
|
+
const text = await ctx.ui.editor(`Instructions — ${agentLabel(agent)}`, current);
|
|
219
|
+
if (text === undefined) return;
|
|
220
|
+
await commit(pi, ctx, agent, { instructions: text.trim() }, "instructions saved");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function editAgent(pi: ExtensionAPI, ctx: ExtensionContext, agent: AgentKind): Promise<void> {
|
|
224
|
+
for (;;) {
|
|
225
|
+
const cfg = agentConfig(loadConfig(), agent);
|
|
226
|
+
const items: SelectItem[] = [
|
|
227
|
+
{ value: "model", label: "Model", description: cfg.model },
|
|
228
|
+
{ value: "thinking", label: "Thinking", description: cfg.thinking },
|
|
229
|
+
{ value: "instructions", label: "Instructions", description: cfg.instructions ? `${cfg.instructions.length} chars` : "(none)" },
|
|
230
|
+
{ value: "back", label: "Back" },
|
|
231
|
+
];
|
|
232
|
+
const action = await pick(ctx, `${agentLabel(agent)} settings`, items);
|
|
233
|
+
if (!action || action === "back") return;
|
|
234
|
+
if (action === "model") await editModel(pi, ctx, agent);
|
|
235
|
+
else if (action === "thinking") await editThinking(pi, ctx, agent);
|
|
236
|
+
else await editInstructions(pi, ctx, agent);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Open the per-agent settings editor; every change is written to the global config. */
|
|
241
|
+
export async function openSettings(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
242
|
+
if (ctx.mode !== "tui") {
|
|
243
|
+
ctx.ui.notify(`bot-lobby settings live in ${globalConfigPath()}; edit that file outside the TUI.`, "info");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (;;) {
|
|
247
|
+
const config = loadConfig();
|
|
248
|
+
const items: SelectItem[] = AGENT_KINDS.map((agent) => {
|
|
249
|
+
const cfg = agentConfig(config, agent);
|
|
250
|
+
const custom = cfg.instructions ? " · custom" : "";
|
|
251
|
+
return { value: agent, label: agentLabel(agent), description: `${cfg.model} · ${cfg.thinking}${custom}` };
|
|
252
|
+
});
|
|
253
|
+
items.push({ value: "close", label: "Close" });
|
|
254
|
+
const choice = await pick(ctx, "bot-lobby settings", items);
|
|
255
|
+
if (!choice || choice === "close") return;
|
|
256
|
+
await editAgent(pi, ctx, choice as AgentKind);
|
|
257
|
+
}
|
|
258
|
+
}
|