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