@qearlyao/familiar 0.2.5 → 0.4.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 (123) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +33 -0
  3. package/config.example.toml +4 -2
  4. package/dist/{agent.js → agent/factory.js} +97 -328
  5. package/dist/agent/payload-normalizers.js +52 -0
  6. package/dist/agent/session-helpers.js +86 -0
  7. package/dist/agent/tool-descriptions.js +4 -0
  8. package/dist/agent/tools.js +30 -0
  9. package/dist/agent/transcript-log.js +93 -0
  10. package/dist/cli.js +45 -15
  11. package/dist/config/enums.js +35 -0
  12. package/dist/{config.js → config/index.js} +9 -272
  13. package/dist/config/interpolate.js +15 -0
  14. package/dist/config/model-refs.js +11 -0
  15. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  16. package/dist/config/readers.js +116 -0
  17. package/dist/{config-registry.js → config/registry.js} +27 -8
  18. package/dist/config/sections.js +113 -0
  19. package/dist/{settings.js → config/settings.js} +5 -2
  20. package/dist/config/types.js +1 -0
  21. package/dist/{chat-log.js → conversation/chat-log.js} +16 -4
  22. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  23. package/dist/conversation/ids.js +11 -0
  24. package/dist/conversation/owner-identity.js +29 -0
  25. package/dist/discord/channel.js +32 -0
  26. package/dist/discord/chunking.js +163 -0
  27. package/dist/discord/client.js +44 -0
  28. package/dist/discord/commands.js +181 -0
  29. package/dist/discord/daemon.js +379 -0
  30. package/dist/discord/inbound.js +44 -0
  31. package/dist/discord/send.js +115 -0
  32. package/dist/discord/turn.js +55 -0
  33. package/dist/index.js +12 -11
  34. package/dist/lifecycle/control.js +1 -0
  35. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  36. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  37. package/dist/{service.js → lifecycle/service.js} +1 -0
  38. package/dist/media/attachment-limits.js +3 -0
  39. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  40. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  41. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  42. package/dist/media/media-understanding.js +215 -0
  43. package/dist/memory/index/store.js +21 -17
  44. package/dist/memory/index/vector-codec.js +2 -2
  45. package/dist/memory/lcm/context-transformer.js +6 -2
  46. package/dist/memory/lcm/segment-manager.js +6 -2
  47. package/dist/memory/lcm/store/index-ids.js +6 -0
  48. package/dist/memory/lcm/store/inserts.js +31 -0
  49. package/dist/memory/lcm/store/normalizers.js +91 -0
  50. package/dist/memory/lcm/store/row-mappers.js +114 -0
  51. package/dist/memory/lcm/store/row-types.js +1 -0
  52. package/dist/memory/lcm/store/serialization.js +37 -0
  53. package/dist/memory/lcm/store/snapshots.js +73 -0
  54. package/dist/memory/lcm/store.js +20 -360
  55. package/dist/memory/lcm/summarizer.js +1 -1
  56. package/dist/{added-models.js → models/added-models.js} +1 -1
  57. package/dist/{persona.js → prompting/persona.js} +1 -1
  58. package/dist/runtime/agent-core.js +82 -0
  59. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  60. package/dist/runtime/agent-work-queue.js +55 -0
  61. package/dist/{runtime.js → runtime/conversation-runtime.js} +91 -43
  62. package/dist/runtime/runtime-manager.js +51 -0
  63. package/dist/runtime/scheduler-runner.js +243 -0
  64. package/dist/{scheduler.js → runtime/scheduler.js} +4 -4
  65. package/dist/{browser-tools.js → tools/browser-tools.js} +24 -34
  66. package/dist/util/fs.js +2 -1
  67. package/dist/web/agent-routes.js +104 -0
  68. package/dist/web/auth-routes.js +39 -0
  69. package/dist/web/auth.js +205 -0
  70. package/dist/web/config-routes.js +55 -0
  71. package/dist/web/conversation-routes.js +122 -0
  72. package/dist/web/daemon.js +108 -0
  73. package/dist/web/diary-routes.js +88 -0
  74. package/dist/web/errors.js +3 -0
  75. package/dist/web/event-hub.js +246 -0
  76. package/dist/{web-http.js → web/http.js} +19 -5
  77. package/dist/web/memes.js +25 -0
  78. package/dist/web/messages.js +348 -0
  79. package/dist/web/multipart.js +86 -0
  80. package/dist/web/payloads.js +34 -0
  81. package/dist/web/request-context.js +25 -0
  82. package/dist/web/route-helpers.js +9 -0
  83. package/dist/web/routes.js +37 -0
  84. package/dist/web/runtime-actions.js +231 -0
  85. package/dist/web/session-store.js +161 -0
  86. package/dist/{web-static.js → web/static.js} +19 -14
  87. package/dist/web/stream.js +78 -0
  88. package/dist/web-tools/cache.js +42 -0
  89. package/dist/web-tools/config.js +16 -0
  90. package/dist/web-tools/fetch-providers.js +119 -0
  91. package/dist/web-tools/format.js +88 -0
  92. package/dist/web-tools/http.js +81 -0
  93. package/dist/web-tools/index.js +152 -0
  94. package/dist/web-tools/routing.js +29 -0
  95. package/dist/web-tools/safety.js +73 -0
  96. package/dist/web-tools/search-providers.js +277 -0
  97. package/dist/web-tools/types.js +54 -0
  98. package/dist/web-tools/util.js +23 -0
  99. package/npm-shrinkwrap.json +319 -201
  100. package/package.json +6 -4
  101. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  102. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  103. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  104. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  105. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  106. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  107. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  108. package/web/dist/index.html +11 -3
  109. package/dist/discord.js +0 -1299
  110. package/dist/media-understanding.js +0 -120
  111. package/dist/web-auth.js +0 -111
  112. package/dist/web-tools.js +0 -941
  113. package/dist/web.js +0 -1209
  114. package/web/dist/assets/index-B23WT77N.js +0 -63
  115. package/web/dist/assets/index-D3MotFzN.css +0 -2
  116. /package/dist/{control.js → agent/types.js} +0 -0
  117. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  118. /package/dist/{tts.js → media/tts.js} +0 -0
  119. /package/dist/{models.js → models/index.js} +0 -0
  120. /package/dist/{skills.js → prompting/skills.js} +0 -0
  121. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
  122. /package/dist/{web-events.js → web/events.js} +0 -0
  123. /package/dist/{web-types.js → web/types.js} +0 -0
