@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/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * hoo-discord-bot: talk to hoocode from Discord.
3
+ * hoobot: talk to hoocode from Discord.
4
4
  *
5
- * - Mention the bot in a channel → it opens a thread and answers there.
6
- * - Every message in that thread goes to the same hoocode session.
7
- * - Commands inside a thread: !stop !new !status !model <pattern> !help
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
- let app: Promise<CodexClient> | null = null;
51
+ /** One app-server per folder, started on first use. */
52
+ const apps = new Map<string, Promise<CodexClient>>();
35
53
 
36
- /** The shared app-server connection; reconnects after it drops. */
37
- function appServer(): Promise<CodexClient> {
38
- app ??= (async () => {
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: config.workdir },
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 connection closed: ${reason}`);
48
- app = null;
49
- // Sessions hold the old client; drop them. Threads resume on the next message.
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
- app = null;
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
- "**Start:** mention me with a request. I'll open a thread.",
76
- "**Continue:** just type in the thread.",
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 (inside a thread)**",
99
+ "**Commands** (for this channel or thread)",
79
100
  "- `!stop`: stop the current run",
80
- "- `!new`: forget this conversation, start fresh",
101
+ "- `!new`: start a fresh conversation here (for everyone; deletes files sent here)",
81
102
  "- `!status`: model, busy or not, folder",
82
- "- `!model <name>`: switch model from the next message, e.g. `!model anthropic/claude-sonnet-4-5`",
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
- "Typing while I'm busy steers the current run.",
86
- "Commands that change files or run shell ask for approval with buttons.",
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
- const inOwnThread = channel.isThread() && isOwnThread(channel);
110
- // Discord's autocomplete often picks the bot's auto-created *role* (also
111
- // named after the bot) instead of the bot user, so accept either.
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 mentioned =
142
+ const called =
114
143
  message.mentions.users.has(client.user.id) ||
115
- (!!botRoleId && message.mentions.roles.has(botRoleId));
116
-
117
- if (!inOwnThread && !mentioned) return;
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
- const parentId = channel.isThread() ? channel.parentId : channel.id;
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
- if (mentioned) await message.reply("Sorry, you're not on this bot's allow list.");
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
- const images = await imageAttachments(message);
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 thread.send(HELP);
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(thread);
174
+ session = await getSession(space, workdirFor(parentId));
165
175
  } catch (err) {
166
- await thread.send(`**Can't reach hoocode:** ${err instanceof Error ? err.message : String(err)}`);
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
- await session.abort();
172
- return;
181
+ return session.abort();
173
182
  case "!new":
174
- await session.newSession();
175
- return;
183
+ clearSpace(session.workdir, space.id);
184
+ return session.newSession();
176
185
  case "!status":
177
- await session.status();
178
- return;
186
+ return session.status();
187
+ case "!verbose":
188
+ return session.toggleVerbose();
179
189
  case "!model":
180
- if (!arg) await thread.send("Usage: `!model provider/model-id`");
181
- else await session.setModel(arg);
182
- return;
190
+ return session.chooseModel(arg);
183
191
  }
184
192
 
185
- await session.prompt(text || "(see attached image)", images);
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
- async function getSession(thread: ThreadChannel): Promise<ThreadSession> {
189
- const client = await appServer();
190
- let s = sessions.get(thread.id);
191
- if (!s) {
192
- s = new ThreadSession(thread, client, links, (id) => sessions.delete(id));
193
- sessions.set(thread.id, s);
194
- }
195
- return s;
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
- /** A thread counts as ours if we created it or already hold a session for it. */
199
- function isOwnThread(thread: ThreadChannel): boolean {
200
- return ownThreads.has(thread.id) || sessions.has(thread.id) || thread.ownerId === client.user?.id;
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 imageAttachments(message: Message) {
204
- const out: { data: string; mimeType: string }[] = [];
205
- for (const att of message.attachments.values()) {
206
- const mime = att.contentType?.split(";")[0] ?? "";
207
- if (!mime.startsWith("image/") || att.size > 5_000_000) continue;
208
- const res = await fetch(att.url);
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 out;
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?.then((c) => c.close()).catch(() => {});
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 = { threadId: string };
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> = {};