+9
-8
.env.example
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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;