@kolisachint/hoobot 0.0.3 → 0.0.4
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/.env.example +12 -1
- package/README.md +60 -27
- package/package.json +3 -3
- package/src/config.ts +115 -43
- package/src/context.ts +160 -0
- package/src/index.ts +104 -79
- package/src/links.ts +7 -1
- package/src/session.ts +248 -23
- package/src/summary.ts +117 -0
package/src/session.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One Discord thread ⇄ one app-server thread. Translates Codex app-server
|
|
2
|
+
* One Discord space (a channel or a thread) ⇄ one app-server thread. Translates Codex app-server
|
|
3
3
|
* notifications into Discord messages and approval requests into buttons.
|
|
4
4
|
* Uses only standard Codex methods, so it works against `hoocode app-server`
|
|
5
5
|
* and the real `codex app-server` alike.
|
|
@@ -9,16 +9,19 @@ import {
|
|
|
9
9
|
ButtonBuilder,
|
|
10
10
|
ButtonStyle,
|
|
11
11
|
ComponentType,
|
|
12
|
+
StringSelectMenuBuilder,
|
|
12
13
|
type Message,
|
|
13
14
|
} from "discord.js";
|
|
14
15
|
import { config } from "./config.ts";
|
|
15
16
|
import { code, describeTool, splitMessage, truncate } from "./format.ts";
|
|
16
17
|
import { CodexClient, RpcError, userInput, type Notification, type RequestId, type ServerRequest } from "./codex-client.ts";
|
|
17
18
|
import type { LinkStore } from "./links.ts";
|
|
19
|
+
import { TurnSummary } from "./summary.ts";
|
|
18
20
|
|
|
19
21
|
/** The bits of a Discord thread this file uses (tests pass a fake). */
|
|
20
22
|
export interface ThreadLike {
|
|
21
23
|
id: string;
|
|
24
|
+
/** Resolves to a message with `edit` and `delete`. */
|
|
22
25
|
send(content: any): Promise<any>;
|
|
23
26
|
sendTyping(): Promise<unknown>;
|
|
24
27
|
}
|
|
@@ -34,10 +37,25 @@ export class ThreadSession {
|
|
|
34
37
|
private progressMsg: Message | null = null;
|
|
35
38
|
private progressDirty = false;
|
|
36
39
|
private progressTimer: ReturnType<typeof setInterval>;
|
|
40
|
+
/** Show every tool step and in-between message (`!verbose`). Off: status line + final answer. */
|
|
41
|
+
private verbose = false;
|
|
42
|
+
/** The running turn's summary and its status-line message. */
|
|
43
|
+
private live: { summary: TurnSummary; msg: Message | null; at: number } | null = null;
|
|
44
|
+
/** Latest agent message of the running turn; posted when the turn ends. */
|
|
45
|
+
private answer: string | null = null;
|
|
37
46
|
private typingTimer: ReturnType<typeof setInterval> | null = null;
|
|
38
47
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
39
48
|
private approvals = new Map<RequestId, Approval>();
|
|
49
|
+
/** The model the server reports for the thread. */
|
|
40
50
|
private model: string | null = null;
|
|
51
|
+
/** The model picked for this Discord thread (`!model`), saved in the link. */
|
|
52
|
+
private chosenModel: string | null = null;
|
|
53
|
+
/** Last Discord message the conversation has read; saved in the link. */
|
|
54
|
+
private seenId: string | null = null;
|
|
55
|
+
/** Whether the space had a conversation before this session opened. */
|
|
56
|
+
private wasLinked = false;
|
|
57
|
+
/** The message that started the running turn; the answer replies to it. */
|
|
58
|
+
private caller: { id: string } | null = null;
|
|
41
59
|
/** Serialises Discord sends so replies stay in order. */
|
|
42
60
|
private queue: Promise<unknown> = Promise.resolve();
|
|
43
61
|
private ready: Promise<void> | null = null;
|
|
@@ -48,6 +66,8 @@ export class ThreadSession {
|
|
|
48
66
|
private readonly client: CodexClient,
|
|
49
67
|
private readonly links: LinkStore,
|
|
50
68
|
private onClose: (threadId: string) => void,
|
|
69
|
+
/** The folder this thread works in (for `!status`). */
|
|
70
|
+
readonly workdir = config.workdir,
|
|
51
71
|
) {
|
|
52
72
|
client.on("notification", this.onNotification);
|
|
53
73
|
client.on("request", this.onRequest);
|
|
@@ -67,6 +87,9 @@ export class ThreadSession {
|
|
|
67
87
|
private ensureThread(): Promise<void> {
|
|
68
88
|
this.ready ??= (async () => {
|
|
69
89
|
const link = this.links.get(this.linkKey);
|
|
90
|
+
this.chosenModel = link?.model ?? null;
|
|
91
|
+
this.seenId = link?.seen ?? null;
|
|
92
|
+
this.wasLinked = !!link;
|
|
70
93
|
if (link) {
|
|
71
94
|
try {
|
|
72
95
|
const res = await this.client.request("thread/resume", { threadId: link.threadId });
|
|
@@ -75,6 +98,8 @@ export class ThreadSession {
|
|
|
75
98
|
} catch (err) {
|
|
76
99
|
console.error(`[${this.thread.id}] resume ${link.threadId} failed; starting fresh`, err);
|
|
77
100
|
await this.post(`Couldn't reopen the earlier conversation (${code(errorText(err))}). Starting a new one.`);
|
|
101
|
+
this.seenId = null;
|
|
102
|
+
this.wasLinked = false;
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
await this.startThread();
|
|
@@ -87,10 +112,34 @@ export class ThreadSession {
|
|
|
87
112
|
|
|
88
113
|
private async startThread() {
|
|
89
114
|
const params: Record<string, unknown> = {};
|
|
90
|
-
|
|
115
|
+
const model = this.chosenModel ?? config.model;
|
|
116
|
+
if (model) params.model = model;
|
|
91
117
|
const res = await this.client.request("thread/start", params);
|
|
92
118
|
this.adopt(res);
|
|
93
|
-
this.
|
|
119
|
+
this.saveLink();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private saveLink() {
|
|
123
|
+
if (!this.threadId) return;
|
|
124
|
+
this.links.set(this.linkKey, {
|
|
125
|
+
threadId: this.threadId,
|
|
126
|
+
...(this.chosenModel ? { model: this.chosenModel } : {}),
|
|
127
|
+
...(this.seenId ? { seen: this.seenId } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read position for context: whether the space had a conversation, and its last read message. */
|
|
132
|
+
async readState(): Promise<{ linked: boolean; seen: string | null }> {
|
|
133
|
+
await this.ensureThread();
|
|
134
|
+
return { linked: this.wasLinked, seen: this.seenId };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Everything up to `messageId` has been sent to the conversation. */
|
|
138
|
+
markSeen(messageId: string) {
|
|
139
|
+
if (this.seenId && BigInt(this.seenId) >= BigInt(messageId)) return;
|
|
140
|
+
this.seenId = messageId;
|
|
141
|
+
this.wasLinked = true;
|
|
142
|
+
this.saveLink();
|
|
94
143
|
}
|
|
95
144
|
|
|
96
145
|
private adopt(res: any) {
|
|
@@ -98,13 +147,17 @@ export class ThreadSession {
|
|
|
98
147
|
this.model = res.model ?? null;
|
|
99
148
|
// A turn still running on the server (e.g. after a bot restart).
|
|
100
149
|
const running = (res.thread.turns ?? []).findLast?.((t: any) => t.status === "inProgress");
|
|
101
|
-
this.turnId =
|
|
102
|
-
if (
|
|
150
|
+
this.turnId = null;
|
|
151
|
+
if (running) this.beginTurn(running.id);
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
// ── Input from Discord ─────────────────────────────────────────────────────
|
|
106
155
|
|
|
107
|
-
|
|
156
|
+
/**
|
|
157
|
+
* `caller`: the Discord message this answers; the final answer replies to it.
|
|
158
|
+
* Resolves true when the server took the message (a new turn or a steer).
|
|
159
|
+
*/
|
|
160
|
+
async prompt(text: string, images: { data: string; mimeType: string }[] = [], caller?: { id: string }): Promise<boolean> {
|
|
108
161
|
this.touch();
|
|
109
162
|
await this.ensureThread();
|
|
110
163
|
const input = userInput(text, images);
|
|
@@ -113,7 +166,7 @@ export class ThreadSession {
|
|
|
113
166
|
try {
|
|
114
167
|
await this.client.request("turn/steer", { threadId: this.threadId, input, expectedTurnId: this.turnId });
|
|
115
168
|
await this.post("Queued. It'll be read after the current step.");
|
|
116
|
-
return;
|
|
169
|
+
return true;
|
|
117
170
|
} catch (err) {
|
|
118
171
|
// The turn ended in the meantime: start a new one below.
|
|
119
172
|
if (!(err instanceof RpcError)) throw err;
|
|
@@ -121,15 +174,17 @@ export class ThreadSession {
|
|
|
121
174
|
}
|
|
122
175
|
try {
|
|
123
176
|
const params: Record<string, unknown> = { threadId: this.threadId, input };
|
|
124
|
-
|
|
177
|
+
// Sent every turn: the server ignores it when it's already the model,
|
|
178
|
+
// and it survives a bot restart or a server that forgot it.
|
|
179
|
+
if (this.chosenModel) params.model = this.chosenModel;
|
|
125
180
|
const res = await this.client.request("turn/start", params);
|
|
126
|
-
if (this.
|
|
127
|
-
this.model = this.pendingModel;
|
|
128
|
-
this.pendingModel = null;
|
|
129
|
-
}
|
|
181
|
+
if (this.chosenModel) this.model = this.chosenModel;
|
|
130
182
|
this.beginTurn(res.turn.id);
|
|
183
|
+
this.caller = caller ?? null;
|
|
184
|
+
return true;
|
|
131
185
|
} catch (err) {
|
|
132
186
|
await this.post(`**Not sent:** ${errorText(err)}`);
|
|
187
|
+
return false;
|
|
133
188
|
}
|
|
134
189
|
}
|
|
135
190
|
|
|
@@ -152,6 +207,9 @@ export class ThreadSession {
|
|
|
152
207
|
}
|
|
153
208
|
if (this.threadId) await this.client.request("thread/unsubscribe", { threadId: this.threadId }).catch(() => {});
|
|
154
209
|
this.turnId = null;
|
|
210
|
+
// The new conversation knows nothing: the next call reads the last 30 again.
|
|
211
|
+
this.seenId = null;
|
|
212
|
+
this.wasLinked = false;
|
|
155
213
|
this.ready = this.startThread();
|
|
156
214
|
try {
|
|
157
215
|
await this.ready;
|
|
@@ -166,20 +224,117 @@ export class ThreadSession {
|
|
|
166
224
|
await this.ensureThread();
|
|
167
225
|
const lines = [
|
|
168
226
|
"**Status**",
|
|
169
|
-
`- Model: ${code(this.
|
|
227
|
+
`- Model: ${code(this.chosenModel ?? this.model ?? "default")}${this.chosenModel && this.chosenModel !== this.model ? " (from the next message)" : ""}`,
|
|
170
228
|
`- Busy: ${this.turnId ? "yes" : "no"}`,
|
|
229
|
+
`- Folder: ${code(this.workdir)}`,
|
|
171
230
|
`- Thread: ${code(this.threadId ?? "none")}`,
|
|
172
231
|
`- Server: ${code(this.client.endpoint)}`,
|
|
173
232
|
];
|
|
174
233
|
await this.post(lines.join("\n"));
|
|
175
234
|
}
|
|
176
235
|
|
|
177
|
-
|
|
236
|
+
/**
|
|
237
|
+
* `!model` → a dropdown of the server's models (hoocode: your
|
|
238
|
+
* `enabledModels` scope). `!model kimi` → that model if one matches,
|
|
239
|
+
* else a dropdown of the matches. Takes effect from the next message;
|
|
240
|
+
* the conversation carries on.
|
|
241
|
+
*/
|
|
242
|
+
async chooseModel(query = "") {
|
|
243
|
+
await this.ensureThread();
|
|
244
|
+
let models: ModelChoice[];
|
|
245
|
+
try {
|
|
246
|
+
models = await this.listModels(query !== "");
|
|
247
|
+
} catch (err) {
|
|
248
|
+
await this.post(`**Can't list models:** ${errorText(err)}`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const q = query.toLowerCase();
|
|
252
|
+
if (q) {
|
|
253
|
+
const exact = models.find((m) => m.value.toLowerCase() === q || m.value.toLowerCase().endsWith(`/${q}`));
|
|
254
|
+
if (exact) return this.setModel(exact.value);
|
|
255
|
+
const matches = models.filter((m) => m.value.toLowerCase().includes(q) || m.label.toLowerCase().includes(q));
|
|
256
|
+
if (matches.length === 1) return this.setModel(matches[0]!.value);
|
|
257
|
+
if (matches.length === 0) {
|
|
258
|
+
await this.post(`No model matches ${code(query)}. Send \`!model\` for the list.`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Scoped models first.
|
|
262
|
+
models = [...matches.filter((m) => !m.hidden), ...matches.filter((m) => m.hidden)];
|
|
263
|
+
}
|
|
264
|
+
if (models.length === 0) {
|
|
265
|
+
await this.post("The server lists no models.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const current = this.chosenModel ?? this.model;
|
|
269
|
+
const shown = models.slice(0, 25);
|
|
270
|
+
const menu = new StringSelectMenuBuilder()
|
|
271
|
+
.setCustomId("model")
|
|
272
|
+
.setPlaceholder("Pick a model")
|
|
273
|
+
.addOptions(
|
|
274
|
+
shown.map((m) => ({
|
|
275
|
+
label: truncate(m.label, 100),
|
|
276
|
+
value: m.value.slice(0, 100),
|
|
277
|
+
description: m.label === m.value ? undefined : truncate(m.value, 100),
|
|
278
|
+
default: m.value === current || m.value.endsWith(`/${current}`),
|
|
279
|
+
})),
|
|
280
|
+
);
|
|
281
|
+
const more = models.length > shown.length ? `\nShowing ${shown.length} of ${models.length}; narrow it with \`!model <part of name>\`.` : "";
|
|
282
|
+
const title = `**Model:** ${code(current ?? "default")}. Pick one for this thread (from the next message):${more}`;
|
|
283
|
+
const msg = (await this.enqueue(() =>
|
|
284
|
+
this.thread.send({ content: title, components: [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)] }),
|
|
285
|
+
)) as Message;
|
|
286
|
+
try {
|
|
287
|
+
const pick = await msg.awaitMessageComponent({
|
|
288
|
+
componentType: ComponentType.StringSelect,
|
|
289
|
+
time: 5 * 60_000,
|
|
290
|
+
filter: async (i) => {
|
|
291
|
+
if (config.allowedUserIds.has(i.user.id)) return true;
|
|
292
|
+
await i.reply({ content: "Only allowed users can change the model.", ephemeral: true }).catch(() => {});
|
|
293
|
+
return false;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
const name = pick.values[0]!;
|
|
297
|
+
this.applyModel(name);
|
|
298
|
+
await pick.update({ content: modelSetText(name), components: [] });
|
|
299
|
+
} catch {
|
|
300
|
+
await msg.edit({ content: `**Model:** ${code(this.chosenModel ?? this.model ?? "default")} (not changed)`, components: [] }).catch(() => {});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
178
303
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
this.
|
|
182
|
-
|
|
304
|
+
private async setModel(name: string) {
|
|
305
|
+
this.applyModel(name);
|
|
306
|
+
await this.post(modelSetText(name));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private applyModel(name: string) {
|
|
310
|
+
this.chosenModel = name;
|
|
311
|
+
this.saveLink();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** All pages of `model/list`. */
|
|
315
|
+
private async listModels(includeHidden: boolean): Promise<ModelChoice[]> {
|
|
316
|
+
const out: ModelChoice[] = [];
|
|
317
|
+
let cursor: string | undefined;
|
|
318
|
+
for (let page = 0; page < 10; page++) {
|
|
319
|
+
const res: any = await this.client.request("model/list", { includeHidden, ...(cursor ? { cursor } : {}) });
|
|
320
|
+
for (const m of res?.data ?? []) {
|
|
321
|
+
const value = String(m.model ?? m.id);
|
|
322
|
+
out.push({ value, label: String(m.displayName || value), hidden: !!m.hidden });
|
|
323
|
+
}
|
|
324
|
+
cursor = res?.nextCursor ?? undefined;
|
|
325
|
+
if (!cursor) break;
|
|
326
|
+
}
|
|
327
|
+
return includeHidden ? out : out.filter((m) => !m.hidden);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Toggle the step-by-step view for this thread. */
|
|
331
|
+
async toggleVerbose() {
|
|
332
|
+
this.verbose = !this.verbose;
|
|
333
|
+
await this.post(
|
|
334
|
+
this.verbose
|
|
335
|
+
? "**Verbose on:** every step and message is shown. `!verbose` again to turn it off."
|
|
336
|
+
: "**Verbose off:** a status line while working, then the final answer.",
|
|
337
|
+
);
|
|
183
338
|
}
|
|
184
339
|
|
|
185
340
|
close() {
|
|
@@ -196,6 +351,8 @@ export class ThreadSession {
|
|
|
196
351
|
this.turnId = turnId;
|
|
197
352
|
this.tools = [];
|
|
198
353
|
this.progressMsg = null;
|
|
354
|
+
this.live = { summary: new TurnSummary(), msg: null, at: 0 };
|
|
355
|
+
this.answer = null;
|
|
199
356
|
this.startTyping();
|
|
200
357
|
}
|
|
201
358
|
|
|
@@ -217,14 +374,13 @@ export class ThreadSession {
|
|
|
217
374
|
this.turnId = null;
|
|
218
375
|
this.stopTyping();
|
|
219
376
|
await this.flushProgress();
|
|
220
|
-
|
|
221
|
-
await this.post(`**Error:**\n${"```"}\n${truncate(p.turn.error.message, 1500)}\n${"```"}`);
|
|
222
|
-
}
|
|
377
|
+
await this.finishTurn(p.turn);
|
|
223
378
|
this.touch();
|
|
224
379
|
break;
|
|
225
380
|
}
|
|
226
381
|
|
|
227
382
|
case "item/started": {
|
|
383
|
+
this.live?.summary.started(p.item);
|
|
228
384
|
const tool = toolLine(p.item);
|
|
229
385
|
if (tool) {
|
|
230
386
|
this.tools.push(tool);
|
|
@@ -235,6 +391,12 @@ export class ThreadSession {
|
|
|
235
391
|
|
|
236
392
|
case "item/completed": {
|
|
237
393
|
const item = p.item;
|
|
394
|
+
this.live?.summary.completed(item);
|
|
395
|
+
if (item.type === "agentMessage" && item.text?.trim() && !this.verbose) {
|
|
396
|
+
// Only the last message of the turn is posted (finishTurn).
|
|
397
|
+
this.answer = item.text;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
238
400
|
if (item.type === "agentMessage" && item.text?.trim()) {
|
|
239
401
|
// Text closes the current tool list; the next tools start a fresh one.
|
|
240
402
|
await this.flushProgress();
|
|
@@ -257,8 +419,59 @@ export class ThreadSession {
|
|
|
257
419
|
}
|
|
258
420
|
}
|
|
259
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Post the final answer with a footer (PR, commits, files, steps, time,
|
|
424
|
+
* model) and remove the status line. Errors are always posted in full.
|
|
425
|
+
*/
|
|
426
|
+
private async finishTurn(turn: any) {
|
|
427
|
+
const status = this.live;
|
|
428
|
+
this.live = null;
|
|
429
|
+
const answer = this.answer;
|
|
430
|
+
this.answer = null;
|
|
431
|
+
const caller = this.caller;
|
|
432
|
+
this.caller = null;
|
|
433
|
+
|
|
434
|
+
const footer = status?.summary.footer(this.model) ?? "";
|
|
435
|
+
const parts: string[] = [];
|
|
436
|
+
if (answer) parts.push(answer);
|
|
437
|
+
if (turn.status === "failed") {
|
|
438
|
+
parts.push(`**Error:**\n${"```"}\n${truncate(turn.error?.message ?? "the turn failed", 1500)}\n${"```"}`);
|
|
439
|
+
} else if (!answer && !this.verbose && turn.status === "completed") {
|
|
440
|
+
parts.push("Done.");
|
|
441
|
+
}
|
|
442
|
+
if (parts.length) {
|
|
443
|
+
const chunks = splitMessage(parts.join("\n\n"));
|
|
444
|
+
const last = chunks.length - 1;
|
|
445
|
+
if (footer && chunks[last]!.length + footer.length + 1 <= 2000) chunks[last] += `\n${footer}`;
|
|
446
|
+
else if (footer) chunks.push(footer);
|
|
447
|
+
// The first chunk is a Discord reply to the message that asked.
|
|
448
|
+
for (const [i, chunk] of chunks.entries()) await this.post(chunk, i === 0 ? caller : null);
|
|
449
|
+
} else if (footer && !this.verbose && turn.status !== "interrupted") {
|
|
450
|
+
await this.post(footer, caller);
|
|
451
|
+
}
|
|
452
|
+
// Queued after any in-flight status send, so that message exists by now.
|
|
453
|
+
if (status) await this.enqueue(async () => status.msg?.delete()).catch(() => {});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Throttled: the status line while a turn runs (non-verbose). */
|
|
457
|
+
private async flushStatus() {
|
|
458
|
+
const status = this.live;
|
|
459
|
+
if (!status || this.verbose) return;
|
|
460
|
+
const now = Date.now();
|
|
461
|
+
// Quick answers need no status line; then refresh every 3 s.
|
|
462
|
+
if (now - status.summary.startedAt < 4000 || now - status.at < 3000) return;
|
|
463
|
+
status.at = now;
|
|
464
|
+
const body = status.summary.statusLine(now);
|
|
465
|
+
await this.enqueue(async () => {
|
|
466
|
+
if (this.live !== status) return; // turn ended meanwhile
|
|
467
|
+
if (status.msg) await status.msg.edit(body).catch(() => {});
|
|
468
|
+
else status.msg = await this.thread.send(body);
|
|
469
|
+
}).catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
|
|
260
472
|
/** Tool activity as one message, edited in place (throttled). */
|
|
261
473
|
private async flushProgress() {
|
|
474
|
+
if (!this.verbose) return this.flushStatus();
|
|
262
475
|
if (!this.progressDirty || this.tools.length === 0) return;
|
|
263
476
|
this.progressDirty = false;
|
|
264
477
|
// Words, not just symbols, so state doesn't rely on colour or icon shape.
|
|
@@ -357,9 +570,15 @@ export class ThreadSession {
|
|
|
357
570
|
|
|
358
571
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
359
572
|
|
|
360
|
-
|
|
573
|
+
/** `replyTo`: post as a Discord reply to that message (no ping); plain send if it's gone. */
|
|
574
|
+
private post(text: string, replyTo?: { id: string } | null) {
|
|
361
575
|
if (!text.trim()) return Promise.resolve();
|
|
362
|
-
|
|
576
|
+
const send = () =>
|
|
577
|
+
replyTo
|
|
578
|
+
? this.thread
|
|
579
|
+
.send({ content: text, reply: { messageReference: replyTo.id, failIfNotExists: false }, allowedMentions: { repliedUser: false } })
|
|
580
|
+
: this.thread.send(text);
|
|
581
|
+
return this.enqueue(send).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
|
|
363
582
|
}
|
|
364
583
|
|
|
365
584
|
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
@@ -398,6 +617,12 @@ export class ThreadSession {
|
|
|
398
617
|
}
|
|
399
618
|
}
|
|
400
619
|
|
|
620
|
+
type ModelChoice = { value: string; label: string; hidden: boolean };
|
|
621
|
+
|
|
622
|
+
function modelSetText(name: string): string {
|
|
623
|
+
return `**Model:** ${code(name)} from the next message. The conversation carries on.`;
|
|
624
|
+
}
|
|
625
|
+
|
|
401
626
|
/** A progress line for a tool-ish item, or null for messages. */
|
|
402
627
|
function toolLine(item: any): ToolLine | null {
|
|
403
628
|
switch (item?.type) {
|
package/src/summary.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a turn did, boiled down for Discord: a one-line status while it runs
|
|
3
|
+
* and a short footer under the final answer (PRs, commits, files, steps,
|
|
4
|
+
* time, model). Pure: fed app-server items, no Discord or network.
|
|
5
|
+
*/
|
|
6
|
+
import { code, truncate } from "./format.ts";
|
|
7
|
+
|
|
8
|
+
const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
|
|
9
|
+
/** `git commit` prints `[branch abc1234] subject`; `git push` prints `old..new branch -> branch`. */
|
|
10
|
+
const COMMIT_LINE = /^\[[^\]\s]+(?: \(root-commit\))? ([0-9a-f]{7,40})\]/gm;
|
|
11
|
+
|
|
12
|
+
export class TurnSummary {
|
|
13
|
+
readonly startedAt: number;
|
|
14
|
+
private steps = new Set<string>();
|
|
15
|
+
private failed = 0;
|
|
16
|
+
private current: string | null = null;
|
|
17
|
+
private prs = new Set<string>();
|
|
18
|
+
private commits = new Set<string>();
|
|
19
|
+
private files = new Set<string>();
|
|
20
|
+
|
|
21
|
+
constructor(now = Date.now()) {
|
|
22
|
+
this.startedAt = now;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An `item/started` item. */
|
|
26
|
+
started(item: any) {
|
|
27
|
+
const what = stepLabel(item);
|
|
28
|
+
if (!what) return;
|
|
29
|
+
this.steps.add(item.id);
|
|
30
|
+
this.current = what;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An `item/completed` item. */
|
|
34
|
+
completed(item: any) {
|
|
35
|
+
if (item?.type === "agentMessage") {
|
|
36
|
+
this.scan(item.text);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!stepLabel(item)) return;
|
|
40
|
+
this.steps.add(item.id);
|
|
41
|
+
if (item.status === "failed" || item.status === "declined" || (item.exitCode ?? 0) !== 0) this.failed++;
|
|
42
|
+
if (item.type === "commandExecution") this.scan(item.aggregatedOutput);
|
|
43
|
+
if (item.type === "fileChange" && item.status !== "declined") {
|
|
44
|
+
for (const c of item.changes ?? []) if (c?.path) this.files.add(c.path);
|
|
45
|
+
}
|
|
46
|
+
if (item.type === "dynamicToolCall" && (item.tool === "edit" || item.tool === "write") && item.success !== false) {
|
|
47
|
+
const path = item.arguments?.path ?? item.arguments?.file_path;
|
|
48
|
+
if (path) this.files.add(String(path));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** e.g. "⏳ Working · 4 steps · 1m 20s · bash `bun test`" */
|
|
53
|
+
statusLine(now = Date.now()): string {
|
|
54
|
+
const parts = ["⏳ Working", plural(this.steps.size, "step"), duration(now - this.startedAt)];
|
|
55
|
+
if (this.current) parts.push(this.current);
|
|
56
|
+
return parts.join(" · ");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** e.g. "-# PR #5 · commit `abc1234` · 3 files · 12 steps · 3m 10s · `model`", or "" for a bare chat turn. */
|
|
60
|
+
footer(model: string | null, now = Date.now()): string {
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
for (const url of this.prs) parts.push(`[PR #${url.split("/").pop()}](<${url}>)`);
|
|
63
|
+
const commits = [...this.commits];
|
|
64
|
+
if (commits.length === 1) parts.push(`commit ${code(commits[0]!.slice(0, 7))}`);
|
|
65
|
+
else if (commits.length > 1) parts.push(`${commits.length} commits`);
|
|
66
|
+
if (this.files.size === 1) parts.push(`edited ${code(truncate(basename([...this.files][0]!), 60))}`);
|
|
67
|
+
else if (this.files.size > 1) parts.push(`${this.files.size} files edited`);
|
|
68
|
+
if (this.steps.size === 0 && parts.length === 0) return "";
|
|
69
|
+
if (this.steps.size) parts.push(plural(this.steps.size, "step") + (this.failed ? ` (${this.failed} failed)` : ""));
|
|
70
|
+
parts.push(duration(now - this.startedAt));
|
|
71
|
+
if (model) parts.push(code(model));
|
|
72
|
+
return `-# ${parts.join(" · ")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private scan(text: unknown) {
|
|
76
|
+
if (typeof text !== "string" || !text) return;
|
|
77
|
+
for (const m of text.matchAll(PR_URL)) this.prs.add(m[0]);
|
|
78
|
+
for (const m of text.matchAll(COMMIT_LINE)) this.commits.add(m[1]!);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Short label for a tool-ish item, or null for messages and reasoning. */
|
|
83
|
+
function stepLabel(item: any): string | null {
|
|
84
|
+
switch (item?.type) {
|
|
85
|
+
case "commandExecution":
|
|
86
|
+
return `bash ${code(truncate(String(item.command ?? ""), 60))}`;
|
|
87
|
+
case "fileChange":
|
|
88
|
+
return `edit ${code(truncate(basename(item.changes?.[0]?.path ?? "?"), 60))}`;
|
|
89
|
+
case "dynamicToolCall": {
|
|
90
|
+
const a = item.arguments ?? {};
|
|
91
|
+
const arg = a.command ?? a.path ?? a.file_path ?? a.query ?? a.pattern;
|
|
92
|
+
return arg ? `${item.tool} ${code(truncate(String(arg), 60))}` : String(item.tool);
|
|
93
|
+
}
|
|
94
|
+
case "mcpToolCall":
|
|
95
|
+
return `${item.server}/${item.tool}`;
|
|
96
|
+
case "webSearch":
|
|
97
|
+
return `web search ${code(truncate(String(item.query ?? ""), 60))}`;
|
|
98
|
+
default:
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function basename(path: string): string {
|
|
104
|
+
return path.split("/").pop() || path;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function plural(n: number, word: string): string {
|
|
108
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function duration(ms: number): string {
|
|
112
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
113
|
+
if (s < 60) return `${s}s`;
|
|
114
|
+
const m = Math.floor(s / 60);
|
|
115
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
116
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
117
|
+
}
|