+2
-1
.env.example
+2
-1
.env.example
···
1
LETTA_API_KEY=
2
LETTA_AGENT_ID=
3
+
LETTA_PROJECT_ID=
4
BSKY_USERNAME=
5
BSKY_APP_PASSWORD=
6
RESPONSIBLE_PARTY_NAME=
···
31
# RESPONSIBLE_PARTY_BSKY="DID:... or example.bsky.app, no @symbol"
32
# EXTERNAL_SERVICES="Letta, Railway, Google Gemini 2.5-pro"
33
# PRESERVE_MEMORY_BLOCKS=true
34
+
# MAX_THREAD_POSTS=25
+1
README.md
+1
README.md
···
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.
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.
···
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.
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
+2
-1
deno.json
+2
-1
deno.json
···
3
"config": "deno run --allow-read --allow-write setup.ts",
4
"mount": "deno run --allow-net --allow-env --allow-read --env mount.ts",
5
"watch": "deno run --allow-net --allow-env --env --watch main.ts",
6
-
"start": "deno run --allow-net --allow-env --env main.ts"
7
},
8
"imports": {
9
"@std/assert": "jsr:@std/assert@1",
···
3
"config": "deno run --allow-read --allow-write setup.ts",
4
"mount": "deno run --allow-net --allow-env --allow-read --env mount.ts",
5
"watch": "deno run --allow-net --allow-env --env --watch main.ts",
6
+
"start": "deno run --allow-net --allow-env --env main.ts",
7
+
"test": "deno test"
8
},
9
"imports": {
10
"@std/assert": "jsr:@std/assert@1",
+11
-7
prompts/quotePrompt.ts
+11
-7
prompts/quotePrompt.ts
···
1
import { Notification } from "../utils/types.ts";
2
import { doesUserFollowTarget } from "../utils/doesUserFollow.ts";
3
import { agentContext } from "../utils/agentContext.ts";
4
-
import { getCleanThread } from "../utils/getCleanThread.ts";
5
6
export const quotePrompt = async (notification: Notification) => {
7
const isUserFollower = await doesUserFollowTarget(
···
60
quotes: undefined,
61
}];
62
63
return `
64
# NOTIFICATION: Someone quoted your post
65
···
75
76
## Your Original Post
77
\`\`\`
78
-
${originalThread[originalThread.length - 1].message}
79
\`\`\`
80
81
## The Quote Post from @${notification.author.handle}
82
\`\`\`
83
-
${quotePostThread[quotePostThread.length - 1].message}
84
\`\`\`
85
86
## 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
92
${
93
originalThread
···
1
import { Notification } from "../utils/types.ts";
2
import { doesUserFollowTarget } from "../utils/doesUserFollow.ts";
3
import { agentContext } from "../utils/agentContext.ts";
4
+
import { getCleanThread, isThreadPost } from "../utils/getCleanThread.ts";
5
6
export const quotePrompt = async (notification: Notification) => {
7
const isUserFollower = await doesUserFollowTarget(
···
60
quotes: undefined,
61
}];
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
+
67
return `
68
# NOTIFICATION: Someone quoted your post
69
···
79
80
## Your Original Post
81
\`\`\`
82
+
${lastOriginalPost.message}
83
\`\`\`
84
85
## The Quote Post from @${notification.author.handle}
86
\`\`\`
87
+
${lastQuotePost.message}
88
\`\`\`
89
90
## Quote Post Engagement
91
+
โข **Likes:** ${lastQuotePost.likes}
92
+
โข **Replies:** ${lastQuotePost.replies}
93
+
โข **Reposts:** ${lastQuotePost.reposts}
94
+
โข **Quotes:** ${lastQuotePost.quotes}
95
96
${
97
originalThread
+7
-6
tasks/checkNotifications.ts
+7
-6
tasks/checkNotifications.ts
···
3
claimTaskThread,
4
releaseTaskThread,
5
} from "../utils/agentContext.ts";
6
-
import {
7
-
msFrom,
8
-
msRandomOffset,
9
-
msUntilNextWakeWindow,
10
-
} from "../utils/time.ts";
11
import { bsky } from "../utils/bsky.ts";
12
import { processNotification } from "../utils/processNotification.ts";
13
···
36
(delay / 1000 / 60 / 60).toFixed(2)
37
} hours from nowโฆ`,
38
);
39
releaseTaskThread();
40
return;
41
}
···
78
// marks all notifications that were processed as seen
79
// based on time from when retrieved instead of finished
80
await bsky.updateSeenNotifications(startedProcessingTime);
81
-
82
// increases counter for notification processing session
83
agentContext.processingCount++;
84
} else {
···
3
claimTaskThread,
4
releaseTaskThread,
5
} from "../utils/agentContext.ts";
6
+
import { msFrom, msUntilNextWakeWindow } from "../utils/time.ts";
7
import { bsky } from "../utils/bsky.ts";
8
import { processNotification } from "../utils/processNotification.ts";
9
···
32
(delay / 1000 / 60 / 60).toFixed(2)
33
} hours from nowโฆ`,
34
);
35
+
agentContext.notifDelayCurrent = agentContext.notifDelayMinimum;
36
releaseTaskThread();
37
return;
38
}
···
75
// marks all notifications that were processed as seen
76
// based on time from when retrieved instead of finished
77
await bsky.updateSeenNotifications(startedProcessingTime);
78
+
console.log(
79
+
`๐น done processing ${unreadNotifications.length} notification${
80
+
unreadNotifications.length > 1 ? "s" : ""
81
+
}โฆ`,
82
+
);
83
// increases counter for notification processing session
84
agentContext.processingCount++;
85
} else {
+2
-1
tasks/logTasks.ts
+2
-1
tasks/logTasks.ts
+2
-1
tasks/sendSleepMessage.ts
+2
-1
tasks/sendSleepMessage.ts
···
2
agentContext,
3
claimTaskThread,
4
releaseTaskThread,
5
} from "../utils/agentContext.ts";
6
import {
7
getNow,
···
35
36
const now = getNow();
37
38
-
if (now.hour >= agentContext.sleepTime) {
39
console.log(`๐น attempting to wind down ${agentContext.agentBskyName}`);
40
} else {
41
const delay = msUntilDailyWindow(
···
2
agentContext,
3
claimTaskThread,
4
releaseTaskThread,
5
+
isAgentAsleep,
6
} from "../utils/agentContext.ts";
7
import {
8
getNow,
···
36
37
const now = getNow();
38
39
+
if (isAgentAsleep(now.hour)) {
40
console.log(`๐น attempting to wind down ${agentContext.agentBskyName}`);
41
} else {
42
const delay = msUntilDailyWindow(
+2
-1
tasks/sendWakeMessage.ts
+2
-1
tasks/sendWakeMessage.ts
···
2
agentContext,
3
claimTaskThread,
4
releaseTaskThread,
5
} from "../utils/agentContext.ts";
6
import { getNow, msFrom, msRandomOffset } from "../utils/time.ts";
7
import { messageAgent } from "../utils/messageAgent.ts";
···
31
32
const now = getNow();
33
34
-
if (now.hour >= agentContext.wakeTime && now.hour < agentContext.sleepTime) {
35
console.log(`๐น attempting to wake up ${agentContext.agentBskyName}`);
36
} else {
37
const delay = msUntilDailyWindow(
···
2
agentContext,
3
claimTaskThread,
4
releaseTaskThread,
5
+
isAgentAwake,
6
} from "../utils/agentContext.ts";
7
import { getNow, msFrom, msRandomOffset } from "../utils/time.ts";
8
import { messageAgent } from "../utils/messageAgent.ts";
···
32
33
const now = getNow();
34
35
+
if (isAgentAwake(now.hour)) {
36
console.log(`๐น attempting to wake up ${agentContext.agentBskyName}`);
37
} else {
38
const delay = msUntilDailyWindow(
+111
-8
tools/bluesky/create_bluesky_post.py
+111
-8
tools/bluesky/create_bluesky_post.py
···
68
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
···
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.
···
193
Raises Exception with specific message if consent denied or verification fails.
194
"""
195
try:
196
-
# Check 1: Self-Post
197
if _check_is_self(agent_did, target_did):
198
return True
199
200
-
# Check 2: Mention Check (Free/Cheap)
201
# If the post we are replying to mentions us, we can reply.
202
if parent_post_record:
203
# Check facets for mention
···
206
for feature in facet.features:
207
if hasattr(feature, 'did') and feature.did == agent_did:
208
return True
209
-
210
# Fallback: Check text for handle
211
if hasattr(parent_post_record, 'text') and f"@{agent_handle}" in parent_post_record.text:
212
return True
213
214
-
# Check 3: Follow Check
215
# Rule: Target must follow agent.
216
-
# Rule 3B: If root author is different from target, Root must ALSO follow agent.
217
218
target_follows = _check_follows(client, agent_did, target_did)
219
···
229
)
230
return True
231
232
-
# Check 4: Thread Participation
233
# This requires fetching the thread (Expensive)
234
if _check_thread_participation(client, agent_did, agent_handle, reply_to_uri):
235
return True
···
68
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
···
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.
···
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
···
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
···
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
+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
+
});
+24
utils/agentContext.ts
+24
utils/agentContext.ts
···
14
} from "./const.ts";
15
import { msFrom } from "./time.ts";
16
import { bsky } from "./bsky.ts";
17
18
export const getLettaApiKey = (): string => {
19
const value = Deno.env.get("LETTA_API_KEY")?.trim();
···
242
return (value / 100) + 1;
243
};
244
245
const getReflectionDelayMinimum = (): number => {
246
const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM"));
247
···
552
mentionCount: 0,
553
replyCount: 0,
554
quoteCount: 0,
555
// required with manual variables
556
lettaProjectIdentifier: getLettaProjectID(),
557
agentBskyHandle: getAgentBskyHandle(),
···
575
timeZone: getTimeZone(),
576
responsiblePartyType: getResponsiblePartyType(),
577
preserveAgentMemory: getPreserveMemoryBlocks(),
578
reflectionEnabled: setReflectionEnabled(),
579
proactiveEnabled: setProactiveEnabled(),
580
sleepEnabled: setSleepEnabled(),
···
626
agentContext.replyCount = 0;
627
agentContext.quoteCount = 0;
628
};
···
14
} from "./const.ts";
15
import { msFrom } from "./time.ts";
16
import { bsky } from "./bsky.ts";
17
+
import {
18
+
isAgentAsleep as checkIsAsleep,
19
+
isAgentAwake as checkIsAwake,
20
+
} from "./sleepWakeHelpers.ts";
21
22
export const getLettaApiKey = (): string => {
23
const value = Deno.env.get("LETTA_API_KEY")?.trim();
···
246
return (value / 100) + 1;
247
};
248
249
+
const getMaxThreadPosts = (): number => {
250
+
const value = Number(Deno.env.get("MAX_THREAD_POSTS"));
251
+
252
+
if (isNaN(value) || value < 5 || value > 250) {
253
+
return 25;
254
+
}
255
+
256
+
return Math.round(value);
257
+
};
258
+
259
const getReflectionDelayMinimum = (): number => {
260
const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM"));
261
···
566
mentionCount: 0,
567
replyCount: 0,
568
quoteCount: 0,
569
+
notifCount: 0,
570
// required with manual variables
571
lettaProjectIdentifier: getLettaProjectID(),
572
agentBskyHandle: getAgentBskyHandle(),
···
590
timeZone: getTimeZone(),
591
responsiblePartyType: getResponsiblePartyType(),
592
preserveAgentMemory: getPreserveMemoryBlocks(),
593
+
maxThreadPosts: getMaxThreadPosts(),
594
reflectionEnabled: setReflectionEnabled(),
595
proactiveEnabled: setProactiveEnabled(),
596
sleepEnabled: setSleepEnabled(),
···
642
agentContext.replyCount = 0;
643
agentContext.quoteCount = 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);
652
+
};
+54
-3
utils/getCleanThread.ts
+54
-3
utils/getCleanThread.ts
···
1
import { bsky } from "./bsky.ts";
2
3
type threadPost = {
4
authorHandle: string;
···
13
quotes: number;
14
};
15
16
-
export const getCleanThread = async (uri: string): Promise<threadPost[]> => {
17
const res = await bsky.getPostThread({ uri: uri });
18
const { thread } = res.data;
19
20
-
const postsThread: threadPost[] = [];
21
22
// Type guard to check if thread is a ThreadViewPost
23
if (thread && "post" in thread) {
···
37
// Now traverse the parent chain
38
if ("parent" in thread) {
39
let current = thread.parent;
40
41
-
while (current && "post" in current) {
42
postsThread.push({
43
authorHandle: `@${current.post.author.handle}`,
44
message: (current.post.record as { text: string }).text,
···
51
likes: current.post.likeCount ?? 0,
52
quotes: current.post.quoteCount ?? 0,
53
});
54
current = "parent" in current ? current.parent : undefined;
55
}
56
postsThread.reverse();
57
}
58
}
59
60
return postsThread;
61
};
···
1
import { bsky } from "./bsky.ts";
2
+
import { agentContext } from "./agentContext.ts";
3
4
type threadPost = {
5
authorHandle: string;
···
14
quotes: number;
15
};
16
17
+
type threadTruncationIndicator = {
18
+
message: string;
19
+
};
20
+
21
+
type threadItem = threadPost | threadTruncationIndicator;
22
+
23
+
export const getCleanThread = async (uri: string): Promise<threadItem[]> => {
24
const res = await bsky.getPostThread({ uri: uri });
25
const { thread } = res.data;
26
27
+
const postsThread: threadItem[] = [];
28
29
// Type guard to check if thread is a ThreadViewPost
30
if (thread && "post" in thread) {
···
44
// Now traverse the parent chain
45
if ("parent" in thread) {
46
let current = thread.parent;
47
+
let postCount = 1; // Start at 1 for the main post
48
+
let wasTruncated = false;
49
50
+
// Collect up to configured limit of posts
51
+
while (current && "post" in current && postCount < agentContext.maxThreadPosts) {
52
postsThread.push({
53
authorHandle: `@${current.post.author.handle}`,
54
message: (current.post.record as { text: string }).text,
···
61
likes: current.post.likeCount ?? 0,
62
quotes: current.post.quoteCount ?? 0,
63
});
64
+
postCount++;
65
current = "parent" in current ? current.parent : undefined;
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
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
+
}
104
}
105
}
106
107
return postsThread;
108
};
109
+
110
+
export const isThreadPost = (item: threadItem): item is threadPost => {
111
+
return "authorHandle" in item;
112
+
};
+137
-15
utils/messageAgent.ts
+137
-15
utils/messageAgent.ts
···
1
import Letta from "@letta-ai/letta-client";
2
import { agentContext } from "./agentContext.ts";
3
// Helper function to format tool arguments as inline key-value pairs
4
-
const formatArgsInline = (args: unknown): string => {
5
try {
6
const parsed = typeof args === "string" ? JSON.parse(args) : args;
7
if (typeof parsed !== "object" || parsed === null) {
···
9
}
10
return Object.entries(parsed)
11
.map(([key, value]) => {
12
-
const valueStr = typeof value === "object"
13
? JSON.stringify(value)
14
: String(value);
15
return `${key}=${valueStr}`;
16
})
17
.join(", ");
···
28
return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`;
29
};
30
31
export const client = new Letta({
32
apiKey: Deno.env.get("LETTA_API_KEY"),
33
// @ts-ignore: Letta SDK type definition might be slightly off or expecting different casing
···
48
stream_tokens: true,
49
});
50
51
for await (const response of reachAgent) {
52
if (response.message_type === "reasoning_message") {
53
// console.log(`๐ญ reasoningโฆ`);
54
} else if (response.message_type === "assistant_message") {
55
console.log(`๐ฌ ${agentContext.agentBskyName}: ${response.content}`);
56
} else if (response.message_type === "tool_call_message") {
57
-
if (
58
-
Array.isArray(response.tool_calls) && response.tool_calls.length > 0
59
-
) {
60
-
const toolCall = response.tool_calls[0];
61
-
const formattedArgs = formatArgsInline(toolCall.arguments);
62
-
console.log(
63
-
`๐๏ธ tool called: ${toolCall.name} with args: ${formattedArgs}`,
64
-
);
65
}
66
} else if (response.message_type === "tool_return_message") {
67
-
const toolReturn = response.tool_returns;
68
-
const returnStr = typeof toolReturn === "string"
69
-
? toolReturn
70
-
: JSON.stringify(toolReturn);
71
-
console.log(`๐ง tool response: ${truncateString(returnStr)}`);
72
} else if (response.message_type === "usage_statistics") {
73
console.log(`๐ข total steps: ${response.step_count}`);
74
} else if (response.message_type === "hidden_reasoning_message") {
···
1
import Letta from "@letta-ai/letta-client";
2
import { agentContext } from "./agentContext.ts";
3
// Helper function to format tool arguments as inline key-value pairs
4
+
const formatArgsInline = (args: unknown, maxValueLength = 50): string => {
5
try {
6
const parsed = typeof args === "string" ? JSON.parse(args) : args;
7
if (typeof parsed !== "object" || parsed === null) {
···
9
}
10
return Object.entries(parsed)
11
.map(([key, value]) => {
12
+
let valueStr = typeof value === "object"
13
? JSON.stringify(value)
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
+
}
24
return `${key}=${valueStr}`;
25
})
26
.join(", ");
···
37
return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`;
38
};
39
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
···
166
stream_tokens: true,
167
});
168
169
+
let lastToolName = "";
170
+
171
for await (const response of reachAgent) {
172
if (response.message_type === "reasoning_message") {
173
// console.log(`๐ญ reasoningโฆ`);
174
} else if (response.message_type === "assistant_message") {
175
console.log(`๐ฌ ${agentContext.agentBskyName}: ${response.content}`);
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") {
+3
-2
utils/processNotification.ts
+3
-2
utils/processNotification.ts
···
37
} as const;
38
39
export const processNotification = async (notification: Notification) => {
40
-
const agentProject = Deno.env.get("LETTA_PROJECT_NAME");
41
const kind = notification.reason;
42
const author = `@${notification.author.handle}`;
43
const handler = notificationHandlers[kind];
···
54
const prompt = await handler.promptFn(notification);
55
await messageAgent(prompt);
56
console.log(
57
-
`๐น sent ${kind} notification from ${author} to ${agentProject}. moving onโฆ`,
58
);
59
} catch (error) {
60
console.log(
···
63
);
64
} finally {
65
(agentContext as any)[handler.counter]++;
66
}
67
};
···
37
} as const;
38
39
export const processNotification = async (notification: Notification) => {
40
+
const agentName = agentContext.agentBskyName;
41
const kind = notification.reason;
42
const author = `@${notification.author.handle}`;
43
const handler = notificationHandlers[kind];
···
54
const prompt = await handler.promptFn(notification);
55
await messageAgent(prompt);
56
console.log(
57
+
`๐น sent ${kind} notification from ${author} to ${agentName}. moving onโฆ`,
58
);
59
} catch (error) {
60
console.log(
···
63
);
64
} finally {
65
(agentContext as any)[handler.counter]++;
66
+
agentContext.notifCount++;
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
+
};
+9
-2
utils/types.ts
+9
-2
utils/types.ts
···
8
} from "./const.ts";
9
import type {
10
AutomationLevel,
11
-
ResponsiblePartyType,
12
AutonomyDeclaration,
13
ResponsibleParty,
14
} from "@voyager/autonomy-lexicon";
15
16
export type Notification = AppBskyNotificationListNotifications.Notification;
17
18
// Re-export types from autonomy-lexicon package
19
-
export type { AutomationLevel, ResponsiblePartyType, AutonomyDeclaration, ResponsibleParty };
20
21
export type notifType = typeof validNotifTypes[number];
22
···
38
mentionCount: number;
39
replyCount: number;
40
quoteCount: number;
41
// required manual variables
42
lettaProjectIdentifier: string;
43
agentBskyHandle: string;
···
61
timeZone: string;
62
responsiblePartyType: string; // person / organization
63
preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks
64
// set automatically
65
agentBskyDID: string;
66
reflectionEnabled: boolean;
···
8
} from "./const.ts";
9
import type {
10
AutomationLevel,
11
AutonomyDeclaration,
12
ResponsibleParty,
13
+
ResponsiblePartyType,
14
} from "@voyager/autonomy-lexicon";
15
16
export type Notification = AppBskyNotificationListNotifications.Notification;
17
18
// Re-export types from autonomy-lexicon package
19
+
export type {
20
+
AutomationLevel,
21
+
AutonomyDeclaration,
22
+
ResponsibleParty,
23
+
ResponsiblePartyType,
24
+
};
25
26
export type notifType = typeof validNotifTypes[number];
27
···
43
mentionCount: number;
44
replyCount: number;
45
quoteCount: number;
46
+
notifCount: number;
47
// required manual variables
48
lettaProjectIdentifier: string;
49
agentBskyHandle: string;
···
67
timeZone: string;
68
responsiblePartyType: string; // person / organization
69
preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks
70
+
maxThreadPosts: number; // maximum number of posts to include in thread context
71
// set automatically
72
agentBskyDID: string;
73
reflectionEnabled: boolean;