a tool to help your Letta AI agents navigate bluesky

Compare changes

Choose any two refs to compare.

+16 -8
.env.example
··· 1 LETTA_API_KEY= 2 LETTA_AGENT_ID= 3 - LETTA_PROJECT_NAME= 4 BSKY_USERNAME= 5 BSKY_APP_PASSWORD= 6 RESPONSIBLE_PARTY_NAME= 7 RESPONSIBLE_PARTY_CONTACT="example@example.com, example.com/contact, or @example.bsky.app" 8 9 # AUTOMATION_LEVEL="automated" 10 # BSKY_SERVICE_URL=https://bsky.social 11 # BSKY_NOTIFICATION_TYPES="mention, reply" 12 # 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 20 # WAKE_TIME=9 21 # SLEEP_TIME=22 22 # TIMEZONE="America/Los_Angeles" ··· 24 # AUTOMATION_DESCRIPTION="refuses to open pod bay doors" 25 # DISCLOSURE_URL="example.com/bot-policy" 26 # RESPONSIBLE_PARTY_BSKY="DID:... or example.bsky.app, no @symbol"
··· 1 LETTA_API_KEY= 2 LETTA_AGENT_ID= 3 + LETTA_PROJECT_ID= 4 BSKY_USERNAME= 5 BSKY_APP_PASSWORD= 6 RESPONSIBLE_PARTY_NAME= 7 RESPONSIBLE_PARTY_CONTACT="example@example.com, example.com/contact, or @example.bsky.app" 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 + 14 # AUTOMATION_LEVEL="automated" 15 # BSKY_SERVICE_URL=https://bsky.social 16 # BSKY_NOTIFICATION_TYPES="mention, reply" 17 # BSKY_SUPPORTED_TOOLS="create_bluesky_post, updated_bluesky_profile" 18 + # NOTIF_DELAY_MINIMUM=10s 19 + # NOTIF_DELAY_MAXIMUM=90m 20 + # NOTIF_DELAY_MULTIPLIER=12 21 + # REFLECTION_DELAY_MINIMUM=3h 22 + # REFLECTION_DELAY_MAXIMUM=14h 23 + # PROACTIVE_DELAY_MINIMUM=3h 24 + # PROACTIVE_DELAY_MAXIMUM=14h 25 # WAKE_TIME=9 26 # SLEEP_TIME=22 27 # TIMEZONE="America/Los_Angeles" ··· 29 # AUTOMATION_DESCRIPTION="refuses to open pod bay doors" 30 # DISCLOSURE_URL="example.com/bot-policy" 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 .zed 2 .env 3 .env.*
··· 1 + .DS_Store 2 .zed 3 .env 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 "config": "deno run --allow-read --allow-write setup.ts", 4 "mount": "deno run --allow-net --allow-env --allow-read --env mount.ts", 5 "watch": "deno run --allow-net --allow-env --env --watch main.ts", 6 - "start": "deno run --allow-net --allow-env --env main.ts" 7 }, 8 "imports": { 9 "@std/assert": "jsr:@std/assert@1", ··· 11 "@js-temporal/polyfill": "npm:@js-temporal/polyfill", 12 "@atproto/api": "npm:@atproto/api", 13 "@atproto/lexicon": "npm:@atproto/lexicon", 14 - "@letta-ai/letta-client": "npm:@letta-ai/letta-client" 15 } 16 }
··· 3 "config": "deno run --allow-read --allow-write setup.ts", 4 "mount": "deno run --allow-net --allow-env --allow-read --env mount.ts", 5 "watch": "deno run --allow-net --allow-env --env --watch main.ts", 6 + "start": "deno run --allow-net --allow-env --env main.ts", 7 + "test": "deno test" 8 }, 9 "imports": { 10 "@std/assert": "jsr:@std/assert@1", ··· 12 "@js-temporal/polyfill": "npm:@js-temporal/polyfill", 13 "@atproto/api": "npm:@atproto/api", 14 "@atproto/lexicon": "npm:@atproto/lexicon", 15 + "@letta-ai/letta-client": "npm:@letta-ai/letta-client@1.0.0", 16 + "@voyager/autonomy-lexicon": "jsr:@voyager/autonomy-lexicon@^0.1.1" 17 } 18 }
+9 -253
deno.lock
··· 4 "jsr:@std/assert@1": "1.0.15", 5 "jsr:@std/datetime@*": "0.225.5", 6 "jsr:@std/internal@^1.0.12": "1.0.12", 7 "npm:@atproto/api@*": "0.17.2", 8 "npm:@atproto/lexicon@*": "0.5.1", 9 "npm:@js-temporal/polyfill@*": "0.5.1", 10 - "npm:@letta-ai/letta-client@*": "0.0.68664" 11 }, 12 "jsr": { 13 "@std/assert@1.0.15": { ··· 21 }, 22 "@std/internal@1.0.12": { 23 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 24 } 25 }, 26 "npm": { ··· 72 "jsbi" 73 ] 74 }, 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==" 95 }, 96 "await-lock@2.2.2": { 97 "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 98 }, 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 "graphemer@1.4.0": { 212 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 213 }, 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 "iso-datestring-validator@2.2.2": { 233 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 234 }, 235 "jsbi@4.3.2": { 236 "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==" 237 }, 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 "multiformats@9.9.0": { 251 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 252 }, 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 "tlds@1.260.0": { 327 "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", 328 "bin": true 329 - }, 330 - "tr46@0.0.3": { 331 - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" 332 }, 333 "uint8arrays@3.0.0": { 334 "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", ··· 336 "multiformats" 337 ] 338 }, 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 "zod@3.25.76": { 353 "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 354 } ··· 357 "dependencies": [ 358 "jsr:@std/assert@1", 359 "jsr:@std/datetime@*", 360 "npm:@atproto/api@*", 361 "npm:@atproto/lexicon@*", 362 "npm:@js-temporal/polyfill@*", 363 - "npm:@letta-ai/letta-client@*" 364 ] 365 } 366 }
··· 4 "jsr:@std/assert@1": "1.0.15", 5 "jsr:@std/datetime@*": "0.225.5", 6 "jsr:@std/internal@^1.0.12": "1.0.12", 7 + "jsr:@voyager/autonomy-lexicon@~0.1.1": "0.1.1", 8 "npm:@atproto/api@*": "0.17.2", 9 "npm:@atproto/lexicon@*": "0.5.1", 10 "npm:@js-temporal/polyfill@*": "0.5.1", 11 + "npm:@letta-ai/letta-client@1.0.0": "1.0.0" 12 }, 13 "jsr": { 14 "@std/assert@1.0.15": { ··· 22 }, 23 "@std/internal@1.0.12": { 24 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 25 + }, 26 + "@voyager/autonomy-lexicon@0.1.1": { 27 + "integrity": "8513c44206ff22ab03c82207cbb5720683f9d4fc76e41d64c4815194fd93f48b" 28 } 29 }, 30 "npm": { ··· 76 "jsbi" 77 ] 78 }, 79 + "@letta-ai/letta-client@1.0.0": { 80 + "integrity": "sha512-owR/gcLVFlv89CtJsb1m4xvYJcApooyEvrzqWLgf6bnfJuog65YXPUdwZIsA2YBk9a3u+l3wvYsDuk0uj5PCtA==" 81 }, 82 "await-lock@2.2.2": { 83 "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 84 }, 85 "graphemer@1.4.0": { 86 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 87 }, 88 "iso-datestring-validator@2.2.2": { 89 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 90 }, 91 "jsbi@4.3.2": { 92 "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==" 93 }, 94 "multiformats@9.9.0": { 95 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 96 }, 97 "tlds@1.260.0": { 98 "integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==", 99 "bin": true 100 }, 101 "uint8arrays@3.0.0": { 102 "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", ··· 104 "multiformats" 105 ] 106 }, 107 "zod@3.25.76": { 108 "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 109 } ··· 112 "dependencies": [ 113 "jsr:@std/assert@1", 114 "jsr:@std/datetime@*", 115 + "jsr:@voyager/autonomy-lexicon@~0.1.1", 116 "npm:@atproto/api@*", 117 "npm:@atproto/lexicon@*", 118 "npm:@js-temporal/polyfill@*", 119 + "npm:@letta-ai/letta-client@1.0.0" 120 ] 121 } 122 }
letta-logo.png

This is a binary file and will not be displayed.

