@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.
- package/README.md +5 -2
- package/config.example.toml +1 -1
- package/dist/added-models.js +6 -15
- package/dist/agent-events.js +1 -3
- package/dist/agent.js +3 -4
- package/dist/browser-tools.js +84 -30
- package/dist/chat-log.js +3 -2
- package/dist/cli.js +2 -2
- package/dist/config-overrides.js +5 -14
- package/dist/config-registry.js +1 -4
- package/dist/config.js +45 -113
- package/dist/contact-note.js +2 -12
- package/dist/data-retention.js +1 -3
- package/dist/discord.js +2 -2
- package/dist/generated-media.js +3 -2
- package/dist/hot-reload.js +1 -3
- package/dist/image-gen.js +102 -61
- package/dist/inbound-attachments.js +53 -22
- package/dist/memory/diary/ambient-injector.js +1 -3
- package/dist/memory/diary/ambient.js +1 -3
- package/dist/memory/diary/chunks.js +1 -3
- package/dist/memory/diary/indexer.js +1 -3
- package/dist/memory/doctor.js +3 -8
- package/dist/memory/index/chunk-indexer.js +6 -2
- package/dist/memory/index/retrieval.js +1 -3
- package/dist/memory/index/store.js +47 -19
- package/dist/memory/lcm/backfill.js +19 -16
- package/dist/memory/lcm/context-transformer.js +12 -24
- package/dist/memory/lcm/context.js +10 -4
- package/dist/memory/lcm/eviction-score.js +25 -13
- package/dist/memory/lcm/indexer.js +1 -5
- package/dist/memory/lcm/normalize.js +22 -1
- package/dist/memory/lcm/store.js +27 -24
- package/dist/memory/operator.js +2 -4
- package/dist/memory/service.js +1 -3
- package/dist/memory/tools.js +0 -4
- package/dist/memory/util.js +6 -0
- package/dist/models.js +3 -0
- package/dist/persona.js +2 -14
- package/dist/runtime.js +2 -23
- package/dist/scheduler.js +15 -49
- package/dist/service.js +24 -14
- package/dist/settings.js +7 -32
- package/dist/tts.js +0 -6
- package/dist/util/fs.js +41 -0
- package/dist/util/guards.js +8 -0
- package/dist/util/image-mime.js +31 -0
- package/dist/util/time.js +29 -0
- package/dist/web-auth.js +4 -1
- package/dist/web-tools.js +8 -5
- package/dist/web.js +188 -62
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/web/dist/assets/index-B23WT77N.js +63 -0
- package/web/dist/assets/index-D3MotFzN.css +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-C-w9fjBf.js +0 -61
- 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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
246
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
511
|
-
chunkMode:
|
|
512
|
-
dmMode:
|
|
513
|
-
channelMode:
|
|
514
|
-
channelTrigger:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
477
|
+
cacheRetention: readEnum(readOptionalString(agentCacheRetentionRaw, "long"), agent.cache_retention === undefined && agent.cacheRetention !== undefined
|
|
546
478
|
? "agent.cacheRetention"
|
|
547
|
-
: "agent.cache_retention"),
|
|
548
|
-
thinkingLevel:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
},
|
package/dist/contact-note.js
CHANGED
|
@@ -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
|
-
|
|
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() ?? "";
|
package/dist/data-retention.js
CHANGED
|
@@ -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(`
|
|
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);
|
package/dist/generated-media.js
CHANGED
|
@@ -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
|
|
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
|
|
56
|
+
if (!isEnoent(error))
|
|
56
57
|
throw error;
|
|
57
58
|
});
|
|
58
59
|
removed++;
|
package/dist/hot-reload.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
},
|