a tool to help your Letta AI agents navigate bluesky

Compare changes

Choose any two refs to compare.

+1
.env.example
··· 31 31 # RESPONSIBLE_PARTY_BSKY="DID:... or example.bsky.app, no @symbol" 32 32 # EXTERNAL_SERVICES="Letta, Railway, Google Gemini 2.5-pro" 33 33 # PRESERVE_MEMORY_BLOCKS=true 34 + # MAX_THREAD_POSTS=25
+1
README.md
··· 70 70 - **`RESPONSIBLE_PARTY_BSKY`**: the DID or bluesky handle of the responsible party 71 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 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 3 "config": "deno run --allow-read --allow-write setup.ts", 4 4 "mount": "deno run --allow-net --allow-env --allow-read --env mount.ts", 5 5 "watch": "deno run --allow-net --allow-env --env --watch main.ts", 6 - "start": "deno run --allow-net --allow-env --env main.ts" 6 + "start": "deno run --allow-net --allow-env --env main.ts", 7 + "test": "deno test" 7 8 }, 8 9 "imports": { 9 10 "@std/assert": "jsr:@std/assert@1",
+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
+7 -6
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 ··· 36 32 (delay / 1000 / 60 / 60).toFixed(2) 37 33 } hours from nowโ€ฆ`, 38 34 ); 35 + agentContext.notifDelayCurrent = agentContext.notifDelayMinimum; 39 36 releaseTaskThread(); 40 37 return; 41 38 } ··· 78 75 // marks all notifications that were processed as seen 79 76 // based on time from when retrieved instead of finished 80 77 await bsky.updateSeenNotifications(startedProcessingTime); 81 - 78 + console.log( 79 + `๐Ÿ”น done processing ${unreadNotifications.length} notification${ 80 + unreadNotifications.length > 1 ? "s" : "" 81 + }โ€ฆ`, 82 + ); 82 83 // increases counter for notification processing session 83 84 agentContext.processingCount++; 84 85 } else {
+2 -1
tasks/logTasks.ts
··· 86 86 } 87 87 88 88 const message = actions.join(", "); 89 + 89 90 console.log( 90 - `๐Ÿ”น ${message}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 91 + `๐Ÿ”น ${message}. total notifications: ${agentContext.notifCount}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 91 92 ); 92 93 } 93 94
+2 -1
tasks/sendSleepMessage.ts
··· 2 2 agentContext, 3 3 claimTaskThread, 4 4 releaseTaskThread, 5 + isAgentAsleep, 5 6 } from "../utils/agentContext.ts"; 6 7 import { 7 8 getNow, ··· 35 36 36 37 const now = getNow(); 37 38 38 - if (now.hour >= agentContext.sleepTime) { 39 + if (isAgentAsleep(now.hour)) { 39 40 console.log(`๐Ÿ”น attempting to wind down ${agentContext.agentBskyName}`); 40 41 } else { 41 42 const delay = msUntilDailyWindow(
+2 -1
tasks/sendWakeMessage.ts
··· 2 2 agentContext, 3 3 claimTaskThread, 4 4 releaseTaskThread, 5 + isAgentAwake, 5 6 } from "../utils/agentContext.ts"; 6 7 import { getNow, msFrom, msRandomOffset } from "../utils/time.ts"; 7 8 import { messageAgent } from "../utils/messageAgent.ts"; ··· 31 32 32 33 const now = getNow(); 33 34 34 - if (now.hour >= agentContext.wakeTime && now.hour < agentContext.sleepTime) { 35 + if (isAgentAwake(now.hour)) { 35 36 console.log(`๐Ÿ”น attempting to wake up ${agentContext.agentBskyName}`); 36 37 } else { 37 38 const delay = msUntilDailyWindow(
+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 14 } from "./const.ts"; 15 15 import { msFrom } from "./time.ts"; 16 16 import { bsky } from "./bsky.ts"; 17 + import { 18 + isAgentAsleep as checkIsAsleep, 19 + isAgentAwake as checkIsAwake, 20 + } from "./sleepWakeHelpers.ts"; 17 21 18 22 export const getLettaApiKey = (): string => { 19 23 const value = Deno.env.get("LETTA_API_KEY")?.trim(); ··· 242 246 return (value / 100) + 1; 243 247 }; 244 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 + 245 259 const getReflectionDelayMinimum = (): number => { 246 260 const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 247 261 ··· 552 566 mentionCount: 0, 553 567 replyCount: 0, 554 568 quoteCount: 0, 569 + notifCount: 0, 555 570 // required with manual variables 556 571 lettaProjectIdentifier: getLettaProjectID(), 557 572 agentBskyHandle: getAgentBskyHandle(), ··· 575 590 timeZone: getTimeZone(), 576 591 responsiblePartyType: getResponsiblePartyType(), 577 592 preserveAgentMemory: getPreserveMemoryBlocks(), 593 + maxThreadPosts: getMaxThreadPosts(), 578 594 reflectionEnabled: setReflectionEnabled(), 579 595 proactiveEnabled: setProactiveEnabled(), 580 596 sleepEnabled: setSleepEnabled(), ··· 626 642 agentContext.replyCount = 0; 627 643 agentContext.quoteCount = 0; 628 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 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 + };
+137 -15
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): 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) { ··· 9 9 } 10 10 return Object.entries(parsed) 11 11 .map(([key, value]) => { 12 - const valueStr = typeof value === "object" 12 + let valueStr = typeof value === "object" 13 13 ? JSON.stringify(value) 14 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 + } 15 24 return `${key}=${valueStr}`; 16 25 }) 17 26 .join(", "); ··· 28 37 return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`; 29 38 }; 30 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 + 31 149 export const client = new Letta({ 32 150 apiKey: Deno.env.get("LETTA_API_KEY"), 33 151 // @ts-ignore: Letta SDK type definition might be slightly off or expecting different casing ··· 48 166 stream_tokens: true, 49 167 }); 50 168 169 + let lastToolName = ""; 170 + 51 171 for await (const response of reachAgent) { 52 172 if (response.message_type === "reasoning_message") { 53 173 // console.log(`๐Ÿ’ญ reasoningโ€ฆ`); 54 174 } else if (response.message_type === "assistant_message") { 55 175 console.log(`๐Ÿ’ฌ ${agentContext.agentBskyName}: ${response.content}`); 56 176 } 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 - ); 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})`); 65 184 } 66 185 } 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)}`); 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)); 72 194 } else if (response.message_type === "usage_statistics") { 73 195 console.log(`๐Ÿ”ข total steps: ${response.step_count}`); 74 196 } else if (response.message_type === "hidden_reasoning_message") {
+1
utils/processNotification.ts
··· 63 63 ); 64 64 } finally { 65 65 (agentContext as any)[handler.counter]++; 66 + agentContext.notifCount++; 66 67 } 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 8 } from "./const.ts"; 9 9 import type { 10 10 AutomationLevel, 11 - ResponsiblePartyType, 12 11 AutonomyDeclaration, 13 12 ResponsibleParty, 13 + ResponsiblePartyType, 14 14 } from "@voyager/autonomy-lexicon"; 15 15 16 16 export type Notification = AppBskyNotificationListNotifications.Notification; 17 17 18 18 // Re-export types from autonomy-lexicon package 19 - export type { AutomationLevel, ResponsiblePartyType, AutonomyDeclaration, ResponsibleParty }; 19 + export type { 20 + AutomationLevel, 21 + AutonomyDeclaration, 22 + ResponsibleParty, 23 + ResponsiblePartyType, 24 + }; 20 25 21 26 export type notifType = typeof validNotifTypes[number]; 22 27 ··· 38 43 mentionCount: number; 39 44 replyCount: number; 40 45 quoteCount: number; 46 + notifCount: number; 41 47 // required manual variables 42 48 lettaProjectIdentifier: string; 43 49 agentBskyHandle: string; ··· 61 67 timeZone: string; 62 68 responsiblePartyType: string; // person / organization 63 69 preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks 70 + maxThreadPosts: number; // maximum number of posts to include in thread context 64 71 // set automatically 65 72 agentBskyDID: string; 66 73 reflectionEnabled: boolean;