@@ -0,0 +1,379 @@
1
+ import { Events, } from "discord.js";
2
+ import { formatSetting } from "../config/settings.js";
3
+ import { chatChannelKey } from "../conversation/chat-log.js";
4
+ import { saveOwnerIdentity } from "../conversation/owner-identity.js";
5
+ import { thinkingDurationMs } from "../runtime/agent-events.js";
6
+ import { buildChannelRef, fetchMessageAnchor, getChannelRef, isDmChannel, runtimeKeyFromMessage, } from "./channel.js";
7
+ import { isAllowedMessage, withReadyClient } from "./client.js";
8
+ import { EPHEMERAL_REPLY, FAMILIAR_COMMAND_NAME, formatCommandResponse, getAutocompleteChoices, inboundInputFromInteraction, isAllowedInteractionChannel, registerFamiliarApplicationCommand, replyEphemeral, replyInteractionError, } from "./commands.js";
9
+ import { canSteerFromRecord, getChannelTriggerSetting, getDispatchMode, toInboundInput } from "./inbound.js";
10
+ import { sendChannelMessage, sendDiscordAttachments, sendReply } from "./send.js";
11
+ import { isCanceledJob, runAgentTurn } from "./turn.js";
12
+ const RETRY_MS = 15_000;
13
+ async function applyControlCommand(options) {
14
+ const { control, runtime, familiarAgent, settings, channelTrigger, isDm, activeAgentOwner, restart } = options;
15
+ if (control.command === "stop") {
16
+ if (runtime.hasActiveJob() && activeAgentOwner === runtime.channelKey)
17
+ familiarAgent.abort(runtime.channelKey);
18
+ await runtime.resetConversation("stop requested");
19
+ return "Stopped current work and cleared the chat queue.";
20
+ }
21
+ if (control.command === "new") {
22
+ await familiarAgent.reset(runtime.channelKey);
23
+ await runtime.resetConversation("new conversation requested");
24
+ return "Started a fresh agent transcript for this channel.";
25
+ }
26
+ if (control.command === "reload") {
27
+ return familiarAgent.reload();
28
+ }
29
+ if (control.command === "restart") {
30
+ return restart
31
+ ? await restart()
32
+ : "Restart requested, but no restart handler is configured. Please restart the Familiar process manually.";
33
+ }
34
+ if (control.command === "model") {
35
+ return control.args
36
+ ? await familiarAgent.setModel(runtime.channelKey, control.args)
37
+ : `Current model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`;
38
+ }
39
+ if (control.command === "thinking") {
40
+ return control.args
41
+ ? await familiarAgent.setThinkingLevel(runtime.channelKey, control.args)
42
+ : `Current thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`;
43
+ }
44
+ if (control.command === "channel-trigger") {
45
+ if (isDm) {
46
+ return "DM channel trigger is always.";
47
+ }
48
+ const triggerInput = control.args.trim().toLowerCase();
49
+ if (triggerInput && triggerInput !== "mention" && triggerInput !== "always") {
50
+ throw new Error("Usage: /channel-trigger mention|always");
51
+ }
52
+ const trigger = triggerInput === "mention" || triggerInput === "always" ? triggerInput : undefined;
53
+ if (trigger) {
54
+ await settings.setChannelTrigger(runtime.channelKey, trigger);
55
+ return `Channel trigger set to ${trigger} for this channel`;
56
+ }
57
+ return `Current channel trigger: ${formatSetting(channelTrigger)}`;
58
+ }
59
+ return formatCommandResponse(control.command, runtime, familiarAgent, channelTrigger);
60
+ }
61
+ function startTypingIndicator(message) {
62
+ const sendTyping = () => {
63
+ if (!message.channel.isSendable())
64
+ return;
65
+ void message.channel.sendTyping().catch(() => undefined);
66
+ };
67
+ sendTyping();
68
+ const timer = setInterval(sendTyping, 8000);
69
+ return () => {
70
+ clearInterval(timer);
71
+ };
72
+ }
73
+ export function startDiscordDaemon(config, token, familiarAgent, settings, _memoryService, core, options = {}) {
74
+ let client;
75
+ let session;
76
+ let stopped = false;
77
+ let retryTimer;
78
+ const collectTimers = new Map();
79
+ const getRuntime = async (message) => {
80
+ return core.getRuntimeForChannel(getChannelRef(message));
81
+ };
82
+ const getInteractionRuntime = async (interaction) => {
83
+ const channel = interaction.channel;
84
+ if (!channel || !interaction.channelId)
85
+ throw new Error("Discord interaction has no channel");
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(token, channel.id, reply.attachments)
142
+ : await sendChannelMessage(config, token, 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);
151
+ try {
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(token, replyAnchor.channelId, reply.attachments)
159
+ : await sendReply(config, token, 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,
169
+ });
170
+ }
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, token, replyAnchor, fallback, dispatch.triggerMessageId);
180
+ await runtime.noteOutbound({
181
+ text: fallback,
182
+ messageIds,
183
+ replyToMessageId: dispatch.triggerMessageId,
184
+ jobId: dispatch.job.jobId,
185
+ });
186
+ }
187
+ finally {
188
+ stopTyping();
189
+ }
190
+ }
191
+ };
192
+ const flushCollected = async (message, runtime) => {
193
+ collectTimers.delete(runtime.channelKey);
194
+ try {
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);
203
+ }
204
+ catch (error) {
205
+ console.error("Discord collect flush failed", error);
206
+ await runtime.appendError(error instanceof Error ? error.message : String(error));
207
+ }
208
+ };
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);
217
+ };
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, token, 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,
249
+ });
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;
255
+ }
256
+ if (shouldTrySteer) {
257
+ await runtime.queueLatestTrigger({ channelTrigger: channelTrigger.value });
258
+ }
259
+ if (dispatchMode === "collect") {
260
+ scheduleCollect(message, runtime);
261
+ return;
262
+ }
263
+ await drainJobs(message, runtime);
264
+ }
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, token, 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
+ });
284
+ return;
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.");
307
+ await runtime.noteControlCommand(input, control);
308
+ const text = await applyControlCommand({
309
+ control,
310
+ runtime,
311
+ familiarAgent,
312
+ settings,
313
+ channelTrigger,
314
+ isDm,
315
+ activeAgentOwner: core.activeOwner,
316
+ restart: options.restart,
317
+ });
318
+ const messageIds = await replyEphemeral(interaction, text);
319
+ await runtime.noteOutbound({ text, messageIds, control: control.command });
320
+ }
321
+ catch (error) {
322
+ await runtime?.appendError(error instanceof Error ? error.message : String(error));
323
+ await replyInteractionError(interaction, error);
324
+ }
325
+ };
326
+ return { onMessageCreate, onInteractionCreate, getOwnerDmSession, getWebSessions, liveSink };
327
+ };
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)
335
+ return;
336
+ try {
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,
351
+ });
352
+ }
353
+ catch (error) {
354
+ console.error("Discord connect failed; retrying in background", error);
355
+ if (!stopped)
356
+ retryTimer = setTimeout(() => void connect(), RETRY_MS);
357
+ }
358
+ };
359
+ void connect();
360
+ return {
361
+ async stop() {
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
+ }
373
+ for (const timer of collectTimers.values())
374
+ clearTimeout(timer);
375
+ collectTimers.clear();
376
+ liveClient?.destroy();
377
+ },
378
+ };
379
+ }
@@ -0,0 +1,44 @@
1
+ import { materializeInboundAttachments } from "../media/inbound-attachments.js";
2
+ import { isDmChannel, messageMentionsBot } from "./channel.js";
3
+ export function getDispatchMode(config, message) {
4
+ return isDmChannel(message.channel) ? config.discord.dmMode : config.discord.channelMode;
5
+ }
6
+ export function getChannelTriggerSetting(config, settings, channelKey, isDm) {
7
+ if (isDm)
8
+ return { value: "always", source: "config" };
9
+ return settings.getChannelTrigger(channelKey, config.discord.channelTrigger);
10
+ }
11
+ export function canSteerFromRecord(config, message, runtime, record, activeAgentOwner, channelTrigger) {
12
+ if (getDispatchMode(config, message) !== "steer")
13
+ return false;
14
+ if (!runtime.hasActiveJob() || activeAgentOwner !== runtime.channelKey)
15
+ return false;
16
+ if (isDmChannel(message.channel))
17
+ return record.authorId === config.discord.ownerId && !record.isBot;
18
+ if (channelTrigger === "always")
19
+ return true;
20
+ return record.mentionedBot;
21
+ }
22
+ export async function toInboundInput(config, message, botUserId) {
23
+ const attachments = await materializeInboundAttachments(config, [...message.attachments.values()].map((attachment) => ({
24
+ id: attachment.id,
25
+ name: attachment.name,
26
+ mimeType: attachment.contentType ?? undefined,
27
+ size: attachment.size,
28
+ url: attachment.url,
29
+ source: "discord",
30
+ })));
31
+ return {
32
+ messageId: message.id,
33
+ authorId: message.author.id,
34
+ authorName: message.author.username,
35
+ text: message.content || "",
36
+ isBot: message.author.bot,
37
+ mentionedBot: messageMentionsBot(message, botUserId),
38
+ remoteTimestamp: new Date(message.createdTimestamp || Date.now()).toISOString(),
39
+ checkpoint: {
40
+ messageId: message.id,
41
+ },
42
+ attachments,
43
+ };
44
+ }
@@ -0,0 +1,115 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname } from "node:path";
3
+ import { parseAgentReply as parseSilentMarker } from "../runtime/silent-marker.js";
4
+ import { chunkDiscord } from "./chunking.js";
5
+ const NEWLINE_BURST_DELAY_MS = 500;
6
+ const DISCORD_ATTACHMENT_SEND_TIMEOUT_MS = 120_000;
7
+ const DISCORD_API_BASE_URL = "https://discord.com/api/v10";
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ async function delayBetweenBurstChunks(config, channel) {
12
+ if (config.discord.chunkMode !== "newline")
13
+ return;
14
+ if (channel.isSendable()) {
15
+ void channel.sendTyping().catch(() => undefined);
16
+ }
17
+ await sleep(NEWLINE_BURST_DELAY_MS);
18
+ }
19
+ export function normalizeOutboundText(text) {
20
+ return text.trim() || "(empty response)";
21
+ }
22
+ function fallbackMimeType(name) {
23
+ return extname(name).toLowerCase() === ".mp3" ? "audio/mpeg" : "application/octet-stream";
24
+ }
25
+ export async function buildDiscordAttachmentFiles(attachments) {
26
+ const files = [];
27
+ for (const attachment of attachments) {
28
+ if (!attachment.localPath)
29
+ continue;
30
+ const data = await readFile(attachment.localPath);
31
+ files.push({
32
+ name: attachment.name,
33
+ data,
34
+ contentType: attachment.mimeType || fallbackMimeType(attachment.name),
35
+ });
36
+ }
37
+ return files;
38
+ }
39
+ export async function postDiscordAttachments(botToken, channelId, attachments) {
40
+ const files = await buildDiscordAttachmentFiles(attachments);
41
+ if (files.length === 0)
42
+ return [];
43
+ const form = new FormData();
44
+ form.set("payload_json", JSON.stringify({}));
45
+ for (const [index, file] of files.entries()) {
46
+ const bytes = new Uint8Array(file.data.byteLength);
47
+ bytes.set(file.data);
48
+ form.set(`files[${index}]`, new Blob([bytes], { type: file.contentType }), file.name);
49
+ }
50
+ const response = await fetch(`${DISCORD_API_BASE_URL}/channels/${channelId}/messages`, {
51
+ method: "POST",
52
+ headers: { Authorization: `Bot ${botToken}` },
53
+ body: form,
54
+ signal: AbortSignal.timeout(DISCORD_ATTACHMENT_SEND_TIMEOUT_MS),
55
+ });
56
+ const data = (await response.json().catch(() => ({})));
57
+ if (!response.ok || !data.id)
58
+ throw new Error(data.message || `Discord attachment send failed (${response.status})`);
59
+ return [data.id];
60
+ }
61
+ export function parseOutboundReply(text) {
62
+ const parsed = parseSilentMarker(text);
63
+ if (parsed.silent)
64
+ return parsed;
65
+ return { text: normalizeOutboundText(parsed.text), silent: false };
66
+ }
67
+ async function sendChunkedMessage(config, botToken, channel, channelId, normalizedText, attachments, sendChunk) {
68
+ const chunks = chunkDiscord(config, normalizedText);
69
+ const sentIds = [];
70
+ for (const [index, chunk] of chunks.entries()) {
71
+ if (index > 0)
72
+ await delayBetweenBurstChunks(config, channel);
73
+ const sent = await sendChunk(chunk, index);
74
+ sentIds.push(sent.id);
75
+ }
76
+ const attachmentIds = await sendDiscordAttachments(botToken, channelId, attachments);
77
+ return [...sentIds, ...attachmentIds];
78
+ }
79
+ export async function sendReply(config, botToken, message, text, replyToMessageId, attachments = []) {
80
+ const normalizedText = normalizeOutboundText(text);
81
+ return sendChunkedMessage(config, botToken, message.channel, message.channelId, normalizedText, attachments, async (chunk, index) => {
82
+ if (!message.channel.isSendable()) {
83
+ throw new Error(`Discord channel is not sendable: ${message.channelId}`);
84
+ }
85
+ if (index === 0 && config.discord.replyMode === "reply") {
86
+ try {
87
+ const replyTarget = replyToMessageId || message.id;
88
+ const options = { content: chunk, reply: { messageReference: replyTarget } };
89
+ return await message.channel.send(options);
90
+ }
91
+ catch (error) {
92
+ console.error("Discord reply failed; falling back to channel send", error);
93
+ }
94
+ }
95
+ return message.channel.send(chunk);
96
+ });
97
+ }
98
+ export async function sendChannelMessage(config, botToken, channel, text, attachments = []) {
99
+ if (!channel.isSendable()) {
100
+ throw new Error("Discord channel is not sendable");
101
+ }
102
+ const normalizedText = normalizeOutboundText(text);
103
+ return sendChunkedMessage(config, botToken, channel, channel.id, normalizedText, attachments, (chunk) => channel.send(chunk));
104
+ }
105
+ export async function sendDiscordAttachments(botToken, channelId, attachments) {
106
+ if (attachments.length === 0)
107
+ return [];
108
+ try {
109
+ return await postDiscordAttachments(botToken, channelId, attachments);
110
+ }
111
+ catch (error) {
112
+ console.error("Discord attachment send failed", error);
113
+ return [];
114
+ }
115
+ }
@@ -0,0 +1,55 @@
1
+ import { messageId } from "../conversation/ids.js";
2
+ import { createAgentEventRecorder, storedAgentEventFromAgentEvent, updateAgentEventSummary, } from "../runtime/agent-events.js";
3
+ import { isHeartbeatDue } from "../runtime/scheduler.js";
4
+ import { parseOutboundReply } from "./send.js";
5
+ export const HEARTBEAT_SKIPPED = Symbol("heartbeat-skipped");
6
+ export const CRON_SKIPPED = Symbol("cron-skipped");
7
+ export function isCanceledJob(error) {
8
+ if (!(error instanceof Error))
9
+ return false;
10
+ return error.name === "CanceledJobError" || error.name === "AbortError" || /aborted|abort/i.test(error.message);
11
+ }
12
+ export function canceledJobError() {
13
+ const error = new Error("Job was canceled before completion.");
14
+ error.name = "CanceledJobError";
15
+ return error;
16
+ }
17
+ export function scheduledUserMessage(text, timestamp) {
18
+ return { role: "user", content: [{ type: "text", text }], timestamp };
19
+ }
20
+ export function heartbeatStillDue(config, now, lastUserInteractionAt, lastHeartbeatAt) {
21
+ return isHeartbeatDue({
22
+ now,
23
+ lastUserInteractionAt,
24
+ lastHeartbeatAt,
25
+ idleThresholdMs: config.heartbeat.idleThresholdMs,
26
+ intervalMs: config.heartbeat.intervalMs,
27
+ });
28
+ }
29
+ export async function runAgentTurn(jobKey, runtime, prompt) {
30
+ const assistantMessageId = messageId();
31
+ const summary = { thinking: "" };
32
+ const recorder = createAgentEventRecorder((storedEvent) => runtime.noteAgentEvent(jobKey, assistantMessageId, storedEvent, { notify: false }));
33
+ let reply;
34
+ try {
35
+ reply = await prompt(async (event) => {
36
+ updateAgentEventSummary(summary, event);
37
+ const storedEvent = storedAgentEventFromAgentEvent(event);
38
+ if (storedEvent) {
39
+ runtime.publishAgentEvent(jobKey, assistantMessageId, storedEvent);
40
+ await recorder.record(storedEvent);
41
+ }
42
+ });
43
+ }
44
+ finally {
45
+ await recorder.flush();
46
+ }
47
+ if (reply === HEARTBEAT_SKIPPED || reply === CRON_SKIPPED)
48
+ return null;
49
+ return {
50
+ reply,
51
+ parsedReply: parseOutboundReply(reply.text),
52
+ summary,
53
+ assistantMessageId,
54
+ };
55
+ }
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
- export { createFamiliarAgent } from "./agent.js";
2
- export { buildRecordBase, chatChannelKey, chatLogPath, createChatLog, } from "./chat-log.js";
3
- export { loadConfig } from "./config.js";
4
- export { startDiscordDaemon } from "./discord.js";
5
- export { startWorkspaceHotReload } from "./hot-reload.js";
6
- export { clampConfiguredThinkingLevel, createConfiguredModel, describeModelAuth, formatAllowedModels, isAllowedModel, isThinkingLevel, parseModelRef, resolveModel, resolveModelApiKey, supportedThinkingLevels, } from "./models.js";
7
- export { buildSystemPrompt, loadPersona } from "./persona.js";
8
- export { ConversationRuntime, } from "./runtime.js";
9
- export { loadSettingsStore, } from "./settings.js";
10
- export { formatFamiliarSkillsForPrompt, loadFamiliarSkills } from "./skills.js";
11
- export { startWebDaemon } from "./web.js";
1
+ export { createFamiliarAgent } from "./agent/factory.js";
2
+ export { loadConfig } from "./config/index.js";
3
+ export { loadSettingsStore, } from "./config/settings.js";
4
+ export { buildRecordBase, chatChannelKey, chatLogPath, createChatLog, } from "./conversation/chat-log.js";
5
+ export { startDiscordDaemon } from "./discord/daemon.js";
6
+ export { startWorkspaceHotReload } from "./lifecycle/hot-reload.js";
7
+ export { clampConfiguredThinkingLevel, createConfiguredModel, describeModelAuth, formatAllowedModels, isAllowedModel, isThinkingLevel, parseModelRef, resolveModel, resolveModelApiKey, supportedThinkingLevels, } from "./models/index.js";
8
+ export { buildSystemPrompt, loadPersona } from "./prompting/persona.js";
9
+ export { formatFamiliarSkillsForPrompt, loadFamiliarSkills } from "./prompting/skills.js";
10
+ export { createAgentCore } from "./runtime/agent-core.js";
11
+ export { ConversationRuntime, } from "./runtime/conversation-runtime.js";
12
+ export { startWebDaemon } from "./web/daemon.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { lstat, readdir, rm } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
- import { isEnoent } from "./util/fs.js";
3
+ import { isEnoent } from "../util/fs.js";
4
4
  export async function runDataRetention(config, now = Date.now()) {
5
5
  if (config.data.transcripts.retentionDays > 0) {
6
6
  console.warn("data.transcripts.retention_days is configured; transcript replay may lose restart context after retention.");
@@ -1,8 +1,8 @@
1
1
  import { watch } from "node:fs";
2
2
  import { readdir } from "node:fs/promises";
3
3
  import { basename, relative, resolve, sep } from "node:path";
4
- import { refreshContactNote } from "./contact-note.js";
5
- import { isEnoent } from "./util/fs.js";
4
+ import { refreshContactNote } from "../conversation/contact-note.js";
5
+ import { isEnoent } from "../util/fs.js";
6
6
  const ROOT_FILES = new Set([
7
7
  "config.toml",
8
8
  ".env",
@@ -279,6 +279,7 @@ export async function upgradeFamiliar(workspacePath, options = {}) {
279
279
  const npmCommand = currentPlatform === "win32" ? "npm.cmd" : "npm";
280
280
  const familiarCommand = currentPlatform === "win32" ? "familiar.cmd" : "familiar";
281
281
  await runInteractive(npmCommand, ["install", "-g", "@qearlyao/familiar@latest"], options, "npm upgrade");
282
+ await runInteractive(npmCommand, ["install", "-g", "@jackwener/opencli"], options, "OpenCLI upgrade");
282
283
  await runInteractive(familiarCommand, ["init", workspacePath], options, "workspace default refresh");
283
284
  }
284
285
  export function formatServiceResult(result) {
@@ -0,0 +1,3 @@
1
+ export const MAX_INBOUND_ATTACHMENTS = 4;
2
+ export const MAX_INBOUND_ATTACHMENT_BYTES = 32 * 1024 * 1024;
3
+ export const MAX_INBOUND_TOTAL_BYTES = 96 * 1024 * 1024;
@@ -1,6 +1,6 @@
1
1
  import { lstat, mkdir, readdir, rm } from "node:fs/promises";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
- import { isEnoent } from "./util/fs.js";
3
+ import { isEnoent } from "../util/fs.js";
4
4
  export function createGeneratedMediaSink() {
5
5
  const attachments = [];
6
6
  return {