@kolisachint/hoobot 0.0.3 → 0.0.4

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,16 @@ 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";
22
26
 
23
- prepareWorkspace();
27
+ for (const dir of allWorkdirs()) prepareWorkspace(dir);
24
28
 
25
29
  const links = new LinkStore(config.linksFile);
26
30
  const sessions = new Map<string, ThreadSession>();
@@ -31,35 +35,36 @@ function endpoint(): string {
31
35
  return `stdio:${config.hoocodeBin} app-server ${config.hoocodeArgs.join(" ")}`;
32
36
  }
33
37
 
34
- let app: Promise<CodexClient> | null = null;
38
+ /** One app-server per folder, started on first use. */
39
+ const apps = new Map<string, Promise<CodexClient>>();
35
40
 
36
- /** The shared app-server connection; reconnects after it drops. */
37
- function appServer(): Promise<CodexClient> {
38
- app ??= (async () => {
41
+ /** The app-server for `workdir`; reconnects after it drops. */
42
+ function appServer(workdir: string): Promise<CodexClient> {
43
+ let app = apps.get(workdir);
44
+ if (app) return app;
45
+ app = (async () => {
39
46
  // A stdio server runs in the work folder; a socket server has its own.
40
47
  const client = await CodexClient.connect(
41
48
  endpoint(),
42
49
  { name: "hoobot", version: "0.1.0" },
43
- { cwd: config.workdir },
50
+ { cwd: workdir },
44
51
  );
45
- console.log(`Connected to app-server ${client.endpoint}`);
52
+ console.log(`Connected to app-server ${client.endpoint} in ${workdir}`);
46
53
  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();
54
+ console.error(`app-server for ${workdir} closed: ${reason}`);
55
+ apps.delete(workdir);
56
+ // Its sessions hold the old client; drop them. Threads resume on the next message.
57
+ for (const s of [...sessions.values()]) if (s.workdir === workdir) s.close();
51
58
  });
52
59
  return client;
53
60
  })().catch((err) => {
54
- console.error(`Can't reach app-server: ${err instanceof Error ? err.message : String(err)}`);
55
- app = null;
61
+ console.error(`Can't reach app-server for ${workdir}: ${err instanceof Error ? err.message : String(err)}`);
62
+ apps.delete(workdir);
56
63
  throw err;
57
64
  });
65
+ apps.set(workdir, app);
58
66
  return app;
59
67
  }
60
- /** Threads this bot created, so it only listens in its own threads. */
61
- const ownThreads = new Set<string>();
62
-
63
68
  const client = new Client({
64
69
  intents: [
65
70
  GatewayIntentBits.Guilds,
@@ -72,23 +77,30 @@ const client = new Client({
72
77
  const HELP = [
73
78
  "**hoo: hoocode in Discord**",
74
79
  "",
75
- "**Start:** mention me with a request. I'll open a thread.",
76
- "**Continue:** just type in the thread.",
80
+ "**Call me:** mention me, or reply to one of my messages. I answer right here.",
81
+ "Every channel and thread is a shared space: anyone can talk, and when I'm called",
82
+ "I read what was said since I last looked (up to 30 messages).",
83
+ "Open a thread for a side task; it has its own conversation in the same folder.",
77
84
  "",
78
- "**Commands (inside a thread)**",
85
+ "**Commands** (for this channel or thread)",
79
86
  "- `!stop`: stop the current run",
80
- "- `!new`: forget this conversation, start fresh",
87
+ "- `!new`: start a fresh conversation here (for everyone)",
81
88
  "- `!status`: model, busy or not, folder",
82
- "- `!model <name>`: switch model from the next message, e.g. `!model anthropic/claude-sonnet-4-5`",
89
+ "- `!model`: pick the model here from a list (from the next message; the conversation carries on)",
90
+ "- `!model <part of name>`: pick it directly, e.g. `!model kimi`",
91
+ "- `!verbose`: show every step here (again to turn off)",
83
92
  "- `!help`: this message",
84
93
  "",
85
- "Typing while I'm busy steers the current run.",
86
- "Commands that change files or run shell ask for approval with buttons.",
94
+ "Calling me while I'm busy steers the current run.",
95
+ "While I work you see one status line; then the answer with a short",
96
+ "summary (PR, commits, files, steps, time, model).",
97
+ ...(config.approvals === "ask" ? ["Commands that change files or run shell ask for approval with buttons."] : []),
87
98
  ].join("\n");
88
99
 
89
100
  client.once(Events.ClientReady, (c) => {
90
101
  console.log(`Logged in as ${c.user.tag}`);
91
102
  console.log(`Working folder: ${config.workdir}`);
103
+ for (const [channel, dir] of config.workspaces) console.log(` channel ${channel} → ${dir}`);
92
104
  console.log(`Allowed users: ${[...config.allowedUserIds].join(", ")}`);
93
105
  });
94
106
 
@@ -106,22 +118,23 @@ async function onMessage(message: Message) {
106
118
  if (config.guildId && message.guildId !== config.guildId) return;
107
119
 
108
120
  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.
121
+ if (channel.type !== ChannelType.GuildText && !channel.isThread()) return;
122
+ const parentId = channel.isThread() ? channel.parentId : channel.id;
123
+ if (config.channelIds.size && (!parentId || !(config.channelIds.has(parentId) || config.workspaces.has(parentId)))) return;
124
+
125
+ // Called by a mention (the bot user, or its auto-created role that Discord's
126
+ // autocomplete often picks), or by replying to one of the bot's messages.
112
127
  const botRoleId = message.guild?.members.me?.roles.botRole?.id;
113
- const mentioned =
128
+ const called =
114
129
  message.mentions.users.has(client.user.id) ||
115
- (!!botRoleId && message.mentions.roles.has(botRoleId));
116
-
117
- if (!inOwnThread && !mentioned) return;
130
+ (!!botRoleId && message.mentions.roles.has(botRoleId)) ||
131
+ message.mentions.repliedUser?.id === client.user.id;
132
+ if (!called) return;
118
133
  console.log(`[msg] ${message.author.username} (${message.author.id}) in ${channel.id}: ${message.content.slice(0, 80)}`);
119
134
 
120
- const parentId = channel.isThread() ? channel.parentId : channel.id;
121
- if (config.channelIds.size && (!parentId || !config.channelIds.has(parentId))) return;
122
-
135
+ // Only allowed users can call the bot (everyone's messages still count as context).
123
136
  if (!config.allowedUserIds.has(message.author.id)) {
124
- if (mentioned) await message.reply("Sorry, you're not on this bot's allow list.");
137
+ await message.reply("Sorry, you're not on this bot's allow list.");
125
138
  return;
126
139
  }
127
140
 
@@ -136,68 +149,80 @@ async function onMessage(message: Message) {
136
149
  return;
137
150
  }
138
151
 
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
152
  // Commands.
156
153
  const [cmd, ...rest] = text.split(/\s+/);
157
154
  const arg = rest.join(" ").trim();
158
155
  if (cmd?.toLowerCase() === "!help") {
159
- await thread.send(HELP);
156
+ await message.reply(HELP);
160
157
  return;
161
158
  }
159
+ const space = channel as TextChannel | ThreadChannel;
162
160
  let session: ThreadSession;
163
161
  try {
164
- session = await getSession(thread);
162
+ session = await getSession(space, workdirFor(parentId));
165
163
  } catch (err) {
166
- await thread.send(`**Can't reach hoocode:** ${err instanceof Error ? err.message : String(err)}`);
164
+ await message.reply(`**Can't reach hoocode:** ${err instanceof Error ? err.message : String(err)}`);
167
165
  return;
168
166
  }
169
167
  switch (cmd?.toLowerCase()) {
170
168
  case "!stop":
171
- await session.abort();
172
- return;
169
+ return session.abort();
173
170
  case "!new":
174
- await session.newSession();
175
- return;
171
+ return session.newSession();
176
172
  case "!status":
177
- await session.status();
178
- return;
173
+ return session.status();
174
+ case "!verbose":
175
+ return session.toggleVerbose();
179
176
  case "!model":
180
- if (!arg) await thread.send("Usage: `!model provider/model-id`");
181
- else await session.setModel(arg);
182
- return;
177
+ return session.chooseModel(arg);
183
178
  }
184
179
 
185
- await session.prompt(text || "(see attached image)", images);
180
+ // One call at a time per space, so read positions advance in order.
181
+ await inOrder(space.id, async () => {
182
+ // What was said here since the conversation last read, plus the message replied to.
183
+ const { linked, seen } = await session.readState();
184
+ const [context, replyTo] = await Promise.all([
185
+ gatherContext({ space: space as unknown as SpaceLike, before: message.id, botId: client.user!.id, linked, seen }),
186
+ fetchReplyTo(message, client.user!.id),
187
+ ]);
188
+ const prompt = buildPrompt({
189
+ context,
190
+ replyTo,
191
+ author: authorName(message),
192
+ text: text || "(see attached image)",
193
+ });
194
+ if (await session.prompt(prompt, images, message)) session.markSeen(message.id);
195
+ });
186
196
  }
187
197
 
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);
198
+ /** Per-space promise chains. */
199
+ const lanes = new Map<string, Promise<unknown>>();
200
+ function inOrder(key: string, fn: () => Promise<unknown>): Promise<unknown> {
201
+ const next = (lanes.get(key) ?? Promise.resolve()).then(fn, fn);
202
+ const tail = next.catch(() => {});
203
+ lanes.set(key, tail);
204
+ tail.then(() => lanes.get(key) === tail && lanes.delete(key));
205
+ return next;
206
+ }
207
+
208
+ /** The message `message` replies to, unless it's the bot's own (already in the conversation). */
209
+ async function fetchReplyTo(message: Message, botId: string): Promise<ContextMessage | null> {
210
+ if (!message.reference?.messageId || message.mentions.repliedUser?.id === botId) return null;
211
+ try {
212
+ return toContext(await message.fetchReference(), botId);
213
+ } catch {
214
+ return null;
194
215
  }
195
- return s;
196
216
  }
197
217
 
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;
218
+ async function getSession(space: TextChannel | ThreadChannel, workdir: string): Promise<ThreadSession> {
219
+ const client = await appServer(workdir);
220
+ let s = sessions.get(space.id);
221
+ if (!s) {
222
+ s = new ThreadSession(space, client, links, (id) => sessions.delete(id), workdir);
223
+ sessions.set(space.id, s);
224
+ }
225
+ return s;
201
226
  }
202
227
 
203
228
  async function imageAttachments(message: Message) {
@@ -215,7 +240,7 @@ async function imageAttachments(message: Message) {
215
240
  function shutdown() {
216
241
  console.log("Shutting down…");
217
242
  for (const s of [...sessions.values()]) s.close();
218
- app?.then((c) => c.close()).catch(() => {});
243
+ for (const app of apps.values()) app.then((c) => c.close()).catch(() => {});
219
244
  client.destroy().finally(() => process.exit(0));
220
245
  }
221
246
  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> = {};