a tool to help your Letta AI agents navigate bluesky

Compare changes

Choose any two refs to compare.

+2 -1
.env.example
··· 1 LETTA_API_KEY= 2 LETTA_AGENT_ID= 3 - LETTA_PROJECT_NAME= 4 BSKY_USERNAME= 5 BSKY_APP_PASSWORD= 6 RESPONSIBLE_PARTY_NAME= ··· 31 # RESPONSIBLE_PARTY_BSKY="DID:... or example.bsky.app, no @symbol" 32 # EXTERNAL_SERVICES="Letta, Railway, Google Gemini 2.5-pro" 33 # PRESERVE_MEMORY_BLOCKS=true
··· 1 LETTA_API_KEY= 2 LETTA_AGENT_ID= 3 + LETTA_PROJECT_ID= 4 BSKY_USERNAME= 5 BSKY_APP_PASSWORD= 6 RESPONSIBLE_PARTY_NAME= ··· 31 # RESPONSIBLE_PARTY_BSKY="DID:... or example.bsky.app, no @symbol" 32 # EXTERNAL_SERVICES="Letta, Railway, Google Gemini 2.5-pro" 33 # PRESERVE_MEMORY_BLOCKS=true 34 + # MAX_THREAD_POSTS=25
+1
README.md
··· 70 - **`RESPONSIBLE_PARTY_BSKY`**: the DID or bluesky handle of the responsible party 71 - **`EXTERNAL_SERVICES`**: a comma-separated list of external tools and services your agent relies on outside of Bluesky (e.g., "Letta, Railway, Google Gemini 2.5-pro"). This information is added to your agent's autonomy declaration record on the PDS and included in the agent's memory for transparency. 72 - **`PRESERVE_MEMORY_BLOCKS`**: a boolean for controlling if your agent's memory blocks can be overridden if you run `deno task mount` more than once. Setting this value to **`true`** will allow your agent's version of those memory blocks to persist if they already exist. This is false by default.
··· 70 - **`RESPONSIBLE_PARTY_BSKY`**: the DID or bluesky handle of the responsible party 71 - **`EXTERNAL_SERVICES`**: a comma-separated list of external tools and services your agent relies on outside of Bluesky (e.g., "Letta, Railway, Google Gemini 2.5-pro"). This information is added to your agent's autonomy declaration record on the PDS and included in the agent's memory for transparency. 72 - **`PRESERVE_MEMORY_BLOCKS`**: a boolean for controlling if your agent's memory blocks can be overridden if you run `deno task mount` more than once. Setting this value to **`true`** will allow your agent's version of those memory blocks to persist if they already exist. This is false by default. 73 + - **`MAX_THREAD_POSTS`**: maximum number of posts to include when fetching thread context (5-250). When a thread exceeds this limit, it will include the root post, a truncation indicator, and the most recent N posts. Default: 25
+2 -1
deno.json
··· 3 "config": "deno run --allow-read --allow-write setup.ts", 4 "mount": "deno run --allow-net --allow-env --allow-read --env mount.ts", 5 "watch": "deno run --allow-net --allow-env --env --watch main.ts", 6 - "start": "deno run --allow-net --allow-env --env main.ts" 7 }, 8 "imports": { 9 "@std/assert": "jsr:@std/assert@1",
··· 3 "config": "deno run --allow-read --allow-write setup.ts", 4 "mount": "deno run --allow-net --allow-env --allow-read --env mount.ts", 5 "watch": "deno run --allow-net --allow-env --env --watch main.ts", 6 + "start": "deno run --allow-net --allow-env --env main.ts", 7 + "test": "deno test" 8 }, 9 "imports": { 10 "@std/assert": "jsr:@std/assert@1",
+11 -7
prompts/quotePrompt.ts
··· 1 import { Notification } from "../utils/types.ts"; 2 import { doesUserFollowTarget } from "../utils/doesUserFollow.ts"; 3 import { agentContext } from "../utils/agentContext.ts"; 4 - import { getCleanThread } from "../utils/getCleanThread.ts"; 5 6 export const quotePrompt = async (notification: Notification) => { 7 const isUserFollower = await doesUserFollowTarget( ··· 60 quotes: undefined, 61 }]; 62 63 return ` 64 # NOTIFICATION: Someone quoted your post 65 ··· 75 76 ## Your Original Post 77 \`\`\` 78 - ${originalThread[originalThread.length - 1].message} 79 \`\`\` 80 81 ## The Quote Post from @${notification.author.handle} 82 \`\`\` 83 - ${quotePostThread[quotePostThread.length - 1].message} 84 \`\`\` 85 86 ## Quote Post Engagement 87 - โ€ข **Likes:** ${quotePostThread[quotePostThread.length - 1].likes} 88 - โ€ข **Replies:** ${quotePostThread[quotePostThread.length - 1].replies} 89 - โ€ข **Reposts:** ${quotePostThread[quotePostThread.length - 1].reposts} 90 - โ€ข **Quotes:** ${quotePostThread[quotePostThread.length - 1].quotes} 91 92 ${ 93 originalThread
··· 1 import { Notification } from "../utils/types.ts"; 2 import { doesUserFollowTarget } from "../utils/doesUserFollow.ts"; 3 import { agentContext } from "../utils/agentContext.ts"; 4 + import { getCleanThread, isThreadPost } from "../utils/getCleanThread.ts"; 5 6 export const quotePrompt = async (notification: Notification) => { 7 const isUserFollower = await doesUserFollowTarget( ··· 60 quotes: undefined, 61 }]; 62 63 + // Get the last post from each thread (last item is always a post, never a system message) 64 + const lastOriginalPost = originalThread[originalThread.length - 1] as any; 65 + const lastQuotePost = quotePostThread[quotePostThread.length - 1] as any; 66 + 67 return ` 68 # NOTIFICATION: Someone quoted your post 69 ··· 79 80 ## Your Original Post 81 \`\`\` 82 + ${lastOriginalPost.message} 83 \`\`\` 84 85 ## The Quote Post from @${notification.author.handle} 86 \`\`\` 87 + ${lastQuotePost.message} 88 \`\`\` 89 90 ## Quote Post Engagement 91 + โ€ข **Likes:** ${lastQuotePost.likes} 92 + โ€ข **Replies:** ${lastQuotePost.replies} 93 + โ€ข **Reposts:** ${lastQuotePost.reposts} 94 + โ€ข **Quotes:** ${lastQuotePost.quotes} 95 96 ${ 97 originalThread
+7 -6
tasks/checkNotifications.ts
··· 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 - import { 7 - msFrom, 8 - msRandomOffset, 9 - msUntilNextWakeWindow, 10 - } from "../utils/time.ts"; 11 import { bsky } from "../utils/bsky.ts"; 12 import { processNotification } from "../utils/processNotification.ts"; 13 ··· 36 (delay / 1000 / 60 / 60).toFixed(2) 37 } hours from nowโ€ฆ`, 38 ); 39 releaseTaskThread(); 40 return; 41 } ··· 78 // marks all notifications that were processed as seen 79 // based on time from when retrieved instead of finished 80 await bsky.updateSeenNotifications(startedProcessingTime); 81 - 82 // increases counter for notification processing session 83 agentContext.processingCount++; 84 } else {
··· 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 + import { msFrom, msUntilNextWakeWindow } from "../utils/time.ts"; 7 import { bsky } from "../utils/bsky.ts"; 8 import { processNotification } from "../utils/processNotification.ts"; 9 ··· 32 (delay / 1000 / 60 / 60).toFixed(2) 33 } hours from nowโ€ฆ`, 34 ); 35 + agentContext.notifDelayCurrent = agentContext.notifDelayMinimum; 36 releaseTaskThread(); 37 return; 38 } ··· 75 // marks all notifications that were processed as seen 76 // based on time from when retrieved instead of finished 77 await bsky.updateSeenNotifications(startedProcessingTime); 78 + console.log( 79 + `๐Ÿ”น done processing ${unreadNotifications.length} notification${ 80 + unreadNotifications.length > 1 ? "s" : "" 81 + }โ€ฆ`, 82 + ); 83 // increases counter for notification processing session 84 agentContext.processingCount++; 85 } else {
+2 -1
tasks/logTasks.ts
··· 86 } 87 88 const message = actions.join(", "); 89 console.log( 90 - `๐Ÿ”น ${message}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 91 ); 92 } 93
··· 86 } 87 88 const message = actions.join(", "); 89 + 90 console.log( 91 + `๐Ÿ”น ${message}. total notifications: ${agentContext.notifCount}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 92 ); 93 } 94
+2 -1
tasks/sendSleepMessage.ts
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 import { 7 getNow, ··· 35 36 const now = getNow(); 37 38 - if (now.hour >= agentContext.sleepTime) { 39 console.log(`๐Ÿ”น attempting to wind down ${agentContext.agentBskyName}`); 40 } else { 41 const delay = msUntilDailyWindow(
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 + isAgentAsleep, 6 } from "../utils/agentContext.ts"; 7 import { 8 getNow, ··· 36 37 const now = getNow(); 38 39 + if (isAgentAsleep(now.hour)) { 40 console.log(`๐Ÿ”น attempting to wind down ${agentContext.agentBskyName}`); 41 } else { 42 const delay = msUntilDailyWindow(
+2 -1
tasks/sendWakeMessage.ts
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 import { getNow, msFrom, msRandomOffset } from "../utils/time.ts"; 7 import { messageAgent } from "../utils/messageAgent.ts"; ··· 31 32 const now = getNow(); 33 34 - if (now.hour >= agentContext.wakeTime && now.hour < agentContext.sleepTime) { 35 console.log(`๐Ÿ”น attempting to wake up ${agentContext.agentBskyName}`); 36 } else { 37 const delay = msUntilDailyWindow(
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 + isAgentAwake, 6 } from "../utils/agentContext.ts"; 7 import { getNow, msFrom, msRandomOffset } from "../utils/time.ts"; 8 import { messageAgent } from "../utils/messageAgent.ts"; ··· 32 33 const now = getNow(); 34 35 + if (isAgentAwake(now.hour)) { 36 console.log(`๐Ÿ”น attempting to wake up ${agentContext.agentBskyName}`); 37 } else { 38 const delay = msUntilDailyWindow(
+111 -8
tools/bluesky/create_bluesky_post.py
··· 68 69 70 def _check_is_self(agent_did: str, target_did: str) -> bool: 71 - """Check 1: Self-Post Check (Free).""" 72 return agent_did == target_did 73 74 ··· 126 raise 127 128 129 def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool: 130 - """Check 3 & 4: Thread Participation and Mention Check (Expensive).""" 131 try: 132 # Fetch the thread 133 # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. ··· 193 Raises Exception with specific message if consent denied or verification fails. 194 """ 195 try: 196 - # Check 1: Self-Post 197 if _check_is_self(agent_did, target_did): 198 return True 199 200 - # Check 2: Mention Check (Free/Cheap) 201 # If the post we are replying to mentions us, we can reply. 202 if parent_post_record: 203 # Check facets for mention ··· 206 for feature in facet.features: 207 if hasattr(feature, 'did') and feature.did == agent_did: 208 return True 209 - 210 # Fallback: Check text for handle 211 if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text: 212 return True 213 214 - # Check 3: Follow Check 215 # Rule: Target must follow agent. 216 - # Rule 3B: If root author is different from target, Root must ALSO follow agent. 217 218 target_follows = _check_follows(client, agent_did, target_did) 219 ··· 229 ) 230 return True 231 232 - # Check 4: Thread Participation 233 # This requires fetching the thread (Expensive) 234 if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): 235 return True
··· 68 69 70 def _check_is_self(agent_did: str, target_did: str) -> bool: 71 + """Check 2: Self-Post Check (Free).""" 72 return agent_did == target_did 73 74 ··· 126 raise 127 128 129 + def _check_already_replied(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> None: 130 + """ 131 + Check 1: Duplicate Reply Prevention (Cheap - 1 API call). 132 + 133 + Prevents agents from replying multiple times to the same message. 134 + This check runs FIRST to block duplicates in ALL scenarios, including: 135 + - Replies to the agent's own posts 136 + - Replies to posts that mention the agent 137 + - Any other reply scenario 138 + 139 + Only checks direct replies, not deeper thread responses. 140 + 141 + When duplicates are found, provides detailed information about existing 142 + replies including URIs and content to help agents continue the conversation 143 + appropriately. 144 + 145 + Args: 146 + client: Authenticated Bluesky client 147 + agent_did: The agent's DID 148 + agent_handle: The agent's handle (username) 149 + reply_to_uri: URI of the post being replied to 150 + 151 + Raises: 152 + Exception: If agent has already replied directly to this message, 153 + with details about the existing reply(ies) 154 + """ 155 + try: 156 + # Fetch post with only direct replies (depth=1) 157 + response = client.app.bsky.feed.get_post_thread({ 158 + 'uri': reply_to_uri, 159 + 'depth': 1, # Only direct replies 160 + 'parentHeight': 0 # Don't fetch parents (not needed) 161 + }) 162 + 163 + # Validate response structure 164 + if not hasattr(response, 'thread'): 165 + return # Can't verify, proceed 166 + 167 + thread = response.thread 168 + if not hasattr(thread, 'replies') or not thread.replies: 169 + return # No replies yet, proceed 170 + 171 + # Collect all replies by this agent 172 + agent_replies = [] 173 + for reply in thread.replies: 174 + # Validate reply structure 175 + if not hasattr(reply, 'post'): 176 + continue 177 + if not hasattr(reply.post, 'author'): 178 + continue 179 + 180 + # Found agent's reply 181 + if reply.post.author.did == agent_did: 182 + agent_replies.append(reply) 183 + 184 + # If no duplicates found, proceed 185 + if not agent_replies: 186 + return 187 + 188 + # Get the most recent reply (last in list) 189 + # Note: Agents may have multiple replies if this issue happened before 190 + most_recent = agent_replies[-1] 191 + reply_post = most_recent.post 192 + reply_text = reply_post.record.text if hasattr(reply_post.record, 'text') else "[text unavailable]" 193 + reply_uri = reply_post.uri 194 + 195 + # Extract rkey from URI for web URL 196 + # URI format: at://did:plc:xyz/app.bsky.feed.post/rkey 197 + rkey = reply_uri.split('/')[-1] 198 + reply_url = f"https://bsky.app/profile/{agent_handle}/post/{rkey}" 199 + 200 + # Handle multiple replies case 201 + count_msg = "" 202 + if len(agent_replies) > 1: 203 + count_msg = f"\n\nNote: You have {len(agent_replies)} direct replies to this message. The most recent one is shown above." 204 + 205 + # Construct detailed error message 206 + error_msg = ( 207 + f"Message not sent: You have already replied directly to this message.{count_msg}\n\n" 208 + f"Your previous reply:\n\"{reply_text}\"\n\n" 209 + f"Reply URI: {reply_uri}\n" 210 + f"Web link: {reply_url}\n\n" 211 + f"Suggestions:\n" 212 + f"1. If you want to add more to your existing reply, use the URI above to continue that thread.\n" 213 + f"2. Make sure you're not repeating yourself - check what you already said before adding more.\n" 214 + f"3. Consider replying to one of the responses to your reply instead.\n" 215 + f"4. If you have something new to say, start a new top-level message with additional context." 216 + ) 217 + 218 + raise Exception(error_msg) 219 + 220 + except Exception as e: 221 + # If it's our duplicate reply exception, raise it 222 + if "already replied" in str(e): 223 + raise e 224 + # For other errors, re-raise to be caught by main error handler 225 + raise 226 + 227 + 228 def _check_thread_participation(client, agent_did: str, agent_handle: str, reply_to_uri: str) -> bool: 229 + """Check 5: Thread Participation and Mention Check (Expensive).""" 230 try: 231 # Fetch the thread 232 # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. ··· 292 Raises Exception with specific message if consent denied or verification fails. 293 """ 294 try: 295 + # Check 1: Duplicate Reply Prevention 296 + # This check must run BEFORE any early returns to prevent duplicates in all scenarios 297 + _check_already_replied(client, agent_did, agent_handle, reply_to_uri) 298 + 299 + # Check 2: Self-Post 300 if _check_is_self(agent_did, target_did): 301 return True 302 303 + # Check 3: Mention Check (Free/Cheap) 304 # If the post we are replying to mentions us, we can reply. 305 if parent_post_record: 306 # Check facets for mention ··· 309 for feature in facet.features: 310 if hasattr(feature, 'did') and feature.did == agent_did: 311 return True 312 + 313 # Fallback: Check text for handle 314 if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text: 315 return True 316 317 + # Check 4: Follow Check 318 # Rule: Target must follow agent. 319 + # Rule 4B: If root author is different from target, Root must ALSO follow agent. 320 321 target_follows = _check_follows(client, agent_did, target_did) 322 ··· 332 ) 333 return True 334 335 + # Check 5: Thread Participation 336 # This requires fetching the thread (Expensive) 337 if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): 338 return True
+175
utils/agentContext.test.ts
···
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { isAgentAwake, isAgentAsleep } from "./sleepWakeHelpers.ts"; 3 + 4 + // Normal Schedule Tests (wake=8, sleep=22) 5 + // Agent should be awake from 8am to 10pm 6 + 7 + Deno.test("Normal schedule - should be asleep before wake time (7am)", () => { 8 + assertEquals(isAgentAwake(7, 8, 22), false); 9 + assertEquals(isAgentAsleep(7, 8, 22), true); 10 + }); 11 + 12 + Deno.test("Normal schedule - should be awake at wake time (8am)", () => { 13 + assertEquals(isAgentAwake(8, 8, 22), true); 14 + assertEquals(isAgentAsleep(8, 8, 22), false); 15 + }); 16 + 17 + Deno.test("Normal schedule - should be awake during day (12pm)", () => { 18 + assertEquals(isAgentAwake(12, 8, 22), true); 19 + assertEquals(isAgentAsleep(12, 8, 22), false); 20 + }); 21 + 22 + Deno.test("Normal schedule - should be awake before sleep time (9pm)", () => { 23 + assertEquals(isAgentAwake(21, 8, 22), true); 24 + assertEquals(isAgentAsleep(21, 8, 22), false); 25 + }); 26 + 27 + Deno.test("Normal schedule - should be asleep at sleep time (10pm)", () => { 28 + assertEquals(isAgentAwake(22, 8, 22), false); 29 + assertEquals(isAgentAsleep(22, 8, 22), true); 30 + }); 31 + 32 + Deno.test("Normal schedule - should be asleep late night (11pm)", () => { 33 + assertEquals(isAgentAwake(23, 8, 22), false); 34 + assertEquals(isAgentAsleep(23, 8, 22), true); 35 + }); 36 + 37 + Deno.test("Normal schedule - should be asleep at midnight", () => { 38 + assertEquals(isAgentAwake(0, 8, 22), false); 39 + assertEquals(isAgentAsleep(0, 8, 22), true); 40 + }); 41 + 42 + // Cross-Midnight Schedule Tests (wake=9, sleep=2) 43 + // Agent should be awake from 9am to 2am (next day) 44 + 45 + Deno.test("Cross-midnight schedule - should be awake at midnight", () => { 46 + assertEquals(isAgentAwake(0, 9, 2), true); 47 + assertEquals(isAgentAsleep(0, 9, 2), false); 48 + }); 49 + 50 + Deno.test("Cross-midnight schedule - should be awake late night (1am)", () => { 51 + assertEquals(isAgentAwake(1, 9, 2), true); 52 + assertEquals(isAgentAsleep(1, 9, 2), false); 53 + }); 54 + 55 + Deno.test("Cross-midnight schedule - should be asleep at sleep time (2am)", () => { 56 + assertEquals(isAgentAwake(2, 9, 2), false); 57 + assertEquals(isAgentAsleep(2, 9, 2), true); 58 + }); 59 + 60 + Deno.test("Cross-midnight schedule - should be asleep early morning (3am)", () => { 61 + assertEquals(isAgentAwake(3, 9, 2), false); 62 + assertEquals(isAgentAsleep(3, 9, 2), true); 63 + }); 64 + 65 + Deno.test("Cross-midnight schedule - should be asleep before wake (8am)", () => { 66 + assertEquals(isAgentAwake(8, 9, 2), false); 67 + assertEquals(isAgentAsleep(8, 9, 2), true); 68 + }); 69 + 70 + Deno.test("Cross-midnight schedule - should be awake at wake time (9am)", () => { 71 + assertEquals(isAgentAwake(9, 9, 2), true); 72 + assertEquals(isAgentAsleep(9, 9, 2), false); 73 + }); 74 + 75 + Deno.test("Cross-midnight schedule - should be awake during day (12pm)", () => { 76 + assertEquals(isAgentAwake(12, 9, 2), true); 77 + assertEquals(isAgentAsleep(12, 9, 2), false); 78 + }); 79 + 80 + Deno.test("Cross-midnight schedule - should be awake late night (11pm)", () => { 81 + assertEquals(isAgentAwake(23, 9, 2), true); 82 + assertEquals(isAgentAsleep(23, 9, 2), false); 83 + }); 84 + 85 + // Edge Case Tests 86 + 87 + Deno.test("Edge case - equal wake/sleep times (midnight) should be asleep", () => { 88 + // When wake == sleep, the agent should be asleep at all hours 89 + assertEquals(isAgentAwake(0, 0, 0), false); 90 + assertEquals(isAgentAsleep(0, 0, 0), true); 91 + assertEquals(isAgentAwake(12, 0, 0), false); 92 + assertEquals(isAgentAsleep(12, 0, 0), true); 93 + }); 94 + 95 + Deno.test("Edge case - nearly 24 hours awake (wake=1, sleep=0)", () => { 96 + // Asleep only from midnight to 1am 97 + assertEquals(isAgentAwake(0, 1, 0), false); 98 + assertEquals(isAgentAsleep(0, 1, 0), true); 99 + assertEquals(isAgentAwake(1, 1, 0), true); 100 + assertEquals(isAgentAsleep(1, 1, 0), false); 101 + assertEquals(isAgentAwake(23, 1, 0), true); 102 + assertEquals(isAgentAsleep(23, 1, 0), false); 103 + }); 104 + 105 + Deno.test("Edge case - nearly 24 hours asleep (wake=0, sleep=23)", () => { 106 + // Awake only from midnight to 11pm 107 + assertEquals(isAgentAwake(0, 0, 23), true); 108 + assertEquals(isAgentAsleep(0, 0, 23), false); 109 + assertEquals(isAgentAwake(22, 0, 23), true); 110 + assertEquals(isAgentAsleep(22, 0, 23), false); 111 + assertEquals(isAgentAwake(23, 0, 23), false); 112 + assertEquals(isAgentAsleep(23, 0, 23), true); 113 + }); 114 + 115 + Deno.test("Edge case - adjacent hours (wake=10, sleep=11)", () => { 116 + // Awake only from 10am to 11am 117 + assertEquals(isAgentAwake(9, 10, 11), false); 118 + assertEquals(isAgentAsleep(9, 10, 11), true); 119 + assertEquals(isAgentAwake(10, 10, 11), true); 120 + assertEquals(isAgentAsleep(10, 10, 11), false); 121 + assertEquals(isAgentAwake(11, 10, 11), false); 122 + assertEquals(isAgentAsleep(11, 10, 11), true); 123 + assertEquals(isAgentAwake(12, 10, 11), false); 124 + assertEquals(isAgentAsleep(12, 10, 11), true); 125 + }); 126 + 127 + // Inverse Relationship Tests 128 + 129 + Deno.test("Inverse relationship - awake and asleep are always opposite (normal schedule)", () => { 130 + const wakeTime = 8; 131 + const sleepTime = 22; 132 + 133 + // Test all 24 hours 134 + for (let hour = 0; hour < 24; hour++) { 135 + const awake = isAgentAwake(hour, wakeTime, sleepTime); 136 + const asleep = isAgentAsleep(hour, wakeTime, sleepTime); 137 + assertEquals( 138 + awake, 139 + !asleep, 140 + `Hour ${hour}: awake=${awake}, asleep=${asleep} should be opposite`, 141 + ); 142 + } 143 + }); 144 + 145 + Deno.test("Inverse relationship - awake and asleep are always opposite (cross-midnight)", () => { 146 + const wakeTime = 9; 147 + const sleepTime = 2; 148 + 149 + // Test all 24 hours 150 + for (let hour = 0; hour < 24; hour++) { 151 + const awake = isAgentAwake(hour, wakeTime, sleepTime); 152 + const asleep = isAgentAsleep(hour, wakeTime, sleepTime); 153 + assertEquals( 154 + awake, 155 + !asleep, 156 + `Hour ${hour}: awake=${awake}, asleep=${asleep} should be opposite`, 157 + ); 158 + } 159 + }); 160 + 161 + Deno.test("Inverse relationship - awake and asleep are always opposite (edge case)", () => { 162 + const wakeTime = 23; 163 + const sleepTime = 1; 164 + 165 + // Test all 24 hours 166 + for (let hour = 0; hour < 24; hour++) { 167 + const awake = isAgentAwake(hour, wakeTime, sleepTime); 168 + const asleep = isAgentAsleep(hour, wakeTime, sleepTime); 169 + assertEquals( 170 + awake, 171 + !asleep, 172 + `Hour ${hour}: awake=${awake}, asleep=${asleep} should be opposite`, 173 + ); 174 + } 175 + });
+24
utils/agentContext.ts
··· 14 } from "./const.ts"; 15 import { msFrom } from "./time.ts"; 16 import { bsky } from "./bsky.ts"; 17 18 export const getLettaApiKey = (): string => { 19 const value = Deno.env.get("LETTA_API_KEY")?.trim(); ··· 242 return (value / 100) + 1; 243 }; 244 245 const getReflectionDelayMinimum = (): number => { 246 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 247 ··· 552 mentionCount: 0, 553 replyCount: 0, 554 quoteCount: 0, 555 // required with manual variables 556 lettaProjectIdentifier: getLettaProjectID(), 557 agentBskyHandle: getAgentBskyHandle(), ··· 575 timeZone: getTimeZone(), 576 responsiblePartyType: getResponsiblePartyType(), 577 preserveAgentMemory: getPreserveMemoryBlocks(), 578 reflectionEnabled: setReflectionEnabled(), 579 proactiveEnabled: setProactiveEnabled(), 580 sleepEnabled: setSleepEnabled(), ··· 626 agentContext.replyCount = 0; 627 agentContext.quoteCount = 0; 628 };
··· 14 } from "./const.ts"; 15 import { msFrom } from "./time.ts"; 16 import { bsky } from "./bsky.ts"; 17 + import { 18 + isAgentAsleep as checkIsAsleep, 19 + isAgentAwake as checkIsAwake, 20 + } from "./sleepWakeHelpers.ts"; 21 22 export const getLettaApiKey = (): string => { 23 const value = Deno.env.get("LETTA_API_KEY")?.trim(); ··· 246 return (value / 100) + 1; 247 }; 248 249 + const 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 + 259 const getReflectionDelayMinimum = (): number => { 260 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 261 ··· 566 mentionCount: 0, 567 replyCount: 0, 568 quoteCount: 0, 569 + notifCount: 0, 570 // required with manual variables 571 lettaProjectIdentifier: getLettaProjectID(), 572 agentBskyHandle: getAgentBskyHandle(), ··· 590 timeZone: getTimeZone(), 591 responsiblePartyType: getResponsiblePartyType(), 592 preserveAgentMemory: getPreserveMemoryBlocks(), 593 + maxThreadPosts: getMaxThreadPosts(), 594 reflectionEnabled: setReflectionEnabled(), 595 proactiveEnabled: setProactiveEnabled(), 596 sleepEnabled: setSleepEnabled(), ··· 642 agentContext.replyCount = 0; 643 agentContext.quoteCount = 0; 644 }; 645 + 646 + export const isAgentAwake = (hour: number): boolean => { 647 + return checkIsAwake(hour, agentContext.wakeTime, agentContext.sleepTime); 648 + }; 649 + 650 + export const isAgentAsleep = (hour: number): boolean => { 651 + return checkIsAsleep(hour, agentContext.wakeTime, agentContext.sleepTime); 652 + };
+54 -3
utils/getCleanThread.ts
··· 1 import { bsky } from "./bsky.ts"; 2 3 type threadPost = { 4 authorHandle: string; ··· 13 quotes: number; 14 }; 15 16 - export const getCleanThread = async (uri: string): Promise<threadPost[]> => { 17 const res = await bsky.getPostThread({ uri: uri }); 18 const { thread } = res.data; 19 20 - const postsThread: threadPost[] = []; 21 22 // Type guard to check if thread is a ThreadViewPost 23 if (thread && "post" in thread) { ··· 37 // Now traverse the parent chain 38 if ("parent" in thread) { 39 let current = thread.parent; 40 41 - while (current && "post" in current) { 42 postsThread.push({ 43 authorHandle: `@${current.post.author.handle}`, 44 message: (current.post.record as { text: string }).text, ··· 51 likes: current.post.likeCount ?? 0, 52 quotes: current.post.quoteCount ?? 0, 53 }); 54 current = "parent" in current ? current.parent : undefined; 55 } 56 postsThread.reverse(); 57 } 58 } 59 60 return postsThread; 61 };
··· 1 import { bsky } from "./bsky.ts"; 2 + import { agentContext } from "./agentContext.ts"; 3 4 type threadPost = { 5 authorHandle: string; ··· 14 quotes: number; 15 }; 16 17 + type threadTruncationIndicator = { 18 + message: string; 19 + }; 20 + 21 + type threadItem = threadPost | threadTruncationIndicator; 22 + 23 + export const getCleanThread = async (uri: string): Promise<threadItem[]> => { 24 const res = await bsky.getPostThread({ uri: uri }); 25 const { thread } = res.data; 26 27 + const postsThread: threadItem[] = []; 28 29 // Type guard to check if thread is a ThreadViewPost 30 if (thread && "post" in thread) { ··· 44 // Now traverse the parent chain 45 if ("parent" in thread) { 46 let current = thread.parent; 47 + let postCount = 1; // Start at 1 for the main post 48 + let wasTruncated = false; 49 50 + // Collect up to configured limit of posts 51 + while (current && "post" in current && postCount < agentContext.maxThreadPosts) { 52 postsThread.push({ 53 authorHandle: `@${current.post.author.handle}`, 54 message: (current.post.record as { text: string }).text, ··· 61 likes: current.post.likeCount ?? 0, 62 quotes: current.post.quoteCount ?? 0, 63 }); 64 + postCount++; 65 current = "parent" in current ? current.parent : undefined; 66 } 67 + 68 + // Check if we stopped early (thread is longer than configured limit) 69 + if (current && "post" in current) { 70 + wasTruncated = true; 71 + 72 + // Continue traversing to find the root post without collecting 73 + while (current && "parent" in current) { 74 + current = current.parent; 75 + } 76 + 77 + // Extract the root post 78 + if (current && "post" in current) { 79 + postsThread.push({ 80 + authorHandle: `@${current.post.author.handle}`, 81 + message: (current.post.record as { text: string }).text, 82 + uri: current.post.uri, 83 + authorDID: current.post.author.did, 84 + postedDateTime: (current.post.record as { createdAt: string }).createdAt, 85 + bookmarks: current.post.bookmarkCount ?? 0, 86 + replies: current.post.replyCount ?? 0, 87 + reposts: current.post.repostCount ?? 0, 88 + likes: current.post.likeCount ?? 0, 89 + quotes: current.post.quoteCount ?? 0, 90 + }); 91 + } 92 + } 93 + 94 + // Reverse and insert truncation indicator if needed 95 postsThread.reverse(); 96 + 97 + if (wasTruncated) { 98 + const limit = agentContext.maxThreadPosts; 99 + const truncationIndicator: threadTruncationIndicator = { 100 + message: `This thread exceeded ${limit} posts. This includes the ${limit} most recent posts and the root post that started the thread.`, 101 + }; 102 + postsThread.splice(1, 0, truncationIndicator); 103 + } 104 } 105 } 106 107 return postsThread; 108 }; 109 + 110 + export const isThreadPost = (item: threadItem): item is threadPost => { 111 + return "authorHandle" in item; 112 + };
+137 -15
utils/messageAgent.ts
··· 1 import Letta from "@letta-ai/letta-client"; 2 import { agentContext } from "./agentContext.ts"; 3 // Helper function to format tool arguments as inline key-value pairs 4 - const formatArgsInline = (args: unknown): string => { 5 try { 6 const parsed = typeof args === "string" ? JSON.parse(args) : args; 7 if (typeof parsed !== "object" || parsed === null) { ··· 9 } 10 return Object.entries(parsed) 11 .map(([key, value]) => { 12 - const valueStr = typeof value === "object" 13 ? JSON.stringify(value) 14 : String(value); 15 return `${key}=${valueStr}`; 16 }) 17 .join(", "); ··· 28 return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`; 29 }; 30 31 export const client = new Letta({ 32 apiKey: Deno.env.get("LETTA_API_KEY"), 33 // @ts-ignore: Letta SDK type definition might be slightly off or expecting different casing ··· 48 stream_tokens: true, 49 }); 50 51 for await (const response of reachAgent) { 52 if (response.message_type === "reasoning_message") { 53 // console.log(`๐Ÿ’ญ reasoningโ€ฆ`); 54 } else if (response.message_type === "assistant_message") { 55 console.log(`๐Ÿ’ฌ ${agentContext.agentBskyName}: ${response.content}`); 56 } else if (response.message_type === "tool_call_message") { 57 - if ( 58 - Array.isArray(response.tool_calls) && response.tool_calls.length > 0 59 - ) { 60 - const toolCall = response.tool_calls[0]; 61 - const formattedArgs = formatArgsInline(toolCall.arguments); 62 - console.log( 63 - `๐Ÿ—œ๏ธ tool called: ${toolCall.name} with args: ${formattedArgs}`, 64 - ); 65 } 66 } else if (response.message_type === "tool_return_message") { 67 - const toolReturn = response.tool_returns; 68 - const returnStr = typeof toolReturn === "string" 69 - ? toolReturn 70 - : JSON.stringify(toolReturn); 71 - console.log(`๐Ÿ”ง tool response: ${truncateString(returnStr)}`); 72 } else if (response.message_type === "usage_statistics") { 73 console.log(`๐Ÿ”ข total steps: ${response.step_count}`); 74 } else if (response.message_type === "hidden_reasoning_message") {
··· 1 import Letta from "@letta-ai/letta-client"; 2 import { agentContext } from "./agentContext.ts"; 3 // Helper function to format tool arguments as inline key-value pairs 4 + const formatArgsInline = (args: unknown, maxValueLength = 50): string => { 5 try { 6 const parsed = typeof args === "string" ? JSON.parse(args) : args; 7 if (typeof parsed !== "object" || parsed === null) { ··· 9 } 10 return Object.entries(parsed) 11 .map(([key, value]) => { 12 + let valueStr = typeof value === "object" 13 ? JSON.stringify(value) 14 : String(value); 15 + // Truncate long values at word boundaries 16 + if (valueStr.length > maxValueLength) { 17 + const truncated = valueStr.slice(0, maxValueLength); 18 + const lastSpace = truncated.lastIndexOf(" "); 19 + // If we found a space in the last 30% of the truncated string, use it 20 + valueStr = lastSpace > maxValueLength * 0.7 21 + ? truncated.slice(0, lastSpace) + "..." 22 + : truncated + "..."; 23 + } 24 return `${key}=${valueStr}`; 25 }) 26 .join(", "); ··· 37 return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`; 38 }; 39 40 + // Helper function to extract tool return value from wrapper structure 41 + const extractToolReturn = (toolReturns: unknown): string => { 42 + try { 43 + // If it's already a string, return it 44 + if (typeof toolReturns === "string") { 45 + return toolReturns; 46 + } 47 + 48 + // If it's an array, extract the tool_return from first element 49 + if (Array.isArray(toolReturns) && toolReturns.length > 0) { 50 + const firstReturn = toolReturns[0]; 51 + if ( 52 + typeof firstReturn === "object" && 53 + firstReturn !== null && 54 + "tool_return" in firstReturn 55 + ) { 56 + const toolReturn = firstReturn.tool_return; 57 + // If tool_return is already a string, return it 58 + if (typeof toolReturn === "string") { 59 + return toolReturn; 60 + } 61 + // Otherwise stringify it 62 + return JSON.stringify(toolReturn); 63 + } 64 + } 65 + 66 + // Fallback: return stringified version of the whole thing 67 + return JSON.stringify(toolReturns); 68 + } catch { 69 + return String(toolReturns); 70 + } 71 + }; 72 + 73 + // Helper function to select important params for logging 74 + const selectImportantParams = (args: unknown): unknown => { 75 + try { 76 + const parsed = typeof args === "string" ? JSON.parse(args) : args; 77 + if (typeof parsed !== "object" || parsed === null) { 78 + return parsed; 79 + } 80 + 81 + const entries = Object.entries(parsed); 82 + 83 + // Filter out URIs/DIDs and very long values 84 + const filtered = entries.filter(([key, value]) => { 85 + const str = String(value); 86 + // Skip AT URIs and DIDs 87 + if (str.includes("at://") || str.includes("did:plc:")) return false; 88 + // Skip very long values 89 + if (str.length > 60) return false; 90 + return true; 91 + }); 92 + 93 + // Take first 3, or first entry if none pass filters 94 + const selected = filtered.length > 0 95 + ? filtered.slice(0, 3) 96 + : entries.slice(0, 1); 97 + 98 + return Object.fromEntries(selected); 99 + } catch { 100 + return args; 101 + } 102 + }; 103 + 104 + // Helper function to format tool response for logging 105 + const formatToolResponse = (returnValue: string): string => { 106 + try { 107 + // Try to parse as JSON - handle both JSON and Python dict syntax 108 + let parsed; 109 + try { 110 + // First try standard JSON 111 + parsed = JSON.parse(returnValue); 112 + } catch { 113 + // Try to parse Python-style dict (with single quotes and None/True/False) 114 + const pythonToJson = returnValue 115 + .replace(/'/g, '"') 116 + .replace(/\bNone\b/g, "null") 117 + .replace(/\bTrue\b/g, "true") 118 + .replace(/\bFalse\b/g, "false"); 119 + parsed = JSON.parse(pythonToJson); 120 + } 121 + 122 + // Handle arrays - show count and sample first item 123 + if (Array.isArray(parsed)) { 124 + const count = parsed.length; 125 + if (count === 0) return "[]"; 126 + 127 + const firstItem = parsed[0]; 128 + if (typeof firstItem === "object" && firstItem !== null) { 129 + // Format first item's key fields (use 30 for samples to keep concise) 130 + const sample = formatArgsInline(firstItem, 30); 131 + return `[${count} items] ${sample}`; 132 + } 133 + return `[${count} items]`; 134 + } 135 + 136 + // If parsed successfully and it's an object, format as key=value pairs 137 + if (typeof parsed === "object" && parsed !== null) { 138 + return `(${formatArgsInline(parsed, 50)})`; 139 + } 140 + 141 + // If it's a primitive, return the original string 142 + return returnValue; 143 + } catch { 144 + // If parsing fails, return as-is (it's a simple string) 145 + return returnValue; 146 + } 147 + }; 148 + 149 export const client = new Letta({ 150 apiKey: Deno.env.get("LETTA_API_KEY"), 151 // @ts-ignore: Letta SDK type definition might be slightly off or expecting different casing ··· 166 stream_tokens: true, 167 }); 168 169 + let lastToolName = ""; 170 + 171 for await (const response of reachAgent) { 172 if (response.message_type === "reasoning_message") { 173 // console.log(`๐Ÿ’ญ reasoningโ€ฆ`); 174 } else if (response.message_type === "assistant_message") { 175 console.log(`๐Ÿ’ฌ ${agentContext.agentBskyName}: ${response.content}`); 176 } else if (response.message_type === "tool_call_message") { 177 + // Use tool_call (singular) or tool_calls (both are objects, not arrays) 178 + const toolCall = response.tool_call || response.tool_calls; 179 + if (toolCall && toolCall.name) { 180 + lastToolName = toolCall.name; 181 + const importantParams = selectImportantParams(toolCall.arguments); 182 + const formattedArgs = formatArgsInline(importantParams); 183 + console.log(`๐Ÿ”ง tool called: ${toolCall.name} (${formattedArgs})`); 184 } 185 } else if (response.message_type === "tool_return_message") { 186 + const extractedReturn = extractToolReturn(response.tool_returns); 187 + const formattedResponse = formatToolResponse(extractedReturn); 188 + 189 + // Determine separator based on format 190 + const separator = formattedResponse.startsWith("(") ? " " : ": "; 191 + const logMessage = `โ†ฉ๏ธ tool response: ${lastToolName}${separator}${formattedResponse}`; 192 + 193 + console.log(truncateString(logMessage, 300)); 194 } else if (response.message_type === "usage_statistics") { 195 console.log(`๐Ÿ”ข total steps: ${response.step_count}`); 196 } else if (response.message_type === "hidden_reasoning_message") {
+3 -2
utils/processNotification.ts
··· 37 } as const; 38 39 export const processNotification = async (notification: Notification) => { 40 - const agentProject = Deno.env.get("LETTA_PROJECT_NAME"); 41 const kind = notification.reason; 42 const author = `@${notification.author.handle}`; 43 const handler = notificationHandlers[kind]; ··· 54 const prompt = await handler.promptFn(notification); 55 await messageAgent(prompt); 56 console.log( 57 - `๐Ÿ”น sent ${kind} notification from ${author} to ${agentProject}. moving onโ€ฆ`, 58 ); 59 } catch (error) { 60 console.log( ··· 63 ); 64 } finally { 65 (agentContext as any)[handler.counter]++; 66 } 67 };
··· 37 } as const; 38 39 export const processNotification = async (notification: Notification) => { 40 + const agentName = agentContext.agentBskyName; 41 const kind = notification.reason; 42 const author = `@${notification.author.handle}`; 43 const handler = notificationHandlers[kind]; ··· 54 const prompt = await handler.promptFn(notification); 55 await messageAgent(prompt); 56 console.log( 57 + `๐Ÿ”น sent ${kind} notification from ${author} to ${agentName}. moving onโ€ฆ`, 58 ); 59 } catch (error) { 60 console.log( ··· 63 ); 64 } finally { 65 (agentContext as any)[handler.counter]++; 66 + agentContext.notifCount++; 67 } 68 };
+40
utils/sleepWakeHelpers.ts
···
··· 1 + /** 2 + * Helper functions for determining agent sleep/wake status 3 + * These functions handle both normal schedules and cross-midnight schedules 4 + */ 5 + 6 + /** 7 + * Core logic: Determines if agent should be asleep at given hour 8 + */ 9 + export const isAgentAsleep = ( 10 + hour: number, 11 + wakeTime: number, 12 + sleepTime: number, 13 + ): boolean => { 14 + // Edge case: if wake == sleep, agent has zero awake time (always asleep) 15 + if (wakeTime === sleepTime) { 16 + return true; 17 + } 18 + 19 + // If sleepTime > wakeTime: normal same-day schedule (e.g., wake=8, sleep=22) 20 + // Agent is asleep from sleepTime until midnight, OR from midnight until wakeTime 21 + if (sleepTime > wakeTime) { 22 + return hour >= sleepTime || hour < wakeTime; 23 + } 24 + 25 + // If sleepTime < wakeTime: schedule crosses midnight (e.g., wake=9, sleep=2) 26 + // Agent is asleep from sleepTime until wakeTime (same day) 27 + return hour >= sleepTime && hour < wakeTime; 28 + }; 29 + 30 + /** 31 + * Semantic wrapper: Determines if agent should be awake at given hour 32 + * Simply the inverse of isAgentAsleep 33 + */ 34 + export const isAgentAwake = ( 35 + hour: number, 36 + wakeTime: number, 37 + sleepTime: number, 38 + ): boolean => { 39 + return !isAgentAsleep(hour, wakeTime, sleepTime); 40 + };
+9 -2
utils/types.ts
··· 8 } from "./const.ts"; 9 import type { 10 AutomationLevel, 11 - ResponsiblePartyType, 12 AutonomyDeclaration, 13 ResponsibleParty, 14 } from "@voyager/autonomy-lexicon"; 15 16 export type Notification = AppBskyNotificationListNotifications.Notification; 17 18 // Re-export types from autonomy-lexicon package 19 - export type { AutomationLevel, ResponsiblePartyType, AutonomyDeclaration, ResponsibleParty }; 20 21 export type notifType = typeof validNotifTypes[number]; 22 ··· 38 mentionCount: number; 39 replyCount: number; 40 quoteCount: number; 41 // required manual variables 42 lettaProjectIdentifier: string; 43 agentBskyHandle: string; ··· 61 timeZone: string; 62 responsiblePartyType: string; // person / organization 63 preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks 64 // set automatically 65 agentBskyDID: string; 66 reflectionEnabled: boolean;
··· 8 } from "./const.ts"; 9 import type { 10 AutomationLevel, 11 AutonomyDeclaration, 12 ResponsibleParty, 13 + ResponsiblePartyType, 14 } from "@voyager/autonomy-lexicon"; 15 16 export type Notification = AppBskyNotificationListNotifications.Notification; 17 18 // Re-export types from autonomy-lexicon package 19 + export type { 20 + AutomationLevel, 21 + AutonomyDeclaration, 22 + ResponsibleParty, 23 + ResponsiblePartyType, 24 + }; 25 26 export type notifType = typeof validNotifTypes[number]; 27 ··· 43 mentionCount: number; 44 replyCount: number; 45 quoteCount: number; 46 + notifCount: number; 47 // required manual variables 48 lettaProjectIdentifier: string; 49 agentBskyHandle: string; ··· 67 timeZone: string; 68 responsiblePartyType: string; // person / organization 69 preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks 70 + maxThreadPosts: number; // maximum number of posts to include in thread context 71 // set automatically 72 agentBskyDID: string; 73 reflectionEnabled: boolean;