@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/index.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* hoobot: talk to hoocode from Discord.
|
|
4
4
|
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
5
|
+
* - Every channel and thread is a shared space with its own hoocode conversation.
|
|
6
|
+
* - Mention the bot (or reply to it) → it reads what was said since it last
|
|
7
|
+
* looked (up to 30 messages) and answers right there.
|
|
8
|
+
* - Commands inside a thread: !stop !new !status !model [name] !verbose !help
|
|
9
|
+
* - Each channel in WORKSPACES works in its own folder with its own app-server.
|
|
8
10
|
*/
|
|
9
11
|
import {
|
|
10
12
|
ChannelType,
|
|
@@ -13,14 +15,29 @@ import {
|
|
|
13
15
|
GatewayIntentBits,
|
|
14
16
|
Partials,
|
|
15
17
|
type Message,
|
|
18
|
+
type TextChannel,
|
|
16
19
|
type ThreadChannel,
|
|
17
20
|
} from "discord.js";
|
|
18
|
-
import { config, prepareWorkspace } from "./config.ts";
|
|
21
|
+
import { allWorkdirs, config, prepareWorkspace, workdirFor } from "./config.ts";
|
|
19
22
|
import { CodexClient } from "./codex-client.ts";
|
|
20
23
|
import { LinkStore } from "./links.ts";
|
|
21
24
|
import { ThreadSession } from "./session.ts";
|
|
25
|
+
import { authorName, buildPrompt, gatherContext, toContext, type ContextMessage, type SpaceLike } from "./context.ts";
|
|
26
|
+
import { clearSpace, formatAttachments, imageInputs, pruneInbox, saveAttachments, type SavedFile, type SkippedFile } from "./inbound.ts";
|
|
22
27
|
|
|
23
|
-
prepareWorkspace();
|
|
28
|
+
for (const dir of allWorkdirs()) prepareWorkspace(dir);
|
|
29
|
+
// Files sent on Discord are kept a week.
|
|
30
|
+
const prune = () => {
|
|
31
|
+
for (const dir of allWorkdirs()) {
|
|
32
|
+
try {
|
|
33
|
+
pruneInbox(dir);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(`Can't clean ${dir}/.discord: ${err instanceof Error ? err.message : String(err)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
prune();
|
|
40
|
+
setInterval(prune, 60 * 60 * 1000).unref();
|
|
24
41
|
|
|
25
42
|
const links = new LinkStore(config.linksFile);
|
|
26
43
|
const sessions = new Map<string, ThreadSession>();
|
|
@@ -31,35 +48,36 @@ function endpoint(): string {
|
|
|
31
48
|
return `stdio:${config.hoocodeBin} app-server ${config.hoocodeArgs.join(" ")}`;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
|
-
|
|
51
|
+
/** One app-server per folder, started on first use. */
|
|
52
|
+
const apps = new Map<string, Promise<CodexClient>>();
|
|
35
53
|
|
|
36
|
-
/** The
|
|
37
|
-
function appServer(): Promise<CodexClient> {
|
|
38
|
-
app
|
|
54
|
+
/** The app-server for `workdir`; reconnects after it drops. */
|
|
55
|
+
function appServer(workdir: string): Promise<CodexClient> {
|
|
56
|
+
let app = apps.get(workdir);
|
|
57
|
+
if (app) return app;
|
|
58
|
+
app = (async () => {
|
|
39
59
|
// A stdio server runs in the work folder; a socket server has its own.
|
|
40
60
|
const client = await CodexClient.connect(
|
|
41
61
|
endpoint(),
|
|
42
62
|
{ name: "hoobot", version: "0.1.0" },
|
|
43
|
-
{ cwd:
|
|
63
|
+
{ cwd: workdir },
|
|
44
64
|
);
|
|
45
|
-
console.log(`Connected to app-server ${client.endpoint}`);
|
|
65
|
+
console.log(`Connected to app-server ${client.endpoint} in ${workdir}`);
|
|
46
66
|
client.on("close", (reason: string) => {
|
|
47
|
-
console.error(`app-server
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
for (const s of [...sessions.values()]) s.close();
|
|
67
|
+
console.error(`app-server for ${workdir} closed: ${reason}`);
|
|
68
|
+
apps.delete(workdir);
|
|
69
|
+
// Its sessions hold the old client; drop them. Threads resume on the next message.
|
|
70
|
+
for (const s of [...sessions.values()]) if (s.workdir === workdir) s.close();
|
|
51
71
|
});
|
|
52
72
|
return client;
|
|
53
73
|
})().catch((err) => {
|
|
54
|
-
console.error(`Can't reach app-server: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
-
|
|
74
|
+
console.error(`Can't reach app-server for ${workdir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
apps.delete(workdir);
|
|
56
76
|
throw err;
|
|
57
77
|
});
|
|
78
|
+
apps.set(workdir, app);
|
|
58
79
|
return app;
|
|
59
80
|
}
|
|
60
|
-
/** Threads this bot created, so it only listens in its own threads. */
|
|
61
|
-
const ownThreads = new Set<string>();
|
|
62
|
-
|
|
63
81
|
const client = new Client({
|
|
64
82
|
intents: [
|
|
65
83
|
GatewayIntentBits.Guilds,
|
|
@@ -72,23 +90,31 @@ const client = new Client({
|
|
|
72
90
|
const HELP = [
|
|
73
91
|
"**hoo: hoocode in Discord**",
|
|
74
92
|
"",
|
|
75
|
-
"**
|
|
76
|
-
"
|
|
93
|
+
"**Call me:** mention me, or reply to one of my messages. I answer right here.",
|
|
94
|
+
"Every channel and thread is a shared space: anyone can talk, and when I'm called",
|
|
95
|
+
"I read what was said since I last looked (up to 30 messages).",
|
|
96
|
+
"Open a thread for a side task; it has its own conversation in the same folder.",
|
|
97
|
+
"Attach files (or reply to a message with files): I save them in the work folder and read them.",
|
|
77
98
|
"",
|
|
78
|
-
"**Commands (
|
|
99
|
+
"**Commands** (for this channel or thread)",
|
|
79
100
|
"- `!stop`: stop the current run",
|
|
80
|
-
"- `!new`:
|
|
101
|
+
"- `!new`: start a fresh conversation here (for everyone; deletes files sent here)",
|
|
81
102
|
"- `!status`: model, busy or not, folder",
|
|
82
|
-
"- `!model
|
|
103
|
+
"- `!model`: pick the model here from a list (from the next message; the conversation carries on)",
|
|
104
|
+
"- `!model <part of name>`: pick it directly, e.g. `!model kimi`",
|
|
105
|
+
"- `!verbose`: show every step here (again to turn off)",
|
|
83
106
|
"- `!help`: this message",
|
|
84
107
|
"",
|
|
85
|
-
"
|
|
86
|
-
"
|
|
108
|
+
"Calling me while I'm busy steers the current run.",
|
|
109
|
+
"While I work you see one status line; then the answer with a short",
|
|
110
|
+
"summary (PR, commits, files, steps, time, model).",
|
|
111
|
+
...(config.approvals === "ask" ? ["Commands that change files or run shell ask for approval with buttons."] : []),
|
|
87
112
|
].join("\n");
|
|
88
113
|
|
|
89
114
|
client.once(Events.ClientReady, (c) => {
|
|
90
115
|
console.log(`Logged in as ${c.user.tag}`);
|
|
91
116
|
console.log(`Working folder: ${config.workdir}`);
|
|
117
|
+
for (const [channel, dir] of config.workspaces) console.log(` channel ${channel} → ${dir}`);
|
|
92
118
|
console.log(`Allowed users: ${[...config.allowedUserIds].join(", ")}`);
|
|
93
119
|
});
|
|
94
120
|
|
|
@@ -106,22 +132,23 @@ async function onMessage(message: Message) {
|
|
|
106
132
|
if (config.guildId && message.guildId !== config.guildId) return;
|
|
107
133
|
|
|
108
134
|
const channel = message.channel;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
135
|
+
if (channel.type !== ChannelType.GuildText && !channel.isThread()) return;
|
|
136
|
+
const parentId = channel.isThread() ? channel.parentId : channel.id;
|
|
137
|
+
if (config.channelIds.size && (!parentId || !(config.channelIds.has(parentId) || config.workspaces.has(parentId)))) return;
|
|
138
|
+
|
|
139
|
+
// Called by a mention (the bot user, or its auto-created role that Discord's
|
|
140
|
+
// autocomplete often picks), or by replying to one of the bot's messages.
|
|
112
141
|
const botRoleId = message.guild?.members.me?.roles.botRole?.id;
|
|
113
|
-
const
|
|
142
|
+
const called =
|
|
114
143
|
message.mentions.users.has(client.user.id) ||
|
|
115
|
-
(!!botRoleId && message.mentions.roles.has(botRoleId))
|
|
116
|
-
|
|
117
|
-
if (!
|
|
144
|
+
(!!botRoleId && message.mentions.roles.has(botRoleId)) ||
|
|
145
|
+
message.mentions.repliedUser?.id === client.user.id;
|
|
146
|
+
if (!called) return;
|
|
118
147
|
console.log(`[msg] ${message.author.username} (${message.author.id}) in ${channel.id}: ${message.content.slice(0, 80)}`);
|
|
119
148
|
|
|
120
|
-
|
|
121
|
-
if (config.channelIds.size && (!parentId || !config.channelIds.has(parentId))) return;
|
|
122
|
-
|
|
149
|
+
// Only allowed users can call the bot (everyone's messages still count as context).
|
|
123
150
|
if (!config.allowedUserIds.has(message.author.id)) {
|
|
124
|
-
|
|
151
|
+
await message.reply("Sorry, you're not on this bot's allow list.");
|
|
125
152
|
return;
|
|
126
153
|
}
|
|
127
154
|
|
|
@@ -129,93 +156,114 @@ async function onMessage(message: Message) {
|
|
|
129
156
|
.replace(new RegExp(`<@!?${client.user.id}>`, "g"), "")
|
|
130
157
|
.replace(botRoleId ? new RegExp(`<@&${botRoleId}>`, "g") : /$^/, "")
|
|
131
158
|
.trim();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!text && images.length === 0) {
|
|
159
|
+
if (!text && message.attachments.size === 0) {
|
|
135
160
|
await message.reply(HELP);
|
|
136
161
|
return;
|
|
137
162
|
}
|
|
138
163
|
|
|
139
|
-
// Resolve (or create) the thread for this conversation.
|
|
140
|
-
let thread: ThreadChannel;
|
|
141
|
-
if (channel.isThread()) {
|
|
142
|
-
thread = channel;
|
|
143
|
-
ownThreads.add(thread.id);
|
|
144
|
-
} else if (channel.type === ChannelType.GuildText) {
|
|
145
|
-
thread = await message.startThread({
|
|
146
|
-
name: (text || "hoocode").replace(/\s+/g, " ").slice(0, 90),
|
|
147
|
-
autoArchiveDuration: 1440,
|
|
148
|
-
});
|
|
149
|
-
ownThreads.add(thread.id);
|
|
150
|
-
} else {
|
|
151
|
-
await message.reply("Mention me in a normal text channel and I'll open a thread.");
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
164
|
// Commands.
|
|
156
165
|
const [cmd, ...rest] = text.split(/\s+/);
|
|
157
166
|
const arg = rest.join(" ").trim();
|
|
158
167
|
if (cmd?.toLowerCase() === "!help") {
|
|
159
|
-
await
|
|
168
|
+
await message.reply(HELP);
|
|
160
169
|
return;
|
|
161
170
|
}
|
|
171
|
+
const space = channel as TextChannel | ThreadChannel;
|
|
162
172
|
let session: ThreadSession;
|
|
163
173
|
try {
|
|
164
|
-
session = await getSession(
|
|
174
|
+
session = await getSession(space, workdirFor(parentId));
|
|
165
175
|
} catch (err) {
|
|
166
|
-
await
|
|
176
|
+
await message.reply(`**Can't reach hoocode:** ${err instanceof Error ? err.message : String(err)}`);
|
|
167
177
|
return;
|
|
168
178
|
}
|
|
169
179
|
switch (cmd?.toLowerCase()) {
|
|
170
180
|
case "!stop":
|
|
171
|
-
|
|
172
|
-
return;
|
|
181
|
+
return session.abort();
|
|
173
182
|
case "!new":
|
|
174
|
-
|
|
175
|
-
return;
|
|
183
|
+
clearSpace(session.workdir, space.id);
|
|
184
|
+
return session.newSession();
|
|
176
185
|
case "!status":
|
|
177
|
-
|
|
178
|
-
|
|
186
|
+
return session.status();
|
|
187
|
+
case "!verbose":
|
|
188
|
+
return session.toggleVerbose();
|
|
179
189
|
case "!model":
|
|
180
|
-
|
|
181
|
-
else await session.setModel(arg);
|
|
182
|
-
return;
|
|
190
|
+
return session.chooseModel(arg);
|
|
183
191
|
}
|
|
184
192
|
|
|
185
|
-
|
|
193
|
+
// One call at a time per space, so read positions advance in order.
|
|
194
|
+
await inOrder(space.id, async () => {
|
|
195
|
+
// What was said here since the conversation last read, plus the message replied to.
|
|
196
|
+
const { linked, seen } = await session.readState();
|
|
197
|
+
const [context, reply] = await Promise.all([
|
|
198
|
+
gatherContext({ space: space as unknown as SpaceLike, before: message.id, botId: client.user!.id, linked, seen }),
|
|
199
|
+
fetchReplyTo(message, client.user!.id),
|
|
200
|
+
]);
|
|
201
|
+
// Files on the calling message and on the message it replies to are
|
|
202
|
+
// saved in the work folder; small text ones are pasted in too.
|
|
203
|
+
const saved: SavedFile[] = [];
|
|
204
|
+
const skipped: SkippedFile[] = [];
|
|
205
|
+
for (const m of [reply?.message, message]) {
|
|
206
|
+
if (!m?.attachments.size) continue;
|
|
207
|
+
const r = await saveAttachments({
|
|
208
|
+
workdir: session.workdir,
|
|
209
|
+
spaceId: space.id,
|
|
210
|
+
messageId: m.id,
|
|
211
|
+
author: authorName(m),
|
|
212
|
+
attachments: m.attachments.values(),
|
|
213
|
+
});
|
|
214
|
+
saved.push(...r.saved);
|
|
215
|
+
skipped.push(...r.skipped);
|
|
216
|
+
}
|
|
217
|
+
const prompt = buildPrompt({
|
|
218
|
+
context,
|
|
219
|
+
replyTo: reply?.context,
|
|
220
|
+
attachments: formatAttachments(saved, skipped),
|
|
221
|
+
author: authorName(message),
|
|
222
|
+
text: text || "(see the attached files)",
|
|
223
|
+
});
|
|
224
|
+
if (await session.prompt(prompt, imageInputs(saved), message)) session.markSeen(message.id);
|
|
225
|
+
});
|
|
186
226
|
}
|
|
187
227
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return
|
|
228
|
+
/** Per-space promise chains. */
|
|
229
|
+
const lanes = new Map<string, Promise<unknown>>();
|
|
230
|
+
function inOrder(key: string, fn: () => Promise<unknown>): Promise<unknown> {
|
|
231
|
+
const next = (lanes.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
232
|
+
const tail = next.catch(() => {});
|
|
233
|
+
lanes.set(key, tail);
|
|
234
|
+
tail.then(() => lanes.get(key) === tail && lanes.delete(key));
|
|
235
|
+
return next;
|
|
196
236
|
}
|
|
197
237
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
238
|
+
/**
|
|
239
|
+
* The message `message` replies to, unless it's the bot's own (already in the
|
|
240
|
+
* conversation, and its files are already in the work folder).
|
|
241
|
+
*/
|
|
242
|
+
async function fetchReplyTo(message: Message, botId: string): Promise<{ message: Message; context: ContextMessage | null } | null> {
|
|
243
|
+
if (!message.reference?.messageId || message.mentions.repliedUser?.id === botId) return null;
|
|
244
|
+
try {
|
|
245
|
+
const ref = await message.fetchReference();
|
|
246
|
+
if (ref.author.id === botId) return null;
|
|
247
|
+
return { message: ref, context: toContext(ref, botId) };
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
201
251
|
}
|
|
202
252
|
|
|
203
|
-
async function
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!res.ok) continue;
|
|
210
|
-
out.push({ data: Buffer.from(await res.arrayBuffer()).toString("base64"), mimeType: mime });
|
|
253
|
+
async function getSession(space: TextChannel | ThreadChannel, workdir: string): Promise<ThreadSession> {
|
|
254
|
+
const client = await appServer(workdir);
|
|
255
|
+
let s = sessions.get(space.id);
|
|
256
|
+
if (!s) {
|
|
257
|
+
s = new ThreadSession(space, client, links, (id) => sessions.delete(id), workdir);
|
|
258
|
+
sessions.set(space.id, s);
|
|
211
259
|
}
|
|
212
|
-
return
|
|
260
|
+
return s;
|
|
213
261
|
}
|
|
214
262
|
|
|
215
263
|
function shutdown() {
|
|
216
264
|
console.log("Shutting down…");
|
|
217
265
|
for (const s of [...sessions.values()]) s.close();
|
|
218
|
-
app
|
|
266
|
+
for (const app of apps.values()) app.then((c) => c.close()).catch(() => {});
|
|
219
267
|
client.destroy().finally(() => process.exit(0));
|
|
220
268
|
}
|
|
221
269
|
process.on("SIGINT", shutdown);
|
package/src/links.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
7
|
|
|
8
|
-
export type Link = {
|
|
8
|
+
export type Link = {
|
|
9
|
+
threadId: string;
|
|
10
|
+
/** Model picked with `!model` for this space; sent on every turn. */
|
|
11
|
+
model?: string;
|
|
12
|
+
/** Last Discord message this conversation has read (context starts after it). */
|
|
13
|
+
seen?: string;
|
|
14
|
+
};
|
|
9
15
|
|
|
10
16
|
export class LinkStore {
|
|
11
17
|
private links: Record<string, Link> = {};
|