@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.
Files changed (81) hide show
  1. package/README.md +4 -0
  2. package/config.example.toml +2 -2
  3. package/dist/agent/payload-normalizers.js +52 -0
  4. package/dist/agent/session-helpers.js +86 -0
  5. package/dist/agent/tool-descriptions.js +4 -0
  6. package/dist/agent/tools.js +30 -0
  7. package/dist/agent/transcript-log.js +93 -0
  8. package/dist/agent/types.js +1 -0
  9. package/dist/agent-core.js +82 -0
  10. package/dist/agent-work-queue.js +55 -0
  11. package/dist/agent.js +91 -322
  12. package/dist/browser-tools.js +7 -8
  13. package/dist/chat-log.js +15 -3
  14. package/dist/cli.js +36 -6
  15. package/dist/config/enums.js +35 -0
  16. package/dist/config/interpolate.js +15 -0
  17. package/dist/config/model-refs.js +11 -0
  18. package/dist/config/readers.js +116 -0
  19. package/dist/config/sections.js +113 -0
  20. package/dist/config/types.js +1 -0
  21. package/dist/config-registry.js +26 -7
  22. package/dist/config.js +8 -271
  23. package/dist/discord/channel.js +32 -0
  24. package/dist/discord/chunking.js +163 -0
  25. package/dist/discord/client.js +44 -0
  26. package/dist/discord/commands.js +181 -0
  27. package/dist/discord/inbound.js +44 -0
  28. package/dist/discord/send.js +106 -0
  29. package/dist/discord/turn.js +55 -0
  30. package/dist/discord.js +266 -1186
  31. package/dist/ids.js +11 -0
  32. package/dist/index.js +1 -0
  33. package/dist/memory/index/store.js +21 -17
  34. package/dist/memory/index/vector-codec.js +2 -2
  35. package/dist/memory/lcm/context-transformer.js +6 -2
  36. package/dist/memory/lcm/segment-manager.js +6 -2
  37. package/dist/memory/lcm/store/index-ids.js +6 -0
  38. package/dist/memory/lcm/store/inserts.js +31 -0
  39. package/dist/memory/lcm/store/normalizers.js +91 -0
  40. package/dist/memory/lcm/store/row-mappers.js +114 -0
  41. package/dist/memory/lcm/store/row-types.js +1 -0
  42. package/dist/memory/lcm/store/serialization.js +37 -0
  43. package/dist/memory/lcm/store/snapshots.js +73 -0
  44. package/dist/memory/lcm/store.js +20 -360
  45. package/dist/owner-identity.js +29 -0
  46. package/dist/runtime-manager.js +51 -0
  47. package/dist/runtime.js +89 -41
  48. package/dist/scheduler-runner.js +243 -0
  49. package/dist/scheduler.js +1 -1
  50. package/dist/service.js +1 -0
  51. package/dist/settings.js +3 -0
  52. package/dist/web/event-hub.js +246 -0
  53. package/dist/{web-http.js → web/http.js} +19 -5
  54. package/dist/web/memes.js +25 -0
  55. package/dist/web/messages.js +345 -0
  56. package/dist/web/multipart.js +80 -0
  57. package/dist/web/payloads.js +34 -0
  58. package/dist/{web-static.js → web/static.js} +19 -14
  59. package/dist/web/stream.js +69 -0
  60. package/dist/web-tools/cache.js +42 -0
  61. package/dist/web-tools/config.js +16 -0
  62. package/dist/web-tools/fetch-providers.js +119 -0
  63. package/dist/web-tools/format.js +88 -0
  64. package/dist/web-tools/http.js +81 -0
  65. package/dist/web-tools/routing.js +29 -0
  66. package/dist/web-tools/safety.js +73 -0
  67. package/dist/web-tools/search-providers.js +277 -0
  68. package/dist/web-tools/types.js +54 -0
  69. package/dist/web-tools/util.js +23 -0
  70. package/dist/web-tools.js +9 -798
  71. package/dist/web.js +416 -984
  72. package/npm-shrinkwrap.json +242 -201
  73. package/package.json +4 -4
  74. package/web/dist/assets/index-CSkxUQCr.js +63 -0
  75. package/web/dist/assets/index-DllM6RqL.css +2 -0
  76. package/web/dist/index.html +6 -3
  77. package/web/dist/assets/index-B23WT77N.js +0 -63
  78. package/web/dist/assets/index-D3MotFzN.css +0 -2
  79. /package/dist/{web-auth.js → web/auth.js} +0 -0
  80. /package/dist/{web-events.js → web/events.js} +0 -0
  81. /package/dist/{web-types.js → web/types.js} +0 -0
