@kolisachint/hoobot 0.0.4 → 0.0.6

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/src/session.ts CHANGED
@@ -1,46 +1,44 @@
1
1
  /**
2
- * One Discord space (a channel or a thread) ⇄ one app-server thread. Translates Codex app-server
3
- * notifications into Discord messages and approval requests into buttons.
2
+ * One chat space (a Discord or Slack channel or thread) ⇄ one app-server
3
+ * thread. Translates Codex app-server notifications into chat messages and
4
+ * approval requests into buttons. Knows nothing about Discord or Slack
5
+ * itself: it talks to a `ChatSpace` (src/chat.ts).
4
6
  * Uses only standard Codex methods, so it works against `hoocode app-server`
5
7
  * and the real `codex app-server` alike.
6
8
  */
7
- import {
8
- ActionRowBuilder,
9
- ButtonBuilder,
10
- ButtonStyle,
11
- ComponentType,
12
- StringSelectMenuBuilder,
13
- type Message,
14
- } from "discord.js";
15
9
  import { config } from "./config.ts";
10
+ import type { ChatSpace, Choice, Posted } from "./chat.ts";
11
+ import { idOrder } from "./context.ts";
16
12
  import { code, describeTool, splitMessage, truncate } from "./format.ts";
17
13
  import { CodexClient, RpcError, userInput, type Notification, type RequestId, type ServerRequest } from "./codex-client.ts";
18
14
  import type { LinkStore } from "./links.ts";
19
15
  import { TurnSummary } from "./summary.ts";
20
-
21
- /** The bits of a Discord thread this file uses (tests pass a fake). */
22
- export interface ThreadLike {
23
- id: string;
24
- /** Resolves to a message with `edit` and `delete`. */
25
- send(content: any): Promise<any>;
26
- sendTyping(): Promise<unknown>;
27
- }
16
+ import {
17
+ changedSince,
18
+ claimChanged,
19
+ MAX_FILES,
20
+ MAX_TOTAL_BYTES,
21
+ pathsInText,
22
+ pickAttachments,
23
+ turnLog,
24
+ type Attachment,
25
+ } from "./attachments.ts";
28
26
 
29
27
  type ToolLine = { id: string; text: string; state: "running" | "ok" | "error" | "declined" };
30
28
 
31
- type Approval = { msg: Message | null; title: string; resolved: boolean };
29
+ type Approval = { msg: Posted | null; title: string; resolved: boolean };
32
30
 
