@a-t-h-i/bot-lobby 0.4.0 → 0.5.1
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 +238 -10
- package/package.json +5 -2
- package/prompts/master.md +12 -0
- package/prompts/panel.md +44 -0
- package/prompts/planner.md +69 -0
- package/prompts/quickfix.md +41 -0
- package/src/execution/agent-runner.ts +7 -2
- package/src/execution/pi-runner.ts +25 -0
- package/src/index.ts +3 -0
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +253 -0
- package/src/lobby/issues.ts +227 -0
- package/src/lobby/keys.ts +51 -0
- package/src/lobby/layout.ts +363 -0
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +581 -0
- package/src/lobby/quickfix.ts +227 -0
- package/src/lobby/runtime.ts +591 -0
- package/src/lobby/tabs/home.ts +242 -0
- package/src/lobby/tabs/issues.ts +72 -0
- package/src/lobby/tabs/metrics.ts +275 -0
- package/src/lobby/tabs/plan.ts +242 -0
- package/src/lobby/tabs/quickfix.ts +129 -0
- package/src/lobby/tabs/tasks.ts +243 -0
- package/src/lobby/view.ts +1610 -0
- package/src/pi/activity.ts +120 -0
- package/src/pi/commands.ts +19 -57
- package/src/pi/events.ts +5 -2
- package/src/pi/model-support.ts +34 -0
- package/src/pi/run-summary.ts +2 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +63 -0
- package/src/pi/tools.ts +2 -2
- package/src/pi/ui.ts +116 -55
- package/src/schemas/configuration.ts +102 -3
- package/src/schemas/findings.ts +6 -0
- package/src/schemas/task.ts +1 -0
- package/src/state/backlog.ts +106 -0
- package/src/state/comments.ts +136 -0
- package/src/state/metrics.ts +305 -0
- package/src/workflow/workflow.ts +39 -7
package/src/lobby/ask.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oracle puts the planning panel's questions to the user one at a time
|
|
3
|
+
* through the ask-user-question library (`@juicesharp/rpiv-ask-user-question`):
|
|
4
|
+
* a tabbed questionnaire with each seat's options, the recommended one first,
|
|
5
|
+
* and a free-text row on every question. The library only publishes a pi
|
|
6
|
+
* extension entry point, so its `ask_user_question` tool is captured by
|
|
7
|
+
* calling that entry with a `pi` whose `registerTool` keeps the definition
|
|
8
|
+
* instead of registering it; its `execute` then runs the real questionnaire.
|
|
9
|
+
* Without the library, pi's own select and input dialogs ask the same
|
|
10
|
+
* questions in the same order.
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { PanelQuestion } from "./planner.ts";
|
|
14
|
+
|
|
15
|
+
/** The library's limits: at most 4 questions per questionnaire, 2-4 options each. */
|
|
16
|
+
export const MAX_QUESTIONS = 4;
|
|
17
|
+
export const MIN_OPTIONS = 2;
|
|
18
|
+
export const MAX_OPTIONS = 4;
|
|
19
|
+
const MAX_HEADER = 16;
|
|
20
|
+
const MAX_LABEL = 60;
|
|
21
|
+
/** Labels the library reserves for its own rows. */
|
|
22
|
+
const RESERVED = new Set(["other", "type something.", "next"]);
|
|
23
|
+
|
|
24
|
+
export interface AskOption {
|
|
25
|
+
label: string;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AskQuestion {
|
|
30
|
+
question: string;
|
|
31
|
+
header: string;
|
|
32
|
+
options: AskOption[];
|
|
33
|
+
multiSelect?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AskAnswer {
|
|
37
|
+
questionIndex: number;
|
|
38
|
+
question: string;
|
|
39
|
+
kind: "option" | "custom" | "multi";
|
|
40
|
+
answer: string | null;
|
|
41
|
+
selected?: string[];
|
|
42
|
+
notes?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AskResult {
|
|
46
|
+
answers: AskAnswer[];
|
|
47
|
+
cancelled: boolean;
|
|
48
|
+
globalNote?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Puts up to `MAX_QUESTIONS` questions to the user and returns what they chose. */
|
|
52
|
+
export type Asker = (questions: readonly AskQuestion[], ctx: ExtensionContext) => Promise<AskResult>;
|
|
53
|
+
|
|
54
|
+
interface ToolLike {
|
|
55
|
+
execute(toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext): Promise<{ details?: unknown }>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clip(text: string, max: number): string {
|
|
59
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
60
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Filler options for a question the panel asked without any. */
|
|
64
|
+
const DEFAULT_OPTIONS: readonly AskOption[] = [
|
|
65
|
+
{ label: "Go with the recommendation", description: "Let the panel pick the option it recommends." },
|
|
66
|
+
{ label: "Leave it open", description: "Skip this one for now; the panel may ask again." },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** A panel question in the library's schema: seat as the chip, 2-4 unique, unreserved option labels. */
|
|
70
|
+
export function toAskQuestion(question: PanelQuestion): AskQuestion {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
const options: AskOption[] = [];
|
|
73
|
+
for (const option of question.options) {
|
|
74
|
+
let label = clip(option.label, MAX_LABEL);
|
|
75
|
+
if (!label || RESERVED.has(label.toLowerCase())) continue;
|
|
76
|
+
for (let n = 2; seen.has(label.toLowerCase()); n++) label = clip(`${option.label} (${n})`, MAX_LABEL);
|
|
77
|
+
seen.add(label.toLowerCase());
|
|
78
|
+
options.push({ label, description: option.description.trim() || label });
|
|
79
|
+
if (options.length === MAX_OPTIONS) break;
|
|
80
|
+
}
|
|
81
|
+
for (const filler of DEFAULT_OPTIONS) {
|
|
82
|
+
if (options.length >= MIN_OPTIONS) break;
|
|
83
|
+
if (!seen.has(filler.label.toLowerCase())) options.push({ ...filler });
|
|
84
|
+
}
|
|
85
|
+
return { question: question.text.trim(), header: clip(question.from, MAX_HEADER), options };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Split the round's questions into questionnaires of at most `MAX_QUESTIONS`, with unique question texts. */
|
|
89
|
+
export function questionnaires(questions: readonly PanelQuestion[]): AskQuestion[][] {
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
const asked = questions.map((question) => {
|
|
92
|
+
const ask = toAskQuestion(question);
|
|
93
|
+
let text = ask.question;
|
|
94
|
+
for (let n = 2; seen.has(text.toLowerCase()); n++) text = `${ask.question} (${question.from}${n > 2 ? ` ${n}` : ""})`;
|
|
95
|
+
seen.add(text.toLowerCase());
|
|
96
|
+
return { ...ask, question: text };
|
|
97
|
+
});
|
|
98
|
+
const chunks: AskQuestion[][] = [];
|
|
99
|
+
for (let i = 0; i < asked.length; i += MAX_QUESTIONS) chunks.push(asked.slice(i, i + MAX_QUESTIONS));
|
|
100
|
+
return chunks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function answerText(answer: AskAnswer | undefined): string | undefined {
|
|
104
|
+
if (!answer) return undefined;
|
|
105
|
+
if (answer.kind === "multi") return answer.selected && answer.selected.length > 0 ? answer.selected.join(", ") : undefined;
|
|
106
|
+
return answer.answer?.trim() || undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The user's turn for the next round: every question with who asked it and
|
|
111
|
+
* the answer (`→ …`), their notes, and what they left unanswered. Undefined
|
|
112
|
+
* when nothing was answered.
|
|
113
|
+
*/
|
|
114
|
+
export function answerMessage(questions: readonly PanelQuestion[], results: readonly AskResult[]): string | undefined {
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
const skipped: string[] = [];
|
|
117
|
+
const notes: string[] = [];
|
|
118
|
+
let answered = 0;
|
|
119
|
+
results.forEach((result, chunk) => {
|
|
120
|
+
if (result.globalNote?.trim()) notes.push(result.globalNote.trim());
|
|
121
|
+
const offset = chunk * MAX_QUESTIONS;
|
|
122
|
+
for (let i = 0; i < MAX_QUESTIONS; i++) {
|
|
123
|
+
const question = questions[offset + i];
|
|
124
|
+
if (!question) break;
|
|
125
|
+
const answer = result.answers.find((entry) => entry.questionIndex === i);
|
|
126
|
+
const text = answerText(answer);
|
|
127
|
+
const label = `${offset + i + 1}. [${question.from}] ${question.text.replace(/\s+/g, " ").trim()}`;
|
|
128
|
+
if (!text) {
|
|
129
|
+
skipped.push(label);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
answered += 1;
|
|
133
|
+
lines.push(`${label}\n → ${text}${answer?.kind === "custom" ? " (in my words)" : ""}`);
|
|
134
|
+
if (answer?.notes?.trim()) lines.push(` note: ${answer.notes.trim()}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
if (answered === 0 && notes.length === 0) return undefined;
|
|
138
|
+
return [
|
|
139
|
+
"Answers to the panel's questions:",
|
|
140
|
+
...lines,
|
|
141
|
+
...(notes.length > 0 ? ["", `Note: ${notes.join(" ")}`] : []),
|
|
142
|
+
...(skipped.length > 0 ? ["", "Not answered this round:", ...skipped] : []),
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Capture the library's tool through its public extension entry: the entry
|
|
148
|
+
* registers the tool on the `pi` it is given, so a `pi` whose `registerTool`
|
|
149
|
+
* keeps the definition (and whose `on` ignores the library's own tool-list
|
|
150
|
+
* reconciler) yields it without touching the session's tools. Undefined when
|
|
151
|
+
* the library is missing or registers nothing.
|
|
152
|
+
*/
|
|
153
|
+
/**
|
|
154
|
+
* The library ships TypeScript sources; a non-literal specifier keeps them out
|
|
155
|
+
* of this package's typecheck (they are compiled by pi's loader at runtime).
|
|
156
|
+
*/
|
|
157
|
+
const ASK_LIBRARY: string = "@juicesharp/rpiv-ask-user-question";
|
|
158
|
+
|
|
159
|
+
export async function loadAskTool(pi: ExtensionAPI, load: () => Promise<unknown> = () => import(ASK_LIBRARY)): Promise<ToolLike | undefined> {
|
|
160
|
+
try {
|
|
161
|
+
const module = (await load()) as { default?: (pi: ExtensionAPI) => void };
|
|
162
|
+
if (typeof module.default !== "function") return undefined;
|
|
163
|
+
let captured: ToolLike | undefined;
|
|
164
|
+
const capture = new Proxy(pi, {
|
|
165
|
+
get(target, property, receiver) {
|
|
166
|
+
if (property === "registerTool") return (tool: ToolLike & { name?: string }) => {
|
|
167
|
+
if (tool.name === "ask_user_question") captured = tool;
|
|
168
|
+
};
|
|
169
|
+
if (property === "on") return () => () => {};
|
|
170
|
+
return Reflect.get(target, property, receiver);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
module.default(capture);
|
|
174
|
+
return captured;
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Run one questionnaire through the captured tool. */
|
|
181
|
+
export function toolAsker(tool: ToolLike): Asker {
|
|
182
|
+
return async (questions, ctx) => {
|
|
183
|
+
const result = await tool.execute(`bot-lobby-panel-${Date.now().toString(36)}`, { questions }, undefined, undefined, ctx);
|
|
184
|
+
const details = result.details as Partial<AskResult> | undefined;
|
|
185
|
+
return { answers: details?.answers ?? [], cancelled: details?.cancelled !== false, ...(details?.globalNote ? { globalNote: details.globalNote } : {}) };
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const TYPE_ANSWER = "Type an answer…";
|
|
190
|
+
const SKIP = "Skip";
|
|
191
|
+
|
|
192
|
+
/** The same questions through pi's built-in dialogs: pick an option, type an answer, or skip; esc stops. */
|
|
193
|
+
export function dialogAsker(): Asker {
|
|
194
|
+
return async (questions, ctx) => {
|
|
195
|
+
const answers: AskAnswer[] = [];
|
|
196
|
+
for (const [index, question] of questions.entries()) {
|
|
197
|
+
const choices = [...question.options.map((option) => option.label), TYPE_ANSWER, SKIP];
|
|
198
|
+
const title = `${question.header} · ${index + 1}/${questions.length}\n\n${question.question}`;
|
|
199
|
+
const choice = await ctx.ui.select(title, choices);
|
|
200
|
+
if (choice === undefined) return { answers, cancelled: true };
|
|
201
|
+
if (choice === SKIP) continue;
|
|
202
|
+
if (choice === TYPE_ANSWER) {
|
|
203
|
+
const typed = await ctx.ui.input(question.question, "your answer");
|
|
204
|
+
if (typed === undefined) return { answers, cancelled: true };
|
|
205
|
+
if (typed.trim()) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: typed.trim() });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
answers.push({ questionIndex: index, question: question.question, kind: "option", answer: choice });
|
|
209
|
+
}
|
|
210
|
+
return { answers, cancelled: false };
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Put every question to the user, one questionnaire after another. Stopping
|
|
216
|
+
* a questionnaire (esc) stops the rest; what was answered before it is kept.
|
|
217
|
+
*/
|
|
218
|
+
export async function askPanel(questions: readonly PanelQuestion[], ask: Asker, ctx: ExtensionContext): Promise<{ results: AskResult[]; stopped: boolean }> {
|
|
219
|
+
const results: AskResult[] = [];
|
|
220
|
+
for (const chunk of questionnaires(questions)) {
|
|
221
|
+
const result = await ask(chunk, ctx);
|
|
222
|
+
if (result.cancelled) return { results, stopped: true };
|
|
223
|
+
results.push(result);
|
|
224
|
+
}
|
|
225
|
+
return { results, stopped: false };
|
|
226
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the lobby narrates: an activity log of plain-words tool steps from the
|
|
3
|
+
* Master and every subagent, a single place for thoughts, and the conversation
|
|
4
|
+
* with the Master stripped of tool rows and thinking. Bounded ring buffers in
|
|
5
|
+
* one module-level store (like the zen widget state), so every event source can
|
|
6
|
+
* write without a reference to the lobby, and the lobby repaints on `version`.
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentRun } from "../schemas/findings.ts";
|
|
9
|
+
import { agentName } from "../pi/run-summary.ts";
|
|
10
|
+
|
|
11
|
+
export type ActivityKind = "info" | "success" | "warning" | "error";
|
|
12
|
+
|
|
13
|
+
export interface ActivityEntry {
|
|
14
|
+
id: number;
|
|
15
|
+
at: number;
|
|
16
|
+
/** Who acted: MASTER, DEV, DESIGN, QA, RESEARCH, QUICK FIX, PLANNER, LOBBY. */
|
|
17
|
+
source: string;
|
|
18
|
+
text: string;
|
|
19
|
+
kind: ActivityKind;
|
|
20
|
+
/** Still in flight; rendered with an ellipsis until it settles. */
|
|
21
|
+
pending: boolean;
|
|
22
|
+
/** Correlates a start with its end (a tool call id or a run id). */
|
|
23
|
+
key?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ThoughtEntry {
|
|
27
|
+
id: number;
|
|
28
|
+
at: number;
|
|
29
|
+
source: string;
|
|
30
|
+
text: string;
|
|
31
|
+
/** The Master's thought still streaming. */
|
|
32
|
+
live: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ChatRole = "you" | "oracle" | "note";
|
|
36
|
+
|
|
37
|
+
export interface ChatEntry {
|
|
38
|
+
id: number;
|
|
39
|
+
at: number;
|
|
40
|
+
role: ChatRole;
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const MAX_ACTIVITY = 400;
|
|
45
|
+
export const MAX_THOUGHTS = 40;
|
|
46
|
+
export const MAX_CHAT = 200;
|
|
47
|
+
/** Longest thought kept; a streaming thought keeps its newest text. */
|
|
48
|
+
export const MAX_THOUGHT_TEXT = 4000;
|
|
49
|
+
|
|
50
|
+
function bounded<T>(list: T[], max: number): T[] {
|
|
51
|
+
return list.length > max ? list.slice(-max) : list;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class LobbyFeed {
|
|
55
|
+
activity: ActivityEntry[] = [];
|
|
56
|
+
thoughts: ThoughtEntry[] = [];
|
|
57
|
+
chat: ChatEntry[] = [];
|
|
58
|
+
/** The oracle's reply while it streams; cleared when the message ends. */
|
|
59
|
+
reply = "";
|
|
60
|
+
version = 0;
|
|
61
|
+
private nextId = 1;
|
|
62
|
+
private readonly listeners = new Set<() => void>();
|
|
63
|
+
/** Last step reported per run, so repeated run updates log each step once. */
|
|
64
|
+
private readonly runSteps = new Map<string, string>();
|
|
65
|
+
private readonly runThoughts = new Map<string, string>();
|
|
66
|
+
private readonly runStatus = new Map<string, AgentRun["status"]>();
|
|
67
|
+
|
|
68
|
+
onChange(listener: () => void): () => void {
|
|
69
|
+
this.listeners.add(listener);
|
|
70
|
+
return () => this.listeners.delete(listener);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private touch(): void {
|
|
74
|
+
this.version += 1;
|
|
75
|
+
for (const listener of this.listeners) listener();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Settle every pending entry of `source` (optionally only the one keyed `key`). */
|
|
79
|
+
private settle(source: string | undefined, key: string | undefined, kind?: ActivityKind): boolean {
|
|
80
|
+
let changed = false;
|
|
81
|
+
for (const entry of this.activity) {
|
|
82
|
+
if (!entry.pending) continue;
|
|
83
|
+
if (key !== undefined ? entry.key !== key : entry.source !== source) continue;
|
|
84
|
+
entry.pending = false;
|
|
85
|
+
if (kind) entry.kind = kind;
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
return changed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A one-off entry that is already done (a receipt, a transition, a note). */
|
|
92
|
+
log(source: string, text: string, kind: ActivityKind = "info", at = Date.now()): void {
|
|
93
|
+
this.activity = bounded([...this.activity, { id: this.nextId++, at, source, text, kind, pending: false }], MAX_ACTIVITY);
|
|
94
|
+
this.touch();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Open an in-flight step; `key` lets `end` settle exactly this one. */
|
|
98
|
+
begin(source: string, text: string, key?: string, at = Date.now()): void {
|
|
99
|
+
this.activity = bounded([...this.activity, { id: this.nextId++, at, source, text, kind: "info", pending: true, ...(key ? { key } : {}) }], MAX_ACTIVITY);
|
|
100
|
+
this.touch();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Settle the step opened with `key`; an error marks it. */
|
|
104
|
+
end(key: string, isError = false): void {
|
|
105
|
+
if (this.settle(undefined, key, isError ? "error" : undefined)) this.touch();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The next step of an agent that reports one step at a time: the previous one settles. */
|
|
109
|
+
step(source: string, text: string, key: string, at = Date.now()): void {
|
|
110
|
+
this.settle(undefined, key);
|
|
111
|
+
this.begin(source, text, key, at);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stream the Master's (or any source's) live thought. */
|
|
115
|
+
thinkDelta(source: string, delta: string, at = Date.now()): void {
|
|
116
|
+
const last = this.thoughts.at(-1);
|
|
117
|
+
if (last && last.live && last.source === source) {
|
|
118
|
+
const text = last.text + delta;
|
|
119
|
+
last.text = text.length > MAX_THOUGHT_TEXT ? text.slice(-MAX_THOUGHT_TEXT) : text;
|
|
120
|
+
} else {
|
|
121
|
+
this.thoughts = bounded([...this.thoughts, { id: this.nextId++, at, source, text: delta.slice(-MAX_THOUGHT_TEXT), live: true }], MAX_THOUGHTS);
|
|
122
|
+
}
|
|
123
|
+
this.touch();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Close the live thought of `source`, replacing it with the final text when given. */
|
|
127
|
+
thinkEnd(source: string, text?: string): void {
|
|
128
|
+
const last = [...this.thoughts].reverse().find((entry) => entry.source === source && entry.live);
|
|
129
|
+
if (!last) {
|
|
130
|
+
if (text?.trim()) this.thought(source, text);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
last.live = false;
|
|
134
|
+
if (text?.trim()) last.text = text.trim().slice(-MAX_THOUGHT_TEXT);
|
|
135
|
+
if (!last.text.trim()) this.thoughts = this.thoughts.filter((entry) => entry !== last);
|
|
136
|
+
this.touch();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A finished thought from an agent that reports whole thoughts (subagents). */
|
|
140
|
+
thought(source: string, text: string, at = Date.now()): void {
|
|
141
|
+
const body = text.trim();
|
|
142
|
+
if (!body) return;
|
|
143
|
+
this.thoughts = bounded([...this.thoughts, { id: this.nextId++, at, source, text: body.slice(-MAX_THOUGHT_TEXT), live: false }], MAX_THOUGHTS);
|
|
144
|
+
this.touch();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Stream the oracle's reply text as it arrives. */
|
|
148
|
+
replyDelta(delta: string): void {
|
|
149
|
+
this.reply = (this.reply + delta).slice(-MAX_THOUGHT_TEXT);
|
|
150
|
+
this.touch();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The streamed reply is over; `say` records the final message. */
|
|
154
|
+
replyEnd(): void {
|
|
155
|
+
if (!this.reply) return;
|
|
156
|
+
this.reply = "";
|
|
157
|
+
this.touch();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
say(role: ChatRole, text: string, at = Date.now()): void {
|
|
161
|
+
const body = text.trim();
|
|
162
|
+
if (!body) return;
|
|
163
|
+
this.chat = bounded([...this.chat, { id: this.nextId++, at, role, text: body }], MAX_CHAT);
|
|
164
|
+
this.touch();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Replace the conversation (seeding from the session when the lobby first opens). */
|
|
168
|
+
seedChat(entries: ReadonlyArray<{ role: ChatRole; text: string; at?: number }>): void {
|
|
169
|
+
this.chat = bounded(entries.filter((entry) => entry.text.trim()).map((entry) => ({ id: this.nextId++, at: entry.at ?? 0, role: entry.role, text: entry.text.trim() })), MAX_CHAT);
|
|
170
|
+
this.touch();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fold streamed subagent run updates into the log: each new plain-words step
|
|
175
|
+
* opens an entry (settling the previous one), each new thought lands in the
|
|
176
|
+
* thinking pane, and a finished run settles with its outcome.
|
|
177
|
+
*/
|
|
178
|
+
runs(runs: readonly AgentRun[], at = Date.now()): void {
|
|
179
|
+
for (const run of runs) {
|
|
180
|
+
const source = agentName(run);
|
|
181
|
+
const key = run.runId;
|
|
182
|
+
const before = this.runStatus.get(key);
|
|
183
|
+
this.runStatus.set(key, run.status);
|
|
184
|
+
if (before === undefined) this.log(source, `started as ${run.role}`, "info", at);
|
|
185
|
+
if (run.step && this.runSteps.get(key) !== run.step) {
|
|
186
|
+
this.runSteps.set(key, run.step);
|
|
187
|
+
this.step(source, run.step, key, at);
|
|
188
|
+
}
|
|
189
|
+
if (run.thought && this.runThoughts.get(key) !== run.thought) {
|
|
190
|
+
this.runThoughts.set(key, run.thought);
|
|
191
|
+
this.thought(source, run.thought, at);
|
|
192
|
+
}
|
|
193
|
+
if (run.status !== "running" && before !== run.status) {
|
|
194
|
+
this.settle(undefined, key);
|
|
195
|
+
const kind: ActivityKind = run.status === "success" ? (run.wrappedUp ? "warning" : "success") : run.status === "cancelled" ? "warning" : "error";
|
|
196
|
+
this.log(source, `${run.role} ${run.status === "success" ? "finished" : run.status}${run.error ? ` — ${run.error.split("\n")[0]}` : ""}`, kind, at);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
clear(): void {
|
|
202
|
+
this.activity = [];
|
|
203
|
+
this.thoughts = [];
|
|
204
|
+
this.chat = [];
|
|
205
|
+
this.reply = "";
|
|
206
|
+
this.runSteps.clear();
|
|
207
|
+
this.runThoughts.clear();
|
|
208
|
+
this.runStatus.clear();
|
|
209
|
+
this.touch();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The process-wide lobby feed; the Master session is the only writer that matters. */
|
|
214
|
+
export const lobbyFeed = new LobbyFeed();
|
|
215
|
+
|
|
216
|
+
/** The text parts of a message's content, joined; thinking and tool calls are dropped. */
|
|
217
|
+
export function textOf(content: unknown): string {
|
|
218
|
+
if (typeof content === "string") return content;
|
|
219
|
+
if (!Array.isArray(content)) return "";
|
|
220
|
+
return content
|
|
221
|
+
.filter((part): part is { type: string; text: string } => typeof part === "object" && part !== null && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string")
|
|
222
|
+
.map((part) => part.text)
|
|
223
|
+
.join("")
|
|
224
|
+
.trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The long kickoff message bot-lobby sends reads as a short note in the conversation. */
|
|
228
|
+
export function chatText(role: "user" | "assistant", text: string): { role: ChatRole; text: string } | undefined {
|
|
229
|
+
const body = text.trim();
|
|
230
|
+
if (!body) return undefined;
|
|
231
|
+
if (role === "assistant") return { role: "oracle", text: body };
|
|
232
|
+
const kickoff = /^A bot-lobby task is active: (\S+)\nTitle: (.*)/.exec(body);
|
|
233
|
+
if (kickoff) return { role: "note", text: `task ${kickoff[1]} started — ${kickoff[2]}` };
|
|
234
|
+
const comment = /^The user left (?:a comment|\d+ comments) on (the approved plan|the proposal) of (\S+) from the lobby:/.exec(body);
|
|
235
|
+
if (comment) return { role: "note", text: `plan comment sent to the oracle for ${comment[2]}` };
|
|
236
|
+
return { role: "you", text: body };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Session entries (`{ type: "message", message }`) as conversation entries, oldest first. */
|
|
240
|
+
export function chatFromEntries(entries: readonly unknown[], max = MAX_CHAT): Array<{ role: ChatRole; text: string; at?: number }> {
|
|
241
|
+
const chat: Array<{ role: ChatRole; text: string; at?: number }> = [];
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
const record = entry as { type?: string; timestamp?: string; message?: { role?: string; content?: unknown } };
|
|
244
|
+
if (record?.type !== "message") continue;
|
|
245
|
+
const role = record.message?.role;
|
|
246
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
247
|
+
const line = chatText(role, textOf(record.message?.content));
|
|
248
|
+
if (!line) continue;
|
|
249
|
+
const at = record.timestamp ? Date.parse(record.timestamp) : undefined;
|
|
250
|
+
chat.push({ ...line, ...(at !== undefined && Number.isFinite(at) ? { at } : {}) });
|
|
251
|
+
}
|
|
252
|
+
return chat.slice(-max);
|
|
253
|
+
}
|