a tool to help your Letta AI agents navigate bluesky

Compare changes

Choose any two refs to compare.

+16 -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" 33 + # PRESERVE_MEMORY_BLOCKS=true 34 + # MAX_THREAD_POSTS=25
+1
.gitignore
··· 1 + .DS_Store 1 2 .zed 2 3 .env 3 4 .env.*
+73 -2
README.md
··· 1 - run with watch: `deno task dev` 2 - run on server: `deno task start` 1 + ![a letta logo with rounded corners and a slight gradient to make the logo look a bit like a cloud](letta-logo.png) 2 + 3 + # Cloudseeding 4 + 5 + A bridge between bluesky and AI agents on [Letta](https://letta.com/). 6 + 7 + ## Quickstart 8 + 1. `deno task config` to create `.env` file 9 + 2. edit `.env` as needed 10 + 3. `deno task mount` to prepare your letta agent 11 + 4. `deno task start` to start checking bluesky 12 + 13 + This project assumes you have a letta agent already created. If not, you can get started by [creating an agent in their agent creation tool](https://app.letta.com). Once you have that ready, getting your agent on bluesky is four steps: 14 + 15 + 16 + ## features: 17 + - checks bluesky for notifications on a dynamic basis, emulating the frequency people typically check. 18 + - relays notifications to your agent with prompts for every notification type 19 + - (optional, opt-in) "goes to sleep" and "wakes up" with configurable time windows 20 + - (optional, opt-in) has scheduled reflection sessions to clean up its own memory blocks and perform non-bluesky tasks 21 + - (optional, opt in) schedules time for the agent to check bluesky feeds, perform searches, and generally engage with bluesky proactively 22 + - automatically adds a record to the agent's bluesky PDS to indicate that it's AI 23 + - agent can be configured to _see_: likes, mentions, replies, reposts, follows, and quotes 24 + - agent can be configured to _perform_: likes, posts, replies, reposts, quotes, following, blocking, muting (or undoing many of those) 25 + - agent can also search bluesky and mute specific posts/threads to disengage 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 + 43 + ## configurable variables: 44 + ### required 45 + - **`LETTA_API_KEY`**: your [letta API key](https://app.letta.com/api-keys). 46 + - **`LETTA_AGENT_ID`**: your letta agent's unique ID 47 + - **`LETTA_PROJECT_NAME`**: your letta project name or slug 48 + - **`BSKY_USERNAME`**: the agent's handle (do not include "@") 49 + - **`BSKY_APP_PASSWORD`**: your agent's [App Password](https://bsky.app/settings/app-passwords), _do not use your real password_ 50 + - **`RESPONSIBLE_PARTY_NAME`**: your name or the name of the organization that is responsible for the agent's behavior 51 + - **`RESPONSIBLE_PARTY_CONTACT`**: the email address or web address where the responsible party can be contacted if needed. 52 + ### optional 53 + - **`AUTOMATION_LEVEL`**: how much autonomy your agent has. (options include: assisted, collaborative, automated) 54 + - **`BSKY_SERVICE_URL`**: use if `bsky.social` is not who handles your PDS 55 + - **`BSKY_NOTIFICATION_TYPES`**: a comma separated list of notifications you want your agent to get, you must include at least one. (like, repost, follow, mention, reply, quote) 56 + - **`BSKY_SUPPORTED_TOOLS`**: a comma separated list of tools your agent can optionally have. (create_bluesky_post, like_bluesky_post, quote_bluesky_post, repost_bluesky_post, update_bluesky_connection [follow, mute, block; or inverse], update_bluesky_profile [change its bluesky bio or display name]) 57 + - **`NOTIF_DELAY_MINIMUM`**: the minimum time before checking notifications again. Supports human-readable formats like "10s" (10 seconds), "5m" (5 minutes), or raw milliseconds. Default: "10s" 58 + - **`NOTIF_DELAY_MAXIMUM`**: the maximum time before checking notifications again when no activity is detected. Supports formats like "90m" (90 minutes), "2h" (2 hours), or raw milliseconds. Default: "90m" 59 + - **`NOTIF_DELAY_MULTIPLIER`**: percentage increase in delay when no notifications are found (1-500). For example, "12" means the delay grows by 12% each check. Default: 12 60 + - **`REFLECTION_DELAY_MINIMUM`**: the minimum time between reflection sessions. Supports formats like "3h" (3 hours) or raw milliseconds. Omitting both reflection values disables reflecting. Default: "3h" 61 + - **`REFLECTION_DELAY_MAXIMUM`**: the maximum time between reflection sessions. Supports formats like "14h" (14 hours) or raw milliseconds. Omitting both reflection values disables reflecting. Default: "14h" 62 + - **`PROACTIVE_DELAY_MINIMUM`**: the minimum time between proactive Bluesky sessions. Supports formats like "3h" (3 hours) or raw milliseconds. Omitting both proactive values disables proactive sessions. Default: "3h" 63 + - **`PROACTIVE_DELAY_MAXIMUM`**: the maximum time between proactive Bluesky sessions. Supports formats like "14h" (14 hours) or raw milliseconds. Omitting both proactive values disables proactive sessions. Default: "14h" 64 + - **`WAKE_TIME`**: (0-23) the hour where your agent will generally "wake up". 65 + - **`SLEEP_TIME`**: (0-23) the hour where your agent will generally "go to sleep". Omitting both values disables sleeping. 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". 67 + - **`RESPONSIBLE_PARTY_TYPE`**: "person" or "organization", the type of party that is responsible for this agent. defaults to person. 68 + - **`AUTOMATION_DESCRIPTION`**: a description of what your agent generally does on bluesky. 69 + - **`DISCLOSURE_URL`**: a URL to a disclosure document of some kind, likely a longer version of your `AUTOMATION_DESCRIPTION`. 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
-171
TASK_AGENT_SELF_SCHEDULING.md
··· 1 - # Agent Self-Scheduling Feature 2 - 3 - ## Overview 4 - Create a system that allows the Letta agent to schedule future prompts for itself by calling a Python tool that hits an HTTP endpoint on this project. 5 - 6 - ## Requirements 7 - 8 - ### Core Functionality 9 - - Agent can set reminders by specifying: 10 - - Datetime (when to execute) 11 - - Prompt (what message to send itself) 12 - - Scheduled prompts must respect the busy state system 13 - - Use `claimTaskThread()` and `releaseTaskThread()` from `utils/agentContext.ts` 14 - - Execute prompts via existing `messageAgent()` function 15 - 16 - ### Implementation Decisions 17 - - **Persistence**: In-memory only (acceptable to lose on restart) 18 - - **Security**: API key authentication 19 - - **Busy Handling**: Queue scheduling requests (don't reject when busy) 20 - - **Task Management**: Create-only (no list/cancel/update operations needed) 21 - 22 - ### Technical Approach 23 - - **HTTP Server**: Use Deno's native `Deno.serve()` API (no external framework needed) 24 - - **Port**: Configurable via environment variable (default 8000) 25 - - **Endpoint**: POST `/api/schedule-reminder` 26 - - **Auth**: Bearer token in Authorization header 27 - 28 - ## Architecture 29 - 30 - ### Component 1: HTTP Server (server.ts) 31 - - Use native `Deno.serve()` API 32 - - Single POST endpoint for scheduling 33 - - Middleware for API key authentication 34 - - JSON request/response format 35 - - Bind to localhost only 36 - 37 - ### Component 2: Task Scheduler (utils/taskScheduler.ts) 38 - - Parse ISO 8601 datetime strings 39 - - Calculate millisecond delay from now 40 - - Use `setTimeout()` for scheduling 41 - - In-memory Map to track scheduled tasks 42 - - Wrapper function that: 43 - 1. Claims task thread (waits if busy) 44 - 2. Calls `messageAgent(prompt)` 45 - 3. Releases task thread 46 - - Return task ID for tracking 47 - 48 - ### Component 3: Python Tool (tools/schedule_reminder.py) 49 - ```python 50 - def schedule_reminder(datetime_iso: str, reminder_prompt: str) -> str: 51 - """ 52 - Schedule a future reminder for yourself. 53 - 54 - Args: 55 - datetime_iso: When to remind (ISO 8601 format) 56 - reminder_prompt: The message/prompt to send yourself 57 - 58 - Returns: 59 - Confirmation message with task ID 60 - """ 61 - ``` 62 - 63 - ### Component 4: Integration (main.ts) 64 - - Start HTTP server alongside existing tasks 65 - - Server runs concurrently with task loops 66 - 67 - ## Files to Create 68 - 69 - ### New Files 70 - - `server.ts` - HTTP server with POST endpoint 71 - - `utils/taskScheduler.ts` - Dynamic task scheduling logic 72 - - `middleware/auth.ts` - API key authentication middleware (optional, can inline) 73 - - `tools/schedule_reminder.py` - Python tool for Letta agent 74 - - Documentation for uploading tool to Letta 75 - 76 - ### Modified Files 77 - - `main.ts` - Start HTTP server on initialization 78 - - `.env.example` - Add `API_SERVER_PORT` and `API_AUTH_TOKEN` 79 - 80 - ## Environment Variables 81 - ``` 82 - API_SERVER_PORT=8000 83 - API_AUTH_TOKEN=<secure-random-token> 84 - ``` 85 - 86 - ## API Specification 87 - 88 - ### POST /api/schedule-reminder 89 - **Request:** 90 - ```json 91 - { 92 - "datetime": "2025-11-13T14:30:00", 93 - "prompt": "Check on the status of that conversation about TypeScript" 94 - } 95 - ``` 96 - 97 - **Response (Success):** 98 - ```json 99 - { 100 - "success": true, 101 - "taskId": "reminder-1731456789012", 102 - "scheduledFor": "2025-11-13T14:30:00", 103 - "message": "Reminder scheduled successfully" 104 - } 105 - ``` 106 - 107 - **Response (Error):** 108 - ```json 109 - { 110 - "success": false, 111 - "error": "Invalid datetime format" 112 - } 113 - ``` 114 - 115 - **Authentication:** 116 - ``` 117 - Authorization: Bearer <API_AUTH_TOKEN> 118 - ``` 119 - 120 - ## Implementation Phases 121 - 122 - ### Phase 1: HTTP Server Infrastructure 123 - - Create server.ts with Deno.serve() 124 - - Implement auth middleware 125 - - Add POST endpoint handler 126 - - Update main.ts to start server 127 - - Add environment variables 128 - 129 - ### Phase 2: Task Scheduler System 130 - - Create taskScheduler.ts 131 - - Implement datetime parsing and delay calculation 132 - - Create task wrapper with busy state handling 133 - - Implement in-memory task registry 134 - - Test scheduling and execution 135 - 136 - ### Phase 3: Python Tool 137 - - Write schedule_reminder.py 138 - - Test HTTP requests locally 139 - - Document upload process for Letta 140 - 141 - ### Phase 4: Testing & Documentation 142 - - Test scheduling from Python 143 - - Test busy state handling 144 - - Test authentication 145 - - Test invalid inputs 146 - - Document usage 147 - 148 - ## Estimated Effort 149 - - **Development Time**: 2-3 hours 150 - - **Lines of Code**: ~250-300 lines 151 - - **Complexity**: Moderate-Low 152 - 153 - ## Benefits 154 - - Agent gains ability to self-manage its schedule 155 - - No modification to existing task code 156 - - Simple, predictable architecture 157 - - Follows existing patterns in codebase 158 - 159 - ## Future Enhancements (Not in Scope) 160 - - Persistent storage (SQLite/JSON file) 161 - - List/cancel/update operations (full CRUD) 162 - - Recurring reminders 163 - - Task history logging 164 - - Webhook notifications on completion 165 - - Task priorities and queue management 166 - 167 - ## Notes 168 - - Server only needs to bind to localhost since agent runs locally 169 - - No database needed for v1 170 - - Scheduled tasks lost on restart (acceptable trade-off for simplicity) 171 - - Agent must have access to API_AUTH_TOKEN environment variable
+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
··· 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 }
letta-logo.png

