@qearlyao/familiar 0.2.3 → 0.2.5

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 (58) hide show
  1. package/README.md +5 -2
  2. package/config.example.toml +1 -1
  3. package/dist/added-models.js +6 -15
  4. package/dist/agent-events.js +1 -3
  5. package/dist/agent.js +3 -4
  6. package/dist/browser-tools.js +84 -30
  7. package/dist/chat-log.js +3 -2
  8. package/dist/cli.js +2 -2
  9. package/dist/config-overrides.js +5 -14
  10. package/dist/config-registry.js +1 -4
  11. package/dist/config.js +45 -113
  12. package/dist/contact-note.js +2 -12
  13. package/dist/data-retention.js +1 -3
  14. package/dist/discord.js +2 -2
  15. package/dist/generated-media.js +3 -2
  16. package/dist/hot-reload.js +1 -3
  17. package/dist/image-gen.js +102 -61
  18. package/dist/inbound-attachments.js +53 -22
  19. package/dist/memory/diary/ambient-injector.js +1 -3
  20. package/dist/memory/diary/ambient.js +1 -3
  21. package/dist/memory/diary/chunks.js +1 -3
  22. package/dist/memory/diary/indexer.js +1 -3
  23. package/dist/memory/doctor.js +3 -8
  24. package/dist/memory/index/chunk-indexer.js +6 -2
  25. package/dist/memory/index/retrieval.js +1 -3
  26. package/dist/memory/index/store.js +47 -19
  27. package/dist/memory/lcm/backfill.js +19 -16
  28. package/dist/memory/lcm/context-transformer.js +12 -24
  29. package/dist/memory/lcm/context.js +10 -4
  30. package/dist/memory/lcm/eviction-score.js +25 -13
  31. package/dist/memory/lcm/indexer.js +1 -5
  32. package/dist/memory/lcm/normalize.js +22 -1
  33. package/dist/memory/lcm/store.js +27 -24
  34. package/dist/memory/operator.js +2 -4
  35. package/dist/memory/service.js +1 -3
  36. package/dist/memory/tools.js +0 -4
  37. package/dist/memory/util.js +6 -0
  38. package/dist/models.js +3 -0
  39. package/dist/persona.js +2 -14
  40. package/dist/runtime.js +2 -23
  41. package/dist/scheduler.js +15 -49
  42. package/dist/service.js +24 -14
  43. package/dist/settings.js +7 -32
  44. package/dist/tts.js +0 -6
  45. package/dist/util/fs.js +41 -0
  46. package/dist/util/guards.js +8 -0
  47. package/dist/util/image-mime.js +31 -0
  48. package/dist/util/time.js +29 -0
  49. package/dist/web-auth.js +4 -1
  50. package/dist/web-tools.js +8 -5
  51. package/dist/web.js +188 -62
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/web/dist/assets/index-B23WT77N.js +63 -0
  55. package/web/dist/assets/index-D3MotFzN.css +2 -0
  56. package/web/dist/index.html +2 -2
  57. package/web/dist/assets/index-C-w9fjBf.js +0 -61
  58. package/web/dist/assets/index-CcQ13VAY.css +0 -2
package/dist/config.js CHANGED
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { isAbsolute, resolve } from "node:path";
4
4
  import { parse } from "smol-toml";
5
+ import { parseModelRef, resolveProviderSetting } from "./models.js";
6
+ import { readEnum } from "./util/guards.js";
5
7
  const loggedConfigWarnings = new Set();
