@qearlyao/familiar 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +4 -0
  2. package/config.example.toml +2 -2
  3. package/dist/agent/payload-normalizers.js +52 -0
  4. package/dist/agent/session-helpers.js +86 -0
  5. package/dist/agent/tool-descriptions.js +4 -0
  6. package/dist/agent/tools.js +30 -0
  7. package/dist/agent/transcript-log.js +93 -0
  8. package/dist/agent/types.js +1 -0
  9. package/dist/agent-core.js +82 -0
  10. package/dist/agent-work-queue.js +55 -0
  11. package/dist/agent.js +91 -322
  12. package/dist/browser-tools.js +80 -28
  13. package/dist/chat-log.js +15 -3
  14. package/dist/cli.js +36 -6
  15. package/dist/config/enums.js +35 -0
  16. package/dist/config/interpolate.js +15 -0
  17. package/dist/config/model-refs.js +11 -0
  18. package/dist/config/readers.js +116 -0
  19. package/dist/config/sections.js +113 -0
  20. package/dist/config/types.js +1 -0
  21. package/dist/config-registry.js +26 -7
  22. package/dist/config.js +8 -271
  23. package/dist/discord/channel.js +32 -0
  24. package/dist/discord/chunking.js +163 -0
  25. package/dist/discord/client.js +44 -0
  26. package/dist/discord/commands.js +181 -0
  27. package/dist/discord/inbound.js +44 -0
  28. package/dist/discord/send.js +106 -0
  29. package/dist/discord/turn.js +55 -0
  30. package/dist/discord.js +266 -1186
  31. package/dist/ids.js +11 -0
  32. package/dist/image-gen.js +90 -10
  33. package/dist/index.js +1 -0
  34. package/dist/memory/index/store.js +21 -17
  35. package/dist/memory/index/vector-codec.js +2 -2
  36. package/dist/memory/lcm/context-transformer.js +6 -2
  37. package/dist/memory/lcm/segment-manager.js +6 -2
  38. package/dist/memory/lcm/store/index-ids.js +6 -0
  39. package/dist/memory/lcm/store/inserts.js +31 -0
  40. package/dist/memory/lcm/store/normalizers.js +91 -0
  41. package/dist/memory/lcm/store/row-mappers.js +114 -0
  42. package/dist/memory/lcm/store/row-types.js +1 -0
  43. package/dist/memory/lcm/store/serialization.js +37 -0
  44. package/dist/memory/lcm/store/snapshots.js +73 -0
  45. package/dist/memory/lcm/store.js +20 -360
  46. package/dist/owner-identity.js +29 -0
  47. package/dist/runtime-manager.js +51 -0
  48. package/dist/runtime.js +89 -41
  49. package/dist/scheduler-runner.js +243 -0
  50. package/dist/scheduler.js +1 -1
  51. package/dist/service.js +1 -0
  52. package/dist/settings.js +3 -0
  53. package/dist/util/fs.js +1 -1
  54. package/dist/web/event-hub.js +246 -0
  55. package/dist/{web-http.js → web/http.js} +19 -5
  56. package/dist/web/memes.js +25 -0
  57. package/dist/web/messages.js +345 -0
  58. package/dist/web/multipart.js +80 -0
  59. package/dist/web/payloads.js +34 -0
  60. package/dist/{web-static.js → web/static.js} +19 -14
  61. package/dist/web/stream.js +69 -0
  62. package/dist/web-tools/cache.js +42 -0
  63. package/dist/web-tools/config.js +16 -0
  64. package/dist/web-tools/fetch-providers.js +119 -0
  65. package/dist/web-tools/format.js +88 -0
  66. package/dist/web-tools/http.js +81 -0
  67. package/dist/web-tools/routing.js +29 -0
  68. package/dist/web-tools/safety.js +73 -0
  69. package/dist/web-tools/search-providers.js +277 -0
  70. package/dist/web-tools/types.js +54 -0
  71. package/dist/web-tools/util.js +23 -0
  72. package/dist/web-tools.js +9 -798
  73. package/dist/web.js +416 -984
  74. package/npm-shrinkwrap.json +242 -201
  75. package/package.json +4 -4
  76. package/web/dist/assets/index-CSkxUQCr.js +63 -0
  77. package/web/dist/assets/index-DllM6RqL.css +2 -0
  78. package/web/dist/index.html +6 -3
  79. package/web/dist/assets/index-B23WT77N.js +0 -63
  80. package/web/dist/assets/index-D3MotFzN.css +0 -2
  81. /package/dist/{web-auth.js → web/auth.js} +0 -0
  82. /package/dist/{web-events.js → web/events.js} +0 -0
  83. /package/dist/{web-types.js → web/types.js} +0 -0
