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