6
8
  const DEFAULT_MEMORY_EMBEDDING_BASE_URLS = {
7
9
  google: "https://generativelanguage.googleapis.com/v1beta",
@@ -72,88 +74,28 @@ function warnOnce(key, message) {
72
74
  loggedConfigWarnings.add(key);
73
75
  console.warn(message);
74
76
  }
75
- function readCacheRetention(value, path = "agent.cache_retention") {
76
- if (value === "none" || value === "short" || value === "long")
77
- return value;
78
- throw new Error(`Config value ${path} must be one of "none", "short", or "long"`);
79
- }
80
- function readThinkingLevel(value) {
81
- if (value === "off" ||
82
- value === "minimal" ||
83
- value === "low" ||
84
- value === "medium" ||
85
- value === "high" ||
86
- value === "xhigh") {
87
- return value;
88
- }
89
- throw new Error('Config value agent.thinking_level must be one of "off", "minimal", "low", "medium", "high", or "xhigh"');
90
- }
91
- function readDiscordReplyMode(value) {
92
- if (value === "plain" || value === "reply")
93
- return value;
94
- throw new Error('Config value discord.reply_mode must be one of "plain" or "reply"');
95
- }
96
- function readDiscordChunkMode(value) {
97
- if (value === "simple" || value === "paragraph" || value === "newline")
98
- return value;
99
- throw new Error('Config value discord.chunk_mode must be one of "simple", "paragraph", or "newline"');
100
- }
101
- function readDiscordDispatchMode(value, path) {
102
- if (value === "steer" || value === "queue" || value === "collect")
103
- return value;
104
- throw new Error(`Config value ${path} must be one of "steer", "queue", or "collect"`);
105
- }
106
- function readDiscordChannelTrigger(value) {
107
- if (value === "mention" || value === "always")
108
- return value;
109
- throw new Error('Config value discord.channel_trigger must be one of "mention" or "always"');
110
- }
111
- function readCronFrequency(value, path) {
112
- if (value === "once" || value === "hourly" || value === "daily" || value === "weekly" || value === "monthly") {
113
- return value;
114
- }
115
- throw new Error(`Config value ${path} must be one of "once", "hourly", "daily", "weekly", or "monthly"`);
116
- }
117
- function readCronDeliveryMode(value, path) {
118
- if (value === "queue" || value === "follow_up")
119
- return value;
120
- throw new Error(`Config value ${path} must be one of "queue" or "follow_up"`);
121
- }
122
- function readWebAuthMode(value) {
123
- if (value === "tailscale-only" || value === "bearer" || value === "public-2fa")
124
- return value;
125
- throw new Error('Config value web.auth_mode must be one of "tailscale-only", "bearer", or "public-2fa"');
126
- }
127
- function readTtsProvider(value) {
128
- if (value === "elevenlabs")
129
- return value;
130
- throw new Error('Config value tts.provider must be "elevenlabs"');
131
- }
132
- function readImageGenApi(value) {
133
- if (value === "openrouter-images")
134
- return value;
135
- throw new Error('Config value image_gen.api must be "openrouter-images"');
136
- }
137
- function readMediaUnderstandingProvider(value) {
138
- if (value === "groq" || value === "google")
139
- return value;
140
- throw new Error('Config value media_understanding provider must be "groq" or "google"');
141
- }
142
- function readMemoryEmbeddingFormat(value, path = "memory.embedding.format") {
143
- if (value === "gemini" || value === "openai" || value === "voyage")
144
- return value;
145
- throw new Error(`Config value ${path} must be one of "gemini", "openai", or "voyage"`);
146
- }
147
- function readBrowserBackend(value) {
148
- if (value === "opencli" || value === "browser-harness")
149
- return value;
150
- throw new Error('Config value browser.backend must be "opencli" or "browser-harness"');
151
- }
152
- function readBrowserWindowMode(value) {
153
- if (value === "foreground" || value === "background")
154
- return value;
155
- throw new Error('Config value browser.window must be one of "foreground" or "background"');
156
- }
77
+ const CACHE_RETENTIONS = ["none", "short", "long"];
78
+ const THINKING_LEVELS = [
79
+ "off",
80
+ "minimal",
81
+ "low",
82
+ "medium",
83
+ "high",
84
+ "xhigh",
85
+ ];
86
+ const DISCORD_REPLY_MODES = ["plain", "reply"];
87
+ const DISCORD_CHUNK_MODES = ["simple", "paragraph", "newline"];
88
+ const DISCORD_DISPATCH_MODES = ["steer", "queue", "collect"];
89
+ const DISCORD_CHANNEL_TRIGGERS = ["mention", "always"];
90
+ const CRON_FREQUENCIES = ["once", "hourly", "daily", "weekly", "monthly"];
91
+ const CRON_DELIVERY_MODES = ["queue", "follow_up"];
92
+ const WEB_AUTH_MODES = ["tailscale-only", "bearer", "public-2fa"];
93
+ const TTS_PROVIDERS = ["elevenlabs"];
94
+ const IMAGE_GEN_APIS = ["openrouter-images"];
95
+ const MEDIA_UNDERSTANDING_PROVIDERS = ["groq", "google"];
96
+ const MEMORY_EMBEDDING_FORMATS = ["gemini", "openai", "voyage"];
97
+ const BROWSER_BACKENDS = ["opencli", "browser-harness"];
98
+ const BROWSER_WINDOW_MODES = ["foreground", "background"];
157
99
  function readBoolean(value, fallback, path) {
158
100
  if (value === undefined)
159
101
  return fallback;
@@ -169,9 +111,6 @@ function readInteger(value, fallback, path, min = 0) {
169
111
  }
170
112
  return value;
171
113
  }
172
- function resolveProviderSetting(records, provider, model) {
173
- return records[`${provider}/${model}`] ?? records[provider];
174
- }
175
114
  function readNumberInRange(value, fallback, path, min, max) {
176
115
  if (value === undefined)
177
116
  return fallback;
@@ -242,15 +181,8 @@ function parseProviderModelRef(value, path) {
242
181
  throw new Error(`Config value ${path} must be a provider/model id`);
243
182
  }
244
183
  function maybeParseProviderModelRef(value) {
245
- const trimmed = value.trim();
246
- const separator = trimmed.indexOf("/");
247
- if (separator <= 0 || separator === trimmed.length - 1)
248
- return undefined;
249
- const provider = trimmed.slice(0, separator).trim();
250
- const modelId = trimmed.slice(separator + 1).trim();
251
- if (!provider || !modelId)
252
- return undefined;
253
- return { provider, modelId, key: `${provider}/${modelId}` };
184
+ const parsed = parseModelRef(value);
185
+ return parsed ? { provider: parsed.provider, modelId: parsed.id, key: parsed.key } : undefined;
254
186
  }
255
187
  function assertKnownKeys(value, path, knownKeys) {
256
188
  const known = new Set(knownKeys);
@@ -308,7 +240,7 @@ function readCronJobs(cron) {
308
240
  if (seen.has(id))
309
241
  throw new Error(`Duplicate cron job id: ${id}`);
310
242
  seen.add(id);
311
- const frequency = readCronFrequency(readConfigString(job.frequency, "once", `${prefix}.frequency`), `${prefix}.frequency`);
243
+ const frequency = readEnum(readConfigString(job.frequency, "once", `${prefix}.frequency`), `${prefix}.frequency`, CRON_FREQUENCIES);
312
244
  const runAt = readOptionalConfigString(job.run_at, `${prefix}.run_at`);
313
245
  const time = readOptionalConfigString(job.time, `${prefix}.time`);
314
246
  assertCronRunAt(runAt, `${prefix}.run_at`);
@@ -326,7 +258,7 @@ function readCronJobs(cron) {
326
258
  id,
327
259
  enabled: readBoolean(job.enabled, true, `${prefix}.enabled`),
328
260
  frequency,
329
- deliveryMode: readCronDeliveryMode(readConfigString(job.delivery_mode, "queue", `${prefix}.delivery_mode`), `${prefix}.delivery_mode`),
261
+ deliveryMode: readEnum(readConfigString(job.delivery_mode, "queue", `${prefix}.delivery_mode`), `${prefix}.delivery_mode`, CRON_DELIVERY_MODES),
330
262
  prompt: readString(job.prompt, `${prefix}.prompt`),
331
263
  ...(runAt ? { runAt } : {}),
332
264
  ...(time ? { time } : {}),
@@ -425,9 +357,9 @@ export async function loadConfig(workspacePathInput) {
425
357
  warnOnce("memory.embedding.api", "Config value memory.embedding.api is deprecated; use memory.embedding.format instead.");
426
358
  memoryEmbeddingFormatRaw = memoryEmbedding.api;
427
359
  }
428
- const memoryEmbeddingFormat = readMemoryEmbeddingFormat(readOptionalString(memoryEmbeddingFormatRaw, "gemini"), memoryEmbedding.format === undefined && memoryEmbedding.api !== undefined
360
+ const memoryEmbeddingFormat = readEnum(readOptionalString(memoryEmbeddingFormatRaw, "gemini"), memoryEmbedding.format === undefined && memoryEmbedding.api !== undefined
429
361
  ? "memory.embedding.api"
430
- : "memory.embedding.format");
362
+ : "memory.embedding.format", MEMORY_EMBEDDING_FORMATS);
431
363
  const memoryEmbeddingProvider = readConfigString(memoryEmbedding.provider, "google", "memory.embedding.provider");
432
364
  const memoryEmbeddingModel = readConfigString(memoryEmbedding.model, "gemini-embedding-2", "memory.embedding.model");
433
365
  const memoryEmbeddingBaseUrl = readOptionalConfigString(memoryEmbedding.base_url, "memory.embedding.base_url") ??
@@ -507,29 +439,29 @@ export async function loadConfig(workspacePathInput) {
507
439
  token: readString(process.env.DISCORD_TOKEN, "DISCORD_TOKEN"),
508
440
  ownerId,
509
441
  allowedChannels: readStringArray(discord.allowed_channels, "discord.allowed_channels"),
510
- replyMode: readDiscordReplyMode(readOptionalString(discord.reply_mode, "plain")),
511
- chunkMode: readDiscordChunkMode(readOptionalString(discord.chunk_mode, "paragraph")),
512
- dmMode: readDiscordDispatchMode(readOptionalString(discord.dm_mode, "steer"), "discord.dm_mode"),
513
- channelMode: readDiscordDispatchMode(readOptionalString(discord.channel_mode, "collect"), "discord.channel_mode"),
514
- channelTrigger: readDiscordChannelTrigger(readOptionalString(discord.channel_trigger, "mention")),
442
+ replyMode: readEnum(readOptionalString(discord.reply_mode, "plain"), "discord.reply_mode", DISCORD_REPLY_MODES),
443
+ chunkMode: readEnum(readOptionalString(discord.chunk_mode, "paragraph"), "discord.chunk_mode", DISCORD_CHUNK_MODES),
444
+ dmMode: readEnum(readOptionalString(discord.dm_mode, "steer"), "discord.dm_mode", DISCORD_DISPATCH_MODES),
445
+ channelMode: readEnum(readOptionalString(discord.channel_mode, "collect"), "discord.channel_mode", DISCORD_DISPATCH_MODES),
446
+ channelTrigger: readEnum(readOptionalString(discord.channel_trigger, "mention"), "discord.channel_trigger", DISCORD_CHANNEL_TRIGGERS),
515
447
  collectDebounceMs: readInteger(discord.collect_debounce_ms, 4000, "discord.collect_debounce_ms"),
516
448
  allowBotMessages: readBoolean(discord.allow_bot_messages, false, "discord.allow_bot_messages"),
517
449
  },
518
450
  web: {
519
451
  port: readInteger(web.port, 8787, "web.port"),
520
- authMode: readWebAuthMode(readOptionalString(web.auth_mode, "tailscale-only")),
452
+ authMode: readEnum(readOptionalString(web.auth_mode, "tailscale-only"), "web.auth_mode", WEB_AUTH_MODES),
521
453
  bearerToken: readOptionalString(web.bearer_token, "") || undefined,
522
454
  totpSecret: readOptionalString(web.totp_secret, "") || undefined,
523
455
  bindAddress: readOptionalString(web.bind_address, "127.0.0.1"),
524
456
  },
525
457
  browser: {
526
458
  enabled: readBoolean(browser.enabled, false, "browser.enabled"),
527
- backend: readBrowserBackend(readOptionalString(browser.backend, "opencli")),
459
+ backend: readEnum(readOptionalString(browser.backend, "opencli"), "browser.backend", BROWSER_BACKENDS),
528
460
  opencliCommand: readOptionalString(browser.opencli_command, "opencli"),
529
461
  harnessCommand: readOptionalString(browser.harness_command, "browser-harness"),
530
462
  session: readOptionalString(browser.session, "familiar"),
531
463
  profile: readOptionalString(browser.profile, "") || undefined,
532
- windowMode: readBrowserWindowMode(readOptionalString(browser.window, "background")),
464
+ windowMode: readEnum(readOptionalString(browser.window, "background"), "browser.window", BROWSER_WINDOW_MODES),
533
465
  timeoutMs: readInteger(browser.timeout_ms, 60_000, "browser.timeout_ms", 1),
534
466
  maxOutputChars: readInteger(browser.max_output_chars, 12_000, "browser.max_output_chars", 1000),
535
467
  readWrite: readBoolean(browser.read_write, false, "browser.read_write"),
@@ -542,10 +474,10 @@ export async function loadConfig(workspacePathInput) {
542
474
  baseUrl: usingLegacyAgentModel ? baseUrl : undefined,
543
475
  apiKeyEnv: usingLegacyAgentModel ? apiKeyEnv : undefined,
544
476
  provider: usingLegacyAgentModel ? provider : undefined,
545
- cacheRetention: readCacheRetention(readOptionalString(agentCacheRetentionRaw, "long"), agent.cache_retention === undefined && agent.cacheRetention !== undefined
477
+ cacheRetention: readEnum(readOptionalString(agentCacheRetentionRaw, "long"), agent.cache_retention === undefined && agent.cacheRetention !== undefined
546
478
  ? "agent.cacheRetention"
547
- : "agent.cache_retention"),
548
- thinkingLevel: readThinkingLevel(readOptionalString(agent.thinking_level, "medium")),
479
+ : "agent.cache_retention", CACHE_RETENTIONS),
480
+ thinkingLevel: readEnum(readOptionalString(agent.thinking_level, "medium"), "agent.thinking_level", THINKING_LEVELS),
549
481
  },
550
482
  heartbeat: {
551
483
  enabled: readBoolean(heartbeat.enabled, false, "heartbeat.enabled"),
@@ -563,7 +495,7 @@ export async function loadConfig(workspacePathInput) {
563
495
  apiKeyEnvs: modelApiKeyEnvs,
564
496
  },
565
497
  tts: {
566
- provider: readTtsProvider(readOptionalString(tts.provider, "elevenlabs")),
498
+ provider: readEnum(readOptionalString(tts.provider, "elevenlabs"), "tts.provider", TTS_PROVIDERS),
567
499
  apiKeyEnv: readOptionalString(tts.api_key_env, "ELEVENLABS_API_KEY"),
568
500
  voiceId: readOptionalString(tts.voice_id, ""),
569
501
  modelId: readOptionalString(tts.model_id, "eleven_multilingual_v2"),
@@ -581,17 +513,17 @@ export async function loadConfig(workspacePathInput) {
581
513
  enabled: readBoolean(imageGen.enabled, true, "image_gen.enabled"),
582
514
  model: readConfigString(imageGen.model, "openrouter/google/gemini-2.5-flash-image", "image_gen.model"),
583
515
  fallbackModel: readOptionalConfigString(imageGen.fallback_model, "image_gen.fallback_model"),
584
- api: readImageGenApi(readOptionalString(imageGen.api, "openrouter-images")),
516
+ api: readEnum(readOptionalString(imageGen.api, "openrouter-images"), "image_gen.api", IMAGE_GEN_APIS),
585
517
  timeoutMs: readInteger(imageGen.timeout_ms, 120_000, "image_gen.timeout_ms", 1),
586
518
  },
587
519
  mediaUnderstanding: {
588
520
  audio: {
589
- provider: readMediaUnderstandingProvider(readOptionalString(mediaUnderstandingAudio.provider, "groq")),
521
+ provider: readEnum(readOptionalString(mediaUnderstandingAudio.provider, "groq"), "media.understanding.audio.provider", MEDIA_UNDERSTANDING_PROVIDERS),
590
522
  model: readOptionalString(mediaUnderstandingAudio.model, "whisper-large-v3"),
591
523
  apiKeyEnv: readOptionalString(mediaUnderstandingAudio.api_key_env, "GROQ_API_KEY"),
592
524
  },
593
525
  video: {
594
- provider: readMediaUnderstandingProvider(readOptionalString(mediaUnderstandingVideo.provider, "google")),
526
+ provider: readEnum(readOptionalString(mediaUnderstandingVideo.provider, "google"), "media.understanding.video.provider", MEDIA_UNDERSTANDING_PROVIDERS),
595
527
  model: readOptionalString(mediaUnderstandingVideo.model, "gemini-3-flash-preview"),
596
528
  apiKeyEnv: readOptionalString(mediaUnderstandingVideo.api_key_env, "GEMINI_API_KEY"),
597
529
  },
@@ -1,23 +1,13 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { resolve } from "node:path";
2
+ import { readFileOrNull } from "./util/fs.js";
3
3
  let contactNotePath = resolve(process.cwd(), "CONTACT.md");
4
4
  let cachedNickname = null;
5
- function isMissingFile(error) {
6
- return error instanceof Error && "code" in error && error.code === "ENOENT";
7
- }
8
5
  export function setContactNotePath(path) {
9
6
  contactNotePath = path;
10
7
  cachedNickname = null;
11
8
  }
12
9
  export async function loadContactNote() {
13
- try {
14
- return await readFile(contactNotePath, "utf8");
15
- }
16
- catch (error) {
17
- if (isMissingFile(error))
18
- return null;
19
- throw error;
20
- }
10
+ return readFileOrNull(contactNotePath, "utf8");
21
11
  }
22
12
  export function parseContactNickname(raw, fallback) {
23
13
  let remaining = raw?.trim() ?? "";
@@ -1,5 +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
4
  export async function runDataRetention(config, now = Date.now()) {
4
5
  if (config.data.transcripts.retentionDays > 0) {
5
6
  console.warn("data.transcripts.retention_days is configured; transcript replay may lose restart context after retention.");
@@ -49,6 +50,3 @@ async function listFiles(root) {
49
50
  }));
50
51
  return nested.flat();
51
52
  }
52
- function isEnoent(error) {
53
- return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
54
- }
package/dist/discord.js CHANGED
@@ -7,7 +7,7 @@ import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurat
7
7
  import { chatChannelKey, createChatLog } from "./chat-log.js";
8
8
  import { materializeInboundAttachments, promptImagesFromAttachments } from "./inbound-attachments.js";
9
9
  import { ConversationRuntime } from "./runtime.js";
10
- import { appendSchedulerLog, buildCronInjectionText, buildHeartbeatInjectionText, dueCronSlot, isHeartbeatDue, loadSchedulerState, saveSchedulerState, } from "./scheduler.js";
10
+ import { appendSchedulerLog, buildCronInjectionText, buildHeartbeatInjectionText, dueCronSlot, formatIdleDuration, isHeartbeatDue, loadSchedulerState, saveSchedulerState, } from "./scheduler.js";
11
11
  import { parseAgentReply as parseSilentMarker } from "./silent-marker.js";
12
12
  const FAMILIAR_COMMAND_NAME = "familiar";
13
13
  const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh"];
@@ -917,7 +917,7 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
917
917
  schedulerState.heartbeat = { lastFiredAt: new Date(queuedNow).toISOString() };
918
918
  await saveScheduler();
919
919
  const text = buildHeartbeatInjectionText({ now: queuedNow, idleSince: latestUserInteractionAt });
920
- await heartbeatRuntime.noteHeartbeat(`started after ${Math.floor((queuedNow - latestUserInteractionAt) / 60_000)} idle minute(s)`);
920
+ await heartbeatRuntime.noteHeartbeat(`heartbeat stirred after ${formatIdleDuration(queuedNow - latestUserInteractionAt)}`);
921
921
  return scheduledUserMessage(text, queuedNow);
922
922
  }, async (event) => {
923
923
  updateAgentEventSummary(summary, event);
@@ -1,5 +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
4
  export function createGeneratedMediaSink() {
4
5
  const attachments = [];
5
6
  return {
@@ -41,7 +42,7 @@ export async function cleanupGeneratedAttachments(config, now = Date.now()) {
41
42
  entries = await readdir(dir);
42
43
  }
43
44
  catch (error) {
44
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
45
+ if (isEnoent(error))
45
46
  return 0;
46
47
  throw error;
47
48
  }
@@ -52,7 +53,7 @@ export async function cleanupGeneratedAttachments(config, now = Date.now()) {
52
53
  if (!fileStat?.isFile() || fileStat.mtimeMs > cutoff)
53
54
  continue;
54
55
  await rm(path).catch((error) => {
55
- if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT"))
56
+ if (!isEnoent(error))
56
57
  throw error;
57
58
  });
58
59
  removed++;
@@ -2,6 +2,7 @@ import { watch } from "node:fs";
2
2
  import { readdir } from "node:fs/promises";
3
3
  import { basename, relative, resolve, sep } from "node:path";
4
4
  import { refreshContactNote } from "./contact-note.js";
5
+ import { isEnoent } from "./util/fs.js";
5
6
  const ROOT_FILES = new Set([
6
7
  "config.toml",
7
8
  ".env",
@@ -13,9 +14,6 @@ const ROOT_FILES = new Set([
13
14
  "HEARTBEAT.md",
14
15
  ]);
15
16
  const SKILLS_DIR = "skills";
16
- function isEnoent(error) {
17
- return !!error && typeof error === "object" && error.code === "ENOENT";
18
- }
19
17
  function shouldReloadForPath(workspacePath, changedPath) {
20
18
  const relativePath = relative(workspacePath, resolve(changedPath));
21
19
  if (!relativePath || relativePath.startsWith("..") || relativePath.split(sep).includes(".."))
package/dist/image-gen.js CHANGED
@@ -1,27 +1,23 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstat, writeFile } from "node:fs/promises";
3
- import { basename, extname, isAbsolute, relative, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { basename, isAbsolute, relative, resolve } from "node:path";
4
5
  import { findEnvKeys, generateImages, getEnvApiKey, getImageModels, getImageProviders, } from "@earendil-works/pi-ai";
5
6
  import { Type } from "typebox";
6
7
  import { ensureGeneratedAttachmentsDir } from "./generated-media.js";
7
8
  import { ensureInlineImageDerivative } from "./image-derivatives.js";
8
9
  import { promptImagesFromAttachments } from "./inbound-attachments.js";
9
10
  import { parseModelRef } from "./models.js";
11
+ import { imageMimeTypeFromPath, sniffImageMimeType } from "./util/image-mime.js";
10
12
  const IMAGE_GEN_NOTICE_PREFIX = "Generated image attachment:";
11
13
  const OPENROUTER_IMAGE_BASE_URL = "https://openrouter.ai/api/v1";
12
- const IMAGE_MIME_BY_EXTENSION = {
13
- ".jpg": "image/jpeg",
14
- ".jpeg": "image/jpeg",
15
- ".png": "image/png",
16
- ".gif": "image/gif",
17
- ".webp": "image/webp",
18
- };
19
14
  const imageGenSchema = Type.Object({
20
15
  prompt: Type.String({ description: "Image generation prompt." }),
21
16
  referenceImages: Type.Optional(Type.Array(Type.String(), {
22
- description: "Optional. Image attachment IDs or names, or workspace-relative image file paths, to use as visual references. Prefer IDs from the attachment tags when available.",
17
+ description: "Optional. Image attachment IDs or names, or workspace-relative, absolute, or ~/ image file paths, to use as visual references. Prefer IDs from the attachment tags when available.",
23
18
  })),
24
19
  }, { additionalProperties: false });
20
+ const MAX_REMOTE_IMAGE_BYTES = 12 * 1024 * 1024;
25
21
  function formatImageGenNotice(name) {
26
22
  return `${IMAGE_GEN_NOTICE_PREFIX} ${name}`;
27
23
  }
@@ -107,20 +103,6 @@ function textOutput(result) {
107
103
  .filter(Boolean)
108
104
  .join("\n");
109
105
  }
110
- function imageMimeTypeFromBytes(buffer) {
111
- if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])))
112
- return "image/jpeg";
113
- if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
114
- return "image/png";
115
- }
116
- if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a") {
117
- return "image/gif";
118
- }
119
- if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
120
- return "image/webp";
121
- }
122
- return undefined;
123
- }
124
106
  function recoveredImageFromBase64(value) {
125
107
  const data = value.trim();
126
108
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(data) || data.length % 4 !== 0)
@@ -128,7 +110,7 @@ function recoveredImageFromBase64(value) {
128
110
  const buffer = Buffer.from(data, "base64");
129
111
  if (!buffer.length)
130
112
  return undefined;
131
- const detectedMimeType = imageMimeTypeFromBytes(buffer);
113
+ const detectedMimeType = sniffImageMimeType(buffer);
132
114
  if (!detectedMimeType)
133
115
  return undefined;
134
116
  return {
@@ -136,7 +118,7 @@ function recoveredImageFromBase64(value) {
136
118
  data,
137
119
  };
138
120
  }
139
- function recoveredImageFromText(text) {
121
+ function recoveredInlineImageFromText(text) {
140
122
  const trimmed = text.trim();
141
123
  const dataUrlMatch = trimmed.match(/^data:(image\/[^;]+);base64,([A-Za-z0-9+/]+={0,2})$/);
142
124
  if (dataUrlMatch)
@@ -147,7 +129,80 @@ function recoveredImageFromText(text) {
147
129
  }
148
130
  return recoveredImageFromBase64(trimmed);
149
131
  }
150
- function normalizeCompatibleImageText(result) {
132
+ function imageUrlFromMarkdownText(text) {
133
+ const match = text.match(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/i);
134
+ if (!match?.[1])
135
+ return undefined;
136
+ try {
137
+ const url = new URL(match[1]);
138
+ if (url.protocol !== "https:" && url.protocol !== "http:")
139
+ return undefined;
140
+ return url;
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ async function readBoundedResponseBody(response, maxBytes) {
147
+ const reader = response.body?.getReader();
148
+ if (!reader) {
149
+ const buffer = Buffer.from(await response.arrayBuffer());
150
+ return buffer.byteLength > maxBytes ? undefined : buffer;
151
+ }
152
+ const chunks = [];
153
+ let total = 0;
154
+ try {
155
+ for (;;) {
156
+ const { done, value } = await reader.read();
157
+ if (done)
158
+ break;
159
+ const chunk = Buffer.from(value);
160
+ total += chunk.byteLength;
161
+ if (total > maxBytes)
162
+ return undefined;
163
+ chunks.push(chunk);
164
+ }
165
+ return Buffer.concat(chunks);
166
+ }
167
+ finally {
168
+ reader.releaseLock();
169
+ }
170
+ }
171
+ async function recoveredImageFromRemoteUrl(url, options) {
172
+ try {
173
+ const response = await fetch(url, { signal: options.signal });
174
+ if (!response.ok)
175
+ return undefined;
176
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
177
+ if (contentLength > MAX_REMOTE_IMAGE_BYTES)
178
+ return undefined;
179
+ const bytes = await readBoundedResponseBody(response, MAX_REMOTE_IMAGE_BYTES);
180
+ if (!bytes)
181
+ return undefined;
182
+ const detectedMimeType = sniffImageMimeType(bytes);
183
+ if (!detectedMimeType)
184
+ return undefined;
185
+ return {
186
+ mimeType: detectedMimeType,
187
+ data: bytes.toString("base64"),
188
+ };
189
+ }
190
+ catch (error) {
191
+ if (options.signal?.aborted)
192
+ throw error;
193
+ return undefined;
194
+ }
195
+ }
196
+ async function recoveredImageFromText(text, options) {
197
+ const inlineImage = recoveredInlineImageFromText(text);
198
+ if (inlineImage)
199
+ return inlineImage;
200
+ const url = imageUrlFromMarkdownText(text);
201
+ if (!url)
202
+ return undefined;
203
+ return recoveredImageFromRemoteUrl(url, options);
204
+ }
205
+ async function normalizeCompatibleImageText(result, options) {
151
206
  if (result.output.some((item) => item.type === "image"))
152
207
  return result;
153
208
  const output = [];
@@ -156,7 +211,7 @@ function normalizeCompatibleImageText(result) {
156
211
  output.push(item);
157
212
  continue;
158
213
  }
159
- const recovered = recoveredImageFromText(item.text);
214
+ const recovered = await recoveredImageFromText(item.text, options);
160
215
  if (!recovered) {
161
216
  output.push(item);
162
217
  continue;
@@ -167,11 +222,12 @@ function normalizeCompatibleImageText(result) {
167
222
  return result;
168
223
  return { ...result, output };
169
224
  }
170
- function mimeTypeFromPath(path) {
171
- return IMAGE_MIME_BY_EXTENSION[extname(path).toLowerCase()];
172
- }
173
225
  function resolveWorkspaceReferencePath(config, rawRef) {
174
- const path = isAbsolute(rawRef) ? resolve(rawRef) : resolve(config.workspacePath, rawRef);
226
+ if (rawRef === "~" || rawRef.startsWith("~/"))
227
+ return resolve(homedir(), rawRef.slice(2));
228
+ if (isAbsolute(rawRef))
229
+ return resolve(rawRef);
230
+ const path = resolve(config.workspacePath, rawRef);
175
231
  const workspaceRelative = relative(config.workspacePath, path);
176
232
  if (!workspaceRelative || workspaceRelative.startsWith("..") || isAbsolute(workspaceRelative)) {
177
233
  throw new Error(`Reference image path must be inside the workspace: ${rawRef}`);
@@ -190,7 +246,7 @@ async function collectWorkspaceReferenceImages(config, rawRef) {
190
246
  }
191
247
  if (!pathStat.isFile())
192
248
  throw new Error(`Reference image path is not a file or folder: ${rawRef}`);
193
- const mimeType = mimeTypeFromPath(path);
249
+ const mimeType = imageMimeTypeFromPath(path);
194
250
  if (!mimeType)
195
251
  throw new Error(`Reference image path is not a supported image: ${rawRef}`);
196
252
  return [
@@ -327,22 +383,19 @@ async function writeGeneratedImages(config, mediaSink, result) {
327
383
  async function tryGenerateImages(config, ref, prompt, references, workspaceRefs, signal, generate) {
328
384
  const model = resolveImageModel(config, ref);
329
385
  const context = await buildImageContext(model, prompt, references, workspaceRefs, config);
386
+ const result = await generate(model, context, {
387
+ apiKey: resolveImageModelApiKey(config, model),
388
+ signal,
389
+ timeoutMs: config.imageGen.timeoutMs,
390
+ });
330
391
  return {
331
392
  model,
332
- result: normalizeCompatibleImageText(await generate(model, context, {
333
- apiKey: resolveImageModelApiKey(config, model),
334
- signal,
335
- timeoutMs: config.imageGen.timeoutMs,
336
- })),
393
+ result: await normalizeCompatibleImageText(result, { signal }),
337
394
  };
338
395
  }
339
396
  function attemptDetails(model, result) {
340
397
  return {
341
- provider: model.provider,
342
- model: model.id,
343
- api: model.api,
344
- baseUrl: model.baseUrl,
345
- ...(result.responseId ? { responseId: result.responseId } : {}),
398
+ model: `${model.provider}/${model.id}`,
346
399
  stopReason: result.stopReason,
347
400
  ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
348
401
  };
@@ -378,18 +431,8 @@ export function createImageGenTool(config, mediaSink, deps = {}) {
378
431
  }
379
432
  catch (error) {
380
433
  const message = error instanceof Error ? error.message : String(error);
381
- let baseUrl = "";
382
- try {
383
- baseUrl = resolveImageModel(config, ref).baseUrl;
384
- }
385
- catch {
386
- baseUrl = "";
387
- }
388
434
  attempts.push({
389
- provider: ref.provider,
390
- model: ref.id,
391
- api: config.imageGen.api,
392
- baseUrl,
435
+ model: `${ref.provider}/${ref.id}`,
393
436
  stopReason: "error",
394
437
  errorMessage: message,
395
438
  });
@@ -414,8 +457,10 @@ export function createImageGenTool(config, mediaSink, deps = {}) {
414
457
  if (!selected)
415
458
  throw new Error(`Image generation failed: ${selectedError}`);
416
459
  const attachments = await writeGeneratedImages(config, mediaSink, selected.result);
460
+ const primaryAttachment = attachments[0];
417
461
  const notices = attachments.map((attachment) => formatImageGenNotice(attachment.name));
418
462
  const sideText = textOutput(selected.result);
463
+ const selectedAttempt = attempts.at(-1);
419
464
  return {
420
465
  content: [
421
466
  {
@@ -424,15 +469,11 @@ export function createImageGenTool(config, mediaSink, deps = {}) {
424
469
  },
425
470
  ],
426
471
  details: {
427
- provider: selected.model.provider,
428
- model: selected.model.id,
429
- api: selected.model.api,
430
- baseUrl: selected.model.baseUrl,
431
- prompt,
432
- ...(selected.result.responseId ? { responseId: selected.result.responseId } : {}),
472
+ model: `${selected.model.provider}/${selected.model.id}`,
433
473
  ...(sideText ? { textOutput: sideText } : {}),
434
- attachments,
435
- attempts,
474
+ ...(primaryAttachment ? { id: primaryAttachment.id, localPath: primaryAttachment.localPath } : {}),
475
+ stopReason: selectedAttempt?.stopReason ?? selected.result.stopReason,
476
+ ...(selectedAttempt?.errorMessage ? { errorMessage: selectedAttempt.errorMessage } : {}),
436
477
  },
437
478
  };
438
479
  },