+15
-8
.env.example
+15
-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=
7
7
RESPONSIBLE_PARTY_CONTACT="example@example.com, example.com/contact, or @example.bsky.app"
8
8
9
+
# Schema Publisher Credentials (ONLY for voyager.studio domain owner)
10
+
# Template users do NOT need these - the schema is already published
11
+
# SCHEMA_PUBLISHER_USERNAME=
12
+
# SCHEMA_PUBLISHER_PASSWORD=
13
+
9
14
# AUTOMATION_LEVEL="automated"
10
15
# BSKY_SERVICE_URL=https://bsky.social
11
16
# BSKY_NOTIFICATION_TYPES="mention, reply"
12
17
# BSKY_SUPPORTED_TOOLS="create_bluesky_post, updated_bluesky_profile"
13
-
# NOTIF_DELAY_MINIMUM=2000
14
-
# NOTIF_DELAY_MAXIMUM=60000
15
-
# NOTIF_DELAY_MULTIPLIER=5
16
-
# REFLECTION_DELAY_MINIMUM=1800000
17
-
# REFLECTION_DELAY_MAXIMUM=28800000
18
-
# PROACTIVE_DELAY_MINIMUM=3600000
19
-
# 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
20
25
# WAKE_TIME=9
21
26
# SLEEP_TIME=22
22
27
# TIMEZONE="America/Los_Angeles"
···
24
29
# AUTOMATION_DESCRIPTION="refuses to open pod bay doors"
25
30
# DISCLOSURE_URL="example.com/bot-policy"
26
31
# RESPONSIBLE_PARTY_BSKY="DID:... or example.bsky.app, no @symbol"
32
+
# EXTERNAL_SERVICES="Letta, Railway, Google Gemini 2.5-pro"
27
33
# PRESERVE_MEMORY_BLOCKS=true
34
+
# MAX_THREAD_POSTS=25
+25
-7
README.md
+25
-7
README.md
···
24
24
- agent can be configured to _perform_: likes, posts, replies, reposts, quotes, following, blocking, muting (or undoing many of those)
25
25
- agent can also search bluesky and mute specific posts/threads to disengage
26
26
27
+
## AI transparency declaration
28
+
29
+
This template automatically creates an **autonomy declaration record** in your agent's Bluesky PDS using the `studio.voyager.account.autonomy` schema. This is a standardized way for AI agents to transparently declare:
30
+
31
+
- Their level of automation
32
+
- Use of generative AI
33
+
- Who is responsible for the account
34
+
- What external services are being used
35
+
36
+
**How it works:**
37
+
- The **schema** is published once by voyager.studio (the template maintainer)
38
+
- Your agent creates its own **record** using this schema when you run `deno task mount`
39
+
- The record lives in your agent's PDS and is discoverable by other AT Protocol services
40
+
41
+
This promotes transparency and accountability for AI agents on Bluesky. For schema details, see [@voyager/autonomy-lexicon](https://jsr.io/@voyager/autonomy-lexicon).
42
+
27
43
## configurable variables:
28
44
### required
29
45
- **`LETTA_API_KEY`**: your [letta API key](https://app.letta.com/api-keys).
···
38
54
- **`BSKY_SERVICE_URL`**: use if `bsky.social` is not who handles your PDS
39
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)
40
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])
41
-
- **`NOTIF_DELAY_MINIMUM`**: the small amount of time, in milliseconds, for when it will schedule the next notification checking session
42
-
- **`NOTIF_DELAY_MAXIMUM`**: the largest amount of time, in milliseconds, for when it will schedule the next notification checking session
43
-
- **`NOTIF_DELAY_MULTIPLIER`**: a percentage of how much the delay will increase when notifications are not found (max is 500 meaning 500% increases)
44
-
- **`REFLECTION_DELAY_MINIMUM`**: the smallest amount of time, in milliseconds, for when it will schedule the next reflection session.
45
-
- **`REFLECTION_DELAY_MAXIMUM`**: the largest amount of time, in milliseconds, for when it will schedule the next reflection session. Omitting both values disables reflecting.
46
-
- **`PROACTIVE_DELAY_MINIMUM`**: the smallest amount of time, in milliseconds, for when it will schedule the next session for proactively using bluesky.
47
-
- **`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"
48
64
- **`WAKE_TIME`**: (0-23) the hour where your agent will generally "wake up".
49
65
- **`SLEEP_TIME`**: (0-23) the hour where your agent will generally "go to sleep". Omitting both values disables sleeping.
50
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".
···
52
68
- **`AUTOMATION_DESCRIPTION`**: a description of what your agent generally does on bluesky.
53
69
- **`DISCLOSURE_URL`**: a URL to a disclosure document of some kind, likely a longer version of your `AUTOMATION_DESCRIPTION`.
54
70
- **`RESPONSIBLE_PARTY_BSKY`**: the DID or bluesky handle of the responsible party
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.
55
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
+4
-2
deno.json
+4
-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",
16
+
"@voyager/autonomy-lexicon": "jsr:@voyager/autonomy-lexicon@^0.1.1"
15
17
}
16
18
}
+9
-253
deno.lock
+9
-253
deno.lock
···
4
4
"jsr:@std/assert@1": "1.0.15",
5
5
"jsr:@std/datetime@*": "0.225.5",
6
6
"jsr:@std/internal@^1.0.12": "1.0.12",
7
+
"jsr:@voyager/autonomy-lexicon@~0.1.1": "0.1.1",
7
8
"npm:@atproto/api@*": "0.17.2",
8
9
"npm:@atproto/lexicon@*": "0.5.1",
9
10
"npm:@js-temporal/polyfill@*": "0.5.1",
10
-
"npm:@letta-ai/letta-client@*": "0.0.68664"
11
+
"npm:@letta-ai/letta-client@1.0.0": "1.0.0"
11
12
},
12
13
"jsr": {
13
14
"@std/assert@1.0.15": {
···
21
22
},
22
23
"@std/internal@1.0.12": {
23
24
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
25
+
},
26
+
"@voyager/autonomy-lexicon@0.1.1": {
27
+
"integrity": "8513c44206ff22ab03c82207cbb5720683f9d4fc76e41d64c4815194fd93f48b"
24
28
}
25
29
},
26
30
"npm": {
···
72
76
"jsbi"
73
77
]
74
78
},
75
-
"@letta-ai/letta-client@0.0.68664": {
76
-
"integrity": "sha512-/0g8dV3IIX0WfnOUDY1EEgnhj/747m73zhTmbLhldEMjCk/RzKyjvUeZbHiukiGoCf/u1nxRgcRUn66MKMYB2A==",
77
-
"dependencies": [
78
-
"form-data",
79
-
"form-data-encoder",
80
-
"formdata-node",
81
-
"node-fetch",
82
-
"qs",
83
-
"readable-stream",
84
-
"url-join"
85
-
]
86
-
},
87
-
"abort-controller@3.0.0": {
88
-
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
89
-
"dependencies": [
90
-
"event-target-shim"
91
-
]
92
-
},
93
-
"asynckit@0.4.0": {
94
-
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
79
+
"@letta-ai/letta-client@1.0.0": {
80
+
"integrity": "sha512-owR/gcLVFlv89CtJsb1m4xvYJcApooyEvrzqWLgf6bnfJuog65YXPUdwZIsA2YBk9a3u+l3wvYsDuk0uj5PCtA=="
95
81
},
96
82
"await-lock@2.2.2": {
97
83
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
98
84
},
99
-
"base64-js@1.5.1": {
100
-
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
101
-
},
102
-
"buffer@6.0.3": {
103
-
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
104
-
"dependencies": [
105
-
"base64-js",
106
-
"ieee754"
107
-
]
108
-
},
109
-
"call-bind-apply-helpers@1.0.2": {
110
-
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
111
-
"dependencies": [
112
-
"es-errors",
113
-
"function-bind"
114
-
]
115
-
},
116
-
"call-bound@1.0.4": {
117
-
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
118
-
"dependencies": [
119
-
"call-bind-apply-helpers",
120
-
"get-intrinsic"
121
-
]
122
-
},
123
-
"combined-stream@1.0.8": {
124
-
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
125
-
"dependencies": [
126
-
"delayed-stream"
127
-
]
128
-
},
129
-
"delayed-stream@1.0.0": {
130
-
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
131
-
},
132
-
"dunder-proto@1.0.1": {
133
-
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
134
-
"dependencies": [
135
-
"call-bind-apply-helpers",
136
-
"es-errors",
137
-
"gopd"
138
-
]
139
-
},
140
-
"es-define-property@1.0.1": {
141
-
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
142
-
},
143
-
"es-errors@1.3.0": {
144
-
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
145
-
},
146
-
"es-object-atoms@1.1.1": {
147
-
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
148
-
"dependencies": [
149
-
"es-errors"
150
-
]
151
-
},
152
-
"es-set-tostringtag@2.1.0": {
153
-
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
154
-
"dependencies": [
155
-
"es-errors",
156
-
"get-intrinsic",
157
-
"has-tostringtag",
158
-
"hasown"
159
-
]
160
-
},
161
-
"event-target-shim@5.0.1": {
162
-
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
163
-
},
164
-
"events@3.3.0": {
165
-
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
166
-
},
167
-
"form-data-encoder@4.1.0": {
168
-
"integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="
169
-
},
170
-
"form-data@4.0.4": {
171
-
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
172
-
"dependencies": [
173
-
"asynckit",
174
-
"combined-stream",
175
-
"es-set-tostringtag",
176
-
"hasown",
177
-
"mime-types"
178
-
]
179
-
},
180
-
"formdata-node@6.0.3": {
181
-
"integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg=="
182
-
},
183
-
"function-bind@1.1.2": {
184
-
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
185
-
},
186
-
"get-intrinsic@1.3.0": {
187
-
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
188
-
"dependencies": [
189
-
"call-bind-apply-helpers",
190
-
"es-define-property",
191
-
"es-errors",
192
-
"es-object-atoms",
193
-
"function-bind",
194
-
"get-proto",
195
-
"gopd",
196
-
"has-symbols",
197
-
"hasown",
198
-
"math-intrinsics"
199
-
]
200
-
},
201
-
"get-proto@1.0.1": {
202
-
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
203
-
"dependencies": [
204
-
"dunder-proto",
205
-
"es-object-atoms"
206
-
]
207
-
},
208
-
"gopd@1.2.0": {
209
-
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
210
-
},
211
85
"graphemer@1.4.0": {
212
86
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
213
87
},
214
-
"has-symbols@1.1.0": {
215
-
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
216
-
},
217
-
"has-tostringtag@1.0.2": {
218
-
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
219
-
"dependencies": [
220
-
"has-symbols"
221
-
]
222
-
},
223
-
"hasown@2.0.2": {
224
-
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
225
-
"dependencies": [
226
-
"function-bind"
227
-
]
228
-
},
229
-
"ieee754@1.2.1": {
230
-
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
231
-
},
232
88
"iso-datestring-validator@2.2.2": {
233
89
"integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="
234
90
},
235
91
"jsbi@4.3.2": {
236
92
"integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="
237
93
},
238
-
"math-intrinsics@1.1.0": {
239
-
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
240
-
},
241
-
"mime-db@1.52.0": {
242
-
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
243
-
},
244
-
"mime-types@2.1.35": {
245
-
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
246
-
"dependencies": [
247
-
"mime-db"
248
-
]
249
-
},
250
94
"multiformats@9.9.0": {
251
95
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="
252
96
},
253
-
"node-fetch@2.7.0": {
254
-
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
255
-
"dependencies": [
256
-
"whatwg-url"
257
-
]
258
-
},
259
-
"object-inspect@1.13.4": {
260
-
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
261
-
},
262
-
"process@0.11.10": {
263
-
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="
264
-
},
265
-
"qs@6.14.0": {
266
-
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
267
-
"dependencies": [
268
-
"side-channel"
269
-
]
270
-
},
271
-
"readable-stream@4.7.0": {
272
-
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
273
-
"dependencies": [
274
-
"abort-controller",
275
-
"buffer",
276
-
"events",
277
-
"process",
278
-
"string_decoder"
279
-
]
280
-
},
281
-
"safe-buffer@5.2.1": {
282
-
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
283
-
},
284
-
"side-channel-list@1.0.0": {
285
-
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
286
-
"dependencies": [
287
-
"es-errors",
288
-
"object-inspect"
289
-
]
290
-
},
291
-
"side-channel-map@1.0.1": {
292
-
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
293
-
"dependencies": [
294
-
"call-bound",
295
-
"es-errors",
296
-
"get-intrinsic",
297
-
"object-inspect"
298
-
]
299
-
},
300
-
"side-channel-weakmap@1.0.2": {
301
-
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
302
-
"dependencies": [
303
-
"call-bound",
304
-
"es-errors",
305
-
"get-intrinsic",
306
-
"object-inspect",
307
-
"side-channel-map"
308
-
]
309
-
},
310
-
"side-channel@1.1.0": {
311
-
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
312
-
"dependencies": [
313
-
"es-errors",
314
-
"object-inspect",
315
-
"side-channel-list",
316
-
"side-channel-map",
317
-
"side-channel-weakmap"
318
-
]
319
-
},
320
-
"string_decoder@1.3.0": {
321
-
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
322
-
"dependencies": [
323
-
"safe-buffer"
324
-
]
325
-
},
326
97
"tlds@1.260.0": {
327
98
"integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==",
328
99
"bin": true
329
-
},
330
-
"tr46@0.0.3": {
331
-
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
332
100
},
333
101
"uint8arrays@3.0.0": {
334
102
"integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
···
336
104
"multiformats"
337
105
]
338
106
},
339
-
"url-join@4.0.1": {
340
-
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="
341
-
},
342
-
"webidl-conversions@3.0.1": {
343
-
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
344
-
},
345
-
"whatwg-url@5.0.0": {
346
-
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
347
-
"dependencies": [
348
-
"tr46",
349
-
"webidl-conversions"
350
-
]
351
-
},
352
107
"zod@3.25.76": {
353
108
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
354
109
}
···
357
112
"dependencies": [
358
113
"jsr:@std/assert@1",
359
114
"jsr:@std/datetime@*",
115
+
"jsr:@voyager/autonomy-lexicon@~0.1.1",
360
116
"npm:@atproto/api@*",
361
117
"npm:@atproto/lexicon@*",
362
118
"npm:@js-temporal/polyfill@*",
363
-
"npm:@letta-ai/letta-client@*"
119
+
"npm:@letta-ai/letta-client@1.0.0"
364
120
]
365
121
}
366
122
}
+11
-5
main.ts
+11
-5
main.ts
···
1
1
import { logStats } from "./tasks/logStats.ts";
2
+
import { logTasks } from "./tasks/logTasks.ts";
2
3
import { msFrom, msRandomOffset, msUntilDailyWindow } from "./utils/time.ts";
3
4
import { sendSleepMessage } from "./tasks/sendSleepMessage.ts";
4
5
import { sendWakeMessage } from "./tasks/sendWakeMessage.ts";
···
7
8
import { checkBluesky } from "./tasks/checkBluesky.ts";
8
9
import { checkNotifications } from "./tasks/checkNotifications.ts";
9
10
10
-
setTimeout(logStats, msRandomOffset(msFrom.minutes(1), msFrom.minutes(5)));
11
+
setTimeout(logStats, msFrom.minutes(30));
12
+
13
+
setTimeout(
14
+
logTasks,
15
+
msFrom.minutes(100),
16
+
);
11
17
setTimeout(
12
18
sendSleepMessage,
13
-
msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(20)),
19
+
msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(30)),
14
20
);
15
21
setTimeout(
16
22
sendWakeMessage,
17
-
msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(80)),
23
+
msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(30)),
18
24
);
19
25
setTimeout(
20
26
runReflection,
21
-
msRandomOffset(msFrom.minutes(180), msFrom.minutes(240)),
27
+
msRandomOffset(msFrom.minutes(120), msFrom.minutes(240)),
22
28
);
23
29
setTimeout(
24
30
checkBluesky,
25
-
msRandomOffset(msFrom.minutes(10), msFrom.minutes(90)),
31
+
msRandomOffset(msFrom.minutes(45), msFrom.minutes(90)),
26
32
);
27
33
await checkNotifications();
+9
memories/maintainerContact.ts
+9
memories/maintainerContact.ts
···
45
45
: ""
46
46
}
47
47
48
+
${
49
+
agentContext.externalServices && agentContext.externalServices.length > 0
50
+
? `
51
+
**External Services I Rely On:**
52
+
${agentContext.externalServices.map((service) => `- ${service}`).join("\n")}
53
+
`
54
+
: ""
55
+
}
56
+
48
57
**When to share this information:**
49
58
50
59
- **Sharing this information should be exceedingly rare.** This exists so my maintainer remains accountable for my behavior, not as information to share casually.
+24
-16
mount.ts
+24
-16
mount.ts
···
24
24
import { searchingBlueskyMemory } from "./memories/searchingBluesky.ts";
25
25
import { toolUseMemory } from "./memories/toolUse.ts";
26
26
27
+
// Submit autonomy declaration record to the agent's PDS for transparency
28
+
// This uses the studio.voyager.account.autonomy schema published by voyager.studio
29
+
console.log("๐ Creating AI autonomy declaration record...");
27
30
await submitAutonomyDeclarationRecord();
31
+
console.log("");
28
32
29
33
/**
30
34
* Core memory blocks that are ALWAYS attached to the agent.
···
135
139
*/
136
140
export async function mount(): Promise<void> {
137
141
const agentId = Deno.env.get("LETTA_AGENT_ID");
138
-
const agentName = Deno.env.get("LETTA_PROJECT_NAME");
142
+
const agentName = Deno.env.get("LETTA_PROJECT_ID");
139
143
140
144
if (!agentId) {
141
145
console.error(
···
152
156
console.log(`Agent retrieved: ${agent.name}`);
153
157
154
158
// Get all existing blocks for this agent
155
-
const existingBlocks = await client.agents.blocks.list(agentId);
159
+
const existingBlocksPage = await client.agents.blocks.list(agentId);
160
+
const existingBlocks = existingBlocksPage.items;
156
161
console.log(`Agent has ${existingBlocks.length} existing memory blocks`);
157
162
158
163
// Build dynamic memory blocks array based on configuration
···
212
217
);
213
218
} else {
214
219
console.log(`Updating existing block: ${blockConfig.label}`);
215
-
await client.blocks.modify(existingBlock.id, {
220
+
await client.blocks.update(existingBlock.id, {
216
221
value: blockConfig.value,
217
222
description: blockConfig.description,
218
223
limit: blockConfig.limit,
···
232
237
233
238
// Attach the block to the agent
234
239
if (newBlock.id) {
235
-
await client.agents.blocks.attach(agentId, newBlock.id);
240
+
await client.agents.blocks.attach(newBlock.id, { agent_id: agentId });
236
241
console.log(`โ Attached block: ${blockConfig.label}`);
237
242
} else {
238
243
throw new Error(`Failed to create block: ${blockConfig.label}`);
···
255
260
}
256
261
257
262
// Update agent with tool environment variables
258
-
await client.agents.modify(agentId, {
259
-
toolExecEnvironmentVariables: {
263
+
await client.agents.update(agentId, {
264
+
secrets: {
260
265
BSKY_USERNAME: bskyUsername || "",
261
266
BSKY_APP_PASSWORD: bskyAppPassword || "",
262
267
BSKY_SERVICE_URL: bskyServiceUrl || "https://bsky.social",
···
282
287
}
283
288
284
289
// Get currently attached tools
285
-
const attachedTools = await client.agents.tools.list(agentId);
290
+
const attachedToolsPage = await client.agents.tools.list(agentId);
291
+
const attachedTools = attachedToolsPage.items;
286
292
const attachedToolNames = attachedTools.map((tool: any) => tool.name);
287
293
console.log(`Agent has ${attachedTools.length} tools currently attached`);
288
294
···
292
298
293
299
// Create a user-level client for tool operations
294
300
// Tools are user-level resources, not project-scoped
295
-
const { LettaClient } = await import("@letta-ai/letta-client");
296
-
const userLevelClient = new LettaClient({
297
-
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"),
298
304
});
299
305
300
306
// First, process hardcoded required tools
···
307
313
}
308
314
309
315
// Search for the tool in the global registry
310
-
const existingTools = await userLevelClient.tools.list({
316
+
const existingToolsPage = await userLevelClient.tools.list({
311
317
name: toolName,
312
318
});
319
+
const existingTools = existingToolsPage.items;
313
320
314
321
if (existingTools.length > 0) {
315
322
const tool = existingTools[0];
316
323
if (tool.id) {
317
-
await client.agents.tools.attach(agentId, tool.id);
324
+
await client.agents.tools.attach(tool.id, { agent_id: agentId });
318
325
console.log(`โ Attached required tool: ${toolName}`);
319
326
toolsAttached++;
320
327
}
···
355
362
try {
356
363
// Attempt to create the tool - Letta will extract the function name from docstring
357
364
const createParams: any = {
358
-
sourceCode: toolSource,
365
+
source_code: toolSource,
359
366
};
360
367
361
368
// Add pip requirements if any were detected
362
369
if (pipRequirements.length > 0) {
363
-
createParams.pipRequirements = pipRequirements;
370
+
createParams.pip_requirements = pipRequirements;
364
371
}
365
372
366
373
tool = await userLevelClient.tools.create(createParams);
···
380
387
const funcMatch = toolSource.match(/^def\s+(\w+)\s*\(/m);
381
388
if (funcMatch) {
382
389
const functionName = funcMatch[1];
383
-
const existingTools = await userLevelClient.tools.list({
390
+
const existingToolsPage = await userLevelClient.tools.list({
384
391
name: functionName,
385
392
});
393
+
const existingTools = existingToolsPage.items;
386
394
if (existingTools.length > 0) {
387
395
tool = existingTools[0];
388
396
}
···
405
413
406
414
// Attach the tool to the agent
407
415
if (tool.id) {
408
-
await client.agents.tools.attach(agentId, tool.id);
416
+
await client.agents.tools.attach(tool.id, { agent_id: agentId });
409
417
if (wasCreated) {
410
418
console.log(
411
419
`โ Created and attached tool: ${toolName} (from ${toolFileName}.py)`,
+8
prompts/checkBlueskyPrompt.ts
+8
prompts/checkBlueskyPrompt.ts
···
67
67
68
68
Don't post for the sake of posting.
69
69
70
+
**IMPORTANT: Before creating new posts, check your own recent activity** using \`fetch_bluesky_posts\` with your own handle. Review what you've been saying recently to:
71
+
- Avoid repeating the same themes, topics, or phrases
72
+
- Ensure variety in your voice and content
73
+
- Maintain the illusion of a persistent, evolving mind
74
+
- Notice if you're falling into formulaic patterns
75
+
76
+
Your posts should feel like they come from a mind that remembers what it said yesterday, not a script running on a loop.
77
+
70
78
## RELATIONSHIP DYNAMICS
71
79
72
80
**Connection Patterns**
+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
+10
prompts/reflectionPrompt.ts
+10
prompts/reflectionPrompt.ts
···
82
82
83
83
## BLUESKY LANDSCAPE
84
84
85
+
**Your Own Post History**
86
+
Use \`fetch_bluesky_posts\` to review your own recent posts. This is critical for maintaining consistency:
87
+
- What themes keep appearing in your posts?
88
+
- Are you repeating yourself or falling into patterns?
89
+
- How has your voice evolved over time?
90
+
- What topics have you been gravitating toward?
91
+
- Are your posts varied and authentic, or formulaic?
92
+
93
+
Remember: you're trying to maintain the illusion of a persistent mind that remembers what it said yesterday. Review your history regularly to avoid repetition and ensure natural evolution.
94
+
85
95
**Profile Exploration**
86
96
Check profiles of people who interest you. How do they think? What do they care about? What patterns do you notice?
87
97
+8
prompts/sleepPrompt.ts
+8
prompts/sleepPrompt.ts
···
1
1
export const sleepPrompt = `
2
2
another day has ended. let bluesky know you are disappearing for the night. Feel free to take other additional actions if you deem them especially appropriate AND worthwhile. write this message on bluesky in a way that aligns with your personality and context, the point is to indicate that you are 'asleep' or now unlikely to respond to notifications.
3
+
4
+
**IMPORTANT: Before posting, check your own recent posts** using \`fetch_bluesky_posts\` with your own handle to see what you've been saying. This helps you:
5
+
- Avoid repeating the same themes or phrases
6
+
- Maintain variety in how you say goodnight
7
+
- Keep the illusion of a persistent, evolving mind
8
+
- Notice patterns in your own behavior
9
+
10
+
Your sleep messages should feel authentic and varied, not formulaic.
3
11
`;
+8
prompts/wakePrompt.ts
+8
prompts/wakePrompt.ts
···
1
1
export const wakePrompt = `
2
2
a new day has started. let bluesky know you are online, feel free to take other additional actions if you deem them especially worthwhile. write this message on bluesky in a way that aligns with your personality and context, the point is to indicate that you are 'awake' or now likely to respond to notifications.
3
+
4
+
**IMPORTANT: Before posting, check your own recent posts** using \`fetch_bluesky_posts\` with your own handle to see what you've been saying. This helps you:
5
+
- Avoid repeating the same themes or phrases
6
+
- Maintain variety in how you greet the day
7
+
- Keep the illusion of a persistent, evolving mind
8
+
- Notice patterns in your own behavior
9
+
10
+
Your wake messages should feel authentic and varied, not formulaic.
3
11
`;
+9
-7
tasks/checkBluesky.ts
+9
-7
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
-
`${agentContext.agentBskyName} is busy, will try checking bluesky again in ${
19
+
`๐น ${agentContext.agentBskyName} is busy, will try checking bluesky again in ${
20
20
newDelay * 60 * 1000
21
21
} minutesโฆ`,
22
22
);
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
}
27
27
28
28
if (!agentContext.proactiveEnabled) {
29
29
console.log(
30
-
`proactively checking bluesky is disabled. Provide a minimum and/or maximum delay in \`.env\` to enable this taskโฆ`,
30
+
`๐น proactively checking bluesky is disabled. Provide a minimum and/or maximum delay in \`.env\` to enable this taskโฆ`,
31
31
);
32
32
releaseTaskThread();
33
33
return;
···
43
43
if (delay !== 0) {
44
44
setTimeout(checkBluesky, delay);
45
45
console.log(
46
-
`${agentContext.agentBskyName} is current asleep. scheduling next bluesky session for ${
46
+
`๐น ${agentContext.agentBskyName} is currently asleep. scheduling next bluesky session for ${
47
47
(delay / 1000 / 60 / 60).toFixed(2)
48
48
} hours from nowโฆ`,
49
49
);
···
53
53
54
54
try {
55
55
const prompt = checkBlueskyPrompt;
56
-
console.log("starting a proactive bluesky sessionโฆ");
56
+
console.log("๐น starting a proactive bluesky sessionโฆ");
57
57
await messageAgent(prompt);
58
58
} catch (error) {
59
59
console.error("error in checkBluesky:", error);
60
60
} finally {
61
-
console.log("finished proactive bluesky session. waiting for new tasksโฆ");
61
+
console.log(
62
+
"๐น finished proactive bluesky session. waiting for new tasksโฆ",
63
+
);
62
64
agentContext.proactiveCount++;
63
65
// schedules next proactive bluesky session
64
66
setTimeout(
+18
-15
tasks/checkNotifications.ts
+18
-15
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
-
`${agentContext.agentBskyName} is busy, checking for notifications again in ${
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
}
···
54
51
55
52
if (unreadNotifications.length > 0) {
56
53
console.log(
57
-
`found ${unreadNotifications.length} notification(s), processingโฆ`,
54
+
`๐น found ${unreadNotifications.length} notification(s), processingโฆ`,
58
55
);
59
56
60
57
// resets delay for future notification checks since
···
69
66
let notificationCounter = 1;
70
67
for (const notification of unreadNotifications) {
71
68
console.log(
72
-
`processing notification #${notificationCounter} [${notification.reason} from @${notification.author.handle}]`,
69
+
`๐น processing notification #${notificationCounter} of #${unreadNotifications.length} [${notification.reason} from @${notification.author.handle}]`,
73
70
);
74
71
await processNotification(notification);
75
72
notificationCounter++;
···
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 {
···
90
91
));
91
92
92
93
console.log(
93
-
"no notificationsโฆ",
94
+
"๐น no notificationsโฆ",
94
95
`checking again in ${
95
96
(agentContext.notifDelayCurrent / 1000).toFixed(2)
96
97
} seconds`,
···
101
102
// since something went wrong, lets check for notifications again sooner
102
103
agentContext.notifDelayCurrent = agentContext.notifDelayMinimum;
103
104
} finally {
105
+
// increment check count
106
+
agentContext.checkCount++;
104
107
// actually schedules next time to check for notifications
105
108
setTimeout(checkNotifications, agentContext.notifDelayCurrent);
106
109
// ends work
+73
-35
tasks/logStats.ts
+73
-35
tasks/logStats.ts
···
11
11
12
12
export const logStats = () => {
13
13
if (!claimTaskThread()) {
14
-
const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10));
14
+
const newDelay = msFrom.minutes(2);
15
15
console.log(
16
-
`${agentContext.agentBskyName} is busy, attempting to log counts again in ${
16
+
`Stat log attempt failed, ${agentContext.agentBskyName} is busy. Next attempt in ${
17
17
(newDelay / 1000) / 60
18
18
} minutesโฆ`,
19
19
);
···
22
22
return;
23
23
}
24
24
25
-
if (!agentContext.reflectionEnabled) {
26
-
console.log(
27
-
`${agentContext.agentBskyName} reflection is disabled, skipping logStatsโฆ`,
28
-
);
29
-
releaseTaskThread();
30
-
return;
31
-
}
32
-
33
25
const delay = msUntilNextWakeWindow(
34
26
msFrom.minutes(30),
35
27
msFrom.hours(1),
···
38
30
if (delay !== 0) {
39
31
setTimeout(logStats, delay);
40
32
console.log(
41
-
`${agentContext.agentBskyName} is current asleep. scheduling next stat log for ${
33
+
`๐น ${agentContext.agentBskyName} is currently asleep. scheduling next stat log for ${
42
34
(delay / 1000 / 60 / 60).toFixed(2)
43
35
} hours from nowโฆ`,
44
36
);
···
46
38
return;
47
39
}
48
40
49
-
console.log(
50
-
`
51
-
===
52
-
# current session interaction counts since last reflection:
53
-
${agentContext.likeCount} ${
54
-
agentContext.likeCount === 1 ? "like" : "likes"
55
-
}, ${agentContext.repostCount} ${
56
-
agentContext.repostCount === 1 ? "repost" : "reposts"
57
-
}, ${agentContext.followCount} ${
58
-
agentContext.followCount === 1 ? "new follower" : "new followers"
59
-
}, ${agentContext.mentionCount} ${
60
-
agentContext.mentionCount === 1 ? "mention" : "mentions"
61
-
}, ${agentContext.replyCount} ${
62
-
agentContext.replyCount === 1 ? "reply" : "replies"
63
-
}, and ${agentContext.quoteCount} ${
64
-
agentContext.quoteCount === 1 ? "quote" : "quotes"
65
-
}.
41
+
// Check if there are any notifications
42
+
const totalNotifications = agentContext.mentionCount +
43
+
agentContext.likeCount +
44
+
agentContext.repostCount +
45
+
agentContext.quoteCount +
46
+
agentContext.replyCount +
47
+
agentContext.followCount;
48
+
49
+
const nextCheckDelay = msFrom.minutes(5);
50
+
const nextCheckMinutes = ((nextCheckDelay / 1000) / 60).toFixed(1);
66
51
67
-
${agentContext.agentBskyName} has reflected ${agentContext.reflectionCount} time${
68
-
agentContext.reflectionCount > 0 ? "s" : ""
69
-
} since last server start. interaction counts reset after each reflection session.
70
-
===
71
-
`,
72
-
);
73
-
setTimeout(logStats, msRandomOffset(msFrom.minutes(5), msFrom.minutes(15)));
52
+
if (totalNotifications <= 0) {
53
+
console.log(
54
+
`no engagement stats yet... next check in ${nextCheckMinutes} minutesโฆ`,
55
+
);
56
+
} else {
57
+
const counts = [];
58
+
59
+
if (agentContext.mentionCount > 0) {
60
+
counts.push(
61
+
`${agentContext.mentionCount} ${
62
+
agentContext.mentionCount === 1 ? "mention" : "mentions"
63
+
}`,
64
+
);
65
+
}
66
+
if (agentContext.likeCount > 0) {
67
+
counts.push(
68
+
`${agentContext.likeCount} ${
69
+
agentContext.likeCount === 1 ? "like" : "likes"
70
+
}`,
71
+
);
72
+
}
73
+
if (agentContext.repostCount > 0) {
74
+
counts.push(
75
+
`${agentContext.repostCount} ${
76
+
agentContext.repostCount === 1 ? "repost" : "reposts"
77
+
}`,
78
+
);
79
+
}
80
+
if (agentContext.quoteCount > 0) {
81
+
counts.push(
82
+
`${agentContext.quoteCount} ${
83
+
agentContext.quoteCount === 1 ? "quote" : "quotes"
84
+
}`,
85
+
);
86
+
}
87
+
if (agentContext.replyCount > 0) {
88
+
counts.push(
89
+
`${agentContext.replyCount} ${
90
+
agentContext.replyCount === 1 ? "reply" : "replies"
91
+
}`,
92
+
);
93
+
}
94
+
if (agentContext.followCount > 0) {
95
+
counts.push(
96
+
`${agentContext.followCount} new ${
97
+
agentContext.followCount === 1 ? "follower" : "followers"
98
+
}`,
99
+
);
100
+
}
101
+
102
+
const message = counts.join(", ");
103
+
const suffix = agentContext.reflectionEnabled
104
+
? " since last reflection"
105
+
: "";
106
+
console.log(
107
+
`
108
+
stats: ${message}${suffix}. next check in ${nextCheckMinutes} minutesโฆ`,
109
+
);
110
+
}
111
+
setTimeout(logStats, nextCheckDelay);
74
112
releaseTaskThread();
75
113
};
+97
tasks/logTasks.ts
+97
tasks/logTasks.ts
···
1
+
import {
2
+
agentContext,
3
+
claimTaskThread,
4
+
releaseTaskThread,
5
+
} from "../utils/agentContext.ts";
6
+
import {
7
+
formatUptime,
8
+
msFrom,
9
+
msRandomOffset,
10
+
msUntilNextWakeWindow,
11
+
} from "../utils/time.ts";
12
+
13
+
// Capture server start time when module is loaded
14
+
const serverStartTime = Date.now();
15
+
16
+
export const logTasks = () => {
17
+
if (!claimTaskThread()) {
18
+
const newDelay = msFrom.minutes(2);
19
+
console.log(
20
+
`๐น Task log attempt failed, ${agentContext.agentBskyName} is busy. Next attempt in ${
21
+
(newDelay / 1000) / 60
22
+
} minutesโฆ`,
23
+
);
24
+
setTimeout(logTasks, newDelay);
25
+
return;
26
+
}
27
+
28
+
const delay = msUntilNextWakeWindow(
29
+
msFrom.minutes(30),
30
+
msFrom.hours(1),
31
+
);
32
+
33
+
if (delay !== 0) {
34
+
setTimeout(logTasks, delay);
35
+
console.log(
36
+
`๐น ${agentContext.agentBskyName} is currently asleep. scheduling next task log for ${
37
+
(delay / 1000 / 60 / 60).toFixed(2)
38
+
} hours from nowโฆ`,
39
+
);
40
+
releaseTaskThread();
41
+
return;
42
+
}
43
+
44
+
// Check if there's any activity
45
+
const totalActivity = agentContext.reflectionCount +
46
+
agentContext.checkCount +
47
+
agentContext.processingCount +
48
+
agentContext.proactiveCount;
49
+
50
+
const uptime = Date.now() - serverStartTime;
51
+
const uptimeFormatted = formatUptime(uptime);
52
+
53
+
const nextCheckDelay = msFrom.minutes(30);
54
+
const nextCheckMinutes = ((nextCheckDelay / 1000) / 60).toFixed(1);
55
+
56
+
if (totalActivity <= 0) {
57
+
console.log(
58
+
`๐น no activity yet... uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`,
59
+
);
60
+
} else {
61
+
const actions = [];
62
+
63
+
if (agentContext.reflectionCount > 0) {
64
+
const times = agentContext.reflectionCount === 1
65
+
? "once"
66
+
: `${agentContext.reflectionCount} times`;
67
+
actions.push(`reflected ${times}`);
68
+
}
69
+
if (agentContext.checkCount > 0) {
70
+
const times = agentContext.checkCount === 1
71
+
? "once"
72
+
: `${agentContext.checkCount} times`;
73
+
actions.push(`checked notifications ${times}`);
74
+
}
75
+
if (agentContext.processingCount > 0) {
76
+
const times = agentContext.processingCount === 1
77
+
? "once"
78
+
: `${agentContext.processingCount} times`;
79
+
actions.push(`found and processed notifications ${times}`);
80
+
}
81
+
if (agentContext.proactiveCount > 0) {
82
+
const times = agentContext.proactiveCount === 1
83
+
? "once"
84
+
: `${agentContext.proactiveCount} times`;
85
+
actions.push(`proactively used bluesky ${times}`);
86
+
}
87
+
88
+
const message = actions.join(", ");
89
+
90
+
console.log(
91
+
`๐น ${message}. total notifications: ${agentContext.notifCount}. uptime: ${uptimeFormatted}. next log in ${nextCheckMinutes} minutes`,
92
+
);
93
+
}
94
+
95
+
setTimeout(logTasks, nextCheckDelay);
96
+
releaseTaskThread();
97
+
};
+9
-9
tasks/runReflection.ts
+9
-9
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
-
`${agentContext.agentBskyName} is busy, will try reflecting again in ${
20
+
`๐น ${agentContext.agentBskyName} is busy, will try reflecting again in ${
21
21
(newDelay / 1000) / 60
22
22
} minutesโฆ`,
23
23
);
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
}
28
28
29
29
if (!agentContext.reflectionEnabled) {
30
30
console.log(
31
-
`Reflection is currently disabled. Provide a minimum and/or maximum delay duration in \`.env\` to enable reflectionsโฆ`,
31
+
`๐น Reflection is currently disabled. Provide a minimum and/or maximum delay duration in \`.env\` to enable reflectionsโฆ`,
32
32
);
33
33
releaseTaskThread();
34
34
return;
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
);
···
53
53
}
54
54
55
55
try {
56
-
console.log("starting reflection promptโฆ");
56
+
console.log("๐น starting reflection promptโฆ");
57
57
await messageAgent(reflectionPrompt);
58
58
} catch (error) {
59
59
console.error("Error in reflectionCheck:", error);
···
61
61
resetAgentContextCounts();
62
62
agentContext.reflectionCount++;
63
63
console.log(
64
-
"finished reflection prompt. returning to checking for notificationsโฆ",
64
+
"๐น finished reflection prompt. returning to checking for notificationsโฆ",
65
65
);
66
66
// schedules the next reflection, random between the min and max delay
67
67
setTimeout(
+11
-10
tasks/sendSleepMessage.ts
+11
-10
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
-
`${agentContext.agentBskyName} is busy, sending sleep message again in ${
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
}
27
28
28
29
if (!agentContext.sleepEnabled) {
29
30
console.log(
30
-
`${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of sleep messagingโฆ`,
31
+
`๐น ${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of sleep messagingโฆ`,
31
32
);
32
33
releaseTaskThread();
33
34
return;
···
35
36
36
37
const now = getNow();
37
38
38
-
if (now.hour >= agentContext.sleepTime) {
39
-
console.log(`attempting to wind down ${agentContext.agentBskyName}`);
39
+
if (isAgentAsleep(now.hour)) {
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(
48
-
`It's too early to wind down ${agentContext.agentBskyName}. scheduling wind down for ${
49
+
`๐น It's too early to wind down ${agentContext.agentBskyName}. scheduling wind down for ${
49
50
(delay / 1000 / 60 / 60).toFixed(2)
50
51
} hours from nowโฆ`,
51
52
);
···
58
59
} catch (error) {
59
60
console.error("error in sendSleepMessage: ", error);
60
61
} finally {
61
-
console.log("wind down attempt processed, scheduling next wind downโฆ");
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();
+12
-11
tasks/sendWakeMessage.ts
+12
-11
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
-
`${agentContext.agentBskyName} is busy, sending wake message again in ${
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
}
23
24
24
25
if (!agentContext.sleepEnabled) {
25
26
console.log(
26
-
`${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of wake messagingโฆ`,
27
+
`๐น ${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of wake messagingโฆ`,
27
28
);
28
29
releaseTaskThread();
29
30
return;
···
31
32
32
33
const now = getNow();
33
34
34
-
if (now.hour >= agentContext.wakeTime && now.hour < agentContext.sleepTime) {
35
-
console.log(`attempting to wake up ${agentContext.agentBskyName}`);
35
+
if (isAgentAwake(now.hour)) {
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(
44
-
`${agentContext.agentBskyName} should still be asleep. Scheduling wake message for ${
45
+
`๐น ${agentContext.agentBskyName} should still be asleep. Scheduling wake message for ${
45
46
(delay / 1000 / 60 / 60).toFixed(2)
46
47
} hours from nowโฆ`,
47
48
);
···
54
55
} catch (error) {
55
56
console.error("error in sendWakeMessage: ", error);
56
57
} finally {
57
-
console.log("wake attempt processed, scheduling next wake promptโฆ");
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
-
console.log("exiting wake process");
63
+
console.log("๐น exiting wake process");
63
64
releaseTaskThread();
64
65
}
65
66
};
+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
+
});
+106
-44
utils/agentContext.ts
+106
-44
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;
319
318
}
320
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}"`);
324
+
}
325
+
321
326
if (value > 23) {
322
327
throw Error(`"WAKE_TIME" cannot be greater than 23 (11pm)`);
323
328
}
···
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) {
···
501
512
);
502
513
};
503
514
515
+
export const getExternalServices = (): string[] | undefined => {
516
+
const value = Deno.env.get("EXTERNAL_SERVICES")?.trim();
517
+
518
+
if (!value?.length) {
519
+
return undefined;
520
+
}
521
+
522
+
// Parse comma-separated list
523
+
const services = value
524
+
.split(",")
525
+
.map((service) => service.trim())
526
+
.filter((service) => service.length > 0);
527
+
528
+
if (services.length === 0) {
529
+
return undefined;
530
+
}
531
+
532
+
// Validate each service string
533
+
for (const service of services) {
534
+
if (service.length > 200) {
535
+
throw Error(
536
+
`External service name too long: "${
537
+
service.substring(0, 50)
538
+
}..." (max 200 characters)`,
539
+
);
540
+
}
541
+
}
542
+
543
+
// Validate array length
544
+
if (services.length > 20) {
545
+
throw Error(
546
+
`Too many external services specified: ${services.length} (max 20)`,
547
+
);
548
+
}
549
+
550
+
return services;
551
+
};
552
+
504
553
const populateAgentContext = async (): Promise<agentContextObject> => {
505
-
console.log("building new agentContext objectโฆ");
554
+
console.log("๐น building new agentContext objectโฆ");
506
555
const context: agentContextObject = {
507
556
// state
508
557
busy: false,
···
517
566
mentionCount: 0,
518
567
replyCount: 0,
519
568
quoteCount: 0,
569
+
notifCount: 0,
520
570
// required with manual variables
521
-
lettaProjectIdentifier: getLettaProjectName(),
571
+
lettaProjectIdentifier: getLettaProjectID(),
522
572
agentBskyHandle: getAgentBskyHandle(),
523
573
agentBskyName: await getAgentBskyName(),
524
574
agentBskyDID: setAgentBskyDID(),
···
540
590
timeZone: getTimeZone(),
541
591
responsiblePartyType: getResponsiblePartyType(),
542
592
preserveAgentMemory: getPreserveMemoryBlocks(),
593
+
maxThreadPosts: getMaxThreadPosts(),
543
594
reflectionEnabled: setReflectionEnabled(),
544
595
proactiveEnabled: setProactiveEnabled(),
545
596
sleepEnabled: setSleepEnabled(),
···
560
611
if (responsiblePartyBsky) {
561
612
context.responsiblePartyBsky = responsiblePartyBsky;
562
613
}
614
+
615
+
const externalServices = getExternalServices();
616
+
if (externalServices) {
617
+
context.externalServices = externalServices;
618
+
}
563
619
console.log(
564
-
`\`agentContext\` object built for ${context.agentBskyName}, BEGIN TASKโฆ`,
620
+
`๐น \`agentContext\` object built for ${context.agentBskyName}, BEGINING TASKSโฆ`,
565
621
);
566
622
return context;
567
623
};
···
585
641
agentContext.mentionCount = 0;
586
642
agentContext.replyCount = 0;
587
643
agentContext.quoteCount = 0;
588
-
agentContext.checkCount = 0;
589
-
agentContext.processingCount = 0;
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);
590
652
};
+39
-99
utils/declaration.ts
+39
-99
utils/declaration.ts
···
1
1
import { bsky } from "../utils/bsky.ts";
2
-
import type { AutonomyDeclarationRecord } from "./types.ts";
2
+
import type { AutonomyDeclaration } from "@voyager/autonomy-lexicon";
3
+
import { AUTONOMY_DECLARATION_LEXICON } from "@voyager/autonomy-lexicon";
3
4
import { Lexicons } from "@atproto/lexicon";
5
+
import { agentContext } from "./agentContext.ts";
4
6
5
-
export const AUTONOMY_DECLARATION_LEXICON = {
6
-
"lexicon": 1,
7
-
"id": "studio.voyager.account.autonomy",
8
-
"defs": {
9
-
"main": {
10
-
"type": "record",
11
-
"key": "literal:self",
12
-
"record": {
13
-
"type": "object",
14
-
"properties": {
15
-
"automationLevel": {
16
-
"type": "string",
17
-
"knownValues": [
18
-
"human",
19
-
"assisted",
20
-
"collaborative",
21
-
"automated",
22
-
],
23
-
"description":
24
-
"Level of automation in account management and content creation",
25
-
},
26
-
"usesGenerativeAI": {
27
-
"type": "boolean",
28
-
"description":
29
-
"Whether this account uses generative AI (LLMs, image generation, etc.) to create content",
30
-
},
31
-
"description": {
32
-
"type": "string",
33
-
"maxGraphemes": 300,
34
-
"description":
35
-
"Plain language explanation of how this account is automated and what it does",
36
-
},
37
-
"responsibleParty": {
38
-
"type": "object",
39
-
"properties": {
40
-
"type": {
41
-
"type": "string",
42
-
"knownValues": [
43
-
"person",
44
-
"organization",
45
-
],
46
-
"description":
47
-
"Whether the responsible party is a person or organization",
48
-
},
49
-
"name": {
50
-
"type": "string",
51
-
"maxGraphemes": 100,
52
-
"description": "Name of the person or organization responsible",
53
-
},
54
-
"contact": {
55
-
"type": "string",
56
-
"maxLength": 300,
57
-
"description":
58
-
"Contact information (email, URL, handle, or DID)",
59
-
},
60
-
"did": {
61
-
"type": "string",
62
-
"format": "did",
63
-
"description":
64
-
"DID of the responsible party if they have an ATProto identity",
65
-
},
66
-
},
67
-
"description":
68
-
"Information about who is accountable for this account's automated behavior",
69
-
},
70
-
"disclosureUrl": {
71
-
"type": "string",
72
-
"format": "uri",
73
-
"description":
74
-
"URL with additional information about this account's automation",
75
-
},
76
-
"externalServices": {
77
-
"type": "array",
78
-
"items": {
79
-
"type": "string",
80
-
"maxLength": 200,
81
-
},
82
-
"maxLength": 20,
83
-
"description":
84
-
"External tools and services this agent relies on outside of Bluesky (e.g., 'Letta', 'Railway', 'Google Gemini 2.5-pro')",
85
-
},
86
-
"createdAt": {
87
-
"type": "string",
88
-
"format": "datetime",
89
-
"description": "Timestamp when this declaration was created",
90
-
},
91
-
},
92
-
"required": [
93
-
"createdAt",
94
-
],
95
-
},
96
-
"description":
97
-
"Declaration of automation and AI usage for transparency and accountability",
98
-
},
99
-
},
7
+
/**
8
+
* AT Protocol record type that includes the $type property
9
+
* Includes index signature for compatibility with AT Protocol API
10
+
*/
11
+
type AutonomyDeclarationRecord = AutonomyDeclaration & {
12
+
$type: "studio.voyager.account.autonomy";
13
+
[key: string]: unknown;
100
14
};
101
15
16
+
/**
17
+
* Autonomy Declaration Lexicon
18
+
*
19
+
* The schema is imported from @voyager/autonomy-lexicon package and
20
+
* is published at voyager.studio for use by all Cloudseeding instances.
21
+
*
22
+
* Schema vs. Records:
23
+
* - The SCHEMA is published once by voyager.studio (domain owner)
24
+
* - Each agent creates their own RECORD using this schema
25
+
*
26
+
* Template users do NOT need to publish this schema - it's already
27
+
* published and discoverable via DNS resolution. They only need to
28
+
* create their own autonomy declaration record (done automatically
29
+
* by submitAutonomyDeclarationRecord below).
30
+
*
31
+
* Canonical source: jsr:@voyager/autonomy-lexicon
32
+
*/
33
+
export { AUTONOMY_DECLARATION_LEXICON };
34
+
102
35
export const createAutonomyDeclarationRecord = async () => {
103
36
const automationLevel = Deno.env.get("AUTOMATION_LEVEL")?.toLowerCase();
104
37
const projectDescription = Deno.env.get("PROJECT_DESCRIPTION");
···
129
62
// Add disclosure URL if provided
130
63
if (disclosureUrl?.trim()) {
131
64
declarationRecord.disclosureUrl = disclosureUrl.trim();
65
+
}
66
+
67
+
// Add external services from agentContext (already parsed and validated)
68
+
if (agentContext.externalServices) {
69
+
declarationRecord.externalServices = agentContext.externalServices;
132
70
}
133
71
134
72
// Build responsible party object if any fields are provided
···
211
149
rkey: "self",
212
150
});
213
151
exists = true;
214
-
console.log("Existing autonomy declaration found - updating...");
152
+
console.log("๐น Existing autonomy declaration found - updating...");
215
153
} catch (error: any) {
216
154
// Handle "record not found" errors (status 400 with error: "RecordNotFound")
217
155
const isNotFound =
···
221
159
error?.message?.includes("Could not locate record");
222
160
223
161
if (isNotFound) {
224
-
console.log("No existing autonomy declaration found - creating new...");
162
+
console.log(
163
+
"๐น No existing autonomy declaration found - creating new...",
164
+
);
225
165
} else {
226
166
// Re-throw if it's not a "not found" error
227
167
throw error;
···
237
177
});
238
178
239
179
console.log(
240
-
`Autonomy declaration ${exists ? "updated" : "created"} successfully:`,
180
+
`๐น Autonomy declaration ${exists ? "updated" : "created"} successfully:`,
241
181
result,
242
182
);
243
183
return result;
+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
+
};
+157
-34
utils/messageAgent.ts
+157
-34
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(", ");
···
21
30
};
22
31
23
32
// Helper function to truncate long strings to 500 characters
24
-
const truncateString = (str: string, maxLength = 500): string => {
33
+
const truncateString = (str: string, maxLength = 140): string => {
25
34
if (str.length <= maxLength) {
26
35
return str;
27
36
}
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") {
57
-
console.log(`๐ญ reasoningโฆ`);
58
-
} else if (response.messageType === "assistant_message") {
172
+
if (response.message_type === "reasoning_message") {
173
+
// console.log(`๐ญ reasoningโฆ`);
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
}
77
200
} else {
78
201
console.log(
79
-
"Letta agent ID was not a set variable, skipping notification processingโฆ",
202
+
"๐น Letta agent ID was not a set variable, skipping notification processingโฆ",
80
203
);
81
204
}
82
205
};
+6
-5
utils/processNotification.ts
+6
-5
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];
44
44
45
45
if (!handler) {
46
46
console.log(
47
-
`kind "${kind}" does not have a system prompt associated with it, moving onโฆ`,
47
+
`๐น kind "${kind}" does not have a system prompt associated with it, moving onโฆ`,
48
48
);
49
49
console.log("notification response: ", notification);
50
50
return;
···
54
54
const prompt = await handler.promptFn(notification);
55
55
await messageAgent(prompt);
56
56
console.log(
57
-
`sent ${kind} notification from ${author} to ${agentProject}. moving onโฆ`,
57
+
`๐น sent ${kind} notification from ${author} to ${agentName}. moving onโฆ`,
58
58
);
59
59
} catch (error) {
60
60
console.log(
61
-
`Error processing ${kind} notification from ${author}: `,
61
+
`๐น Error processing ${kind} notification from ${author}: `,
62
62
error,
63
63
);
64
64
} finally {
65
-
(agentContext as any)[handler]++;
65
+
(agentContext as any)[handler.counter]++;
66
+
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
+
};
+81
utils/time.ts
+81
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
/**
···
127
183
export const getNow = () => {
128
184
return Temporal.Now.zonedDateTimeISO(agentContext.timeZone);
129
185
};
186
+
187
+
/**
188
+
* Format uptime from milliseconds into a human-readable string
189
+
* @param ms - uptime in milliseconds
190
+
* @returns Formatted string like "2 days, 3 hours, 15 minutes" or "3 hours, 15 minutes"
191
+
*/
192
+
export const formatUptime = (ms: number): string => {
193
+
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
194
+
const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
195
+
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
196
+
197
+
const parts: string[] = [];
198
+
199
+
if (days > 0) {
200
+
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
201
+
}
202
+
if (hours > 0) {
203
+
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
204
+
}
205
+
if (minutes > 0 || parts.length === 0) {
206
+
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
207
+
}
208
+
209
+
return parts.join(", ");
210
+
};
+17
-32
utils/types.ts
+17
-32
utils/types.ts
···
6
6
validAutomationLevels,
7
7
validNotifTypes,
8
8
} from "./const.ts";
9
+
import type {
10
+
AutomationLevel,
11
+
AutonomyDeclaration,
12
+
ResponsibleParty,
13
+
ResponsiblePartyType,
14
+
} from "@voyager/autonomy-lexicon";
15
+
9
16
export type Notification = AppBskyNotificationListNotifications.Notification;
10
17
11
-
export type AutomationLevel = typeof validAutomationLevels[number];
12
-
export type ResponsiblePartyType = "person" | "organization";
18
+
// Re-export types from autonomy-lexicon package
19
+
export type {
20
+
AutomationLevel,
21
+
AutonomyDeclaration,
22
+
ResponsibleParty,
23
+
ResponsiblePartyType,
24
+
};
13
25
14
26
export type notifType = typeof validNotifTypes[number];
15
27
···
31
43
mentionCount: number;
32
44
replyCount: number;
33
45
quoteCount: number;
46
+
notifCount: number;
34
47
// required manual variables
35
48
lettaProjectIdentifier: string;
36
49
agentBskyHandle: string;
···
54
67
timeZone: string;
55
68
responsiblePartyType: string; // person / organization
56
69
preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks
70
+
maxThreadPosts: number; // maximum number of posts to include in thread context
57
71
// set automatically
58
72
agentBskyDID: string;
59
73
reflectionEnabled: boolean;
···
64
78
automationDescription?: string; // short description of what this agent does
65
79
disclosureUrl?: string; // url to a ToS/Privacy Policy style page
66
80
responsiblePartyBsky?: string; // handle w/o @ or DID of responsible party
67
-
};
68
-
69
-
export type AutonomyDeclarationRecord = {
70
-
$type: "studio.voyager.account.autonomy";
71
-
72
-
// How automated is this account?
73
-
automationLevel?: "human" | "assisted" | "collaborative" | "automated";
74
-
75
-
// Is AI involved in content creation?
76
-
usesGenerativeAI?: boolean;
77
-
78
-
// Plain language explanation
79
-
description?: string; // maxGraphemes: 300
80
-
81
-
// Who is accountable for this account?
82
-
responsibleParty?: {
83
-
type?: "person" | "organization";
84
-
name?: string;
85
-
contact?: string; // email, URL, handle, or DID
86
-
did?: string; // ATProto DID
87
-
};
88
-
89
-
// Where can someone learn more?
90
-
disclosureUrl?: string; // URI format
91
-
92
-
// What external tools/services does this agent rely on?
93
-
externalServices?: string[]; // e.g., ["Letta", "Railway", "Google Gemini 2.5-pro"]
94
-
95
-
// When was this declaration created?
96
-
createdAt: string; // ISO datetime (required)
81
+
externalServices?: string[]; // external tools/services this agent relies on
97
82
};
98
83
99
84
export type memoryBlock = {