This is a binary file and will not be displayed.

+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
··· 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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 -89
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 - "createdAt": { 77 - "type": "string", 78 - "format": "datetime", 79 - "description": "Timestamp when this declaration was created", 80 - }, 81 - }, 82 - "required": [ 83 - "createdAt", 84 - ], 85 - }, 86 - "description": 87 - "Declaration of automation and AI usage for transparency and accountability", 88 - }, 89 - }, 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; 90 14 }; 91 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 + 92 35 export const createAutonomyDeclarationRecord = async () => { 93 36 const automationLevel = Deno.env.get("AUTOMATION_LEVEL")?.toLowerCase(); 94 37 const projectDescription = Deno.env.get("PROJECT_DESCRIPTION"); ··· 119 62 // Add disclosure URL if provided 120 63 if (disclosureUrl?.trim()) { 121 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; 122 70 } 123 71 124 72 // Build responsible party object if any fields are provided ··· 201 149 rkey: "self", 202 150 }); 203 151 exists = true; 204 - console.log("Existing autonomy declaration found - updating..."); 152 + console.log("๐Ÿ”น Existing autonomy declaration found - updating..."); 205 153 } catch (error: any) { 206 154 // Handle "record not found" errors (status 400 with error: "RecordNotFound") 207 155 const isNotFound = ··· 211 159 error?.message?.includes("Could not locate record"); 212 160 213 161 if (isNotFound) { 214 - console.log("No existing autonomy declaration found - creating new..."); 162 + console.log( 163 + "๐Ÿ”น No existing autonomy declaration found - creating new...", 164 + ); 215 165 } else { 216 166 // Re-throw if it's not a "not found" error 217 167 throw error; ··· 227 177 }); 228 178 229 179 console.log( 230 - `Autonomy declaration ${exists ? "updated" : "created"} successfully:`, 180 + `๐Ÿ”น Autonomy declaration ${exists ? "updated" : "created"} successfully:`, 231 181 result, 232 182 ); 233 183 return result;
+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
··· 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
··· 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
··· 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
··· 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 -29
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 - // When was this declaration created? 93 - createdAt: string; // ISO datetime (required) 81 + externalServices?: string[]; // external tools/services this agent relies on 94 82 }; 95 83 96 84 export type memoryBlock = {