@kolisachint/hoobot 0.0.5 → 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,47 +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
- import { changedSince, pathsInText, pickAttachments, type Attachment } from "./attachments.ts";
21
-
22
- /** The bits of a Discord thread this file uses (tests pass a fake). */
23
- export interface ThreadLike {
24
- id: string;
25
- /** Resolves to a message with `edit` and `delete`. */
26
- send(content: any): Promise<any>;
27
- sendTyping(): Promise<unknown>;
28
- }
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";
29
26
 
30
27
  type ToolLine = { id: string; text: string; state: "running" | "ok" | "error" | "declined" };
31
28
 
32
- type Approval = { msg: Message | null; title: string; resolved: boolean };
29
+ type Approval = { msg: Posted | null; title: string; resolved: boolean };
33
30
 
34
31
  export class ThreadSession {
35
32
  private threadId: string | null = null;
36
33
  private turnId: string | null = null;
37
34
  private tools: ToolLine[] = [];
38
- private progressMsg: Message | null = null;
35
+ private progressMsg: Posted | null = null;
39
36
  private progressDirty = false;
40
37
  private progressTimer: ReturnType<typeof setInterval>;
41
38
  /** Show every tool step and in-between message (`!verbose`). Off: status line + final answer. */
42
39
  private verbose = false;
43
40
  /** The running turn's summary and its status-line message. */
44
- private live: { summary: TurnSummary; msg: Message | null; at: number } | null = null;
41
+ private live: { summary: TurnSummary; msg: Posted | null; at: number } | null = null;
45
42
  /** Latest agent message of the running turn; posted when the turn ends. */
46
43
  private answer: string | null = null;
47
44
  private typingTimer: ReturnType<typeof setInterval> | null = null;
@@ -49,21 +46,21 @@ export class ThreadSession {
49
46
  private approvals = new Map<RequestId, Approval>();
50
47
  /** The model the server reports for the thread. */
51
48
  private model: string | null = null;
52
- /** The model picked for this Discord thread (`!model`), saved in the link. */
49
+ /** The model picked for this space (`!model`), saved in the link. */
53
50
  private chosenModel: string | null = null;
54
- /** Last Discord message the conversation has read; saved in the link. */
51
+ /** Last chat message the conversation has read; saved in the link. */
55
52
  private seenId: string | null = null;
56
53
  /** Whether the space had a conversation before this session opened. */
57
54
  private wasLinked = false;
58
55
  /** The message that started the running turn; the answer replies to it. */
59
56
  private caller: { id: string } | null = null;
60
- /** Serialises Discord sends so replies stay in order. */
57
+ /** Serialises chat sends so replies stay in order. */
61
58
  private queue: Promise<unknown> = Promise.resolve();
62
59
  private ready: Promise<void> | null = null;
63
60
  private closed = false;
64
61
 
65
62
  constructor(
66
- readonly thread: ThreadLike,
63
+ readonly thread: ChatSpace,
67
64
  private readonly client: CodexClient,
68
65
  private readonly links: LinkStore,
69
66
  private onClose: (threadId: string) => void,
@@ -77,7 +74,7 @@ export class ThreadSession {
77
74
  }
78
75
 
79
76
  private get linkKey() {
80
- return `discord:${this.thread.id}`;
77
+ return `${this.thread.surface}:${this.thread.id}`;
81
78
  }
82
79
 
83
80
  get busy() {
@@ -137,7 +134,7 @@ export class ThreadSession {
137
134
 
138
135
  /** Everything up to `messageId` has been sent to the conversation. */
139
136
  markSeen(messageId: string) {
140
- if (this.seenId && BigInt(this.seenId) >= BigInt(messageId)) return;
137
+ if (this.seenId && idOrder(this.seenId, messageId) >= 0) return;
141
138
  this.seenId = messageId;
142
139
  this.wasLinked = true;
143
140
  this.saveLink();
@@ -152,10 +149,10 @@ export class ThreadSession {
152
149
  if (running) this.beginTurn(running.id);
153
150
  }
154
151
 
155
- // ── Input from Discord ─────────────────────────────────────────────────────
152
+ // ── Input from chat ────────────────────────────────────────────────────────
156
153
 
157
154
  /**
158
- * `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.
159
156
  * Resolves true when the server took the message (a new turn or a steer).
160
157
  */
161
158
  async prompt(text: string, images: { data: string; mimeType: string }[] = [], caller?: { id: string }): Promise<boolean> {
@@ -208,6 +205,7 @@ export class ThreadSession {
208
205
  }
209
206
  if (this.threadId) await this.client.request("thread/unsubscribe", { threadId: this.threadId }).catch(() => {});
210
207
  this.turnId = null;
208
+ turnLog.end(this.workdir, this.linkKey);
211
209
  // The new conversation knows nothing: the next call reads the last 30 again.
212
210
  this.seenId = null;
213
211
  this.wasLinked = false;
@@ -267,38 +265,22 @@ export class ThreadSession {
267
265
  return;
268
266
  }
269
267
  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
- );
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
+ }));
282
275
  const more = models.length > shown.length ? `\nShowing ${shown.length} of ${models.length}; narrow it with \`!model <part of name>\`.` : "";
283
276
  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;
277
+ const { msg, pick } = await this.enqueue(() => this.thread.choose(title, "menu", choices, 5 * 60_000, "Pick a model"));
287
278
  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: [] });
279
+ const picked = await pick;
280
+ this.applyModel(picked.value);
281
+ await picked.update(modelSetText(picked.value));
300
282
  } catch {
301
- 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(() => {});
302
284
  }