@@ -0,0 +1,181 @@
1
+ import { ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, ChannelType, InteractionContextType, MessageFlags, } from "discord.js";
2
+ import { formatSetting } from "../settings.js";
3
+ import { normalizeOutboundText } from "./send.js";
4
+ export const FAMILIAR_COMMAND_NAME = "familiar";
5
+ const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh"];
6
+ const CHANNEL_TRIGGER_CHOICES = ["mention", "always"];
7
+ export const EPHEMERAL_REPLY = MessageFlags.Ephemeral;
8
+ export function getFamiliarApplicationCommand() {
9
+ const modelOption = {
10
+ name: "model",
11
+ description: "Provider/model id",
12
+ type: ApplicationCommandOptionType.String,
13
+ required: false,
14
+ autocomplete: true,
15
+ };
16
+ return {
17
+ name: FAMILIAR_COMMAND_NAME,
18
+ description: "Control Familiar",
19
+ type: ApplicationCommandType.ChatInput,
20
+ contexts: [InteractionContextType.Guild, InteractionContextType.BotDM],
21
+ integrationTypes: [ApplicationIntegrationType.GuildInstall],
22
+ options: [
23
+ {
24
+ name: "status",
25
+ description: "Show Familiar status for this channel",
26
+ type: ApplicationCommandOptionType.Subcommand,
27
+ },
28
+ {
29
+ name: "stop",
30
+ description: "Stop current work and clear the queue",
31
+ type: ApplicationCommandOptionType.Subcommand,
32
+ },
33
+ {
34
+ name: "new",
35
+ description: "Start a fresh agent transcript for this channel",
36
+ type: ApplicationCommandOptionType.Subcommand,
37
+ },
38
+ {
39
+ name: "reload",
40
+ description: "Reload persona prompt files and live agent settings",
41
+ type: ApplicationCommandOptionType.Subcommand,
42
+ },
43
+ {
44
+ name: "restart",
45
+ description: "Restart Familiar if this runtime has a restart handler",
46
+ type: ApplicationCommandOptionType.Subcommand,
47
+ },
48
+ {
49
+ name: "compact",
50
+ description: "Show compaction status",
51
+ type: ApplicationCommandOptionType.Subcommand,
52
+ },
53
+ {
54
+ name: "model",
55
+ description: "Show or set the model for this channel",
56
+ type: ApplicationCommandOptionType.Subcommand,
57
+ options: [modelOption],
58
+ },
59
+ {
60
+ name: "thinking",
61
+ description: "Show or set thinking level for this channel",
62
+ type: ApplicationCommandOptionType.Subcommand,
63
+ options: [
64
+ {
65
+ name: "level",
66
+ description: "Thinking level",
67
+ type: ApplicationCommandOptionType.String,
68
+ required: false,
69
+ choices: THINKING_CHOICES.map((level) => ({ name: level, value: level })),
70
+ },
71
+ ],
72
+ },
73
+ {
74
+ name: "channel-trigger",
75
+ description: "Show or set when Familiar responds in this channel",
76
+ type: ApplicationCommandOptionType.Subcommand,
77
+ options: [
78
+ {
79
+ name: "trigger",
80
+ description: "Channel trigger policy",
81
+ type: ApplicationCommandOptionType.String,
82
+ required: false,
83
+ choices: CHANNEL_TRIGGER_CHOICES.map((trigger) => ({ name: trigger, value: trigger })),
84
+ },
85
+ ],
86
+ },
87
+ ],
88
+ };
89
+ }
90
+ export async function registerFamiliarApplicationCommand(client) {
91
+ const command = getFamiliarApplicationCommand();
92
+ const commands = await client.application.commands.fetch({ force: true });
93
+ const existing = commands.find((candidate) => candidate.name === FAMILIAR_COMMAND_NAME && candidate.type === ApplicationCommandType.ChatInput);
94
+ if (existing) {
95
+ if (!existing.equals(command)) {
96
+ await client.application.commands.edit(existing.id, command);
97
+ console.log(`Updated Discord /${FAMILIAR_COMMAND_NAME} command`);
98
+ }
99
+ return;
100
+ }
101
+ await client.application.commands.create(command);
102
+ console.log(`Registered Discord /${FAMILIAR_COMMAND_NAME} command`);
103
+ }
104
+ export function commandTextFromInteraction(interaction) {
105
+ const subcommand = interaction.options.getSubcommand(true);
106
+ if (subcommand === "model") {
107
+ const model = interaction.options.getString("model");
108
+ return model ? `/model ${model}` : "/model";
109
+ }
110
+ if (subcommand === "thinking") {
111
+ const level = interaction.options.getString("level");
112
+ return level ? `/thinking ${level}` : "/thinking";
113
+ }
114
+ if (subcommand === "channel-trigger") {
115
+ const trigger = interaction.options.getString("trigger");
116
+ return trigger ? `/channel-trigger ${trigger}` : "/channel-trigger";
117
+ }
118
+ return `/${subcommand}`;
119
+ }
120
+ export function inboundInputFromInteraction(interaction) {
121
+ return {
122
+ messageId: interaction.id,
123
+ authorId: interaction.user.id,
124
+ authorName: interaction.user.username,
125
+ text: commandTextFromInteraction(interaction),
126
+ isBot: false,
127
+ mentionedBot: true,
128
+ remoteTimestamp: new Date(interaction.createdTimestamp || Date.now()).toISOString(),
129
+ };
130
+ }
131
+ export async function replyEphemeral(interaction, text) {
132
+ const content = normalizeOutboundText(text);
133
+ if (interaction.deferred || interaction.replied) {
134
+ await interaction.editReply({ content });
135
+ }
136
+ else {
137
+ await interaction.reply({ content, flags: EPHEMERAL_REPLY });
138
+ }
139
+ const reply = await interaction.fetchReply().catch(() => undefined);
140
+ return reply?.id ? [reply.id] : [];
141
+ }
142
+ export async function replyInteractionError(interaction, error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ console.error("Discord interaction handling failed", error);
145
+ await replyEphemeral(interaction, `I hit an error while handling that command.\n${message}`);
146
+ }
147
+ export function formatCommandResponse(command, runtime, familiarAgent, channelTrigger) {
148
+ if (command === "status") {
149
+ return [
150
+ runtime.formatStatus(),
151
+ `model: ${formatSetting(familiarAgent.getModel(runtime.channelKey))}`,
152
+ `thinking: ${formatSetting(familiarAgent.getThinkingLevel(runtime.channelKey))}`,
153
+ `channel_trigger: ${formatSetting(channelTrigger)}`,
154
+ ].join("\n");
155
+ }
156
+ return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
157
+ }
158
+ export function getAutocompleteChoices(config, interaction) {
159
+ if (interaction.commandName !== FAMILIAR_COMMAND_NAME)
160
+ return [];
161
+ const subcommand = interaction.options.getSubcommand(false);
162
+ const focused = interaction.options.getFocused(true);
163
+ const value = String(focused.value ?? "").toLowerCase();
164
+ if (subcommand !== "model" || focused.name !== "model")
165
+ return [];
166
+ const candidates = config.models.allow.length > 0 ? config.models.allow : [config.agent.model];
167
+ return [...new Set(candidates)]
168
+ .filter((model) => !value || model.toLowerCase().includes(value))
169
+ .slice(0, 25)
170
+ .map((model) => ({ name: model, value: model }));
171
+ }
172
+ export function isAllowedInteractionChannel(config, interaction) {
173
+ if (interaction.user.id !== config.discord.ownerId)
174
+ return false;
175
+ const channel = interaction.channel;
176
+ if (!channel)
177
+ return false;
178
+ if (channel.type === ChannelType.DM)
179
+ return true;
180
+ return interaction.channelId ? config.discord.allowedChannels.includes(interaction.channelId) : false;
181
+ }
@@ -0,0 +1,44 @@
1
+ import { materializeInboundAttachments } from "../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,106 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname } from "node:path";
3
+ import { Routes } from "discord.js";
4
+ import { parseAgentReply as parseSilentMarker } from "../silent-marker.js";
5
+ import { chunkDiscord } from "./chunking.js";
6
+ const NEWLINE_BURST_DELAY_MS = 500;
7
+ const DISCORD_ATTACHMENT_SEND_TIMEOUT_MS = 20_000;
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 buildRawFiles(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(rest, channelId, attachments) {
40
+ const files = await buildRawFiles(attachments);
41
+ if (files.length === 0)
42
+ return [];
43
+ const data = (await rest.post(Routes.channelMessages(channelId), {
44
+ files,
45
+ body: {},
46
+ signal: AbortSignal.timeout(DISCORD_ATTACHMENT_SEND_TIMEOUT_MS),
47
+ }));
48
+ if (!data.id)
49
+ throw new Error("Discord attachment send failed: no message id returned");
50
+ return [data.id];
51
+ }
52
+ export function parseOutboundReply(text) {
53
+ const parsed = parseSilentMarker(text);
54
+ if (parsed.silent)
55
+ return parsed;
56
+ return { text: normalizeOutboundText(parsed.text), silent: false };
57
+ }
58
+ async function sendChunkedMessage(config, rest, channel, channelId, normalizedText, attachments, sendChunk) {
59
+ const chunks = chunkDiscord(config, normalizedText);
60
+ const sentIds = [];
61
+ for (const [index, chunk] of chunks.entries()) {
62
+ if (index > 0)
63
+ await delayBetweenBurstChunks(config, channel);
64
+ const sent = await sendChunk(chunk, index);
65
+ sentIds.push(sent.id);
66
+ }
67
+ const attachmentIds = await sendDiscordAttachments(rest, channelId, attachments);
68
+ return [...sentIds, ...attachmentIds];
69
+ }
70
+ export async function sendReply(config, rest, message, text, replyToMessageId, attachments = []) {
71
+ const normalizedText = normalizeOutboundText(text);
72
+ return sendChunkedMessage(config, rest, message.channel, message.channelId, normalizedText, attachments, async (chunk, index) => {
73
+ if (!message.channel.isSendable()) {
74
+ throw new Error(`Discord channel is not sendable: ${message.channelId}`);
75
+ }
76
+ if (index === 0 && config.discord.replyMode === "reply") {
77
+ try {
78
+ const replyTarget = replyToMessageId || message.id;
79
+ const options = { content: chunk, reply: { messageReference: replyTarget } };
80
+ return await message.channel.send(options);
81
+ }
82
+ catch (error) {
83
+ console.error("Discord reply failed; falling back to channel send", error);
84
+ }
85
+ }
86
+ return message.channel.send(chunk);
87
+ });
88
+ }
89
+ export async function sendChannelMessage(config, rest, channel, text, attachments = []) {
90
+ if (!channel.isSendable()) {
91
+ throw new Error("Discord channel is not sendable");
92
+ }
93
+ const normalizedText = normalizeOutboundText(text);
94
+ return sendChunkedMessage(config, rest, channel, channel.id, normalizedText, attachments, (chunk) => channel.send(chunk));
95
+ }
96
+ export async function sendDiscordAttachments(rest, channelId, attachments) {
97
+ if (attachments.length === 0)
98
+ return [];
99
+ try {
100
+ return await postDiscordAttachments(rest, channelId, attachments);
101
+ }
102
+ catch (error) {
103
+ console.error("Discord attachment send failed", error);
104
+ return [];
105
+ }
106
+ }
@@ -0,0 +1,55 @@
1
+ import { createAgentEventRecorder, storedAgentEventFromAgentEvent, updateAgentEventSummary, } from "../agent-events.js";
2
+ import { messageId } from "../ids.js";
3
+ import { isHeartbeatDue } from "../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
+ }