a tool to help your Letta AI agents navigate bluesky
1import type {
2 agentContextObject,
3 allAgentTool,
4 AutomationLevel,
5 configAgentTool,
6 notifType,
7 ResponsiblePartyType,
8} from "./types.ts";
9import {
10 configAgentTools,
11 requiredAgentTools,
12 validAutomationLevels,
13 validNotifTypes,
14} from "./const.ts";
15import { msFrom } from "./time.ts";
16import { bsky } from "./bsky.ts";
17import {
18 isAgentAsleep as checkIsAsleep,
19 isAgentAwake as checkIsAwake,
20} from "./sleepWakeHelpers.ts";
21
22export const getLettaApiKey = (): string => {
23 const value = Deno.env.get("LETTA_API_KEY")?.trim();
24
25 if (!value?.length) {
26 throw Error(
27 "Letta API key not provided in `.env`. add variable `LETTA_API_KEY=`.",
28 );
29 } else if (!value.startsWith("sk")) {
30 throw Error(
31 "Letta API key is not formed correctly, check variable `LETTA_API_KEY",
32 );
33 }
34
35 return value;
36};
37
38export const getLettaAgentID = (): string => {
39 const value = Deno.env.get("LETTA_AGENT_ID")?.trim();
40
41 if (!value?.length) {
42 throw Error(
43 "Letta Agent ID not provided in `.env`. add variable `LETTA_AGENT_ID=`.",
44 );
45 } else if (!value.startsWith("agent-")) {
46 throw Error(
47 "Letta Agent ID is not formed correctly, check variable `LETTA_AGENT_ID`",
48 );
49 }
50
51 return value;
52};
53
54const getLettaProjectID = (): string => {
55 const value = Deno.env.get("LETTA_PROJECT_ID")?.trim();
56
57 if (!value?.length) {
58 throw Error(
59 "Letta Project ID not provided in `.env`. add variable `LETTA_PROJECT_ID=`.",
60 );
61 } else if (!value.includes("-")) {
62 throw Error(
63 "Letta Project ID is not formed correctly, check variable `LETTA_PROJECT_ID`",
64 );
65 }
66
67 return value;
68};
69
70const getAgentBskyHandle = (): string => {
71 const value = Deno.env.get("BSKY_USERNAME")?.trim();
72
73 if (!value?.length) {
74 throw Error(
75 "Bluesky Handle for agent not provided in `.env`. add variable `BSKY_USERNAME=`",
76 );
77 }
78
79 const cleanHandle = value.startsWith("@") ? value.slice(1) : value;
80
81 if (!cleanHandle.includes(".")) {
82 throw Error(
83 `Invalid handle format: ${value}. Expected format: user.bsky.social`,
84 );
85 }
86
87 return cleanHandle;
88};
89
90const getAgentBskyName = async (): Promise<string> => {
91 try {
92 const profile = await bsky.getProfile({ actor: getAgentBskyHandle() });
93 const displayName = profile?.data.displayName?.trim();
94
95 if (displayName) {
96 return displayName;
97 }
98 throw Error(`No display name found for ${getAgentBskyHandle()}`);
99 } catch (error) {
100 throw Error(`Failed to get display name: ${error}`);
101 }
102};
103
104const getResponsiblePartyName = (): string => {
105 const value = Deno.env.get("RESPONSIBLE_PARTY_NAME")?.trim();
106
107 if (!value?.length) {
108 throw Error("RESPONSIBLE_PARTY_NAME environment variable is not set");
109 }
110
111 return value;
112};
113
114const getResponsiblePartyContact = (): string => {
115 const value = Deno.env.get("RESPONSIBLE_PARTY_CONTACT")?.trim();
116
117 if (!value?.length) {
118 throw Error("RESPONSIBLE_PARTY_CONTACT environment variable is not set");
119 }
120
121 return value;
122};
123
124const getAutomationLevel = (): AutomationLevel => {
125 const value = Deno.env.get("AUTOMATION_LEVEL")?.trim();
126 const valid = validAutomationLevels;
127
128 if (!value) {
129 return "automated";
130 }
131
132 if (!valid.includes(value as typeof valid[number])) {
133 throw Error(
134 `Invalid automation level: ${value}. Must be one of: ${valid.join(", ")}`,
135 );
136 }
137
138 return value as AutomationLevel;
139};
140
141const setAgentBskyDID = (): string => {
142 if (!bsky.did) {
143 throw Error(`couldn't get DID for ${getAgentBskyHandle()}`);
144 } else {
145 return bsky.did;
146 }
147};
148
149const getBskyServiceUrl = (): string => {
150 const value = Deno.env.get("BSKY_SERVICE_URL")?.trim();
151
152 if (!value?.length || !value?.startsWith("https://")) {
153 return "https://bsky.social";
154 }
155
156 return value;
157};
158
159const getSupportedNotifTypes = (): notifType[] => {
160 const value = Deno.env.get("BSKY_NOTIFICATION_TYPES");
161
162 if (!value?.length) {
163 return ["mention", "reply"];
164 }
165
166 const notifList = value.split(",").map((type) => type.trim());
167
168 for (const notifType of notifList) {
169 if (
170 !validNotifTypes.includes(notifType as typeof validNotifTypes[number])
171 ) {
172 throw Error(
173 `"${notifType}" is not a valid notification type. check "BSKY_NOTIFICATION_TYPES" variable in your \`.env\` file.`,
174 );
175 }
176 }
177
178 return notifList as notifType[];
179};
180
181const getSupportedTools = (): allAgentTool[] => {
182 const value = Deno.env.get("BSKY_SUPPORTED_TOOLS");
183 const defaultTools: configAgentTool[] = [
184 "create_bluesky_post",
185 "update_bluesky_profile",
186 ];
187
188 if (!value?.length) {
189 return [...defaultTools, ...requiredAgentTools] as allAgentTool[];
190 }
191
192 const toolList = value.split(",").map((type) => type.trim());
193
194 for (const tool of toolList) {
195 if (!configAgentTools.includes(tool as typeof configAgentTools[number])) {
196 throw Error(
197 `"${tool}" is not a valid tool name. check "BSKY_SUPPORTED_TOOLS" variable in your \`.env\` file.`,
198 );
199 } else if (
200 requiredAgentTools.includes(tool as typeof requiredAgentTools[number])
201 ) {
202 throw Error(
203 `${tool} is always included and does not need to be added to "BSKY_SUPPORTED_TOOLS" in \`env\`.`,
204 );
205 }
206 }
207
208 return toolList.concat(requiredAgentTools) as allAgentTool[];
209};
210
211const getNotifDelayMinimum = (): number => {
212 const value = msFrom.parse(Deno.env.get("NOTIF_DELAY_MINIMUM"));
213
214 if (isNaN(value) || value < msFrom.seconds(1) || value > msFrom.hours(24)) {
215 return msFrom.seconds(10);
216 }
217
218 return value;
219};
220
221const getNotifDelayMaximum = (): number => {
222 const value = msFrom.parse(Deno.env.get("NOTIF_DELAY_MAXIMUM"));
223
224 if (isNaN(value) || value < msFrom.seconds(5) || value > msFrom.hours(24)) {
225 return msFrom.minutes(90);
226 }
227
228 const minimum = getNotifDelayMinimum();
229
230 if (value <= minimum) {
231 throw Error(
232 `"NOTIF_DELAY_MAXIMUM" cannot be less than or equal to "NOTIF_DELAY_MINIMUM"`,
233 );
234 }
235
236 return value;
237};
238
239const getNotifDelayMultiplier = (): number => {
240 const value = Number(Deno.env.get("NOTIF_DELAY_MULTIPLIER"));
241
242 if (isNaN(value) || value < 0 || value > 500) {
243 return 1.12;
244 }
245
246 return (value / 100) + 1;
247};
248
249const getMaxThreadPosts = (): number => {
250 const value = Number(Deno.env.get("MAX_THREAD_POSTS"));
251
252 if (isNaN(value) || value < 5 || value > 250) {
253 return 25;
254 }
255
256 return Math.round(value);
257};
258
259const getReflectionDelayMinimum = (): number => {
260 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM"));
261
262 if (isNaN(value) || value < msFrom.minutes(30) || value > msFrom.hours(24)) {
263 return msFrom.hours(3);
264 }
265
266 return value;
267};
268
269const getReflectionDelayMaximum = (): number => {
270 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MAXIMUM"));
271 const minimum = getReflectionDelayMinimum();
272
273 if (isNaN(value) || value < msFrom.minutes(60) || value > msFrom.hours(24)) {
274 return msFrom.hours(14);
275 }
276
277 if (value <= minimum) {
278 throw Error(
279 `"REFLECTION_DELAY_MAXIMUM" cannot be less than or equal to "REFLECTION_DELAY_MINIMUM"`,
280 );
281 }
282
283 return value;
284};
285
286const getProactiveDelayMinimum = (): number => {
287 const value = msFrom.parse(Deno.env.get("PROACTIVE_DELAY_MINIMUM"));
288
289 if (isNaN(value) || value < msFrom.hours(1) || value > msFrom.hours(24)) {
290 return msFrom.hours(3);
291 }
292
293 return value;
294};
295
296const getProactiveDelayMaximum = (): number => {
297 const value = msFrom.parse(Deno.env.get("PROACTIVE_DELAY_MAXIMUM"));
298 const minimum = getProactiveDelayMinimum();
299
300 if (isNaN(value) || value < msFrom.hours(3) || value > msFrom.hours(24)) {
301 return msFrom.hours(14);
302 }
303
304 if (value <= minimum) {
305 throw Error(
306 `"PROACTIVE_DELAY_MAXIMUM" cannot be less than or equal to "PROACTIVE_DELAY_MINIMUM"`,
307 );
308 }
309
310 return value;
311};
312
313const getWakeTime = (): number => {
314 const envValue = Deno.env.get("WAKE_TIME");
315
316 if (envValue === undefined || envValue === null || envValue === "") {
317 return 8;
318 }
319
320 const value = Math.round(Number(envValue));
321
322 if (isNaN(value)) {
323 throw Error(`"WAKE_TIME" must be a valid number, got: "${envValue}"`);
324 }
325
326 if (value > 23) {
327 throw Error(`"WAKE_TIME" cannot be greater than 23 (11pm)`);
328 }
329
330 if (value < 0) {
331 throw Error(`"WAKE_TIME" cannot be less than 0 (midnight)`);
332 }
333
334 return value;
335};
336
337const getSleepTime = (): number => {
338 const envValue = Deno.env.get("SLEEP_TIME");
339
340 if (envValue === undefined || envValue === null || envValue === "") {
341 return 10;
342 }
343
344 const value = Math.round(Number(envValue));
345
346 if (isNaN(value)) {
347 throw Error(`"SLEEP_TIME" must be a valid number, got: "${envValue}"`);
348 }
349
350 if (value > 23) {
351 throw Error(`"SLEEP_TIME" cannot be greater than 23 (11pm)`);
352 }
353
354 if (value < 0) {
355 throw Error(`"SLEEP_TIME" cannot be less than 0 (midnight)`);
356 }
357
358 return value;
359};
360
361const getTimeZone = (): string => {
362 const value = Deno.env.get("TIMEZONE")?.trim();
363
364 if (!value?.length) {
365 return "America/Los_Angeles";
366 }
367
368 try {
369 Intl.DateTimeFormat(undefined, { timeZone: value });
370 return value;
371 } catch {
372 throw Error(
373 `Invalid timezone: ${value}. Must be a valid IANA timezone like "America/New_York"`,
374 );
375 }
376};
377
378const getResponsiblePartyType = (): ResponsiblePartyType => {
379 const value = Deno.env.get("RESPONSIBLE_PARTY_TYPE")?.trim().toLowerCase();
380
381 if (value === "person" || value === "organization") {
382 return value;
383 }
384
385 return "person";
386};
387
388const setReflectionEnabled = (): boolean => {
389 const reflectionMinVal = Deno.env.get("REFLECTION_DELAY_MINIMUM");
390 const reflectionMaxVal = Deno.env.get("REFLECTION_DELAY_MAXIMUM");
391
392 if (reflectionMinVal?.length && reflectionMaxVal?.length) {
393 return true;
394 }
395
396 return false;
397};
398
399const setProactiveEnabled = (): boolean => {
400 const proactiveMinVal = Deno.env.get("PROACTIVE_DELAY_MINIMUM");
401 const proactiveMaxVal = Deno.env.get("PROACTIVE_DELAY_MAXIMUM");
402
403 if (proactiveMinVal?.length && proactiveMaxVal?.length) {
404 return true;
405 }
406
407 return false;
408};
409
410const setSleepEnabled = (): boolean => {
411 const sleep = Deno.env.get("SLEEP_TIME");
412 const wake = Deno.env.get("WAKE_TIME");
413
414 if (sleep?.length && wake?.length) {
415 return true;
416 }
417
418 return false;
419};
420
421const getPreserveMemoryBlocks = (): boolean => {
422 const value = Deno.env.get("PRESERVE_MEMORY_BLOCKS")?.trim().toLowerCase();
423
424 if (!value?.length) {
425 return false;
426 }
427
428 return value === "true" || value === "1";
429};
430
431export const getBskyAppPassword = (): string => {
432 const value = Deno.env.get("BSKY_APP_PASSWORD")?.trim();
433
434 if (!value?.length) {
435 throw Error(
436 "Bluesky app password not provided in `.env`. add variable `BSKY_APP_PASSWORD=`",
437 );
438 }
439
440 const hyphenCount = value.split("-").length - 1;
441
442 if (value.length !== 19 || hyphenCount !== 2) {
443 throw Error(
444 "You are likely not using an app password. App passwords are 19 characters with 2 hyphens (format: xxxx-xxxx-xxxx). You can generate one at https://bsky.app/settings/app-passwords",
445 );
446 }
447
448 return value;
449};
450
451export const getAutomationDescription = (): string | undefined => {
452 const value = Deno.env.get("AUTOMATION_DESCRIPTION")?.trim();
453
454 if (!value?.length) {
455 return undefined;
456 }
457
458 if (value.length < 10) {
459 throw Error(
460 "Automation description must be at least 10 characters long",
461 );
462 }
463
464 return value;
465};
466
467export const getDisclosureUrl = (): string | undefined => {
468 const value = Deno.env.get("DISCLOSURE_URL")?.trim();
469
470 if (!value?.length) {
471 return undefined;
472 }
473
474 if (value.length < 6) {
475 throw Error(
476 "Disclosure URL must be at least 6 characters long",
477 );
478 }
479
480 return value;
481};
482
483export const getResponsiblePartyBsky = async (): Promise<
484 string | undefined
485> => {
486 const value = Deno.env.get("RESPONSIBLE_PARTY_BSKY")?.trim();
487
488 if (!value?.length) {
489 return undefined;
490 }
491
492 // If it's already a DID, return it
493 if (value.startsWith("did:")) {
494 return value;
495 }
496
497 // If it looks like a handle (contains a dot), resolve it to a DID
498 if (value.includes(".")) {
499 try {
500 const profile = await bsky.getProfile({ actor: value });
501 return profile.data.did;
502 } catch (error) {
503 throw Error(
504 `Failed to resolve DID for handle "${value}": ${error}`,
505 );
506 }
507 }
508
509 // Not a DID and not a handle
510 throw Error(
511 `Invalid RESPONSIBLE_PARTY_BSKY value: "${value}". Must be either a DID (starts with "did:") or a handle (contains ".")`,
512 );
513};
514
515export const getExternalServices = (): string[] | undefined => {
516 const value = Deno.env.get("EXTERNAL_SERVICES")?.trim();
517
518 if (!value?.length) {
519 return undefined;
520 }
521
522 // Parse comma-separated list
523 const services = value
524 .split(",")
525 .map((service) => service.trim())
526 .filter((service) => service.length > 0);
527
528 if (services.length === 0) {
529 return undefined;
530 }
531
532 // Validate each service string
533 for (const service of services) {
534 if (service.length > 200) {
535 throw Error(
536 `External service name too long: "${
537 service.substring(0, 50)
538 }..." (max 200 characters)`,
539 );
540 }
541 }
542
543 // Validate array length
544 if (services.length > 20) {
545 throw Error(
546 `Too many external services specified: ${services.length} (max 20)`,
547 );
548 }
549
550 return services;
551};
552
553const populateAgentContext = async (): Promise<agentContextObject> => {
554 console.log("🔹 building new agentContext object…");
555 const context: agentContextObject = {
556 // state
557 busy: false,
558 sleeping: false,
559 checkCount: 0,
560 reflectionCount: 0,
561 processingCount: 0,
562 proactiveCount: 0,
563 likeCount: 0,
564 repostCount: 0,
565 followCount: 0,
566 mentionCount: 0,
567 replyCount: 0,
568 quoteCount: 0,
569 notifCount: 0,
570 // required with manual variables
571 lettaProjectIdentifier: getLettaProjectID(),
572 agentBskyHandle: getAgentBskyHandle(),
573 agentBskyName: await getAgentBskyName(),
574 agentBskyDID: setAgentBskyDID(),
575 responsiblePartyName: getResponsiblePartyName(),
576 responsiblePartyContact: getResponsiblePartyContact(),
577 agentBskyServiceUrl: getBskyServiceUrl(),
578 automationLevel: getAutomationLevel(),
579 supportedNotifTypes: getSupportedNotifTypes(),
580 supportedTools: getSupportedTools(),
581 notifDelayMinimum: getNotifDelayMinimum(),
582 notifDelayMaximum: getNotifDelayMaximum(),
583 notifDelayMultiplier: getNotifDelayMultiplier(),
584 reflectionDelayMinimum: getReflectionDelayMinimum(),
585 reflectionDelayMaximum: getReflectionDelayMaximum(),
586 proactiveDelayMinimum: getProactiveDelayMinimum(),
587 proactiveDelayMaximum: getProactiveDelayMaximum(),
588 wakeTime: getWakeTime(),
589 sleepTime: getSleepTime(),
590 timeZone: getTimeZone(),
591 responsiblePartyType: getResponsiblePartyType(),
592 preserveAgentMemory: getPreserveMemoryBlocks(),
593 maxThreadPosts: getMaxThreadPosts(),
594 reflectionEnabled: setReflectionEnabled(),
595 proactiveEnabled: setProactiveEnabled(),
596 sleepEnabled: setSleepEnabled(),
597 notifDelayCurrent: getNotifDelayMinimum(),
598 };
599
600 const automationDescription = getAutomationDescription();
601 if (automationDescription) {
602 context.automationDescription = automationDescription;
603 }
604
605 const disclosureUrl = getDisclosureUrl();
606 if (disclosureUrl) {
607 context.disclosureUrl = disclosureUrl;
608 }
609
610 const responsiblePartyBsky = await getResponsiblePartyBsky();
611 if (responsiblePartyBsky) {
612 context.responsiblePartyBsky = responsiblePartyBsky;
613 }
614
615 const externalServices = getExternalServices();
616 if (externalServices) {
617 context.externalServices = externalServices;
618 }
619 console.log(
620 `🔹 \`agentContext\` object built for ${context.agentBskyName}, BEGINING TASKS…`,
621 );
622 return context;
623};
624
625export const agentContext = await populateAgentContext();
626
627export const claimTaskThread = () => {
628 if (agentContext.busy) return false;
629 agentContext.busy = true;
630 return true;
631};
632
633export const releaseTaskThread = () => {
634 agentContext.busy = false;
635};
636
637export const resetAgentContextCounts = () => {
638 agentContext.likeCount = 0;
639 agentContext.repostCount = 0;
640 agentContext.followCount = 0;
641 agentContext.mentionCount = 0;
642 agentContext.replyCount = 0;
643 agentContext.quoteCount = 0;
644};
645
646export const isAgentAwake = (hour: number): boolean => {
647 return checkIsAwake(hour, agentContext.wakeTime, agentContext.sleepTime);
648};
649
650export const isAgentAsleep = (hour: number): boolean => {
651 return checkIsAsleep(hour, agentContext.wakeTime, agentContext.sleepTime);
652};