@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/.env.example +18 -8
- package/README.md +97 -16
- package/package.json +5 -3
- package/src/attachments.ts +186 -0
- package/src/chat.ts +66 -0
- package/src/config.ts +90 -19
- package/src/context.ts +48 -15
- package/src/core.ts +216 -0
- package/src/discord.ts +187 -0
- package/src/inbound.ts +228 -0
- package/src/index.ts +42 -234
- package/src/mrkdwn.ts +87 -0
- package/src/session.ts +106 -93
- package/src/slack.ts +440 -0
- package/src/summary.ts +16 -1
package/src/session.ts
CHANGED
|
@@ -1,46 +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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
49
|
+
/** The model picked for this space (`!model`), saved in the link. */
|
|
52
50
|
private chosenModel: string | null = null;
|
|
53
|
-
/** Last
|
|
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
|
|
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:
|
|
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
|
|
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 &&
|
|
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
|
|
152
|
+
// ── Input from chat ────────────────────────────────────────────────────────
|
|
155
153
|
|
|
156
154
|
/**
|
|
157
|
-
* `caller`: the
|
|
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,
|
|
270
|
-
const
|
|
271
|
-
.
|
|
272
|
-
.
|
|
273
|
-
.
|
|
274
|
-
|
|
275
|
-
|
|
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 =
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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 <=
|
|
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
|
|
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(-
|
|
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
|
|
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",
|
|
517
|
-
{ label: "Deny",
|
|
526
|
+
const options: Choice[] = [
|
|
527
|
+
{ label: "Allow once", value: "accept", style: "primary" },
|
|
528
|
+
{ label: "Deny", value: "decline", style: "danger" },
|
|
518
529
|
];
|
|
519
|
-
const
|
|
520
|
-
|
|
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
|
|
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(
|
|
543
|
+
await click.update(`${title.slice(0, 1800)}\n→ **Answered elsewhere.**`).catch(() => {});
|
|
542
544
|
return;
|
|
543
545
|
}
|
|
544
|
-
const chosen = options
|
|
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.
|
|
547
|
-
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}`);
|
|
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(
|
|
570
|
+
await approval.msg?.edit(`${approval.title.slice(0, 1800)}\n→ ${note}`).catch(() => {});
|
|
569
571
|
}
|
|
570
572
|
|
|
571
573
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
572
574
|
|
|
573
|
-
/**
|
|
574
|
-
private
|
|
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
|
-
|
|
578
|
-
? this.thread
|
|
579
|
-
|
|
580
|
-
|
|
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();
|