a tool to help your Letta AI agents navigate bluesky
at main 16 kB view raw
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};