33
31
  export class ThreadSession {
34
32
  private threadId: string | null = null;
35
33
  private turnId: string | null = null;
36
34
  private tools: ToolLine[] = [];
37
- private progressMsg: Message | null = null;
35
+ private progressMsg: Posted | null = null;
38
36
  private progressDirty = false;
39
37
  private progressTimer: ReturnType<typeof setInterval>;
40
38
  /** Show every tool step and in-between message (`!verbose`). Off: status line + final answer. */
41
39
  private verbose = false;
42
40
  /** The running turn's summary and its status-line message. */
43
- private live: { summary: TurnSummary; msg: Message | null; at: number } | null = null;
41
+ private live: { summary: TurnSummary; msg: Posted | null; at: number } | null = null;
44
42
  /** Latest agent message of the running turn; posted when the turn ends. */
45
43
  private answer: string | null = null;
46
44
  private typingTimer: ReturnType<typeof setInterval> | null = null;
@@ -48,25 +46,25 @@ export class ThreadSession {
48
46
  private approvals = new Map<RequestId, Approval>();
49
47
  /** The model the server reports for the thread. */
50
48
  private model: string | null = null;
51
- /** The model picked for this Discord thread (`!model`), saved in the link. */
49
+ /** The model picked for this space (`!model`), saved in the link. */
52
50
  private chosenModel: string | null = null;
53
- /** Last Discord message the conversation has read; saved in the link. */
51
+ /** Last chat message the conversation has read; saved in the link. */
54
52
  private seenId: string | null = null;
55
53
  /** Whether the space had a conversation before this session opened. */
56
54
  private wasLinked = false;
57
55
  /** The message that started the running turn; the answer replies to it. */
58
56
  private caller: { id: string } | null = null;
59
- /** Serialises Discord sends so replies stay in order. */
57
+ /** Serialises chat sends so replies stay in order. */
60
58
  private queue: Promise<unknown> = Promise.resolve();
61
59
  private ready: Promise<void> | null = null;
62
60
  private closed = false;
63
61
 
64
62
  constructor(
65
- readonly thread: ThreadLike,
63
+ readonly thread: ChatSpace,
66
64
  private readonly client: CodexClient,
67
65
  private readonly links: LinkStore,
68
66
  private onClose: (threadId: string) => void,
69
- /** The folder this thread works in (for `!status`). */
67
+ /** The folder this thread works in (for `!status`, and files to attach). */
70
68
  readonly workdir = config.workdir,
71
69
  ) {
72
70
  client.on("notification", this.onNotification);
@@ -76,7 +74,7 @@ export class ThreadSession {
76
74
  }
77
75
 
78
76
  private get linkKey() {
79
- return `discord:${this.thread.id}`;
77
+ return `${this.thread.surface}:${this.thread.id}`;
80
78
  }
81
79
 
82
80
  get busy() {
@@ -136,7 +134,7 @@ export class ThreadSession {
136
134
 
137
135
  /** Everything up to `messageId` has been sent to the conversation. */
138
136
  markSeen(messageId: string) {
139
- if (this.seenId && BigInt(this.seenId) >= BigInt(messageId)) return;
137
+ if (this.seenId && idOrder(this.seenId, messageId) >= 0) return;
140
138
  this.seenId = messageId;
141
139
  this.wasLinked = true;
142
140
  this.saveLink();
@@ -151,10 +149,10 @@ export class ThreadSession {
151
149
  if (running) this.beginTurn(running.id);
152
150
  }
153
151
 
154
- // ── Input from Discord ─────────────────────────────────────────────────────
152
+ // ── Input from chat ────────────────────────────────────────────────────────
155
153
 
156
154
  /**
157
- * `caller`: the Discord message this answers; the final answer replies to it.
155
+ * `caller`: the chat message this answers; the final answer replies to it.
158
156
  * Resolves true when the server took the message (a new turn or a steer).
159
157
  */
160
158
  async prompt(text: string, images: { data: string; mimeType: string }[] = [], caller?: { id: string }): Promise<boolean> {
@@ -207,6 +205,7 @@ export class ThreadSession {
207
205
  }
208
206
  if (this.threadId) await this.client.request("thread/unsubscribe", { threadId: this.threadId }).catch(() => {});
209
207
  this.turnId = null;
208
+ turnLog.end(this.workdir, this.linkKey);
210
209
  // The new conversation knows nothing: the next call reads the last 30 again.
211
210
  this.seenId = null;
212
211
  this.wasLinked = false;
@@ -266,38 +265,22 @@ export class ThreadSession {
266
265
  return;
267
266
  }
268
267
  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
- );
268
+ const shown = models.slice(0, this.thread.maxChoices);
269
+ const choices: Choice[] = shown.map((m) => ({
270
+ label: truncate(m.label, 75),
271
+ value: m.value.slice(0, 100),
272
+ description: m.label === m.value ? undefined : truncate(m.value, 75),
273
+ default: m.value === current || m.value.endsWith(`/${current}`),
274
+ }));
281
275
  const more = models.length > shown.length ? `\nShowing ${shown.length} of ${models.length}; narrow it with \`!model <part of name>\`.` : "";
282
276
  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;
277
+ const { msg, pick } = await this.enqueue(() => this.thread.choose(title, "menu", choices, 5 * 60_000, "Pick a model"));
286
278
  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: [] });
279
+ const picked = await pick;
280
+ this.applyModel(picked.value);
281
+ await picked.update(modelSetText(picked.value));
299
282
  } catch {
300
- await msg.edit({ content: `**Model:** ${code(this.chosenModel ?? this.model ?? "default")} (not changed)`, components: [] }).catch(() => {});
283
+ await msg.edit(`**Model:** ${code(this.chosenModel ?? this.model ?? "default")} (not changed)`).catch(() => {});
301
284
  }
302
285
  }
303
286
 
@@ -344,11 +327,12 @@ export class ThreadSession {
344
327
  this.dispose();
345
328
  }
346
329
 
347
- // ── Output to Discord ──────────────────────────────────────────────────────
330
+ // ── Output to chat ─────────────────────────────────────────────────────────
348
331
 
349
332
  private beginTurn(turnId: string) {
350
333
  if (this.turnId === turnId) return;
351
334
  this.turnId = turnId;
335
+ turnLog.begin(this.workdir, this.linkKey);
352
336
  this.tools = [];
353
337
  this.progressMsg = null;
354
338
  this.live = { summary: new TurnSummary(), msg: null, at: 0 };
@@ -372,6 +356,7 @@ export class ThreadSession {
372
356
  case "turn/completed": {
373
357
  if (p.turn.id !== this.turnId) break;
374
358
  this.turnId = null;
359
+ turnLog.end(this.workdir, this.linkKey);
375
360
  this.stopTyping();
376
361
  await this.flushProgress();
377
362
  await this.finishTurn(p.turn);
@@ -402,7 +387,7 @@ export class ThreadSession {
402
387
  await this.flushProgress();
403
388
  this.progressMsg = null;
404
389
  this.tools = [];
405
- for (const chunk of splitMessage(item.text)) await this.post(chunk);
390
+ for (const chunk of this.split(item.text)) await this.post(chunk);
406
391
  break;
407
392
  }
408
393
  const t = this.tools.find((x) => x.id === item.id);
@@ -432,6 +417,27 @@ export class ThreadSession {
432
417
  this.caller = null;
433
418
 
434
419
  const footer = status?.summary.footer(this.model) ?? "";
420
+ // Files the model wrote, named in its answer, or that changed in the work
421
+ // folder during the turn (shell output) go back as attachments.
422
+ const { files, skipped } =
423
+ turn.status === "completed" && status
424
+ ? pickAttachments(
425
+ [
426
+ ...status.summary.editedFiles,
427
+ ...pathsInText(answer ?? ""),
428
+ // Whole seconds: some filesystems store mtimes that coarse.
429
+ // A channel and its threads share the folder: when another turn ran
430
+ // there too, only files this turn names are its own.
431
+ ...claimChanged(
432
+ changedSince(this.workdir, Math.floor(status.summary.startedAt / 1000) * 1000),
433
+ this.workdir,
434
+ !turnLog.overlapped(this.workdir, this.linkKey),
435
+ `${answer ?? ""}\n${status.summary.shellText}`,
436
+ ),
437
+ ],
438
+ this.workdir,
439
+ )
440
+ : { files: [], skipped: [] };
435
441
  const parts: string[] = [];
436
442
  if (answer) parts.push(answer);
437
443
  if (turn.status === "failed") {
@@ -440,14 +446,19 @@ export class ThreadSession {
440
446
  parts.push("Done.");
441
447
  }
442
448
  if (parts.length) {
443
- const chunks = splitMessage(parts.join("\n\n"));
449
+ const chunks = this.split(parts.join("\n\n"));
444
450
  const last = chunks.length - 1;
445
- if (footer && chunks[last]!.length + footer.length + 1 <= 2000) chunks[last] += `\n${footer}`;
451
+ if (footer && chunks[last]!.length + footer.length + 1 <= this.thread.maxLength) chunks[last] += `\n${footer}`;
446
452
  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);
453
+ // The first chunk replies to the message that asked.
454
+ for (const [i, chunk] of chunks.entries()) await this.post(chunk, i === 0 ? caller : null, i === chunks.length - 1 ? files : []);
449
455
  } else if (footer && !this.verbose && turn.status !== "interrupted") {
450
- await this.post(footer, caller);
456
+ await this.post(footer, caller, files);
457
+ } else if (files.length) {
458
+ await this.post("Files:", caller, files);
459
+ }
460
+ if (skipped.length) {
461
+ await this.post(`-# Not attached (over ${MAX_FILES} files / ${Math.floor(MAX_TOTAL_BYTES / 1024 / 1024)} MB per answer): ${skipped.map((s) => code(s)).join(", ")}`);
451
462
  }
452
463
  // Queued after any in-flight status send, so that message exists by now.
453
464
  if (status) await this.enqueue(async () => status.msg?.delete()).catch(() => {});
@@ -479,7 +490,7 @@ export class ThreadSession {
479
490
  const lines = this.tools.slice(-15).map((t) => `${label[t.state]} · ${t.text}`);
480
491
  const hidden = this.tools.length - 15;
481
492
  if (hidden > 0) lines.unshift(`*…${hidden} earlier steps*`);
482
- const body = lines.join("\n").slice(-1900);
493
+ const body = lines.join("\n").slice(-(this.thread.maxLength - 100));
483
494
  await this.enqueue(async () => {
484
495
  if (this.progressMsg) await this.progressMsg.edit(body).catch(() => {});
485
496
  else this.progressMsg = await this.thread.send(body);
@@ -495,7 +506,7 @@ export class ThreadSession {
495
506
 
496
507
  private async handleRequest(r: ServerRequest) {
497
508
  if (r.method !== "item/commandExecution/requestApproval" && r.method !== "item/fileChange/requestApproval") {
498
- // Not something Discord can answer; say no rather than leave it hanging.
509
+ // Not something a chat can answer; say no rather than leave it hanging.
499
510
  this.client.respond(r.id, { decision: "decline" });
500
511
  return;
501
512
  }
@@ -512,14 +523,13 @@ export class ThreadSession {
512
523
  this.approvals.set(r.id, approval);
513
524
 
514
525
  // No "always" button: it would change hoocode's global config.
515
- const options = [
516
- { label: "Allow once", decision: "accept", style: ButtonStyle.Success },
517
- { label: "Deny", decision: "decline", style: ButtonStyle.Danger },
526
+ const options: Choice[] = [
527
+ { label: "Allow once", value: "accept", style: "primary" },
528
+ { label: "Deny", value: "decline", style: "danger" },
518
529
  ];
519
- const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
520
- options.map((o, i) => new ButtonBuilder().setCustomId(`ui:${i}`).setLabel(o.label).setStyle(o.style)),
530
+ const { msg, pick } = await this.enqueue(() =>
531
+ this.thread.choose(title.slice(0, 1800), "buttons", options, config.approvalTimeoutMs),
521
532
  );
522
- const msg = (await this.enqueue(() => this.thread.send({ content: title.slice(0, 1800), components: [row] }))) as Message;
523
533
  approval.msg = msg;
524
534
  this.stopTyping();
525
535
  if (approval.resolved) {
@@ -528,23 +538,15 @@ export class ThreadSession {
528
538
  return;
529
539
  }
530
540
  try {
531
- const click = await msg.awaitMessageComponent({
532
- componentType: ComponentType.Button,
533
- time: config.approvalTimeoutMs,
534
- filter: async (i) => {
535
- if (config.allowedUserIds.has(i.user.id)) return true;
536
- await i.reply({ content: "Only allowed users can answer this.", ephemeral: true }).catch(() => {});
537
- return false;
538
- },
539
- });
541
+ const click = await pick;
540
542
  if (approval.resolved) {
541
- await click.update({ content: `${title.slice(0, 1800)}\n→ **Answered elsewhere.**`, components: [] }).catch(() => {});
543
+ await click.update(`${title.slice(0, 1800)}\n→ **Answered elsewhere.**`).catch(() => {});
542
544
  return;
543
545
  }
544
- const chosen = options[Number(click.customId.split(":")[1])]!;
546
+ const chosen = options.find((o) => o.value === click.value) ?? options[1]!;
545
547
  approval.resolved = true;
546
- this.client.respond(r.id, { decision: chosen.decision });
547
- await click.update({ content: `${title.slice(0, 1800)}\n→ **${chosen.label}** by ${click.user.username}`, components: [] });
548
+ this.client.respond(r.id, { decision: chosen.value });
549
+ await click.update(`${title.slice(0, 1800)}\n→ **${chosen.label}** by ${click.user}`);
548
550
  } catch {
549
551
  if (approval.resolved) return;
550
552
  approval.resolved = true;
@@ -565,19 +567,29 @@ export class ThreadSession {
565
567
  }
566
568
 
567
569
  private async markResolved(approval: Approval, note: string) {
568
- await approval.msg?.edit({ content: `${approval.title.slice(0, 1800)}\n→ ${note}`, components: [] }).catch(() => {});
570
+ await approval.msg?.edit(`${approval.title.slice(0, 1800)}\n→ ${note}`).catch(() => {});
569
571
  }
570
572
 
571
573
  // ── Helpers ────────────────────────────────────────────────────────────────
572
574
 
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) {
575
+ /** Long text in pieces that fit one message. */
576
+ private split(text: string): string[] {
577
+ return splitMessage(text, this.thread.maxLength - 100);
578
+ }
579
+
580
+ /** `replyTo`: post as a reply to that message (no ping); plain send if it's gone. */
581
+ private post(text: string, replyTo?: { id: string } | null, files: Attachment[] = []) {
575
582
  if (!text.trim()) return Promise.resolve();
583
+ const opts = { replyTo: replyTo?.id ?? null };
576
584
  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);
585
+ !files.length
586
+ ? this.thread.send(text, opts)
587
+ : this.thread.send(text, { ...opts, files }).catch(async (err: unknown) => {
588
+ // e.g. over the upload limit: still post the text.
589
+ console.error(`[${this.thread.id}] upload failed`, err);
590
+ await this.thread.send(text, opts);
591
+ return this.thread.send(`-# Couldn't attach ${files.map((f) => code(f.name)).join(", ")}: ${errorText(err)}`);
592
+ });
581
593
  return this.enqueue(send).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
582
594
  }
583
595
 
@@ -608,6 +620,7 @@ export class ThreadSession {
608
620
  }
609
621
 
610
622
  private dispose() {
623
+ turnLog.end(this.workdir, this.linkKey);
611
624
  clearInterval(this.progressTimer);
612
625
  if (this.idleTimer) clearTimeout(this.idleTimer);
613
626
  this.stopTyping();