@kolisachint/hoobot 0.0.5 → 0.0.7
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 +25 -8
- package/README.md +92 -19
- package/package.json +5 -3
- package/src/attachments.ts +72 -5
- package/src/chat.ts +66 -0
- package/src/config.ts +90 -28
- package/src/context.ts +39 -13
- package/src/core.ts +220 -0
- package/src/discord.ts +187 -0
- package/src/inbound.ts +31 -19
- package/src/index.ts +30 -245
- package/src/mrkdwn.ts +95 -0
- package/src/peers.ts +65 -0
- package/src/session.ts +82 -99
- package/src/slack.ts +507 -0
- package/src/summary.ts +11 -1
package/src/session.ts
CHANGED
|
@@ -1,47 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One
|
|
3
|
-
* notifications into
|
|
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 {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
49
|
+
/** The model picked for this space (`!model`), saved in the link. */
|
|
53
50
|
private chosenModel: string | null = null;
|
|
54
|
-
/** Last
|
|
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
|
|
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:
|
|
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
|
|
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 &&
|
|
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
|
|
152
|
+
// ── Input from chat ────────────────────────────────────────────────────────
|
|
156
153
|
|
|
157
154
|
/**
|
|
158
|
-
* `caller`: the
|
|
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,
|
|
271
|
-
const
|
|
272
|
-
.
|
|
273
|
-
.
|
|
274
|
-
.
|
|
275
|
-
|
|
276
|
-
|
|
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 =
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 <=
|
|
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
|
|
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
|
|
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(-
|
|
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
|
|
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",
|
|
537
|
-
{ label: "Deny",
|
|
526
|
+
const options: Choice[] = [
|
|
527
|
+
{ label: "Allow once", value: "accept", style: "primary" },
|
|
528
|
+
{ label: "Deny", value: "decline", style: "danger" },
|
|
538
529
|
];
|
|
539
|
-
const
|
|
540
|
-
|
|
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
|
|
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(
|
|
543
|
+
await click.update(`${title.slice(0, 1800)}\n→ **Answered elsewhere.**`).catch(() => {});
|
|
562
544
|
return;
|
|
563
545
|
}
|
|
564
|
-
const chosen = options
|
|
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.
|
|
567
|
-
await click.update(
|
|
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(
|
|
570
|
+
await approval.msg?.edit(`${approval.title.slice(0, 1800)}\n→ ${note}`).catch(() => {});
|
|
589
571
|
}
|
|
590
572
|
|
|
591
573
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
592
574
|
|
|
593
|
-
/**
|
|
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
|
|
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
|
-
!
|
|
603
|
-
? this.thread.send(text)
|
|
604
|
-
: this.thread.send(
|
|
605
|
-
|
|
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(
|
|
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();
|