a tool to help your Letta AI agents navigate bluesky

Merge pull request #6 from taurean/logging-improvements

Add task logging and update agent context interactions

authored by taurean.bryant.land and committed by GitHub 9802adcd 75423daf

+6
main.ts
··· 1 1 import { logStats } from "./tasks/logStats.ts"; 2 + import { logTasks } from "./tasks/logTasks.ts"; 2 3 import { msFrom, msRandomOffset, msUntilDailyWindow } from "./utils/time.ts"; 3 4 import { sendSleepMessage } from "./tasks/sendSleepMessage.ts"; 4 5 import { sendWakeMessage } from "./tasks/sendWakeMessage.ts"; ··· 8 9 import { checkNotifications } from "./tasks/checkNotifications.ts"; 9 10 10 11 setTimeout(logStats, msRandomOffset(msFrom.minutes(1), msFrom.minutes(5))); 12 + 13 + setTimeout( 14 + logTasks, 15 + msRandomOffset(msFrom.minutes(30), msFrom.minutes(60)), 16 + ); 11 17 setTimeout( 12 18 sendSleepMessage, 13 19 msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(20)),
+7 -5
tasks/checkBluesky.ts
··· 16 16 const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 17 17 18 18 console.log( 19 - `${agentContext.agentBskyName} is busy, will try checking bluesky again in ${ 19 + `🔹 ${agentContext.agentBskyName} is busy, will try checking bluesky again in ${ 20 20 newDelay * 60 * 1000 21 21 } minutes…`, 22 22 ); ··· 27 27 28 28 if (!agentContext.proactiveEnabled) { 29 29 console.log( 30 - `proactively checking bluesky is disabled. Provide a minimum and/or maximum delay in \`.env\` to enable this task…`, 30 + `🔹 proactively checking bluesky is disabled. Provide a minimum and/or maximum delay in \`.env\` to enable this task…`, 31 31 ); 32 32 releaseTaskThread(); 33 33 return; ··· 43 43 if (delay !== 0) { 44 44 setTimeout(checkBluesky, delay); 45 45 console.log( 46 - `${agentContext.agentBskyName} is current asleep. scheduling next bluesky session for ${ 46 + `🔹 ${agentContext.agentBskyName} is current asleep. scheduling next bluesky session for ${ 47 47 (delay / 1000 / 60 / 60).toFixed(2) 48 48 } hours from now…`, 49 49 ); ··· 53 53 54 54 try { 55 55 const prompt = checkBlueskyPrompt; 56 - console.log("starting a proactive bluesky session…"); 56 + console.log("🔹 starting a proactive bluesky session…"); 57 57 await messageAgent(prompt); 58 58 } catch (error) { 59 59 console.error("error in checkBluesky:", error); 60 60 } finally { 61 - console.log("finished proactive bluesky session. waiting for new tasks…"); 61 + console.log( 62 + "🔹 finished proactive bluesky session. waiting for new tasks…", 63 + ); 62 64 agentContext.proactiveCount++; 63 65 // schedules next proactive bluesky session 64 66 setTimeout(
+7 -5
tasks/checkNotifications.ts
··· 15 15 if (!claimTaskThread()) { 16 16 const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 17 17 console.log( 18 - `${agentContext.agentBskyName} is busy, checking for notifications again in ${ 18 + `🔹 ${agentContext.agentBskyName} is busy, checking for notifications again in ${ 19 19 (newDelay * 1000) * 60 20 20 } minutes…`, 21 21 ); ··· 32 32 if (delay !== 0) { 33 33 setTimeout(checkNotifications, delay); 34 34 console.log( 35 - `${agentContext.agentBskyName} is current asleep. scheduling next notification check for ${ 35 + `🔹 ${agentContext.agentBskyName} is current asleep. scheduling next notification check for ${ 36 36 (delay / 1000 / 60 / 60).toFixed(2) 37 37 } hours from now…`, 38 38 ); ··· 54 54 55 55 if (unreadNotifications.length > 0) { 56 56 console.log( 57 - `found ${unreadNotifications.length} notification(s), processing…`, 57 + `🔹 found ${unreadNotifications.length} notification(s), processing…`, 58 58 ); 59 59 60 60 // resets delay for future notification checks since ··· 69 69 let notificationCounter = 1; 70 70 for (const notification of unreadNotifications) { 71 71 console.log( 72 - `processing notification #${notificationCounter} [${notification.reason} from @${notification.author.handle}]`, 72 + `🔹 processing notification #${notificationCounter} of #${unreadNotifications.length} [${notification.reason} from @${notification.author.handle}]`, 73 73 ); 74 74 await processNotification(notification); 75 75 notificationCounter++; ··· 90 90 )); 91 91 92 92 console.log( 93 - "no notifications…", 93 + "🔹 no notifications…", 94 94 `checking again in ${ 95 95 (agentContext.notifDelayCurrent / 1000).toFixed(2) 96 96 } seconds`, ··· 101 101 // since something went wrong, lets check for notifications again sooner 102 102 agentContext.notifDelayCurrent = agentContext.notifDelayMinimum; 103 103 } finally { 104 + // increment check count 105 + agentContext.checkCount++; 104 106 // actually schedules next time to check for notifications 105 107 setTimeout(checkNotifications, agentContext.notifDelayCurrent); 106 108 // ends work
+74 -33
tasks/logStats.ts
··· 11 11 12 12 export const logStats = () => { 13 13 if (!claimTaskThread()) { 14 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 14 + const newDelay = msFrom.minutes(2); 15 15 console.log( 16 - `${agentContext.agentBskyName} is busy, attempting to log counts again in ${ 16 + `Stat log attempt failed, ${agentContext.agentBskyName} is busy. Next attempt in ${ 17 17 (newDelay / 1000) / 60 18 18 } minutes…`, 19 19 ); 20 20 // session is busy, logging stats in 5~10 minutes. 21 21 setTimeout(logStats, newDelay); 22 - return; 23 - } 24 - 25 - if (!agentContext.reflectionEnabled) { 26 - console.log( 27 - `${agentContext.agentBskyName} reflection is disabled, skipping logStats…`, 28 - ); 29 - releaseTaskThread(); 30 22 return; 31 23 } 32 24 ··· 46 38 return; 47 39 } 48 40 49 - console.log( 50 - ` 51 - === 52 - # current session interaction counts since last reflection: 53 - ${agentContext.likeCount} ${ 54 - agentContext.likeCount === 1 ? "like" : "likes" 55 - }, ${agentContext.repostCount} ${ 56 - agentContext.repostCount === 1 ? "repost" : "reposts" 57 - }, ${agentContext.followCount} ${ 58 - agentContext.followCount === 1 ? "new follower" : "new followers" 59 - }, ${agentContext.mentionCount} ${ 60 - agentContext.mentionCount === 1 ? "mention" : "mentions" 61 - }, ${agentContext.replyCount} ${ 62 - agentContext.replyCount === 1 ? "reply" : "replies" 63 - }, and ${agentContext.quoteCount} ${ 64 - agentContext.quoteCount === 1 ? "quote" : "quotes" 65 - }. 41 + // Check if there are any notifications 42 + const totalNotifications = agentContext.mentionCount + 43 + agentContext.likeCount + 44 + agentContext.repostCount + 45 + agentContext.quoteCount + 46 + agentContext.replyCount + 47 + agentContext.followCount; 66 48 67 - ${agentContext.agentBskyName} has reflected ${agentContext.reflectionCount} time${ 68 - agentContext.reflectionCount > 0 ? "s" : "" 69 - } since last server start. interaction counts reset after each reflection session. 70 - === 71 - `, 49 + const nextCheckDelay = msRandomOffset( 50 + msFrom.minutes(5), 51 + msFrom.minutes(15), 72 52 ); 73 - setTimeout(logStats, msRandomOffset(msFrom.minutes(5), msFrom.minutes(15))); 53 + const nextCheckMinutes = ((nextCheckDelay / 1000) / 60).toFixed(1); 54 + 55 + if (totalNotifications <= 0) { 56 + console.log( 57 + `no engagement stats yet... next check in ${nextCheckMinutes} minutes…`, 58 + ); 59 + } else { 60 + const counts = []; 61 + 62 + if (agentContext.mentionCount > 0) { 63 + counts.push( 64 + `${agentContext.mentionCount} ${ 65 + agentContext.mentionCount === 1 ? "mention" : "mentions" 66 + }`, 67 + ); 68 + } 69 + if (agentContext.likeCount > 0) { 70 + counts.push( 71 + `${agentContext.likeCount} ${ 72 + agentContext.likeCount === 1 ? "like" : "likes" 73 + }`, 74 + ); 75 + } 76 + if (agentContext.repostCount > 0) { 77 + counts.push( 78 + `${agentContext.repostCount} ${ 79 + agentContext.repostCount === 1 ? "repost" : "reposts" 80 + }`, 81 + ); 82 + } 83 + if (agentContext.quoteCount > 0) { 84 + counts.push( 85 + `${agentContext.quoteCount} ${ 86 + agentContext.quoteCount === 1 ? "quote" : "quotes" 87 + }`, 88 + ); 89 + } 90 + if (agentContext.replyCount > 0) { 91 + counts.push( 92 + `${agentContext.replyCount} ${ 93 + agentContext.replyCount === 1 ? "reply" : "replies" 94 + }`, 95 + ); 96 + } 97 + if (agentContext.followCount > 0) { 98 + counts.push( 99 + `${agentContext.followCount} new ${ 100 + agentContext.followCount === 1 ? "follower" : "followers" 101 + }`, 102 + ); 103 + } 104 + 105 + const message = counts.join(", "); 106 + const suffix = agentContext.reflectionEnabled 107 + ? " since last reflection" 108 + : ""; 109 + console.log( 110 + ` 111 + stats: ${message}${suffix}. next check in ${nextCheckMinutes} minutes…`, 112 + ); 113 + } 114 + setTimeout(logStats, nextCheckDelay); 74 115 releaseTaskThread(); 75 116 };
+99
tasks/logTasks.ts
··· 1 + import { 2 + agentContext, 3 + claimTaskThread, 4 + releaseTaskThread, 5 + } from "../utils/agentContext.ts"; 6 + import { 7 + formatUptime, 8 + msFrom, 9 + msRandomOffset, 10 + msUntilNextWakeWindow, 11 + } from "../utils/time.ts"; 12 + 13 + // Capture server start time when module is loaded 14 + const serverStartTime = Date.now(); 15 + 16 + export const logTasks = () => { 17 + if (!claimTaskThread()) { 18 + const newDelay = msFrom.minutes(2); 19 + console.log( 20 + `🔹 Task log attempt failed, ${agentContext.agentBskyName} is busy. Next attempt in ${ 21 + (newDelay / 1000) / 60 22 + } minutes…`, 23 + ); 24 + setTimeout(logTasks, newDelay); 25 + return; 26 + } 27 + 28 + const delay = msUntilNextWakeWindow( 29 + msFrom.minutes(30), 30 + msFrom.hours(1), 31 + ); 32 + 33 + if (delay !== 0) { 34 + setTimeout(logTasks, delay); 35 + console.log( 36 + `🔹 ${agentContext.agentBskyName} is current asleep. scheduling next task log for ${ 37 + (delay / 1000 / 60 / 60).toFixed(2) 38 + } hours from now…`, 39 + ); 40 + releaseTaskThread(); 41 + return; 42 + } 43 + 44 + // Check if there's any activity 45 + const totalActivity = agentContext.reflectionCount + 46 + agentContext.checkCount + 47 + agentContext.processingCount + 48 + agentContext.proactiveCount; 49 + 50 + const uptime = Date.now() - serverStartTime; 51 + const uptimeFormatted = formatUptime(uptime); 52 + 53 + const nextCheckDelay = msRandomOffset( 54 + msFrom.minutes(30), 55 + msFrom.hours(1), 56 + ); 57 + const nextCheckMinutes = ((nextCheckDelay / 1000) / 60).toFixed(1); 58 + 59 + if (totalActivity <= 0) { 60 + console.log( 61 + `🔹 no activity yet... uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 62 + ); 63 + } else { 64 + const actions = []; 65 + 66 + if (agentContext.reflectionCount > 0) { 67 + const times = agentContext.reflectionCount === 1 68 + ? "once" 69 + : `${agentContext.reflectionCount} times`; 70 + actions.push(`reflected ${times}`); 71 + } 72 + if (agentContext.checkCount > 0) { 73 + const times = agentContext.checkCount === 1 74 + ? "once" 75 + : `${agentContext.checkCount} times`; 76 + actions.push(`checked notifications ${times}`); 77 + } 78 + if (agentContext.processingCount > 0) { 79 + const times = agentContext.processingCount === 1 80 + ? "once" 81 + : `${agentContext.processingCount} times`; 82 + actions.push(`found and processed notifications ${times}`); 83 + } 84 + if (agentContext.proactiveCount > 0) { 85 + const times = agentContext.proactiveCount === 1 86 + ? "once" 87 + : `${agentContext.proactiveCount} times`; 88 + actions.push(`proactively used bluesky ${times}`); 89 + } 90 + 91 + const message = actions.join(", "); 92 + console.log( 93 + `🔹 ${message}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 94 + ); 95 + } 96 + 97 + setTimeout(logTasks, nextCheckDelay); 98 + releaseTaskThread(); 99 + };
+5 -5
tasks/runReflection.ts
··· 17 17 const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 18 18 19 19 console.log( 20 - `${agentContext.agentBskyName} is busy, will try reflecting again in ${ 20 + `🔹 ${agentContext.agentBskyName} is busy, will try reflecting again in ${ 21 21 (newDelay / 1000) / 60 22 22 } minutes…`, 23 23 ); ··· 28 28 29 29 if (!agentContext.reflectionEnabled) { 30 30 console.log( 31 - `Reflection is currently disabled. Provide a minimum and/or maximum delay duration in \`.env\` to enable reflections…`, 31 + `🔹 Reflection is currently disabled. Provide a minimum and/or maximum delay duration in \`.env\` to enable reflections…`, 32 32 ); 33 33 releaseTaskThread(); 34 34 return; ··· 44 44 if (delay !== 0) { 45 45 setTimeout(runReflection, delay); 46 46 console.log( 47 - `${agentContext.agentBskyName} is current asleep. scheduling next reflection for ${ 47 + `🔹 ${agentContext.agentBskyName} is current asleep. scheduling next reflection for ${ 48 48 (delay / 1000 / 60 / 60).toFixed(2) 49 49 } hours from now…`, 50 50 ); ··· 53 53 } 54 54 55 55 try { 56 - console.log("starting reflection prompt…"); 56 + console.log("🔹 starting reflection prompt…"); 57 57 await messageAgent(reflectionPrompt); 58 58 } catch (error) { 59 59 console.error("Error in reflectionCheck:", error); ··· 61 61 resetAgentContextCounts(); 62 62 agentContext.reflectionCount++; 63 63 console.log( 64 - "finished reflection prompt. returning to checking for notifications…", 64 + "🔹 finished reflection prompt. returning to checking for notifications…", 65 65 ); 66 66 // schedules the next reflection, random between the min and max delay 67 67 setTimeout(
+5 -5
tasks/sendSleepMessage.ts
··· 16 16 if (!claimTaskThread()) { 17 17 const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 18 18 console.log( 19 - `${agentContext.agentBskyName} is busy, sending sleep message again in ${ 19 + `🔹 ${agentContext.agentBskyName} is busy, sending sleep message again in ${ 20 20 (newDelay / 1000) / 60 21 21 } minutes…`, 22 22 ); ··· 27 27 28 28 if (!agentContext.sleepEnabled) { 29 29 console.log( 30 - `${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of sleep messaging…`, 30 + `🔹 ${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of sleep messaging…`, 31 31 ); 32 32 releaseTaskThread(); 33 33 return; ··· 36 36 const now = getNow(); 37 37 38 38 if (now.hour >= agentContext.sleepTime) { 39 - console.log(`attempting to wind down ${agentContext.agentBskyName}`); 39 + console.log(`🔹 attempting to wind down ${agentContext.agentBskyName}`); 40 40 } else { 41 41 const delay = msUntilDailyWindow( 42 42 agentContext.sleepTime, ··· 45 45 ); 46 46 setTimeout(sendSleepMessage, delay); 47 47 console.log( 48 - `It's too early to wind down ${agentContext.agentBskyName}. scheduling wind down for ${ 48 + `🔹 It's too early to wind down ${agentContext.agentBskyName}. scheduling wind down for ${ 49 49 (delay / 1000 / 60 / 60).toFixed(2) 50 50 } hours from now…`, 51 51 ); ··· 58 58 } catch (error) { 59 59 console.error("error in sendSleepMessage: ", error); 60 60 } finally { 61 - console.log("wind down attempt processed, scheduling next wind down…"); 61 + console.log("🔹 wind down attempt processed, scheduling next wind down…"); 62 62 setTimeout( 63 63 sendSleepMessage, 64 64 msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(20)),
+6 -6
tasks/sendWakeMessage.ts
··· 12 12 if (!claimTaskThread()) { 13 13 const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 14 14 console.log( 15 - `${agentContext.agentBskyName} is busy, sending wake message again in ${ 15 + `🔹 ${agentContext.agentBskyName} is busy, sending wake message again in ${ 16 16 (newDelay / 1000) / 60 17 17 } minutes…`, 18 18 ); ··· 23 23 24 24 if (!agentContext.sleepEnabled) { 25 25 console.log( 26 - `${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of wake messaging…`, 26 + `🔹 ${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of wake messaging…`, 27 27 ); 28 28 releaseTaskThread(); 29 29 return; ··· 32 32 const now = getNow(); 33 33 34 34 if (now.hour >= agentContext.wakeTime && now.hour < agentContext.sleepTime) { 35 - console.log(`attempting to wake up ${agentContext.agentBskyName}`); 35 + console.log(`🔹 attempting to wake up ${agentContext.agentBskyName}`); 36 36 } else { 37 37 const delay = msUntilDailyWindow( 38 38 agentContext.wakeTime, ··· 41 41 ); 42 42 setTimeout(sendWakeMessage, delay); 43 43 console.log( 44 - `${agentContext.agentBskyName} should still be asleep. Scheduling wake message for ${ 44 + `🔹 ${agentContext.agentBskyName} should still be asleep. Scheduling wake message for ${ 45 45 (delay / 1000 / 60 / 60).toFixed(2) 46 46 } hours from now…`, 47 47 ); ··· 54 54 } catch (error) { 55 55 console.error("error in sendWakeMessage: ", error); 56 56 } finally { 57 - console.log("wake attempt processed, scheduling next wake prompt…"); 57 + console.log("🔹 wake attempt processed, scheduling next wake prompt…"); 58 58 setTimeout( 59 59 sendWakeMessage, 60 60 msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(80)), 61 61 ); 62 - console.log("exiting wake process"); 62 + console.log("🔹 exiting wake process"); 63 63 releaseTaskThread(); 64 64 } 65 65 };
+5 -5
utils/agentContext.ts
··· 522 522 for (const service of services) { 523 523 if (service.length > 200) { 524 524 throw Error( 525 - `External service name too long: "${service.substring(0, 50)}..." (max 200 characters)`, 525 + `External service name too long: "${ 526 + service.substring(0, 50) 527 + }..." (max 200 characters)`, 526 528 ); 527 529 } 528 530 } ··· 538 540 }; 539 541 540 542 const populateAgentContext = async (): Promise<agentContextObject> => { 541 - console.log("building new agentContext object…"); 543 + console.log("🔹 building new agentContext object…"); 542 544 const context: agentContextObject = { 543 545 // state 544 546 busy: false, ··· 602 604 context.externalServices = externalServices; 603 605 } 604 606 console.log( 605 - `\`agentContext\` object built for ${context.agentBskyName}, BEGIN TASK…`, 607 + `🔹 \`agentContext\` object built for ${context.agentBskyName}, BEGINING TASKS…`, 606 608 ); 607 609 return context; 608 610 }; ··· 626 628 agentContext.mentionCount = 0; 627 629 agentContext.replyCount = 0; 628 630 agentContext.quoteCount = 0; 629 - agentContext.checkCount = 0; 630 - agentContext.processingCount = 0; 631 631 };
+5 -3
utils/declaration.ts
··· 149 149 rkey: "self", 150 150 }); 151 151 exists = true; 152 - console.log("Existing autonomy declaration found - updating..."); 152 + console.log("🔹 Existing autonomy declaration found - updating..."); 153 153 } catch (error: any) { 154 154 // Handle "record not found" errors (status 400 with error: "RecordNotFound") 155 155 const isNotFound = ··· 159 159 error?.message?.includes("Could not locate record"); 160 160 161 161 if (isNotFound) { 162 - console.log("No existing autonomy declaration found - creating new..."); 162 + console.log( 163 + "🔹 No existing autonomy declaration found - creating new...", 164 + ); 163 165 } else { 164 166 // Re-throw if it's not a "not found" error 165 167 throw error; ··· 175 177 }); 176 178 177 179 console.log( 178 - `Autonomy declaration ${exists ? "updated" : "created"} successfully:`, 180 + `🔹 Autonomy declaration ${exists ? "updated" : "created"} successfully:`, 179 181 result, 180 182 ); 181 183 return result;
+3 -3
utils/messageAgent.ts
··· 21 21 }; 22 22 23 23 // Helper function to truncate long strings to 500 characters 24 - const truncateString = (str: string, maxLength = 500): string => { 24 + const truncateString = (str: string, maxLength = 140): string => { 25 25 if (str.length <= maxLength) { 26 26 return str; 27 27 } ··· 54 54 55 55 for await (const response of reachAgent) { 56 56 if (response.messageType === "reasoning_message") { 57 - console.log(`💭 reasoning…`); 57 + // console.log(`💭 reasoning…`); 58 58 } else if (response.messageType === "assistant_message") { 59 59 console.log(`💬 ${agentContext.agentBskyName}: ${response.content}`); 60 60 } else if (response.messageType === "tool_call_message") { ··· 76 76 } 77 77 } else { 78 78 console.log( 79 - "Letta agent ID was not a set variable, skipping notification processing…", 79 + "🔹 Letta agent ID was not a set variable, skipping notification processing…", 80 80 ); 81 81 } 82 82 };
+4 -4
utils/processNotification.ts
··· 44 44 45 45 if (!handler) { 46 46 console.log( 47 - `kind "${kind}" does not have a system prompt associated with it, moving on…`, 47 + `🔹 kind "${kind}" does not have a system prompt associated with it, moving on…`, 48 48 ); 49 49 console.log("notification response: ", notification); 50 50 return; ··· 54 54 const prompt = await handler.promptFn(notification); 55 55 await messageAgent(prompt); 56 56 console.log( 57 - `sent ${kind} notification from ${author} to ${agentProject}. moving on…`, 57 + `🔹 sent ${kind} notification from ${author} to ${agentProject}. moving on…`, 58 58 ); 59 59 } catch (error) { 60 60 console.log( 61 - `Error processing ${kind} notification from ${author}: `, 61 + `🔹 Error processing ${kind} notification from ${author}: `, 62 62 error, 63 63 ); 64 64 } finally { 65 - (agentContext as any)[handler]++; 65 + (agentContext as any)[handler.counter]++; 66 66 } 67 67 };
+25
utils/time.ts
··· 127 127 export const getNow = () => { 128 128 return Temporal.Now.zonedDateTimeISO(agentContext.timeZone); 129 129 }; 130 + 131 + /** 132 + * Format uptime from milliseconds into a human-readable string 133 + * @param ms - uptime in milliseconds 134 + * @returns Formatted string like "2 days, 3 hours, 15 minutes" or "3 hours, 15 minutes" 135 + */ 136 + export const formatUptime = (ms: number): string => { 137 + const days = Math.floor(ms / (1000 * 60 * 60 * 24)); 138 + const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); 139 + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); 140 + 141 + const parts: string[] = []; 142 + 143 + if (days > 0) { 144 + parts.push(`${days} ${days === 1 ? "day" : "days"}`); 145 + } 146 + if (hours > 0) { 147 + parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`); 148 + } 149 + if (minutes > 0 || parts.length === 0) { 150 + parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`); 151 + } 152 + 153 + return parts.join(", "); 154 + };