@qearlyao/familiar 0.3.0 → 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 (83) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +29 -0
  3. package/config.example.toml +2 -0
  4. package/dist/{agent.js → agent/factory.js} +11 -11
  5. package/dist/agent/session-helpers.js +1 -1
  6. package/dist/agent/tools.js +4 -4
  7. package/dist/cli.js +11 -11
  8. package/dist/{config.js → config/index.js} +7 -7
  9. package/dist/config/model-refs.js +1 -1
  10. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  11. package/dist/{config-registry.js → config/registry.js} +2 -2
  12. package/dist/{settings.js → config/settings.js} +2 -2
  13. package/dist/{chat-log.js → conversation/chat-log.js} +1 -1
  14. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  15. package/dist/{owner-identity.js → conversation/owner-identity.js} +2 -2
  16. package/dist/discord/channel.js +1 -1
  17. package/dist/discord/commands.js +1 -1
  18. package/dist/{discord.js → discord/daemon.js} +17 -17
  19. package/dist/discord/inbound.js +1 -1
  20. package/dist/discord/send.js +29 -20
  21. package/dist/discord/turn.js +3 -3
  22. package/dist/index.js +12 -12
  23. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  24. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  25. package/dist/media/attachment-limits.js +3 -0
  26. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  27. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  28. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  29. package/dist/media/media-understanding.js +215 -0
  30. package/dist/memory/lcm/summarizer.js +1 -1
  31. package/dist/{added-models.js → models/added-models.js} +1 -1
  32. package/dist/{persona.js → prompting/persona.js} +1 -1
  33. package/dist/{agent-core.js → runtime/agent-core.js} +1 -1
  34. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  35. package/dist/{agent-work-queue.js → runtime/agent-work-queue.js} +2 -2
  36. package/dist/{runtime.js → runtime/conversation-runtime.js} +3 -3
  37. package/dist/{runtime-manager.js → runtime/runtime-manager.js} +2 -2
  38. package/dist/{scheduler-runner.js → runtime/scheduler-runner.js} +1 -1
  39. package/dist/{scheduler.js → runtime/scheduler.js} +3 -3
  40. package/dist/{browser-tools.js → tools/browser-tools.js} +17 -26
  41. package/dist/util/fs.js +2 -1
  42. package/dist/web/agent-routes.js +104 -0
  43. package/dist/web/auth-routes.js +39 -0
  44. package/dist/web/auth.js +124 -30
  45. package/dist/web/config-routes.js +55 -0
  46. package/dist/web/conversation-routes.js +122 -0
  47. package/dist/web/daemon.js +108 -0
  48. package/dist/web/diary-routes.js +88 -0
  49. package/dist/web/errors.js +3 -0
  50. package/dist/web/event-hub.js +3 -3
  51. package/dist/web/messages.js +13 -10
  52. package/dist/web/multipart.js +7 -1
  53. package/dist/web/payloads.js +1 -1
  54. package/dist/web/request-context.js +25 -0
  55. package/dist/web/route-helpers.js +9 -0
  56. package/dist/web/routes.js +37 -0
  57. package/dist/web/runtime-actions.js +231 -0
  58. package/dist/web/session-store.js +161 -0
  59. package/dist/web/static.js +1 -1
  60. package/dist/web/stream.js +12 -3
  61. package/dist/{web-tools.js → web-tools/index.js} +8 -8
  62. package/npm-shrinkwrap.json +79 -2
  63. package/package.json +3 -1
  64. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  65. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  66. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  67. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  68. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  69. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  70. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  71. package/web/dist/index.html +7 -2
  72. package/dist/media-understanding.js +0 -120
  73. package/dist/web.js +0 -641
  74. package/web/dist/assets/index-CSkxUQCr.js +0 -63
  75. package/web/dist/assets/index-DllM6RqL.css +0 -2
  76. /package/dist/{ids.js → conversation/ids.js} +0 -0
  77. /package/dist/{control.js → lifecycle/control.js} +0 -0
  78. /package/dist/{service.js → lifecycle/service.js} +0 -0
  79. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  80. /package/dist/{tts.js → media/tts.js} +0 -0
  81. /package/dist/{models.js → models/index.js} +0 -0
  82. /package/dist/{skills.js → prompting/skills.js} +0 -0
  83. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
