@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/.env.example +12 -1
- package/README.md +60 -27
- package/package.json +3 -3
- package/src/config.ts +115 -43
- package/src/context.ts +160 -0
- package/src/index.ts +104 -79
- package/src/links.ts +7 -1
- package/src/session.ts +248 -23
- package/src/summary.ts +117 -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,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
|
-
|
|
38
|
+
/** One app-server per folder, started on first use. */
|
|
39
|
+
const apps = new Map<string, Promise<CodexClient>>();
|
|
35
40
|
|
|
36
|
-
/** The
|
|
37
|
-
function appServer(): Promise<CodexClient> {
|
|
38
|
-
app
|
|
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:
|
|
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
|
|
48
|
-
|
|
49
|
-
//
|
|
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
|
-
|
|
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
|
-
"**
|
|
76
|
-
"
|
|
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 (
|
|
85
|
+
"**Commands** (for this channel or thread)",
|
|
79
86
|
"- `!stop`: stop the current run",
|
|
80
|
-
"- `!new`:
|
|
87
|
+
"- `!new`: start a fresh conversation here (for everyone)",
|
|
81
88
|
"- `!status`: model, busy or not, folder",
|
|
82
|
-
"- `!model
|
|
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
|
-
"
|
|
86
|
-
"
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
128
|
+
const called =
|
|
114
129
|
message.mentions.users.has(client.user.id) ||
|
|
115
|
-
(!!botRoleId && message.mentions.roles.has(botRoleId))
|
|
116
|
-
|
|
117
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
162
|
+
session = await getSession(space, workdirFor(parentId));
|
|
165
163
|
} catch (err) {
|
|
166
|
-
await
|
|
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
|
-
|
|
172
|
-
return;
|
|
169
|
+
return session.abort();
|
|
173
170
|
case "!new":
|
|
174
|
-
|
|
175
|
-
return;
|
|
171
|
+
return session.newSession();
|
|
176
172
|
case "!status":
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
return session.status();
|
|
174
|
+
case "!verbose":
|
|
175
|
+
return session.toggleVerbose();
|
|
179
176
|
case "!model":
|
|
180
|
-
|
|
181
|
-
else await session.setModel(arg);
|
|
182
|
-
return;
|
|
177
|
+
return session.chooseModel(arg);
|
|
183
178
|
}
|
|
184
179
|
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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 = {
|
|
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> = {};
|