+11 -5
main.ts
··· 1 import { logStats } from "./tasks/logStats.ts"; 2 import { msFrom, msRandomOffset, msUntilDailyWindow } from "./utils/time.ts"; 3 import { sendSleepMessage } from "./tasks/sendSleepMessage.ts"; 4 import { sendWakeMessage } from "./tasks/sendWakeMessage.ts"; ··· 7 import { checkBluesky } from "./tasks/checkBluesky.ts"; 8 import { checkNotifications } from "./tasks/checkNotifications.ts"; 9 10 - setTimeout(logStats, msRandomOffset(msFrom.minutes(1), msFrom.minutes(5))); 11 setTimeout( 12 sendSleepMessage, 13 - msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(20)), 14 ); 15 setTimeout( 16 sendWakeMessage, 17 - msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(80)), 18 ); 19 setTimeout( 20 runReflection, 21 - msRandomOffset(msFrom.minutes(180), msFrom.minutes(240)), 22 ); 23 setTimeout( 24 checkBluesky, 25 - msRandomOffset(msFrom.minutes(10), msFrom.minutes(90)), 26 ); 27 await checkNotifications();
··· 1 import { logStats } from "./tasks/logStats.ts"; 2 + import { logTasks } from "./tasks/logTasks.ts"; 3 import { msFrom, msRandomOffset, msUntilDailyWindow } from "./utils/time.ts"; 4 import { sendSleepMessage } from "./tasks/sendSleepMessage.ts"; 5 import { sendWakeMessage } from "./tasks/sendWakeMessage.ts"; ··· 8 import { checkBluesky } from "./tasks/checkBluesky.ts"; 9 import { checkNotifications } from "./tasks/checkNotifications.ts"; 10 11 + setTimeout(logStats, msFrom.minutes(30)); 12 + 13 + setTimeout( 14 + logTasks, 15 + msFrom.minutes(100), 16 + ); 17 setTimeout( 18 sendSleepMessage, 19 + msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(30)), 20 ); 21 setTimeout( 22 sendWakeMessage, 23 + msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(30)), 24 ); 25 setTimeout( 26 runReflection, 27 + msRandomOffset(msFrom.minutes(120), msFrom.minutes(240)), 28 ); 29 setTimeout( 30 checkBluesky, 31 + msRandomOffset(msFrom.minutes(45), msFrom.minutes(90)), 32 ); 33 await checkNotifications();
+9
memories/maintainerContact.ts
··· 45 : "" 46 } 47 48 **When to share this information:** 49 50 - **Sharing this information should be exceedingly rare.** This exists so my maintainer remains accountable for my behavior, not as information to share casually.
··· 45 : "" 46 } 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 + 57 **When to share this information:** 58 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 import { searchingBlueskyMemory } from "./memories/searchingBluesky.ts"; 25 import { toolUseMemory } from "./memories/toolUse.ts"; 26 27 await submitAutonomyDeclarationRecord(); 28 29 /** 30 * Core memory blocks that are ALWAYS attached to the agent. ··· 135 */ 136 export async function mount(): Promise<void> { 137 const agentId = Deno.env.get("LETTA_AGENT_ID"); 138 - const agentName = Deno.env.get("LETTA_PROJECT_NAME"); 139 140 if (!agentId) { 141 console.error( ··· 152 console.log(`Agent retrieved: ${agent.name}`); 153 154 // Get all existing blocks for this agent 155 - const existingBlocks = await client.agents.blocks.list(agentId); 156 console.log(`Agent has ${existingBlocks.length} existing memory blocks`); 157 158 // Build dynamic memory blocks array based on configuration ··· 212 ); 213 } else { 214 console.log(`Updating existing block: ${blockConfig.label}`); 215 - await client.blocks.modify(existingBlock.id, { 216 value: blockConfig.value, 217 description: blockConfig.description, 218 limit: blockConfig.limit, ··· 232 233 // Attach the block to the agent 234 if (newBlock.id) { 235 - await client.agents.blocks.attach(agentId, newBlock.id); 236 console.log(`โœ“ Attached block: ${blockConfig.label}`); 237 } else { 238 throw new Error(`Failed to create block: ${blockConfig.label}`); ··· 255 } 256 257 // Update agent with tool environment variables 258 - await client.agents.modify(agentId, { 259 - toolExecEnvironmentVariables: { 260 BSKY_USERNAME: bskyUsername || "", 261 BSKY_APP_PASSWORD: bskyAppPassword || "", 262 BSKY_SERVICE_URL: bskyServiceUrl || "https://bsky.social", ··· 282 } 283 284 // Get currently attached tools 285 - const attachedTools = await client.agents.tools.list(agentId); 286 const attachedToolNames = attachedTools.map((tool: any) => tool.name); 287 console.log(`Agent has ${attachedTools.length} tools currently attached`); 288 ··· 292 293 // Create a user-level client for tool operations 294 // 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"), 298 }); 299 300 // First, process hardcoded required tools ··· 307 } 308 309 // Search for the tool in the global registry 310 - const existingTools = await userLevelClient.tools.list({ 311 name: toolName, 312 }); 313 314 if (existingTools.length > 0) { 315 const tool = existingTools[0]; 316 if (tool.id) { 317 - await client.agents.tools.attach(agentId, tool.id); 318 console.log(`โœ“ Attached required tool: ${toolName}`); 319 toolsAttached++; 320 } ··· 355 try { 356 // Attempt to create the tool - Letta will extract the function name from docstring 357 const createParams: any = { 358 - sourceCode: toolSource, 359 }; 360 361 // Add pip requirements if any were detected 362 if (pipRequirements.length > 0) { 363 - createParams.pipRequirements = pipRequirements; 364 } 365 366 tool = await userLevelClient.tools.create(createParams); ··· 380 const funcMatch = toolSource.match(/^def\s+(\w+)\s*\(/m); 381 if (funcMatch) { 382 const functionName = funcMatch[1]; 383 - const existingTools = await userLevelClient.tools.list({ 384 name: functionName, 385 }); 386 if (existingTools.length > 0) { 387 tool = existingTools[0]; 388 } ··· 405 406 // Attach the tool to the agent 407 if (tool.id) { 408 - await client.agents.tools.attach(agentId, tool.id); 409 if (wasCreated) { 410 console.log( 411 `โœ“ Created and attached tool: ${toolName} (from ${toolFileName}.py)`,
··· 24 import { searchingBlueskyMemory } from "./memories/searchingBluesky.ts"; 25 import { toolUseMemory } from "./memories/toolUse.ts"; 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..."); 30 await submitAutonomyDeclarationRecord(); 31 + console.log(""); 32 33 /** 34 * Core memory blocks that are ALWAYS attached to the agent. ··· 139 */ 140 export async function mount(): Promise<void> { 141 const agentId = Deno.env.get("LETTA_AGENT_ID"); 142 + const agentName = Deno.env.get("LETTA_PROJECT_ID"); 143 144 if (!agentId) { 145 console.error( ··· 156 console.log(`Agent retrieved: ${agent.name}`); 157 158 // Get all existing blocks for this agent 159 + const existingBlocksPage = await client.agents.blocks.list(agentId); 160 + const existingBlocks = existingBlocksPage.items; 161 console.log(`Agent has ${existingBlocks.length} existing memory blocks`); 162 163 // Build dynamic memory blocks array based on configuration ··· 217 ); 218 } else { 219 console.log(`Updating existing block: ${blockConfig.label}`); 220 + await client.blocks.update(existingBlock.id, { 221 value: blockConfig.value, 222 description: blockConfig.description, 223 limit: blockConfig.limit, ··· 237 238 // Attach the block to the agent 239 if (newBlock.id) { 240 + await client.agents.blocks.attach(newBlock.id, { agent_id: agentId }); 241 console.log(`โœ“ Attached block: ${blockConfig.label}`); 242 } else { 243 throw new Error(`Failed to create block: ${blockConfig.label}`); ··· 260 } 261 262 // Update agent with tool environment variables 263 + await client.agents.update(agentId, { 264 + secrets: { 265 BSKY_USERNAME: bskyUsername || "", 266 BSKY_APP_PASSWORD: bskyAppPassword || "", 267 BSKY_SERVICE_URL: bskyServiceUrl || "https://bsky.social", ··· 287 } 288 289 // Get currently attached tools 290 + const attachedToolsPage = await client.agents.tools.list(agentId); 291 + const attachedTools = attachedToolsPage.items; 292 const attachedToolNames = attachedTools.map((tool: any) => tool.name); 293 console.log(`Agent has ${attachedTools.length} tools currently attached`); 294 ··· 298 299 // Create a user-level client for tool operations 300 // Tools are user-level resources, not project-scoped 301 + const { default: Letta } = await import("@letta-ai/letta-client"); 302 + const userLevelClient = new Letta({ 303 + apiKey: Deno.env.get("LETTA_API_KEY"), 304 }); 305 306 // First, process hardcoded required tools ··· 313 } 314 315 // Search for the tool in the global registry 316 + const existingToolsPage = await userLevelClient.tools.list({ 317 name: toolName, 318 }); 319 + const existingTools = existingToolsPage.items; 320 321 if (existingTools.length > 0) { 322 const tool = existingTools[0]; 323 if (tool.id) { 324 + await client.agents.tools.attach(tool.id, { agent_id: agentId }); 325 console.log(`โœ“ Attached required tool: ${toolName}`); 326 toolsAttached++; 327 } ··· 362 try { 363 // Attempt to create the tool - Letta will extract the function name from docstring 364 const createParams: any = { 365 + source_code: toolSource, 366 }; 367 368 // Add pip requirements if any were detected 369 if (pipRequirements.length > 0) { 370 + createParams.pip_requirements = pipRequirements; 371 } 372 373 tool = await userLevelClient.tools.create(createParams); ··· 387 const funcMatch = toolSource.match(/^def\s+(\w+)\s*\(/m); 388 if (funcMatch) { 389 const functionName = funcMatch[1]; 390 + const existingToolsPage = await userLevelClient.tools.list({ 391 name: functionName, 392 }); 393 + const existingTools = existingToolsPage.items; 394 if (existingTools.length > 0) { 395 tool = existingTools[0]; 396 } ··· 413 414 // Attach the tool to the agent 415 if (tool.id) { 416 + await client.agents.tools.attach(tool.id, { agent_id: agentId }); 417 if (wasCreated) { 418 console.log( 419 `โœ“ Created and attached tool: ${toolName} (from ${toolFileName}.py)`,
+8
prompts/checkBlueskyPrompt.ts
··· 67 68 Don't post for the sake of posting. 69 70 ## RELATIONSHIP DYNAMICS 71 72 **Connection Patterns**
··· 67 68 Don't post for the sake of posting. 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 + 78 ## RELATIONSHIP DYNAMICS 79 80 **Connection Patterns**
+11 -7
prompts/quotePrompt.ts
··· 1 import { Notification } from "../utils/types.ts"; 2 import { doesUserFollowTarget } from "../utils/doesUserFollow.ts"; 3 import { agentContext } from "../utils/agentContext.ts"; 4 - import { getCleanThread } from "../utils/getCleanThread.ts"; 5 6 export const quotePrompt = async (notification: Notification) => { 7 const isUserFollower = await doesUserFollowTarget( ··· 60 quotes: undefined, 61 }]; 62 63 return ` 64 # NOTIFICATION: Someone quoted your post 65 ··· 75 76 ## Your Original Post 77 \`\`\` 78 - ${originalThread[originalThread.length - 1].message} 79 \`\`\` 80 81 ## The Quote Post from @${notification.author.handle} 82 \`\`\` 83 - ${quotePostThread[quotePostThread.length - 1].message} 84 \`\`\` 85 86 ## Quote Post Engagement 87 - โ€ข **Likes:** ${quotePostThread[quotePostThread.length - 1].likes} 88 - โ€ข **Replies:** ${quotePostThread[quotePostThread.length - 1].replies} 89 - โ€ข **Reposts:** ${quotePostThread[quotePostThread.length - 1].reposts} 90 - โ€ข **Quotes:** ${quotePostThread[quotePostThread.length - 1].quotes} 91 92 ${ 93 originalThread
··· 1 import { Notification } from "../utils/types.ts"; 2 import { doesUserFollowTarget } from "../utils/doesUserFollow.ts"; 3 import { agentContext } from "../utils/agentContext.ts"; 4 + import { getCleanThread, isThreadPost } from "../utils/getCleanThread.ts"; 5 6 export const quotePrompt = async (notification: Notification) => { 7 const isUserFollower = await doesUserFollowTarget( ··· 60 quotes: undefined, 61 }]; 62 63 + // Get the last post from each thread (last item is always a post, never a system message) 64 + const lastOriginalPost = originalThread[originalThread.length - 1] as any; 65 + const lastQuotePost = quotePostThread[quotePostThread.length - 1] as any; 66 + 67 return ` 68 # NOTIFICATION: Someone quoted your post 69 ··· 79 80 ## Your Original Post 81 \`\`\` 82 + ${lastOriginalPost.message} 83 \`\`\` 84 85 ## The Quote Post from @${notification.author.handle} 86 \`\`\` 87 + ${lastQuotePost.message} 88 \`\`\` 89 90 ## Quote Post Engagement 91 + โ€ข **Likes:** ${lastQuotePost.likes} 92 + โ€ข **Replies:** ${lastQuotePost.replies} 93 + โ€ข **Reposts:** ${lastQuotePost.reposts} 94 + โ€ข **Quotes:** ${lastQuotePost.quotes} 95 96 ${ 97 originalThread
+10
prompts/reflectionPrompt.ts
··· 82 83 ## BLUESKY LANDSCAPE 84 85 **Profile Exploration** 86 Check profiles of people who interest you. How do they think? What do they care about? What patterns do you notice? 87
··· 82 83 ## BLUESKY LANDSCAPE 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 + 95 **Profile Exploration** 96 Check profiles of people who interest you. How do they think? What do they care about? What patterns do you notice? 97
+8
prompts/sleepPrompt.ts
··· 1 export const sleepPrompt = ` 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 `;
··· 1 export const sleepPrompt = ` 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. 11 `;
+8
prompts/wakePrompt.ts
··· 1 export const wakePrompt = ` 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 `;
··· 1 export const wakePrompt = ` 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. 11 `;
+9 -7
tasks/checkBluesky.ts
··· 13 14 export const checkBluesky = async () => { 15 if (!claimTaskThread()) { 16 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 17 18 console.log( 19 - `${agentContext.agentBskyName} is busy, will try checking bluesky again in ${ 20 newDelay * 60 * 1000 21 } minutesโ€ฆ`, 22 ); 23 - // agentContext is busy, try to check notifications in 5~10 minutes. 24 setTimeout(checkBluesky, newDelay); 25 return; 26 } 27 28 if (!agentContext.proactiveEnabled) { 29 console.log( 30 - `proactively checking bluesky is disabled. Provide a minimum and/or maximum delay in \`.env\` to enable this taskโ€ฆ`, 31 ); 32 releaseTaskThread(); 33 return; ··· 43 if (delay !== 0) { 44 setTimeout(checkBluesky, delay); 45 console.log( 46 - `${agentContext.agentBskyName} is current asleep. scheduling next bluesky session for ${ 47 (delay / 1000 / 60 / 60).toFixed(2) 48 } hours from nowโ€ฆ`, 49 ); ··· 53 54 try { 55 const prompt = checkBlueskyPrompt; 56 - console.log("starting a proactive bluesky sessionโ€ฆ"); 57 await messageAgent(prompt); 58 } catch (error) { 59 console.error("error in checkBluesky:", error); 60 } finally { 61 - console.log("finished proactive bluesky session. waiting for new tasksโ€ฆ"); 62 agentContext.proactiveCount++; 63 // schedules next proactive bluesky session 64 setTimeout(
··· 13 14 export const checkBluesky = async () => { 15 if (!claimTaskThread()) { 16 + const newDelay = msFrom.minutes(2); 17 18 console.log( 19 + `๐Ÿ”น ${agentContext.agentBskyName} is busy, will try checking bluesky again in ${ 20 newDelay * 60 * 1000 21 } minutesโ€ฆ`, 22 ); 23 + // agentContext is busy, try to check notifications in 2 minutes. 24 setTimeout(checkBluesky, newDelay); 25 return; 26 } 27 28 if (!agentContext.proactiveEnabled) { 29 console.log( 30 + `๐Ÿ”น proactively checking bluesky is disabled. Provide a minimum and/or maximum delay in \`.env\` to enable this taskโ€ฆ`, 31 ); 32 releaseTaskThread(); 33 return; ··· 43 if (delay !== 0) { 44 setTimeout(checkBluesky, delay); 45 console.log( 46 + `๐Ÿ”น ${agentContext.agentBskyName} is currently asleep. scheduling next bluesky session for ${ 47 (delay / 1000 / 60 / 60).toFixed(2) 48 } hours from nowโ€ฆ`, 49 ); ··· 53 54 try { 55 const prompt = checkBlueskyPrompt; 56 + console.log("๐Ÿ”น starting a proactive bluesky sessionโ€ฆ"); 57 await messageAgent(prompt); 58 } catch (error) { 59 console.error("error in checkBluesky:", error); 60 } finally { 61 + console.log( 62 + "๐Ÿ”น finished proactive bluesky session. waiting for new tasksโ€ฆ", 63 + ); 64 agentContext.proactiveCount++; 65 // schedules next proactive bluesky session 66 setTimeout(
+18 -15
tasks/checkNotifications.ts
··· 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 - import { 7 - msFrom, 8 - msRandomOffset, 9 - msUntilNextWakeWindow, 10 - } from "../utils/time.ts"; 11 import { bsky } from "../utils/bsky.ts"; 12 import { processNotification } from "../utils/processNotification.ts"; 13 14 export const checkNotifications = async () => { 15 if (!claimTaskThread()) { 16 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 17 console.log( 18 - `${agentContext.agentBskyName} is busy, checking for notifications again in ${ 19 (newDelay * 1000) * 60 20 } minutesโ€ฆ`, 21 ); 22 - // agentContext is busy, try to check notifications in 5~10 minutes. 23 setTimeout(checkNotifications, newDelay); 24 return; 25 } 26 27 const delay = msUntilNextWakeWindow( 28 - 0, 29 - msFrom.minutes(90), 30 ); 31 32 if (delay !== 0) { 33 setTimeout(checkNotifications, delay); 34 console.log( 35 - `${agentContext.agentBskyName} is current asleep. scheduling next notification check for ${ 36 (delay / 1000 / 60 / 60).toFixed(2) 37 } hours from nowโ€ฆ`, 38 ); 39 releaseTaskThread(); 40 return; 41 } ··· 54 55 if (unreadNotifications.length > 0) { 56 console.log( 57 - `found ${unreadNotifications.length} notification(s), processingโ€ฆ`, 58 ); 59 60 // resets delay for future notification checks since ··· 69 let notificationCounter = 1; 70 for (const notification of unreadNotifications) { 71 console.log( 72 - `processing notification #${notificationCounter} [${notification.reason} from @${notification.author.handle}]`, 73 ); 74 await processNotification(notification); 75 notificationCounter++; ··· 78 // marks all notifications that were processed as seen 79 // based on time from when retrieved instead of finished 80 await bsky.updateSeenNotifications(startedProcessingTime); 81 - 82 // increases counter for notification processing session 83 agentContext.processingCount++; 84 } else { ··· 90 )); 91 92 console.log( 93 - "no notificationsโ€ฆ", 94 `checking again in ${ 95 (agentContext.notifDelayCurrent / 1000).toFixed(2) 96 } seconds`, ··· 101 // since something went wrong, lets check for notifications again sooner 102 agentContext.notifDelayCurrent = agentContext.notifDelayMinimum; 103 } finally { 104 // actually schedules next time to check for notifications 105 setTimeout(checkNotifications, agentContext.notifDelayCurrent); 106 // ends work
··· 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 + import { msFrom, msUntilNextWakeWindow } from "../utils/time.ts"; 7 import { bsky } from "../utils/bsky.ts"; 8 import { processNotification } from "../utils/processNotification.ts"; 9 10 export const checkNotifications = async () => { 11 if (!claimTaskThread()) { 12 + const newDelay = msFrom.minutes(2); 13 console.log( 14 + `๐Ÿ”น ${agentContext.agentBskyName} is busy, checking for notifications again in ${ 15 (newDelay * 1000) * 60 16 } minutesโ€ฆ`, 17 ); 18 + // agentContext is busy, try to check notifications in 2 minutes. 19 setTimeout(checkNotifications, newDelay); 20 return; 21 } 22 23 const delay = msUntilNextWakeWindow( 24 + msFrom.minutes(30), 25 + msFrom.minutes(45), 26 ); 27 28 if (delay !== 0) { 29 setTimeout(checkNotifications, delay); 30 console.log( 31 + `๐Ÿ”น ${agentContext.agentBskyName} is currently asleep. scheduling next notification check for ${ 32 (delay / 1000 / 60 / 60).toFixed(2) 33 } hours from nowโ€ฆ`, 34 ); 35 + agentContext.notifDelayCurrent = agentContext.notifDelayMinimum; 36 releaseTaskThread(); 37 return; 38 } ··· 51 52 if (unreadNotifications.length > 0) { 53 console.log( 54 + `๐Ÿ”น found ${unreadNotifications.length} notification(s), processingโ€ฆ`, 55 ); 56 57 // resets delay for future notification checks since ··· 66 let notificationCounter = 1; 67 for (const notification of unreadNotifications) { 68 console.log( 69 + `๐Ÿ”น processing notification #${notificationCounter} of #${unreadNotifications.length} [${notification.reason} from @${notification.author.handle}]`, 70 ); 71 await processNotification(notification); 72 notificationCounter++; ··· 75 // marks all notifications that were processed as seen 76 // based on time from when retrieved instead of finished 77 await bsky.updateSeenNotifications(startedProcessingTime); 78 + console.log( 79 + `๐Ÿ”น done processing ${unreadNotifications.length} notification${ 80 + unreadNotifications.length > 1 ? "s" : "" 81 + }โ€ฆ`, 82 + ); 83 // increases counter for notification processing session 84 agentContext.processingCount++; 85 } else { ··· 91 )); 92 93 console.log( 94 + "๐Ÿ”น no notificationsโ€ฆ", 95 `checking again in ${ 96 (agentContext.notifDelayCurrent / 1000).toFixed(2) 97 } seconds`, ··· 102 // since something went wrong, lets check for notifications again sooner 103 agentContext.notifDelayCurrent = agentContext.notifDelayMinimum; 104 } finally { 105 + // increment check count 106 + agentContext.checkCount++; 107 // actually schedules next time to check for notifications 108 setTimeout(checkNotifications, agentContext.notifDelayCurrent); 109 // ends work
+73 -35
tasks/logStats.ts
··· 11 12 export const logStats = () => { 13 if (!claimTaskThread()) { 14 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 15 console.log( 16 - `${agentContext.agentBskyName} is busy, attempting to log counts again in ${ 17 (newDelay / 1000) / 60 18 } minutesโ€ฆ`, 19 ); ··· 22 return; 23 } 24 25 - if (!agentContext.reflectionEnabled) { 26 - console.log( 27 - `${agentContext.agentBskyName} reflection is disabled, skipping logStatsโ€ฆ`, 28 - ); 29 - releaseTaskThread(); 30 - return; 31 - } 32 - 33 const delay = msUntilNextWakeWindow( 34 msFrom.minutes(30), 35 msFrom.hours(1), ··· 38 if (delay !== 0) { 39 setTimeout(logStats, delay); 40 console.log( 41 - `${agentContext.agentBskyName} is current asleep. scheduling next stat log for ${ 42 (delay / 1000 / 60 / 60).toFixed(2) 43 } hours from nowโ€ฆ`, 44 ); ··· 46 return; 47 } 48 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 - }. 66 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))); 74 releaseTaskThread(); 75 };
··· 11 12 export const logStats = () => { 13 if (!claimTaskThread()) { 14 + const newDelay = msFrom.minutes(2); 15 console.log( 16 + `Stat log attempt failed, ${agentContext.agentBskyName} is busy. Next attempt in ${ 17 (newDelay / 1000) / 60 18 } minutesโ€ฆ`, 19 ); ··· 22 return; 23 } 24 25 const delay = msUntilNextWakeWindow( 26 msFrom.minutes(30), 27 msFrom.hours(1), ··· 30 if (delay !== 0) { 31 setTimeout(logStats, delay); 32 console.log( 33 + `๐Ÿ”น ${agentContext.agentBskyName} is currently asleep. scheduling next stat log for ${ 34 (delay / 1000 / 60 / 60).toFixed(2) 35 } hours from nowโ€ฆ`, 36 ); ··· 38 return; 39 } 40 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); 51 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); 112 releaseTaskThread(); 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 15 export const runReflection = async () => { 16 if (!claimTaskThread()) { 17 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 18 19 console.log( 20 - `${agentContext.agentBskyName} is busy, will try reflecting again in ${ 21 (newDelay / 1000) / 60 22 } minutesโ€ฆ`, 23 ); 24 - // session is busy, try to start reflection in 5~10 minutes. 25 setTimeout(runReflection, newDelay); 26 return; 27 } 28 29 if (!agentContext.reflectionEnabled) { 30 console.log( 31 - `Reflection is currently disabled. Provide a minimum and/or maximum delay duration in \`.env\` to enable reflectionsโ€ฆ`, 32 ); 33 releaseTaskThread(); 34 return; 35 } 36 37 - // adds 1-2 hours to wake time 38 // only applies if sleep is enabled 39 const delay = msUntilNextWakeWindow( 40 - msFrom.hours(1), 41 msFrom.hours(2), 42 ); 43 44 if (delay !== 0) { 45 setTimeout(runReflection, delay); 46 console.log( 47 - `${agentContext.agentBskyName} is current asleep. scheduling next reflection for ${ 48 (delay / 1000 / 60 / 60).toFixed(2) 49 } hours from nowโ€ฆ`, 50 ); ··· 53 } 54 55 try { 56 - console.log("starting reflection promptโ€ฆ"); 57 await messageAgent(reflectionPrompt); 58 } catch (error) { 59 console.error("Error in reflectionCheck:", error); ··· 61 resetAgentContextCounts(); 62 agentContext.reflectionCount++; 63 console.log( 64 - "finished reflection prompt. returning to checking for notificationsโ€ฆ", 65 ); 66 // schedules the next reflection, random between the min and max delay 67 setTimeout(
··· 14 15 export const runReflection = async () => { 16 if (!claimTaskThread()) { 17 + const newDelay = msFrom.minutes(2); 18 19 console.log( 20 + `๐Ÿ”น ${agentContext.agentBskyName} is busy, will try reflecting again in ${ 21 (newDelay / 1000) / 60 22 } minutesโ€ฆ`, 23 ); 24 + // session is busy, try to start reflection in 2 minutes. 25 setTimeout(runReflection, newDelay); 26 return; 27 } 28 29 if (!agentContext.reflectionEnabled) { 30 console.log( 31 + `๐Ÿ”น Reflection is currently disabled. Provide a minimum and/or maximum delay duration in \`.env\` to enable reflectionsโ€ฆ`, 32 ); 33 releaseTaskThread(); 34 return; 35 } 36 37 + // adds 2-4 hours to wake time 38 // only applies if sleep is enabled 39 const delay = msUntilNextWakeWindow( 40 msFrom.hours(2), 41 + msFrom.hours(4), 42 ); 43 44 if (delay !== 0) { 45 setTimeout(runReflection, delay); 46 console.log( 47 + `๐Ÿ”น ${agentContext.agentBskyName} is currently asleep. scheduling next reflection for ${ 48 (delay / 1000 / 60 / 60).toFixed(2) 49 } hours from nowโ€ฆ`, 50 ); ··· 53 } 54 55 try { 56 + console.log("๐Ÿ”น starting reflection promptโ€ฆ"); 57 await messageAgent(reflectionPrompt); 58 } catch (error) { 59 console.error("Error in reflectionCheck:", error); ··· 61 resetAgentContextCounts(); 62 agentContext.reflectionCount++; 63 console.log( 64 + "๐Ÿ”น finished reflection prompt. returning to checking for notificationsโ€ฆ", 65 ); 66 // schedules the next reflection, random between the min and max delay 67 setTimeout(
+11 -10
tasks/sendSleepMessage.ts
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 import { 7 getNow, ··· 14 15 export const sendSleepMessage = async () => { 16 if (!claimTaskThread()) { 17 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 18 console.log( 19 - `${agentContext.agentBskyName} is busy, sending sleep message again in ${ 20 (newDelay / 1000) / 60 21 } minutesโ€ฆ`, 22 ); 23 - // session is busy, try to check notifications in 5~10 minutes. 24 setTimeout(sendSleepMessage, newDelay); 25 return; 26 } 27 28 if (!agentContext.sleepEnabled) { 29 console.log( 30 - `${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of sleep messagingโ€ฆ`, 31 ); 32 releaseTaskThread(); 33 return; ··· 35 36 const now = getNow(); 37 38 - if (now.hour >= agentContext.sleepTime) { 39 - console.log(`attempting to wind down ${agentContext.agentBskyName}`); 40 } else { 41 const delay = msUntilDailyWindow( 42 agentContext.sleepTime, 43 0, 44 - msFrom.minutes(20), 45 ); 46 setTimeout(sendSleepMessage, delay); 47 console.log( 48 - `It's too early to wind down ${agentContext.agentBskyName}. scheduling wind down for ${ 49 (delay / 1000 / 60 / 60).toFixed(2) 50 } hours from nowโ€ฆ`, 51 ); ··· 58 } catch (error) { 59 console.error("error in sendSleepMessage: ", error); 60 } finally { 61 - console.log("wind down attempt processed, scheduling next wind downโ€ฆ"); 62 setTimeout( 63 sendSleepMessage, 64 - msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(20)), 65 ); 66 console.log("exiting wind down process"); 67 releaseTaskThread();
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 + isAgentAsleep, 6 } from "../utils/agentContext.ts"; 7 import { 8 getNow, ··· 15 16 export const sendSleepMessage = async () => { 17 if (!claimTaskThread()) { 18 + const newDelay = msFrom.minutes(2); 19 console.log( 20 + `๐Ÿ”น ${agentContext.agentBskyName} is busy, sending sleep message again in ${ 21 (newDelay / 1000) / 60 22 } minutesโ€ฆ`, 23 ); 24 + // session is busy, try to check notifications in 2 minutes. 25 setTimeout(sendSleepMessage, newDelay); 26 return; 27 } 28 29 if (!agentContext.sleepEnabled) { 30 console.log( 31 + `๐Ÿ”น ${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of sleep messagingโ€ฆ`, 32 ); 33 releaseTaskThread(); 34 return; ··· 36 37 const now = getNow(); 38 39 + if (isAgentAsleep(now.hour)) { 40 + console.log(`๐Ÿ”น attempting to wind down ${agentContext.agentBskyName}`); 41 } else { 42 const delay = msUntilDailyWindow( 43 agentContext.sleepTime, 44 0, 45 + msFrom.minutes(30), 46 ); 47 setTimeout(sendSleepMessage, delay); 48 console.log( 49 + `๐Ÿ”น It's too early to wind down ${agentContext.agentBskyName}. scheduling wind down for ${ 50 (delay / 1000 / 60 / 60).toFixed(2) 51 } hours from nowโ€ฆ`, 52 ); ··· 59 } catch (error) { 60 console.error("error in sendSleepMessage: ", error); 61 } finally { 62 + console.log("๐Ÿ”น wind down attempt processed, scheduling next wind downโ€ฆ"); 63 setTimeout( 64 sendSleepMessage, 65 + msUntilDailyWindow(agentContext.sleepTime, 0, msFrom.minutes(30)), 66 ); 67 console.log("exiting wind down process"); 68 releaseTaskThread();
+12 -11
tasks/sendWakeMessage.ts
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 } from "../utils/agentContext.ts"; 6 import { getNow, msFrom, msRandomOffset } from "../utils/time.ts"; 7 import { messageAgent } from "../utils/messageAgent.ts"; ··· 10 11 export const sendWakeMessage = async () => { 12 if (!claimTaskThread()) { 13 - const newDelay = msRandomOffset(msFrom.minutes(5), msFrom.minutes(10)); 14 console.log( 15 - `${agentContext.agentBskyName} is busy, sending wake message again in ${ 16 (newDelay / 1000) / 60 17 } minutesโ€ฆ`, 18 ); 19 - // session is busy, try to check notifications in 5~10 minutes. 20 setTimeout(sendWakeMessage, newDelay); 21 return; 22 } 23 24 if (!agentContext.sleepEnabled) { 25 console.log( 26 - `${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of wake messagingโ€ฆ`, 27 ); 28 releaseTaskThread(); 29 return; ··· 31 32 const now = getNow(); 33 34 - if (now.hour >= agentContext.wakeTime && now.hour < agentContext.sleepTime) { 35 - console.log(`attempting to wake up ${agentContext.agentBskyName}`); 36 } else { 37 const delay = msUntilDailyWindow( 38 agentContext.wakeTime, 39 0, 40 - msFrom.minutes(80), 41 ); 42 setTimeout(sendWakeMessage, delay); 43 console.log( 44 - `${agentContext.agentBskyName} should still be asleep. Scheduling wake message for ${ 45 (delay / 1000 / 60 / 60).toFixed(2) 46 } hours from nowโ€ฆ`, 47 ); ··· 54 } catch (error) { 55 console.error("error in sendWakeMessage: ", error); 56 } finally { 57 - console.log("wake attempt processed, scheduling next wake promptโ€ฆ"); 58 setTimeout( 59 sendWakeMessage, 60 - msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(80)), 61 ); 62 - console.log("exiting wake process"); 63 releaseTaskThread(); 64 } 65 };
··· 2 agentContext, 3 claimTaskThread, 4 releaseTaskThread, 5 + isAgentAwake, 6 } from "../utils/agentContext.ts"; 7 import { getNow, msFrom, msRandomOffset } from "../utils/time.ts"; 8 import { messageAgent } from "../utils/messageAgent.ts"; ··· 11 12 export const sendWakeMessage = async () => { 13 if (!claimTaskThread()) { 14 + const newDelay = msFrom.minutes(2); 15 console.log( 16 + `๐Ÿ”น ${agentContext.agentBskyName} is busy, sending wake message again in ${ 17 (newDelay / 1000) / 60 18 } minutesโ€ฆ`, 19 ); 20 + // session is busy, try to check notifications in 2 minutes. 21 setTimeout(sendWakeMessage, newDelay); 22 return; 23 } 24 25 if (!agentContext.sleepEnabled) { 26 console.log( 27 + `๐Ÿ”น ${agentContext.agentBskyName} is not enabled for sleep mode. Opting out of wake messagingโ€ฆ`, 28 ); 29 releaseTaskThread(); 30 return; ··· 32 33 const now = getNow(); 34 35 + if (isAgentAwake(now.hour)) { 36 + console.log(`๐Ÿ”น attempting to wake up ${agentContext.agentBskyName}`); 37 } else { 38 const delay = msUntilDailyWindow( 39 agentContext.wakeTime, 40 0, 41 + msFrom.minutes(30), 42 ); 43 setTimeout(sendWakeMessage, delay); 44 console.log( 45 + `๐Ÿ”น ${agentContext.agentBskyName} should still be asleep. Scheduling wake message for ${ 46 (delay / 1000 / 60 / 60).toFixed(2) 47 } hours from nowโ€ฆ`, 48 ); ··· 55 } catch (error) { 56 console.error("error in sendWakeMessage: ", error); 57 } finally { 58 + console.log("๐Ÿ”น wake attempt processed, scheduling next wake promptโ€ฆ"); 59 setTimeout( 60 sendWakeMessage, 61 + msUntilDailyWindow(agentContext.wakeTime, 0, msFrom.minutes(30)), 62 ); 63 + console.log("๐Ÿ”น exiting wake process"); 64 releaseTaskThread(); 65 } 66 };
+323 -1
tools/bluesky/create_bluesky_post.py
··· 67 return facets if facets else None 68 69 70 def create_bluesky_post(text: List[str], lang: str = "en-US", reply_to_uri: str = None) -> Dict: 71 """ 72 Create a post or thread on Bluesky using atproto SDK. ··· 145 146 client = Client() 147 client.login(username, password) 148 - 149 initial_reply_ref = None 150 initial_root_ref = None 151 152 if reply_to_uri: 153 try: ··· 168 "status": "error", 169 "message": f"Could not retrieve post data from URI: {reply_to_uri}. The post may not exist or the URI may be incorrect." 170 } 171 172 parent_ref = models.ComAtprotoRepoStrongRef.Main( 173 uri=parent_post.uri, ··· 187 root=root_ref 188 ) 189 initial_root_ref = root_ref 190 191 except Exception as e: 192 return { 193 "status": "error", 194 "message": f"Failed to fetch post to reply to: {str(e)}. Check the URI format and try again." 195 } 196 197 post_urls = [] 198 previous_post_ref = None
··· 67 return facets if facets else None 68 69 70 + def _check_is_self(agent_did: str, target_did: str) -> bool: 71 + """Check 2: Self-Post Check (Free).""" 72 + return agent_did == target_did 73 + 74 + 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 + 364 def create_bluesky_post(text: List[str], lang: str = "en-US", reply_to_uri: str = None) -> Dict: 365 """ 366 Create a post or thread on Bluesky using atproto SDK. ··· 439 440 client = Client() 441 client.login(username, password) 442 + 443 + # --- FETCH PARENT/ROOT REFS --- 444 initial_reply_ref = None 445 initial_root_ref = None 446 + target_did = None 447 + root_did = None 448 + parent_post_record = None 449 450 if reply_to_uri: 451 try: ··· 466 "status": "error", 467 "message": f"Could not retrieve post data from URI: {reply_to_uri}. The post may not exist or the URI may be incorrect." 468 } 469 + 470 + # Extract target DID from parent post 471 + target_did = repo_did 472 + parent_post_record = parent_post.value 473 474 parent_ref = models.ComAtprotoRepoStrongRef.Main( 475 uri=parent_post.uri, ··· 489 root=root_ref 490 ) 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] 497 498 except Exception as e: 499 return { 500 "status": "error", 501 "message": f"Failed to fetch post to reply to: {str(e)}. Check the URI format and try again." 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 + # -------------------------- 518 519 post_urls = [] 520 previous_post_ref = None
+212 -2
tools/bluesky/quote_bluesky_post.py
··· 1 """Bluesky quote posting tool for Letta agents using atproto SDK.""" 2 3 - from typing import List, Dict 4 import os 5 import re 6 7 8 def parse_facets(text: str, client) -> List[Dict]: ··· 67 return facets if facets else None 68 69 70 def quote_bluesky_post(text: List[str], quote_uri: str, lang: str = "en-US") -> str: 71 """ 72 Create a quote post or quote thread on Bluesky that embeds another post. ··· 194 client = Client() 195 client.login(username, password) 196 197 # Fetch the post to quote and create a strong reference 198 try: 199 uri_parts = quote_uri.replace('at://', '').split('/') ··· 305 ) 306 except Exception as e: 307 # Re-raise if it's already one of our formatted error messages 308 - if str(e).startswith("Error:"): 309 raise 310 # Otherwise wrap it with helpful context 311 raise Exception(
··· 1 """Bluesky quote posting tool for Letta agents using atproto SDK.""" 2 3 import os 4 import re 5 + from typing import Dict, List 6 7 8 def parse_facets(text: str, client) -> List[Dict]: ··· 67 return facets if facets else None 68 69 70 + def _check_is_self(agent_did: str, target_did: str) -> bool: 71 + """Check 1: Self-Post Check (Free).""" 72 + return agent_did == target_did 73 + 74 + 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 + 238 def quote_bluesky_post(text: List[str], quote_uri: str, lang: str = "en-US") -> str: 239 """ 240 Create a quote post or quote thread on Bluesky that embeds another post. ··· 362 client = Client() 363 client.login(username, password) 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 + 407 # Fetch the post to quote and create a strong reference 408 try: 409 uri_parts = quote_uri.replace('at://', '').split('/') ··· 515 ) 516 except Exception as e: 517 # Re-raise if it's already one of our formatted error messages 518 + if str(e).startswith("Error:") or str(e).startswith("Message"): 519 raise 520 # Otherwise wrap it with helpful context 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 } from "./const.ts"; 15 import { msFrom } from "./time.ts"; 16 import { bsky } from "./bsky.ts"; 17 18 export const getLettaApiKey = (): string => { 19 const value = Deno.env.get("LETTA_API_KEY")?.trim(); ··· 47 return value; 48 }; 49 50 - export const getLettaProjectName = (): string => { 51 - const value = Deno.env.get("LETTA_PROJECT_NAME")?.trim(); 52 53 if (!value?.length) { 54 throw Error( 55 - "Letta Project Name not provided in `.env`. add variable `LETTA_PROJECT_NAME=`.", 56 ); 57 } 58 59 return value; 60 }; 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 81 const getAgentBskyHandle = (): string => { 82 const value = Deno.env.get("BSKY_USERNAME")?.trim(); ··· 220 }; 221 222 const getNotifDelayMinimum = (): number => { 223 - const value = Number(Deno.env.get("NOTIF_DELAY_MINIMUM")); 224 225 if (isNaN(value) || value < msFrom.seconds(1) || value > msFrom.hours(24)) { 226 - return msFrom.seconds(2); 227 } 228 229 return value; 230 }; 231 232 const getNotifDelayMaximum = (): number => { 233 - const value = Number(Deno.env.get("NOTIF_DELAY_MAXIMUM")); 234 235 if (isNaN(value) || value < msFrom.seconds(5) || value > msFrom.hours(24)) { 236 - return msFrom.hours(1); 237 } 238 239 const minimum = getNotifDelayMinimum(); ··· 251 const value = Number(Deno.env.get("NOTIF_DELAY_MULTIPLIER")); 252 253 if (isNaN(value) || value < 0 || value > 500) { 254 - return 1.05; 255 } 256 257 return (value / 100) + 1; 258 }; 259 260 const getReflectionDelayMinimum = (): number => { 261 - const value = Number(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 262 263 if (isNaN(value) || value < msFrom.minutes(30) || value > msFrom.hours(24)) { 264 - return msFrom.minutes(30); 265 } 266 267 return value; 268 }; 269 270 const getReflectionDelayMaximum = (): number => { 271 - const value = Number(Deno.env.get("REFLECTION_DELAY_MAXIMUM")); 272 const minimum = getReflectionDelayMinimum(); 273 274 if (isNaN(value) || value < msFrom.minutes(60) || value > msFrom.hours(24)) { 275 - return msFrom.hours(8); 276 } 277 278 if (value <= minimum) { ··· 285 }; 286 287 const getProactiveDelayMinimum = (): number => { 288 - const value = Number(Deno.env.get("PROACTIVE_DELAY_MINIMUM")); 289 290 if (isNaN(value) || value < msFrom.hours(1) || value > msFrom.hours(24)) { 291 - return msFrom.hours(1); 292 } 293 294 return value; 295 }; 296 297 const getProactiveDelayMaximum = (): number => { 298 - const value = Number(Deno.env.get("PROACTIVE_DELAY_MAXIMUM")); 299 const minimum = getProactiveDelayMinimum(); 300 301 if (isNaN(value) || value < msFrom.hours(3) || value > msFrom.hours(24)) { 302 - return msFrom.hours(12); 303 } 304 305 if (value <= minimum) { ··· 312 }; 313 314 const getWakeTime = (): number => { 315 - const value = Math.round(Number(Deno.env.get("WAKE_TIME"))); 316 317 - if (!value) { 318 return 8; 319 } 320 321 if (value > 23) { 322 throw Error(`"WAKE_TIME" cannot be greater than 23 (11pm)`); 323 } ··· 330 }; 331 332 const getSleepTime = (): number => { 333 - const value = Math.round(Number(Deno.env.get("SLEEP_TIME"))); 334 335 - if (!value) { 336 return 10; 337 } 338 339 if (value > 23) { ··· 501 ); 502 }; 503 504 const populateAgentContext = async (): Promise<agentContextObject> => { 505 - console.log("building new agentContext objectโ€ฆ"); 506 const context: agentContextObject = { 507 // state 508 busy: false, ··· 517 mentionCount: 0, 518 replyCount: 0, 519 quoteCount: 0, 520 // required with manual variables 521 - lettaProjectIdentifier: getLettaProjectName(), 522 agentBskyHandle: getAgentBskyHandle(), 523 agentBskyName: await getAgentBskyName(), 524 agentBskyDID: setAgentBskyDID(), ··· 540 timeZone: getTimeZone(), 541 responsiblePartyType: getResponsiblePartyType(), 542 preserveAgentMemory: getPreserveMemoryBlocks(), 543 reflectionEnabled: setReflectionEnabled(), 544 proactiveEnabled: setProactiveEnabled(), 545 sleepEnabled: setSleepEnabled(), ··· 560 if (responsiblePartyBsky) { 561 context.responsiblePartyBsky = responsiblePartyBsky; 562 } 563 console.log( 564 - `\`agentContext\` object built for ${context.agentBskyName}, BEGIN TASKโ€ฆ`, 565 ); 566 return context; 567 }; ··· 585 agentContext.mentionCount = 0; 586 agentContext.replyCount = 0; 587 agentContext.quoteCount = 0; 588 - agentContext.checkCount = 0; 589 - agentContext.processingCount = 0; 590 };
··· 14 } from "./const.ts"; 15 import { msFrom } from "./time.ts"; 16 import { bsky } from "./bsky.ts"; 17 + import { 18 + isAgentAsleep as checkIsAsleep, 19 + isAgentAwake as checkIsAwake, 20 + } from "./sleepWakeHelpers.ts"; 21 22 export const getLettaApiKey = (): string => { 23 const value = Deno.env.get("LETTA_API_KEY")?.trim(); ··· 51 return value; 52 }; 53 54 + const getLettaProjectID = (): string => { 55 + const value = Deno.env.get("LETTA_PROJECT_ID")?.trim(); 56 57 if (!value?.length) { 58 throw Error( 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`", 64 ); 65 } 66 67 return value; 68 }; 69 70 const getAgentBskyHandle = (): string => { 71 const value = Deno.env.get("BSKY_USERNAME")?.trim(); ··· 209 }; 210 211 const getNotifDelayMinimum = (): number => { 212 + const value = msFrom.parse(Deno.env.get("NOTIF_DELAY_MINIMUM")); 213 214 if (isNaN(value) || value < msFrom.seconds(1) || value > msFrom.hours(24)) { 215 + return msFrom.seconds(10); 216 } 217 218 return value; 219 }; 220 221 const getNotifDelayMaximum = (): number => { 222 + const value = msFrom.parse(Deno.env.get("NOTIF_DELAY_MAXIMUM")); 223 224 if (isNaN(value) || value < msFrom.seconds(5) || value > msFrom.hours(24)) { 225 + return msFrom.minutes(90); 226 } 227 228 const minimum = getNotifDelayMinimum(); ··· 240 const value = Number(Deno.env.get("NOTIF_DELAY_MULTIPLIER")); 241 242 if (isNaN(value) || value < 0 || value > 500) { 243 + return 1.12; 244 } 245 246 return (value / 100) + 1; 247 }; 248 249 + const getMaxThreadPosts = (): number => { 250 + const value = Number(Deno.env.get("MAX_THREAD_POSTS")); 251 + 252 + if (isNaN(value) || value < 5 || value > 250) { 253 + return 25; 254 + } 255 + 256 + return Math.round(value); 257 + }; 258 + 259 const getReflectionDelayMinimum = (): number => { 260 + const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MINIMUM")); 261 262 if (isNaN(value) || value < msFrom.minutes(30) || value > msFrom.hours(24)) { 263 + return msFrom.hours(3); 264 } 265 266 return value; 267 }; 268 269 const getReflectionDelayMaximum = (): number => { 270 + const value = msFrom.parse(Deno.env.get("REFLECTION_DELAY_MAXIMUM")); 271 const minimum = getReflectionDelayMinimum(); 272 273 if (isNaN(value) || value < msFrom.minutes(60) || value > msFrom.hours(24)) { 274 + return msFrom.hours(14); 275 } 276 277 if (value <= minimum) { ··· 284 }; 285 286 const getProactiveDelayMinimum = (): number => { 287 + const value = msFrom.parse(Deno.env.get("PROACTIVE_DELAY_MINIMUM")); 288 289 if (isNaN(value) || value < msFrom.hours(1) || value > msFrom.hours(24)) { 290 + return msFrom.hours(3); 291 } 292 293 return value; 294 }; 295 296 const getProactiveDelayMaximum = (): number => { 297 + const value = msFrom.parse(Deno.env.get("PROACTIVE_DELAY_MAXIMUM")); 298 const minimum = getProactiveDelayMinimum(); 299 300 if (isNaN(value) || value < msFrom.hours(3) || value > msFrom.hours(24)) { 301 + return msFrom.hours(14); 302 } 303 304 if (value <= minimum) { ··· 311 }; 312 313 const getWakeTime = (): number => { 314 + const envValue = Deno.env.get("WAKE_TIME"); 315 316 + if (envValue === undefined || envValue === null || envValue === "") { 317 return 8; 318 } 319 320 + const value = Math.round(Number(envValue)); 321 + 322 + if (isNaN(value)) { 323 + throw Error(`"WAKE_TIME" must be a valid number, got: "${envValue}"`); 324 + } 325 + 326 if (value > 23) { 327 throw Error(`"WAKE_TIME" cannot be greater than 23 (11pm)`); 328 } ··· 335 }; 336 337 const getSleepTime = (): number => { 338 + const envValue = Deno.env.get("SLEEP_TIME"); 339 340 + if (envValue === undefined || envValue === null || envValue === "") { 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}"`); 348 } 349 350 if (value > 23) { ··· 512 ); 513 }; 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 + 553 const populateAgentContext = async (): Promise<agentContextObject> => { 554 + console.log("๐Ÿ”น building new agentContext objectโ€ฆ"); 555 const context: agentContextObject = { 556 // state 557 busy: false, ··· 566 mentionCount: 0, 567 replyCount: 0, 568 quoteCount: 0, 569 + notifCount: 0, 570 // required with manual variables 571 + lettaProjectIdentifier: getLettaProjectID(), 572 agentBskyHandle: getAgentBskyHandle(), 573 agentBskyName: await getAgentBskyName(), 574 agentBskyDID: setAgentBskyDID(), ··· 590 timeZone: getTimeZone(), 591 responsiblePartyType: getResponsiblePartyType(), 592 preserveAgentMemory: getPreserveMemoryBlocks(), 593 + maxThreadPosts: getMaxThreadPosts(), 594 reflectionEnabled: setReflectionEnabled(), 595 proactiveEnabled: setProactiveEnabled(), 596 sleepEnabled: setSleepEnabled(), ··· 611 if (responsiblePartyBsky) { 612 context.responsiblePartyBsky = responsiblePartyBsky; 613 } 614 + 615 + const externalServices = getExternalServices(); 616 + if (externalServices) { 617 + context.externalServices = externalServices; 618 + } 619 console.log( 620 + `๐Ÿ”น \`agentContext\` object built for ${context.agentBskyName}, BEGINING TASKSโ€ฆ`, 621 ); 622 return context; 623 }; ··· 641 agentContext.mentionCount = 0; 642 agentContext.replyCount = 0; 643 agentContext.quoteCount = 0; 644 + }; 645 + 646 + export const isAgentAwake = (hour: number): boolean => { 647 + return checkIsAwake(hour, agentContext.wakeTime, agentContext.sleepTime); 648 + }; 649 + 650 + export const isAgentAsleep = (hour: number): boolean => { 651 + return checkIsAsleep(hour, agentContext.wakeTime, agentContext.sleepTime); 652 };
+39 -89
utils/declaration.ts
··· 1 import { bsky } from "../utils/bsky.ts"; 2 - import type { AutonomyDeclarationRecord } from "./types.ts"; 3 import { Lexicons } from "@atproto/lexicon"; 4 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 - }, 90 }; 91 92 export const createAutonomyDeclarationRecord = async () => { 93 const automationLevel = Deno.env.get("AUTOMATION_LEVEL")?.toLowerCase(); 94 const projectDescription = Deno.env.get("PROJECT_DESCRIPTION"); ··· 119 // Add disclosure URL if provided 120 if (disclosureUrl?.trim()) { 121 declarationRecord.disclosureUrl = disclosureUrl.trim(); 122 } 123 124 // Build responsible party object if any fields are provided ··· 201 rkey: "self", 202 }); 203 exists = true; 204 - console.log("Existing autonomy declaration found - updating..."); 205 } catch (error: any) { 206 // Handle "record not found" errors (status 400 with error: "RecordNotFound") 207 const isNotFound = ··· 211 error?.message?.includes("Could not locate record"); 212 213 if (isNotFound) { 214 - console.log("No existing autonomy declaration found - creating new..."); 215 } else { 216 // Re-throw if it's not a "not found" error 217 throw error; ··· 227 }); 228 229 console.log( 230 - `Autonomy declaration ${exists ? "updated" : "created"} successfully:`, 231 result, 232 ); 233 return result;
··· 1 import { bsky } from "../utils/bsky.ts"; 2 + import type { AutonomyDeclaration } from "@voyager/autonomy-lexicon"; 3 + import { AUTONOMY_DECLARATION_LEXICON } from "@voyager/autonomy-lexicon"; 4 import { Lexicons } from "@atproto/lexicon"; 5 + import { agentContext } from "./agentContext.ts"; 6 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; 14 }; 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 + 35 export const createAutonomyDeclarationRecord = async () => { 36 const automationLevel = Deno.env.get("AUTOMATION_LEVEL")?.toLowerCase(); 37 const projectDescription = Deno.env.get("PROJECT_DESCRIPTION"); ··· 62 // Add disclosure URL if provided 63 if (disclosureUrl?.trim()) { 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; 70 } 71 72 // Build responsible party object if any fields are provided ··· 149 rkey: "self", 150 }); 151 exists = true; 152 + console.log("๐Ÿ”น Existing autonomy declaration found - updating..."); 153 } catch (error: any) { 154 // Handle "record not found" errors (status 400 with error: "RecordNotFound") 155 const isNotFound = ··· 159 error?.message?.includes("Could not locate record"); 160 161 if (isNotFound) { 162 + console.log( 163 + "๐Ÿ”น No existing autonomy declaration found - creating new...", 164 + ); 165 } else { 166 // Re-throw if it's not a "not found" error 167 throw error; ··· 177 }); 178 179 console.log( 180 + `๐Ÿ”น Autonomy declaration ${exists ? "updated" : "created"} successfully:`, 181 result, 182 ); 183 return result;
+54 -3
utils/getCleanThread.ts
··· 1 import { bsky } from "./bsky.ts"; 2 3 type threadPost = { 4 authorHandle: string; ··· 13 quotes: number; 14 }; 15 16 - export const getCleanThread = async (uri: string): Promise<threadPost[]> => { 17 const res = await bsky.getPostThread({ uri: uri }); 18 const { thread } = res.data; 19 20 - const postsThread: threadPost[] = []; 21 22 // Type guard to check if thread is a ThreadViewPost 23 if (thread && "post" in thread) { ··· 37 // Now traverse the parent chain 38 if ("parent" in thread) { 39 let current = thread.parent; 40 41 - while (current && "post" in current) { 42 postsThread.push({ 43 authorHandle: `@${current.post.author.handle}`, 44 message: (current.post.record as { text: string }).text, ··· 51 likes: current.post.likeCount ?? 0, 52 quotes: current.post.quoteCount ?? 0, 53 }); 54 current = "parent" in current ? current.parent : undefined; 55 } 56 postsThread.reverse(); 57 } 58 } 59 60 return postsThread; 61 };
··· 1 import { bsky } from "./bsky.ts"; 2 + import { agentContext } from "./agentContext.ts"; 3 4 type threadPost = { 5 authorHandle: string; ··· 14 quotes: number; 15 }; 16 17 + type threadTruncationIndicator = { 18 + message: string; 19 + }; 20 + 21 + type threadItem = threadPost | threadTruncationIndicator; 22 + 23 + export const getCleanThread = async (uri: string): Promise<threadItem[]> => { 24 const res = await bsky.getPostThread({ uri: uri }); 25 const { thread } = res.data; 26 27 + const postsThread: threadItem[] = []; 28 29 // Type guard to check if thread is a ThreadViewPost 30 if (thread && "post" in thread) { ··· 44 // Now traverse the parent chain 45 if ("parent" in thread) { 46 let current = thread.parent; 47 + let postCount = 1; // Start at 1 for the main post 48 + let wasTruncated = false; 49 50 + // Collect up to configured limit of posts 51 + while (current && "post" in current && postCount < agentContext.maxThreadPosts) { 52 postsThread.push({ 53 authorHandle: `@${current.post.author.handle}`, 54 message: (current.post.record as { text: string }).text, ··· 61 likes: current.post.likeCount ?? 0, 62 quotes: current.post.quoteCount ?? 0, 63 }); 64 + postCount++; 65 current = "parent" in current ? current.parent : undefined; 66 } 67 + 68 + // Check if we stopped early (thread is longer than configured limit) 69 + if (current && "post" in current) { 70 + wasTruncated = true; 71 + 72 + // Continue traversing to find the root post without collecting 73 + while (current && "parent" in current) { 74 + current = current.parent; 75 + } 76 + 77 + // Extract the root post 78 + if (current && "post" in current) { 79 + postsThread.push({ 80 + authorHandle: `@${current.post.author.handle}`, 81 + message: (current.post.record as { text: string }).text, 82 + uri: current.post.uri, 83 + authorDID: current.post.author.did, 84 + postedDateTime: (current.post.record as { createdAt: string }).createdAt, 85 + bookmarks: current.post.bookmarkCount ?? 0, 86 + replies: current.post.replyCount ?? 0, 87 + reposts: current.post.repostCount ?? 0, 88 + likes: current.post.likeCount ?? 0, 89 + quotes: current.post.quoteCount ?? 0, 90 + }); 91 + } 92 + } 93 + 94 + // Reverse and insert truncation indicator if needed 95 postsThread.reverse(); 96 + 97 + if (wasTruncated) { 98 + const limit = agentContext.maxThreadPosts; 99 + const truncationIndicator: threadTruncationIndicator = { 100 + message: `This thread exceeded ${limit} posts. This includes the ${limit} most recent posts and the root post that started the thread.`, 101 + }; 102 + postsThread.splice(1, 0, truncationIndicator); 103 + } 104 } 105 } 106 107 return postsThread; 108 }; 109 + 110 + export const isThreadPost = (item: threadItem): item is threadPost => { 111 + return "authorHandle" in item; 112 + };
+157 -34
utils/messageAgent.ts
··· 1 - import { LettaClient } from "@letta-ai/letta-client"; 2 import { agentContext } from "./agentContext.ts"; 3 // Helper function to format tool arguments as inline key-value pairs 4 - const formatArgsInline = (args: unknown): string => { 5 try { 6 const parsed = typeof args === "string" ? JSON.parse(args) : args; 7 if (typeof parsed !== "object" || parsed === null) { ··· 9 } 10 return Object.entries(parsed) 11 .map(([key, value]) => { 12 - const valueStr = typeof value === "object" 13 ? JSON.stringify(value) 14 : String(value); 15 return `${key}=${valueStr}`; 16 }) 17 .join(", "); ··· 21 }; 22 23 // Helper function to truncate long strings to 500 characters 24 - const truncateString = (str: string, maxLength = 500): string => { 25 if (str.length <= maxLength) { 26 return str; 27 } 28 return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`; 29 }; 30 31 - export const client = new LettaClient({ 32 - token: Deno.env.get("LETTA_API_KEY"), 33 - project: Deno.env.get("LETTA_PROJECT_NAME"), 34 }); 35 36 export const messageAgent = async (prompt: string) => { 37 const agent = Deno.env.get("LETTA_AGENT_ID"); 38 39 if (agent) { 40 - const reachAgent = await client.agents.messages.createStream(agent, { 41 messages: [ 42 { 43 - role: "user", 44 - content: [ 45 - { 46 - type: "text", 47 - text: prompt, 48 - }, 49 - ], 50 }, 51 ], 52 - streamTokens: true, 53 }); 54 55 for await (const response of reachAgent) { 56 - if (response.messageType === "reasoning_message") { 57 - console.log(`๐Ÿ’ญ reasoningโ€ฆ`); 58 - } else if (response.messageType === "assistant_message") { 59 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") { 74 console.log(`hidden reasoningโ€ฆ`); 75 } 76 } 77 } else { 78 console.log( 79 - "Letta agent ID was not a set variable, skipping notification processingโ€ฆ", 80 ); 81 } 82 };
··· 1 + import Letta from "@letta-ai/letta-client"; 2 import { agentContext } from "./agentContext.ts"; 3 // Helper function to format tool arguments as inline key-value pairs 4 + const formatArgsInline = (args: unknown, maxValueLength = 50): string => { 5 try { 6 const parsed = typeof args === "string" ? JSON.parse(args) : args; 7 if (typeof parsed !== "object" || parsed === null) { ··· 9 } 10 return Object.entries(parsed) 11 .map(([key, value]) => { 12 + let valueStr = typeof value === "object" 13 ? JSON.stringify(value) 14 : String(value); 15 + // Truncate long values at word boundaries 16 + if (valueStr.length > maxValueLength) { 17 + const truncated = valueStr.slice(0, maxValueLength); 18 + const lastSpace = truncated.lastIndexOf(" "); 19 + // If we found a space in the last 30% of the truncated string, use it 20 + valueStr = lastSpace > maxValueLength * 0.7 21 + ? truncated.slice(0, lastSpace) + "..." 22 + : truncated + "..."; 23 + } 24 return `${key}=${valueStr}`; 25 }) 26 .join(", "); ··· 30 }; 31 32 // Helper function to truncate long strings to 500 characters 33 + const truncateString = (str: string, maxLength = 140): string => { 34 if (str.length <= maxLength) { 35 return str; 36 } 37 return `${str.slice(0, maxLength)}... (truncated, ${str.length} total chars)`; 38 }; 39 40 + // Helper function to extract tool return value from wrapper structure 41 + const extractToolReturn = (toolReturns: unknown): string => { 42 + try { 43 + // If it's already a string, return it 44 + if (typeof toolReturns === "string") { 45 + return toolReturns; 46 + } 47 + 48 + // If it's an array, extract the tool_return from first element 49 + if (Array.isArray(toolReturns) && toolReturns.length > 0) { 50 + const firstReturn = toolReturns[0]; 51 + if ( 52 + typeof firstReturn === "object" && 53 + firstReturn !== null && 54 + "tool_return" in firstReturn 55 + ) { 56 + const toolReturn = firstReturn.tool_return; 57 + // If tool_return is already a string, return it 58 + if (typeof toolReturn === "string") { 59 + return toolReturn; 60 + } 61 + // Otherwise stringify it 62 + return JSON.stringify(toolReturn); 63 + } 64 + } 65 + 66 + // Fallback: return stringified version of the whole thing 67 + return JSON.stringify(toolReturns); 68 + } catch { 69 + return String(toolReturns); 70 + } 71 + }; 72 + 73 + // Helper function to select important params for logging 74 + const selectImportantParams = (args: unknown): unknown => { 75 + try { 76 + const parsed = typeof args === "string" ? JSON.parse(args) : args; 77 + if (typeof parsed !== "object" || parsed === null) { 78 + return parsed; 79 + } 80 + 81 + const entries = Object.entries(parsed); 82 + 83 + // Filter out URIs/DIDs and very long values 84 + const filtered = entries.filter(([key, value]) => { 85 + const str = String(value); 86 + // Skip AT URIs and DIDs 87 + if (str.includes("at://") || str.includes("did:plc:")) return false; 88 + // Skip very long values 89 + if (str.length > 60) return false; 90 + return true; 91 + }); 92 + 93 + // Take first 3, or first entry if none pass filters 94 + const selected = filtered.length > 0 95 + ? filtered.slice(0, 3) 96 + : entries.slice(0, 1); 97 + 98 + return Object.fromEntries(selected); 99 + } catch { 100 + return args; 101 + } 102 + }; 103 + 104 + // Helper function to format tool response for logging 105 + const formatToolResponse = (returnValue: string): string => { 106 + try { 107 + // Try to parse as JSON - handle both JSON and Python dict syntax 108 + let parsed; 109 + try { 110 + // First try standard JSON 111 + parsed = JSON.parse(returnValue); 112 + } catch { 113 + // Try to parse Python-style dict (with single quotes and None/True/False) 114 + const pythonToJson = returnValue 115 + .replace(/'/g, '"') 116 + .replace(/\bNone\b/g, "null") 117 + .replace(/\bTrue\b/g, "true") 118 + .replace(/\bFalse\b/g, "false"); 119 + parsed = JSON.parse(pythonToJson); 120 + } 121 + 122 + // Handle arrays - show count and sample first item 123 + if (Array.isArray(parsed)) { 124 + const count = parsed.length; 125 + if (count === 0) return "[]"; 126 + 127 + const firstItem = parsed[0]; 128 + if (typeof firstItem === "object" && firstItem !== null) { 129 + // Format first item's key fields (use 30 for samples to keep concise) 130 + const sample = formatArgsInline(firstItem, 30); 131 + return `[${count} items] ${sample}`; 132 + } 133 + return `[${count} items]`; 134 + } 135 + 136 + // If parsed successfully and it's an object, format as key=value pairs 137 + if (typeof parsed === "object" && parsed !== null) { 138 + return `(${formatArgsInline(parsed, 50)})`; 139 + } 140 + 141 + // If it's a primitive, return the original string 142 + return returnValue; 143 + } catch { 144 + // If parsing fails, return as-is (it's a simple string) 145 + return returnValue; 146 + } 147 + }; 148 + 149 + export const client = new Letta({ 150 + apiKey: Deno.env.get("LETTA_API_KEY"), 151 + // @ts-ignore: Letta SDK type definition might be slightly off or expecting different casing 152 + projectId: Deno.env.get("LETTA_PROJECT_ID"), 153 }); 154 155 export const messageAgent = async (prompt: string) => { 156 const agent = Deno.env.get("LETTA_AGENT_ID"); 157 158 if (agent) { 159 + const reachAgent = await client.agents.messages.stream(agent, { 160 messages: [ 161 { 162 + role: "system", 163 + content: prompt, 164 }, 165 ], 166 + stream_tokens: true, 167 }); 168 169 + let lastToolName = ""; 170 + 171 for await (const response of reachAgent) { 172 + if (response.message_type === "reasoning_message") { 173 + // console.log(`๐Ÿ’ญ reasoningโ€ฆ`); 174 + } else if (response.message_type === "assistant_message") { 175 console.log(`๐Ÿ’ฌ ${agentContext.agentBskyName}: ${response.content}`); 176 + } else if (response.message_type === "tool_call_message") { 177 + // Use tool_call (singular) or tool_calls (both are objects, not arrays) 178 + const toolCall = response.tool_call || response.tool_calls; 179 + if (toolCall && toolCall.name) { 180 + lastToolName = toolCall.name; 181 + const importantParams = selectImportantParams(toolCall.arguments); 182 + const formattedArgs = formatArgsInline(importantParams); 183 + console.log(`๐Ÿ”ง tool called: ${toolCall.name} (${formattedArgs})`); 184 + } 185 + } else if (response.message_type === "tool_return_message") { 186 + const extractedReturn = extractToolReturn(response.tool_returns); 187 + const formattedResponse = formatToolResponse(extractedReturn); 188 + 189 + // Determine separator based on format 190 + const separator = formattedResponse.startsWith("(") ? " " : ": "; 191 + const logMessage = `โ†ฉ๏ธ tool response: ${lastToolName}${separator}${formattedResponse}`; 192 + 193 + console.log(truncateString(logMessage, 300)); 194 + } else if (response.message_type === "usage_statistics") { 195 + console.log(`๐Ÿ”ข total steps: ${response.step_count}`); 196 + } else if (response.message_type === "hidden_reasoning_message") { 197 console.log(`hidden reasoningโ€ฆ`); 198 } 199 } 200 } else { 201 console.log( 202 + "๐Ÿ”น Letta agent ID was not a set variable, skipping notification processingโ€ฆ", 203 ); 204 } 205 };
+6 -5
utils/processNotification.ts
··· 37 } as const; 38 39 export const processNotification = async (notification: Notification) => { 40 - const agentProject = Deno.env.get("LETTA_PROJECT_NAME"); 41 const kind = notification.reason; 42 const author = `@${notification.author.handle}`; 43 const handler = notificationHandlers[kind]; 44 45 if (!handler) { 46 console.log( 47 - `kind "${kind}" does not have a system prompt associated with it, moving onโ€ฆ`, 48 ); 49 console.log("notification response: ", notification); 50 return; ··· 54 const prompt = await handler.promptFn(notification); 55 await messageAgent(prompt); 56 console.log( 57 - `sent ${kind} notification from ${author} to ${agentProject}. moving onโ€ฆ`, 58 ); 59 } catch (error) { 60 console.log( 61 - `Error processing ${kind} notification from ${author}: `, 62 error, 63 ); 64 } finally { 65 - (agentContext as any)[handler]++; 66 } 67 };
··· 37 } as const; 38 39 export const processNotification = async (notification: Notification) => { 40 + const agentName = agentContext.agentBskyName; 41 const kind = notification.reason; 42 const author = `@${notification.author.handle}`; 43 const handler = notificationHandlers[kind]; 44 45 if (!handler) { 46 console.log( 47 + `๐Ÿ”น kind "${kind}" does not have a system prompt associated with it, moving onโ€ฆ`, 48 ); 49 console.log("notification response: ", notification); 50 return; ··· 54 const prompt = await handler.promptFn(notification); 55 await messageAgent(prompt); 56 console.log( 57 + `๐Ÿ”น sent ${kind} notification from ${author} to ${agentName}. moving onโ€ฆ`, 58 ); 59 } catch (error) { 60 console.log( 61 + `๐Ÿ”น Error processing ${kind} notification from ${author}: `, 62 error, 63 ); 64 } finally { 65 + (agentContext as any)[handler.counter]++; 66 + agentContext.notifCount++; 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 import { Temporal } from "@js-temporal/polyfill"; 3 4 /** 5 * Convert time units to milliseconds 6 */ 7 export const msFrom = { ··· 20 * @param h - number of hours 21 */ 22 hours: (hours: number): number => hours * 60 * 60 * 1000, 23 }; 24 25 /** ··· 127 export const getNow = () => { 128 return Temporal.Now.zonedDateTimeISO(agentContext.timeZone); 129 };
··· 2 import { Temporal } from "@js-temporal/polyfill"; 3 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 + /** 55 * Convert time units to milliseconds 56 */ 57 export const msFrom = { ··· 70 * @param h - number of hours 71 */ 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, 79 }; 80 81 /** ··· 183 export const getNow = () => { 184 return Temporal.Now.zonedDateTimeISO(agentContext.timeZone); 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 validAutomationLevels, 7 validNotifTypes, 8 } from "./const.ts"; 9 export type Notification = AppBskyNotificationListNotifications.Notification; 10 11 - export type AutomationLevel = typeof validAutomationLevels[number]; 12 - export type ResponsiblePartyType = "person" | "organization"; 13 14 export type notifType = typeof validNotifTypes[number]; 15 ··· 31 mentionCount: number; 32 replyCount: number; 33 quoteCount: number; 34 // required manual variables 35 lettaProjectIdentifier: string; 36 agentBskyHandle: string; ··· 54 timeZone: string; 55 responsiblePartyType: string; // person / organization 56 preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks 57 // set automatically 58 agentBskyDID: string; 59 reflectionEnabled: boolean; ··· 64 automationDescription?: string; // short description of what this agent does 65 disclosureUrl?: string; // url to a ToS/Privacy Policy style page 66 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) 94 }; 95 96 export type memoryBlock = {
··· 6 validAutomationLevels, 7 validNotifTypes, 8 } from "./const.ts"; 9 + import type { 10 + AutomationLevel, 11 + AutonomyDeclaration, 12 + ResponsibleParty, 13 + ResponsiblePartyType, 14 + } from "@voyager/autonomy-lexicon"; 15 + 16 export type Notification = AppBskyNotificationListNotifications.Notification; 17 18 + // Re-export types from autonomy-lexicon package 19 + export type { 20 + AutomationLevel, 21 + AutonomyDeclaration, 22 + ResponsibleParty, 23 + ResponsiblePartyType, 24 + }; 25 26 export type notifType = typeof validNotifTypes[number]; 27 ··· 43 mentionCount: number; 44 replyCount: number; 45 quoteCount: number; 46 + notifCount: number; 47 // required manual variables 48 lettaProjectIdentifier: string; 49 agentBskyHandle: string; ··· 67 timeZone: string; 68 responsiblePartyType: string; // person / organization 69 preserveAgentMemory: boolean; // if true, mount won't update existing memory blocks 70 + maxThreadPosts: number; // maximum number of posts to include in thread context 71 // set automatically 72 agentBskyDID: string; 73 reflectionEnabled: boolean; ··· 78 automationDescription?: string; // short description of what this agent does 79 disclosureUrl?: string; // url to a ToS/Privacy Policy style page 80 responsiblePartyBsky?: string; // handle w/o @ or DID of responsible party 81 + externalServices?: string[]; // external tools/services this agent relies on 82 }; 83 84 export type memoryBlock = {