303
285
  }
304
286
 
@@ -345,11 +327,12 @@ export class ThreadSession {
345
327
  this.dispose();
346
328
  }
347
329
 
348
- // ── Output to Discord ──────────────────────────────────────────────────────
330
+ // ── Output to chat ─────────────────────────────────────────────────────────
349
331
 
350
332
  private beginTurn(turnId: string) {
351
333
  if (this.turnId === turnId) return;
352
334
  this.turnId = turnId;
335
+ turnLog.begin(this.workdir, this.linkKey);
353
336
  this.tools = [];
354
337
  this.progressMsg = null;
355
338
  this.live = { summary: new TurnSummary(), msg: null, at: 0 };
@@ -373,6 +356,7 @@ export class ThreadSession {
373
356
  case "turn/completed": {
374
357
  if (p.turn.id !== this.turnId) break;
375
358
  this.turnId = null;
359
+ turnLog.end(this.workdir, this.linkKey);
376
360
  this.stopTyping();
377
361
  await this.flushProgress();
378
362
  await this.finishTurn(p.turn);
@@ -403,7 +387,7 @@ export class ThreadSession {
403
387
  await this.flushProgress();
404
388
  this.progressMsg = null;
405
389
  this.tools = [];
406
- for (const chunk of splitMessage(item.text)) await this.post(chunk);
390
+ for (const chunk of this.split(item.text)) await this.post(chunk);
407
391
  break;
408
392
  }
409
393
  const t = this.tools.find((x) => x.id === item.id);
@@ -442,7 +426,14 @@ export class ThreadSession {
442
426
  ...status.summary.editedFiles,
443
427
  ...pathsInText(answer ?? ""),
444
428
  // Whole seconds: some filesystems store mtimes that coarse.
445
- ...changedSince(this.workdir, Math.floor(status.summary.startedAt / 1000) * 1000),
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
+ ),
446
437
  ],
447
438
  this.workdir,
448
439
  )
@@ -455,11 +446,11 @@ export class ThreadSession {
455
446
  parts.push("Done.");
456
447
  }
457
448
  if (parts.length) {
458
- const chunks = splitMessage(parts.join("\n\n"));
449
+ const chunks = this.split(parts.join("\n\n"));
459
450
  const last = chunks.length - 1;
460
- 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}`;
461
452
  else if (footer) chunks.push(footer);
462
- // The first chunk is a Discord reply to the message that asked.
453
+ // The first chunk replies to the message that asked.
463
454
  for (const [i, chunk] of chunks.entries()) await this.post(chunk, i === 0 ? caller : null, i === chunks.length - 1 ? files : []);
464
455
  } else if (footer && !this.verbose && turn.status !== "interrupted") {
465
456
  await this.post(footer, caller, files);
@@ -467,7 +458,7 @@ export class ThreadSession {
467
458
  await this.post("Files:", caller, files);
468
459
  }
469
460
  if (skipped.length) {
470
- await this.post(`-# Not attached (over Discord's 10 files / 10 MB): ${skipped.map((s) => code(s)).join(", ")}`);
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(", ")}`);
471
462
  }
472
463
  // Queued after any in-flight status send, so that message exists by now.
473
464
  if (status) await this.enqueue(async () => status.msg?.delete()).catch(() => {});
@@ -499,7 +490,7 @@ export class ThreadSession {
499
490
  const lines = this.tools.slice(-15).map((t) => `${label[t.state]} · ${t.text}`);
500
491
  const hidden = this.tools.length - 15;
501
492
  if (hidden > 0) lines.unshift(`*…${hidden} earlier steps*`);
502
- const body = lines.join("\n").slice(-1900);
493
+ const body = lines.join("\n").slice(-(this.thread.maxLength - 100));
503
494
  await this.enqueue(async () => {
504
495
  if (this.progressMsg) await this.progressMsg.edit(body).catch(() => {});
505
496
  else this.progressMsg = await this.thread.send(body);
@@ -515,7 +506,7 @@ export class ThreadSession {
515
506
 
516
507
  private async handleRequest(r: ServerRequest) {
517
508
  if (r.method !== "item/commandExecution/requestApproval" && r.method !== "item/fileChange/requestApproval") {
518
- // 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.
519
510
  this.client.respond(r.id, { decision: "decline" });
520
511
  return;
521
512
  }
@@ -532,14 +523,13 @@ export class ThreadSession {
532
523
  this.approvals.set(r.id, approval);
533
524
 
534
525
  // No "always" button: it would change hoocode's global config.
535
- const options = [
536
- { label: "Allow once", decision: "accept", style: ButtonStyle.Success },
537
- { 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" },
538
529
  ];
539
- const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
540
- 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),
541
532
  );
542
- const msg = (await this.enqueue(() => this.thread.send({ content: title.slice(0, 1800), components: [row] }))) as Message;
543
533
  approval.msg = msg;
544
534
  this.stopTyping();
545
535
  if (approval.resolved) {
@@ -548,23 +538,15 @@ export class ThreadSession {
548
538
  return;
549
539
  }
550
540
  try {
551
- const click = await msg.awaitMessageComponent({
552
- componentType: ComponentType.Button,
553
- time: config.approvalTimeoutMs,
554
- filter: async (i) => {
555
- if (config.allowedUserIds.has(i.user.id)) return true;
556
- await i.reply({ content: "Only allowed users can answer this.", ephemeral: true }).catch(() => {});
557
- return false;
558
- },
559
- });
541
+ const click = await pick;
560
542
  if (approval.resolved) {
561
- 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(() => {});
562
544
  return;
563
545
  }
564
- const chosen = options[Number(click.customId.split(":")[1])]!;
546
+ const chosen = options.find((o) => o.value === click.value) ?? options[1]!;
565
547
  approval.resolved = true;
566
- this.client.respond(r.id, { decision: chosen.decision });
567
- 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}`);
568
550
  } catch {
569
551
  if (approval.resolved) return;
570
552
  approval.resolved = true;
@@ -585,27 +567,27 @@ export class ThreadSession {
585
567
  }
586
568
 
587
569
  private async markResolved(approval: Approval, note: string) {
588
- 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(() => {});
589
571
  }
590
572
 
591
573
  // ── Helpers ────────────────────────────────────────────────────────────────
592
574
 
593
- /** `replyTo`: post as a Discord reply to that message (no ping); plain send if it's gone. */
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. */
594
581
  private post(text: string, replyTo?: { id: string } | null, files: Attachment[] = []) {
595
582
  if (!text.trim()) return Promise.resolve();
596
- const message = (withFiles: boolean) => ({
597
- content: text,
598
- ...(replyTo ? { reply: { messageReference: replyTo.id, failIfNotExists: false }, allowedMentions: { repliedUser: false } } : {}),
599
- ...(withFiles ? { files } : {}),
600
- });
583
+ const opts = { replyTo: replyTo?.id ?? null };
601
584
  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.
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.
607
589
  console.error(`[${this.thread.id}] upload failed`, err);
608
- await this.thread.send(message(false));
590
+ await this.thread.send(text, opts);
609
591
  return this.thread.send(`-# Couldn't attach ${files.map((f) => code(f.name)).join(", ")}: ${errorText(err)}`);
610
592
  });
611
593
  return this.enqueue(send).catch((err) => console.error(`[${this.thread.id}] send failed`, err));
@@ -638,6 +620,7 @@ export class ThreadSession {
638
620
  }
639
621
 
640
622
  private dispose() {
623
+ turnLog.end(this.workdir, this.linkKey);
641
624
  clearInterval(this.progressTimer);
642
625
  if (this.idleTimer) clearTimeout(this.idleTimer);
643
626
  this.stopTyping();