a tool to help your Letta AI agents navigate bluesky

Enhance thread context retrieval with configurable max posts

+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
+1 -5
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
··· 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
+11
utils/agentContext.ts
··· 242 return (value / 100) + 1; 243 }; 244 245 const getReflectionDelayMinimum = (): number => { 246 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 247 ··· 575 timeZone: getTimeZone(), 576 responsiblePartyType: getResponsiblePartyType(), 577 preserveAgentMemory: getPreserveMemoryBlocks(), 578 reflectionEnabled: setReflectionEnabled(), 579 proactiveEnabled: setProactiveEnabled(), 580 sleepEnabled: setSleepEnabled(),
··· 242 return (value / 100) + 1; 243 }; 244 245 + const getMaxThreadPosts = (): number => { 246 + const value = Number(Deno.env.get("MAX_THREAD_POSTS")); 247 + 248 + if (isNaN(value) || value < 5 || value > 250) { 249 + return 2; 250 + } 251 + 252 + return Math.round(value); 253 + }; 254 + 255 const getReflectionDelayMinimum = (): number => { 256 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 257 ··· 585 timeZone: getTimeZone(), 586 responsiblePartyType: getResponsiblePartyType(), 587 preserveAgentMemory: getPreserveMemoryBlocks(), 588 + maxThreadPosts: getMaxThreadPosts(), 589 reflectionEnabled: setReflectionEnabled(), 590 proactiveEnabled: setProactiveEnabled(), 591 sleepEnabled: setSleepEnabled(),
+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 + };
+59 -8
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, maxValueLength = 30): string => { 5 try { 6 const parsed = typeof args === "string" ? JSON.parse(args) : args; 7 if (typeof parsed !== "object" || parsed === null) { ··· 12 let valueStr = typeof value === "object" 13 ? JSON.stringify(value) 14 : String(value); 15 - // Truncate long values 16 if (valueStr.length > maxValueLength) { 17 - valueStr = valueStr.slice(0, maxValueLength) + "..."; 18 } 19 return `${key}=${valueStr}`; 20 }) ··· 65 } 66 }; 67 68 // Helper function to format tool response for logging 69 const formatToolResponse = (returnValue: string): string => { 70 try { ··· 83 parsed = JSON.parse(pythonToJson); 84 } 85 86 // If parsed successfully and it's an object, format as key=value pairs 87 - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { 88 - return `(${formatArgsInline(parsed, 30)})`; 89 } 90 91 - // If it's an array or primitive, return the original string 92 return returnValue; 93 } catch { 94 // If parsing fails, return as-is (it's a simple string) ··· 128 const toolCall = response.tool_call || response.tool_calls; 129 if (toolCall && toolCall.name) { 130 lastToolName = toolCall.name; 131 - const formattedArgs = formatArgsInline(toolCall.arguments); 132 console.log(`🔧 tool called: ${toolCall.name} (${formattedArgs})`); 133 } 134 } else if (response.message_type === "tool_return_message") { ··· 139 const separator = formattedResponse.startsWith("(") ? " " : ": "; 140 const logMessage = `↩️ tool response: ${lastToolName}${separator}${formattedResponse}`; 141 142 - console.log(truncateString(logMessage, 80)); 143 } else if (response.message_type === "usage_statistics") { 144 console.log(`🔢 total steps: ${response.step_count}`); 145 } 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) { ··· 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 }) ··· 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 { ··· 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) ··· 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") { ··· 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") {
+1
utils/types.ts
··· 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;
··· 61 timeZone: string; 62 responsiblePartyType: string; // person / organization 63 preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks 64 + maxThreadPosts: number; // maximum number of posts to include in thread context 65 // set automatically 66 agentBskyDID: string; 67 reflectionEnabled: boolean;