+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
+2
-2
tasks/checkBluesky.ts
+2
-2
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
}
+11
-10
tasks/checkNotifications.ts
+11
-10
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) {
···
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 {
+1
-4
tasks/logStats.ts
+1
-4
tasks/logStats.ts
···
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) {
+3
-5
tasks/logTasks.ts
+3
-5
tasks/logTasks.ts
···
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
+4
-4
tasks/runReflection.ts
+4
-4
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) {
+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();
+111
-8
tools/bluesky/create_bluesky_post.py
+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
+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;