package/dist/discord.js CHANGED
@@ -1,585 +1,15 @@
1
- import { randomUUID } from "node:crypto";
2
- import { once } from "node:events";
3
- import { readFile } from "node:fs/promises";
4
- import { extname } from "node:path";
5
- import { ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, ChannelType, Client, Events, GatewayIntentBits, InteractionContextType, MessageFlags, Partials, } from "discord.js";
6
- import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
7
- import { chatChannelKey, createChatLog } from "./chat-log.js";
8
- import { materializeInboundAttachments, promptImagesFromAttachments } from "./inbound-attachments.js";
9
- import { ConversationRuntime } from "./runtime.js";
10
- import { appendSchedulerLog, buildCronInjectionText, buildHeartbeatInjectionText, dueCronSlot, formatIdleDuration, isHeartbeatDue, loadSchedulerState, saveSchedulerState, } from "./scheduler.js";
11
- import { parseAgentReply as parseSilentMarker } from "./silent-marker.js";
12
- const FAMILIAR_COMMAND_NAME = "familiar";
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 async function startDiscordDaemon(config, familiarAgent, settings, memoryService, options = {}) {
683
- const client = await withReadyClient(config.discord.token);
684
- console.log(`Discord connected as ${client.user.tag}`);
685
- const runtimes = new Map();
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 getWebSessions = async () => {
769
- const sessions = [];
770
- const dmChannel = await client.users.createDM(config.discord.ownerId);
771
- const dmRef = buildChannelRef(dmChannel, dmChannel.id);
772
- sessions.push({
773
- key: chatChannelKey(dmRef),
774
- label: "Main Chat",
775
- channel: dmRef,
776
- isDefault: true,
777
- });
778
- for (const channelId of config.discord.allowedChannels) {
779
- const channel = await client.channels.fetch(channelId).catch(() => undefined);
780
- if (!channel)
781
- continue;
782
- const ref = buildChannelRef(channel, channelId);
783
- sessions.push({
784
- key: chatChannelKey(ref),
785
- label: ref.channelName || `Discord ${ref.scope}`,
786
- channel: ref,
787
- });
788
- }
789
- return sessions;
790
- };
791
- const getOwnerDmSession = async () => {
792
- const dmChannel = await client.users.createDM(config.discord.ownerId);
793
- const runtime = await getRuntimeForChannel(buildChannelRef(dmChannel, dmChannel.id));
794
- return { runtime, channel: dmChannel };
795
- };
796
- const saveScheduler = async () => {
797
- await saveSchedulerState(config.workspace.dataDir, schedulerState);
798
- };
799
- const initializeHeartbeatState = async (runtime) => {
800
- if (!config.heartbeat.enabled || schedulerState.heartbeat)
801
- return;
802
- const now = Date.now();
803
- const lastUserInteractionAt = runtime.getLastUserInteractionAt();
804
- if (now - lastUserInteractionAt < config.heartbeat.idleThresholdMs)
805
- return;
806
- // Treat a cold start on an already-idle transcript as "we just fired at boot":
807
- // the standard cadence/first-fire branches in isHeartbeatDue then handle user-reply
808
- // vs. no-reply correctly without a separate suppression concept.
809
- schedulerState.heartbeat = { lastFiredAt: new Date(now).toISOString() };
810
- await saveScheduler();
811
- };
812
- const getRuntimeForWebChannel = async (channelKey) => {
813
- const sessions = await getWebSessions();
814
- const session = channelKey ? sessions.find((candidate) => candidate.key === channelKey) : sessions[0];
815
- if (!session)
816
- throw new Error(channelKey ? `Unknown web session: ${channelKey}` : "No Discord sessions available");
817
- return getRuntimeForChannel(session.channel);
818
- };
819
- const drainJobs = async (message, runtime) => {
820
- for (;;) {
821
- const dispatch = runtime.beginNextJob();
822
- if (!dispatch)
823
- return;
824
- const stopTyping = startTypingIndicator(message);
825
- try {
826
- const assistantMessageId = webMessageId();
827
- const summary = { thinking: "" };
828
- const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(dispatch.job.jobId, assistantMessageId, storedEvent, { notify: false }));
829
- let reply;
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
- reply = await promptForRuntime(runtime, dispatch.job.jobId, dispatch.prompt, dispatch.attachments, async (event) => {
832
- updateAgentEventSummary(summary, event);
833
- const storedEvent = storedAgentEventFromAgentEvent(event);
834
- if (storedEvent) {
835
- runtime.publishAgentEvent(dispatch.job.jobId, assistantMessageId, storedEvent);
836
- await recorder.record(storedEvent);
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
- finally {
841
- await recorder.flush();
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
- const parsedReply = parseAgentReply(reply.text);
844
- const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
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
- const assistantMessageId = webMessageId();
905
- const summary = { thinking: "" };
906
- const recorder = createAgentEventRecorder((storedEvent) => heartbeatRuntime.noteAgentEvent("heartbeat", assistantMessageId, storedEvent, { notify: false }));
907
- let reply;
191
+ };
192
+ const flushCollected = async (message, runtime) => {
193
+ collectTimers.delete(runtime.channelKey);
908
194
  try {
909
- reply = await promptScheduledMessage(heartbeatRuntime, async () => {
910
- const queuedNow = Date.now();
911
- const latestUserInteractionAt = heartbeatRuntime.getLastUserInteractionAt();
912
- if (heartbeatRuntime.hasLiveWork())
913
- return HEARTBEAT_SKIPPED;
914
- if (!heartbeatStillDue(config, queuedNow, latestUserInteractionAt, schedulerState.heartbeat?.lastFiredAt)) {
915
- return HEARTBEAT_SKIPPED;
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
- finally {
932
- await recorder.flush();
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
- await saveScheduler();
970
- };
971
- const completeCronSlot = async (job, slot) => {
972
- schedulerState.cron[job.id] = {
973
- ...schedulerState.cron[job.id],
974
- lastFiredSlot: slot,
975
- lastFiredAt: schedulerState.cron[job.id]?.lastFiredAt ?? new Date().toISOString(),
976
- ...(job.frequency === "once" ? { completed: true } : {}),
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
- await saveScheduler();
979
- };
980
- const runCronJob = async (job, slot, runtime, channel) => {
981
- await appendSchedulerLog(config.workspace.dataDir, {
982
- type: "cron_due",
983
- jobId: job.id,
984
- slot,
985
- deliveryMode: job.deliveryMode,
986
- });
987
- if (job.deliveryMode === "follow_up" && activeAgentOwner === runtime.channelKey) {
988
- const now = Date.now();
989
- const text = buildCronInjectionText({ job, slot, now });
990
- await appendSchedulerLog(config.workspace.dataDir, {
991
- type: "cron_started",
992
- jobId: job.id,
993
- slot,
994
- deliveryMode: job.deliveryMode,
995
- });
996
- await markCronSlotStarted(job, slot);
997
- await familiarAgent.followUpMessage(runtime.channelKey, scheduledUserMessage(text, now), {
998
- skipAmbient: true,
999
- });
1000
- await completeCronSlot(job, slot);
1001
- await appendSchedulerLog(config.workspace.dataDir, {
1002
- type: "cron_completed",
1003
- jobId: job.id,
1004
- slot,
1005
- deliveryMode: job.deliveryMode,
1006
- detail: "queued as follow-up",
1007
- });
1008
- return;
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
- await markCronSlotStarted(job, slot);
1027
- return scheduledUserMessage(buildCronInjectionText({ job, slot, now }), now);
1028
- }, async (event) => {
1029
- updateAgentEventSummary(summary, event);
1030
- const storedEvent = storedAgentEventFromAgentEvent(event);
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
- }, { skipAmbient: true });
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
- catch (error) {
1088
- const message = error instanceof Error ? error.message : String(error);
1089
- await appendSchedulerLog(config.workspace.dataDir, {
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
- finally {
1102
- cronRunning = false;
1103
- }
1104
- };
1105
- const flushCollected = async (message, runtime) => {
1106
- collectTimers.delete(runtime.channelKey);
1107
- try {
1108
- const isDm = isDmChannel(message.channel);
1109
- const queued = await runtime.queueLatestTrigger({
1110
- channelTrigger: getChannelTriggerSetting(config, settings, runtime.channelKey, isDm).value,
1111
- });
1112
- if (!queued)
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
- // The captured message is only a channel handle; drainJobs fetches the trigger record's message id for replies.
1115
- await drainJobs(message, runtime);
1116
- }
1117
- catch (error) {
1118
- console.error("Discord collect flush failed", error);
1119
- await runtime.appendError(error instanceof Error ? error.message : String(error));
1120
- }
1121
- };
1122
- const scheduleCollect = (message, runtime) => {
1123
- const existing = collectTimers.get(runtime.channelKey);
1124
- if (existing)
1125
- clearTimeout(existing);
1126
- const timer = setTimeout(() => {
1127
- void flushCollected(message, runtime);
1128
- }, config.discord.collectDebounceMs);
1129
- collectTimers.set(runtime.channelKey, timer);
1130
- };
1131
- const onMessageCreate = async (message) => {
1132
- if (!isAllowedMessage(config, message, client.user.id))
1133
- return;
1134
- let runtime;
1135
- try {
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 sendReply(config, message, text);
318
+ const messageIds = await replyEphemeral(interaction, text);
1154
319
  await runtime.noteOutbound({ text, messageIds, control: control.command });
1155
- return;
1156
320
  }
1157
- const dispatchMode = getDispatchMode(config, message);
1158
- const shouldTrySteer = dispatchMode === "steer" && runtime.hasActiveJob() && activeAgentOwner === runtime.channelKey;
1159
- const { record } = await runtime.ingestInbound(input, {
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
- await drainJobs(message, runtime);
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 onInteractionCreate = async (interaction) => {
1187
- if (interaction.isAutocomplete()) {
1188
- if (interaction.commandName !== FAMILIAR_COMMAND_NAME)
1189
- return;
1190
- if (!isAllowedInteractionChannel(config, interaction)) {
1191
- await interaction.respond([]);
1192
- return;
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 interaction.deferReply({ flags: EPHEMERAL_REPLY });
1213
- runtime = await getInteractionRuntime(interaction);
1214
- const isDm = isDmChannel(interaction.channel);
1215
- const channelTrigger = getChannelTriggerSetting(config, settings, runtime.channelKey, isDm);
1216
- const input = inboundInputFromInteraction(interaction);
1217
- const control = runtime.parseControlCommand(input);
1218
- if (!control)
1219
- throw new Error("Unsupported Familiar command.");
1220
- await runtime.noteControlCommand(input, control);
1221
- const text = await applyControlCommand({
1222
- control,
1223
- runtime,
1224
- familiarAgent,
1225
- settings,
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
- await runtime?.appendError(error instanceof Error ? error.message : String(error));
1236
- await replyInteractionError(interaction, error);
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
- await registerFamiliarApplicationCommand(client);
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
- client.off(Events.MessageCreate, onMessageCreate);
1286
- client.off(Events.InteractionCreate, onInteractionCreate);
1287
- if (heartbeatTimer)
1288
- clearInterval(heartbeatTimer);
1289
- if (cronTimer)
1290
- clearInterval(cronTimer);
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
- const resolvedRuntimes = await Promise.all([...runtimes.values()].map((runtime) => runtime.catch(() => undefined)));
1295
- await Promise.all(resolvedRuntimes.flatMap((runtime) => (runtime ? [runtime.disconnect()] : [])));
1296
- client.destroy();
376
+ liveClient?.destroy();
1297
377
  },
1298
378
  };
1299
379
  }