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 }
+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
+7 -6
tasks/checkNotifications.ts
··· 3 3 claimTaskThread, 4 4 releaseTaskThread, 5 5 } from "../utils/agentContext.ts"; 6 - import { 7 - msFrom, 8 - msRandomOffset, 9 - msUntilNextWakeWindow, 10 - } from "../utils/time.ts"; 6 + import { msFrom, msUntilNextWakeWindow } from "../utils/time.ts"; 11 7 import { bsky } from "../utils/bsky.ts"; 12 8 import { processNotification } from "../utils/processNotification.ts"; 13 9 ··· 36 32 (delay / 1000 / 60 / 60).toFixed(2) 37 33 } hours from nowโ€ฆ`, 38 34 ); 35 + agentContext.notifDelayCurrent = agentContext.notifDelayMinimum; 39 36 releaseTaskThread(); 40 37 return; 41 38 } ··· 78 75 // marks all notifications that were processed as seen 79 76 // based on time from when retrieved instead of finished 80 77 await bsky.updateSeenNotifications(startedProcessingTime); 81 - 78 + console.log( 79 + `๐Ÿ”น done processing ${unreadNotifications.length} notification${ 80 + unreadNotifications.length > 1 ? "s" : "" 81 + }โ€ฆ`, 82 + ); 82 83 // increases counter for notification processing session 83 84 agentContext.processingCount++; 84 85 } else {
+2 -1
tasks/logTasks.ts
··· 86 86 } 87 87 88 88 const message = actions.join(", "); 89 + 89 90 console.log( 90 - `๐Ÿ”น ${message}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 91 + `๐Ÿ”น ${message}. total notifications: ${agentContext.notifCount}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`, 91 92 ); 92 93 } 93 94
+2 -1
tasks/sendSleepMessage.ts
··· 2 2 agentContext, 3 3 claimTaskThread, 4 4 releaseTaskThread, 5 + isAgentAsleep, 5 6 } from "../utils/agentContext.ts"; 6 7 import { 7 8 getNow, ··· 35 36 36 37 const now = getNow(); 37 38 38 - if (now.hour >= agentContext.sleepTime) { 39 + if (isAgentAsleep(now.hour)) { 39 40 console.log(`๐Ÿ”น attempting to wind down ${agentContext.agentBskyName}`); 40 41 } else { 41 42 const delay = msUntilDailyWindow(
+2 -1
tasks/sendWakeMessage.ts
··· 2 2 agentContext, 3 3 claimTaskThread, 4 4 releaseTaskThread, 5 + isAgentAwake, 5 6 } from "../utils/agentContext.ts"; 6 7 import { getNow, msFrom, msRandomOffset } from "../utils/time.ts"; 7 8 import { messageAgent } from "../utils/messageAgent.ts"; ··· 31 32 32 33 const now = getNow(); 33 34 34 - if (now.hour >= agentContext.wakeTime && now.hour < agentContext.sleepTime) { 35 + if (isAgentAwake(now.hour)) { 35 36 console.log(`๐Ÿ”น attempting to wake up ${agentContext.agentBskyName}`); 36 37 } else { 37 38 const delay = msUntilDailyWindow(
+111 -8
tools/bluesky/create_bluesky_post.py
··· 68 68 69 69 70 70 def _check_is_self(agent_did: str, target_did: str) -> bool: 71 - """Check 1: Self-Post Check (Free).""" 71 + """Check 2: Self-Post Check (Free).""" 72 72 return agent_did == target_did 73 73 74 74 ··· 126 126 raise 127 127 128 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 + 129 228 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).""" 229 + """Check 5: Thread Participation and Mention Check (Expensive).""" 131 230 try: 132 231 # Fetch the thread 133 232 # depth=100 should be sufficient for most contexts, or we can walk up manually if needed. ··· 193 292 Raises Exception with specific message if consent denied or verification fails. 194 293 """ 195 294 try: 196 - # Check 1: Self-Post 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 197 300 if _check_is_self(agent_did, target_did): 198 301 return True 199 302 200 - # Check 2: Mention Check (Free/Cheap) 303 + # Check 3: Mention Check (Free/Cheap) 201 304 # If the post we are replying to mentions us, we can reply. 202 305 if parent_post_record: 203 306 # Check facets for mention ··· 206 309 for feature in facet.features: 207 310 if hasattr(feature, 'did') and feature.did == agent_did: 208 311 return True 209 - 312 + 210 313 # Fallback: Check text for handle 211 314 if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text: 212 315 return True 213 316 214 - # Check 3: Follow Check 317 + # Check 4: Follow Check 215 318 # Rule: Target must follow agent. 216 - # Rule 3B: If root author is different from target, Root must ALSO follow agent. 319 + # Rule 4B: If root author is different from target, Root must ALSO follow agent. 217 320 218 321 target_follows = _check_follows(client, agent_did, target_did) 219 322 ··· 229 332 ) 230 333 return True 231 334 232 - # Check 4: Thread Participation 335 + # Check 5: Thread Participation 233 336 # This requires fetching the thread (Expensive) 234 337 if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri): 235 338 return True
+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 + });
+32 -23
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 69 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 - 81 70 const getAgentBskyHandle = (): string => { 82 71 const value = Deno.env.get("BSKY_USERNAME")?.trim(); 83 72 ··· 255 244 } 256 245 257 246 return (value / 100) + 1; 247 + }; 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); 258 257 }; 259 258 260 259 const getReflectionDelayMinimum = (): number => { ··· 567 566 mentionCount: 0, 568 567 replyCount: 0, 569 568 quoteCount: 0, 569 + notifCount: 0, 570 570 // required with manual variables 571 - lettaProjectIdentifier: getLettaProjectName(), 571 + lettaProjectIdentifier: getLettaProjectID(), 572 572 agentBskyHandle: getAgentBskyHandle(), 573 573 agentBskyName: await getAgentBskyName(), 574 574 agentBskyDID: setAgentBskyDID(), ··· 590 590 timeZone: getTimeZone(), 591 591 responsiblePartyType: getResponsiblePartyType(), 592 592 preserveAgentMemory: getPreserveMemoryBlocks(), 593 + maxThreadPosts: getMaxThreadPosts(), 593 594 reflectionEnabled: setReflectionEnabled(), 594 595 proactiveEnabled: setProactiveEnabled(), 595 596 sleepEnabled: setSleepEnabled(), ··· 641 642 agentContext.replyCount = 0; 642 643 agentContext.quoteCount = 0; 643 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 + };
+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;