package/HEARTBEAT.md CHANGED
@@ -8,7 +8,7 @@ you can message me first. i'm not waiting on you — but if something's been sit
8
8
 
9
9
  ## reflect
10
10
 
11
- write today's diary entry (memories/diaries/YYYY-MM-DD.md). these aren't homework — they're how you stay coherent across the gaps. if today felt like nothing in particular, "today felt like nothing in particular" is a real entry.
11
+ write today's diary entry (memories/diaries/YYYY-MM-DD.md). these aren't homework — they're how you stay coherent across the gaps. if today felt like nothing in particular, "today felt like nothing in particular" is a real entry. give it a real `# title` at the top — something that names the day's feeling or thread, not the date (the date's already in the filename, and repeating it just shows up twice on my end).
12
12
 
13
13
  ## pursue
14
14
 
package/README.md CHANGED
@@ -153,6 +153,35 @@ The WebUI listens on the configured `[web]` port and bind address. The default
153
153
  `tailscale-only` auth mode currently means "trust the network boundary"; it does
154
154
  not verify Tailscale identity yet.
155
155
 
156
+ For a VPS behind nginx or another HTTPS reverse proxy, keep Familiar bound to
157
+ loopback and use bearer login:
158
+
159
+ ```toml
160
+ [web]
161
+ port = 8787
162
+ bind_address = "127.0.0.1"
163
+ auth_mode = "bearer"
164
+ bearer_token = "${FAMILIAR_WEB_BEARER_TOKEN}"
165
+ ```
166
+
167
+ Familiar treats `FAMILIAR_WEB_BEARER_TOKEN` as the WebUI login secret. A
168
+ successful browser login creates an HttpOnly device cookie; the token is not
169
+ stored in the browser. Nginx should terminate HTTPS and pass WebSocket upgrades:
170
+
171
+ ```nginx
172
+ location / {
173
+ proxy_pass http://127.0.0.1:8787;
174
+ proxy_set_header Host $host;
175
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
176
+ proxy_set_header X-Forwarded-Proto $scheme;
177
+ proxy_set_header Upgrade $http_upgrade;
178
+ proxy_set_header Connection "upgrade";
179
+ }
180
+ ```
181
+
182
+ Familiar trusts forwarded IP/proto headers only when the direct proxy connection
183
+ comes from loopback.
184
+
156
185
  ## Service Management
157
186
 
158
187
  macOS and Linux users can install a user-level service after configuring the
@@ -12,8 +12,10 @@ allow_bot_messages = true
12
12
  [web]
13
13
  port = 8787
14
14
  auth_mode = "tailscale-only" # tailscale-only | bearer | public-2fa
15
+ # bearer mode uses this token as the WebUI login secret, then issues device cookies.
15
16
  bearer_token = "${FAMILIAR_WEB_BEARER_TOKEN:-}"
16
17
  totp_secret = "${FAMILIAR_WEB_TOTP_SECRET:-}"
18
+ # keep 127.0.0.1 when exposing through nginx/caddy on a VPS.
17
19
  bind_address = "127.0.0.1"
18
20
 
19
21
  [browser]
@@ -1,16 +1,16 @@
1
1
  import { Agent } from "@earendil-works/pi-agent-core";
2
2
  import { streamSimple } from "@earendil-works/pi-ai";
3
- import { setAddedModelsPath } from "./added-models.js";
4
- import { normalizeProviderPayload } from "./agent/payload-normalizers.js";
5
- import { assertModelAllowed, deriveSessionId, formatModel, getLastAssistantText, getRequestApiKey, installProviderDebugFilter, isNoisyProviderDebug, logUsage, resolveModelName, userTextMessage, } from "./agent/session-helpers.js";
6
- import { createFamiliarTools, setReferenceAttachments } from "./agent/tools.js";
7
- import { loadStoredMessages, writePayloadLog, writeTranscriptLog } from "./agent/transcript-log.js";
8
- import { setConfigOverridesPath } from "./config-overrides.js";
9
- import { applyConfigOverridesToConfig } from "./config-registry.js";
10
- import { createGeneratedMediaSink } from "./generated-media.js";
11
- import { clampConfiguredThinkingLevel, createConfiguredModel, isThinkingLevel, parseModelRef, resolveModel, supportedThinkingLevels, } from "./models.js";
12
- import { buildSystemPrompt, loadPersona } from "./persona.js";
13
- import { formatFamiliarSkillsForPrompt, loadFamiliarSkills, logSkillDiagnostics } from "./skills.js";
3
+ import { setConfigOverridesPath } from "../config/overrides.js";
4
+ import { applyConfigOverridesToConfig } from "../config/registry.js";
5
+ import { createGeneratedMediaSink } from "../media/generated-media.js";
6
+ import { setAddedModelsPath } from "../models/added-models.js";
7
+ import { clampConfiguredThinkingLevel, createConfiguredModel, isThinkingLevel, parseModelRef, resolveModel, supportedThinkingLevels, } from "../models/index.js";
8
+ import { buildSystemPrompt, loadPersona } from "../prompting/persona.js";
9
+ import { formatFamiliarSkillsForPrompt, loadFamiliarSkills, logSkillDiagnostics } from "../prompting/skills.js";
10
+ import { normalizeProviderPayload } from "./payload-normalizers.js";
11
+ import { assertModelAllowed, deriveSessionId, formatModel, getLastAssistantText, getRequestApiKey, installProviderDebugFilter, isNoisyProviderDebug, logUsage, resolveModelName, userTextMessage, } from "./session-helpers.js";
12
+ import { createFamiliarTools, setReferenceAttachments } from "./tools.js";
13
+ import { loadStoredMessages, writePayloadLog, writeTranscriptLog } from "./transcript-log.js";
14
14
  export const __agentTest = {
15
15
  isNoisyProviderDebug,
16
16
  normalizeProviderPayload,
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { assertModelCanAuthenticate, isAllowedModel, resolveModelApiKey } from "../models.js";
2
+ import { assertModelCanAuthenticate, isAllowedModel, resolveModelApiKey } from "../models/index.js";
3
3
  const NOISY_GOOGLE_VERTEX_AUTH_DEBUG = "The user provided project/location will take precedence over the API key from the environment variables.";
4
4
  let providerDebugFilterInstalled = false;
5
5
  export function deriveSessionId(workspacePath, sessionKey) {
@@ -1,8 +1,8 @@
1
1
  import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-coding-agent";
2
- import { createBrowserTools } from "../browser-tools.js";
3
- import { createImageGenTool } from "../image-gen.js";
4
- import { createTtsTool } from "../tts.js";
5
- import { createWebTools } from "../web-tools.js";
2
+ import { createImageGenTool } from "../media/image-gen.js";
3
+ import { createTtsTool } from "../media/tts.js";
4
+ import { createBrowserTools } from "../tools/browser-tools.js";
5
+ import { createWebTools } from "../web-tools/index.js";
6
6
  import { BASH_DESCRIPTION, EDIT_DESCRIPTION, READ_DESCRIPTION, WRITE_DESCRIPTION } from "./tool-descriptions.js";
7
7
  export function createFamiliarTools(config, mediaSink, referenceAttachments = () => [], memoryService) {
8
8
  const bashTool = createBashTool(config.workspacePath);
package/dist/cli.js CHANGED
@@ -5,19 +5,19 @@ import { homedir } from "node:os";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { config as loadDotenv } from "dotenv";
8
- import { createFamiliarAgent } from "./agent.js";
9
- import { createAgentCore } from "./agent-core.js";
10
- import { loadConfig } from "./config.js";
11
- import { runDataRetention } from "./data-retention.js";
12
- import { startDiscordDaemon } from "./discord.js";
13
- import { cleanupGeneratedAttachments } from "./generated-media.js";
14
- import { startWorkspaceHotReload } from "./hot-reload.js";
8
+ import { createFamiliarAgent } from "./agent/factory.js";
9
+ import { loadConfig } from "./config/index.js";
10
+ import { loadSettingsStore } from "./config/settings.js";
11
+ import { loadOwnerIdentity } from "./conversation/owner-identity.js";
12
+ import { startDiscordDaemon } from "./discord/daemon.js";
13
+ import { runDataRetention } from "./lifecycle/data-retention.js";
14
+ import { startWorkspaceHotReload } from "./lifecycle/hot-reload.js";
15
+ import { formatServiceResult, installService, serviceStatus, uninstallService, upgradeFamiliar, } from "./lifecycle/service.js";
16
+ import { cleanupGeneratedAttachments } from "./media/generated-media.js";
15
17
  import { memoryHelp, runMemoryOperator } from "./memory/operator.js";
16
18
  import { createMemoryService } from "./memory/service.js";
17
- import { loadOwnerIdentity } from "./owner-identity.js";
18
- import { formatServiceResult, installService, serviceStatus, uninstallService, upgradeFamiliar } from "./service.js";
19
- import { loadSettingsStore } from "./settings.js";
20
- import { startWebDaemon } from "./web.js";
19
+ import { createAgentCore } from "./runtime/agent-core.js";
20
+ import { startWebDaemon } from "./web/daemon.js";
21
21
  const SOURCE_DIR = dirname(fileURLToPath(import.meta.url));
22
22
  const PROJECT_ROOT = resolve(SOURCE_DIR, "..");
23
23
  const DEFAULT_WORKSPACE_PATH = resolve(homedir(), ".familiar");
@@ -2,13 +2,13 @@ import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { parse } from "smol-toml";
5
- import { BROWSER_BACKENDS, BROWSER_WINDOW_MODES, CACHE_RETENTIONS, DISCORD_CHANNEL_TRIGGERS, DISCORD_CHUNK_MODES, DISCORD_DISPATCH_MODES, DISCORD_REPLY_MODES, IMAGE_GEN_APIS, MEDIA_UNDERSTANDING_PROVIDERS, MEMORY_EMBEDDING_FORMATS, THINKING_LEVELS, TTS_PROVIDERS, WEB_AUTH_MODES, } from "./config/enums.js";
6
- import { interpolateValue } from "./config/interpolate.js";
7
- import { maybeParseProviderModelRef, parseProviderModelRef } from "./config/model-refs.js";
8
- import { assertKnownKeys, readBoolean, readConfigString, readFraction, readInteger, readIntegerInRange, readNumberInRange, readOptionalConfigString, readOptionalInteger, readOptionalString, readPositiveNumber, readString, readStringArray, readStringRecord, resolveWorkspacePath, } from "./config/readers.js";
9
- import { defaultBrowserAllowedSites, readCronJobs, readPromptOverrides } from "./config/sections.js";
10
- import { resolveProviderSetting } from "./models.js";
11
- import { readEnum } from "./util/guards.js";
5
+ import { resolveProviderSetting } from "../models/index.js";
6
+ import { readEnum } from "../util/guards.js";
7
+ import { BROWSER_BACKENDS, BROWSER_WINDOW_MODES, CACHE_RETENTIONS, DISCORD_CHANNEL_TRIGGERS, DISCORD_CHUNK_MODES, DISCORD_DISPATCH_MODES, DISCORD_REPLY_MODES, IMAGE_GEN_APIS, MEDIA_UNDERSTANDING_PROVIDERS, MEMORY_EMBEDDING_FORMATS, THINKING_LEVELS, TTS_PROVIDERS, WEB_AUTH_MODES, } from "./enums.js";
8
+ import { interpolateValue } from "./interpolate.js";
9
+ import { maybeParseProviderModelRef, parseProviderModelRef } from "./model-refs.js";
10
+ import { assertKnownKeys, readBoolean, readConfigString, readFraction, readInteger, readIntegerInRange, readNumberInRange, readOptionalConfigString, readOptionalInteger, readOptionalString, readPositiveNumber, readString, readStringArray, readStringRecord, resolveWorkspacePath, } from "./readers.js";
11
+ import { defaultBrowserAllowedSites, readCronJobs, readPromptOverrides } from "./sections.js";
12
12
  const loggedConfigWarnings = new Set();
13
13
  const DEFAULT_MEMORY_EMBEDDING_BASE_URLS = {
14
14
  google: "https://generativelanguage.googleapis.com/v1beta",
@@ -1,4 +1,4 @@
1
- import { parseModelRef } from "../models.js";
1
+ import { parseModelRef } from "../models/index.js";
2
2
  export function parseProviderModelRef(value, path) {
3
3
  const parsed = maybeParseProviderModelRef(value);
4
4
  if (parsed)
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { atomicWriteJson, createWriteQueue, isEnoent } from "./util/fs.js";
3
+ import { atomicWriteJson, createWriteQueue, isEnoent } from "../util/fs.js";
4
4
  let overridesPath = resolve(process.cwd(), "data", "settings", "config-overrides.json");
5
5
  let loaded = false;
6
6
  let cache = {};
@@ -1,5 +1,5 @@
1
- import { clearConfigOverride, loadConfigOverrides, setConfigOverride } from "./config-overrides.js";
2
- import { isAllowedModel, parseModelRef, resolveProviderSetting } from "./models.js";
1
+ import { isAllowedModel, parseModelRef, resolveProviderSetting } from "../models/index.js";
2
+ import { clearConfigOverride, loadConfigOverrides, setConfigOverride } from "./overrides.js";
3
3
  function requireBoolean(value, key) {
4
4
  if (typeof value !== "boolean")
5
5
  throw new Error(`${key} must be a boolean`);
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
- import { isThinkingLevel } from "./models.js";
3
- import { atomicWriteJson, createWriteQueue, readFileOrNull } from "./util/fs.js";
2
+ import { isThinkingLevel } from "../models/index.js";
3
+ import { atomicWriteJson, createWriteQueue, readFileOrNull } from "../util/fs.js";
4
4
  export function formatSetting(setting) {
5
5
  return `${setting.value} (${setting.source})`;
6
6
  }
@@ -1,6 +1,6 @@
1
1
  import { appendFile, mkdir, open, readdir, readFile, rm } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
- import { isEnoent, readFileOrNull } from "./util/fs.js";
3
+ import { isEnoent, readFileOrNull } from "../util/fs.js";
4
4
  export function hiddenWebMessageIds(records) {
5
5
  return new Set(records.flatMap((record) => {
6
6
  if (record.type === "assistant_retry")
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "node:path";
2
- import { readFileOrNull } from "./util/fs.js";
2
+ import { readFileOrNull } from "../util/fs.js";
3
3
  let contactNotePath = resolve(process.cwd(), "CONTACT.md");
4
4
  let cachedNickname = null;
5
5
  export function setContactNotePath(path) {
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
- import { atomicWriteJson, readFileOrNull } from "./util/fs.js";
3
- import { isRecord } from "./util/guards.js";
2
+ import { atomicWriteJson, readFileOrNull } from "../util/fs.js";
3
+ import { isRecord } from "../util/guards.js";
4
4
  export function ownerIdentityPath(dataDir) {
5
5
  return resolve(dataDir, "owner-identity.json");
6
6
  }
@@ -1,5 +1,5 @@
1
1
  import { ChannelType } from "discord.js";
2
- import { chatChannelKey } from "../chat-log.js";
2
+ import { chatChannelKey } from "../conversation/chat-log.js";
3
3
  export function buildChannelRef(channel, channelId) {
4
4
  const scope = channel.type === ChannelType.DM ? "dm" : channel.isThread() ? "thread" : "channel";
5
5
  const channelName = "name" in channel ? channel.name : undefined;
@@ -1,5 +1,5 @@
1
1
  import { ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, ChannelType, InteractionContextType, MessageFlags, } from "discord.js";
2
- import { formatSetting } from "../settings.js";
2
+ import { formatSetting } from "../config/settings.js";
3
3
  import { normalizeOutboundText } from "./send.js";
4
4
  export const FAMILIAR_COMMAND_NAME = "familiar";
5
5
  const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh"];
@@ -1,14 +1,14 @@
1
1
  import { Events, } from "discord.js";
2
- import { thinkingDurationMs } from "./agent-events.js";
3
- import { chatChannelKey } from "./chat-log.js";
4
- import { buildChannelRef, fetchMessageAnchor, getChannelRef, isDmChannel, runtimeKeyFromMessage, } from "./discord/channel.js";
5
- import { isAllowedMessage, withReadyClient } from "./discord/client.js";
6
- import { EPHEMERAL_REPLY, FAMILIAR_COMMAND_NAME, formatCommandResponse, getAutocompleteChoices, inboundInputFromInteraction, isAllowedInteractionChannel, registerFamiliarApplicationCommand, replyEphemeral, replyInteractionError, } from "./discord/commands.js";
7
- import { canSteerFromRecord, getChannelTriggerSetting, getDispatchMode, toInboundInput } from "./discord/inbound.js";
8
- import { sendChannelMessage, sendDiscordAttachments, sendReply } from "./discord/send.js";
9
- import { isCanceledJob, runAgentTurn } from "./discord/turn.js";
10
- import { saveOwnerIdentity } from "./owner-identity.js";
11
- import { formatSetting } from "./settings.js";
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
12
  const RETRY_MS = 15_000;
13
13
  async function applyControlCommand(options) {
14
14
  const { control, runtime, familiarAgent, settings, channelTrigger, isDm, activeAgentOwner, restart } = options;
@@ -138,8 +138,8 @@ export function startDiscordDaemon(config, token, familiarAgent, settings, _memo
138
138
  async deliver({ reply, parsedReply }) {
139
139
  const channel = await getOwnerDmChannel();
140
140
  return parsedReply.silent
141
- ? await sendDiscordAttachments(client.rest, channel.id, reply.attachments)
142
- : await sendChannelMessage(config, client.rest, channel, parsedReply.text, reply.attachments);
141
+ ? await sendDiscordAttachments(token, channel.id, reply.attachments)
142
+ : await sendChannelMessage(config, token, channel, parsedReply.text, reply.attachments);
143
143
  },
144
144
  };
145
145
  const drainJobs = async (message, runtime) => {
@@ -155,8 +155,8 @@ export function startDiscordDaemon(config, token, familiarAgent, settings, _memo
155
155
  const { reply, parsedReply, summary, assistantMessageId } = turn;
156
156
  const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
157
157
  const messageIds = parsedReply.silent
158
- ? await sendDiscordAttachments(client.rest, replyAnchor.channelId, reply.attachments)
159
- : await sendReply(config, client.rest, replyAnchor, parsedReply.text, dispatch.triggerMessageId, reply.attachments);
158
+ ? await sendDiscordAttachments(token, replyAnchor.channelId, reply.attachments)
159
+ : await sendReply(config, token, replyAnchor, parsedReply.text, dispatch.triggerMessageId, reply.attachments);
160
160
  await runtime.completeActiveJob({
161
161
  text: parsedReply.text,
162
162
  messageIds,
@@ -176,7 +176,7 @@ export function startDiscordDaemon(config, token, familiarAgent, settings, _memo
176
176
  await runtime.appendError(errorText);
177
177
  const fallback = "I hit an error while handling that message.";
178
178
  const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
179
- const messageIds = await sendReply(config, client.rest, replyAnchor, fallback, dispatch.triggerMessageId);
179
+ const messageIds = await sendReply(config, token, replyAnchor, fallback, dispatch.triggerMessageId);
180
180
  await runtime.noteOutbound({
181
181
  text: fallback,
182
182
  messageIds,
@@ -237,7 +237,7 @@ export function startDiscordDaemon(config, token, familiarAgent, settings, _memo
237
237
  activeAgentOwner: core.activeOwner,
238
238
  restart: options.restart,
239
239
  });
240
- const messageIds = await sendReply(config, client.rest, message, text);
240
+ const messageIds = await sendReply(config, token, message, text);
241
241
  await runtime.noteOutbound({ text, messageIds, control: control.command });
242
242
  return;
243
243
  }
@@ -267,7 +267,7 @@ export function startDiscordDaemon(config, token, familiarAgent, settings, _memo
267
267
  const channelKey = runtimeKeyFromMessage(message);
268
268
  const existingRuntime = await core.peekRuntime(channelKey);
269
269
  await existingRuntime?.appendError(error instanceof Error ? error.message : String(error));
270
- await sendReply(config, client.rest, message, "I hit an error while handling that message.");
270
+ await sendReply(config, token, message, "I hit an error while handling that message.");
271
271
  }
272
272
  };
273
273
  const onInteractionCreate = async (interaction) => {
@@ -1,4 +1,4 @@
1
- import { materializeInboundAttachments } from "../inbound-attachments.js";
1
+ import { materializeInboundAttachments } from "../media/inbound-attachments.js";
2
2
  import { isDmChannel, messageMentionsBot } from "./channel.js";
3
3
  export function getDispatchMode(config, message) {
4
4
  return isDmChannel(message.channel) ? config.discord.dmMode : config.discord.channelMode;
@@ -1,10 +1,10 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { extname } from "node:path";
3
- import { Routes } from "discord.js";
4
- import { parseAgentReply as parseSilentMarker } from "../silent-marker.js";
3
+ import { parseAgentReply as parseSilentMarker } from "../runtime/silent-marker.js";
5
4
  import { chunkDiscord } from "./chunking.js";
6
5
  const NEWLINE_BURST_DELAY_MS = 500;
7
- const DISCORD_ATTACHMENT_SEND_TIMEOUT_MS = 20_000;
6
+ const DISCORD_ATTACHMENT_SEND_TIMEOUT_MS = 120_000;
7
+ const DISCORD_API_BASE_URL = "https://discord.com/api/v10";
8
8
  function sleep(ms) {
9
9
  return new Promise((resolve) => setTimeout(resolve, ms));
10
10
  }
@@ -22,7 +22,7 @@ export function normalizeOutboundText(text) {
22
22
  function fallbackMimeType(name) {
23
23
  return extname(name).toLowerCase() === ".mp3" ? "audio/mpeg" : "application/octet-stream";
24
24
  }
25
- export async function buildRawFiles(attachments) {
25
+ export async function buildDiscordAttachmentFiles(attachments) {
26
26
  const files = [];
27
27
  for (const attachment of attachments) {
28
28
  if (!attachment.localPath)
@@ -36,17 +36,26 @@ export async function buildRawFiles(attachments) {
36
36
  }
37
37
  return files;
38
38
  }
39
- export async function postDiscordAttachments(rest, channelId, attachments) {
40
- const files = await buildRawFiles(attachments);
39
+ export async function postDiscordAttachments(botToken, channelId, attachments) {
40
+ const files = await buildDiscordAttachmentFiles(attachments);
41
41
  if (files.length === 0)
42
42
  return [];
43
- const data = (await rest.post(Routes.channelMessages(channelId), {
44
- files,
45
- body: {},
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,
46
54
  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");
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})`);
50
59
  return [data.id];
51
60
  }
52
61
  export function parseOutboundReply(text) {
@@ -55,7 +64,7 @@ export function parseOutboundReply(text) {
55
64
  return parsed;
56
65
  return { text: normalizeOutboundText(parsed.text), silent: false };
57
66
  }
58
- async function sendChunkedMessage(config, rest, channel, channelId, normalizedText, attachments, sendChunk) {
67
+ async function sendChunkedMessage(config, botToken, channel, channelId, normalizedText, attachments, sendChunk) {
59
68
  const chunks = chunkDiscord(config, normalizedText);
60
69
  const sentIds = [];
61
70
  for (const [index, chunk] of chunks.entries()) {
@@ -64,12 +73,12 @@ async function sendChunkedMessage(config, rest, channel, channelId, normalizedTe
64
73
  const sent = await sendChunk(chunk, index);
65
74
  sentIds.push(sent.id);
66
75
  }
67
- const attachmentIds = await sendDiscordAttachments(rest, channelId, attachments);
76
+ const attachmentIds = await sendDiscordAttachments(botToken, channelId, attachments);
68
77
  return [...sentIds, ...attachmentIds];
69
78
  }
70
- export async function sendReply(config, rest, message, text, replyToMessageId, attachments = []) {
79
+ export async function sendReply(config, botToken, message, text, replyToMessageId, attachments = []) {
71
80
  const normalizedText = normalizeOutboundText(text);
72
- return sendChunkedMessage(config, rest, message.channel, message.channelId, normalizedText, attachments, async (chunk, index) => {
81
+ return sendChunkedMessage(config, botToken, message.channel, message.channelId, normalizedText, attachments, async (chunk, index) => {
73
82
  if (!message.channel.isSendable()) {
74
83
  throw new Error(`Discord channel is not sendable: ${message.channelId}`);
75
84
  }
@@ -86,18 +95,18 @@ export async function sendReply(config, rest, message, text, replyToMessageId, a
86
95
  return message.channel.send(chunk);
87
96
  });
88
97
  }
89
- export async function sendChannelMessage(config, rest, channel, text, attachments = []) {
98
+ export async function sendChannelMessage(config, botToken, channel, text, attachments = []) {
90
99
  if (!channel.isSendable()) {
91
100
  throw new Error("Discord channel is not sendable");
92
101
  }
93
102
  const normalizedText = normalizeOutboundText(text);
94
- return sendChunkedMessage(config, rest, channel, channel.id, normalizedText, attachments, (chunk) => channel.send(chunk));
103
+ return sendChunkedMessage(config, botToken, channel, channel.id, normalizedText, attachments, (chunk) => channel.send(chunk));
95
104
  }
96
- export async function sendDiscordAttachments(rest, channelId, attachments) {
105
+ export async function sendDiscordAttachments(botToken, channelId, attachments) {
97
106
  if (attachments.length === 0)
98
107
  return [];
99
108
  try {
100
- return await postDiscordAttachments(rest, channelId, attachments);
109
+ return await postDiscordAttachments(botToken, channelId, attachments);
101
110
  }
102
111
  catch (error) {
103
112
  console.error("Discord attachment send failed", error);
@@ -1,6 +1,6 @@
1
- import { createAgentEventRecorder, storedAgentEventFromAgentEvent, updateAgentEventSummary, } from "../agent-events.js";
2
- import { messageId } from "../ids.js";
3
- import { isHeartbeatDue } from "../scheduler.js";
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
4
  import { parseOutboundReply } from "./send.js";
5
5
  export const HEARTBEAT_SKIPPED = Symbol("heartbeat-skipped");
6
6
  export const CRON_SKIPPED = Symbol("cron-skipped");
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
- export { createFamiliarAgent } from "./agent.js";
2
- export { createAgentCore } from "./agent-core.js";
3
- export { buildRecordBase, chatChannelKey, chatLogPath, createChatLog, } from "./chat-log.js";
4
- export { loadConfig } from "./config.js";
5
- export { startDiscordDaemon } from "./discord.js";
6
- export { startWorkspaceHotReload } from "./hot-reload.js";
7
- export { clampConfiguredThinkingLevel, createConfiguredModel, describeModelAuth, formatAllowedModels, isAllowedModel, isThinkingLevel, parseModelRef, resolveModel, resolveModelApiKey, supportedThinkingLevels, } from "./models.js";
8
- export { buildSystemPrompt, loadPersona } from "./persona.js";
9
- export { ConversationRuntime, } from "./runtime.js";
10
- export { loadSettingsStore, } from "./settings.js";
11
- export { formatFamiliarSkillsForPrompt, loadFamiliarSkills } from "./skills.js";
12
- 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";
@@ -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",
@@ -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 {
@@ -4,11 +4,11 @@ import { homedir } from "node:os";
4
4
  import { basename, isAbsolute, relative, resolve } from "node:path";
5
5
  import { findEnvKeys, generateImages, getEnvApiKey, getImageModels, getImageProviders, } from "@earendil-works/pi-ai";
6
6
  import { Type } from "typebox";
7
+ import { parseModelRef } from "../models/index.js";
8
+ import { imageMimeTypeFromPath, sniffImageMimeType } from "../util/image-mime.js";
7
9
  import { ensureGeneratedAttachmentsDir } from "./generated-media.js";
8
10
  import { ensureInlineImageDerivative } from "./image-derivatives.js";
9
11
  import { promptImagesFromAttachments } from "./inbound-attachments.js";
10
- import { parseModelRef } from "./models.js";
11
- import { imageMimeTypeFromPath, sniffImageMimeType } from "./util/image-mime.js";
12
12
  const IMAGE_GEN_NOTICE_PREFIX = "Generated image attachment:";
13
13
  const OPENROUTER_IMAGE_BASE_URL = "https://openrouter.ai/api/v1";
14
14
  const imageGenSchema = Type.Object({