@qearlyao/familiar 0.2.5 → 0.3.0
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/README.md +4 -0
- package/config.example.toml +2 -2
- package/dist/agent/payload-normalizers.js +52 -0
- package/dist/agent/session-helpers.js +86 -0
- package/dist/agent/tool-descriptions.js +4 -0
- package/dist/agent/tools.js +30 -0
- package/dist/agent/transcript-log.js +93 -0
- package/dist/agent/types.js +1 -0
- package/dist/agent-core.js +82 -0
- package/dist/agent-work-queue.js +55 -0
- package/dist/agent.js +91 -322
- package/dist/browser-tools.js +7 -8
- package/dist/chat-log.js +15 -3
- package/dist/cli.js +36 -6
- package/dist/config/enums.js +35 -0
- package/dist/config/interpolate.js +15 -0
- package/dist/config/model-refs.js +11 -0
- package/dist/config/readers.js +116 -0
- package/dist/config/sections.js +113 -0
- package/dist/config/types.js +1 -0
- package/dist/config-registry.js +26 -7
- package/dist/config.js +8 -271
- package/dist/discord/channel.js +32 -0
- package/dist/discord/chunking.js +163 -0
- package/dist/discord/client.js +44 -0
- package/dist/discord/commands.js +181 -0
- package/dist/discord/inbound.js +44 -0
- package/dist/discord/send.js +106 -0
- package/dist/discord/turn.js +55 -0
- package/dist/discord.js +266 -1186
- package/dist/ids.js +11 -0
- package/dist/index.js +1 -0
- package/dist/memory/index/store.js +21 -17
- package/dist/memory/index/vector-codec.js +2 -2
- package/dist/memory/lcm/context-transformer.js +6 -2
- package/dist/memory/lcm/segment-manager.js +6 -2
- package/dist/memory/lcm/store/index-ids.js +6 -0
- package/dist/memory/lcm/store/inserts.js +31 -0
- package/dist/memory/lcm/store/normalizers.js +91 -0
- package/dist/memory/lcm/store/row-mappers.js +114 -0
- package/dist/memory/lcm/store/row-types.js +1 -0
- package/dist/memory/lcm/store/serialization.js +37 -0
- package/dist/memory/lcm/store/snapshots.js +73 -0
- package/dist/memory/lcm/store.js +20 -360
- package/dist/owner-identity.js +29 -0
- package/dist/runtime-manager.js +51 -0
- package/dist/runtime.js +89 -41
- package/dist/scheduler-runner.js +243 -0
- package/dist/scheduler.js +1 -1
- package/dist/service.js +1 -0
- package/dist/settings.js +3 -0
- package/dist/web/event-hub.js +246 -0
- package/dist/{web-http.js → web/http.js} +19 -5
- package/dist/web/memes.js +25 -0
- package/dist/web/messages.js +345 -0
- package/dist/web/multipart.js +80 -0
- package/dist/web/payloads.js +34 -0
- package/dist/{web-static.js → web/static.js} +19 -14
- package/dist/web/stream.js +69 -0
- package/dist/web-tools/cache.js +42 -0
- package/dist/web-tools/config.js +16 -0
- package/dist/web-tools/fetch-providers.js +119 -0
- package/dist/web-tools/format.js +88 -0
- package/dist/web-tools/http.js +81 -0
- package/dist/web-tools/routing.js +29 -0
- package/dist/web-tools/safety.js +73 -0
- package/dist/web-tools/search-providers.js +277 -0
- package/dist/web-tools/types.js +54 -0
- package/dist/web-tools/util.js +23 -0
- package/dist/web-tools.js +9 -798
- package/dist/web.js +416 -984
- package/npm-shrinkwrap.json +242 -201
- package/package.json +4 -4
- package/web/dist/assets/index-CSkxUQCr.js +63 -0
- package/web/dist/assets/index-DllM6RqL.css +2 -0
- package/web/dist/index.html +6 -3
- package/web/dist/assets/index-B23WT77N.js +0 -63
- package/web/dist/assets/index-D3MotFzN.css +0 -2
- /package/dist/{web-auth.js → web/auth.js} +0 -0
- /package/dist/{web-events.js → web/events.js} +0 -0
- /package/dist/{web-types.js → web/types.js} +0 -0
package/dist/discord.js
CHANGED
|
@@ -1,585 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
const
|
|
13
|
-
const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
14
|
-
const CHANNEL_TRIGGER_CHOICES = ["mention", "always"];
|
|
15
|
-
const EPHEMERAL_REPLY = MessageFlags.Ephemeral;
|
|
16
|
-
const HEARTBEAT_SKIPPED = Symbol("heartbeat-skipped");
|
|
17
|
-
const CRON_SKIPPED = Symbol("cron-skipped");
|
|
18
|
-
const DISCORD_ATTACHMENT_SEND_TIMEOUT_MS = 20_000;
|
|
19
|
-
async function withReadyClient(token) {
|
|
20
|
-
const client = new Client({
|
|
21
|
-
intents: [
|
|
22
|
-
GatewayIntentBits.Guilds,
|
|
23
|
-
GatewayIntentBits.GuildMessages,
|
|
24
|
-
GatewayIntentBits.DirectMessages,
|
|
25
|
-
GatewayIntentBits.MessageContent,
|
|
26
|
-
],
|
|
27
|
-
partials: [Partials.Channel],
|
|
28
|
-
});
|
|
29
|
-
const readyPromise = once(client, Events.ClientReady);
|
|
30
|
-
try {
|
|
31
|
-
await client.login(token);
|
|
32
|
-
}
|
|
33
|
-
catch (error) {
|
|
34
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
-
if (message.includes("Used disallowed intents")) {
|
|
36
|
-
throw new Error('Discord rejected the configured gateway intents. Enable the "Message Content Intent" in the Discord Developer Portal.');
|
|
37
|
-
}
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
if (!client.isReady()) {
|
|
41
|
-
await Promise.race([
|
|
42
|
-
readyPromise,
|
|
43
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error("Discord client failed to become ready")), 10000)),
|
|
44
|
-
]);
|
|
45
|
-
}
|
|
46
|
-
if (!client.isReady())
|
|
47
|
-
throw new Error("Discord client failed to become ready");
|
|
48
|
-
return client;
|
|
49
|
-
}
|
|
50
|
-
function getFamiliarApplicationCommand() {
|
|
51
|
-
const modelOption = {
|
|
52
|
-
name: "model",
|
|
53
|
-
description: "Provider/model id",
|
|
54
|
-
type: ApplicationCommandOptionType.String,
|
|
55
|
-
required: false,
|
|
56
|
-
autocomplete: true,
|
|
57
|
-
};
|
|
58
|
-
return {
|
|
59
|
-
name: FAMILIAR_COMMAND_NAME,
|
|
60
|
-
description: "Control Familiar",
|
|
61
|
-
type: ApplicationCommandType.ChatInput,
|
|
62
|
-
contexts: [InteractionContextType.Guild, InteractionContextType.BotDM],
|
|
63
|
-
integrationTypes: [ApplicationIntegrationType.GuildInstall],
|
|
64
|
-
options: [
|
|
65
|
-
{
|
|
66
|
-
name: "status",
|
|
67
|
-
description: "Show Familiar status for this channel",
|
|
68
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
name: "stop",
|
|
72
|
-
description: "Stop current work and clear the queue",
|
|
73
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
name: "new",
|
|
77
|
-
description: "Start a fresh agent transcript for this channel",
|
|
78
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
name: "reload",
|
|
82
|
-
description: "Reload persona prompt files and live agent settings",
|
|
83
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
name: "restart",
|
|
87
|
-
description: "Restart Familiar if this runtime has a restart handler",
|
|
88
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
name: "compact",
|
|
92
|
-
description: "Show compaction status",
|
|
93
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
name: "model",
|
|
97
|
-
description: "Show or set the model for this channel",
|
|
98
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
99
|
-
options: [modelOption],
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
name: "thinking",
|
|
103
|
-
description: "Show or set thinking level for this channel",
|
|
104
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
105
|
-
options: [
|
|
106
|
-
{
|
|
107
|
-
name: "level",
|
|
108
|
-
description: "Thinking level",
|
|
109
|
-
type: ApplicationCommandOptionType.String,
|
|
110
|
-
required: false,
|
|
111
|
-
choices: THINKING_CHOICES.map((level) => ({ name: level, value: level })),
|
|
112
|
-
},
|
|
113
|
-
],
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
name: "channel-trigger",
|
|
117
|
-
description: "Show or set when Familiar responds in this channel",
|
|
118
|
-
type: ApplicationCommandOptionType.Subcommand,
|
|
119
|
-
options: [
|
|
120
|
-
{
|
|
121
|
-
name: "trigger",
|
|
122
|
-
description: "Channel trigger policy",
|
|
123
|
-
type: ApplicationCommandOptionType.String,
|
|
124
|
-
required: false,
|
|
125
|
-
choices: CHANNEL_TRIGGER_CHOICES.map((trigger) => ({ name: trigger, value: trigger })),
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
},
|
|
129
|
-
],
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
async function registerFamiliarApplicationCommand(client) {
|
|
133
|
-
const command = getFamiliarApplicationCommand();
|
|
134
|
-
const commands = await client.application.commands.fetch({ force: true });
|
|
135
|
-
const existing = commands.find((candidate) => candidate.name === FAMILIAR_COMMAND_NAME && candidate.type === ApplicationCommandType.ChatInput);
|
|
136
|
-
if (existing) {
|
|
137
|
-
if (!existing.equals(command)) {
|
|
138
|
-
await client.application.commands.edit(existing.id, command);
|
|
139
|
-
console.log(`Updated Discord /${FAMILIAR_COMMAND_NAME} command`);
|
|
140
|
-
}
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
await client.application.commands.create(command);
|
|
144
|
-
console.log(`Registered Discord /${FAMILIAR_COMMAND_NAME} command`);
|
|
145
|
-
}
|
|
146
|
-
function isAllowedMessage(config, message, botUserId) {
|
|
147
|
-
if (message.author.id === botUserId)
|
|
148
|
-
return false;
|
|
149
|
-
if (message.author.bot && !config.discord.allowBotMessages)
|
|
150
|
-
return false;
|
|
151
|
-
if (message.channel.type === ChannelType.DM && message.author.id !== config.discord.ownerId)
|
|
152
|
-
return false;
|
|
153
|
-
if (message.channel.type === ChannelType.DM)
|
|
154
|
-
return true;
|
|
155
|
-
return config.discord.allowedChannels.includes(message.channelId);
|
|
156
|
-
}
|
|
157
|
-
function isAllowedInteractionChannel(config, interaction) {
|
|
158
|
-
if (interaction.user.id !== config.discord.ownerId)
|
|
159
|
-
return false;
|
|
160
|
-
const channel = interaction.channel;
|
|
161
|
-
if (!channel)
|
|
162
|
-
return false;
|
|
163
|
-
if (channel.type === ChannelType.DM)
|
|
164
|
-
return true;
|
|
165
|
-
return interaction.channelId ? config.discord.allowedChannels.includes(interaction.channelId) : false;
|
|
166
|
-
}
|
|
167
|
-
const NEWLINE_BURST_DELAY_MS = 500;
|
|
168
|
-
function chunkDiscordSimple(text, limit = 2000) {
|
|
169
|
-
if (text.length <= limit)
|
|
170
|
-
return [text || "(empty response)"];
|
|
171
|
-
const chunks = [];
|
|
172
|
-
let remaining = text;
|
|
173
|
-
while (remaining.length > 0) {
|
|
174
|
-
if (remaining.length <= limit) {
|
|
175
|
-
chunks.push(remaining);
|
|
176
|
-
break;
|
|
177
|
-
}
|
|
178
|
-
const breakpoint = Math.max(remaining.lastIndexOf("\n", limit), remaining.lastIndexOf(" ", limit));
|
|
179
|
-
const splitAt = breakpoint > 0 ? breakpoint : limit;
|
|
180
|
-
chunks.push(remaining.slice(0, splitAt));
|
|
181
|
-
remaining = remaining.slice(splitAt).trimStart();
|
|
182
|
-
}
|
|
183
|
-
return chunks;
|
|
184
|
-
}
|
|
185
|
-
function splitLongBlock(block, limit) {
|
|
186
|
-
if (block.length <= limit)
|
|
187
|
-
return [block];
|
|
188
|
-
const pieces = [];
|
|
189
|
-
let lineCurrent = "";
|
|
190
|
-
for (const line of block.split("\n")) {
|
|
191
|
-
const candidate = lineCurrent ? `${lineCurrent}\n${line}` : line;
|
|
192
|
-
if (candidate.length <= limit) {
|
|
193
|
-
lineCurrent = candidate;
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (lineCurrent) {
|
|
197
|
-
pieces.push(lineCurrent);
|
|
198
|
-
lineCurrent = "";
|
|
199
|
-
}
|
|
200
|
-
if (line.length <= limit) {
|
|
201
|
-
lineCurrent = line;
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
let remaining = line;
|
|
205
|
-
while (remaining.length > limit) {
|
|
206
|
-
let splitAt = remaining.lastIndexOf(" ", limit);
|
|
207
|
-
if (splitAt < Math.floor(limit * 0.6))
|
|
208
|
-
splitAt = limit;
|
|
209
|
-
pieces.push(remaining.slice(0, splitAt));
|
|
210
|
-
remaining = remaining.slice(splitAt).trimStart();
|
|
211
|
-
}
|
|
212
|
-
lineCurrent = remaining;
|
|
213
|
-
}
|
|
214
|
-
if (lineCurrent)
|
|
215
|
-
pieces.push(lineCurrent);
|
|
216
|
-
return pieces;
|
|
217
|
-
}
|
|
218
|
-
function chunkDiscordParagraph(text, limit = 2000) {
|
|
219
|
-
if (text.length <= limit)
|
|
220
|
-
return [text || "(empty response)"];
|
|
221
|
-
const normalized = text.replace(/\r\n/g, "\n");
|
|
222
|
-
const paragraphs = normalized.split(/\n\n+/);
|
|
223
|
-
const chunks = [];
|
|
224
|
-
let current = "";
|
|
225
|
-
const pushCurrent = () => {
|
|
226
|
-
if (current.trim())
|
|
227
|
-
chunks.push(current);
|
|
228
|
-
current = "";
|
|
229
|
-
};
|
|
230
|
-
for (const paragraph of paragraphs) {
|
|
231
|
-
if (!paragraph)
|
|
232
|
-
continue;
|
|
233
|
-
for (const part of splitLongBlock(paragraph, limit)) {
|
|
234
|
-
const candidate = current ? `${current}\n\n${part}` : part;
|
|
235
|
-
if (candidate.length <= limit) {
|
|
236
|
-
current = candidate;
|
|
237
|
-
}
|
|
238
|
-
else {
|
|
239
|
-
pushCurrent();
|
|
240
|
-
current = part;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
pushCurrent();
|
|
245
|
-
return chunks.length > 0 ? chunks : [normalized.slice(0, limit)];
|
|
246
|
-
}
|
|
247
|
-
function splitPreservingCodeFences(text) {
|
|
248
|
-
const normalized = text.replace(/\r\n/g, "\n");
|
|
249
|
-
const segments = [];
|
|
250
|
-
const fence = /```/g;
|
|
251
|
-
let cursor = 0;
|
|
252
|
-
let inCode = false;
|
|
253
|
-
let buffer = "";
|
|
254
|
-
const flushParagraphs = (slab) => {
|
|
255
|
-
const parts = slab.split(/\n\n+/);
|
|
256
|
-
for (let i = 0; i < parts.length; i++) {
|
|
257
|
-
const part = parts[i];
|
|
258
|
-
if (i === 0) {
|
|
259
|
-
buffer += part;
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
if (buffer.trim())
|
|
263
|
-
segments.push(buffer);
|
|
264
|
-
buffer = part;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
let match;
|
|
269
|
-
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex iteration
|
|
270
|
-
while ((match = fence.exec(normalized)) !== null) {
|
|
271
|
-
const slab = normalized.slice(cursor, match.index);
|
|
272
|
-
if (inCode) {
|
|
273
|
-
buffer += slab + match[0];
|
|
274
|
-
inCode = false;
|
|
275
|
-
}
|
|
276
|
-
else {
|
|
277
|
-
flushParagraphs(slab);
|
|
278
|
-
buffer += match[0];
|
|
279
|
-
inCode = true;
|
|
280
|
-
}
|
|
281
|
-
cursor = match.index + match[0].length;
|
|
282
|
-
}
|
|
283
|
-
const tail = normalized.slice(cursor);
|
|
284
|
-
if (inCode) {
|
|
285
|
-
buffer += tail;
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
flushParagraphs(tail);
|
|
289
|
-
}
|
|
290
|
-
if (buffer.trim())
|
|
291
|
-
segments.push(buffer);
|
|
292
|
-
return segments.map((segment) => segment.trim()).filter((segment) => segment.length > 0);
|
|
293
|
-
}
|
|
294
|
-
function chunkDiscordNewline(text, limit = 2000) {
|
|
295
|
-
const segments = splitPreservingCodeFences(text);
|
|
296
|
-
if (segments.length === 0)
|
|
297
|
-
return [];
|
|
298
|
-
const chunks = [];
|
|
299
|
-
for (const segment of segments) {
|
|
300
|
-
if (segment.length <= limit) {
|
|
301
|
-
chunks.push(segment);
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
for (const part of splitLongBlock(segment, limit)) {
|
|
305
|
-
if (part.trim())
|
|
306
|
-
chunks.push(part);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return chunks;
|
|
310
|
-
}
|
|
311
|
-
function chunkDiscord(config, text) {
|
|
312
|
-
if (config.discord.chunkMode === "simple")
|
|
313
|
-
return chunkDiscordSimple(text);
|
|
314
|
-
if (config.discord.chunkMode === "newline")
|
|
315
|
-
return chunkDiscordNewline(text);
|
|
316
|
-
return chunkDiscordParagraph(text);
|
|
317
|
-
}
|
|
318
|
-
function sleep(ms) {
|
|
319
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
320
|
-
}
|
|
321
|
-
async function delayBetweenBurstChunks(config, channel) {
|
|
322
|
-
if (config.discord.chunkMode !== "newline")
|
|
323
|
-
return;
|
|
324
|
-
if (channel.isSendable()) {
|
|
325
|
-
void channel.sendTyping().catch(() => undefined);
|
|
326
|
-
}
|
|
327
|
-
await sleep(NEWLINE_BURST_DELAY_MS);
|
|
328
|
-
}
|
|
329
|
-
function normalizeOutboundText(text) {
|
|
330
|
-
return text.trim() || "(empty response)";
|
|
331
|
-
}
|
|
332
|
-
function fallbackMimeType(name) {
|
|
333
|
-
return extname(name).toLowerCase() === ".mp3" ? "audio/mpeg" : "application/octet-stream";
|
|
334
|
-
}
|
|
335
|
-
async function discordAttachmentPayload(attachment) {
|
|
336
|
-
if (!attachment.localPath)
|
|
337
|
-
return undefined;
|
|
338
|
-
const data = await readFile(attachment.localPath);
|
|
339
|
-
const bytes = new Uint8Array(data.byteLength);
|
|
340
|
-
bytes.set(data);
|
|
341
|
-
return {
|
|
342
|
-
bytes,
|
|
343
|
-
name: attachment.name,
|
|
344
|
-
mimeType: attachment.mimeType || fallbackMimeType(attachment.name),
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
async function discordAttachmentPayloads(attachments) {
|
|
348
|
-
const payloads = [];
|
|
349
|
-
for (const attachment of attachments) {
|
|
350
|
-
const payload = await discordAttachmentPayload(attachment);
|
|
351
|
-
if (payload)
|
|
352
|
-
payloads.push(payload);
|
|
353
|
-
}
|
|
354
|
-
return payloads;
|
|
355
|
-
}
|
|
356
|
-
async function postDiscordAttachments(config, channelId, attachments) {
|
|
357
|
-
const files = await discordAttachmentPayloads(attachments);
|
|
358
|
-
if (files.length === 0)
|
|
359
|
-
return [];
|
|
360
|
-
const form = new FormData();
|
|
361
|
-
form.set("payload_json", JSON.stringify({}));
|
|
362
|
-
for (const [index, file] of files.entries()) {
|
|
363
|
-
form.set(`files[${index}]`, new Blob([file.bytes], { type: file.mimeType }), file.name);
|
|
364
|
-
}
|
|
365
|
-
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
366
|
-
method: "POST",
|
|
367
|
-
headers: { Authorization: `Bot ${config.discord.token}` },
|
|
368
|
-
body: form,
|
|
369
|
-
});
|
|
370
|
-
const data = (await response.json().catch(() => ({})));
|
|
371
|
-
if (!response.ok || !data.id)
|
|
372
|
-
throw new Error(data.message || `Discord attachment send failed (${response.status})`);
|
|
373
|
-
return [data.id];
|
|
374
|
-
}
|
|
375
|
-
async function withDiscordSendTimeout(operation, label, timeoutMs = DISCORD_ATTACHMENT_SEND_TIMEOUT_MS) {
|
|
376
|
-
let timeout;
|
|
377
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
378
|
-
timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
379
|
-
});
|
|
380
|
-
try {
|
|
381
|
-
return await Promise.race([operation, timeoutPromise]);
|
|
382
|
-
}
|
|
383
|
-
finally {
|
|
384
|
-
if (timeout)
|
|
385
|
-
clearTimeout(timeout);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
export const __test = {
|
|
389
|
-
discordAttachmentPayloads,
|
|
390
|
-
postDiscordAttachments,
|
|
391
|
-
withDiscordSendTimeout,
|
|
392
|
-
};
|
|
393
|
-
function parseAgentReply(text) {
|
|
394
|
-
const parsed = parseSilentMarker(text);
|
|
395
|
-
if (parsed.silent)
|
|
396
|
-
return parsed;
|
|
397
|
-
return { text: normalizeOutboundText(parsed.text), silent: false };
|
|
398
|
-
}
|
|
399
|
-
async function sendReply(config, message, text, replyToMessageId, attachments = []) {
|
|
400
|
-
const normalizedText = normalizeOutboundText(text);
|
|
401
|
-
const chunks = chunkDiscord(config, normalizedText);
|
|
402
|
-
const sentIds = [];
|
|
403
|
-
for (const [index, chunk] of chunks.entries()) {
|
|
404
|
-
if (index > 0)
|
|
405
|
-
await delayBetweenBurstChunks(config, message.channel);
|
|
406
|
-
let sent;
|
|
407
|
-
if (index === 0 && config.discord.replyMode === "reply") {
|
|
408
|
-
try {
|
|
409
|
-
const replyTarget = replyToMessageId || message.id;
|
|
410
|
-
if (!message.channel.isSendable()) {
|
|
411
|
-
throw new Error(`Discord channel is not sendable: ${message.channelId}`);
|
|
412
|
-
}
|
|
413
|
-
const options = { content: chunk, reply: { messageReference: replyTarget } };
|
|
414
|
-
sent = await message.channel.send(options);
|
|
415
|
-
sentIds.push(sent.id);
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
catch (error) {
|
|
419
|
-
console.error("Discord reply failed; falling back to channel send", error);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
if (!message.channel.isSendable()) {
|
|
423
|
-
throw new Error(`Discord channel is not sendable: ${message.channelId}`);
|
|
424
|
-
}
|
|
425
|
-
sent = await message.channel.send(chunk);
|
|
426
|
-
sentIds.push(sent.id);
|
|
427
|
-
}
|
|
428
|
-
sendDiscordAttachmentsInBackground(config, message.channelId, attachments);
|
|
429
|
-
return sentIds;
|
|
430
|
-
}
|
|
431
|
-
async function sendChannelMessage(config, channel, text, attachments = []) {
|
|
432
|
-
if (!channel.isSendable()) {
|
|
433
|
-
throw new Error("Discord channel is not sendable");
|
|
434
|
-
}
|
|
435
|
-
const normalizedText = normalizeOutboundText(text);
|
|
436
|
-
const chunks = chunkDiscord(config, normalizedText);
|
|
437
|
-
const sentIds = [];
|
|
438
|
-
for (const [index, chunk] of chunks.entries()) {
|
|
439
|
-
if (index > 0)
|
|
440
|
-
await delayBetweenBurstChunks(config, channel);
|
|
441
|
-
const sent = await channel.send(chunk);
|
|
442
|
-
sentIds.push(sent.id);
|
|
443
|
-
}
|
|
444
|
-
sendDiscordAttachmentsInBackground(config, channel.id, attachments);
|
|
445
|
-
return sentIds;
|
|
446
|
-
}
|
|
447
|
-
function sendDiscordAttachmentsInBackground(config, channelId, attachments) {
|
|
448
|
-
if (attachments.length === 0)
|
|
449
|
-
return;
|
|
450
|
-
void withDiscordSendTimeout(postDiscordAttachments(config, channelId, attachments), "Discord attachment send").catch((error) => {
|
|
451
|
-
console.error("Discord attachment send failed", error);
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
function buildChannelRef(channel, channelId) {
|
|
455
|
-
const scope = channel.type === ChannelType.DM ? "dm" : channel.isThread() ? "thread" : "channel";
|
|
456
|
-
const channelName = "name" in channel ? channel.name : undefined;
|
|
457
|
-
return {
|
|
458
|
-
service: "discord",
|
|
459
|
-
scope,
|
|
460
|
-
channelId,
|
|
461
|
-
channelName: typeof channelName === "string" ? channelName : undefined,
|
|
462
|
-
threadId: channel.isThread() ? channel.id : undefined,
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
function getChannelRef(message) {
|
|
466
|
-
return buildChannelRef(message.channel, message.channelId);
|
|
467
|
-
}
|
|
468
|
-
function runtimeKeyFromMessage(message) {
|
|
469
|
-
return chatChannelKey(getChannelRef(message));
|
|
470
|
-
}
|
|
471
|
-
function isDmChannel(channel) {
|
|
472
|
-
return channel?.type === ChannelType.DM;
|
|
473
|
-
}
|
|
474
|
-
function getDispatchMode(config, message) {
|
|
475
|
-
return isDmChannel(message.channel) ? config.discord.dmMode : config.discord.channelMode;
|
|
476
|
-
}
|
|
477
|
-
function getChannelTriggerSetting(config, settings, channelKey, isDm) {
|
|
478
|
-
if (isDm)
|
|
479
|
-
return { value: "always", source: "config" };
|
|
480
|
-
return settings.getChannelTrigger(channelKey, config.discord.channelTrigger);
|
|
481
|
-
}
|
|
482
|
-
function canSteerFromRecord(config, message, runtime, record, activeAgentOwner, channelTrigger) {
|
|
483
|
-
if (getDispatchMode(config, message) !== "steer")
|
|
484
|
-
return false;
|
|
485
|
-
if (!runtime.hasActiveJob() || activeAgentOwner !== runtime.channelKey)
|
|
486
|
-
return false;
|
|
487
|
-
if (isDmChannel(message.channel))
|
|
488
|
-
return record.authorId === config.discord.ownerId && !record.isBot;
|
|
489
|
-
if (channelTrigger === "always")
|
|
490
|
-
return true;
|
|
491
|
-
return record.mentionedBot;
|
|
492
|
-
}
|
|
493
|
-
function formatSetting(setting) {
|
|
494
|
-
return `${setting.value} (${setting.source})`;
|
|
495
|
-
}
|
|
496
|
-
function messageMentionsBot(message, botUserId) {
|
|
497
|
-
if (message.mentions.users.has(botUserId))
|
|
498
|
-
return true;
|
|
499
|
-
return message.content.includes(`<@${botUserId}>`) || message.content.includes(`<@!${botUserId}>`);
|
|
500
|
-
}
|
|
501
|
-
async function toInboundInput(config, message, botUserId) {
|
|
502
|
-
const attachments = await materializeInboundAttachments(config, [...message.attachments.values()].map((attachment) => ({
|
|
503
|
-
id: attachment.id,
|
|
504
|
-
name: attachment.name,
|
|
505
|
-
mimeType: attachment.contentType ?? undefined,
|
|
506
|
-
size: attachment.size,
|
|
507
|
-
url: attachment.url,
|
|
508
|
-
source: "discord",
|
|
509
|
-
})));
|
|
510
|
-
return {
|
|
511
|
-
messageId: message.id,
|
|
512
|
-
authorId: message.author.id,
|
|
513
|
-
authorName: message.author.username,
|
|
514
|
-
text: message.content || "",
|
|
515
|
-
isBot: message.author.bot,
|
|
516
|
-
mentionedBot: messageMentionsBot(message, botUserId),
|
|
517
|
-
remoteTimestamp: new Date(message.createdTimestamp || Date.now()).toISOString(),
|
|
518
|
-
checkpoint: {
|
|
519
|
-
messageId: message.id,
|
|
520
|
-
},
|
|
521
|
-
attachments,
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
async function fetchMessageAnchor(message, messageId) {
|
|
525
|
-
if (!messageId || message.id === messageId)
|
|
526
|
-
return message;
|
|
527
|
-
return message.channel.messages.fetch(messageId).catch(() => message);
|
|
528
|
-
}
|
|
529
|
-
function commandTextFromInteraction(interaction) {
|
|
530
|
-
const subcommand = interaction.options.getSubcommand(true);
|
|
531
|
-
if (subcommand === "model") {
|
|
532
|
-
const model = interaction.options.getString("model");
|
|
533
|
-
return model ? `/model ${model}` : "/model";
|
|
534
|
-
}
|
|
535
|
-
if (subcommand === "thinking") {
|
|
536
|
-
const level = interaction.options.getString("level");
|
|
537
|
-
return level ? `/thinking ${level}` : "/thinking";
|
|
538
|
-
}
|
|
539
|
-
if (subcommand === "channel-trigger") {
|
|
540
|
-
const trigger = interaction.options.getString("trigger");
|
|
541
|
-
return trigger ? `/channel-trigger ${trigger}` : "/channel-trigger";
|
|
542
|
-
}
|
|
543
|
-
return `/${subcommand}`;
|
|
544
|
-
}
|
|
545
|
-
function inboundInputFromInteraction(interaction) {
|
|
546
|
-
return {
|
|
547
|
-
messageId: interaction.id,
|
|
548
|
-
authorId: interaction.user.id,
|
|
549
|
-
authorName: interaction.user.username,
|
|
550
|
-
text: commandTextFromInteraction(interaction),
|
|
551
|
-
isBot: false,
|
|
552
|
-
mentionedBot: true,
|
|
553
|
-
remoteTimestamp: new Date(interaction.createdTimestamp || Date.now()).toISOString(),
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
async function replyEphemeral(interaction, text) {
|
|
557
|
-
const content = normalizeOutboundText(text);
|
|
558
|
-
if (interaction.deferred || interaction.replied) {
|
|
559
|
-
await interaction.editReply({ content });
|
|
560
|
-
}
|
|
561
|
-
else {
|
|
562
|
-
await interaction.reply({ content, flags: EPHEMERAL_REPLY });
|
|
563
|
-
}
|
|
564
|
-
const reply = await interaction.fetchReply().catch(() => undefined);
|
|
565
|
-
return reply?.id ? [reply.id] : [];
|
|
566
|
-
}
|
|
567
|
-
async function replyInteractionError(interaction, error) {
|
|
568
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
569
|
-
console.error("Discord interaction handling failed", error);
|
|
570
|
-
await replyEphemeral(interaction, `I hit an error while handling that command.\n${message}`);
|
|
571
|
-
}
|
|
572
|
-
function formatCommandResponse(command, runtime, familiarAgent, channelTrigger) {
|
|
573
|
-
if (command === "status") {
|
|
574
|
-
return [
|
|
575
|
-
runtime.formatStatus(),
|
|
576
|
-
`model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`,
|
|
577
|
-
`thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`,
|
|
578
|
-
`channel_trigger: ${formatSetting(channelTrigger)}`,
|
|
579
|
-
].join("\n");
|
|
580
|
-
}
|
|
581
|
-
return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
|
|
582
|
-
}
|
|
1
|
+
import { Events, } from "discord.js";
|
|
2
|
+
import { thinkingDurationMs } from "./agent-events.js";
|
|
3
|
+
import { chatChannelKey } from "./chat-log.js";
|
|
4
|
+
import { buildChannelRef, fetchMessageAnchor, getChannelRef, isDmChannel, runtimeKeyFromMessage, } from "./discord/channel.js";
|
|
5
|
+
import { isAllowedMessage, withReadyClient } from "./discord/client.js";
|
|
6
|
+
import { EPHEMERAL_REPLY, FAMILIAR_COMMAND_NAME, formatCommandResponse, getAutocompleteChoices, inboundInputFromInteraction, isAllowedInteractionChannel, registerFamiliarApplicationCommand, replyEphemeral, replyInteractionError, } from "./discord/commands.js";
|
|
7
|
+
import { canSteerFromRecord, getChannelTriggerSetting, getDispatchMode, toInboundInput } from "./discord/inbound.js";
|
|
8
|
+
import { sendChannelMessage, sendDiscordAttachments, sendReply } from "./discord/send.js";
|
|
9
|
+
import { isCanceledJob, runAgentTurn } from "./discord/turn.js";
|
|
10
|
+
import { saveOwnerIdentity } from "./owner-identity.js";
|
|
11
|
+
import { formatSetting } from "./settings.js";
|
|
12
|
+
const RETRY_MS = 15_000;
|
|
583
13
|
async function applyControlCommand(options) {
|
|
584
14
|
const { control, runtime, familiarAgent, settings, channelTrigger, isDm, activeAgentOwner, restart } = options;
|
|
585
15
|
if (control.command === "stop") {
|
|
@@ -628,45 +58,6 @@ async function applyControlCommand(options) {
|
|
|
628
58
|
}
|
|
629
59
|
return formatCommandResponse(control.command, runtime, familiarAgent, channelTrigger);
|
|
630
60
|
}
|
|
631
|
-
function getAutocompleteChoices(config, interaction) {
|
|
632
|
-
if (interaction.commandName !== FAMILIAR_COMMAND_NAME)
|
|
633
|
-
return [];
|
|
634
|
-
const subcommand = interaction.options.getSubcommand(false);
|
|
635
|
-
const focused = interaction.options.getFocused(true);
|
|
636
|
-
const value = String(focused.value ?? "").toLowerCase();
|
|
637
|
-
if (subcommand !== "model" || focused.name !== "model")
|
|
638
|
-
return [];
|
|
639
|
-
const candidates = config.models.allow.length > 0 ? config.models.allow : [config.agent.model];
|
|
640
|
-
return [...new Set(candidates)]
|
|
641
|
-
.filter((model) => !value || model.toLowerCase().includes(value))
|
|
642
|
-
.slice(0, 25)
|
|
643
|
-
.map((model) => ({ name: model, value: model }));
|
|
644
|
-
}
|
|
645
|
-
function isCanceledJob(error) {
|
|
646
|
-
if (!(error instanceof Error))
|
|
647
|
-
return false;
|
|
648
|
-
return error.name === "CanceledJobError" || error.name === "AbortError" || /aborted|abort/i.test(error.message);
|
|
649
|
-
}
|
|
650
|
-
function canceledJobError() {
|
|
651
|
-
const error = new Error("Job was canceled before completion.");
|
|
652
|
-
error.name = "CanceledJobError";
|
|
653
|
-
return error;
|
|
654
|
-
}
|
|
655
|
-
function webMessageId() {
|
|
656
|
-
return `msg_${randomUUID()}`;
|
|
657
|
-
}
|
|
658
|
-
function scheduledUserMessage(text, timestamp) {
|
|
659
|
-
return { role: "user", content: [{ type: "text", text }], timestamp };
|
|
660
|
-
}
|
|
661
|
-
function heartbeatStillDue(config, now, lastUserInteractionAt, lastHeartbeatAt) {
|
|
662
|
-
return isHeartbeatDue({
|
|
663
|
-
now,
|
|
664
|
-
lastUserInteractionAt,
|
|
665
|
-
lastHeartbeatAt,
|
|
666
|
-
idleThresholdMs: config.heartbeat.idleThresholdMs,
|
|
667
|
-
intervalMs: config.heartbeat.intervalMs,
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
61
|
function startTypingIndicator(message) {
|
|
671
62
|
const sendTyping = () => {
|
|
672
63
|
if (!message.channel.isSendable())
|
|
@@ -679,466 +70,240 @@ function startTypingIndicator(message) {
|
|
|
679
70
|
clearInterval(timer);
|
|
680
71
|
};
|
|
681
72
|
}
|
|
682
|
-
export
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
73
|
+
export function startDiscordDaemon(config, token, familiarAgent, settings, _memoryService, core, options = {}) {
|
|
74
|
+
let client;
|
|
75
|
+
let session;
|
|
76
|
+
let stopped = false;
|
|
77
|
+
let retryTimer;
|
|
686
78
|
const collectTimers = new Map();
|
|
687
|
-
let activeAgentOwner;
|
|
688
|
-
let agentWorkQueue = Promise.resolve();
|
|
689
|
-
let heartbeatTimer;
|
|
690
|
-
let cronTimer;
|
|
691
|
-
let heartbeatQueued = false;
|
|
692
|
-
let cronRunning = false;
|
|
693
|
-
let schedulerState = { cron: {} };
|
|
694
|
-
const promptForRuntime = async (runtime, jobId, prompt, attachments = [], onEvent, onTurnEnd) => {
|
|
695
|
-
const run = agentWorkQueue.then(async () => {
|
|
696
|
-
if (!runtime.hasActiveJob(jobId))
|
|
697
|
-
throw canceledJobError();
|
|
698
|
-
activeAgentOwner = runtime.channelKey;
|
|
699
|
-
try {
|
|
700
|
-
const promptImages = await promptImagesFromAttachments(attachments);
|
|
701
|
-
const input = [prompt, promptImages.promptSuffix].filter(Boolean).join("\n");
|
|
702
|
-
const reply = await familiarAgent.prompt(runtime.channelKey, input, promptImages.images, onEvent, {
|
|
703
|
-
referenceAttachments: attachments,
|
|
704
|
-
onTurnEnd,
|
|
705
|
-
});
|
|
706
|
-
if (!runtime.hasActiveJob(jobId))
|
|
707
|
-
throw canceledJobError();
|
|
708
|
-
return reply;
|
|
709
|
-
}
|
|
710
|
-
finally {
|
|
711
|
-
if (activeAgentOwner === runtime.channelKey)
|
|
712
|
-
activeAgentOwner = undefined;
|
|
713
|
-
}
|
|
714
|
-
});
|
|
715
|
-
agentWorkQueue = run.then(() => undefined, () => undefined);
|
|
716
|
-
return run;
|
|
717
|
-
};
|
|
718
|
-
const promptScheduledMessage = async (runtime, buildMessage, onEvent, options) => {
|
|
719
|
-
const run = agentWorkQueue.then(async () => {
|
|
720
|
-
const message = await buildMessage();
|
|
721
|
-
if (message === HEARTBEAT_SKIPPED || message === CRON_SKIPPED)
|
|
722
|
-
return message;
|
|
723
|
-
activeAgentOwner = runtime.channelKey;
|
|
724
|
-
try {
|
|
725
|
-
return await familiarAgent.promptMessage(runtime.channelKey, message, onEvent, options);
|
|
726
|
-
}
|
|
727
|
-
finally {
|
|
728
|
-
if (activeAgentOwner === runtime.channelKey)
|
|
729
|
-
activeAgentOwner = undefined;
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
agentWorkQueue = run.then(() => undefined, () => undefined);
|
|
733
|
-
return run;
|
|
734
|
-
};
|
|
735
|
-
const getRuntimeForChannel = async (channel) => {
|
|
736
|
-
const channelKey = chatChannelKey(channel);
|
|
737
|
-
const existing = runtimes.get(channelKey);
|
|
738
|
-
if (existing)
|
|
739
|
-
return existing;
|
|
740
|
-
const runtimePromise = ConversationRuntime.connect({
|
|
741
|
-
channelKey,
|
|
742
|
-
log: createChatLog(config, channel),
|
|
743
|
-
ownerId: config.discord.ownerId,
|
|
744
|
-
botUserId: client.user.id,
|
|
745
|
-
}).then(async (runtime) => {
|
|
746
|
-
memoryService?.subscribeRuntime(runtime, runtime.channelKey);
|
|
747
|
-
await runtime.armAfterCurrentTail();
|
|
748
|
-
return runtime;
|
|
749
|
-
});
|
|
750
|
-
runtimes.set(channelKey, runtimePromise);
|
|
751
|
-
try {
|
|
752
|
-
return await runtimePromise;
|
|
753
|
-
}
|
|
754
|
-
catch (error) {
|
|
755
|
-
runtimes.delete(channelKey);
|
|
756
|
-
throw error;
|
|
757
|
-
}
|
|
758
|
-
};
|
|
759
79
|
const getRuntime = async (message) => {
|
|
760
|
-
return getRuntimeForChannel(getChannelRef(message));
|
|
80
|
+
return core.getRuntimeForChannel(getChannelRef(message));
|
|
761
81
|
};
|
|
762
82
|
const getInteractionRuntime = async (interaction) => {
|
|
763
83
|
const channel = interaction.channel;
|
|
764
84
|
if (!channel || !interaction.channelId)
|
|
765
85
|
throw new Error("Discord interaction has no channel");
|
|
766
|
-
return getRuntimeForChannel(buildChannelRef(channel, interaction.channelId));
|
|
767
|
-
};
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
86
|
+
return core.getRuntimeForChannel(buildChannelRef(channel, interaction.channelId));
|
|
87
|
+
};
|
|
88
|
+
const createConnectedSession = (client) => {
|
|
89
|
+
let ownerDmChannelPromise;
|
|
90
|
+
const getOwnerDmChannel = () => {
|
|
91
|
+
if (!ownerDmChannelPromise) {
|
|
92
|
+
ownerDmChannelPromise = client.users
|
|
93
|
+
.createDM(config.discord.ownerId)
|
|
94
|
+
.then(async (dm) => {
|
|
95
|
+
try {
|
|
96
|
+
await saveOwnerIdentity(config.workspace.dataDir, {
|
|
97
|
+
botUserId: client.user.id,
|
|
98
|
+
dmChannelId: dm.id,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
console.error("failed to persist owner identity", error);
|
|
103
|
+
}
|
|
104
|
+
return dm;
|
|
105
|
+
})
|
|
106
|
+
.catch((error) => {
|
|
107
|
+
ownerDmChannelPromise = undefined;
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return ownerDmChannelPromise;
|
|
112
|
+
};
|
|
113
|
+
const getWebSessions = async () => {
|
|
114
|
+
const dmChannel = await getOwnerDmChannel();
|
|
115
|
+
const dmRef = buildChannelRef(dmChannel, dmChannel.id);
|
|
116
|
+
const sessions = [
|
|
117
|
+
{ key: chatChannelKey(dmRef), label: "Main Chat", channel: dmRef, isDefault: true },
|
|
118
|
+
];
|
|
119
|
+
const fetched = await Promise.all(config.discord.allowedChannels.map((channelId) => client.channels.fetch(channelId).catch(() => undefined)));
|
|
120
|
+
for (const [index, channel] of fetched.entries()) {
|
|
121
|
+
if (!channel)
|
|
122
|
+
continue;
|
|
123
|
+
const ref = buildChannelRef(channel, config.discord.allowedChannels[index]);
|
|
124
|
+
sessions.push({
|
|
125
|
+
key: chatChannelKey(ref),
|
|
126
|
+
label: ref.channelName || `Discord ${ref.scope}`,
|
|
127
|
+
channel: ref,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return sessions;
|
|
131
|
+
};
|
|
132
|
+
const getOwnerDmSession = async () => {
|
|
133
|
+
const dmChannel = await getOwnerDmChannel();
|
|
134
|
+
const runtime = await core.getRuntimeForChannel(buildChannelRef(dmChannel, dmChannel.id));
|
|
135
|
+
return { runtime };
|
|
136
|
+
};
|
|
137
|
+
const liveSink = {
|
|
138
|
+
async deliver({ reply, parsedReply }) {
|
|
139
|
+
const channel = await getOwnerDmChannel();
|
|
140
|
+
return parsedReply.silent
|
|
141
|
+
? await sendDiscordAttachments(client.rest, channel.id, reply.attachments)
|
|
142
|
+
: await sendChannelMessage(config, client.rest, channel, parsedReply.text, reply.attachments);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
const drainJobs = async (message, runtime) => {
|
|
146
|
+
for (;;) {
|
|
147
|
+
const dispatch = runtime.beginNextJob();
|
|
148
|
+
if (!dispatch)
|
|
149
|
+
return;
|
|
150
|
+
const stopTyping = startTypingIndicator(message);
|
|
830
151
|
try {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
152
|
+
const turn = await runAgentTurn(dispatch.job.jobId, runtime, (onEvent) => core.promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments, onEvent));
|
|
153
|
+
if (!turn)
|
|
154
|
+
return;
|
|
155
|
+
const { reply, parsedReply, summary, assistantMessageId } = turn;
|
|
156
|
+
const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
|
|
157
|
+
const messageIds = parsedReply.silent
|
|
158
|
+
? await sendDiscordAttachments(client.rest, replyAnchor.channelId, reply.attachments)
|
|
159
|
+
: await sendReply(config, client.rest, replyAnchor, parsedReply.text, dispatch.triggerMessageId, reply.attachments);
|
|
160
|
+
await runtime.completeActiveJob({
|
|
161
|
+
text: parsedReply.text,
|
|
162
|
+
messageIds,
|
|
163
|
+
webMessageId: assistantMessageId,
|
|
164
|
+
attachments: reply.attachments,
|
|
165
|
+
thinking: summary.thinking,
|
|
166
|
+
thinkingMs: thinkingDurationMs(summary),
|
|
167
|
+
silent: parsedReply.silent,
|
|
168
|
+
replyToMessageId: dispatch.triggerMessageId,
|
|
838
169
|
});
|
|
839
170
|
}
|
|
840
|
-
|
|
841
|
-
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (isCanceledJob(error) || !runtime.hasActiveJob(dispatch.job.jobId))
|
|
173
|
+
return;
|
|
174
|
+
const errorText = error instanceof Error ? error.message : String(error);
|
|
175
|
+
await runtime.failActiveJob(errorText);
|
|
176
|
+
await runtime.appendError(errorText);
|
|
177
|
+
const fallback = "I hit an error while handling that message.";
|
|
178
|
+
const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
|
|
179
|
+
const messageIds = await sendReply(config, client.rest, replyAnchor, fallback, dispatch.triggerMessageId);
|
|
180
|
+
await runtime.noteOutbound({
|
|
181
|
+
text: fallback,
|
|
182
|
+
messageIds,
|
|
183
|
+
replyToMessageId: dispatch.triggerMessageId,
|
|
184
|
+
jobId: dispatch.job.jobId,
|
|
185
|
+
});
|
|
842
186
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const messageIds = parsedReply.silent
|
|
846
|
-
? []
|
|
847
|
-
: await sendReply(config, replyAnchor, parsedReply.text, dispatch.triggerMessageId, reply.attachments);
|
|
848
|
-
if (parsedReply.silent) {
|
|
849
|
-
sendDiscordAttachmentsInBackground(config, replyAnchor.channelId, reply.attachments);
|
|
187
|
+
finally {
|
|
188
|
+
stopTyping();
|
|
850
189
|
}
|
|
851
|
-
await runtime.completeActiveJob({
|
|
852
|
-
text: parsedReply.text,
|
|
853
|
-
messageIds,
|
|
854
|
-
webMessageId: assistantMessageId,
|
|
855
|
-
attachments: reply.attachments,
|
|
856
|
-
thinking: summary.thinking,
|
|
857
|
-
thinkingMs: thinkingDurationMs(summary),
|
|
858
|
-
silent: parsedReply.silent,
|
|
859
|
-
replyToMessageId: dispatch.triggerMessageId,
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
catch (error) {
|
|
863
|
-
if (isCanceledJob(error) || !runtime.hasActiveJob(dispatch.job.jobId))
|
|
864
|
-
return;
|
|
865
|
-
const errorText = error instanceof Error ? error.message : String(error);
|
|
866
|
-
await runtime.failActiveJob(errorText);
|
|
867
|
-
await runtime.appendError(errorText);
|
|
868
|
-
const fallback = "I hit an error while handling that message.";
|
|
869
|
-
const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
|
|
870
|
-
const messageIds = await sendReply(config, replyAnchor, fallback, dispatch.triggerMessageId);
|
|
871
|
-
await runtime.noteOutbound({
|
|
872
|
-
text: fallback,
|
|
873
|
-
messageIds,
|
|
874
|
-
replyToMessageId: dispatch.triggerMessageId,
|
|
875
|
-
jobId: dispatch.job.jobId,
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
finally {
|
|
879
|
-
stopTyping();
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
};
|
|
883
|
-
const runHeartbeat = async () => {
|
|
884
|
-
if (!config.heartbeat.enabled)
|
|
885
|
-
return;
|
|
886
|
-
if (activeAgentOwner)
|
|
887
|
-
return;
|
|
888
|
-
if (heartbeatQueued)
|
|
889
|
-
return;
|
|
890
|
-
heartbeatQueued = true;
|
|
891
|
-
let runtime;
|
|
892
|
-
try {
|
|
893
|
-
const session = await getOwnerDmSession();
|
|
894
|
-
runtime = session.runtime;
|
|
895
|
-
const heartbeatRuntime = session.runtime;
|
|
896
|
-
const channel = session.channel;
|
|
897
|
-
const now = Date.now();
|
|
898
|
-
if (heartbeatRuntime.hasLiveWork())
|
|
899
|
-
return;
|
|
900
|
-
const lastUserInteractionAt = heartbeatRuntime.getLastUserInteractionAt();
|
|
901
|
-
if (!heartbeatStillDue(config, now, lastUserInteractionAt, schedulerState.heartbeat?.lastFiredAt)) {
|
|
902
|
-
return;
|
|
903
190
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
let reply;
|
|
191
|
+
};
|
|
192
|
+
const flushCollected = async (message, runtime) => {
|
|
193
|
+
collectTimers.delete(runtime.channelKey);
|
|
908
194
|
try {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
schedulerState.heartbeat = { lastFiredAt: new Date(queuedNow).toISOString() };
|
|
918
|
-
await saveScheduler();
|
|
919
|
-
const text = buildHeartbeatInjectionText({ now: queuedNow, idleSince: latestUserInteractionAt });
|
|
920
|
-
await heartbeatRuntime.noteHeartbeat(`heartbeat stirred after ${formatIdleDuration(queuedNow - latestUserInteractionAt)}`);
|
|
921
|
-
return scheduledUserMessage(text, queuedNow);
|
|
922
|
-
}, async (event) => {
|
|
923
|
-
updateAgentEventSummary(summary, event);
|
|
924
|
-
const storedEvent = storedAgentEventFromAgentEvent(event);
|
|
925
|
-
if (storedEvent) {
|
|
926
|
-
heartbeatRuntime.publishAgentEvent("heartbeat", assistantMessageId, storedEvent);
|
|
927
|
-
await recorder.record(storedEvent);
|
|
928
|
-
}
|
|
929
|
-
}, { skipAmbient: true });
|
|
195
|
+
const isDm = isDmChannel(message.channel);
|
|
196
|
+
const queued = await runtime.queueLatestTrigger({
|
|
197
|
+
channelTrigger: getChannelTriggerSetting(config, settings, runtime.channelKey, isDm).value,
|
|
198
|
+
});
|
|
199
|
+
if (!queued)
|
|
200
|
+
return;
|
|
201
|
+
// The captured message is only a channel handle; drainJobs fetches the trigger record's message id for replies.
|
|
202
|
+
await drainJobs(message, runtime);
|
|
930
203
|
}
|
|
931
|
-
|
|
932
|
-
|
|
204
|
+
catch (error) {
|
|
205
|
+
console.error("Discord collect flush failed", error);
|
|
206
|
+
await runtime.appendError(error instanceof Error ? error.message : String(error));
|
|
933
207
|
}
|
|
934
|
-
if (reply === HEARTBEAT_SKIPPED || reply === CRON_SKIPPED)
|
|
935
|
-
return;
|
|
936
|
-
const parsedReply = parseAgentReply(reply.text);
|
|
937
|
-
const messageIds = parsedReply.silent
|
|
938
|
-
? []
|
|
939
|
-
: await sendChannelMessage(config, channel, parsedReply.text, reply.attachments);
|
|
940
|
-
if (parsedReply.silent)
|
|
941
|
-
sendDiscordAttachmentsInBackground(config, channel.id, reply.attachments);
|
|
942
|
-
await heartbeatRuntime.noteOutbound({
|
|
943
|
-
text: parsedReply.text,
|
|
944
|
-
messageIds,
|
|
945
|
-
webMessageId: assistantMessageId,
|
|
946
|
-
attachments: reply.attachments,
|
|
947
|
-
thinking: summary.thinking,
|
|
948
|
-
thinkingMs: thinkingDurationMs(summary),
|
|
949
|
-
silent: parsedReply.silent,
|
|
950
|
-
jobId: "heartbeat",
|
|
951
|
-
});
|
|
952
|
-
}
|
|
953
|
-
catch (error) {
|
|
954
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
955
|
-
await runtime?.noteHeartbeatFailure(message);
|
|
956
|
-
await runtime?.appendError(`Heartbeat failed: ${message}`);
|
|
957
|
-
console.error("Heartbeat failed", error);
|
|
958
|
-
}
|
|
959
|
-
finally {
|
|
960
|
-
heartbeatQueued = false;
|
|
961
|
-
}
|
|
962
|
-
};
|
|
963
|
-
const markCronSlotStarted = async (job, slot) => {
|
|
964
|
-
schedulerState.cron[job.id] = {
|
|
965
|
-
lastFiredSlot: slot,
|
|
966
|
-
lastFiredAt: new Date().toISOString(),
|
|
967
|
-
...(schedulerState.cron[job.id]?.completed ? { completed: true } : {}),
|
|
968
208
|
};
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
209
|
+
const scheduleCollect = (message, runtime) => {
|
|
210
|
+
const existing = collectTimers.get(runtime.channelKey);
|
|
211
|
+
if (existing)
|
|
212
|
+
clearTimeout(existing);
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
void flushCollected(message, runtime);
|
|
215
|
+
}, config.discord.collectDebounceMs);
|
|
216
|
+
collectTimers.set(runtime.channelKey, timer);
|
|
977
217
|
};
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1010
|
-
const assistantMessageId = webMessageId();
|
|
1011
|
-
const summary = { thinking: "" };
|
|
1012
|
-
const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(`cron:${job.id}`, assistantMessageId, storedEvent, { notify: false }));
|
|
1013
|
-
let reply;
|
|
1014
|
-
try {
|
|
1015
|
-
reply = await promptScheduledMessage(runtime, async () => {
|
|
1016
|
-
const jobState = schedulerState.cron[job.id];
|
|
1017
|
-
if (jobState?.completed || jobState?.lastFiredSlot === slot)
|
|
1018
|
-
return CRON_SKIPPED;
|
|
1019
|
-
const now = Date.now();
|
|
1020
|
-
await appendSchedulerLog(config.workspace.dataDir, {
|
|
1021
|
-
type: "cron_started",
|
|
1022
|
-
jobId: job.id,
|
|
1023
|
-
slot,
|
|
1024
|
-
deliveryMode: job.deliveryMode,
|
|
218
|
+
const onMessageCreate = async (message) => {
|
|
219
|
+
if (!isAllowedMessage(config, message, client.user.id))
|
|
220
|
+
return;
|
|
221
|
+
let runtime;
|
|
222
|
+
try {
|
|
223
|
+
runtime = await getRuntime(message);
|
|
224
|
+
const isDm = isDmChannel(message.channel);
|
|
225
|
+
const channelTrigger = getChannelTriggerSetting(config, settings, runtime.channelKey, isDm);
|
|
226
|
+
const input = await toInboundInput(config, message, client.user.id);
|
|
227
|
+
const control = runtime.parseControlCommand(input);
|
|
228
|
+
if (control) {
|
|
229
|
+
await runtime.noteControlCommand(input, control);
|
|
230
|
+
const text = await applyControlCommand({
|
|
231
|
+
control,
|
|
232
|
+
runtime,
|
|
233
|
+
familiarAgent,
|
|
234
|
+
settings,
|
|
235
|
+
channelTrigger,
|
|
236
|
+
isDm,
|
|
237
|
+
activeAgentOwner: core.activeOwner,
|
|
238
|
+
restart: options.restart,
|
|
239
|
+
});
|
|
240
|
+
const messageIds = await sendReply(config, client.rest, message, text);
|
|
241
|
+
await runtime.noteOutbound({ text, messageIds, control: control.command });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const dispatchMode = getDispatchMode(config, message);
|
|
245
|
+
const shouldTrySteer = dispatchMode === "steer" && runtime.hasActiveJob() && core.activeOwner === runtime.channelKey;
|
|
246
|
+
const { record } = await runtime.ingestInbound(input, {
|
|
247
|
+
mode: dispatchMode === "collect" || shouldTrySteer ? "collect" : "queue",
|
|
248
|
+
channelTrigger: channelTrigger.value,
|
|
1025
249
|
});
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
if (storedEvent) {
|
|
1032
|
-
runtime.publishAgentEvent(`cron:${job.id}`, assistantMessageId, storedEvent);
|
|
1033
|
-
await recorder.record(storedEvent);
|
|
250
|
+
const canSteer = shouldTrySteer &&
|
|
251
|
+
canSteerFromRecord(config, message, runtime, record, core.activeOwner, channelTrigger.value);
|
|
252
|
+
if (canSteer) {
|
|
253
|
+
familiarAgent.steer(runtime.channelKey, runtime.buildSteerPromptForRecord(record));
|
|
254
|
+
return;
|
|
1034
255
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
finally {
|
|
1038
|
-
await recorder.flush();
|
|
1039
|
-
}
|
|
1040
|
-
if (reply === HEARTBEAT_SKIPPED || reply === CRON_SKIPPED) {
|
|
1041
|
-
await appendSchedulerLog(config.workspace.dataDir, {
|
|
1042
|
-
type: "cron_skipped",
|
|
1043
|
-
jobId: job.id,
|
|
1044
|
-
slot,
|
|
1045
|
-
deliveryMode: job.deliveryMode,
|
|
1046
|
-
detail: "already completed before prompt",
|
|
1047
|
-
});
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
const parsedReply = parseAgentReply(reply.text);
|
|
1051
|
-
const messageIds = parsedReply.silent
|
|
1052
|
-
? []
|
|
1053
|
-
: await sendChannelMessage(config, channel, parsedReply.text, reply.attachments);
|
|
1054
|
-
if (parsedReply.silent)
|
|
1055
|
-
sendDiscordAttachmentsInBackground(config, channel.id, reply.attachments);
|
|
1056
|
-
await runtime.noteOutbound({
|
|
1057
|
-
text: parsedReply.text,
|
|
1058
|
-
messageIds,
|
|
1059
|
-
webMessageId: assistantMessageId,
|
|
1060
|
-
attachments: reply.attachments,
|
|
1061
|
-
thinking: summary.thinking,
|
|
1062
|
-
thinkingMs: thinkingDurationMs(summary),
|
|
1063
|
-
silent: parsedReply.silent,
|
|
1064
|
-
jobId: `cron:${job.id}`,
|
|
1065
|
-
});
|
|
1066
|
-
await completeCronSlot(job, slot);
|
|
1067
|
-
await appendSchedulerLog(config.workspace.dataDir, {
|
|
1068
|
-
type: "cron_completed",
|
|
1069
|
-
jobId: job.id,
|
|
1070
|
-
slot,
|
|
1071
|
-
deliveryMode: job.deliveryMode,
|
|
1072
|
-
});
|
|
1073
|
-
};
|
|
1074
|
-
const tickCron = async () => {
|
|
1075
|
-
if (!config.cron.enabled || cronRunning)
|
|
1076
|
-
return;
|
|
1077
|
-
cronRunning = true;
|
|
1078
|
-
try {
|
|
1079
|
-
const session = await getOwnerDmSession();
|
|
1080
|
-
for (const job of config.cron.jobs) {
|
|
1081
|
-
const slot = dueCronSlot(job, schedulerState.cron[job.id], Date.now());
|
|
1082
|
-
if (!slot)
|
|
1083
|
-
continue;
|
|
1084
|
-
try {
|
|
1085
|
-
await runCronJob(job, slot, session.runtime, session.channel);
|
|
256
|
+
if (shouldTrySteer) {
|
|
257
|
+
await runtime.queueLatestTrigger({ channelTrigger: channelTrigger.value });
|
|
1086
258
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
type: "cron_failed",
|
|
1091
|
-
jobId: job.id,
|
|
1092
|
-
slot,
|
|
1093
|
-
deliveryMode: job.deliveryMode,
|
|
1094
|
-
detail: message,
|
|
1095
|
-
});
|
|
1096
|
-
await session.runtime.appendError(`Cron job ${job.id} failed: ${message}`);
|
|
1097
|
-
console.error(`Cron job ${job.id} failed`, error);
|
|
259
|
+
if (dispatchMode === "collect") {
|
|
260
|
+
scheduleCollect(message, runtime);
|
|
261
|
+
return;
|
|
1098
262
|
}
|
|
263
|
+
await drainJobs(message, runtime);
|
|
1099
264
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
265
|
+
catch (error) {
|
|
266
|
+
console.error("Discord message handling failed", error);
|
|
267
|
+
const channelKey = runtimeKeyFromMessage(message);
|
|
268
|
+
const existingRuntime = await core.peekRuntime(channelKey);
|
|
269
|
+
await existingRuntime?.appendError(error instanceof Error ? error.message : String(error));
|
|
270
|
+
await sendReply(config, client.rest, message, "I hit an error while handling that message.");
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const onInteractionCreate = async (interaction) => {
|
|
274
|
+
if (interaction.isAutocomplete()) {
|
|
275
|
+
if (interaction.commandName !== FAMILIAR_COMMAND_NAME)
|
|
276
|
+
return;
|
|
277
|
+
if (!isAllowedInteractionChannel(config, interaction)) {
|
|
278
|
+
await interaction.respond([]);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
await interaction.respond(getAutocompleteChoices(config, interaction)).catch((error) => {
|
|
282
|
+
console.error("Discord autocomplete response failed", error);
|
|
283
|
+
});
|
|
1113
284
|
return;
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
runtime = await getRuntime(message);
|
|
1137
|
-
const isDm = isDmChannel(message.channel);
|
|
1138
|
-
const channelTrigger = getChannelTriggerSetting(config, settings, runtime.channelKey, isDm);
|
|
1139
|
-
const input = await toInboundInput(config, message, client.user.id);
|
|
1140
|
-
const control = runtime.parseControlCommand(input);
|
|
1141
|
-
if (control) {
|
|
285
|
+
}
|
|
286
|
+
if (!interaction.isChatInputCommand())
|
|
287
|
+
return;
|
|
288
|
+
if (interaction.commandName !== FAMILIAR_COMMAND_NAME)
|
|
289
|
+
return;
|
|
290
|
+
if (!isAllowedInteractionChannel(config, interaction)) {
|
|
291
|
+
await interaction.reply({
|
|
292
|
+
content: "This Familiar command is owner-only for configured channels.",
|
|
293
|
+
flags: EPHEMERAL_REPLY,
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
let runtime;
|
|
298
|
+
try {
|
|
299
|
+
await interaction.deferReply({ flags: EPHEMERAL_REPLY });
|
|
300
|
+
runtime = await getInteractionRuntime(interaction);
|
|
301
|
+
const isDm = isDmChannel(interaction.channel);
|
|
302
|
+
const channelTrigger = getChannelTriggerSetting(config, settings, runtime.channelKey, isDm);
|
|
303
|
+
const input = inboundInputFromInteraction(interaction);
|
|
304
|
+
const control = runtime.parseControlCommand(input);
|
|
305
|
+
if (!control)
|
|
306
|
+
throw new Error("Unsupported Familiar command.");
|
|
1142
307
|
await runtime.noteControlCommand(input, control);
|
|
1143
308
|
const text = await applyControlCommand({
|
|
1144
309
|
control,
|
|
@@ -1147,153 +312,68 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
|
|
|
1147
312
|
settings,
|
|
1148
313
|
channelTrigger,
|
|
1149
314
|
isDm,
|
|
1150
|
-
activeAgentOwner,
|
|
315
|
+
activeAgentOwner: core.activeOwner,
|
|
1151
316
|
restart: options.restart,
|
|
1152
317
|
});
|
|
1153
|
-
const messageIds = await
|
|
318
|
+
const messageIds = await replyEphemeral(interaction, text);
|
|
1154
319
|
await runtime.noteOutbound({ text, messageIds, control: control.command });
|
|
1155
|
-
return;
|
|
1156
320
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
mode: dispatchMode === "collect" || shouldTrySteer ? "collect" : "queue",
|
|
1161
|
-
channelTrigger: channelTrigger.value,
|
|
1162
|
-
});
|
|
1163
|
-
const canSteer = shouldTrySteer &&
|
|
1164
|
-
canSteerFromRecord(config, message, runtime, record, activeAgentOwner, channelTrigger.value);
|
|
1165
|
-
if (canSteer) {
|
|
1166
|
-
familiarAgent.steer(runtime.channelKey, runtime.buildSteerPromptForRecord(record));
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
if (shouldTrySteer) {
|
|
1170
|
-
await runtime.queueLatestTrigger({ channelTrigger: channelTrigger.value });
|
|
1171
|
-
}
|
|
1172
|
-
if (dispatchMode === "collect") {
|
|
1173
|
-
scheduleCollect(message, runtime);
|
|
1174
|
-
return;
|
|
321
|
+
catch (error) {
|
|
322
|
+
await runtime?.appendError(error instanceof Error ? error.message : String(error));
|
|
323
|
+
await replyInteractionError(interaction, error);
|
|
1175
324
|
}
|
|
1176
|
-
|
|
1177
|
-
}
|
|
1178
|
-
catch (error) {
|
|
1179
|
-
console.error("Discord message handling failed", error);
|
|
1180
|
-
const channelKey = runtimeKeyFromMessage(message);
|
|
1181
|
-
const existingRuntime = await runtimes.get(channelKey)?.catch(() => undefined);
|
|
1182
|
-
await existingRuntime?.appendError(error instanceof Error ? error.message : String(error));
|
|
1183
|
-
await sendReply(config, message, "I hit an error while handling that message.");
|
|
1184
|
-
}
|
|
325
|
+
};
|
|
326
|
+
return { onMessageCreate, onInteractionCreate, getOwnerDmSession, getWebSessions, liveSink };
|
|
1185
327
|
};
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1194
|
-
await interaction.respond(getAutocompleteChoices(config, interaction)).catch((error) => {
|
|
1195
|
-
console.error("Discord autocomplete response failed", error);
|
|
1196
|
-
});
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
if (!interaction.isChatInputCommand())
|
|
1200
|
-
return;
|
|
1201
|
-
if (interaction.commandName !== FAMILIAR_COMMAND_NAME)
|
|
1202
|
-
return;
|
|
1203
|
-
if (!isAllowedInteractionChannel(config, interaction)) {
|
|
1204
|
-
await interaction.reply({
|
|
1205
|
-
content: "This Familiar command is owner-only for configured channels.",
|
|
1206
|
-
flags: EPHEMERAL_REPLY,
|
|
1207
|
-
});
|
|
328
|
+
const onClientError = (error) => console.error("Discord client error", error);
|
|
329
|
+
const onClientWarn = (warning) => console.warn("Discord warning", warning);
|
|
330
|
+
const onWsClose = (event) => {
|
|
331
|
+
console.warn("Discord websocket closed; discord.js will reconnect when possible", event);
|
|
332
|
+
};
|
|
333
|
+
const connect = async () => {
|
|
334
|
+
if (stopped)
|
|
1208
335
|
return;
|
|
1209
|
-
}
|
|
1210
|
-
let runtime;
|
|
1211
336
|
try {
|
|
1212
|
-
await
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
channelTrigger,
|
|
1227
|
-
isDm,
|
|
1228
|
-
activeAgentOwner,
|
|
1229
|
-
restart: options.restart,
|
|
337
|
+
client = await withReadyClient(token);
|
|
338
|
+
console.log(`Discord connected as ${client.user.tag}`);
|
|
339
|
+
await registerFamiliarApplicationCommand(client);
|
|
340
|
+
session = createConnectedSession(client);
|
|
341
|
+
client.on(Events.MessageCreate, session.onMessageCreate);
|
|
342
|
+
client.on(Events.InteractionCreate, session.onInteractionCreate);
|
|
343
|
+
client.on(Events.Error, onClientError);
|
|
344
|
+
client.on(Events.Warn, onClientWarn);
|
|
345
|
+
client.ws.on("close", onWsClose);
|
|
346
|
+
core.attachDiscord({
|
|
347
|
+
botUserId: client.user.id,
|
|
348
|
+
resolveDefaultSession: session.getOwnerDmSession,
|
|
349
|
+
getWebSessions: session.getWebSessions,
|
|
350
|
+
delivery: session.liveSink,
|
|
1230
351
|
});
|
|
1231
|
-
const messageIds = await replyEphemeral(interaction, text);
|
|
1232
|
-
await runtime.noteOutbound({ text, messageIds, control: control.command });
|
|
1233
352
|
}
|
|
1234
353
|
catch (error) {
|
|
1235
|
-
|
|
1236
|
-
|
|
354
|
+
console.error("Discord connect failed; retrying in background", error);
|
|
355
|
+
if (!stopped)
|
|
356
|
+
retryTimer = setTimeout(() => void connect(), RETRY_MS);
|
|
1237
357
|
}
|
|
1238
358
|
};
|
|
1239
|
-
|
|
1240
|
-
client.on(Events.MessageCreate, onMessageCreate);
|
|
1241
|
-
client.on(Events.InteractionCreate, onInteractionCreate);
|
|
1242
|
-
client.on(Events.Error, (error) => console.error("Discord client error", error));
|
|
1243
|
-
client.on(Events.Warn, (warning) => console.warn("Discord warning", warning));
|
|
1244
|
-
client.ws.on("close", (event) => {
|
|
1245
|
-
console.warn("Discord websocket closed; discord.js will reconnect when possible", event);
|
|
1246
|
-
});
|
|
1247
|
-
schedulerState = await loadSchedulerState(config.workspace.dataDir);
|
|
1248
|
-
const tickHeartbeat = () => {
|
|
1249
|
-
void runHeartbeat().catch((error) => console.error("Heartbeat tick failed", error));
|
|
1250
|
-
};
|
|
1251
|
-
const rearmHeartbeat = () => {
|
|
1252
|
-
if (heartbeatTimer) {
|
|
1253
|
-
clearInterval(heartbeatTimer);
|
|
1254
|
-
heartbeatTimer = undefined;
|
|
1255
|
-
}
|
|
1256
|
-
if (config.heartbeat.enabled) {
|
|
1257
|
-
heartbeatTimer = setInterval(tickHeartbeat, Math.min(config.heartbeat.intervalMs, 60_000));
|
|
1258
|
-
}
|
|
1259
|
-
};
|
|
1260
|
-
if (config.heartbeat.enabled) {
|
|
1261
|
-
await initializeHeartbeatState((await getOwnerDmSession()).runtime);
|
|
1262
|
-
rearmHeartbeat();
|
|
1263
|
-
tickHeartbeat();
|
|
1264
|
-
}
|
|
1265
|
-
if (config.cron.enabled && config.cron.jobs.some((job) => job.enabled)) {
|
|
1266
|
-
const runCronTick = () => {
|
|
1267
|
-
void tickCron().catch((error) => console.error("Cron tick failed", error));
|
|
1268
|
-
};
|
|
1269
|
-
cronTimer = setInterval(runCronTick, config.cron.pollMs);
|
|
1270
|
-
runCronTick();
|
|
1271
|
-
}
|
|
359
|
+
void connect();
|
|
1272
360
|
return {
|
|
1273
|
-
client,
|
|
1274
|
-
getWebSessions,
|
|
1275
|
-
getRuntimeForWebChannel,
|
|
1276
|
-
runPromptForWeb: promptForRuntime,
|
|
1277
|
-
abortWebRuntime(runtime) {
|
|
1278
|
-
familiarAgent.requestSoftStop(runtime.channelKey);
|
|
1279
|
-
},
|
|
1280
|
-
getActiveRuntimeKey() {
|
|
1281
|
-
return activeAgentOwner;
|
|
1282
|
-
},
|
|
1283
|
-
rearmHeartbeat,
|
|
1284
361
|
async stop() {
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
if (
|
|
1290
|
-
|
|
362
|
+
stopped = true;
|
|
363
|
+
if (retryTimer)
|
|
364
|
+
clearTimeout(retryTimer);
|
|
365
|
+
const liveClient = client;
|
|
366
|
+
if (liveClient && session) {
|
|
367
|
+
liveClient.off(Events.MessageCreate, session.onMessageCreate);
|
|
368
|
+
liveClient.off(Events.InteractionCreate, session.onInteractionCreate);
|
|
369
|
+
liveClient.off(Events.Error, onClientError);
|
|
370
|
+
liveClient.off(Events.Warn, onClientWarn);
|
|
371
|
+
liveClient.ws.off("close", onWsClose);
|
|
372
|
+
}
|
|
1291
373
|
for (const timer of collectTimers.values())
|
|
1292
374
|
clearTimeout(timer);
|
|
1293
375
|
collectTimers.clear();
|
|
1294
|
-
|
|
1295
|
-
await Promise.all(resolvedRuntimes.flatMap((runtime) => (runtime ? [runtime.disconnect()] : [])));
|
|
1296
|
-
client.destroy();
|
|
376
|
+
liveClient?.destroy();
|
|
1297
377
|
},
|
|
1298
378
|
};
|
|
1299
379
|
}
|