a tool to help your Letta AI agents navigate bluesky

Enhance thread context retrieval with configurable max posts

+11 -7
prompts/quotePrompt.ts
··· 1 1 import { Notification } from "../utils/types.ts"; 2 2 import { doesUserFollowTarget } from "../utils/doesUserFollow.ts"; 3 3 import { agentContext } from "../utils/agentContext.ts"; 4 - import { getCleanThread } from "../utils/getCleanThread.ts"; 4 + import { getCleanThread, isThreadPost } from "../utils/getCleanThread.ts"; 5 5 6 6 export const quotePrompt = async (notification: Notification) => { 7 7 const isUserFollower = await doesUserFollowTarget( ··· 60 60 quotes: undefined, 61 61 }]; 62 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 + 63 67 return ` 64 68 # NOTIFICATION: Someone quoted your post 65 69 ··· 75 79 76 80 ## Your Original Post 77 81 \`\`\` 78 - ${originalThread[originalThread.length - 1].message} 82 + ${lastOriginalPost.message} 79 83 \`\`\` 80 84 81 85 ## The Quote Post from @${notification.author.handle} 82 86 \`\`\` 83 - ${quotePostThread[quotePostThread.length - 1].message} 87 + ${lastQuotePost.message} 84 88 \`\`\` 85 89 86 90 ## 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 + • **Likes:** ${lastQuotePost.likes} 92 + • **Replies:** ${lastQuotePost.replies} 93 + • **Reposts:** ${lastQuotePost.reposts} 94 + • **Quotes:** ${lastQuotePost.quotes} 91 95 92 96 ${ 93 97 originalThread
+1 -5
tasks/checkNotifications.ts
··· 3 3 claimTaskThread, 4 4 releaseTaskThread, 5 5 } from "../utils/agentContext.ts"; 6 - import { 7 - msFrom, 8 - msRandomOffset, 9 - msUntilNextWakeWindow, 10 - } from "../utils/time.ts"; 6 + import { msFrom, msUntilNextWakeWindow } from "../utils/time.ts"; 11 7 import { bsky } from "../utils/bsky.ts"; 12 8 import { processNotification } from "../utils/processNotification.ts"; 13 9
+11
utils/agentContext.ts
··· 242 242 return (value / 100) + 1; 243 243 }; 244 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 + 245 255 const getReflectionDelayMinimum = (): number => { 246 256 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 247 257 ··· 575 585 timeZone: getTimeZone(), 576 586 responsiblePartyType: getResponsiblePartyType(), 577 587 preserveAgentMemory: getPreserveMemoryBlocks(), 588 + maxThreadPosts: getMaxThreadPosts(), 578 589 reflectionEnabled: setReflectionEnabled(), 579 590 proactiveEnabled: setProactiveEnabled(), 580 591 sleepEnabled: setSleepEnabled(),
+54 -3
utils/getCleanThread.ts
··· 1 1 import { bsky } from "./bsky.ts"; 2 + import { agentContext } from "./agentContext.ts"; 2 3 3 4 type threadPost = { 4 5 authorHandle: string; ··· 13 14 quotes: number; 14 15 }; 15 16 16 - export const getCleanThread = async (uri: string): Promise<threadPost[]> => { 17 + type threadTruncationIndicator = { 18 + message: string; 19 + }; 20 + 21 + type threadItem = threadPost | threadTruncationIndicator; 22 + 23 + export const getCleanThread = async (uri: string): Promise<threadItem[]> => { 17 24 const res = await bsky.getPostThread({ uri: uri }); 18 25 const { thread } = res.data; 19 26 20 - const postsThread: threadPost[] = []; 27 + const postsThread: threadItem[] = []; 21 28 22 29 // Type guard to check if thread is a ThreadViewPost 23 30 if (thread && "post" in thread) { ··· 37 44 // Now traverse the parent chain 38 45 if ("parent" in thread) { 39 46 let current = thread.parent; 47 + let postCount = 1; // Start at 1 for the main post 48 + let wasTruncated = false; 40 49 41 - while (current && "post" in current) { 50 + // Collect up to configured limit of posts 51 + while (current && "post" in current && postCount < agentContext.maxThreadPosts) { 42 52 postsThread.push({ 43 53 authorHandle: `@${current.post.author.handle}`, 44 54 message: (current.post.record as { text: string }).text, ··· 51 61 likes: current.post.likeCount ?? 0, 52 62 quotes: current.post.quoteCount ?? 0, 53 63 }); 64 + postCount++; 54 65 current = "parent" in current ? current.parent : undefined; 55 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 56 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 + } 57 104 } 58 105 } 59 106 60 107 return postsThread; 61 108 }; 109 + 110 + export const isThreadPost = (item: threadItem): item is threadPost => { 111 + return "authorHandle" in item; 112 + };
+59 -8
utils/messageAgent.ts
··· 1 1 import Letta from "@letta-ai/letta-client"; 2 2 import { agentContext } from "./agentContext.ts"; 3 3 // Helper function to format tool arguments as inline key-value pairs 4 - const formatArgsInline = (args: unknown, maxValueLength = 30): string => { 4 + const formatArgsInline = (args: unknown, maxValueLength = 50): string => { 5 5 try { 6 6 const parsed = typeof args === "string" ? JSON.parse(args) : args; 7 7 if (typeof parsed !== "object" || parsed === null) { ··· 12 12 let valueStr = typeof value === "object" 13 13 ? JSON.stringify(value) 14 14 : String(value); 15 - // Truncate long values 15 + // Truncate long values at word boundaries 16 16 if (valueStr.length > maxValueLength) { 17 - valueStr = valueStr.slice(0, 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 + "..."; 18 23 } 19 24 return `${key}=${valueStr}`; 20 25 }) ··· 65 70 } 66 71 }; 67 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 + 68 104 // Helper function to format tool response for logging 69 105 const formatToolResponse = (returnValue: string): string => { 70 106 try { ··· 83 119 parsed = JSON.parse(pythonToJson); 84 120 } 85 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 + 86 136 // 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)})`; 137 + if (typeof parsed === "object" && parsed !== null) { 138 + return `(${formatArgsInline(parsed, 50)})`; 89 139 } 90 140 91 - // If it's an array or primitive, return the original string 141 + // If it's a primitive, return the original string 92 142 return returnValue; 93 143 } catch { 94 144 // If parsing fails, return as-is (it's a simple string) ··· 128 178 const toolCall = response.tool_call || response.tool_calls; 129 179 if (toolCall && toolCall.name) { 130 180 lastToolName = toolCall.name; 131 - const formattedArgs = formatArgsInline(toolCall.arguments); 181 + const importantParams = selectImportantParams(toolCall.arguments); 182 + const formattedArgs = formatArgsInline(importantParams); 132 183 console.log(`🔧 tool called: ${toolCall.name} (${formattedArgs})`); 133 184 } 134 185 } else if (response.message_type === "tool_return_message") { ··· 139 190 const separator = formattedResponse.startsWith("(") ? " " : ": "; 140 191 const logMessage = `↩️ tool response: ${lastToolName}${separator}${formattedResponse}`; 141 192 142 - console.log(truncateString(logMessage, 80)); 193 + console.log(truncateString(logMessage, 300)); 143 194 } else if (response.message_type === "usage_statistics") { 144 195 console.log(`🔢 total steps: ${response.step_count}`); 145 196 } else if (response.message_type === "hidden_reasoning_message") {
+1
utils/types.ts
··· 61 61 timeZone: string; 62 62 responsiblePartyType: string; // person / organization 63 63 preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks 64 + maxThreadPosts: number; // maximum number of posts to include in thread context 64 65 // set automatically 65 66 agentBskyDID: string; 66 67 reflectionEnabled: boolean;