a tool to help your Letta AI agents navigate bluesky

Compare changes

Choose any two refs to compare.

+9 -8
.env.example
··· 1 1 LETTA_API_KEY= 2 2 LETTA_AGENT_ID= 3 - LETTA_PROJECT_NAME= 3 + LETTA_PROJECT_ID= 4 4 BSKY_USERNAME= 5 5 BSKY_APP_PASSWORD= 6 6 RESPONSIBLE_PARTY_NAME= ··· 15 15 # BSKY_SERVICE_URL=https://bsky.social 16 16 # BSKY_NOTIFICATION_TYPES="mention, reply" 17 17 # BSKY_SUPPORTED_TOOLS="create_bluesky_post, updated_bluesky_profile" 18 - # NOTIF_DELAY_MINIMUM=2000 19 - # NOTIF_DELAY_MAXIMUM=60000 20 - # NOTIF_DELAY_MULTIPLIER=5 21 - # REFLECTION_DELAY_MINIMUM=1800000 22 - # REFLECTION_DELAY_MAXIMUM=28800000 23 - # PROACTIVE_DELAY_MINIMUM=3600000 24 - # PROACTIVE_DELAY_MAXIMUM=43200000 18 + # NOTIF_DELAY_MINIMUM=10s 19 + # NOTIF_DELAY_MAXIMUM=90m 20 + # NOTIF_DELAY_MULTIPLIER=12 21 + # REFLECTION_DELAY_MINIMUM=3h 22 + # REFLECTION_DELAY_MAXIMUM=14h 23 + # PROACTIVE_DELAY_MINIMUM=3h 24 + # PROACTIVE_DELAY_MAXIMUM=14h 25 25 # WAKE_TIME=9 26 26 # SLEEP_TIME=22 27 27 # TIMEZONE="America/Los_Angeles" ··· 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
+8 -7
README.md
··· 54 54 - **`BSKY_SERVICE_URL`**: use if `bsky.social` is not who handles your PDS 55 55 - **`BSKY_NOTIFICATION_TYPES`**: a comma separated list of notifications you want your agent to get, you must include at least one. (like, repost, follow, mention, reply, quote) 56 56 - **`BSKY_SUPPORTED_TOOLS`**: a comma separated list of tools your agent can optionally have. (create_bluesky_post, like_bluesky_post, quote_bluesky_post, repost_bluesky_post, update_bluesky_connection [follow, mute, block; or inverse], update_bluesky_profile [change its bluesky bio or display name]) 57 - - **`NOTIF_DELAY_MINIMUM`**: the small amount of time, in milliseconds, for when it will schedule the next notification checking session 58 - - **`NOTIF_DELAY_MAXIMUM`**: the largest amount of time, in milliseconds, for when it will schedule the next notification checking session 59 - - **`NOTIF_DELAY_MULTIPLIER`**: a percentage of how much the delay will increase when notifications are not found (max is 500 meaning 500% increases) 60 - - **`REFLECTION_DELAY_MINIMUM`**: the smallest amount of time, in milliseconds, for when it will schedule the next reflection session. 61 - - **`REFLECTION_DELAY_MAXIMUM`**: the largest amount of time, in milliseconds, for when it will schedule the next reflection session. Omitting both values disables reflecting. 62 - - **`PROACTIVE_DELAY_MINIMUM`**: the smallest amount of time, in milliseconds, for when it will schedule the next session for proactively using bluesky. 63 - - **`PROACTIVE_DELAY_MAXIMUM`**: the largest amount of time, in milliseconds, for when it will schedule the next session for proactively using bluesky. Omitting both values disables proactive bluesky sessions. 57 + - **`NOTIF_DELAY_MINIMUM`**: the minimum time before checking notifications again. Supports human-readable formats like "10s" (10 seconds), "5m" (5 minutes), or raw milliseconds. Default: "10s" 58 + - **`NOTIF_DELAY_MAXIMUM`**: the maximum time before checking notifications again when no activity is detected. Supports formats like "90m" (90 minutes), "2h" (2 hours), or raw milliseconds. Default: "90m" 59 + - **`NOTIF_DELAY_MULTIPLIER`**: percentage increase in delay when no notifications are found (1-500). For example, "12" means the delay grows by 12% each check. Default: 12 60 + - **`REFLECTION_DELAY_MINIMUM`**: the minimum time between reflection sessions. Supports formats like "3h" (3 hours) or raw milliseconds. Omitting both reflection values disables reflecting. Default: "3h" 61 + - **`REFLECTION_DELAY_MAXIMUM`**: the maximum time between reflection sessions. Supports formats like "14h" (14 hours) or raw milliseconds. Omitting both reflection values disables reflecting. Default: "14h" 62 + - **`PROACTIVE_DELAY_MINIMUM`**: the minimum time between proactive Bluesky sessions. Supports formats like "3h" (3 hours) or raw milliseconds. Omitting both proactive values disables proactive sessions. Default: "3h" 63 + - **`PROACTIVE_DELAY_MAXIMUM`**: the maximum time between proactive Bluesky sessions. Supports formats like "14h" (14 hours) or raw milliseconds. Omitting both proactive values disables proactive sessions. Default: "14h" 64 64 - **`WAKE_TIME`**: (0-23) the hour where your agent will generally "wake up". 65 65 - **`SLEEP_TIME`**: (0-23) the hour where your agent will generally "go to sleep". Omitting both values disables sleeping. 66 66 - **`TIMEZONE`**: the [timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for your agent's location, eg "America/Los_Angeles". ··· 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
+3 -2
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 12 "@js-temporal/polyfill": "npm:@js-temporal/polyfill", 12 13 "@atproto/api": "npm:@atproto/api", 13 14 "@atproto/lexicon": "npm:@atproto/lexicon", 14 - "@letta-ai/letta-client": "npm:@letta-ai/letta-client", 15 + "@letta-ai/letta-client": "npm:@letta-ai/letta-client@1.0.0", 15 16 "@voyager/autonomy-lexicon": "jsr:@voyager/autonomy-lexicon@^0.1.1" 16 17 } 17 18 }
+4 -253
deno.lock
··· 8 8 "npm:@atproto/api@*": "0.17.2", 9 9 "npm:@atproto/lexicon@*": "0.5.1", 10 10 "npm:@js-temporal/polyfill@*": "0.5.1", 11 - "npm:@letta-ai/letta-client@*": "0.0.68664" 11 + "npm:@letta-ai/letta-client@1.0.0": "1.0.0" 12 12 }, 13 13 "jsr": { 14 14 "@std/assert@1.0.15": { ··· 76 76 "jsbi" 77 77 ] 78 78 }, 79 - "@letta-ai/letta-client@0.0.68664": { 80 - "integrity": "sha512-/0g8dV3IIX0WfnOUDY1EEgnhj/747m73zhTmbLhldEMjCk/RzKyjvUeZbHiukiGoCf/u1nxRgcRUn66MKMYB2A==", 81 - "dependencies": [ 82 - "form-data", 83 - "form-data-encoder", 84 - "formdata-node", 85 - "node-fetch", 86 - "qs", 87 - "readable-stream", 88 - "url-join" 89 - ] 90 - }, 91 - "abort-controller@3.0.0": { 92 - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", 93 - "dependencies": [ 94 - "event-target-shim" 95 - ] 96 - }, 97 - "asynckit@0.4.0": { 98 - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 79 + "@letta-ai/letta-client@1.0.0": { 80 + "integrity": "sha512-owR/gcLVFlv89CtJsb1m4xvYJcApooyEvrzqWLgf6bnfJuog65YXPUdwZIsA2YBk9a3u+l3wvYsDuk0uj5PCtA==" 99 81 }, 100 82 "await-lock@2.2.2": { 101 83 "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 102 84 }, 103 - "base64-js@1.5.1": { 104 - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" 105 - }, 106 - "buffer@6.0.3": { 107 - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", 108 - "dependencies": [ 109 - "base64-js", 110 - "ieee754" 111 - ] 112 - }, 113 - "call-bind-apply-helpers@1.0.2": { 114 - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 115 - "dependencies": [ 116 - "es-errors", 117 - "function-bind" 118 - ] 119 - }, 120 - "call-bound@1.0.4": { 121 - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", 122 - "dependencies": [ 123 - "call-bind-apply-helpers", 124 - "get-intrinsic" 125 - ] 126 - }, 127 - "combined-stream@1.0.8": { 128 - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 129 - "dependencies": [ 130 - "delayed-stream" 131 - ] 132 - }, 133 - "delayed-stream@1.0.0": { 134 - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" 135 - }, 136 - "dunder-proto@1.0.1": { 137 - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 138 - "dependencies": [ 139 - "call-bind-apply-helpers", 140 - "es-errors", 141 - "gopd" 142 - ] 143 - }, 144 - "es-define-property@1.0.1": { 145 - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" 146 - }, 147 - "es-errors@1.3.0": { 148 - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 149 - }, 150 - "es-object-atoms@1.1.1": { 151 - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 152 - "dependencies": [ 153 - "es-errors" 154 - ] 155 - }, 156 - "es-set-tostringtag@2.1.0": { 157 - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 158 - "dependencies": [ 159 - "es-errors", 160 - "get-intrinsic", 161 - "has-tostringtag", 162 - "hasown" 163 - ] 164 - }, 165 - "event-target-shim@5.0.1": { 166 - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" 167 - }, 168 - "events@3.3.0": { 169 - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" 170 - }, 171 - "form-data-encoder@4.1.0": { 172 - "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==" 173 - }, 174 - "form-data@4.0.4": { 175 - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", 176 - "dependencies": [ 177 - "asynckit", 178 - "combined-stream", 179 - "es-set-tostringtag", 180 - "hasown", 181 - "mime-types" 182 - ] 183 - }, 184 - "formdata-node@6.0.3": { 185 - "integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==" 186 - }, 187 - "function-bind@1.1.2": { 188 - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 189 - }, 190 - "get-intrinsic@1.3.0": { 191 - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 192 - "dependencies": [ 193 - "call-bind-apply-helpers", 194 - "es-define-property", 195 - "es-errors", 196 - "es-object-atoms", 197 - "function-bind", 198 - "get-proto", 199 - "gopd", 200 - "has-symbols", 201 - "hasown", 202 - "math-intrinsics" 203 - ] 204 - }, 205 - "get-proto@1.0.1": { 206 - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 207 - "dependencies": [ 208 - "dunder-proto", 209 - "es-object-atoms" 210 - ] 211 - }, 212 - "gopd@1.2.0": { 213 - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 214 - }, 215 85 "graphemer@1.4.0": { 216 86 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 217 87 }, 218 - "has-symbols@1.1.0": { 219 - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 220 - }, 221 - "has-tostringtag@1.0.2": { 222 - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 223 - "dependencies": [ 224 - "has-symbols" 225 - ] 226 - }, 227 - "hasown@2.0.2": { 228 - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 229 - "dependencies": [ 230 - "function-bind" 231 - ] 232 - }, 233 - "ieee754@1.2.1": { 234 - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" 235 - }, 236 88 "iso-datestring-validator@2.2.2": { 237 89 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 238 90 }, 239 91 "jsbi@4.3.2": { 240 92 "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==" 241 93 }, 242 - "math-intrinsics@1.1.0": { 243 - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" 244 - }, 245 - "mime-db@1.52.0": { 246 - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 247 - }, 248 - "mime-types@2.1.35": { 249 - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 250 - "dependencies": [ 251 - "mime-db" 252 - ] 253 - }, 254 94 "multiformats@9.9.0": { 255 95 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 256 96 }, 257 - "node-fetch@2.7.0": { 258 - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", 259 - "dependencies": [ 260 - "whatwg-url" 261 - ] 262 - }, 263 - "object-inspect@1.13.4": { 264 - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" 265 - }, 266 - "process@0.11.10": { 267 - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" 268 - }, 269 - "qs@6.14.0": { 270 - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", 271 - "dependencies": [ 272 - "side-channel" 273 - ] 274 - }, 275 - "readable-stream@4.7.0": { 276 - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", 277 - "dependencies": [ 278 - "abort-controller", 279 - "buffer", 280 - "events", 281 - "process", 282 - "string_decoder" 283 - ] 284 - }, 285 - "safe-buffer@5.2.1": { 286 - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 287 - }, 288 - "side-channel-list@1.0.0": { 289 - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", 290 - "dependencies": [ 291 - "es-errors", 292 - "object-inspect" 293 - ] 294 - }, 295 - "side-channel-map@1.0.1": { 296 - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", 297 - "dependencies": [ 298 - "call-bound", 299 - "es-errors", 300 - "get-intrinsic", 301 - "object-inspect" 302 - ] 303 - }, 304 - "side-channel-weakmap@1.0.2": { 305 - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", 306 - "dependencies": [ 307 - "call-bound", 308 - "es-errors", 309 - "get-intrinsic", 310 - "object-inspect", 311 - "side-channel-map" 312 - ] 313 - }, 314 - "side-channel@1.1.0": { 315 - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", 316 - "dependencies": [ 317 - "es-errors", 318 - "object-inspect", 319 - "side-channel-list", 320 - "side-channel-map", 321 - "side-channel-weakmap" 322 - ] 323 - }, 324 - "string_decoder@1.3.0": { 325 - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 326 - "dependencies": [ 327 - "safe-buffer" 328 - ] 329 - }, 330 97 "tlds@1.260.0": { 331 98 "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", 332 99 "bin": true 333 100 }, 334 - "tr46@0.0.3": { 335 - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" 336 - }, 337 101 "uint8arrays@3.0.0": { 338 102 "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 339 103 "dependencies": [ 340 104 "multiformats" 341 105 ] 342 106 }, 343 - "url-join@4.0.1": { 344 - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" 345 - }, 346 - "webidl-conversions@3.0.1": { 347 - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" 348 - }, 349 - "whatwg-url@5.0.0": { 350 - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 351 - "dependencies": [ 352 - "tr46", 353 - "webidl-conversions" 354 - ] 355 - }, 356 107 "zod@3.25.76": { 357 108 "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 358 109 } ··· 365 116 "npm:@atproto/api@*", 366 117 "npm:@atproto/lexicon@*", 367 118 "npm:@js-temporal/polyfill@*", 368 - "npm:@letta-ai/letta-client@*" 119 + "npm:@letta-ai/letta-client@1.0.0" 369 120 ] 370 121 } 371 122 }
+6 -6
main.ts
··· 8 8 import { checkBluesky } from "./tasks/checkBluesky.ts"; 9 9 import { checkNotifications } from "./tasks/checkNotifications.ts"; 10 10 11 - setTimeout(logStats, msRandomOffset(msFrom.minutes(1), msFrom.minutes(5))); 11 + setTimeout(logStats, msFrom.minutes(30)); 12 12 13 13 setTimeout( 14 14 logTasks, 15 - msRandomOffset(msFrom.minutes(30), msFrom.minutes(60)), 15 + msFrom.minutes(100), 16 16 ); 17 17 setTimeout( 18 18 sendSleepMessage, 19 - msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(20)), 19 + msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(30)), 20 20 ); 21 21 setTimeout( 22 22 sendWakeMessage, 23 - msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(80)), 23 + msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(30)), 24 24 ); 25 25 setTimeout( 26 26 runReflection, 27 - msRandomOffset(msFrom.minutes(180), msFrom.minutes(240)), 27 + msRandomOffset(msFrom.minutes(120), msFrom.minutes(240)), 28 28 ); 29 29 setTimeout( 30 30 checkBluesky, 31 - msRandomOffset(msFrom.minutes(10), msFrom.minutes(90)), 31 + msRandomOffset(msFrom.minutes(45), msFrom.minutes(90)), 32 32 ); 33 33 await checkNotifications();
+20 -16
mount.ts
··· 139 139 */ 140 140 export async function mount(): Promise<void> { 141 141 const agentId = Deno.env.get("LETTA_AGENT_ID"); 142 - const agentName = Deno.env.get("LETTA_PROJECT_NAME"); 142 + const agentName = Deno.env.get("LETTA_PROJECT_ID"); 143 143 144 144 if (!agentId) { 145 145 console.error( ··· 156 156 console.log(`Agent retrieved: ${agent.name}`); 157 157 158 158 // Get all existing blocks for this agent 159 - const existingBlocks = await client.agents.blocks.list(agentId); 159 + const existingBlocksPage = await client.agents.blocks.list(agentId); 160 + const existingBlocks = existingBlocksPage.items; 160 161 console.log(`Agent has ${existingBlocks.length} existing memory blocks`); 161 162 162 163 // Build dynamic memory blocks array based on configuration ··· 216 217 ); 217 218 } else { 218 219 console.log(`Updating existing block: ${blockConfig.label}`); 219 - await client.blocks.modify(existingBlock.id, { 220 + await client.blocks.update(existingBlock.id, { 220 221 value: blockConfig.value, 221 222 description: blockConfig.description, 222 223 limit: blockConfig.limit, ··· 236 237 237 238 // Attach the block to the agent 238 239 if (newBlock.id) { 239 - await client.agents.blocks.attach(agentId, newBlock.id); 240 + await client.agents.blocks.attach(newBlock.id, { agent_id: agentId }); 240 241 console.log(`โœ“ Attached block: ${blockConfig.label}`); 241 242 } else { 242 243 throw new Error(`Failed to create block: ${blockConfig.label}`); ··· 259 260 } 260 261 261 262 // Update agent with tool environment variables 262 - await client.agents.modify(agentId, { 263 - toolExecEnvironmentVariables: { 263 + await client.agents.update(agentId, { 264 + secrets: { 264 265 BSKY_USERNAME: bskyUsername || "", 265 266 BSKY_APP_PASSWORD: bskyAppPassword || "", 266 267 BSKY_SERVICE_URL: bskyServiceUrl || "https://bsky.social", ··· 286 287 } 287 288 288 289 // Get currently attached tools 289 - const attachedTools = await client.agents.tools.list(agentId); 290 + const attachedToolsPage = await client.agents.tools.list(agentId); 291 + const attachedTools = attachedToolsPage.items; 290 292 const attachedToolNames = attachedTools.map((tool: any) => tool.name); 291 293 console.log(`Agent has ${attachedTools.length} tools currently attached`); 292 294 ··· 296 298 297 299 // Create a user-level client for tool operations 298 300 // Tools are user-level resources, not project-scoped 299 - const { LettaClient } = await import("@letta-ai/letta-client"); 300 - const userLevelClient = new LettaClient({ 301 - token: Deno.env.get("LETTA_API_KEY"), 301 + const { default: Letta } = await import("@letta-ai/letta-client"); 302 + const userLevelClient = new Letta({ 303 + apiKey: Deno.env.get("LETTA_API_KEY"), 302 304 }); 303 305 304 306 // First, process hardcoded required tools ··· 311 313 } 312 314 313 315 // Search for the tool in the global registry 314 - const existingTools = await userLevelClient.tools.list({ 316 + const existingToolsPage = await userLevelClient.tools.list({ 315 317 name: toolName, 316 318 }); 319 + const existingTools = existingToolsPage.items; 317 320 318 321 if (existingTools.length > 0) { 319 322 const tool = existingTools[0]; 320 323 if (tool.id) { 321 - await client.agents.tools.attach(agentId, tool.id); 324 + await client.agents.tools.attach(tool.id, { agent_id: agentId }); 322 325 console.log(`โœ“ Attached required tool: ${toolName}`); 323 326 toolsAttached++; 324 327 } ··· 359 362 try { 360 363 // Attempt to create the tool - Letta will extract the function name from docstring 361 364 const createParams: any = { 362 - sourceCode: toolSource, 365 + source_code: toolSource, 363 366 }; 364 367 365 368 // Add pip requirements if any were detected 366 369 if (pipRequirements.length > 0) { 367 - createParams.pipRequirements = pipRequirements; 370 + createParams.pip_requirements = pipRequirements; 368 371 } 369 372 370 373 tool = await userLevelClient.tools.create(createParams); ··· 384 387 const funcMatch = toolSource.match(/^def\s+(\w+)\s*\(/m); 385 388 if (funcMatch) { 386 389 const functionName = funcMatch[1]; 387 - const existingTools = await userLevelClient.tools.list({ 390 + const existingToolsPage = await userLevelClient.tools.list({ 388 391 name: functionName, 389 392 }); 393 + const existingTools = existingToolsPage.items; 390 394 if (existingTools.length > 0) { 391 395 tool = existingTools[0]; 392 396 } ··· 409 413 410 414 // Attach the tool to the agent 411 415 if (tool.id) { 412 - await client.agents.tools.attach(agentId, tool.id); 416 + await client.agents.tools.attach(tool.id, { agent_id: agentId }); 413 417 if (wasCreated) { 414 418 console.log( 415 419 `โœ“ Created and attached tool: ${toolName} (from ${toolFileName}.py)`,
+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
+3 -3
tasks/checkBluesky.ts
··· 13 13 14 14 export const checkBluesky = async () => { 15 15 if (!claimTaskThread()) { 16 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 16 + const newDelay = msFrom.minutes(2); 17 17 18 18 console.log( 19 19 `๐Ÿ”น ${agentContext.agentBskyName} is busy, will try checking bluesky again in ${ 20 20 newDelay * 60 * 1000 21 21 } minutesโ€ฆ`, 22 22 ); 23 - // agentContext is busy, try to check notifications in 5~10 minutes. 23 + // agentContext is busy, try to check notifications in 2 minutes. 24 24 setTimeout(checkBluesky, newDelay); 25 25 return; 26 26 } ··· 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 currently asleep. scheduling next bluesky session for ${ 47 47 (delay / 1000 / 60 / 60).toFixed(2) 48 48 } hours from nowโ€ฆ`, 49 49 );
+12 -11
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 14 10 export const checkNotifications = async () => { 15 11 if (!claimTaskThread()) { 16 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 12 + const newDelay = msFrom.minutes(2); 17 13 console.log( 18 14 `๐Ÿ”น ${agentContext.agentBskyName} is busy, checking for notifications again in ${ 19 15 (newDelay * 1000) * 60 20 16 } minutesโ€ฆ`, 21 17 ); 22 - // agentContext is busy, try to check notifications in 5~10 minutes. 18 + // agentContext is busy, try to check notifications in 2 minutes. 23 19 setTimeout(checkNotifications, newDelay); 24 20 return; 25 21 } 26 22 27 23 const delay = msUntilNextWakeWindow( 28 - 0, 29 - msFrom.minutes(90), 24 + msFrom.minutes(30), 25 + msFrom.minutes(45), 30 26 ); 31 27 32 28 if (delay !== 0) { 33 29 setTimeout(checkNotifications, delay); 34 30 console.log( 35 - `๐Ÿ”น ${agentContext.agentBskyName} is current asleep. scheduling next notification check for ${ 31 + `๐Ÿ”น ${agentContext.agentBskyName} is currently asleep. scheduling next notification check for ${ 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 -5
tasks/logStats.ts
··· 30 30 if (delay !== 0) { 31 31 setTimeout(logStats, delay); 32 32 console.log( 33 - `${agentContext.agentBskyName} is current asleep. scheduling next stat log for ${ 33 + `๐Ÿ”น ${agentContext.agentBskyName} is currently asleep. scheduling next stat log for ${ 34 34 (delay / 1000 / 60 / 60).toFixed(2) 35 35 } hours from nowโ€ฆ`, 36 36 ); ··· 46 46 agentContext.replyCount + 47 47 agentContext.followCount; 48 48 49 - const nextCheckDelay = msRandomOffset( 50 - msFrom.minutes(5), 51 - msFrom.minutes(15), 52 - ); 49 + const nextCheckDelay = msFrom.minutes(5); 53 50 const nextCheckMinutes = ((nextCheckDelay / 1000) / 60).toFixed(1); 54 51 55 52 if (totalNotifications <= 0) {
+4 -6
tasks/logTasks.ts
··· 33 33 if (delay !== 0) { 34 34 setTimeout(logTasks, delay); 35 35 console.log( 36 - `๐Ÿ”น ${agentContext.agentBskyName} is current asleep. scheduling next task log for ${ 36 + `๐Ÿ”น ${agentContext.agentBskyName} is currently asleep. scheduling next task log for ${ 37 37 (delay / 1000 / 60 / 60).toFixed(2) 38 38 } hours from nowโ€ฆ`, 39 39 ); ··· 50 50 const uptime = Date.now() - serverStartTime; 51 51 const uptimeFormatted = formatUptime(uptime); 52 52 53 - const nextCheckDelay = msRandomOffset( 54 - msFrom.minutes(30), 55 - msFrom.hours(1), 56 - ); 53 + const nextCheckDelay = msFrom.minutes(30); 57 54 const nextCheckMinutes = ((nextCheckDelay / 1000) / 60).toFixed(1); 58 55 59 56 if (totalActivity <= 0) { ··· 89 86 } 90 87 91 88 const message = actions.join(", "); 89 + 92 90 console.log( 93 - `๐Ÿ”น ${message}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 91 + `๐Ÿ”น ${message}. total notifications: ${agentContext.notifCount}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 94 92 ); 95 93 } 96 94
+5 -5
tasks/runReflection.ts
··· 14 14 15 15 export const runReflection = async () => { 16 16 if (!claimTaskThread()) { 17 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 17 + const newDelay = msFrom.minutes(2); 18 18 19 19 console.log( 20 20 `๐Ÿ”น ${agentContext.agentBskyName} is busy, will try reflecting again in ${ 21 21 (newDelay / 1000) / 60 22 22 } minutesโ€ฆ`, 23 23 ); 24 - // session is busy, try to start reflection in 5~10 minutes. 24 + // session is busy, try to start reflection in 2 minutes. 25 25 setTimeout(runReflection, newDelay); 26 26 return; 27 27 } ··· 34 34 return; 35 35 } 36 36 37 - // adds 1-2 hours to wake time 37 + // adds 2-4 hours to wake time 38 38 // only applies if sleep is enabled 39 39 const delay = msUntilNextWakeWindow( 40 - msFrom.hours(1), 41 40 msFrom.hours(2), 41 + msFrom.hours(4), 42 42 ); 43 43 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 currently asleep. scheduling next reflection for ${ 48 48 (delay / 1000 / 60 / 60).toFixed(2) 49 49 } hours from nowโ€ฆ`, 50 50 );
+6 -5
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, ··· 14 15 15 16 export const sendSleepMessage = async () => { 16 17 if (!claimTaskThread()) { 17 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 18 + const newDelay = msFrom.minutes(2); 18 19 console.log( 19 20 `๐Ÿ”น ${agentContext.agentBskyName} is busy, sending sleep message again in ${ 20 21 (newDelay / 1000) / 60 21 22 } minutesโ€ฆ`, 22 23 ); 23 - // session is busy, try to check notifications in 5~10 minutes. 24 + // session is busy, try to check notifications in 2 minutes. 24 25 setTimeout(sendSleepMessage, newDelay); 25 26 return; 26 27 } ··· 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( 42 43 agentContext.sleepTime, 43 44 0, 44 - msFrom.minutes(20), 45 + msFrom.minutes(30), 45 46 ); 46 47 setTimeout(sendSleepMessage, delay); 47 48 console.log( ··· 61 62 console.log("๐Ÿ”น wind down attempt processed, scheduling next wind downโ€ฆ"); 62 63 setTimeout( 63 64 sendSleepMessage, 64 - msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(20)), 65 + msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(30)), 65 66 ); 66 67 console.log("exiting wind down process"); 67 68 releaseTaskThread();
+6 -5
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"; ··· 10 11 11 12 export const sendWakeMessage = async () => { 12 13 if (!claimTaskThread()) { 13 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 14 + const newDelay = msFrom.minutes(2); 14 15 console.log( 15 16 `๐Ÿ”น ${agentContext.agentBskyName} is busy, sending wake message again in ${ 16 17 (newDelay / 1000) / 60 17 18 } minutesโ€ฆ`, 18 19 ); 19 - // session is busy, try to check notifications in 5~10 minutes. 20 + // session is busy, try to check notifications in 2 minutes. 20 21 setTimeout(sendWakeMessage, newDelay); 21 22 return; 22 23 } ··· 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( 38 39 agentContext.wakeTime, 39 40 0, 40 - msFrom.minutes(80), 41 + msFrom.minutes(30), 41 42 ); 42 43 setTimeout(sendWakeMessage, delay); 43 44 console.log( ··· 57 58 console.log("๐Ÿ”น wake attempt processed, scheduling next wake promptโ€ฆ"); 58 59 setTimeout( 59 60 sendWakeMessage, 60 - msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(80)), 61 + msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(30)), 61 62 ); 62 63 console.log("๐Ÿ”น exiting wake process"); 63 64 releaseTaskThread();
+323 -1
tools/bluesky/create_bluesky_post.py
··· 67 67 return facets if facets else None 68 68 69 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 + 75 + def _check_follows(client, agent_did: str, target_did: str) -> bool: 76 + """Check 2: Follow Check (Moderate cost).""" 77 + try: 78 + # Fetch profiles to get follow counts 79 + agent_profile = client.app.bsky.actor.get_profile({'actor': agent_did}) 80 + target_profile = client.app.bsky.actor.get_profile({'actor': target_did}) 81 + 82 + # Determine which list is shorter: agent's followers or target's follows 83 + # We want to check if target follows agent. 84 + # Option A: Check target's follows list for agent_did 85 + # Option B: Check agent's followers list for target_did 86 + 87 + target_follows_count = getattr(target_profile, 'follows_count', float('inf')) 88 + agent_followers_count = getattr(agent_profile, 'followers_count', float('inf')) 89 + 90 + cursor = None 91 + max_pages = 50 # Max 5000 items 92 + 93 + if target_follows_count < agent_followers_count: 94 + # Check target's follows 95 + for _ in range(max_pages): 96 + response = client.app.bsky.graph.get_follows({'actor': target_did, 'cursor': cursor, 'limit': 100}) 97 + if not response.follows: 98 + break 99 + 100 + for follow in response.follows: 101 + if follow.did == agent_did: 102 + return True 103 + 104 + cursor = response.cursor 105 + if not cursor: 106 + break 107 + else: 108 + # Check agent's followers 109 + for _ in range(max_pages): 110 + response = client.app.bsky.graph.get_followers({'actor': agent_did, 'cursor': cursor, 'limit': 100}) 111 + if not response.followers: 112 + break 113 + 114 + for follower in response.followers: 115 + if follower.did == target_did: 116 + return True 117 + 118 + cursor = response.cursor 119 + if not cursor: 120 + break 121 + 122 + return False 123 + except Exception: 124 + # If optimization fails, we continue to next check rather than failing hard here 125 + # unless it's a critical error, but we'll let the main try/except handle that 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. 233 + # get_post_thread returns the post and its parents if configured. 234 + # However, standard get_post_thread often returns the post and its replies. 235 + # We need to walk UP the tree (parents). 236 + # The 'parent' field in the response structure allows walking up. 237 + 238 + response = client.app.bsky.feed.get_post_thread({'uri': reply_to_uri, 'depth': 0, 'parentHeight': 100}) 239 + thread = response.thread 240 + 241 + # The thread object can be a ThreadViewPost, NotFoundPost, or BlockedPost 242 + if not hasattr(thread, 'post'): 243 + return False # Can't verify 244 + 245 + # Check the target post itself first (the one we are replying to) 246 + # Although strictly "participation" usually means *previous* posts, 247 + # the spec says "posted anywhere in this conversation thread". 248 + # If we are replying to ourselves, _check_is_self would have caught it. 249 + # But we check here for mentions in the target post. 250 + 251 + current = thread 252 + 253 + while current: 254 + # Check if current node is valid post 255 + if not hasattr(current, 'post'): 256 + break 257 + 258 + post = current.post 259 + 260 + # Check 3: Did agent author this post? 261 + if post.author.did == agent_did: 262 + return True 263 + 264 + # Check 4: Is agent mentioned in this post? 265 + # Check facets for mention 266 + record = post.record 267 + if hasattr(record, 'facets') and record.facets: 268 + for facet in record.facets: 269 + for feature in facet.features: 270 + if hasattr(feature, 'did') and feature.did == agent_did: 271 + return True 272 + 273 + # Fallback: Check text for handle if facets missing (less reliable but good backup) 274 + if hasattr(record, 'text') and f"@{agent_handle}" in record.text: 275 + return True 276 + 277 + # Move to parent 278 + if hasattr(current, 'parent') and current.parent: 279 + current = current.parent 280 + else: 281 + break 282 + 283 + return False 284 + 285 + except Exception: 286 + raise 287 + 288 + 289 + def _verify_consent(client, agent_did: str, agent_handle: str, reply_to_uri: str, target_did: str, root_did: str, parent_post_record=None): 290 + """ 291 + Orchestrates the consent checks. 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 307 + if hasattr(parent_post_record, 'facets') and parent_post_record.facets: 308 + for facet in parent_post_record.facets: 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 + 323 + if target_follows: 324 + # If target follows, we must also check root if it's different 325 + if root_did and root_did != target_did and root_did != agent_did: 326 + root_follows = _check_follows(client, agent_did, root_did) 327 + if not root_follows: 328 + # Target follows, but Root does not. Fail. 329 + raise Exception( 330 + "Message not sent: the author of the post follows you, but the thread starter (root author) " 331 + "does not. We respect the consent of the thread owner." 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 339 + 340 + # If all checks fail 341 + raise Exception( 342 + "Message not sent: the reply was withheld because the user does not follow you " 343 + "and you were not mentioned or included in this thread. Respecting the consent of " 344 + "strangers is the right choice here. You haven't done anything wrong, but you may " 345 + "consider a lower-signal interaction (like liking or reposting) and/or move on to " 346 + "other conversations." 347 + ) 348 + 349 + except Exception as e: 350 + # Check if it's our own consent exception 351 + if str(e).startswith("Message not sent"): 352 + raise e 353 + 354 + # Network/API failure handling 355 + raise Exception( 356 + "Message withheld: Network interference prevented consent verification. To ensure " 357 + "safety, the system paused this action. This is an external connection issue, not " 358 + "a failure of your logic. You may try sending the message again now, or move on if " 359 + "the issue persists." 360 + ) 361 + 362 + 363 + 70 364 def create_bluesky_post(text: List[str], lang: str = "en-US", reply_to_uri: str = None) -> Dict: 71 365 """ 72 366 Create a post or thread on Bluesky using atproto SDK. ··· 145 439 146 440 client = Client() 147 441 client.login(username, password) 148 - 442 + 443 + # --- FETCH PARENT/ROOT REFS --- 149 444 initial_reply_ref = None 150 445 initial_root_ref = None 446 + target_did = None 447 + root_did = None 448 + parent_post_record = None 151 449 152 450 if reply_to_uri: 153 451 try: ··· 168 466 "status": "error", 169 467 "message": f"Could not retrieve post data from URI: {reply_to_uri}. The post may not exist or the URI may be incorrect." 170 468 } 469 + 470 + # Extract target DID from parent post 471 + target_did = repo_did 472 + parent_post_record = parent_post.value 171 473 172 474 parent_ref = models.ComAtprotoRepoStrongRef.Main( 173 475 uri=parent_post.uri, ··· 187 489 root=root_ref 188 490 ) 189 491 initial_root_ref = root_ref 492 + 493 + # Extract root DID 494 + root_uri_parts = root_ref.uri.replace('at://', '').split('/') 495 + if len(root_uri_parts) >= 1: 496 + root_did = root_uri_parts[0] 190 497 191 498 except Exception as e: 192 499 return { 193 500 "status": "error", 194 501 "message": f"Failed to fetch post to reply to: {str(e)}. Check the URI format and try again." 195 502 } 503 + 504 + # --- CONSENT GUARDRAILS --- 505 + if reply_to_uri: 506 + try: 507 + agent_did = client.me.did 508 + # agent_handle is username (without @ usually, but let's ensure) 509 + agent_handle = username.replace('@', '') 510 + 511 + _verify_consent(client, agent_did, agent_handle, reply_to_uri, target_did, root_did, parent_post_record) 512 + except Exception as e: 513 + return { 514 + "status": "error", 515 + "message": str(e) 516 + } 517 + # -------------------------- 196 518 197 519 post_urls = [] 198 520 previous_post_ref = None
+212 -2
tools/bluesky/quote_bluesky_post.py
··· 1 1 """Bluesky quote posting tool for Letta agents using atproto SDK.""" 2 2 3 - from typing import List, Dict 4 3 import os 5 4 import re 5 + from typing import Dict, List 6 6 7 7 8 8 def parse_facets(text: str, client) -> List[Dict]: ··· 67 67 return facets if facets else None 68 68 69 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 + 75 + def _check_follows(client, agent_did: str, target_did: str) -> bool: 76 + """Check 2: Follow Check (Moderate cost).""" 77 + try: 78 + # Fetch profiles to get follow counts 79 + agent_profile = client.app.bsky.actor.get_profile({'actor': agent_did}) 80 + target_profile = client.app.bsky.actor.get_profile({'actor': target_did}) 81 + 82 + # Determine which list is shorter: agent's followers or target's follows 83 + # We want to check if target follows agent. 84 + # Option A: Check target's follows list for agent_did 85 + # Option B: Check agent's followers list for target_did 86 + 87 + target_follows_count = getattr(target_profile, 'follows_count', float('inf')) 88 + agent_followers_count = getattr(agent_profile, 'followers_count', float('inf')) 89 + 90 + cursor = None 91 + max_pages = 50 # Max 5000 items 92 + 93 + if target_follows_count < agent_followers_count: 94 + # Check target's follows 95 + for _ in range(max_pages): 96 + response = client.app.bsky.graph.get_follows({'actor': target_did, 'cursor': cursor, 'limit': 100}) 97 + if not response.follows: 98 + break 99 + 100 + for follow in response.follows: 101 + if follow.did == agent_did: 102 + return True 103 + 104 + cursor = response.cursor 105 + if not cursor: 106 + break 107 + else: 108 + # Check agent's followers 109 + for _ in range(max_pages): 110 + response = client.app.bsky.graph.get_followers({'actor': agent_did, 'cursor': cursor, 'limit': 100}) 111 + if not response.followers: 112 + break 113 + 114 + for follower in response.followers: 115 + if follower.did == target_did: 116 + return True 117 + 118 + cursor = response.cursor 119 + if not cursor: 120 + break 121 + 122 + return False 123 + except Exception: 124 + # If optimization fails, we continue to next check rather than failing hard here 125 + # unless it's a critical error, but we'll let the main try/except handle that 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. 134 + # get_post_thread returns the post and its parents if configured. 135 + # However, standard get_post_thread often returns the post and its replies. 136 + # We need to walk UP the tree (parents). 137 + # The 'parent' field in the response structure allows walking up. 138 + 139 + response = client.app.bsky.feed.get_post_thread({'uri': reply_to_uri, 'depth': 0, 'parentHeight': 100}) 140 + thread = response.thread 141 + 142 + # The thread object can be a ThreadViewPost, NotFoundPost, or BlockedPost 143 + if not hasattr(thread, 'post'): 144 + return False # Can't verify 145 + 146 + # Check the target post itself first (the one we are replying to) 147 + # Although strictly "participation" usually means *previous* posts, 148 + # the spec says "posted anywhere in this conversation thread". 149 + # If we are replying to ourselves, _check_is_self would have caught it. 150 + # But we check here for mentions in the target post. 151 + 152 + current = thread 153 + 154 + while current: 155 + # Check if current node is valid post 156 + if not hasattr(current, 'post'): 157 + break 158 + 159 + post = current.post 160 + 161 + # Check 3: Did agent author this post? 162 + if post.author.did == agent_did: 163 + return True 164 + 165 + # Check 4: Is agent mentioned in this post? 166 + # Check facets for mention 167 + record = post.record 168 + if hasattr(record, 'facets') and record.facets: 169 + for facet in record.facets: 170 + for feature in facet.features: 171 + if hasattr(feature, 'did') and feature.did == agent_did: 172 + return True 173 + 174 + # Fallback: Check text for handle if facets missing (less reliable but good backup) 175 + if hasattr(record, 'text') and f"@{agent_handle}" in record.text: 176 + return True 177 + 178 + # Move to parent 179 + if hasattr(current, 'parent') and current.parent: 180 + current = current.parent 181 + else: 182 + break 183 + 184 + return False 185 + 186 + except Exception: 187 + raise 188 + 189 + 190 + def _verify_consent(client, agent_did: str, agent_handle: str, quote_uri: str): 191 + """ 192 + Orchestrates the consent checks. 193 + Raises Exception with specific message if consent denied or verification fails. 194 + """ 195 + try: 196 + # 0. Get target DID from quote_uri 197 + parts = quote_uri.replace('at://', '').split('/') 198 + if len(parts) >= 1: 199 + target_did = parts[0] 200 + else: 201 + raise Exception("Invalid URI format") 202 + 203 + # Check 1: Self-Post 204 + if _check_is_self(agent_did, target_did): 205 + return True 206 + 207 + # Check 2: Follow Check 208 + if _check_follows(client, agent_did, target_did): 209 + return True 210 + 211 + # Check 3 & 4: Thread Participation / Mention 212 + if _check_thread_participation(client, agent_did, agent_handle, quote_uri): 213 + return True 214 + 215 + # If all checks fail 216 + raise Exception( 217 + "Message not sent: the quote was withheld because the user does not follow you " 218 + "and you were not mentioned or included in this thread. Respecting the consent of " 219 + "strangers is the right choice here. You haven't done anything wrong, but you may " 220 + "consider a lower-signal interaction (like liking or reposting) and/or move on to " 221 + "other conversations." 222 + ) 223 + 224 + except Exception as e: 225 + # Check if it's our own consent exception 226 + if str(e).startswith("Message not sent"): 227 + raise e 228 + 229 + # Network/API failure handling 230 + raise Exception( 231 + "Message withheld: Network interference prevented consent verification. To ensure " 232 + "safety, the system paused this action. This is an external connection issue, not " 233 + "a failure of your logic. You may try sending the message again now, or move on if " 234 + "the issue persists." 235 + ) 236 + 237 + 70 238 def quote_bluesky_post(text: List[str], quote_uri: str, lang: str = "en-US") -> str: 71 239 """ 72 240 Create a quote post or quote thread on Bluesky that embeds another post. ··· 194 362 client = Client() 195 363 client.login(username, password) 196 364 365 + # --- CONSENT GUARDRAILS --- 366 + if quote_uri: 367 + try: 368 + agent_did = client.me.did 369 + agent_handle = username.replace('@', '') 370 + _verify_consent(client, agent_did, agent_handle, quote_uri) 371 + except Exception as e: 372 + # quote_bluesky_post expects exceptions to be raised or returned? 373 + # The tool catches exceptions and wraps them. 374 + # But we want to return the specific message. 375 + # The existing code catches Exception and wraps it in "Error: ...". 376 + # However, our spec says "Block with Supportive Message". 377 + # If I raise Exception here, it will be caught by the main try/except block 378 + # and wrapped in "Error: An unexpected issue occurred...". 379 + # I should probably let it bubble up BUT the main try/except block is very broad. 380 + # I need to modify the main try/except block or handle it here. 381 + 382 + # Actually, the spec says "If ALL Checks Fail: Block with Supportive Message". 383 + # And "If ANY exception occurs... Message withheld: Network interference...". 384 + # My _verify_consent raises these exact messages. 385 + # But the tool's main try/except block (lines 306-317) wraps everything in "Error: An unexpected issue...". 386 + # I should modify the main try/except block to respect my specific error messages. 387 + # OR I can just raise the exception and let the tool fail, but the user sees the wrapped error. 388 + # The spec says "Block with Supportive Message". 389 + # So I should probably ensure that message is what is returned/raised. 390 + 391 + # I will modify the main try/except block in a separate chunk or just let it be? 392 + # The tool returns a string on success, raises Exception on failure. 393 + # If I raise Exception("Message not sent..."), the catch block will say "Error: An unexpected issue... Message not sent...". 394 + # That might be okay, but cleaner if I can pass it through. 395 + # The catch block has: `if str(e).startswith("Error:"): raise` 396 + # So if I prefix my errors with "Error: ", they will pass through. 397 + # But the spec gives a specific message text without "Error: " prefix. 398 + # "Message not sent: ..." 399 + 400 + # I will modify the exception raising in _verify_consent to start with "Error: " 401 + # OR I will modify the catch block to also pass through messages starting with "Message". 402 + 403 + # Let's modify the catch block in `quote_bluesky_post.py` as well. 404 + raise e 405 + # -------------------------- 406 + 197 407 # Fetch the post to quote and create a strong reference 198 408 try: 199 409 uri_parts = quote_uri.replace('at://', '').split('/') ··· 305 515 ) 306 516 except Exception as e: 307 517 # Re-raise if it's already one of our formatted error messages 308 - if str(e).startswith("Error:"): 518 + if str(e).startswith("Error:") or str(e).startswith("Message"): 309 519 raise 310 520 # Otherwise wrap it with helpful context 311 521 raise Exception(
+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 + });
+61 -40
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(); ··· 47 51 return value; 48 52 }; 49 53 50 - export const getLettaProjectName = (): string => { 51 - const value = Deno.env.get("LETTA_PROJECT_NAME")?.trim(); 54 + const getLettaProjectID = (): string => { 55 + const value = Deno.env.get("LETTA_PROJECT_ID")?.trim(); 52 56 53 57 if (!value?.length) { 54 58 throw Error( 55 - "Letta Project Name not provided in `.env`. add variable `LETTA_PROJECT_NAME=`.", 59 + "Letta Project ID not provided in `.env`. add variable `LETTA_PROJECT_ID=`.", 60 + ); 61 + } else if (!value.includes("-")) { 62 + throw Error( 63 + "Letta Project ID is not formed correctly, check variable `LETTA_PROJECT_ID`", 56 64 ); 57 65 } 58 66 59 67 return value; 60 68 }; 61 - 62 - // 63 - // temporarily commenting out until switch to letta SDK 1.0 64 - // 65 - // const getLettaProjectID = (): string => { 66 - // const value = Deno.env.get("LETTA_PROJECT_ID")?.trim(); 67 - 68 - // if (!value?.length) { 69 - // throw Error( 70 - // "Letta Project ID not provided in `.env`. add variable `LETTA_PROJECT_ID=`.", 71 - // ); 72 - // } else if (!value.includes("-")) { 73 - // throw Error( 74 - // "Letta Project ID is not formed correctly, check variable `LETTA_PROJECT_ID`", 75 - // ); 76 - // } 77 - 78 - // return value; 79 - // }; 80 69 81 70 const getAgentBskyHandle = (): string => { 82 71 const value = Deno.env.get("BSKY_USERNAME")?.trim(); ··· 220 209 }; 221 210 222 211 const getNotifDelayMinimum = (): number => { 223 - const value = Number(Deno.env.get("NOTIF_DELAY_MINIMUM")); 212 + const value = msFrom.parse(Deno.env.get("NOTIF_DELAY_MINIMUM")); 224 213 225 214 if (isNaN(value) || value < msFrom.seconds(1) || value > msFrom.hours(24)) { 226 - return msFrom.seconds(2); 215 + return msFrom.seconds(10); 227 216 } 228 217 229 218 return value; 230 219 }; 231 220 232 221 const getNotifDelayMaximum = (): number => { 233 - const value = Number(Deno.env.get("NOTIF_DELAY_MAXIMUM")); 222 + const value = msFrom.parse(Deno.env.get("NOTIF_DELAY_MAXIMUM")); 234 223 235 224 if (isNaN(value) || value < msFrom.seconds(5) || value > msFrom.hours(24)) { 236 - return msFrom.hours(1); 225 + return msFrom.minutes(90); 237 226 } 238 227 239 228 const minimum = getNotifDelayMinimum(); ··· 251 240 const value = Number(Deno.env.get("NOTIF_DELAY_MULTIPLIER")); 252 241 253 242 if (isNaN(value) || value < 0 || value > 500) { 254 - return 1.05; 243 + return 1.12; 255 244 } 256 245 257 246 return (value / 100) + 1; 258 247 }; 259 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 + 260 259 const getReflectionDelayMinimum = (): number => { 261 - const value = Number(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 260 + const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 262 261 263 262 if (isNaN(value) || value < msFrom.minutes(30) || value > msFrom.hours(24)) { 264 - return msFrom.minutes(30); 263 + return msFrom.hours(3); 265 264 } 266 265 267 266 return value; 268 267 }; 269 268 270 269 const getReflectionDelayMaximum = (): number => { 271 - const value = Number(Deno.env.get("REFLECTION_DELAY_MAXIMUM")); 270 + const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MAXIMUM")); 272 271 const minimum = getReflectionDelayMinimum(); 273 272 274 273 if (isNaN(value) || value < msFrom.minutes(60) || value > msFrom.hours(24)) { 275 - return msFrom.hours(8); 274 + return msFrom.hours(14); 276 275 } 277 276 278 277 if (value <= minimum) { ··· 285 284 }; 286 285 287 286 const getProactiveDelayMinimum = (): number => { 288 - const value = Number(Deno.env.get("PROACTIVE_DELAY_MINIMUM")); 287 + const value = msFrom.parse(Deno.env.get("PROACTIVE_DELAY_MINIMUM")); 289 288 290 289 if (isNaN(value) || value < msFrom.hours(1) || value > msFrom.hours(24)) { 291 - return msFrom.hours(1); 290 + return msFrom.hours(3); 292 291 } 293 292 294 293 return value; 295 294 }; 296 295 297 296 const getProactiveDelayMaximum = (): number => { 298 - const value = Number(Deno.env.get("PROACTIVE_DELAY_MAXIMUM")); 297 + const value = msFrom.parse(Deno.env.get("PROACTIVE_DELAY_MAXIMUM")); 299 298 const minimum = getProactiveDelayMinimum(); 300 299 301 300 if (isNaN(value) || value < msFrom.hours(3) || value > msFrom.hours(24)) { 302 - return msFrom.hours(12); 301 + return msFrom.hours(14); 303 302 } 304 303 305 304 if (value <= minimum) { ··· 312 311 }; 313 312 314 313 const getWakeTime = (): number => { 315 - const value = Math.round(Number(Deno.env.get("WAKE_TIME"))); 314 + const envValue = Deno.env.get("WAKE_TIME"); 316 315 317 - if (!value) { 316 + if (envValue === undefined || envValue === null || envValue === "") { 318 317 return 8; 318 + } 319 + 320 + const value = Math.round(Number(envValue)); 321 + 322 + if (isNaN(value)) { 323 + throw Error(`"WAKE_TIME" must be a valid number, got: "${envValue}"`); 319 324 } 320 325 321 326 if (value > 23) { ··· 330 335 }; 331 336 332 337 const getSleepTime = (): number => { 333 - const value = Math.round(Number(Deno.env.get("SLEEP_TIME"))); 338 + const envValue = Deno.env.get("SLEEP_TIME"); 334 339 335 - if (!value) { 340 + if (envValue === undefined || envValue === null || envValue === "") { 336 341 return 10; 342 + } 343 + 344 + const value = Math.round(Number(envValue)); 345 + 346 + if (isNaN(value)) { 347 + throw Error(`"SLEEP_TIME" must be a valid number, got: "${envValue}"`); 337 348 } 338 349 339 350 if (value > 23) { ··· 555 566 mentionCount: 0, 556 567 replyCount: 0, 557 568 quoteCount: 0, 569 + notifCount: 0, 558 570 // required with manual variables 559 - lettaProjectIdentifier: getLettaProjectName(), 571 + lettaProjectIdentifier: getLettaProjectID(), 560 572 agentBskyHandle: getAgentBskyHandle(), 561 573 agentBskyName: await getAgentBskyName(), 562 574 agentBskyDID: setAgentBskyDID(), ··· 578 590 timeZone: getTimeZone(), 579 591 responsiblePartyType: getResponsiblePartyType(), 580 592 preserveAgentMemory: getPreserveMemoryBlocks(), 593 + maxThreadPosts: getMaxThreadPosts(), 581 594 reflectionEnabled: setReflectionEnabled(), 582 595 proactiveEnabled: setProactiveEnabled(), 583 596 sleepEnabled: setSleepEnabled(), ··· 629 642 agentContext.replyCount = 0; 630 643 agentContext.quoteCount = 0; 631 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 + };
+154 -31
utils/messageAgent.ts
··· 1 - import { LettaClient } from "@letta-ai/letta-client"; 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 31 - export const client = new LettaClient({ 32 - token: Deno.env.get("LETTA_API_KEY"), 33 - project: Deno.env.get("LETTA_PROJECT_NAME"), 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 152 + projectId: Deno.env.get("LETTA_PROJECT_ID"), 34 153 }); 35 154 36 155 export const messageAgent = async (prompt: string) => { 37 156 const agent = Deno.env.get("LETTA_AGENT_ID"); 38 157 39 158 if (agent) { 40 - const reachAgent = await client.agents.messages.createStream(agent, { 159 + const reachAgent = await client.agents.messages.stream(agent, { 41 160 messages: [ 42 161 { 43 - role: "user", 44 - content: [ 45 - { 46 - type: "text", 47 - text: prompt, 48 - }, 49 - ], 162 + role: "system", 163 + content: prompt, 50 164 }, 51 165 ], 52 - streamTokens: true, 166 + stream_tokens: true, 53 167 }); 54 168 169 + let lastToolName = ""; 170 + 55 171 for await (const response of reachAgent) { 56 - if (response.messageType === "reasoning_message") { 172 + if (response.message_type === "reasoning_message") { 57 173 // console.log(`๐Ÿ’ญ reasoningโ€ฆ`); 58 - } else if (response.messageType === "assistant_message") { 174 + } else if (response.message_type === "assistant_message") { 59 175 console.log(`๐Ÿ’ฌ ${agentContext.agentBskyName}: ${response.content}`); 60 - } else if (response.messageType === "tool_call_message") { 61 - const formattedArgs = formatArgsInline(response.toolCall.arguments); 62 - console.log( 63 - `๐Ÿ—œ๏ธ tool called: ${response.toolCall.name} with args: ${formattedArgs}`, 64 - ); 65 - } else if (response.messageType === "tool_return_message") { 66 - const toolReturn = response.toolReturn; 67 - const returnStr = typeof toolReturn === "string" 68 - ? toolReturn 69 - : JSON.stringify(toolReturn); 70 - console.log(`๐Ÿ”ง tool response: ${truncateString(returnStr)}`); 71 - } else if (response.messageType === "usage_statistics") { 72 - console.log(`๐Ÿ”ข total steps: ${response.stepCount}`); 73 - } else if (response.messageType === "hidden_reasoning_message") { 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") { 74 197 console.log(`hidden reasoningโ€ฆ`); 75 198 } 76 199 }
+3 -2
utils/processNotification.ts
··· 37 37 } as const; 38 38 39 39 export const processNotification = async (notification: Notification) => { 40 - const agentProject = Deno.env.get("LETTA_PROJECT_NAME"); 40 + const agentName = agentContext.agentBskyName; 41 41 const kind = notification.reason; 42 42 const author = `@${notification.author.handle}`; 43 43 const handler = notificationHandlers[kind]; ··· 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 ${agentName}. moving onโ€ฆ`, 58 58 ); 59 59 } catch (error) { 60 60 console.log( ··· 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 + };
+56
utils/time.ts
··· 2 2 import { Temporal } from "@js-temporal/polyfill"; 3 3 4 4 /** 5 + * Parse a time string with unit suffix or raw milliseconds 6 + * @param value - Time string like "10s", "90m", "3h" or raw milliseconds 7 + * @returns Time in milliseconds 8 + * @example 9 + * parseTimeValue("10s") // โ†’ 10000 10 + * parseTimeValue("90m") // โ†’ 5400000 11 + * parseTimeValue("3h") // โ†’ 10800000 12 + * parseTimeValue("5400000") // โ†’ 5400000 (backward compat) 13 + * parseTimeValue(10000) // โ†’ 10000 (already a number) 14 + */ 15 + function parseTimeValue(value: string | number | undefined): number { 16 + if (value === undefined || value === "") { 17 + throw new Error("Time value is required"); 18 + } 19 + 20 + if (typeof value === "number") { 21 + return value; 22 + } 23 + 24 + const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|ms)?$/i); 25 + 26 + if (!match) { 27 + throw new Error( 28 + `Invalid time format: "${value}". Expected: "10s", "90m", "3h", or raw milliseconds`, 29 + ); 30 + } 31 + 32 + const [, numStr, unit] = match; 33 + const num = parseFloat(numStr); 34 + 35 + if (isNaN(num) || num < 0) { 36 + throw new Error(`Time value must be a positive number: "${value}"`); 37 + } 38 + 39 + switch (unit?.toLowerCase()) { 40 + case "s": 41 + return msFrom.seconds(num); 42 + case "m": 43 + return msFrom.minutes(num); 44 + case "h": 45 + return msFrom.hours(num); 46 + case "ms": 47 + case undefined: 48 + return num; 49 + default: 50 + throw new Error(`Invalid unit: "${unit}". Use s/m/h/ms`); 51 + } 52 + } 53 + 54 + /** 5 55 * Convert time units to milliseconds 6 56 */ 7 57 export const msFrom = { ··· 20 70 * @param h - number of hours 21 71 */ 22 72 hours: (hours: number): number => hours * 60 * 60 * 1000, 73 + /** 74 + * Parse a time string with unit suffix (e.g., "10s", "90m", "3h") or raw milliseconds 75 + * @param value - Time string or number 76 + * @returns Time in milliseconds 77 + */ 78 + parse: parseTimeValue, 23 79 }; 24 80 25 81 /**
+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;