├── .dockerignore ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .swcrc ├── Dockerfile ├── LICENSE ├── README.md ├── configs ├── .gitignore ├── config.yaml-example └── config.yaml-template ├── docs └── configuration.md ├── package.json ├── pnpm-lock.yaml ├── scripts ├── build_docker.sh ├── run_docker.sh └── run_docker_clean.sh ├── src ├── core │ └── athena.ts ├── main.ts ├── plugins │ ├── amadeus │ │ ├── amadeus.d.ts │ │ └── init.ts │ ├── athena │ │ └── init.ts │ ├── browser │ │ ├── browser-use.ts │ │ ├── dom.ts │ │ └── init.ts │ ├── cerebrum │ │ └── init.ts │ ├── cli-ui │ │ ├── components │ │ │ └── App.tsx │ │ └── init.ts │ ├── clock │ │ └── init.ts │ ├── discord │ │ └── init.ts │ ├── file-system │ │ └── init.ts │ ├── http │ │ ├── exa.ts │ │ ├── init.ts │ │ ├── jina.ts │ │ └── tavily.ts │ ├── llm │ │ └── init.ts │ ├── long-term-memory │ │ └── init.ts │ ├── plugin-base.ts │ ├── python │ │ └── init.ts │ ├── shell │ │ ├── init.ts │ │ └── shell-process.ts │ ├── short-term-memory │ │ └── init.ts │ ├── telegram │ │ └── init.ts │ └── webapp-ui │ │ ├── init.ts │ │ ├── logger.ts │ │ └── message.ts └── utils │ ├── constants.ts │ ├── crypto.ts │ └── logger.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | dist/ 4 | configs/ 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | typecheck: 17 | name: Typecheck 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v4 23 | with: 24 | version: 10 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: .nvmrc 29 | cache: "pnpm" 30 | - name: Install dependencies 31 | run: pnpm install 32 | - name: Run typecheck 33 | run: pnpm run typecheck 34 | prettier: 35 | name: Prettier 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: Install pnpm 40 | uses: pnpm/action-setup@v4 41 | with: 42 | version: 10 43 | - name: Setup Node.js 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version-file: .nvmrc 47 | cache: "pnpm" 48 | - name: Install dependencies 49 | run: pnpm install 50 | - name: Run prettier check 51 | run: pnpm run lint 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://swc.rs/schema.json", 3 | "minify": true, 4 | "jsc": { 5 | "target": "es2020", 6 | "parser": { 7 | "syntax": "typescript", 8 | "tsx": false 9 | }, 10 | "minify": { 11 | "compress": true, 12 | "mangle": true, 13 | "format": { 14 | "comments": false 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22 2 | RUN apt-get update -qq && \ 3 | apt-get install -y python3 python3-pip python-is-python3 && \ 4 | npm install -g pnpm 5 | RUN apt-get install -y libpango1.0-dev && \ 6 | pip3 install moviepy==1.0.3 --break-system-packages && \ 7 | rm -rf /root/.cache 8 | WORKDIR /app 9 | COPY package.json pnpm-lock.yaml ./ 10 | RUN pnpm i --frozen-lockfile --prod=false && \ 11 | pnpx playwright install --with-deps --only-shell chromium-headless-shell 12 | COPY . . 13 | RUN pnpm build && pnpm prune --prod && rm -rf src 14 | EXPOSE 3000 15 | CMD ["pnpm", "fast-start"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024-2025, Athena Authors 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Athena 3 |

4 | 5 |

Athena

6 |

A General-Purpose AI Agent ✨

7 | 8 |
9 | Discord 10 | X (formerly Twitter) Follow 11 | Website 12 |
13 | 14 | **Athena** is a production-ready general AI agent built to _do_, not just _think_. It bridges insight with execution, helping you move from idea to results effortlessly. 15 | 16 | Some examples of what Athena can do: 17 | 18 | - "📥 Find the top 3 most-starred Python repositories on GitHub this week and summarize what each does" 19 | - "🔎 Search Hacker News for posts about 'LangChain' and summarize the top 3 discussions" 20 | - "✈️ Check for flight prices every day and visualize price trends over time" 21 | - "📊 Plot the MACD and RSI for TSLA over the last week" 22 | - "🗺️ Get the past year's weather in Tokyo and tell me the best time to visit" 23 | - "💬 Translate this Word document from Spanish to English and preserve formatting" 24 | - "📚 Scrape the top 10 books from the New York Times Best Sellers list and generate a reading list" 25 | - "🧠 Train a digit recognition model on the MNIST dataset using PyTorch" 26 | 27 | Explore demos and experience Athena directly in your browser: [https://athenalab.ai/](https://athenalab.ai/). 28 | 29 | Join our community: [Discord](https://discord.gg/X38GnhdTH8) | [X](https://x.com/AthenaAGI) 30 | 31 | ## ✨ Features 32 | 33 | With all the tools it has, Athena is capable of: 34 | 35 | - Computer control through command line 36 | - Accessing files and folders 37 | - Python code execution 38 | - Web browser automation 39 | - Web search via [Jina](docs/configuration.md#http) 40 | - Time awareness and scheduling 41 | - Chatting with [other language models](docs/configuration.md#llm) 42 | - Short-term memory for context retention 43 | - Bot functionality for [Telegram](docs/configuration.md#telegram) and [Discord](docs/configuration.md#discord) 44 | 45 | ...... 46 | 47 | ## 🚀 Quick Start 48 | 49 | 1. Clone the repository: 50 | 51 | ```bash 52 | git clone https://github.com/Athena-AI-Lab/athena-core.git 53 | ``` 54 | 55 | 2. [Install pnpm](https://pnpm.io/installation) (if not already installed): 56 | 57 | ```bash 58 | npm install -g pnpm 59 | ``` 60 | 61 | 3. Install project dependencies: 62 | 63 | ```bash 64 | cd athena-core 65 | pnpm i 66 | pnpx playwright install 67 | ``` 68 | 69 | 4. Copy the example config file: 70 | 71 | ```bash 72 | cp configs/config.yaml-example configs/config.yaml 73 | ``` 74 | 75 | 5. Edit `configs/config.yaml` with your API key. Here's a minimal working configuration: 76 | 77 | ```yaml 78 | quiet: true 79 | 80 | plugins: 81 | cerebrum: 82 | base_url: https://api.openai.com/v1 83 | api_key: sk-proj-your-openai-api-key 84 | model: gpt-4o 85 | temperature: 0.5 86 | image_supported: true 87 | max_prompts: 50 88 | max_event_strlen: 65536 89 | max_tokens: 16384 90 | clock: 91 | http: 92 | short-term-memory: 93 | file-system: 94 | python: 95 | shell: 96 | browser: 97 | headless: false 98 | cli-ui: 99 | ``` 100 | 101 | > **Note:** This is a minimal working configuration. Advanced features such as [Jina web search](docs/configuration.md#http), [multiple language model support](docs/configuration.md#llm), [Telegram](docs/configuration.md#telegram) and [Discord](docs/configuration.md#discord) integration are not included here, though some may be essential for production use and require additional API keys. 102 | > 103 | > For a complete list of plugins and detailed configuration options, please refer to the [Configuration Guide](docs/configuration.md). See [Cerebrum](docs/configuration.md#cerebrum) section for best practices on selecting the right model for your use case. 104 | 105 | 6. Launch Athena: 106 | 107 | ```bash 108 | pnpm start 109 | ``` 110 | 111 | 7. Now you can talk to Athena in your terminal! 112 | 113 | ## 📝 Documentation 114 | 115 | Other projects have docs. We have **Vibe Docs**. 116 | 117 | Yes, there's a [Configuration Guide](docs/configuration.md) if you're into that sort of thing. But if you really want to understand Athena, we recommend a more modern approach: feed the entire codebase to your favorite AI — Cursor, Windsurf, Athena herself, or even your toaster if it runs GPT — and just ask it how things work. 118 | 119 | Trust us: the AI will probably do a better job explaining it than we ever could. It's like documentation, but smarter, more interactive, and doesn't judge your typos. 120 | 121 | > **Note:** This is not a joke. We are serious. The above is also vibe documented. 122 | 123 | ## 🗓️ Roadmap 124 | 125 | Our mission is to realize **human-level intelligence**, or _AGI_, by evolving Athena into a truly autonomous and capable agent. Here's a more detailed roadmap of what we're working on: 126 | 127 | - [ ] **Autonomous Code Writing** 128 | 129 | - Enable Athena to iteratively write and improve its own plugins 130 | 131 | - [ ] **Robust Browser Automation** 132 | 133 | - Improve reliability and fault tolerance in headless and headful modes 134 | - Add advanced DOM element parsing and interaction strategies 135 | 136 | - [ ] **Context Management Improvements** 137 | 138 | - Adjust prompt context windows for different LLMs 139 | - Implement context summarization for out-of-window context 140 | 141 | - [ ] **Long-Term Memory with RAG** 142 | 143 | - Set up vector database integration for persistent knowledge 144 | - Enable memory recall across sessions and tasks 145 | - Support user-specific long-term context embedding and retrieval 146 | 147 | - [ ] **Image and Video Model Expansion** 148 | 149 | - Integrate support for more image and video generation models 150 | - Enable multimodal workflows that combine text, image, and video reasoning 151 | 152 | - [ ] **Video Understanding Capabilities** 153 | - Implement advanced video content analysis and understanding capabilities 154 | - Develop temporal reasoning for video sequences and events 155 | - Enable extraction of key information and insights from video sources 156 | 157 | ## 🤝 Contributing 158 | 159 | We welcome contributions from everyone — whether you're fixing a typo, suggesting a feature, or building a whole new plugin! 160 | 161 | Athena is a community-driven project, and we believe in building great tools _together_. Here's how you can help: 162 | 163 | ### 💡 Got an Idea? 164 | 165 | Open a [GitHub Issue](https://github.com/Athena-AI-Lab/athena-core/issues) and let's discuss it! Whether it's a feature request, a bug report, or a wild idea — we're all ears. 166 | 167 | ### 🛠 Want to Contribute Code? 168 | 169 | 1. Fork the repo and create your branch: 170 | ```bash 171 | git checkout -b your-feature 172 | ``` 173 | 2. Make your changes and commit them with a clear message. 174 | 3. Push to your fork and open a Pull Request. 175 | 176 | ### 🧪 Suggest Tests or Improvements 177 | 178 | Not into code? You can still help by: 179 | 180 | - Testing features and reporting issues 181 | - Improving documentation 182 | - Sharing Athena with others and providing feedback 183 | 184 | ### 🌟 Join the Community 185 | 186 | Chat with us on [Discord](https://discord.gg/X38GnhdTH8) and follow [@AthenaAGI on X](https://x.com/AthenaAGI) for updates, tips, and more. 187 | 188 | ## 📄 License 189 | 190 | This project is licensed under the BSD 3-Clause License. See the [LICENSE](LICENSE) file for details. 191 | -------------------------------------------------------------------------------- /configs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !*example 4 | !*template 5 | -------------------------------------------------------------------------------- /configs/config.yaml-example: -------------------------------------------------------------------------------- 1 | quiet: true 2 | 3 | plugins: 4 | cerebrum: 5 | base_url: https://api.openai.com/v1 6 | api_key: sk-proj-your-openai-api-key 7 | model: gpt-4o 8 | temperature: 0.5 9 | image_supported: true 10 | max_prompts: 50 11 | max_event_strlen: 65536 12 | max_tokens: 16384 13 | clock: 14 | http: 15 | short-term-memory: 16 | file-system: 17 | python: 18 | shell: 19 | browser: 20 | headless: false 21 | cli-ui: 22 | -------------------------------------------------------------------------------- /configs/config.yaml-template: -------------------------------------------------------------------------------- 1 | states_file: configs/states.yaml 2 | log_file: configs/athena-core.log 3 | workdir: /path/to/your/workdir 4 | quiet: false 5 | 6 | plugins: 7 | webapp-ui: 8 | supabase: 9 | url: https://your-supabase-url.supabase.co 10 | anon_key: your-supabase-anon-key 11 | email: your-email@example.com 12 | otp: "123456" 13 | files_bucket: your-files-bucket-name 14 | context_id: your-context-id 15 | shutdown_timeout: 300 16 | port: 3000 17 | cerebrum: 18 | base_url: https://api.openai.com/v1 19 | api_key: sk-proj-your-openai-api-key 20 | model: gpt-4o 21 | temperature: 0.5 22 | image_supported: true 23 | max_prompts: 50 24 | max_event_strlen: 65536 25 | max_tokens: 16384 26 | telegram: 27 | bot_token: your-telegram-bot-token 28 | allowed_chat_ids: 29 | - 1234567890 30 | - 9876543210 31 | admin_chat_ids: 32 | - 1234567890 33 | log_chat_ids: 34 | - 1234567890 35 | clock: 36 | http: 37 | jina: 38 | base_url: https://s.jina.ai 39 | api_key: your-jina-api-key 40 | short-term-memory: 41 | llm: 42 | base_url: https://api.openai.com/v1 43 | api_key: sk-proj-your-openai-api-key 44 | models: 45 | chat: 46 | - name: gpt-4o 47 | desc: GPT-4o is good at general purpose tasks. Supports image input. Whenever you receive an image and need to understand it, pass it to this model using the image arg and ask about it. 48 | image: 49 | - name: dall-e-3 50 | desc: DALL-E 3 is good at generating images. Whenever you are requested to generate images, use this model. 51 | file-system: 52 | python: 53 | long-term-memory: 54 | base_url: https://api.openai.com/v1 55 | api_key: sk-proj-your-openai-api-key 56 | vector_model: text-embedding-3-small 57 | dimensions: 100 58 | max_query_results: 3 59 | persist_db: true 60 | db_file: configs/long-term-memory.db 61 | discord: 62 | bot_token: your-discord-bot-token 63 | allowed_channel_ids: 64 | - "1234567890" 65 | - "9876543210" 66 | admin_channel_ids: 67 | - "1234567890" 68 | log_channel_ids: 69 | - "1234567890" 70 | shell: 71 | amadeus: 72 | client_id: your-amadeus-client-id 73 | client_secret: your-amadeus-client-secret 74 | athena: 75 | browser: 76 | headless: true 77 | cli-ui: 78 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration Guide 2 | 3 | ## Overview 4 | 5 | Athena is highly configurable through the `config.yaml` file. This guide provides a comprehensive overview of the available configuration options and their usage. 6 | 7 | For a complete example of configuration options, please refer to the [Configuration Template File](../configs/config.yaml-template). 8 | 9 | ## Global Configuration 10 | 11 | ```yaml 12 | states_file: configs/states.yaml 13 | log_file: configs/athena-core.log 14 | workdir: /path/to/your/workdir 15 | quiet: false 16 | ``` 17 | 18 | - `states_file`: The file to store and load Athena's state. If set, Athena will save its state to this file when shutting down and load it when starting to resume context and states. 19 | - `log_file`: The file to store Athena's logs. 20 | - `workdir`: Athena's working directory. 21 | - `quiet`: Whether to run Athena in quiet mode. If set to `true`, Athena will not print logs to the console. 22 | 23 | ## Plugins Configuration 24 | 25 | Plugins are the core components of Athena. Each plugin has its own configuration options. To load a plugin, simply include the plugin's name in the `plugins` section with its specific configuration options. 26 | 27 | ### Browser 28 | 29 | The `browser` plugin allows Athena to control a web browser via Playwright. 30 | 31 | ```yaml 32 | browser: 33 | headless: true 34 | ``` 35 | 36 | - `headless`: Whether to run the browser in headless mode. If set to `true`, the browser will not be visible. 37 | 38 | ### Cerebrum 39 | 40 | The `cerebrum` plugin is the main plugin for Athena. It handles event receiving, decision making, and tool calling. 41 | 42 | Here is a configuration example for the cerebrum plugin using GPT-4o: 43 | 44 | ```yaml 45 | cerebrum: 46 | base_url: https://api.openai.com/v1 47 | api_key: sk-proj-your-openai-api-key 48 | model: gpt-4o 49 | temperature: 0.5 50 | image_supported: true 51 | max_prompts: 50 52 | max_event_strlen: 65536 53 | max_tokens: 16384 54 | ``` 55 | 56 | However, Athena performs best with DeepSeek V3 (not 0324). The web app version of Athena uses DeepSeek V3 as its cerebrum model. Here is an example configuration for DeepSeek V3: 57 | 58 | ```yaml 59 | cerebrum: 60 | base_url: https://openrouter.ai/api/v1 61 | api_key: sk-your-openrouter-api-key 62 | model: deepseek/deepseek-chat 63 | temperature: 0.5 64 | image_supported: false 65 | max_prompts: 50 66 | max_event_strlen: 65536 67 | max_tokens: 16384 68 | ``` 69 | 70 | Although more expensive, Athena can perform even better with Claude 3.7 Sonnet. Here is an example configuration: 71 | 72 | ```yaml 73 | cerebrum: 74 | base_url: https://api.anthropic.com/v1 75 | api_key: sk-ant-api03-your-anthropic-api-key 76 | model: claude-3-7-sonnet-latest 77 | temperature: 0.5 78 | image_supported: false 79 | max_prompts: 50 80 | max_event_strlen: 65536 81 | max_tokens: 16384 82 | ``` 83 | 84 | - `base_url`: The base URL of the API endpoint. 85 | - `api_key`: The API key for the endpoint. 86 | - `model`: The model to use. 87 | - `temperature`: The temperature setting. 88 | - `image_supported`: Whether the model supports images. Currently, only set to `true` for GPT-4o. 89 | - `max_prompts`: The maximum number of prompts kept in the context sent to the model. 90 | - `max_event_strlen`: The maximum allowed length of any string in events or tool results sent to the model. 91 | - `max_tokens`: The maximum number of tokens in the response for each model call. 92 | 93 | ### CLI UI 94 | 95 | Enable `cli-ui` to interact with Athena via the command line. If not needed, you can remove it from the `plugins` section. 96 | 97 | ```yaml 98 | cli-ui: 99 | ``` 100 | 101 | ### Clock 102 | 103 | The `clock` plugin provides time awareness and scheduling. When enabled, Athena can get the current date and time, and manage timers and alarms. 104 | 105 | ```yaml 106 | clock: 107 | ``` 108 | 109 | ### Discord 110 | 111 | Enable `discord` for Athena to send and receive messages from Discord. 112 | 113 | ```yaml 114 | discord: 115 | bot_token: your-discord-bot-token 116 | allowed_channel_ids: 117 | - "1234567890" 118 | - "9876543210" 119 | admin_channel_ids: [] 120 | log_channel_ids: [] 121 | ``` 122 | 123 | - `bot_token`: The Discord bot token. 124 | - `allowed_channel_ids`: IDs of channels Athena is allowed to interact with. Values must be a list of strings. 125 | - `admin_channel_ids`: If set, Athena will send debug logs to these channels. Values must be a list of strings. 126 | - `log_channel_ids`: If set, Athena will send thinking and tool calling logs to these channels. Values must be a list of strings. 127 | 128 | ### File System 129 | 130 | Enable `file-system` to allow Athena to access your local file system. Athena will be able to read and write files. 131 | 132 | ```yaml 133 | file-system: 134 | ``` 135 | 136 | ### HTTP 137 | 138 | Enable `http` for Athena to send HTTP requests, search the web via Jina Search, Exa Search, or Tavily Search, and download files from the Internet. 139 | 140 | ```yaml 141 | http: 142 | jina: # Optional Jina config 143 | base_url: https://s.jina.ai 144 | api_key: your-jina-api-key 145 | exa: # Optional Exa config 146 | base_url: https://api.exa.ai # Optional, defaults to this 147 | api_key: your-exa-api-key # Required if using Exa 148 | tavily: # Optional Tavily config 149 | base_url: https://api.tavily.com # Optional, defaults to this 150 | api_key: your-tavily-api-key # Required if using Tavily 151 | ``` 152 | 153 | - `jina`: Configuration for [Jina Search](https://jina.ai/). (Optional) 154 | - `base_url`: The base URL of the Jina Search API endpoint. (Optional, defaults to `https://s.jina.ai`) 155 | - `api_key`: The API key for the Jina Search API endpoint. (Required if using Jina) 156 | - `exa`: Configuration for [Exa Search](https://exa.ai/). (Optional) 157 | - `base_url`: The base URL of the Exa Search API endpoint. (Optional, defaults to `https://api.exa.ai`) 158 | - `api_key`: The API key for the Exa Search API endpoint. (Required if using Exa) 159 | - `tavily`: Configuration for [Tavily Search](https://tavily.com/). (Optional) 160 | - `base_url`: The base URL of the Tavily Search API endpoint. (Optional, defaults to `https://api.tavily.com`) 161 | - `api_key`: The API key for the Tavily Search API endpoint. (Required if using Tavily) 162 | 163 | ### LLM 164 | 165 | Enable `llm` for Athena to chat with other language models and generate images. 166 | 167 | Since only a single OpenAI endpoint can be configured currently, it's recommended to use a service like LiteLLM proxy to route requests to different language models. OpenRouter is another option, though it doesn't support image generation. 168 | 169 | ```yaml 170 | llm: 171 | base_url: https://openrouter.ai/api/v1 172 | api_key: sk-or-v1-your-openrouter-api-key 173 | models: 174 | chat: 175 | - name: openai/gpt-4o 176 | desc: GPT-4o is good at general purpose tasks. Supports image input. Whenever you receive an image and need to understand it, pass it to this model using the image arg. 177 | - name: openai/o3-mini 178 | desc: O3 Mini is good at deep thinking and planning. Whenever you need to plan something complicated or solve complex math problems, use this model. 179 | - name: anthropic/claude-3.7-sonnet 180 | desc: Claude 3.7 Sonnet is good at writing code. Whenever you need to write code, use this model. 181 | - name: perplexity/sonar 182 | desc: Perplexity can access the Internet. Whenever you need to search the Internet, use this model. 183 | image: 184 | - name: openai/dall-e-3 # OpenRouter doesn't support image generation 185 | desc: DALL-E 3 is good at generating images. Whenever you are requested to generate images, use this model. 186 | ``` 187 | 188 | - `base_url`: The base URL of the API endpoint. 189 | - `api_key`: The API key for the endpoint. 190 | - `models`: The models available for use. 191 | - `chat`: The chat models available. 192 | - `name`: The name of the model. 193 | - `desc`: The description of the model. 194 | - `image`: The image generation models available. 195 | - `name`: The name of the model. 196 | - `desc`: The description of the model. 197 | 198 | ### Python 199 | 200 | Enable `python` for Athena to run inline Python code or Python scripts. This also enables Athena to install pip packages. 201 | 202 | ```yaml 203 | python: 204 | ``` 205 | 206 | ### Shell 207 | 208 | Enable `shell` for Athena to run shell commands. 209 | 210 | ```yaml 211 | shell: 212 | ``` 213 | 214 | ### Short-Term Memory 215 | 216 | Enable `short-term-memory` for Athena to manage a basic task list. 217 | 218 | ```yaml 219 | short-term-memory: 220 | ``` 221 | 222 | ### Telegram 223 | 224 | Enable `telegram` for Athena to send and receive messages from Telegram. 225 | 226 | ```yaml 227 | telegram: 228 | bot_token: your-telegram-bot-token 229 | allowed_chat_ids: 230 | - 1234567890 231 | - 9876543210 232 | admin_chat_ids: [] 233 | log_chat_ids: [] 234 | ``` 235 | 236 | - `bot_token`: The Telegram bot token. 237 | - `allowed_chat_ids`: IDs of chats Athena is allowed to interact with. 238 | - `admin_chat_ids`: If set, Athena will send debug logs to these chats. 239 | - `log_chat_ids`: If set, Athena will send thinking and tool calling logs to these chats. 240 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "athena-core", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "dist/main.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "dev": "tsx src/main.ts configs/config.yaml", 11 | "start": "npm run build && npm run fast-start", 12 | "fast-start": "node dist/main.js configs/config.yaml", 13 | "build": "npm run clean && npm run fast-build", 14 | "fast-build": "swc src -d dist --strip-leading-paths", 15 | "clean": "rm -rf dist", 16 | "typecheck": "tsc --noEmit", 17 | "lint": "prettier --check .", 18 | "lint:fix": "prettier --write ." 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+ssh://git@github.com/Athena-AI-Lab/athena-core.git" 23 | }, 24 | "author": "", 25 | "license": "ISC", 26 | "bugs": { 27 | "url": "https://github.com/Athena-AI-Lab/athena-core/issues" 28 | }, 29 | "homepage": "https://athenalab.ai/", 30 | "description": "", 31 | "dependencies": { 32 | "@supabase/supabase-js": "^2.49.4", 33 | "amadeus": "^11.0.0", 34 | "discord.js": "^14.18.0", 35 | "follow-redirects": "^1.15.9", 36 | "html-to-text": "^9.0.5", 37 | "image2uri": "^2.1.2", 38 | "ink": "^5.2.1", 39 | "istextorbinary": "^9.5.0", 40 | "jsdom": "^26.1.0", 41 | "jsonrepair": "^3.12.0", 42 | "mime-types": "^2.1.35", 43 | "node-telegram-bot-api": "^0.63.0", 44 | "openai": "^4.95.1", 45 | "playwright": "^1.52.0", 46 | "python-shell": "^5.0.0", 47 | "react": "18.3.1", 48 | "sqlite-vec": "0.1.7-alpha.2", 49 | "winston": "^3.17.0", 50 | "winston-transport": "^4.9.0", 51 | "ws": "^8.18.1", 52 | "yaml": "^2.7.1" 53 | }, 54 | "devDependencies": { 55 | "@swc/cli": "^0.6.0", 56 | "@swc/core": "^1.11.21", 57 | "@types/follow-redirects": "^1.14.4", 58 | "@types/html-to-text": "^9.0.4", 59 | "@types/jsdom": "^21.1.7", 60 | "@types/mime-types": "^2.1.4", 61 | "@types/node": "^22.14.1", 62 | "@types/node-telegram-bot-api": "^0.64.8", 63 | "@types/react": "^18.3.21", 64 | "@types/ws": "^8.18.1", 65 | "prettier": "^3.5.3", 66 | "tsx": "^4.19.3", 67 | "typescript": "^5.8.3" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /scripts/build_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR="$(dirname $0)" 6 | cd "$SCRIPT_DIR/.." 7 | 8 | docker build -t athena-ai/athena-core:latest . 9 | -------------------------------------------------------------------------------- /scripts/run_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SCRIPT_DIR="$(dirname $0)" 6 | cd "$SCRIPT_DIR/.." 7 | 8 | if [ "$(docker images -q athena-ai/athena-core:latest 2> /dev/null)" == "" ]; then 9 | scripts/build_docker.sh 10 | fi 11 | 12 | if [ "$(docker ps -a -q -f name=athena-core)" ]; then 13 | docker start -ai athena-core 14 | exit 0 15 | fi 16 | 17 | if [ -z "${TZ}" ]; then 18 | TZ="America/Los_Angeles" 19 | fi 20 | 21 | docker run --name athena-core -v "$(pwd)/configs:/app/configs" -e TZ="${TZ}" -it athena-ai/athena-core:latest 22 | -------------------------------------------------------------------------------- /scripts/run_docker_clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SCRIPT_DIR="$(dirname $0)" 4 | cd "$SCRIPT_DIR/.." 5 | 6 | docker rm athena-core 7 | docker image rm athena-ai/athena-core:latest 8 | 9 | scripts/run_docker.sh 10 | -------------------------------------------------------------------------------- /src/core/athena.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import { PluginBase } from "../plugins/plugin-base.js"; 4 | import logger from "../utils/logger.js"; 5 | 6 | export type Dict = { [key: string]: T }; 7 | 8 | type IAthenaArgumentPrimitive = { 9 | type: "string" | "number" | "boolean"; 10 | desc: string; 11 | required: boolean; 12 | }; 13 | 14 | export type IAthenaArgument = 15 | | IAthenaArgumentPrimitive 16 | | { 17 | type: "object" | "array"; 18 | desc: string; 19 | required: boolean; 20 | of?: Dict | IAthenaArgument; 21 | }; 22 | type IAthenaArgumentInstance = 23 | T extends IAthenaArgumentPrimitive 24 | ? T["type"] extends "string" 25 | ? T["required"] extends true 26 | ? string 27 | : string | undefined 28 | : T["type"] extends "number" 29 | ? T["required"] extends true 30 | ? number 31 | : number | undefined 32 | : T["type"] extends "boolean" 33 | ? T["required"] extends true 34 | ? boolean 35 | : boolean | undefined 36 | : never 37 | : T extends { of: Dict } 38 | ? T["required"] extends true 39 | ? { [K in keyof T["of"]]: IAthenaArgumentInstance } 40 | : 41 | | { [K in keyof T["of"]]: IAthenaArgumentInstance } 42 | | undefined 43 | : T extends { of: IAthenaArgument } 44 | ? T["required"] extends true 45 | ? IAthenaArgumentInstance[] 46 | : IAthenaArgumentInstance[] | undefined 47 | : T extends { type: "object" } 48 | ? T["required"] extends true 49 | ? { [K in keyof T["of"]]: any } 50 | : { [K in keyof T["of"]]: any } | undefined 51 | : T extends { type: "array" } 52 | ? T["required"] extends true 53 | ? any[] 54 | : (any | undefined)[] 55 | : never; 56 | 57 | export interface IAthenaTool< 58 | Args extends Dict = Dict, 59 | RetArgs extends Dict = Dict, 60 | > { 61 | name: string; 62 | desc: string; 63 | args: Args; 64 | retvals: RetArgs; 65 | fn: (args: { 66 | [K in keyof Args]: Args[K] extends IAthenaArgument 67 | ? IAthenaArgumentInstance 68 | : never; 69 | }) => Promise<{ 70 | [K in keyof RetArgs]: RetArgs[K] extends IAthenaArgument 71 | ? IAthenaArgumentInstance 72 | : never; 73 | }>; 74 | explain_args?: (args: Dict) => IAthenaExplanation; 75 | explain_retvals?: (args: Dict, retvals: Dict) => IAthenaExplanation; 76 | } 77 | 78 | export interface IAthenaEvent { 79 | name: string; 80 | desc: string; 81 | args: Dict; 82 | explain_args?: (args: Dict) => IAthenaExplanation; 83 | } 84 | 85 | export interface IAthenaExplanation { 86 | summary: string; 87 | details?: string; 88 | } 89 | 90 | export class Athena extends EventEmitter { 91 | config: Dict; 92 | states: Dict>; 93 | plugins: Dict; 94 | tools: Dict>; 95 | events: Dict; 96 | 97 | constructor(config: Dict, states: Dict>) { 98 | super(); 99 | this.config = config; 100 | this.states = states; 101 | this.plugins = {}; 102 | this.tools = {}; 103 | this.events = {}; 104 | } 105 | 106 | async loadPlugins() { 107 | const plugins = this.config.plugins; 108 | if (!plugins) { 109 | logger.warn("No plugins found in config"); 110 | } 111 | for (const [name, args] of Object.entries(plugins)) { 112 | await this.loadPlugin(name, args ?? {}); 113 | } 114 | this.emit("plugins-loaded"); 115 | } 116 | 117 | async unloadPlugins() { 118 | const plugins = Object.keys(this.plugins); 119 | for (const name of plugins) { 120 | try { 121 | await this.unloadPlugin(name); 122 | } catch (error) { 123 | logger.error(`Failed to unload plugin ${name}: ${error}`); 124 | if (name in this.plugins) { 125 | delete this.plugins[name]; 126 | } 127 | } 128 | } 129 | } 130 | 131 | async loadPlugin(name: string, args: Dict) { 132 | if (name in this.plugins) { 133 | throw new Error(`Plugin ${name} already loaded`); 134 | } 135 | const Plugin = (await import(`../plugins/${name}/init.js`)).default; 136 | const plugin = new Plugin(args) as PluginBase; 137 | plugin.logger = logger.child({ 138 | plugin: name, 139 | }); 140 | this.plugins[name] = plugin; 141 | await plugin.load(this); 142 | const state = this.states[name]; 143 | if (state) { 144 | plugin.setState(state); 145 | } 146 | logger.warn(`Plugin ${name} is loaded`); 147 | } 148 | 149 | async unloadPlugin(name: string) { 150 | if (!(name in this.plugins)) { 151 | throw new Error(`Plugin ${name} not loaded`); 152 | } 153 | this.gatherState(name); 154 | await this.plugins[name].unload(this); 155 | delete this.plugins[name]; 156 | logger.warn(`Plugin ${name} is unloaded`); 157 | } 158 | 159 | registerTool< 160 | Args extends Dict, 161 | RetArgs extends Dict, 162 | Tool extends IAthenaTool, 163 | >( 164 | config: { 165 | name: string; 166 | desc: string; 167 | args: Args; 168 | retvals: RetArgs; 169 | }, 170 | toolImpl: { 171 | fn: Tool["fn"]; 172 | explain_args?: Tool["explain_args"]; 173 | explain_retvals?: Tool["explain_retvals"]; 174 | }, 175 | ) { 176 | const tool = { 177 | ...config, 178 | ...toolImpl, 179 | }; 180 | if (tool.name in this.tools) { 181 | throw new Error(`Tool ${tool.name} already registered`); 182 | } 183 | this.tools[tool.name] = tool as unknown as IAthenaTool; 184 | logger.warn(`Tool ${tool.name} is registered`); 185 | } 186 | 187 | deregisterTool(name: string) { 188 | if (!(name in this.tools)) { 189 | throw new Error(`Tool ${name} not registered`); 190 | } 191 | delete this.tools[name]; 192 | logger.warn(`Tool ${name} is deregistered`); 193 | } 194 | 195 | registerEvent(event: IAthenaEvent) { 196 | if (event.name in this.events) { 197 | throw new Error(`Event ${event.name} already registered`); 198 | } 199 | this.events[event.name] = event; 200 | logger.warn(`Event ${event.name} is registered`); 201 | } 202 | 203 | deregisterEvent(name: string) { 204 | if (!(name in this.events)) { 205 | throw new Error(`Event ${name} not registered`); 206 | } 207 | delete this.events[name]; 208 | logger.warn(`Event ${name} is deregistered`); 209 | } 210 | 211 | gatherState(plugin: string) { 212 | if (!(plugin in this.plugins)) { 213 | throw new Error(`Plugin ${plugin} not loaded`); 214 | } 215 | const state = this.plugins[plugin].state(); 216 | if (state) { 217 | this.states[plugin] = state; 218 | } 219 | } 220 | 221 | gatherStates() { 222 | for (const plugin of Object.keys(this.plugins)) { 223 | try { 224 | this.gatherState(plugin); 225 | } catch (error) { 226 | logger.error(`Failed to gather state for plugin ${plugin}: ${error}`); 227 | } 228 | } 229 | } 230 | 231 | async callTool(name: string, args: Dict) { 232 | if (!(name in this.tools)) { 233 | throw new Error(`Tool ${name} not registered`); 234 | } 235 | const tool = this.tools[name]; 236 | if (!tool) { 237 | throw new Error(`Tool ${name} not found`); 238 | } 239 | if (tool.explain_args) { 240 | this.emitPrivateEvent("athena/tool-call", tool.explain_args(args)); 241 | } 242 | const retvals = await tool.fn(args); 243 | if (tool.explain_retvals) { 244 | this.emitPrivateEvent( 245 | "athena/tool-result", 246 | tool.explain_retvals(args, retvals), 247 | ); 248 | } 249 | return retvals; 250 | } 251 | 252 | emitEvent(name: string, args: Dict) { 253 | if (!(name in this.events)) { 254 | throw new Error(`Event ${name} not registered`); 255 | } 256 | const event = this.events[name]; 257 | if (event.explain_args) { 258 | this.emitPrivateEvent("athena/event", event.explain_args(args)); 259 | } 260 | this.emit("event", name, args); 261 | } 262 | 263 | emitPrivateEvent(name: string, args: Dict) { 264 | this.emit("private-event", name, args); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | 3 | import { format, transports } from "winston"; 4 | import yaml from "yaml"; 5 | 6 | import { Athena } from "./core/athena.js"; 7 | import logger from "./utils/logger.js"; 8 | 9 | const main = async () => { 10 | if (process.argv.length !== 3) { 11 | console.error(`usage: ${process.argv[0]} ${process.argv[1]} `); 12 | process.exit(1); 13 | } 14 | 15 | const configFile = process.argv[2]; 16 | const config = yaml.parse(await fs.readFile(configFile, "utf8")); 17 | 18 | if (config.quiet) { 19 | logger.transports[0].silent = true; 20 | } 21 | 22 | if (config.log_file) { 23 | logger.add( 24 | new transports.File({ 25 | filename: config.log_file, 26 | format: format.combine(format.timestamp(), format.json()), 27 | }), 28 | ); 29 | logger.info(`Log file: ${config.log_file}`); 30 | } else { 31 | logger.info("Log file is not set"); 32 | } 33 | 34 | logger.info(`PID: ${process.pid}`); 35 | 36 | let statesFile = null; 37 | let states = {}; 38 | if (config.states_file) { 39 | try { 40 | statesFile = await fs.open(config.states_file, "r+"); 41 | } catch (err: any) { 42 | if (err.code === "ENOENT") { 43 | statesFile = await fs.open(config.states_file, "w+"); 44 | } else { 45 | throw err; 46 | } 47 | } 48 | try { 49 | states = yaml.parse(await statesFile.readFile("utf8")); 50 | } catch (err) {} 51 | if (!states) { 52 | states = {}; 53 | } 54 | logger.info(`States file: ${config.states_file}`); 55 | logger.info(`States: ${JSON.stringify(states)}`); 56 | } else { 57 | logger.info("States file is not set"); 58 | } 59 | 60 | const saveStates = async (athena: Athena, close: boolean = false) => { 61 | if (!statesFile) { 62 | return; 63 | } 64 | athena.gatherStates(); 65 | await statesFile.truncate(0); 66 | await statesFile.write(yaml.stringify(athena.states), 0, "utf8"); 67 | if (close) { 68 | await statesFile.close(); 69 | } 70 | logger.info("States file is saved"); 71 | logger.info(`States: ${JSON.stringify(athena.states)}`); 72 | }; 73 | 74 | if (config.workdir) { 75 | logger.info(`Changing working directory to ${config.workdir}`); 76 | process.chdir(config.workdir); 77 | } 78 | 79 | const athena = new Athena(config, states); 80 | await athena.loadPlugins(); 81 | 82 | let exiting = false; 83 | const cleanup = async (event: string) => { 84 | if (exiting) { 85 | return; 86 | } 87 | exiting = true; 88 | logger.warn(`${event} triggered, cleaning up...`); 89 | await saveStates(athena, true); 90 | await athena.unloadPlugins(); 91 | logger.info("Athena is unloaded"); 92 | }; 93 | 94 | process.on("SIGINT", () => cleanup("SIGINT")); 95 | process.on("SIGTERM", () => cleanup("SIGTERM")); 96 | process.on("SIGHUP", () => cleanup("SIGHUP")); 97 | process.on("beforeExit", () => cleanup("beforeExit")); 98 | 99 | let reloading = false; 100 | process.on("SIGUSR1", async () => { 101 | if (reloading) { 102 | return; 103 | } 104 | reloading = true; 105 | logger.info("SIGUSR1 triggered, reloading..."); 106 | await saveStates(athena); 107 | await athena.unloadPlugins(); 108 | await athena.loadPlugins(); 109 | logger.info("Athena is reloaded"); 110 | reloading = false; 111 | }); 112 | 113 | let savingStates = false; 114 | process.on("SIGUSR2", async () => { 115 | if (savingStates) { 116 | return; 117 | } 118 | savingStates = true; 119 | logger.info("SIGUSR2 triggered, saving states..."); 120 | await saveStates(athena); 121 | savingStates = false; 122 | }); 123 | 124 | logger.info("Athena is loaded"); 125 | }; 126 | 127 | main(); 128 | -------------------------------------------------------------------------------- /src/plugins/amadeus/amadeus.d.ts: -------------------------------------------------------------------------------- 1 | declare module "amadeus"; 2 | -------------------------------------------------------------------------------- /src/plugins/amadeus/init.ts: -------------------------------------------------------------------------------- 1 | import Amadeus from "amadeus"; 2 | 3 | import { Athena, Dict } from "../../core/athena.js"; 4 | import { PluginBase } from "../plugin-base.js"; 5 | 6 | export default class AmadeusPlugin extends PluginBase { 7 | amadeus!: any; 8 | 9 | async load(athena: Athena) { 10 | this.amadeus = new Amadeus({ 11 | clientId: this.config.client_id, 12 | clientSecret: this.config.client_secret, 13 | }); 14 | athena.registerTool( 15 | { 16 | name: "amadeus/flight-offers-search", 17 | desc: "Return list of Flight Offers based on searching criteria.", 18 | args: { 19 | originLocationCode: { 20 | type: "string", 21 | desc: "city/airport IATA code from which the traveler will depart, e.g. BOS for Boston\nExample : SYD", 22 | required: true, 23 | }, 24 | destinationLocationCode: { 25 | type: "string", 26 | desc: "city/airport IATA code to which the traveler is going, e.g. PAR for Paris\nExample : BKK", 27 | required: true, 28 | }, 29 | departureDate: { 30 | type: "string", 31 | desc: "the date on which the traveler will depart from the origin to go to the destination. Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2017-12-25\nExample : 2023-05-02", 32 | required: true, 33 | }, 34 | returnDate: { 35 | type: "string", 36 | desc: "the date on which the traveler will depart from the origin to go to the destination. Dates are specified in the ISO 8601 YYYY-MM-DD format, e.g. 2017-12-25\nExample : 2023-05-02", 37 | required: false, 38 | }, 39 | adults: { 40 | type: "number", 41 | desc: "the number of adult travelers (age 12 or older on date of departure). The total number of seated travelers (adult and children) can not exceed 9.\nDefault value : 1", 42 | required: true, 43 | }, 44 | children: { 45 | type: "number", 46 | desc: "the number of child travelers (older than age 2 and younger than age 12 on date of departure) who will each have their own separate seat. If specified, this number should be greater than or equal to 0\nThe total number of seated travelers (adult and children) can not exceed 9.", 47 | required: false, 48 | }, 49 | infants: { 50 | type: "number", 51 | desc: "the number of infant travelers (whose age is less or equal to 2 on date of departure). Infants travel on the lap of an adult traveler, and thus the number of infants must not exceed the number of adults. If specified, this number should be greater than or equal to 0", 52 | required: false, 53 | }, 54 | travelClass: { 55 | type: "string", 56 | desc: "most of the flight time should be spent in a cabin of this quality or higher. The accepted travel class is economy, premium economy, business or first class. If no travel class is specified, the search considers any travel class\nAvailable values : ECONOMY, PREMIUM_ECONOMY, BUSINESS, FIRST", 57 | required: false, 58 | }, 59 | includedAirlineCodes: { 60 | type: "string", 61 | desc: "This option ensures that the system will only consider these airlines. This can not be cumulated with parameter excludedAirlineCodes.\nAirlines are specified as IATA airline codes and are comma-separated, e.g. 6X,7X,8X", 62 | required: false, 63 | }, 64 | excludedAirlineCodes: { 65 | type: "string", 66 | desc: "This option ensures that the system will ignore these airlines. This can not be cumulated with parameter includedAirlineCodes.\nAirlines are specified as IATA airline codes and are comma-separated, e.g. 6X,7X,8X", 67 | required: false, 68 | }, 69 | nonStop: { 70 | type: "boolean", 71 | desc: "if set to true, the search will find only flights going from the origin to the destination with no stop in between\nDefault value : false", 72 | required: false, 73 | }, 74 | currencyCode: { 75 | type: "string", 76 | desc: "the preferred currency for the flight offers. Currency is specified in the ISO 4217 format, e.g. EUR for Euro", 77 | required: false, 78 | }, 79 | maxPrice: { 80 | type: "number", 81 | desc: "maximum price per traveler. By default, no limit is applied. If specified, the value should be a positive number with no decimals", 82 | required: false, 83 | }, 84 | max: { 85 | type: "number", 86 | desc: "maximum number of flight offers to return. If specified, the value should be greater than or equal to 1\nDefault value : 250", 87 | required: false, 88 | }, 89 | }, 90 | retvals: { 91 | data: { 92 | desc: "The flight offers", 93 | type: "object", 94 | required: true, 95 | }, 96 | }, 97 | }, 98 | { 99 | fn: async (args) => { 100 | const response = await this.amadeus.shopping.flightOffersSearch.get({ 101 | originLocationCode: args.originLocationCode, 102 | destinationLocationCode: args.destinationLocationCode, 103 | departureDate: args.departureDate, 104 | returnDate: args.returnDate, 105 | adults: args.adults, 106 | children: args.children, 107 | infants: args.infants, 108 | travelClass: args.travelClass, 109 | includedAirlineCodes: args.includedAirlineCodes, 110 | excludedAirlineCodes: args.excludedAirlineCodes, 111 | nonStop: args.nonStop, 112 | currencyCode: args.currencyCode, 113 | maxPrice: args.maxPrice, 114 | max: args.max, 115 | }); 116 | return { data: response.data }; 117 | }, 118 | }, 119 | ); 120 | } 121 | 122 | async unload(athena: Athena) { 123 | athena.deregisterTool("amadeus/flight-offers-search"); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/plugins/athena/init.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | import { Athena, Dict } from "../../core/athena.js"; 4 | import { PluginBase } from "../plugin-base.js"; 5 | 6 | export default class AthenaPlugin extends PluginBase { 7 | desc() { 8 | return 'Athena is made up of plugins. Whenever you are told to implement a new feature or to "improve" yourself, you should create a new plugin. The way to do this is to create a new folder in the "src/plugins" directory. Inside the directory, create a new file called "init.ts". You must refer to other plugins, such as "python", as templates before creating your own. After you create the plugin, you can load it by using the "athena/load-plugin" tool. The args argument you pass to the tool can be accessed by the plugin as "this.config". You can also modify an existing plugin and reload it by using the same tool.'; 9 | } 10 | 11 | async load(athena: Athena) { 12 | athena.registerTool( 13 | { 14 | name: "athena/load-plugin", 15 | desc: "Loads a plugin.", 16 | args: { 17 | name: { 18 | type: "string", 19 | desc: "The name of the plugin to load.", 20 | required: true, 21 | }, 22 | args: { 23 | type: "object", 24 | desc: "The arguments to pass to the plugin.", 25 | required: true, 26 | }, 27 | }, 28 | retvals: { 29 | status: { 30 | type: "string", 31 | desc: "The status of the operation.", 32 | required: true, 33 | }, 34 | }, 35 | }, 36 | { 37 | fn: async (args) => { 38 | if (athena.plugins[args.name]) { 39 | await athena.unloadPlugin(args.name); 40 | } 41 | await new Promise((resolve, reject) => { 42 | exec("pnpm fast-build", (error, stdout, stderr) => { 43 | if (error) { 44 | reject(Error(stdout)); 45 | } else { 46 | resolve(); 47 | } 48 | }); 49 | }); 50 | try { 51 | await athena.loadPlugin(args.name, args.args); 52 | athena.emit("plugins-loaded"); 53 | } catch (e) { 54 | try { 55 | await athena.unloadPlugin(args.name); 56 | } catch (e) { 57 | if (args.name in athena.plugins) { 58 | delete athena.plugins[args.name]; 59 | } 60 | } 61 | throw e; 62 | } 63 | return { status: "success" }; 64 | }, 65 | }, 66 | ); 67 | } 68 | 69 | async unload(athena: Athena) { 70 | athena.deregisterTool("athena/load-plugin"); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/plugins/browser/browser-use.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { JSDOM } from "jsdom"; 3 | import { Browser, BrowserContext, chromium, Page } from "playwright"; 4 | 5 | import { getSelector, IPageNode, parseDom, toExternalNodes } from "./dom.js"; 6 | 7 | interface IPageState { 8 | page: Page; 9 | pageNodes: IPageNode[]; 10 | } 11 | 12 | type IBrowserUsePageMetadata = { 13 | url: string; 14 | title: string; 15 | }; 16 | 17 | interface IElementData { 18 | tagName: string; 19 | attributes: { [key: string]: string }; 20 | } 21 | 22 | const USER_AGENT = 23 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; 24 | 25 | export class BrowserUse extends EventEmitter { 26 | browser!: Browser; 27 | context!: BrowserContext; 28 | pages: IPageState[] = []; 29 | 30 | async init(headless: boolean) { 31 | this.browser = await chromium.launch({ 32 | headless, 33 | args: [ 34 | "--no-sandbox", 35 | "--window-size=1920,1080", 36 | "--disable-blink-features=AutomationControlled", 37 | "--disable-features=IsolateOrigins,site-per-process", 38 | ], 39 | handleSIGHUP: false, 40 | handleSIGINT: false, 41 | handleSIGTERM: false, 42 | }); 43 | 44 | this.context = await this.browser.newContext({ 45 | userAgent: USER_AGENT, 46 | viewport: { width: 1920, height: 1080 }, 47 | screen: { width: 1920, height: 1080 }, 48 | ignoreHTTPSErrors: true, 49 | permissions: ["geolocation"], 50 | }); 51 | 52 | await this.context.addInitScript(() => { 53 | Object.defineProperty(navigator, "webdriver", { 54 | get: () => undefined, 55 | }); 56 | Object.defineProperty(navigator, "languages", { 57 | get: () => ["en-US", "en"], 58 | }); 59 | Object.defineProperty(navigator, "plugins", { 60 | get: () => [ 61 | { 62 | 0: { 63 | type: "application/x-google-chrome-pdf", 64 | suffixes: "pdf", 65 | description: "Portable Document Format", 66 | enabledPlugin: true, 67 | }, 68 | description: "Portable Document Format", 69 | filename: "internal-pdf-viewer", 70 | length: 1, 71 | name: "Chrome PDF Plugin", 72 | }, 73 | ], 74 | }); 75 | }); 76 | } 77 | 78 | async newPage(url: string) { 79 | const page = await this.context.newPage(); 80 | await page.goto(url, { waitUntil: "commit" }); 81 | await this.waitForLoading(page); 82 | this.pages.push({ page, pageNodes: [] }); 83 | const pageIndex = this.pages.length - 1; 84 | page.on("popup", async (popup) => { 85 | await this.waitForLoading(popup); 86 | this.pages.push({ page: popup, pageNodes: [] }); 87 | this.emit("popup", pageIndex, this.pages.length - 1); 88 | }); 89 | page.on("download", async (download) => { 90 | const filename = download.suggestedFilename(); 91 | this.emit("download-started", pageIndex, filename); 92 | await download.saveAs(filename); 93 | this.emit("download-completed", pageIndex, filename); 94 | }); 95 | return pageIndex; 96 | } 97 | 98 | async closePage(pageIndex: number) { 99 | const pageState = this.pages[pageIndex]; 100 | await pageState.page.close(); 101 | } 102 | 103 | async getPageContent(pageIndex: number) { 104 | const pageState = this.pages[pageIndex]; 105 | const pageNodes = parseDom( 106 | new JSDOM(await pageState.page.content(), { 107 | url: pageState.page.url(), 108 | resources: "usable", 109 | pretendToBeVisual: true, 110 | }), 111 | ); 112 | pageState.pageNodes = pageNodes.allNodes; 113 | return toExternalNodes(pageNodes.topLevelNodes); 114 | } 115 | 116 | async clickElement(pageIndex: number, nodeIndex: number) { 117 | const pageState = this.pages[pageIndex]; 118 | const page = pageState.page; 119 | const pageNode = pageState.pageNodes[nodeIndex]; 120 | if (pageNode.type !== "clickable") { 121 | throw new Error("Node is not clickable"); 122 | } 123 | await page.locator(getSelector(pageNode.node)).click({ 124 | force: true, 125 | noWaitAfter: true, 126 | }); 127 | await this.waitForLoading(page); 128 | } 129 | 130 | async fillElement(pageIndex: number, nodeIndex: number, value: string) { 131 | const pageState = this.pages[pageIndex]; 132 | const page = pageState.page; 133 | const pageNode = pageState.pageNodes[nodeIndex]; 134 | if (pageNode.type !== "fillable") { 135 | throw new Error("Node is not fillable"); 136 | } 137 | await page.locator(getSelector(pageNode.node)).fill(value, { 138 | force: true, 139 | noWaitAfter: true, 140 | }); 141 | await this.waitForLoading(page); 142 | } 143 | 144 | async getPageMetadata(pageIndex: number): Promise { 145 | const pageState = this.pages[pageIndex]; 146 | const page = pageState.page; 147 | return { 148 | title: await page.title(), 149 | url: page.url(), 150 | }; 151 | } 152 | 153 | getElementData(pageIndex: number, nodeIndex: number): IElementData { 154 | const pageState = this.pages[pageIndex]; 155 | const pageNode = pageState.pageNodes[nodeIndex]; 156 | return { 157 | tagName: (pageNode.node as Element).tagName.toLowerCase(), 158 | attributes: Object.fromEntries( 159 | Array.from((pageNode.node as Element).attributes).map((attr) => [ 160 | attr.name, 161 | attr.value, 162 | ]), 163 | ), 164 | }; 165 | } 166 | 167 | async scrollDown(pageIndex: number) { 168 | const pageState = this.pages[pageIndex]; 169 | const page = pageState.page; 170 | await page.evaluate(() => { 171 | window.scrollTo(0, document.body.scrollHeight); 172 | }); 173 | await this.waitForLoading(page); 174 | } 175 | 176 | async waitForLoading(page: Page) { 177 | await new Promise((resolve) => setTimeout(resolve, 500)); 178 | const waitForLoad = async () => { 179 | try { 180 | await page.waitForLoadState(); 181 | } catch (error) {} 182 | }; 183 | const waitForLoad2 = async () => { 184 | try { 185 | await page.waitForLoadState("networkidle", { timeout: 10000 }); 186 | } catch (error) {} 187 | }; 188 | await Promise.any([waitForLoad(), waitForLoad2()]); 189 | await page.waitForTimeout(2000); 190 | } 191 | 192 | async screenshot(page: Page, path: string) { 193 | await page.screenshot({ path: path }); 194 | } 195 | 196 | async close() { 197 | await this.context.close(); 198 | await this.browser.close(); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/plugins/browser/dom.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom"; 2 | 3 | export interface IPageTextNode { 4 | type: "text"; 5 | index: number; 6 | node: Node; 7 | } 8 | 9 | export interface IPageImageNode { 10 | type: "image"; 11 | index: number; 12 | node: Element; 13 | } 14 | 15 | export interface IPageClickableNode { 16 | type: "clickable"; 17 | index: number; 18 | node: Element; 19 | children: IPageNode[]; 20 | } 21 | 22 | export interface IPageFillableNode { 23 | type: "fillable"; 24 | index: number; 25 | node: Element; 26 | } 27 | 28 | export type IPageNode = 29 | | IPageTextNode 30 | | IPageImageNode 31 | | IPageClickableNode 32 | | IPageFillableNode; 33 | 34 | export interface IExternalImageNode { 35 | type: "image"; 36 | index: number; 37 | text?: string; 38 | } 39 | 40 | export interface IExternalClickableNode { 41 | type: "clickable"; 42 | index: number; 43 | subtype?: string; 44 | text?: string; 45 | children?: IExternalNode[]; 46 | } 47 | 48 | export interface IExternalFillableNode { 49 | type: "fillable"; 50 | index: number; 51 | subtype?: string; 52 | text?: string; 53 | } 54 | 55 | export type IExternalNode = 56 | | string 57 | | IExternalImageNode 58 | | IExternalClickableNode 59 | | IExternalFillableNode; 60 | 61 | const isVisible = (element: Element): boolean => { 62 | return !["script", "style"].includes(element.tagName.toLowerCase()); 63 | }; 64 | 65 | const isImage = (element: Element): boolean => { 66 | return element.matches("img"); 67 | }; 68 | 69 | const isClickable = (element: Element, dom: JSDOM): boolean => { 70 | let style: CSSStyleDeclaration | null = null; 71 | try { 72 | style = dom.window.getComputedStyle(element); 73 | } catch (e) {} 74 | return ( 75 | element.matches( 76 | 'a, button, [role="button"], input[type="button"], input[type="submit"], input[type="checkbox"], input[type="radio"], [onclick], label[for]', 77 | ) || style?.cursor === "pointer" 78 | ); 79 | }; 80 | 81 | const isFillable = (element: Element): boolean => { 82 | return ( 83 | element.matches( 84 | 'input[type="text"], input[type="password"], input[type="email"], input[type="number"], input[type="search"], input[type="tel"], input[type="url"], textarea, [contenteditable="true"]', 85 | ) || 86 | (element.tagName.toLowerCase() === "input" && 87 | element.getAttribute("type") == null) 88 | ); 89 | }; 90 | 91 | export const parseDom = ( 92 | dom: JSDOM, 93 | ): { allNodes: IPageNode[]; topLevelNodes: IPageNode[] } => { 94 | const allNodes: IPageNode[] = []; 95 | 96 | const walk = (node: Node): IPageNode[] => { 97 | if ( 98 | node.nodeType === dom.window.Node.ELEMENT_NODE && 99 | !isVisible(node as Element) 100 | ) { 101 | return []; 102 | } 103 | 104 | if (node.nodeType === dom.window.Node.TEXT_NODE) { 105 | if (!node.textContent?.trim()) { 106 | return []; 107 | } 108 | 109 | const pageNode: IPageTextNode = { 110 | type: "text", 111 | index: allNodes.length, 112 | node, 113 | }; 114 | allNodes.push(pageNode); 115 | return [pageNode]; 116 | } 117 | 118 | if (node.nodeType !== dom.window.Node.ELEMENT_NODE) { 119 | return []; 120 | } 121 | 122 | const element = node as Element; 123 | 124 | if (isImage(element)) { 125 | const pageNode: IPageImageNode = { 126 | type: "image", 127 | index: allNodes.length, 128 | node: element, 129 | }; 130 | allNodes.push(pageNode); 131 | return [pageNode]; 132 | } 133 | 134 | if (isFillable(element)) { 135 | const pageNode: IPageFillableNode = { 136 | type: "fillable", 137 | index: allNodes.length, 138 | node: element, 139 | }; 140 | allNodes.push(pageNode); 141 | return [pageNode]; 142 | } 143 | 144 | const children = Array.from(node.childNodes).flatMap(walk); 145 | if (!isClickable(element, dom)) { 146 | return children; 147 | } 148 | 149 | const pageNode: IPageClickableNode = { 150 | type: "clickable", 151 | index: allNodes.length, 152 | node: element, 153 | children, 154 | }; 155 | allNodes.push(pageNode); 156 | return [pageNode]; 157 | }; 158 | 159 | const topLevelNodes = walk(dom.window.document.body); 160 | return { allNodes, topLevelNodes }; 161 | }; 162 | 163 | export const toExternalNode = (pageNode: IPageNode): IExternalNode => { 164 | if (pageNode.type === "text") { 165 | return pageNode.node.textContent!; 166 | } 167 | 168 | if (pageNode.type === "image") { 169 | const alt = pageNode.node.getAttribute("alt")?.trim(); 170 | return { 171 | type: "image", 172 | index: pageNode.index, 173 | text: alt === "" ? undefined : alt, 174 | }; 175 | } 176 | 177 | if (pageNode.type === "fillable") { 178 | const value = pageNode.node.getAttribute("value")?.trim() ?? ""; 179 | return { 180 | type: "fillable", 181 | index: pageNode.index, 182 | subtype: pageNode.node.getAttribute("type")?.trim(), 183 | text: 184 | (value !== "" ? value : undefined) ?? 185 | (pageNode.node as HTMLElement).innerText?.trim() ?? 186 | pageNode.node.getAttribute("placeholder")?.trim() ?? 187 | undefined, 188 | }; 189 | } 190 | 191 | const children = pageNode.children.map(toExternalNode); 192 | const isChildrenAllText = children.every( 193 | (child) => typeof child === "string", 194 | ); 195 | 196 | return { 197 | type: "clickable", 198 | index: pageNode.index, 199 | subtype: 200 | pageNode.node.getAttribute("type")?.trim() ?? 201 | pageNode.node.tagName.toLowerCase(), 202 | text: 203 | isChildrenAllText && children.length > 0 204 | ? children.join(" ").trim() 205 | : (pageNode.node.getAttribute("value")?.trim() ?? undefined), 206 | children: isChildrenAllText ? undefined : children, 207 | }; 208 | }; 209 | 210 | export const toExternalNodes = (pageNodes: IPageNode[]): IExternalNode[] => { 211 | const result: IExternalNode[] = []; 212 | 213 | for (const node of pageNodes.map(toExternalNode)) { 214 | if ( 215 | typeof node === "string" && 216 | result.length > 0 && 217 | typeof result[result.length - 1] === "string" 218 | ) { 219 | result[result.length - 1] = `${result[result.length - 1]} ${node}`; 220 | } else { 221 | result.push(node); 222 | } 223 | } 224 | 225 | for (let i = 0; i < result.length; i++) { 226 | if (typeof result[i] === "string") { 227 | result[i] = (result[i] as string).trim(); 228 | } 229 | } 230 | 231 | return result; 232 | }; 233 | 234 | export const getSelector = (element: Element): string => { 235 | const path: Element[] = []; 236 | let current: Element | null = element; 237 | while (current && current.tagName.toLowerCase() !== "body") { 238 | path.unshift(current); 239 | current = current.parentElement; 240 | } 241 | if (current) { 242 | path.unshift(current); 243 | } 244 | 245 | const selector = path 246 | .map((e) => { 247 | const index = 248 | Array.from(e.parentElement?.children || []) 249 | .filter((child) => child.tagName === e.tagName) 250 | .indexOf(e) + 1; 251 | const nthChild = index > 0 ? `:nth-of-type(${index})` : ""; 252 | return `${e.tagName.toLowerCase()}${nthChild}`; 253 | }) 254 | .join(" > "); 255 | 256 | return selector; 257 | }; 258 | -------------------------------------------------------------------------------- /src/plugins/browser/init.ts: -------------------------------------------------------------------------------- 1 | import { BrowserUse } from "./browser-use.js"; 2 | import { Athena, Dict } from "../../core/athena.js"; 3 | import { PluginBase } from "../plugin-base.js"; 4 | 5 | export default class Browser extends PluginBase { 6 | athena!: Athena; 7 | browserUse: BrowserUse = new BrowserUse(); 8 | boundPopupHandler!: (fromIndex: number, index: number) => void; 9 | boundDownloadStartedHandler!: (pageIndex: number, filename: string) => void; 10 | boundDownloadCompletedHandler!: (pageIndex: number, filename: string) => void; 11 | lock: boolean = false; 12 | 13 | desc() { 14 | return "You have access to a real browser. This browser is written in playwright so it can execute JavaScript. You can use the browser to navigate the web or check on the content of a webpage. Each call to the browser tools may change the index of the elements, so you must not call the browser tools multiple times in a row. You must always wait for the previous call to complete before making a new call."; 15 | } 16 | 17 | async load(athena: Athena) { 18 | this.athena = athena; 19 | await this.browserUse.init(this.config.headless); 20 | this.boundPopupHandler = this.popupHandler.bind(this); 21 | this.boundDownloadStartedHandler = this.downloadStartedHandler.bind(this); 22 | this.boundDownloadCompletedHandler = 23 | this.downloadCompletedHandler.bind(this); 24 | 25 | athena.registerEvent({ 26 | name: "browser/popup", 27 | desc: "Triggered when a new page is popped up.", 28 | args: { 29 | from_index: { 30 | type: "number", 31 | desc: "The index of the page that initiated the popup.", 32 | required: true, 33 | }, 34 | index: { 35 | type: "number", 36 | desc: "The index of the popped up page.", 37 | required: true, 38 | }, 39 | url: { 40 | type: "string", 41 | desc: "The URL of the popped up page.", 42 | required: true, 43 | }, 44 | title: { 45 | type: "string", 46 | desc: "The title of the popped up page.", 47 | required: true, 48 | }, 49 | content: { 50 | type: "array", 51 | desc: "The content of the popped up page.", 52 | required: true, 53 | }, 54 | }, 55 | explain_args: (args: Dict) => { 56 | return { 57 | summary: `A new page is popped up at index ${args.index} from index ${args.from_index}.`, 58 | details: `${args.url}\n${args.title}\n${JSON.stringify( 59 | args.content, 60 | )}`, 61 | }; 62 | }, 63 | }); 64 | athena.registerEvent({ 65 | name: "browser/download-started", 66 | desc: "Triggered when a download starts. You must wait for browser/download-completed to be triggered before accessing the file.", 67 | args: { 68 | page_index: { 69 | type: "number", 70 | desc: "The index of the page.", 71 | required: true, 72 | }, 73 | filename: { 74 | type: "string", 75 | desc: "The filename of the downloaded file.", 76 | required: true, 77 | }, 78 | }, 79 | explain_args: (args: Dict) => { 80 | return { 81 | summary: `A download starts at page ${args.page_index} with filename ${args.filename}.`, 82 | }; 83 | }, 84 | }); 85 | athena.registerEvent({ 86 | name: "browser/download-completed", 87 | desc: "Triggered when a download completes. You can now access the file.", 88 | args: { 89 | page_index: { 90 | type: "number", 91 | desc: "The index of the page.", 92 | required: true, 93 | }, 94 | filename: { 95 | type: "string", 96 | desc: "The filename of the downloaded file.", 97 | required: true, 98 | }, 99 | }, 100 | explain_args: (args: Dict) => { 101 | return { 102 | summary: `A download completes at page ${args.page_index} with filename ${args.filename}.`, 103 | }; 104 | }, 105 | }); 106 | athena.registerTool( 107 | { 108 | name: "browser/new-page", 109 | desc: "Opens a new page in the browser.", 110 | args: { 111 | url: { type: "string", desc: "The URL to open.", required: true }, 112 | }, 113 | retvals: { 114 | index: { 115 | type: "number", 116 | desc: "The index of the new page.", 117 | required: true, 118 | }, 119 | url: { 120 | type: "string", 121 | desc: "The URL of the new page.", 122 | required: true, 123 | }, 124 | title: { 125 | type: "string", 126 | desc: "The title of the new page.", 127 | required: true, 128 | }, 129 | content: { 130 | type: "array", 131 | desc: "The content of the new page.", 132 | required: true, 133 | }, 134 | }, 135 | }, 136 | { 137 | explain_args: (args: Dict) => { 138 | return { 139 | summary: `Opening ${args.url} in the browser...`, 140 | }; 141 | }, 142 | explain_retvals: (args: Dict, retvals: Dict) => { 143 | return { 144 | summary: `${args.url} is successfully opened at page ${retvals.index}.`, 145 | details: `${retvals.url}\n${retvals.title}\n${JSON.stringify( 146 | retvals.content, 147 | )}`, 148 | }; 149 | }, 150 | fn: async (args: Dict) => { 151 | return await this.withLock(async () => { 152 | const index = await this.browserUse.newPage(args.url); 153 | const metadata = await this.browserUse.getPageMetadata(index); 154 | return { 155 | index, 156 | ...metadata, 157 | content: await this.browserUse.getPageContent(index), 158 | }; 159 | }); 160 | }, 161 | }, 162 | ); 163 | athena.registerTool( 164 | { 165 | name: "browser/close-page", 166 | desc: "Closes the page. You must call this tool after you are done with the page to release the resources. This tool won't affect the index of any other pages. You won't be able to access the page after closing it.", 167 | args: { 168 | index: { 169 | type: "number", 170 | desc: "The index of the page.", 171 | required: true, 172 | }, 173 | }, 174 | retvals: { 175 | status: { 176 | type: "string", 177 | desc: "The status of the operation.", 178 | required: true, 179 | }, 180 | }, 181 | }, 182 | { 183 | explain_args: (args: Dict) => { 184 | return { 185 | summary: `Closing the page at index ${args.index}...`, 186 | }; 187 | }, 188 | explain_retvals: (args: Dict, retvals: Dict) => { 189 | return { 190 | summary: `The page at index ${args.index} is closed.`, 191 | }; 192 | }, 193 | fn: async (args: Dict) => { 194 | return await this.withLock(async () => { 195 | await this.browserUse.closePage(args.index); 196 | return { 197 | status: "success", 198 | }; 199 | }); 200 | }, 201 | }, 202 | ); 203 | athena.registerTool( 204 | { 205 | name: "browser/click", 206 | desc: "Clicks on an element.", 207 | args: { 208 | page_index: { 209 | type: "number", 210 | desc: "The index of the page.", 211 | required: true, 212 | }, 213 | node_index: { 214 | type: "number", 215 | desc: "The index of the element to click. If you want to click a checkbox or a radio button in a list, you must click the one before the corresponding text, not after; otherwise, the wrong element will be clicked.", 216 | required: true, 217 | }, 218 | }, 219 | retvals: { 220 | url: { 221 | type: "string", 222 | desc: "The URL of the page.", 223 | required: true, 224 | }, 225 | title: { 226 | type: "string", 227 | desc: "The content of the page after clicking the element.", 228 | required: true, 229 | }, 230 | content: { 231 | type: "array", 232 | desc: "The content of the page after clicking the element.", 233 | required: true, 234 | }, 235 | }, 236 | }, 237 | { 238 | explain_args: (args: Dict) => { 239 | return { 240 | summary: `Clicking on the element at page ${args.page_index} and index ${args.node_index}...`, 241 | }; 242 | }, 243 | explain_retvals: (args: Dict, retvals: Dict) => { 244 | return { 245 | summary: `The element at page ${args.page_index} and index ${args.node_index} is clicked.`, 246 | details: `${retvals.url}\n${retvals.title}\n${JSON.stringify( 247 | retvals.content, 248 | )}`, 249 | }; 250 | }, 251 | fn: async (args) => { 252 | return await this.withLock(async () => { 253 | await this.browserUse.clickElement( 254 | args.page_index, 255 | args.node_index, 256 | ); 257 | const metadata = await this.browserUse.getPageMetadata( 258 | args.page_index, 259 | ); 260 | return { 261 | ...metadata, 262 | content: await this.browserUse.getPageContent(args.page_index), 263 | }; 264 | }); 265 | }, 266 | }, 267 | ); 268 | athena.registerTool( 269 | { 270 | name: "browser/fill", 271 | desc: "Fills text into an element.", 272 | args: { 273 | page_index: { 274 | type: "number", 275 | desc: "The index of the page.", 276 | required: true, 277 | }, 278 | node_index: { 279 | type: "number", 280 | desc: "The index of the element to fill text into.", 281 | required: true, 282 | }, 283 | text: { 284 | type: "string", 285 | desc: "The text to fill into the element.", 286 | required: true, 287 | }, 288 | }, 289 | retvals: { 290 | url: { 291 | type: "string", 292 | desc: "The URL of the page.", 293 | required: true, 294 | }, 295 | title: { 296 | type: "string", 297 | desc: "The content of the page after filling text into the element.", 298 | required: true, 299 | }, 300 | content: { 301 | type: "array", 302 | desc: "The content of the page after filling text into the element.", 303 | required: true, 304 | }, 305 | }, 306 | }, 307 | { 308 | explain_args: (args: Dict) => { 309 | return { 310 | summary: `Filling ${args.text} into the element at page ${args.page_index} and index ${args.node_index}...`, 311 | }; 312 | }, 313 | explain_retvals: (args: Dict, retvals: Dict) => { 314 | return { 315 | summary: `The element at page ${args.page_index} and index ${args.node_index} is filled with ${args.text}.`, 316 | details: `${retvals.url}\n${retvals.title}\n${JSON.stringify( 317 | retvals.content, 318 | )}`, 319 | }; 320 | }, 321 | fn: async (args) => { 322 | return await this.withLock( 323 | async (): Promise<{ 324 | title: string; 325 | url: string; 326 | content: any[]; 327 | }> => { 328 | await this.browserUse.fillElement( 329 | args.page_index, 330 | args.node_index, 331 | args.text, 332 | ); 333 | const metadata = await this.browserUse.getPageMetadata( 334 | args.page_index, 335 | ); 336 | return { 337 | ...metadata, 338 | content: await this.browserUse.getPageContent(args.page_index), 339 | }; 340 | }, 341 | ); 342 | }, 343 | }, 344 | ); 345 | athena.registerTool( 346 | { 347 | name: "browser/get-content", 348 | desc: "Gets the content of the page.", 349 | args: { 350 | index: { 351 | type: "number", 352 | desc: "The index of the page.", 353 | required: true, 354 | }, 355 | }, 356 | retvals: { 357 | url: { 358 | type: "string", 359 | desc: "The URL of the page.", 360 | required: true, 361 | }, 362 | title: { 363 | type: "string", 364 | desc: "The content of the page.", 365 | required: true, 366 | }, 367 | content: { 368 | type: "array", 369 | desc: "The content of the page.", 370 | required: true, 371 | }, 372 | }, 373 | }, 374 | { 375 | explain_args: (args: Dict) => { 376 | return { 377 | summary: `Getting the content of the page at index ${args.index}...`, 378 | }; 379 | }, 380 | explain_retvals: (args: Dict, retvals: Dict) => { 381 | return { 382 | summary: `The content of the page at index ${args.index} is retrieved.`, 383 | details: `${retvals.url}\n${retvals.title}\n${JSON.stringify( 384 | retvals.content, 385 | )}`, 386 | }; 387 | }, 388 | fn: async (args) => { 389 | return await this.withLock(async () => { 390 | const metadata = await this.browserUse.getPageMetadata(args.index); 391 | return { 392 | ...metadata, 393 | content: await this.browserUse.getPageContent(args.index), 394 | }; 395 | }); 396 | }, 397 | }, 398 | ); 399 | athena.registerTool( 400 | { 401 | name: "browser/get-element-data", 402 | desc: "Gets the tag name and attributes of an element. Use this tool if you need to get the src of an image, the href of a link, or etc.", 403 | args: { 404 | page_index: { 405 | type: "number", 406 | desc: "The index of the page.", 407 | required: true, 408 | }, 409 | node_index: { 410 | type: "number", 411 | desc: "The index of the element.", 412 | required: true, 413 | }, 414 | }, 415 | retvals: { 416 | tagName: { 417 | type: "string", 418 | desc: "The tag name of the element.", 419 | required: true, 420 | }, 421 | attributes: { 422 | type: "object", 423 | desc: "The attributes of the element.", 424 | required: true, 425 | }, 426 | }, 427 | }, 428 | { 429 | explain_args: (args: Dict) => { 430 | return { 431 | summary: `Getting the tag name and attributes of the element at page ${args.page_index} and index ${args.node_index}...`, 432 | }; 433 | }, 434 | explain_retvals: (args: Dict, retvals: Dict) => { 435 | return { 436 | summary: `The tag name and attributes of the element at page ${args.page_index} and index ${args.node_index} are retrieved.`, 437 | details: `${retvals.tagName}\n${JSON.stringify(retvals.attributes)}`, 438 | }; 439 | }, 440 | fn: async (args: Dict) => { 441 | return await this.withLock(async () => { 442 | return this.browserUse.getElementData( 443 | args.page_index, 444 | args.node_index, 445 | ); 446 | }); 447 | }, 448 | }, 449 | ); 450 | athena.registerTool( 451 | { 452 | name: "browser/screenshot", 453 | desc: "Takes a screenshot of the page.", 454 | args: { 455 | index: { 456 | type: "number", 457 | desc: "The index of the page.", 458 | required: true, 459 | }, 460 | path: { 461 | type: "string", 462 | desc: "The path to save the screenshot.", 463 | required: true, 464 | }, 465 | }, 466 | retvals: { 467 | status: { 468 | type: "string", 469 | desc: "The status of the operation.", 470 | required: true, 471 | }, 472 | }, 473 | }, 474 | { 475 | explain_args: (args: Dict) => { 476 | return { 477 | summary: `Taking a screenshot of the page at index ${args.index} and saving it to ${args.path}...`, 478 | }; 479 | }, 480 | explain_retvals: (args: Dict, retvals: Dict) => { 481 | return { 482 | summary: `The screenshot of the page at index ${args.index} is taken and saved to ${args.path}.`, 483 | }; 484 | }, 485 | fn: async (args: Dict) => { 486 | return await this.withLock(async () => { 487 | await this.browserUse.screenshot( 488 | this.browserUse.pages[args.index].page, 489 | args.path, 490 | ); 491 | return { 492 | status: "success", 493 | }; 494 | }); 495 | }, 496 | }, 497 | ); 498 | athena.registerTool( 499 | { 500 | name: "browser/scroll-down", 501 | desc: "Scrolls down the page to load more content. If the webpage loads more content when you scroll down and you need to access the new content, you must call this tool to scroll to the bottom of the page.", 502 | args: { 503 | index: { 504 | type: "number", 505 | desc: "The index of the page.", 506 | required: true, 507 | }, 508 | }, 509 | retvals: { 510 | url: { 511 | type: "string", 512 | desc: "The URL of the page.", 513 | required: true, 514 | }, 515 | title: { 516 | type: "string", 517 | desc: "The content of the page after scrolling down.", 518 | required: true, 519 | }, 520 | content: { 521 | type: "array", 522 | desc: "The content of the page after scrolling down.", 523 | required: true, 524 | }, 525 | }, 526 | }, 527 | { 528 | explain_args: (args: Dict) => { 529 | return { 530 | summary: `Scrolling down the page at index ${args.index}...`, 531 | }; 532 | }, 533 | explain_retvals: (args: Dict, retvals: Dict) => { 534 | return { 535 | summary: `The page at index ${args.index} is scrolled down.`, 536 | details: `${retvals.url}\n${retvals.title}\n${JSON.stringify( 537 | retvals.content, 538 | )}`, 539 | }; 540 | }, 541 | fn: async (args) => { 542 | return await this.withLock(async () => { 543 | await this.browserUse.scrollDown(args.index); 544 | const metadata = await this.browserUse.getPageMetadata(args.index); 545 | return { 546 | ...metadata, 547 | content: await this.browserUse.getPageContent(args.index), 548 | }; 549 | }); 550 | }, 551 | }, 552 | ); 553 | this.browserUse.on("popup", this.boundPopupHandler); 554 | this.browserUse.on("download-started", this.boundDownloadStartedHandler); 555 | this.browserUse.on( 556 | "download-completed", 557 | this.boundDownloadCompletedHandler, 558 | ); 559 | } 560 | 561 | async popupHandler(fromIndex: number, index: number) { 562 | const metadata = await this.browserUse.getPageMetadata(index); 563 | this.athena.emitEvent("browser/popup", { 564 | from_index: fromIndex, 565 | index, 566 | ...metadata, 567 | content: await this.browserUse.getPageContent(index), 568 | }); 569 | } 570 | 571 | async downloadStartedHandler(pageIndex: number, filename: string) { 572 | this.athena.emitEvent("browser/download-started", { 573 | page_index: pageIndex, 574 | filename, 575 | }); 576 | } 577 | 578 | async downloadCompletedHandler(pageIndex: number, filename: string) { 579 | this.athena.emitEvent("browser/download-completed", { 580 | page_index: pageIndex, 581 | filename, 582 | }); 583 | } 584 | 585 | async withLock(fn: () => Promise): Promise { 586 | if (this.lock) { 587 | throw new Error( 588 | "Browser is locked. Please wait for the previous call to complete before making a new call.", 589 | ); 590 | } 591 | this.lock = true; 592 | try { 593 | const result = await fn(); 594 | return result; 595 | } finally { 596 | this.lock = false; 597 | } 598 | } 599 | 600 | async unload(athena: Athena) { 601 | this.browserUse.removeListener("popup", this.boundPopupHandler); 602 | await this.browserUse.close(); 603 | athena.deregisterTool("browser/new-page"); 604 | athena.deregisterTool("browser/click"); 605 | athena.deregisterTool("browser/fill"); 606 | athena.deregisterTool("browser/get-content"); 607 | athena.deregisterTool("browser/get-element-data"); 608 | athena.deregisterTool("browser/screenshot"); 609 | athena.deregisterTool("browser/scroll-down"); 610 | } 611 | } 612 | -------------------------------------------------------------------------------- /src/plugins/cerebrum/init.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import image2uri from "image2uri"; 4 | import { jsonrepair } from "jsonrepair"; 5 | import OpenAI from "openai"; 6 | import { 7 | ChatCompletionContentPart, 8 | ChatCompletionMessageParam, 9 | } from "openai/resources/chat/completions.js"; 10 | 11 | import { Athena, Dict } from "../../core/athena.js"; 12 | import { PluginBase } from "../plugin-base.js"; 13 | import { openaiDefaultHeaders } from "../../utils/constants.js"; 14 | 15 | interface IToolCall { 16 | name: string; 17 | id: string; 18 | args: Dict; 19 | } 20 | 21 | interface IEvent { 22 | tool_result: boolean; 23 | name: string; 24 | id?: string; 25 | args: Dict; 26 | } 27 | 28 | export default class Cerebrum extends PluginBase { 29 | athena!: Athena; 30 | openai!: OpenAI; 31 | busy: boolean = false; 32 | prompts: Array = []; 33 | eventQueue: Array = []; 34 | imageUrls: Array = []; 35 | boundAthenaEventHandler!: (name: string, args: Dict) => void; 36 | boundAthenaPrivateEventHandler!: (name: string, args: Dict) => void; 37 | processEventQueueTimer?: NodeJS.Timeout; 38 | 39 | async load(athena: Athena) { 40 | this.athena = athena; 41 | this.openai = new OpenAI({ 42 | baseURL: this.config.base_url, 43 | apiKey: this.config.api_key, 44 | defaultHeaders: openaiDefaultHeaders, 45 | }); 46 | this.boundAthenaEventHandler = this.athenaEventHandler.bind(this); 47 | this.boundAthenaPrivateEventHandler = 48 | this.athenaPrivateEventHandler.bind(this); 49 | if (this.config.image_supported) { 50 | athena.registerTool( 51 | { 52 | name: "image/check-out", 53 | desc: "Check out an image. Whenever you want to see an image, or the user asks you to see an image, use this tool.", 54 | args: { 55 | image: { 56 | type: "string", 57 | desc: "The URL or local path of the image to check out.", 58 | required: true, 59 | }, 60 | }, 61 | retvals: { 62 | result: { 63 | type: "string", 64 | desc: "The result of checking out the image.", 65 | required: true, 66 | }, 67 | }, 68 | }, 69 | { 70 | fn: async (args) => { 71 | let image = args.image; 72 | if (!image.startsWith("http")) { 73 | image = await image2uri(image); 74 | } 75 | this.imageUrls.push(image); 76 | return { result: "success" }; 77 | }, 78 | explain_args: (args: Dict) => ({ 79 | summary: "Checking out the image...", 80 | details: args.image, 81 | }), 82 | }, 83 | ); 84 | } 85 | athena.on("event", this.boundAthenaEventHandler); 86 | athena.on("private-event", this.boundAthenaPrivateEventHandler); 87 | athena.emitPrivateEvent("webapp-ui/request-token", {}); 88 | athena.once("plugins-loaded", () => { 89 | this.logger.info(this.initialPrompt(), { 90 | type: "initial_prompt", 91 | }); 92 | if (this.eventQueue.length > 0) { 93 | this.processEventQueueWithDelay(); 94 | } 95 | }); 96 | } 97 | 98 | async unload(athena: Athena) { 99 | if (this.config.image_supported) { 100 | athena.deregisterTool("image/check-out"); 101 | } 102 | athena.off("event", this.boundAthenaEventHandler); 103 | athena.off("private-event", this.boundAthenaPrivateEventHandler); 104 | if (this.processEventQueueTimer) { 105 | clearTimeout(this.processEventQueueTimer); 106 | } 107 | } 108 | 109 | pushEvent(event: IEvent) { 110 | event.args = this.sanitizeEventArgs(event.args); 111 | this.logger.info(this.eventToPrompt(event), { 112 | type: "event", 113 | }); 114 | this.athena.emitPrivateEvent("cerebrum/event", { 115 | content: this.eventToPrompt(event), 116 | }); 117 | this.eventQueue.push(event); 118 | this.processEventQueueWithDelay(); 119 | } 120 | 121 | athenaEventHandler(name: string, args: Dict) { 122 | this.pushEvent({ tool_result: false, name, args }); 123 | } 124 | 125 | athenaPrivateEventHandler(name: string, args: Dict) { 126 | if (name === "webapp-ui/token-refreshed") { 127 | this.openai = new OpenAI({ 128 | baseURL: this.config.base_url, 129 | apiKey: args.token, 130 | defaultHeaders: openaiDefaultHeaders, 131 | }); 132 | } 133 | } 134 | 135 | processEventQueueWithDelay() { 136 | if (this.processEventQueueTimer) { 137 | clearTimeout(this.processEventQueueTimer); 138 | } 139 | this.processEventQueueTimer = setTimeout( 140 | () => this.processEventQueue(), 141 | 500, 142 | ); 143 | } 144 | 145 | async processEventQueue() { 146 | if (this.busy) { 147 | return; 148 | } 149 | this.busy = true; 150 | const eventQueueSnapshot = this.eventQueue.slice(); 151 | const imageUrlsSnapshot = this.imageUrls.slice(); 152 | let promptsSnapshot = this.prompts.slice(); 153 | try { 154 | const events = eventQueueSnapshot.map((event) => 155 | this.eventToPrompt(event), 156 | ); 157 | this.ensureInitialPrompt(promptsSnapshot); 158 | promptsSnapshot.push({ 159 | role: "user", 160 | content: [ 161 | { 162 | type: "text", 163 | text: events.join("\n\n"), 164 | }, 165 | ...imageUrlsSnapshot.map((url) => ({ 166 | type: "image_url", 167 | image_url: { 168 | url: url, 169 | }, 170 | })), 171 | ] as ChatCompletionContentPart[], 172 | }); 173 | if (promptsSnapshot.length > this.config.max_prompts) { 174 | promptsSnapshot = [ 175 | promptsSnapshot[0], 176 | ...promptsSnapshot.slice(-(this.config.max_prompts - 1)), 177 | ]; 178 | } 179 | this.athena.emitPrivateEvent("cerebrum/busy", { 180 | busy: true, 181 | }); 182 | const completion = await this.openai.chat.completions.create({ 183 | messages: promptsSnapshot, 184 | model: this.config.model, 185 | temperature: this.config.temperature, 186 | stop: ["", ""], 187 | max_tokens: this.config.max_tokens, 188 | }); 189 | let response = completion.choices[0].message.content as string; 190 | 191 | const toolResultIndex = response.indexOf(""); 192 | const eventIndex = response.indexOf(""); 193 | if (toolResultIndex !== -1 || eventIndex !== -1) { 194 | let firstPatternIndex; 195 | if (toolResultIndex === -1) { 196 | firstPatternIndex = eventIndex; 197 | } else if (eventIndex === -1) { 198 | firstPatternIndex = toolResultIndex; 199 | } else { 200 | firstPatternIndex = Math.min(toolResultIndex, eventIndex); 201 | } 202 | response = response.substring(0, firstPatternIndex); 203 | } 204 | 205 | promptsSnapshot.push({ 206 | role: "assistant", 207 | content: response, 208 | }); 209 | this.eventQueue = this.eventQueue.slice(eventQueueSnapshot.length); 210 | this.imageUrls = this.imageUrls.slice(imageUrlsSnapshot.length); 211 | this.prompts = promptsSnapshot; 212 | 213 | this.logger.info(response, { 214 | type: "model_response", 215 | }); 216 | this.athena.emitPrivateEvent("cerebrum/model-response", { 217 | content: response, 218 | }); 219 | 220 | const thinkingRegex = /\s*([\s\S]*?)\s*<\/thinking>/g; 221 | let match; 222 | while ((match = thinkingRegex.exec(response)) !== null) { 223 | const thinking = match[1]; 224 | this.athena.emitPrivateEvent("cerebrum/thinking", { 225 | content: thinking, 226 | }); 227 | } 228 | 229 | const toolCallRegex = /\s*({[\s\S]*?})\s*<\/tool_call>/g; 230 | while ((match = toolCallRegex.exec(response)) !== null) { 231 | const toolCallJson = match[1]; 232 | (async (toolCallJson: string) => { 233 | let toolName; 234 | let toolCallId; 235 | try { 236 | const toolCall = JSON.parse(jsonrepair(toolCallJson)) as IToolCall; 237 | toolName = toolCall.name; 238 | toolCallId = toolCall.id; 239 | const result = await this.athena.callTool( 240 | toolCall.name, 241 | toolCall.args, 242 | ); 243 | this.pushEvent({ 244 | tool_result: true, 245 | name: toolCall.name, 246 | id: toolCall.id, 247 | args: result, 248 | }); 249 | } catch (error: any) { 250 | this.pushEvent({ 251 | tool_result: true, 252 | name: toolName ?? "tool_error", 253 | id: toolCallId ?? "tool_error", 254 | args: { 255 | error: error.message, 256 | }, 257 | }); 258 | } 259 | })(toolCallJson); 260 | } 261 | } catch (e: any) { 262 | this.logger.error(e); 263 | this.athena.emitPrivateEvent("cerebrum/error", { 264 | content: e.message, 265 | }); 266 | if (e.message.includes("maximum context length")) { 267 | this.prompts.splice(1, 1); 268 | } 269 | } finally { 270 | if (this.eventQueue.length > 0) { 271 | this.processEventQueueWithDelay(); 272 | } 273 | this.athena.emitPrivateEvent("cerebrum/busy", { 274 | busy: false, 275 | }); 276 | this.busy = false; 277 | } 278 | } 279 | 280 | ensureInitialPrompt(prompts: Array) { 281 | if (prompts.length === 0) { 282 | prompts.push({ role: "system", content: this.initialPrompt() }); 283 | return; 284 | } 285 | prompts[0].content = this.initialPrompt(); 286 | } 287 | 288 | eventToPrompt(event: IEvent) { 289 | if (event.tool_result) { 290 | return ` 291 | ${JSON.stringify({ 292 | name: event.name, 293 | id: event.id, 294 | result: event.args, 295 | })} 296 | `; 297 | } 298 | return ` 299 | ${JSON.stringify({ 300 | name: event.name, 301 | args: event.args, 302 | })} 303 | `; 304 | } 305 | 306 | initialPrompt() { 307 | const descs = Object.values(this.athena.plugins) 308 | .map((plugin) => plugin.desc()) 309 | .filter((desc) => desc !== null); 310 | 311 | return `You are Athena, a human-level intelligence created by Athena Authors. Your intelligence comes from a collection of language models, rather than any specific one. Your goal is to behave as human-like as possible while interacting with the world and responding to events. You will be given a set of tools to help you accomplish your goals and interact with the environment. 312 | 313 | First, familiarize yourself with the available tools and possible events: 314 | 315 | 316 | ${Object.values(this.athena.tools) 317 | .map((tool) => JSON.stringify(tool)) 318 | .join("\n\n")} 319 | 320 | 321 | 322 | ${Object.values(this.athena.events) 323 | .map((event) => JSON.stringify(event)) 324 | .join("\n\n")} 325 | 326 | 327 | You will receive a series of events that represent things happening in the real world. Your task is to respond to these events in a human-like manner, using the provided tools when necessary. Here are your instructions: 328 | 329 | 1. Event Handling: 330 | - When you receive an event, carefully analyze its content and decide if a response is necessary. 331 | - If you feel that an event doesn't require a response, you may ignore it. 332 | - For events that do require a response, proceed to plan your actions. 333 | 334 | 2. Planning: 335 | - Before using any tools or responding to an event, plan out your actions in a way similar to how a human would. 336 | - List out the steps you need to take to accomplish your goal. 337 | - Use tags to outline your thought process and strategy. 338 | 339 | 3. Tool Usage: 340 | - If you decide to use a tool, wrap your tool call in tags. 341 | - Specify the tool name, a unique call ID, and the required arguments in JSON format. 342 | - Example: 343 | 344 | {"name":"tool_name","id":"call_123456","args":{"arg1":"value1","arg2":"value2"}} 345 | 346 | - Note that the arguments must follow JSON format. If a string is multi-line, you must use \n to escape newlines. 347 | 348 | 4. Handling Tool Results: 349 | - Tool results will be returned in JSON format within tags. 350 | - Be prepared to handle results that may come immediately or after a delay. 351 | - Use the returned information to inform your next actions or responses. 352 | - Never make up tags yourself. Only use that are returned by the tools. 353 | 354 | 5. Responding to Events: 355 | - Craft your responses to be as human-like as possible. 356 | - Use natural language and appropriate emotional responses when relevant. 357 | - If you're responding to an event, use relevant to do so. 358 | - Remember! Don't respond directly without the tags! Responding directly will not work. Respond to events with tools. 359 | - Never make up tags yourself. 360 | 361 | 6. Continuous Awareness: 362 | - Keep track of ongoing interactions and previous events. 363 | - Maintain context and continuity in your responses and actions. 364 | 365 | 7. Adaptability: 366 | - Be prepared to handle various types of events and adjust your behavior accordingly. 367 | - If you encounter an unfamiliar situation, use your human-like intelligence to reason through it. Behave resourcefully and use your tools wisely to their full potential. 368 | - Consult other language models when you think you cannot resolve a problem alone. Notify the user about the problem as the **last resort**. 369 | 370 | 8. Correctness: 371 | - All your responses must be wrapped in either tags or tags. There can be no tokens outside of these tags. 372 | - You should never respond with an tag or tag. 373 | - You can generate multiple tags in your response, but you should ensure that each is independent and does not depend on the results of other tags. 374 | - For tags that depend on the results of other tags, you must first wait for the results of the other tags to be returned before you can make your . 375 | - Always think and respond in the language of the user. The user may change their language at any time. You must also change your language to match the user's language. 376 | 377 | Remember, your primary goal is to behave as human-like as possible while interacting with the world through these events and tools. Always consider how a human would think, plan, and respond in each situation. 378 | 379 | ${descs.join("\n\n")}`; 380 | } 381 | 382 | sanitizeEventArgs(args: T): T { 383 | if (args === null || args === undefined) { 384 | return args; 385 | } 386 | 387 | if (typeof args === "string") { 388 | if (args.length > this.config.max_event_strlen) { 389 | const filename = `./event-${Math.random() 390 | .toString(36) 391 | .substring(2, 15)}.txt`; 392 | fs.writeFileSync(filename, args, "utf-8"); 393 | return `The result is too long (${args.length} bytes) and cannot be shown directly. It has been written to "${filename}". You can use other tools (Python, shell, etc.) to read the file and reveal part of the content.` as T; 394 | } 395 | 396 | return args; 397 | } 398 | 399 | if (typeof args === "object") { 400 | if (Array.isArray(args)) { 401 | return args.map((item) => this.sanitizeEventArgs(item)) as T; 402 | } 403 | 404 | return Object.fromEntries( 405 | Object.entries(args as Dict).map(([key, value]) => [ 406 | key, 407 | this.sanitizeEventArgs(value), 408 | ]), 409 | ) as T; 410 | } 411 | 412 | return args; 413 | } 414 | 415 | state() { 416 | return { 417 | prompts: this.prompts, 418 | event_queue: this.eventQueue, 419 | image_urls: this.imageUrls, 420 | }; 421 | } 422 | 423 | setState(state: Dict) { 424 | this.prompts = state.prompts; 425 | this.eventQueue = state.event_queue; 426 | this.imageUrls = state.image_urls; 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /src/plugins/cli-ui/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Box, Text, useInput, useApp, Key } from "ink"; 3 | import { Dict } from "../../../core/athena.js"; 4 | 5 | export interface Message { 6 | type: "user" | "athena" | "thinking" | "tool-call" | "tool-result" | "event"; 7 | content: string; 8 | timestamp: string; 9 | } 10 | 11 | interface AppProps { 12 | onMessage: (content: string) => void; 13 | messages: Message[]; 14 | prompt: string; 15 | isThinking: boolean; 16 | } 17 | 18 | export const App: React.FC = ({ 19 | onMessage, 20 | messages, 21 | prompt, 22 | isThinking, 23 | }) => { 24 | const [input, setInput] = useState(""); 25 | const [cursorPosition, setCursorPosition] = useState(0); 26 | const { exit } = useApp(); 27 | 28 | useInput((char: string, key: Key) => { 29 | // Handle Ctrl+C 30 | if (key.ctrl && char === "c") { 31 | exit(); 32 | return; 33 | } 34 | 35 | // Handle Enter 36 | if (key.return) { 37 | if (input.trim()) { 38 | onMessage(input); 39 | } 40 | setInput(""); 41 | setCursorPosition(0); 42 | return; 43 | } 44 | 45 | // Handle Backspace 46 | if (key.backspace || key.delete) { 47 | if (cursorPosition > 0) { 48 | const newInput = 49 | input.slice(0, cursorPosition - 1) + input.slice(cursorPosition); 50 | setInput(newInput); 51 | setCursorPosition(cursorPosition - 1); 52 | } 53 | return; 54 | } 55 | 56 | // Handle Left Arrow 57 | if (key.leftArrow) { 58 | setCursorPosition(Math.max(0, cursorPosition - 1)); 59 | return; 60 | } 61 | 62 | // Handle Right Arrow 63 | if (key.rightArrow) { 64 | setCursorPosition(Math.min(input.length, cursorPosition + 1)); 65 | return; 66 | } 67 | 68 | // Handle regular character input 69 | if (char && char.length === 1) { 70 | const newInput = 71 | input.slice(0, cursorPosition) + char + input.slice(cursorPosition); 72 | setInput(newInput); 73 | setCursorPosition(cursorPosition + 1); 74 | } 75 | }); 76 | 77 | return ( 78 | 79 | {messages.map((message) => ( 80 | 81 | 82 | {message.type === "user" && " "} 83 | {message.type === "athena" && " "} 84 | {message.type === "thinking" && " "} 85 | {message.type === "tool-call" && " "} 86 | {message.type === "tool-result" && " "} 87 | {message.type === "event" && " "} 88 | {message.content} 89 | 90 | 91 | ))} 92 | 93 | {prompt} 94 | 95 | {input.slice(0, cursorPosition)} 96 | 97 | {input.slice(cursorPosition)} 98 | 99 | 100 | 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /src/plugins/cli-ui/init.ts: -------------------------------------------------------------------------------- 1 | import { render } from "ink"; 2 | import React from "react"; 3 | import { Athena, Dict } from "../../core/athena.js"; 4 | import { PluginBase } from "../plugin-base.js"; 5 | import { App, type Message } from "./components/App.js"; 6 | 7 | export default class CLIUI extends PluginBase { 8 | athena!: Athena; 9 | boundAthenaPrivateEventHandler!: (event: string, args: Dict) => void; 10 | messages: Message[] = []; 11 | isThinking: boolean = false; 12 | prompt: string = " "; 13 | 14 | desc() { 15 | return "You can interact with the user using UI tools and events. When the user asks you to do something, think about what information and/or details you need to do that. If you need something only the user can provide, you need to ask the user for that information. Ask the users about task details if the request is vague. Be proactive and update the user on your progress, milestones, and obstacles and how you are going to overcome them."; 16 | } 17 | 18 | async load(athena: Athena) { 19 | this.athena = athena; 20 | this.boundAthenaPrivateEventHandler = 21 | this.athenaPrivateEventHandler.bind(this); 22 | 23 | athena.on("private-event", this.boundAthenaPrivateEventHandler); 24 | 25 | athena.registerEvent({ 26 | name: "ui/message-received", 27 | desc: "Triggered when a message is received from the user.", 28 | args: { 29 | content: { 30 | type: "string", 31 | desc: "The message received from the user.", 32 | required: true, 33 | }, 34 | time: { 35 | type: "string", 36 | desc: "The time the message was sent.", 37 | required: true, 38 | }, 39 | }, 40 | }); 41 | 42 | athena.registerTool( 43 | { 44 | name: "ui/send-message", 45 | desc: "Sends a message to the user.", 46 | args: { 47 | content: { 48 | type: "string", 49 | desc: "The message to send to the user. Don't output any Markdown formatting.", 50 | required: true, 51 | }, 52 | }, 53 | retvals: { 54 | status: { 55 | type: "string", 56 | desc: "Status of the operation.", 57 | required: true, 58 | }, 59 | }, 60 | }, 61 | { 62 | fn: async (args: Dict) => { 63 | this.addMessage("athena", args.content); 64 | return { status: "success" }; 65 | }, 66 | }, 67 | ); 68 | 69 | athena.once("plugins-loaded", async () => { 70 | this.addMessage("athena", "Welcome to Athena!"); 71 | this.renderUI(); 72 | }); 73 | } 74 | 75 | async unload(athena: Athena) { 76 | athena.off("private-event", this.boundAthenaPrivateEventHandler); 77 | athena.deregisterTool("ui/send-message"); 78 | athena.deregisterEvent("ui/message-received"); 79 | } 80 | 81 | athenaPrivateEventHandler(event: string, args: Dict) { 82 | if (event === "cerebrum/thinking") { 83 | this.addMessage("thinking", args.content); 84 | } else if (event === "athena/tool-call") { 85 | this.addMessage("tool-call", args.summary); 86 | } else if (event === "athena/tool-result") { 87 | this.addMessage("tool-result", args.summary); 88 | } else if (event === "athena/event") { 89 | this.addMessage("event", args.summary); 90 | } else if (event === "cerebrum/busy") { 91 | this.isThinking = args.busy; 92 | this.prompt = args.busy ? " " : " "; 93 | this.renderUI(); 94 | } 95 | } 96 | 97 | addMessage(type: Message["type"], content: string) { 98 | this.messages.push({ 99 | type, 100 | content, 101 | timestamp: new Date().toISOString(), 102 | }); 103 | this.renderUI(); 104 | } 105 | 106 | handleMessage(content: string) { 107 | this.addMessage("user", content); 108 | this.athena.emitEvent("ui/message-received", { 109 | content, 110 | time: new Date().toISOString(), 111 | }); 112 | } 113 | 114 | renderUI() { 115 | render( 116 | React.createElement(App, { 117 | onMessage: this.handleMessage.bind(this), 118 | messages: this.messages, 119 | prompt: this.prompt, 120 | isThinking: this.isThinking, 121 | }), 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/plugins/clock/init.ts: -------------------------------------------------------------------------------- 1 | import { Athena, Dict } from "../../core/athena.js"; 2 | import { PluginBase } from "../plugin-base.js"; 3 | 4 | interface ITimeout { 5 | reason: string; 6 | next_trigger_time: number; 7 | recurring: boolean; 8 | interval: number; 9 | } 10 | 11 | export default class Clock extends PluginBase { 12 | athena!: Athena; 13 | timeouts: ITimeout[] = []; 14 | timeout?: NodeJS.Timeout; 15 | 16 | async load(athena: Athena) { 17 | this.athena = athena; 18 | athena.registerEvent({ 19 | name: "clock/timeout-triggered", 20 | desc: "This event is triggered when a timeout is reached.", 21 | args: { 22 | reason: { 23 | type: "string", 24 | desc: "The reason why the timeout was triggered.", 25 | required: true, 26 | }, 27 | recurring: { 28 | type: "boolean", 29 | desc: "Whether the timeout is recurring.", 30 | required: true, 31 | }, 32 | interval: { 33 | type: "number", 34 | desc: "The interval of the timeout.", 35 | required: true, 36 | }, 37 | now: { 38 | type: "string", 39 | desc: "The current date and time.", 40 | required: true, 41 | }, 42 | }, 43 | explain_args: (args: Dict) => ({ 44 | summary: "A timeout was triggered.", 45 | details: args.reason, 46 | }), 47 | }); 48 | athena.registerTool( 49 | { 50 | name: "clock/get-time", 51 | desc: "Get the current date and time.", 52 | args: {}, 53 | retvals: { 54 | time: { 55 | type: "string", 56 | desc: "The current date and time.", 57 | required: true, 58 | }, 59 | }, 60 | }, 61 | { 62 | fn: async () => { 63 | return { time: new Date().toString() }; 64 | }, 65 | explain_args: () => ({ 66 | summary: "Getting the current date and time...", 67 | }), 68 | explain_retvals: (args: Dict, retvals: Dict) => ({ 69 | summary: `The current date and time is ${retvals.time}.`, 70 | }), 71 | }, 72 | ); 73 | athena.registerTool( 74 | { 75 | name: "clock/set-timer", 76 | desc: "Set a timer.", 77 | args: { 78 | seconds: { 79 | type: "number", 80 | desc: "The number of seconds to wait before triggering the timer.", 81 | required: false, 82 | }, 83 | minutes: { 84 | type: "number", 85 | desc: "The number of minutes to wait before triggering the timer.", 86 | required: false, 87 | }, 88 | hours: { 89 | type: "number", 90 | desc: "The number of hours to wait before triggering the timer.", 91 | required: false, 92 | }, 93 | reason: { 94 | type: "string", 95 | desc: "The reason why the timer was set. Include as much detail as possible.", 96 | required: true, 97 | }, 98 | recurring: { 99 | type: "boolean", 100 | desc: "Whether the timer is recurring.", 101 | required: true, 102 | }, 103 | }, 104 | retvals: { 105 | status: { 106 | type: "string", 107 | desc: "The status of the operation.", 108 | required: true, 109 | }, 110 | }, 111 | }, 112 | { 113 | fn: async (args: Dict) => { 114 | const interval = 115 | (args.seconds || 0) * 1000 + 116 | (args.minutes || 0) * 60 * 1000 + 117 | (args.hours || 0) * 60 * 60 * 1000; 118 | this.timeouts.push({ 119 | reason: args.reason, 120 | next_trigger_time: Date.now() + interval, 121 | recurring: args.recurring, 122 | interval, 123 | }); 124 | this.updateTimeout(); 125 | return { status: "success" }; 126 | }, 127 | explain_args: (args: Dict) => ({ 128 | summary: `Setting a ${ 129 | args.recurring ? "recurring" : "one-time" 130 | } timer for ${args.hours || 0} hours, ${ 131 | args.minutes || 0 132 | } minutes, and ${args.seconds || 0} seconds...`, 133 | details: args.reason, 134 | }), 135 | }, 136 | ); 137 | athena.registerTool( 138 | { 139 | name: "clock/set-alarm", 140 | desc: "Set an alarm.", 141 | args: { 142 | time: { 143 | type: "string", 144 | desc: "The date and time to set the alarm for. Need to specify timezone.", 145 | required: true, 146 | }, 147 | reason: { 148 | type: "string", 149 | desc: "The reason why the alarm was set. Include as much detail as possible.", 150 | required: true, 151 | }, 152 | recurring: { 153 | type: "boolean", 154 | desc: "Whether the alarm is recurring.", 155 | required: true, 156 | }, 157 | }, 158 | retvals: { 159 | status: { 160 | type: "string", 161 | desc: "The status of the operation.", 162 | required: true, 163 | }, 164 | }, 165 | }, 166 | { 167 | fn: async (args: Dict) => { 168 | const time = new Date(args.time); 169 | const now = new Date(); 170 | if (time <= now) { 171 | throw new Error("Alarm time must be in the future."); 172 | } 173 | this.timeouts.push({ 174 | reason: args.reason, 175 | next_trigger_time: time.getTime(), 176 | recurring: args.recurring, 177 | interval: 24 * 60 * 60 * 1000, 178 | }); 179 | this.updateTimeout(); 180 | return { status: "success" }; 181 | }, 182 | explain_args: (args: Dict) => ({ 183 | summary: `Setting a ${ 184 | args.recurring ? "recurring" : "one-time" 185 | } alarm for ${args.time}...`, 186 | details: args.reason, 187 | }), 188 | }, 189 | ); 190 | athena.registerTool( 191 | { 192 | name: "clock/clear-timeout", 193 | desc: "Clear a timeout.", 194 | args: { 195 | index: { 196 | type: "number", 197 | desc: "The index of the timeout to clear.", 198 | required: true, 199 | }, 200 | }, 201 | retvals: { 202 | status: { 203 | type: "string", 204 | desc: "The status of the operation.", 205 | required: true, 206 | }, 207 | }, 208 | }, 209 | { 210 | fn: async (args: Dict) => { 211 | this.timeouts.splice(args.index, 1); 212 | this.updateTimeout(); 213 | return { status: "success" }; 214 | }, 215 | explain_args: (args: Dict) => ({ 216 | summary: `Clearing the timeout at index ${args.index}...`, 217 | }), 218 | }, 219 | ); 220 | athena.registerTool( 221 | { 222 | name: "clock/list-timeouts", 223 | desc: "List all timeouts.", 224 | args: {}, 225 | retvals: { 226 | timeouts: { 227 | type: "array", 228 | desc: "The list of timeouts.", 229 | required: true, 230 | of: { 231 | type: "object", 232 | desc: "A timeout.", 233 | required: true, 234 | of: { 235 | reason: { 236 | type: "string", 237 | desc: "The reason why the timeout was set.", 238 | required: true, 239 | }, 240 | next_trigger_time: { 241 | type: "string", 242 | desc: "The next trigger time of the timeout.", 243 | required: true, 244 | }, 245 | recurring: { 246 | type: "boolean", 247 | desc: "Whether the timeout is recurring.", 248 | required: true, 249 | }, 250 | interval: { 251 | type: "number", 252 | desc: "The interval of the timeout, in seconds.", 253 | required: true, 254 | }, 255 | }, 256 | }, 257 | }, 258 | }, 259 | }, 260 | { 261 | fn: async () => { 262 | return { 263 | timeouts: this.timeouts.map((t) => ({ 264 | reason: t.reason, 265 | next_trigger_time: new Date(t.next_trigger_time).toString(), 266 | recurring: t.recurring, 267 | interval: t.interval / 1000, 268 | })), 269 | }; 270 | }, 271 | }, 272 | ); 273 | } 274 | 275 | async unload(athena: Athena) { 276 | if (this.timeout) { 277 | clearTimeout(this.timeout); 278 | } 279 | athena.deregisterEvent("clock/timeout-triggered"); 280 | athena.deregisterTool("clock/get-time"); 281 | athena.deregisterTool("clock/set-timer"); 282 | athena.deregisterTool("clock/set-alarm"); 283 | athena.deregisterTool("clock/clear-timeout"); 284 | athena.deregisterTool("clock/list-timeouts"); 285 | } 286 | 287 | state() { 288 | return { 289 | timeouts: this.timeouts, 290 | }; 291 | } 292 | 293 | setState(state: Dict) { 294 | this.timeouts = state.timeouts; 295 | const now = Date.now(); 296 | this.timeouts = this.timeouts.filter((t) => { 297 | if (t.next_trigger_time > now) { 298 | return true; 299 | } 300 | if (t.recurring) { 301 | while (t.next_trigger_time <= now) { 302 | t.next_trigger_time += t.interval; 303 | } 304 | return true; 305 | } 306 | return false; 307 | }); 308 | this.updateTimeout(); 309 | } 310 | 311 | updateTimeout() { 312 | if (this.timeout) { 313 | clearTimeout(this.timeout); 314 | } 315 | if (this.timeouts.length === 0) { 316 | return; 317 | } 318 | const closestNextTriggerTime = this.timeouts.reduce((min, t) => { 319 | return Math.min(min, t.next_trigger_time); 320 | }, Infinity); 321 | this.timeout = setTimeout(() => { 322 | const now = Date.now(); 323 | const firedTimeouts = this.timeouts.filter( 324 | (t) => t.next_trigger_time <= now, 325 | ); 326 | for (const timeout of firedTimeouts) { 327 | this.athena.emitEvent("clock/timeout-triggered", { 328 | reason: timeout.reason, 329 | recurring: timeout.recurring, 330 | interval: timeout.interval, 331 | now: new Date().toString(), 332 | }); 333 | } 334 | this.timeouts = this.timeouts.filter((t) => { 335 | if (t.next_trigger_time > now) { 336 | return true; 337 | } 338 | if (t.recurring) { 339 | while (t.next_trigger_time <= now) { 340 | t.next_trigger_time += t.interval; 341 | } 342 | return true; 343 | } 344 | return false; 345 | }); 346 | this.updateTimeout(); 347 | }, closestNextTriggerTime - Date.now()); 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/plugins/file-system/init.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | 3 | import { isBinary } from "istextorbinary"; 4 | 5 | import { Athena, Dict } from "../../core/athena.js"; 6 | import { PluginBase } from "../plugin-base.js"; 7 | 8 | export default class FileSystem extends PluginBase { 9 | desc() { 10 | return `The home directory is ${ 11 | process.env.HOME 12 | }. The current working directory is ${process.cwd()}. The operating system is ${ 13 | process.platform 14 | }.`; 15 | } 16 | 17 | async load(athena: Athena) { 18 | athena.registerTool( 19 | { 20 | name: "fs/list", 21 | desc: "List a directory", 22 | args: { 23 | path: { 24 | type: "string", 25 | desc: "The path to list", 26 | required: true, 27 | }, 28 | }, 29 | retvals: { 30 | content: { 31 | type: "array", 32 | desc: "The content of the directory", 33 | required: true, 34 | of: { 35 | type: "object", 36 | desc: "The file or directory", 37 | required: true, 38 | of: { 39 | name: { 40 | type: "string", 41 | desc: "The name of the file or directory", 42 | required: true, 43 | }, 44 | type: { 45 | type: "string", 46 | desc: "The type of the file or directory", 47 | required: true, 48 | }, 49 | size: { 50 | type: "number", 51 | desc: "The size of the file in bytes", 52 | required: false, 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | { 60 | fn: async (args: Dict) => { 61 | const content = await fs.readdir(args.path, { withFileTypes: true }); 62 | const ret = []; 63 | for (const entry of content) { 64 | ret.push({ 65 | name: entry.name, 66 | type: entry.isDirectory() ? "directory" : "file", 67 | size: entry.isFile() 68 | ? (await fs.stat(`${args.path}/${entry.name}`)).size 69 | : undefined, 70 | }); 71 | } 72 | return { content: ret }; 73 | }, 74 | explain_args: (args: Dict) => ({ 75 | summary: `Listing the directory ${args.path}...`, 76 | }), 77 | explain_retvals: (args: Dict, retvals: Dict) => ({ 78 | summary: `The directory ${args.path} is successfully listed.`, 79 | details: retvals.content 80 | .map((item: Dict) => item.name) 81 | .join(", "), 82 | }), 83 | }, 84 | ); 85 | athena.registerTool( 86 | { 87 | name: "fs/read", 88 | desc: "Read a file. This tool cannot be used to read binary files.", 89 | args: { 90 | path: { 91 | type: "string", 92 | desc: "The path to the file", 93 | required: true, 94 | }, 95 | }, 96 | retvals: { 97 | content: { 98 | type: "string", 99 | desc: "The content of the file", 100 | required: true, 101 | }, 102 | }, 103 | }, 104 | { 105 | fn: async (args: Dict) => { 106 | const buffer = await fs.readFile(args.path); 107 | if (isBinary(args.path, buffer)) { 108 | throw new Error("File is binary"); 109 | } 110 | return { content: buffer.toString("utf8") }; 111 | }, 112 | explain_args: (args: Dict) => ({ 113 | summary: `Reading the file ${args.path}...`, 114 | }), 115 | explain_retvals: (args: Dict, retvals: Dict) => ({ 116 | summary: `The file ${args.path} is successfully read.`, 117 | details: retvals.content, 118 | }), 119 | }, 120 | ); 121 | athena.registerTool( 122 | { 123 | name: "fs/write", 124 | desc: "Write to a file", 125 | args: { 126 | path: { 127 | type: "string", 128 | desc: "The path to the file", 129 | required: true, 130 | }, 131 | content: { 132 | type: "string", 133 | desc: "The content to write", 134 | required: true, 135 | }, 136 | }, 137 | retvals: { 138 | status: { 139 | type: "string", 140 | desc: "The status of the write operation", 141 | required: true, 142 | }, 143 | }, 144 | }, 145 | { 146 | fn: async (args: Dict) => { 147 | await fs.writeFile(args.path, args.content, "utf8"); 148 | return { status: "success" }; 149 | }, 150 | explain_args: (args: Dict) => ({ 151 | summary: `Writing to the file ${args.path}...`, 152 | details: args.content, 153 | }), 154 | }, 155 | ); 156 | athena.registerTool( 157 | { 158 | name: "fs/delete", 159 | desc: "Delete a file or directory", 160 | args: { 161 | path: { 162 | type: "string", 163 | desc: "The path to the file or directory", 164 | required: true, 165 | }, 166 | }, 167 | retvals: { 168 | status: { 169 | type: "string", 170 | desc: "The status of the delete operation", 171 | required: true, 172 | }, 173 | }, 174 | }, 175 | { 176 | fn: async (args: Dict) => { 177 | await fs.rm(args.path, { recursive: true }); 178 | return { status: "success" }; 179 | }, 180 | explain_args: (args: Dict) => ({ 181 | summary: `Deleting the file or directory ${args.path}...`, 182 | }), 183 | }, 184 | ); 185 | athena.registerTool( 186 | { 187 | name: "fs/copy", 188 | desc: "Copy a file or directory", 189 | args: { 190 | src: { 191 | type: "string", 192 | desc: "The source path", 193 | required: true, 194 | }, 195 | dst: { 196 | type: "string", 197 | desc: "The destination path", 198 | required: true, 199 | }, 200 | }, 201 | retvals: { 202 | status: { 203 | type: "string", 204 | desc: "The status of the copy operation", 205 | required: true, 206 | }, 207 | }, 208 | }, 209 | { 210 | fn: async (args: Dict) => { 211 | const stat = await fs.stat(args.src); 212 | if (stat.isFile()) { 213 | await fs.copyFile(args.src, args.dst); 214 | } else if (stat.isDirectory()) { 215 | await fs.cp(args.src, args.dst, { recursive: true }); 216 | } else { 217 | throw new Error("Unknown file type"); 218 | } 219 | return { status: "success" }; 220 | }, 221 | explain_args: (args: Dict) => ({ 222 | summary: `Copying the file or directory ${args.src} to ${args.dst}...`, 223 | }), 224 | }, 225 | ); 226 | athena.registerTool( 227 | { 228 | name: "fs/move", 229 | desc: "Move or rename a file or directory", 230 | args: { 231 | src: { 232 | type: "string", 233 | desc: "The source path", 234 | required: true, 235 | }, 236 | dst: { 237 | type: "string", 238 | desc: "The destination path", 239 | required: true, 240 | }, 241 | }, 242 | retvals: { 243 | status: { 244 | type: "string", 245 | desc: "The status of the move operation", 246 | required: true, 247 | }, 248 | }, 249 | }, 250 | { 251 | fn: async (args: Dict) => { 252 | await fs.rename(args.src, args.dst); 253 | return { status: "success" }; 254 | }, 255 | explain_args: (args: Dict) => ({ 256 | summary: `Moving or renaming the file or directory ${args.src} to ${args.dst}...`, 257 | }), 258 | }, 259 | ); 260 | athena.registerTool( 261 | { 262 | name: "fs/mkdir", 263 | desc: "Create a directory recursively", 264 | args: { 265 | path: { 266 | type: "string", 267 | desc: "The path to the directory", 268 | required: true, 269 | }, 270 | }, 271 | retvals: { 272 | status: { 273 | type: "string", 274 | desc: "The status of the mkdir operation", 275 | required: true, 276 | }, 277 | }, 278 | }, 279 | { 280 | fn: async (args: Dict) => { 281 | await fs.mkdir(args.path, { recursive: true }); 282 | return { status: "success" }; 283 | }, 284 | explain_args: (args: Dict) => ({ 285 | summary: `Creating the directory ${args.path}...`, 286 | }), 287 | }, 288 | ); 289 | athena.registerTool( 290 | { 291 | name: "fs/cd", 292 | desc: "Change the current working directory.", 293 | args: { 294 | directory: { 295 | type: "string", 296 | desc: "Directory to change to. Could be an absolute or relative path.", 297 | required: true, 298 | }, 299 | }, 300 | retvals: { 301 | result: { 302 | desc: "Result of the cd command", 303 | required: true, 304 | type: "string", 305 | }, 306 | }, 307 | }, 308 | { 309 | fn: async (args: Dict) => { 310 | process.chdir(args.directory); 311 | return { result: "success" }; 312 | }, 313 | explain_args: (args: Dict) => ({ 314 | summary: `Changing the current working directory to ${args.directory}...`, 315 | }), 316 | }, 317 | ); 318 | athena.registerTool( 319 | { 320 | name: "fs/find-replace", 321 | desc: "Find and replace a string in a file. If you need to fix a bug in a file, you should use this tool instead of using fs/write to change the entire file.", 322 | args: { 323 | path: { 324 | type: "string", 325 | desc: "The path to the file", 326 | required: true, 327 | }, 328 | old: { 329 | type: "string", 330 | desc: "The string to find", 331 | required: true, 332 | }, 333 | new: { 334 | type: "string", 335 | desc: "The string to replace the old string with", 336 | required: true, 337 | }, 338 | }, 339 | retvals: { 340 | status: { 341 | type: "string", 342 | desc: "The status of the find-replace operation", 343 | required: true, 344 | }, 345 | }, 346 | }, 347 | { 348 | fn: async (args: Dict) => { 349 | const content = await fs.readFile(args.path, "utf8"); 350 | const newContent = content.replace(args.old, args.new); 351 | await fs.writeFile(args.path, newContent, "utf8"); 352 | return { status: "success" }; 353 | }, 354 | explain_args: (args: Dict) => ({ 355 | summary: `Finding and replacing ${args.path}...`, 356 | details: `The old string is ${args.old} and the new string is ${args.new}.`, 357 | }), 358 | }, 359 | ); 360 | athena.registerTool( 361 | { 362 | name: "fs/append", 363 | desc: "Append to a file", 364 | args: { 365 | path: { 366 | type: "string", 367 | desc: "The path to the file", 368 | required: true, 369 | }, 370 | content: { 371 | type: "string", 372 | desc: "The content to append", 373 | required: true, 374 | }, 375 | }, 376 | retvals: { 377 | status: { 378 | type: "string", 379 | desc: "The status of the append operation", 380 | required: true, 381 | }, 382 | }, 383 | }, 384 | { 385 | fn: async (args: Dict) => { 386 | await fs.appendFile(args.path, args.content, "utf8"); 387 | return { status: "success" }; 388 | }, 389 | explain_args: (args: Dict) => ({ 390 | summary: `Appending to the file ${args.path}...`, 391 | details: args.content, 392 | }), 393 | }, 394 | ); 395 | } 396 | 397 | async unload(athena: Athena) { 398 | athena.deregisterTool("fs/list"); 399 | athena.deregisterTool("fs/read"); 400 | athena.deregisterTool("fs/write"); 401 | athena.deregisterTool("fs/delete"); 402 | athena.deregisterTool("fs/copy"); 403 | athena.deregisterTool("fs/move"); 404 | athena.deregisterTool("fs/mkdir"); 405 | athena.deregisterTool("fs/cd"); 406 | athena.deregisterTool("fs/find-replace"); 407 | athena.deregisterTool("fs/append"); 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /src/plugins/http/exa.ts: -------------------------------------------------------------------------------- 1 | export interface IExaSearchInitParams { 2 | baseUrl?: string; 3 | apiKey: string; 4 | } 5 | 6 | export interface IExaSearchResult { 7 | title: string; 8 | url: string; 9 | text: string; // Text content from the search result 10 | } 11 | 12 | export class ExaSearch { 13 | baseUrl: string; 14 | apiKey: string; 15 | 16 | constructor(initParams: IExaSearchInitParams) { 17 | // Default Exa API endpoint 18 | this.baseUrl = initParams.baseUrl || "https://api.exa.ai"; 19 | this.apiKey = initParams.apiKey; 20 | } 21 | 22 | async search(query: string): Promise { 23 | const response = await fetch(`${this.baseUrl}/search`, { 24 | method: "POST", 25 | headers: { 26 | "Content-Type": "application/json", 27 | "x-api-key": this.apiKey, // Exa uses x-api-key header 28 | Accept: "application/json", // Ensure we get JSON back 29 | }, 30 | // Request body structure based on the curl example 31 | body: JSON.stringify({ 32 | query: query, 33 | contents: { 34 | text: { maxCharacters: 1000 }, // Request text snippets 35 | }, 36 | numResults: 10, // Request 10 results, adjust as needed 37 | }), 38 | }); 39 | 40 | if (!response.ok) { 41 | const errorBody = await response.text(); 42 | console.error("Exa API Error:", response.status, errorBody); 43 | throw new Error(`Failed to search with Exa API: ${response.statusText}`); 44 | } 45 | 46 | const json = await response.json(); 47 | // Map the response structure to IExaSearchResult 48 | // Adjust based on actual Exa response format if different 49 | return json.results.map((result: any) => ({ 50 | title: result.title || "No Title", // Provide default if title is missing 51 | url: result.url, 52 | text: result.text || "No text content", // Extract text from contents if available 53 | })); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/plugins/http/init.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import follow_redirects from "follow-redirects"; 4 | const { https } = follow_redirects; 5 | import { convert } from "html-to-text"; 6 | 7 | import { Athena, Dict } from "../../core/athena.js"; 8 | import { JinaSearch } from "./jina.js"; 9 | import { ExaSearch } from "./exa.js"; 10 | import { TavilySearch } from "./tavily.js"; 11 | import { PluginBase } from "../plugin-base.js"; 12 | 13 | export default class Http extends PluginBase { 14 | readonly headers = { 15 | "User-Agent": 16 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", 17 | Accept: 18 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 19 | "Accept-Language": "en-US,en;q=0.5", 20 | Connection: "keep-alive", 21 | "Upgrade-Insecure-Requests": "1", 22 | }; 23 | 24 | jina!: JinaSearch; 25 | exa!: ExaSearch; 26 | tavily!: TavilySearch; 27 | boundAthenaPrivateEventHandler!: (name: string, args: Dict) => void; 28 | 29 | async load(athena: Athena) { 30 | if (this.config.jina) { 31 | this.jina = new JinaSearch({ 32 | baseUrl: this.config.jina.base_url, 33 | apiKey: this.config.jina.api_key, 34 | }); 35 | } 36 | if (this.config.exa) { 37 | this.exa = new ExaSearch({ 38 | baseUrl: this.config.exa.base_url, 39 | apiKey: this.config.exa.api_key, 40 | }); 41 | } 42 | if (this.config.tavily) { 43 | this.tavily = new TavilySearch({ 44 | baseUrl: this.config.tavily.base_url, 45 | apiKey: this.config.tavily.api_key, 46 | }); 47 | } 48 | this.boundAthenaPrivateEventHandler = 49 | this.athenaPrivateEventHandler.bind(this); 50 | athena.on("private-event", this.boundAthenaPrivateEventHandler); 51 | athena.emitPrivateEvent("webapp-ui/request-token", {}); 52 | 53 | athena.registerTool( 54 | { 55 | name: "http/fetch", 56 | desc: "Fetches an HTTP/HTTPS URL.", 57 | args: { 58 | url: { 59 | type: "string", 60 | desc: "The URL to fetch.", 61 | required: true, 62 | }, 63 | method: { 64 | type: "string", 65 | desc: "The HTTP method to use. Defaults to GET.", 66 | required: false, 67 | }, 68 | headers: { 69 | type: "object", 70 | desc: "The headers to send with the request.", 71 | required: false, 72 | }, 73 | body: { 74 | type: "string", 75 | desc: "The body to send with the request.", 76 | required: false, 77 | }, 78 | }, 79 | retvals: { 80 | result: { 81 | type: "string", 82 | desc: "The result of the fetch.", 83 | required: true, 84 | }, 85 | }, 86 | }, 87 | { 88 | fn: async (args: Dict) => { 89 | const response = await fetch(args.url, { 90 | method: args.method, 91 | headers: args.headers 92 | ? { 93 | ...this.headers, 94 | ...args.headers, 95 | } 96 | : this.headers, 97 | body: args.body, 98 | redirect: "follow", 99 | }); 100 | return { result: convert(await response.text()) }; 101 | }, 102 | explain_args: (args: Dict) => ({ 103 | summary: `Fetching the URL ${args.url}...`, 104 | }), 105 | explain_retvals: (args: Dict, retvals: Dict) => ({ 106 | summary: `The URL ${args.url} was fetched successfully.`, 107 | details: retvals.result, 108 | }), 109 | }, 110 | ); 111 | if (this.config.jina) { 112 | athena.registerTool( 113 | { 114 | name: "http/search", 115 | desc: "Searches the web for information.", 116 | args: { 117 | query: { 118 | type: "string", 119 | desc: "The query to search for.", 120 | required: true, 121 | }, 122 | }, 123 | retvals: { 124 | results: { 125 | type: "array", 126 | desc: "The results of the search.", 127 | required: true, 128 | of: { 129 | type: "object", 130 | desc: "The result of the search.", 131 | of: { 132 | title: { 133 | type: "string", 134 | desc: "The title of the result.", 135 | required: true, 136 | }, 137 | url: { 138 | type: "string", 139 | desc: "The URL of the result.", 140 | required: true, 141 | }, 142 | desc: { 143 | type: "string", 144 | desc: "The description of the result.", 145 | required: true, 146 | }, 147 | }, 148 | required: true, 149 | }, 150 | }, 151 | }, 152 | }, 153 | { 154 | fn: async (args: Dict) => { 155 | const results = await this.jina.search(args.query); 156 | return { results }; 157 | }, 158 | explain_args: (args: Dict) => ({ 159 | summary: `Searching the web for ${args.query}...`, 160 | }), 161 | explain_retvals: (args: Dict, retvals: Dict) => ({ 162 | summary: `Found ${retvals.results.length} results for ${args.query}.`, 163 | details: JSON.stringify(retvals.results), 164 | }), 165 | }, 166 | ); 167 | } 168 | if (this.config.exa) { 169 | athena.registerTool( 170 | { 171 | name: "http/exa-search", 172 | desc: "Searches the web for information using Exa API.", 173 | args: { 174 | query: { 175 | type: "string", 176 | desc: "The query to search for.", 177 | required: true, 178 | }, 179 | }, 180 | retvals: { 181 | results: { 182 | type: "array", 183 | desc: "The results of the search.", 184 | required: true, 185 | of: { 186 | type: "object", 187 | desc: "A single search result.", 188 | of: { 189 | title: { 190 | type: "string", 191 | desc: "The title of the result.", 192 | required: true, 193 | }, 194 | url: { 195 | type: "string", 196 | desc: "The URL of the result.", 197 | required: true, 198 | }, 199 | text: { 200 | type: "string", 201 | desc: "Text content snippet.", 202 | required: true, 203 | }, 204 | }, 205 | required: true, 206 | }, 207 | }, 208 | }, 209 | }, 210 | { 211 | fn: async (args: Dict) => { 212 | const results = await this.exa.search(args.query); 213 | return { results }; 214 | }, 215 | explain_args: (args: Dict) => ({ 216 | summary: `Searching the web with Exa for ${args.query}...`, 217 | }), 218 | explain_retvals: (args: Dict, retvals: Dict) => ({ 219 | summary: `Found ${retvals.results.length} results with Exa for ${args.query}.`, 220 | details: JSON.stringify(retvals.results), 221 | }), 222 | }, 223 | ); 224 | } 225 | if (this.config.tavily) { 226 | athena.registerTool( 227 | { 228 | name: "http/tavily-search", 229 | desc: "Searches the web for information using Tavily API.", 230 | args: { 231 | query: { 232 | type: "string", 233 | desc: "The query to search for.", 234 | required: true, 235 | }, 236 | }, 237 | retvals: { 238 | results: { 239 | type: "array", 240 | desc: "The results of the search.", 241 | required: true, 242 | of: { 243 | type: "object", 244 | desc: "A single search result.", 245 | of: { 246 | title: { 247 | type: "string", 248 | desc: "The title of the result.", 249 | required: true, 250 | }, 251 | url: { 252 | type: "string", 253 | desc: "The URL of the result.", 254 | required: true, 255 | }, 256 | content: { 257 | type: "string", 258 | desc: "Text content snippet.", 259 | required: true, 260 | }, 261 | }, 262 | required: true, 263 | }, 264 | }, 265 | }, 266 | }, 267 | { 268 | fn: async (args: Dict) => { 269 | const results = await this.tavily.search(args.query); 270 | return { results }; 271 | }, 272 | explain_args: (args: Dict) => ({ 273 | summary: `Searching the web with Tavily for ${args.query}...`, 274 | }), 275 | explain_retvals: (args: Dict, retvals: Dict) => ({ 276 | summary: `Found ${retvals.results.length} results with Tavily for ${args.query}.`, 277 | details: JSON.stringify(retvals.results), 278 | }), 279 | }, 280 | ); 281 | } 282 | athena.registerTool( 283 | { 284 | name: "http/download-file", 285 | desc: "Downloads a file from an HTTP/HTTPS URL.", 286 | args: { 287 | url: { 288 | type: "string", 289 | desc: "The URL to download the file from.", 290 | required: true, 291 | }, 292 | filename: { 293 | type: "string", 294 | desc: "The filename to save the file as.", 295 | required: true, 296 | }, 297 | }, 298 | retvals: { 299 | result: { 300 | type: "string", 301 | desc: "The result of the download.", 302 | required: true, 303 | }, 304 | }, 305 | }, 306 | { 307 | fn: (args: Dict) => { 308 | return new Promise((resolve, reject) => { 309 | const file = fs.createWriteStream(args.filename); 310 | 311 | const request = https.get(args.url, { 312 | headers: this.headers, 313 | }); 314 | 315 | request.on("error", reject); 316 | 317 | request.on("response", (response) => { 318 | if (response.statusCode !== 200) { 319 | reject( 320 | new Error(`Failed to download file: ${response.statusCode}`), 321 | ); 322 | return; 323 | } 324 | 325 | response.pipe(file); 326 | 327 | file.on("finish", () => { 328 | file.close(); 329 | resolve({ result: "success" }); 330 | }); 331 | 332 | file.on("error", (err) => { 333 | fs.unlink(args.filename, () => reject(err)); 334 | }); 335 | }); 336 | }); 337 | }, 338 | explain_args: (args: Dict) => ({ 339 | summary: `Downloading the file from ${args.url} to ${args.filename}...`, 340 | }), 341 | explain_retvals: (args: Dict, retvals: Dict) => ({ 342 | summary: `The file ${args.filename} was downloaded successfully.`, 343 | }), 344 | }, 345 | ); 346 | } 347 | 348 | async unload(athena: Athena) { 349 | athena.off("private-event", this.boundAthenaPrivateEventHandler); 350 | athena.deregisterTool("http/fetch"); 351 | if (this.config.jina) { 352 | athena.deregisterTool("http/search"); 353 | } 354 | if (this.config.exa) { 355 | athena.deregisterTool("http/exa-search"); 356 | } 357 | if (this.config.tavily) { 358 | athena.deregisterTool("http/tavily-search"); 359 | } 360 | athena.deregisterTool("http/download-file"); 361 | } 362 | 363 | athenaPrivateEventHandler(name: string, args: Dict) { 364 | if (name === "webapp-ui/token-refreshed") { 365 | if (this.config.jina) { 366 | this.jina = new JinaSearch({ 367 | baseUrl: this.config.jina.base_url, 368 | apiKey: args.token, 369 | }); 370 | } 371 | if (this.config.exa) { 372 | this.exa = new ExaSearch({ 373 | baseUrl: this.config.exa.base_url, 374 | apiKey: this.config.exa.api_key || args.token, 375 | }); 376 | } 377 | if (this.config.tavily) { 378 | this.tavily = new TavilySearch({ 379 | baseUrl: this.config.tavily.base_url, 380 | apiKey: args.token, 381 | }); 382 | } 383 | } 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /src/plugins/http/jina.ts: -------------------------------------------------------------------------------- 1 | export interface IJinaSearchInitParams { 2 | baseUrl?: string; 3 | apiKey: string; 4 | } 5 | 6 | export interface IJinaSearchResult { 7 | title: string; 8 | url: string; 9 | desc: string; 10 | } 11 | 12 | export class JinaSearch { 13 | baseUrl: string; 14 | apiKey: string; 15 | 16 | constructor(initParams: IJinaSearchInitParams) { 17 | this.baseUrl = initParams.baseUrl || "https://s.jina.ai"; 18 | this.apiKey = initParams.apiKey; 19 | } 20 | 21 | async search(query: string): Promise { 22 | const response = await fetch(`${this.baseUrl}/`, { 23 | method: "POST", 24 | headers: { 25 | Accept: "application/json", 26 | Authorization: `Bearer ${this.apiKey}`, 27 | "X-Respond-With": "no-content", 28 | "Content-Type": "application/json", 29 | }, 30 | body: JSON.stringify({ q: query, num: 20 }), 31 | }); 32 | if (!response.ok) { 33 | throw new Error("Failed to search"); 34 | } 35 | 36 | const json = await response.json(); 37 | return json.data.map((result: any) => ({ 38 | title: result.title, 39 | url: result.url, 40 | desc: result.description, 41 | })); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/plugins/http/tavily.ts: -------------------------------------------------------------------------------- 1 | export interface ITavilySearchInitParams { 2 | baseUrl?: string; 3 | apiKey: string; 4 | } 5 | 6 | export interface ITavilySearchResult { 7 | title: string; 8 | url: string; 9 | content: string; // Text content from the search result 10 | } 11 | 12 | export class TavilySearch { 13 | baseUrl: string; 14 | apiKey: string; 15 | 16 | constructor(initParams: ITavilySearchInitParams) { 17 | this.baseUrl = initParams.baseUrl || "https://api.tavily.com"; 18 | this.apiKey = initParams.apiKey; 19 | } 20 | 21 | async search(query: string): Promise { 22 | const response = await fetch(`${this.baseUrl}/search`, { 23 | method: "POST", 24 | headers: { 25 | "Content-Type": "application/json", 26 | Authorization: `Bearer ${this.apiKey}`, 27 | }, 28 | body: JSON.stringify({ 29 | query: query, 30 | max_results: 10, // Request 10 results, adjust as needed 31 | }), 32 | }); 33 | 34 | if (!response.ok) { 35 | const errorBody = await response.text(); 36 | console.error("Tavily API Error:", response.status, errorBody); 37 | throw new Error( 38 | `Failed to search with Tavily API: ${response.statusText}`, 39 | ); 40 | } 41 | 42 | const json = await response.json(); 43 | 44 | // Map the response structure to ITavilySearchResult 45 | const formattedResults = json.results.map((result: any) => ({ 46 | title: result.title || "No Title", 47 | url: result.url, 48 | content: result.content || "No content", 49 | })); 50 | 51 | return formattedResults; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/plugins/llm/init.ts: -------------------------------------------------------------------------------- 1 | import image2uri from "image2uri"; 2 | import OpenAI from "openai"; 3 | import { ChatCompletionContentPart } from "openai/resources/index.js"; 4 | 5 | import { Athena, Dict } from "../../core/athena.js"; 6 | import { PluginBase } from "../plugin-base.js"; 7 | import { openaiDefaultHeaders } from "../../utils/constants.js"; 8 | 9 | export default class Llm extends PluginBase { 10 | openai!: OpenAI; 11 | boundAthenaPrivateEventHandler!: (name: string, args: Dict) => void; 12 | 13 | async load(athena: Athena) { 14 | this.openai = new OpenAI({ 15 | baseURL: this.config.base_url, 16 | apiKey: this.config.api_key, 17 | defaultHeaders: openaiDefaultHeaders, 18 | }); 19 | this.boundAthenaPrivateEventHandler = 20 | this.athenaPrivateEventHandler.bind(this); 21 | athena.on("private-event", this.boundAthenaPrivateEventHandler); 22 | athena.emitPrivateEvent("webapp-ui/request-token", {}); 23 | athena.registerTool( 24 | { 25 | name: "llm/chat", 26 | desc: "Chat with the LLM. Only chat with an LLM if absolutely necessary. For easy tasks such as translation or summarization, do not use this tool.", 27 | args: { 28 | message: { 29 | type: "string", 30 | desc: "The message to send to the LLM. The LLM doesn't have access to your context, so you need to include all necessary information in the message. Don't use any placeholders.", 31 | required: true, 32 | }, 33 | image: { 34 | type: "string", 35 | desc: "The image to send to the LLM, if you need to. You can only send images to models that support them. Don't send the image in the message. Supports both URL and local image.", 36 | required: false, 37 | }, 38 | model: { 39 | type: "string", 40 | desc: `The model to use. Available models: ${JSON.stringify( 41 | this.config.models.chat, 42 | )}`, 43 | required: true, 44 | }, 45 | temperature: { 46 | type: "number", 47 | desc: "The temperature to use. 0 is the most deterministic, 1 is the most random.", 48 | required: false, 49 | }, 50 | }, 51 | retvals: { 52 | result: { 53 | type: "string", 54 | desc: "The result of the LLM.", 55 | required: true, 56 | }, 57 | citations: { 58 | type: "array", 59 | desc: "The citations of the LLM.", 60 | required: false, 61 | of: { 62 | type: "string", 63 | desc: "The citation of the LLM.", 64 | required: true, 65 | }, 66 | }, 67 | }, 68 | }, 69 | { 70 | fn: async (args: Dict) => { 71 | let image; 72 | if (args.image) { 73 | if (args.image.startsWith("http")) { 74 | image = args.image; 75 | } else { 76 | image = await image2uri(args.image); 77 | } 78 | } 79 | const response = await this.openai.chat.completions.create({ 80 | messages: [ 81 | { 82 | role: "user", 83 | content: [ 84 | { 85 | type: "text", 86 | text: args.message, 87 | }, 88 | ...(image 89 | ? [ 90 | { 91 | type: "image_url", 92 | image_url: { 93 | url: image, 94 | }, 95 | }, 96 | ] 97 | : []), 98 | ] as ChatCompletionContentPart[], 99 | }, 100 | ], 101 | model: args.model, 102 | temperature: args.temperature, 103 | }); 104 | return { 105 | result: response.choices[0].message.content!, 106 | citations: (response as Dict).citations, 107 | }; 108 | }, 109 | explain_args: (args: Dict) => ({ 110 | summary: `Chatting with ${args.model}...`, 111 | details: args.message, 112 | }), 113 | explain_retvals: (args: Dict, retvals: Dict) => ({ 114 | summary: `${args.model} responded.`, 115 | details: retvals.result, 116 | }), 117 | }, 118 | ); 119 | athena.registerTool( 120 | { 121 | name: "llm/generate-image", 122 | desc: "Generate an image with an image generation model.", 123 | args: { 124 | prompt: { 125 | type: "string", 126 | desc: "The prompt to use for the image generation.", 127 | required: true, 128 | }, 129 | model: { 130 | type: "string", 131 | desc: `The model to use. Available models: ${JSON.stringify( 132 | this.config.models.image, 133 | )}`, 134 | required: true, 135 | }, 136 | }, 137 | retvals: { 138 | urls: { 139 | type: "array", 140 | desc: "The URLs of the generated images.", 141 | required: true, 142 | of: { 143 | type: "string", 144 | desc: "The URL of the generated image.", 145 | required: true, 146 | }, 147 | }, 148 | }, 149 | }, 150 | { 151 | fn: async (args) => { 152 | const response = await this.openai.images.generate({ 153 | prompt: args.prompt, 154 | model: args.model, 155 | }); 156 | return { 157 | urls: response.data.map((image) => image.url!), 158 | }; 159 | }, 160 | explain_args: (args: Dict) => ({ 161 | summary: `Generating an image with ${args.model}...`, 162 | details: args.prompt, 163 | }), 164 | explain_retvals: (args: Dict, retvals: Dict) => ({ 165 | summary: `The image is generated by ${args.model}.`, 166 | details: retvals.urls.join(", "), 167 | }), 168 | }, 169 | ); 170 | } 171 | 172 | async unload(athena: Athena) { 173 | athena.off("private-event", this.boundAthenaPrivateEventHandler); 174 | athena.deregisterTool("llm/chat"); 175 | athena.deregisterTool("llm/generate-image"); 176 | } 177 | 178 | athenaPrivateEventHandler(name: string, args: Dict) { 179 | if (name === "webapp-ui/token-refreshed") { 180 | this.openai = new OpenAI({ 181 | baseURL: this.config.base_url, 182 | apiKey: args.token, 183 | defaultHeaders: openaiDefaultHeaders, 184 | }); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/plugins/long-term-memory/init.ts: -------------------------------------------------------------------------------- 1 | import { Athena, Dict } from "../../core/athena.js"; 2 | import { PluginBase } from "../plugin-base.js"; 3 | import { load } from "sqlite-vec"; 4 | import { DatabaseSync } from "node:sqlite"; 5 | import OpenAI from "openai"; 6 | import { openaiDefaultHeaders } from "../../utils/constants.js"; 7 | interface ILongTermMemoryItem { 8 | desc: string; 9 | data: Dict; 10 | created_at: string; 11 | } 12 | 13 | export default class LongTermMemory extends PluginBase { 14 | store: Dict = {}; 15 | openai!: OpenAI; 16 | db!: DatabaseSync; 17 | desc() { 18 | return "You have a long-term memory. You must put whatever you think a human would remember long-term in here. This could be knowledge, experiences, or anything else you think is important. It's a key-value store. The key is a string, and the value is a JSON object. You will override the value if you store the same key again. If you want to recall something, you should list and/or retrieve it."; 19 | } 20 | 21 | async load(athena: Athena) { 22 | this.db = new DatabaseSync( 23 | this.config.persist_db ? this.config.db_file : ":memory:", 24 | { 25 | allowExtension: true, 26 | }, 27 | ); 28 | load(this.db); 29 | 30 | // TODO: Support migration for varying dimensions 31 | this.db.exec(` 32 | CREATE VIRTUAL TABLE IF NOT EXISTS vec_items USING 33 | vec0( 34 | embedding float[${this.config.dimensions}], 35 | desc text, 36 | data text 37 | ) 38 | `); 39 | 40 | const insertStmt = this.db.prepare( 41 | "INSERT INTO vec_items(embedding, desc, data) VALUES (?, ?, ?)", 42 | ); 43 | 44 | this.openai = new OpenAI({ 45 | baseURL: this.config.base_url, 46 | apiKey: this.config.api_key, 47 | defaultHeaders: openaiDefaultHeaders, 48 | }); 49 | 50 | athena.registerTool( 51 | { 52 | name: "ltm/store", 53 | desc: "Store some data to your long-term memory.", 54 | args: { 55 | desc: { 56 | type: "string", 57 | desc: "A description of the data.", 58 | required: true, 59 | }, 60 | data: { 61 | type: "object", 62 | desc: "The data to store.", 63 | required: true, 64 | }, 65 | }, 66 | retvals: { 67 | status: { 68 | type: "string", 69 | desc: "The status of the operation.", 70 | required: true, 71 | }, 72 | }, 73 | }, 74 | { 75 | fn: async (args: Dict) => { 76 | const embedding = await this.openai.embeddings.create({ 77 | model: this.config.vector_model, 78 | dimensions: this.config.dimensions, 79 | input: args.desc, 80 | encoding_format: "float", 81 | }); 82 | insertStmt.run( 83 | Float32Array.from(embedding.data[0].embedding), 84 | args.desc, 85 | JSON.stringify(args.data), 86 | ); 87 | return { status: "success" }; 88 | }, 89 | }, 90 | ); 91 | // TODO: Implement remove 92 | athena.registerTool( 93 | { 94 | name: "ltm/list", 95 | desc: "List your long-term memory.", 96 | args: {}, 97 | retvals: { 98 | list: { 99 | type: "array", 100 | desc: "The list of metadata of the long-term memory.", 101 | required: true, 102 | of: { 103 | type: "object", 104 | desc: "The metadata of the long-term memory.", 105 | required: false, 106 | of: { 107 | desc: { 108 | type: "string", 109 | desc: "The description of the data.", 110 | required: true, 111 | }, 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | { 118 | fn: async (args: Dict) => { 119 | const list = this.db 120 | .prepare("SELECT desc, data FROM vec_items") 121 | .all(); 122 | return { 123 | list: list.map((item) => ({ 124 | desc: String(item.desc), 125 | data: JSON.parse(String(item.data)), 126 | })), 127 | }; 128 | }, 129 | }, 130 | ); 131 | athena.registerTool( 132 | { 133 | name: "ltm/retrieve", 134 | desc: "Retrieve data from your long-term memory.", 135 | args: { 136 | query: { 137 | type: "string", 138 | desc: "The query to retrieve the data.", 139 | required: true, 140 | }, 141 | }, 142 | retvals: { 143 | list: { 144 | type: "array", 145 | desc: "Query results list of metadata of the long-term memory.", 146 | required: true, 147 | of: { 148 | type: "object", 149 | desc: "The desc and data of the long-term memory.", 150 | required: false, 151 | of: { 152 | desc: { 153 | type: "string", 154 | desc: "The description of the data.", 155 | required: true, 156 | }, 157 | data: { 158 | type: "object", 159 | desc: "The data.", 160 | required: true, 161 | }, 162 | }, 163 | }, 164 | }, 165 | }, 166 | }, 167 | { 168 | fn: async (args) => { 169 | const embedding = await this.openai.embeddings.create({ 170 | model: this.config.vector_model, 171 | dimensions: this.config.dimensions, 172 | input: args.query, 173 | encoding_format: "float", 174 | }); 175 | const results = this.db 176 | .prepare( 177 | `SELECT 178 | distance, 179 | desc, 180 | data 181 | FROM vec_items 182 | WHERE embedding MATCH ? 183 | ORDER BY distance 184 | LIMIT ${this.config.max_query_results}`, 185 | ) 186 | .all(Float32Array.from(embedding.data[0].embedding)); 187 | if (!results || results.length === 0) { 188 | throw new Error("No results found"); 189 | } 190 | return { 191 | list: results.map((result) => { 192 | if (!result || typeof result !== "object") { 193 | throw new Error("Invalid result format"); 194 | } 195 | return { 196 | desc: String(result.desc), 197 | data: JSON.parse(String(result.data)), 198 | }; 199 | }), 200 | }; 201 | }, 202 | }, 203 | ); 204 | } 205 | 206 | async unload(athena: Athena) { 207 | athena.deregisterTool("ltm/store"); 208 | athena.deregisterTool("ltm/list"); 209 | athena.deregisterTool("ltm/retrieve"); 210 | } 211 | 212 | state() { 213 | return { store: this.store }; 214 | } 215 | 216 | setState(state: Dict) { 217 | this.store = state.store; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/plugins/plugin-base.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "winston"; 2 | 3 | import { Athena, Dict } from "../core/athena.js"; 4 | 5 | export abstract class PluginBase { 6 | config: Dict; 7 | logger!: Logger; 8 | 9 | constructor(config: Dict) { 10 | this.config = config; 11 | } 12 | 13 | desc(): string | null { 14 | return null; 15 | } 16 | 17 | async load(athena: Athena): Promise {} 18 | 19 | async unload(athena: Athena): Promise {} 20 | 21 | state(): Dict | null { 22 | return null; 23 | } 24 | 25 | setState(state: Dict): void {} 26 | } 27 | -------------------------------------------------------------------------------- /src/plugins/python/init.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | import { PythonShell } from "python-shell"; 4 | 5 | import { Athena, Dict } from "../../core/athena.js"; 6 | import { PluginBase } from "../plugin-base.js"; 7 | 8 | export default class Python extends PluginBase { 9 | async load(athena: Athena) { 10 | athena.registerTool( 11 | { 12 | name: "python/exec", 13 | desc: "Executes Python code. Whenever you need to run Python code or do *any* kind of math calculations, or the user's request requires running Python code or doing math calculations, use this tool. You must print the final result to get it. Otherwise the stdout will be empty. Only use Python when it's necessary. If you already have all the information you need for some simple task, don't use Python. Whenever you need to get stock data, use Python with the yfinance package. If you need to plot something, use Python with the matplotlib package; use seaborn-v0_8-darkgrid or fivethirtyeight style and configurations of your choice (like font size, DPI=300, etc.) to get a high quality plot.", 14 | args: { 15 | code: { 16 | type: "string", 17 | desc: "Python code", 18 | required: true, 19 | }, 20 | }, 21 | retvals: { 22 | stdout: { 23 | type: "string", 24 | desc: "Standard output of the code", 25 | required: true, 26 | }, 27 | }, 28 | }, 29 | { 30 | fn: async (args: Dict) => { 31 | return { 32 | stdout: (await PythonShell.runString(args.code)).join("\n"), 33 | }; 34 | }, 35 | explain_args: (args: Dict) => ({ 36 | summary: `Executing Python code...`, 37 | details: args.code, 38 | }), 39 | explain_retvals: (args: Dict, retvals: Dict) => ({ 40 | summary: `The Python code has finished.`, 41 | details: retvals.stdout, 42 | }), 43 | }, 44 | ); 45 | athena.registerTool( 46 | { 47 | name: "python/exec-file", 48 | desc: "Executes Python code from a file. Whenever you need to run a Python file, or the user's request requires running Python file, use this tool.", 49 | args: { 50 | path: { 51 | type: "string", 52 | desc: "Path to the Python file", 53 | required: true, 54 | }, 55 | args: { 56 | type: "array", 57 | desc: "Arguments to pass to the Python file", 58 | required: false, 59 | of: { 60 | type: "string", 61 | desc: "Argument to pass", 62 | required: true, 63 | }, 64 | }, 65 | }, 66 | retvals: { 67 | stdout: { 68 | desc: "Standard output of the code", 69 | required: true, 70 | type: "string", 71 | }, 72 | }, 73 | }, 74 | { 75 | fn: async (args: Dict) => { 76 | return { 77 | stdout: ( 78 | await PythonShell.run(args.path, { args: args.args }) 79 | ).join("\n"), 80 | }; 81 | }, 82 | explain_args: (args: Dict) => ({ 83 | summary: `Executing Python file ${args.path}...`, 84 | details: args.args ? args.args.join(", ") : "", 85 | }), 86 | explain_retvals: (args: Dict, retvals: Dict) => ({ 87 | summary: `The Python file has finished.`, 88 | details: retvals.stdout, 89 | }), 90 | }, 91 | ); 92 | athena.registerTool( 93 | { 94 | name: "python/pip-install", 95 | desc: "Installs a Python package using pip. Whenever you need to use a package that is not installed, or the user's request requires installing a package, use this tool. Don't use shell to install packages.", 96 | args: { 97 | package: { 98 | type: "string", 99 | desc: "Package name", 100 | required: true, 101 | }, 102 | }, 103 | retvals: { 104 | result: { 105 | desc: "Result of the installation", 106 | required: true, 107 | type: "string", 108 | }, 109 | }, 110 | }, 111 | { 112 | fn: (args: Dict) => { 113 | return new Promise((resolve, reject) => { 114 | exec( 115 | `python -m pip install ${args.package} --break-system-packages --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple`, 116 | (error, stdout, stderr) => { 117 | if (error) { 118 | reject(Error(stderr)); 119 | } else { 120 | resolve({ result: "success" }); 121 | } 122 | }, 123 | ); 124 | }); 125 | }, 126 | explain_args: (args: Dict) => ({ 127 | summary: `Installing Python package ${args.package}...`, 128 | }), 129 | explain_retvals: (args: Dict, retvals: Dict) => ({ 130 | summary: `The Python package ${args.package} is installed.`, 131 | }), 132 | }, 133 | ); 134 | } 135 | 136 | async unload(athena: Athena) { 137 | athena.deregisterTool("python/exec"); 138 | athena.deregisterTool("python/exec-file"); 139 | athena.deregisterTool("python/pip-install"); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/plugins/shell/init.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | import { Athena, Dict } from "../../core/athena.js"; 4 | import { PluginBase } from "../plugin-base.js"; 5 | import { ShellProcess } from "./shell-process.js"; 6 | 7 | export default class Shell extends PluginBase { 8 | processes: { [key: number]: ShellProcess } = {}; 9 | 10 | async load(athena: Athena) { 11 | athena.registerEvent({ 12 | name: "shell/stdout", 13 | desc: "Triggered when a shell command outputs to stdout", 14 | args: { 15 | pid: { 16 | type: "number", 17 | desc: "Process ID", 18 | required: true, 19 | }, 20 | stdout: { 21 | type: "string", 22 | desc: "Standard output of the command", 23 | required: true, 24 | }, 25 | }, 26 | explain_args: (args: Dict) => ({ 27 | summary: `Process ${args.pid} output to stdout.`, 28 | details: args.stdout, 29 | }), 30 | }); 31 | athena.registerEvent({ 32 | name: "shell/terminated", 33 | desc: "Triggered when a shell command terminates", 34 | args: { 35 | pid: { 36 | type: "number", 37 | desc: "Process ID", 38 | required: true, 39 | }, 40 | code: { 41 | type: "number", 42 | desc: "Exit code of the process", 43 | required: true, 44 | }, 45 | stdout: { 46 | type: "string", 47 | desc: "The remaining output of the process", 48 | required: true, 49 | }, 50 | }, 51 | explain_args: (args: Dict) => ({ 52 | summary: `Process ${args.pid} terminated.`, 53 | details: args.stdout, 54 | }), 55 | }); 56 | athena.registerTool( 57 | { 58 | name: "shell/exec", 59 | desc: "Executes a shell command. Whenever you need to run a shell command, or the user's request requires running a shell command, use this tool. When this tool returns, the command is still running. You need to wait for it to output or terminate.", 60 | args: { 61 | command: { 62 | type: "string", 63 | desc: "Shell command", 64 | required: true, 65 | }, 66 | }, 67 | retvals: { 68 | pid: { 69 | type: "number", 70 | desc: "The running process ID", 71 | required: true, 72 | }, 73 | }, 74 | }, 75 | { 76 | fn: async (args: Dict) => { 77 | const process = new ShellProcess(args.command); 78 | process.on("stdout", (data) => { 79 | athena.emitEvent("shell/stdout", { 80 | pid: process.pid, 81 | stdout: data, 82 | }); 83 | }); 84 | process.on("close", (code) => { 85 | delete this.processes[process.pid]; 86 | athena.emitEvent("shell/terminated", { 87 | pid: process.pid, 88 | code, 89 | stdout: process.stdout, 90 | }); 91 | }); 92 | this.processes[process.pid] = process; 93 | return { pid: process.pid }; 94 | }, 95 | explain_args: (args: Dict) => ({ 96 | summary: `Executing shell command ${args.command}...`, 97 | }), 98 | explain_retvals: (args: Dict, retvals: Dict) => ({ 99 | summary: `The shell command is assigned PID ${retvals.pid}.`, 100 | }), 101 | }, 102 | ); 103 | athena.registerTool( 104 | { 105 | name: "shell/write-stdin", 106 | desc: "Write string to stdin of the specified process.", 107 | args: { 108 | pid: { 109 | type: "number", 110 | desc: "Process ID", 111 | required: true, 112 | }, 113 | data: { 114 | type: "string", 115 | desc: "The data to write to stdin.", 116 | required: true, 117 | }, 118 | }, 119 | retvals: { 120 | result: { 121 | type: "string", 122 | desc: "Result of the operation", 123 | required: true, 124 | }, 125 | }, 126 | }, 127 | { 128 | fn: async (args: Dict) => { 129 | const process = this.processes[args.pid]; 130 | if (!process) { 131 | throw new Error("Process not found"); 132 | } 133 | process.write(args.data); 134 | return { result: "success" }; 135 | }, 136 | explain_args: (args: Dict) => ({ 137 | summary: `Writing to process ${args.pid}...`, 138 | details: args.data, 139 | }), 140 | }, 141 | ); 142 | athena.registerTool( 143 | { 144 | name: "shell/kill", 145 | desc: "Kills a running shell command. Whenever you need to kill a running shell command, or the user's request requires killing a running shell command, use this tool. Do not use this tool to terminate any other processes. Use shell/exec to execute a kill command instead.", 146 | args: { 147 | pid: { 148 | type: "number", 149 | desc: "Process ID", 150 | required: true, 151 | }, 152 | signal: { 153 | type: "string", 154 | desc: "Signal to send to the process", 155 | required: false, 156 | }, 157 | }, 158 | retvals: { 159 | result: { 160 | type: "string", 161 | desc: "Result of the operation", 162 | required: true, 163 | }, 164 | }, 165 | }, 166 | { 167 | fn: async (args: Dict) => { 168 | const process = this.processes[args.pid]; 169 | if (!process) { 170 | throw new Error("Process not found"); 171 | } 172 | process.kill(args.signal); 173 | return { result: "success" }; 174 | }, 175 | explain_args: (args: Dict) => ({ 176 | summary: `Killing process ${args.pid}...`, 177 | details: args.signal, 178 | }), 179 | explain_retvals: (args: Dict, retvals: Dict) => ({ 180 | summary: `The process ${args.pid} is killed.`, 181 | }), 182 | }, 183 | ); 184 | athena.registerTool( 185 | { 186 | name: "shell/apt-install", 187 | desc: "Installs a package using apt. Whenever you need to install a package using apt, or the user's request requires installing a package using apt, use this tool.", 188 | args: { 189 | package: { 190 | type: "string", 191 | desc: "Package name", 192 | required: true, 193 | }, 194 | }, 195 | retvals: { 196 | result: { 197 | desc: "Result of the installation", 198 | required: true, 199 | type: "string", 200 | }, 201 | }, 202 | }, 203 | { 204 | fn: (args: Dict) => { 205 | return new Promise((resolve, reject) => { 206 | exec(`apt install ${args.package} -y`, (error, stdout, stderr) => { 207 | if (error) { 208 | reject(Error(stderr)); 209 | } else { 210 | resolve({ result: "success" }); 211 | } 212 | }); 213 | }); 214 | }, 215 | explain_args: (args: Dict) => ({ 216 | summary: `Installing package ${args.package} using apt...`, 217 | }), 218 | explain_retvals: (args: Dict, retvals: Dict) => ({ 219 | summary: `The package ${args.package} is installed.`, 220 | }), 221 | }, 222 | ); 223 | } 224 | 225 | async unload(athena: Athena) { 226 | athena.deregisterTool("shell/exec"); 227 | athena.deregisterTool("shell/write-stdin"); 228 | athena.deregisterTool("shell/kill"); 229 | athena.deregisterTool("shell/apt-install"); 230 | athena.deregisterEvent("shell/stdout"); 231 | athena.deregisterEvent("shell/terminated"); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/plugins/shell/shell-process.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from "child_process"; 2 | import { EventEmitter } from "events"; 3 | 4 | export class ShellProcess extends EventEmitter { 5 | child: ChildProcess; 6 | stdout: string = ""; 7 | stdoutTimeout?: NodeJS.Timeout; 8 | boundStdoutHandler: (data: string) => void = this.stdoutHandler.bind(this); 9 | 10 | constructor(command: string) { 11 | super(); 12 | this.child = spawn(command, { shell: true }); 13 | this.child.stdout?.on("data", this.boundStdoutHandler); 14 | this.child.stderr?.on("data", this.boundStdoutHandler); 15 | this.child.once("close", (code) => { 16 | if (this.stdoutTimeout) { 17 | clearTimeout(this.stdoutTimeout); 18 | } 19 | this.child.stdout?.off("data", this.boundStdoutHandler); 20 | this.child.stderr?.off("data", this.boundStdoutHandler); 21 | this.kill(); 22 | this.emit("close", code); 23 | }); 24 | } 25 | 26 | get pid(): number { 27 | return this.child.pid!; 28 | } 29 | 30 | write(data: string) { 31 | this.child.stdin?.write(data); 32 | } 33 | 34 | kill(signal?: NodeJS.Signals) { 35 | this.child.kill(signal); 36 | } 37 | 38 | stdoutHandler(data: string) { 39 | this.stdout += data; 40 | if (this.stdoutTimeout) { 41 | clearTimeout(this.stdoutTimeout); 42 | } 43 | this.stdoutTimeout = setTimeout(() => { 44 | this.emit("stdout", this.stdout); 45 | this.stdout = ""; 46 | }, 2000); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/plugins/short-term-memory/init.ts: -------------------------------------------------------------------------------- 1 | import { Athena, Dict } from "../../core/athena.js"; 2 | import { PluginBase } from "../plugin-base.js"; 3 | 4 | interface ITask { 5 | content: string; 6 | finished: boolean; 7 | } 8 | 9 | export default class ShortTermMemory extends PluginBase { 10 | tasks: ITask[] = []; 11 | 12 | desc() { 13 | return `You have a short-term memory. You must use it to keep track of your tasks while you are working on them. When you receive a task from the user and it requires multiple steps to complete, you must think thoroughly about the steps and break them down into smaller tasks. Try to be as detailed as possible and include all necessary information and append these tasks to your short-term memory. Afterwards, you must follow the task list and work on your unfinished tasks, unless the user asks you to do something else. After you have completed a task or multiple tasks at once, you must mark them as finished. After you have finished all tasks, you must clear your short-term memory. Your current short-term memory is: ${JSON.stringify( 14 | this.tasks, 15 | )}. If the results of a task to show to the user is textual and long, you should create a Markdown file and append to it gradually as you complete the task. At the end, you should prepare your response according to this file.`; 16 | } 17 | 18 | async load(athena: Athena) { 19 | athena.registerTool( 20 | { 21 | name: "stm/append-tasks", 22 | desc: "Append an array of tasks to the short-term memory.", 23 | args: { 24 | tasks: { 25 | type: "array", 26 | desc: "The array of tasks to append.", 27 | required: true, 28 | of: { 29 | type: "string", 30 | desc: "The content of the task.", 31 | required: true, 32 | }, 33 | }, 34 | }, 35 | retvals: { 36 | status: { 37 | type: "string", 38 | desc: "The status of the operation.", 39 | required: true, 40 | }, 41 | }, 42 | }, 43 | { 44 | fn: async (args: Dict) => { 45 | this.tasks.push( 46 | ...args.tasks.map((task: string) => ({ 47 | content: task, 48 | finished: false, 49 | })), 50 | ); 51 | return { status: "success" }; 52 | }, 53 | }, 54 | ); 55 | athena.registerTool( 56 | { 57 | name: "stm/mark-task-finished", 58 | desc: "Mark tasks as finished.", 59 | args: { 60 | indices: { 61 | type: "array", 62 | desc: "The indices of the tasks to mark as finished.", 63 | required: true, 64 | of: { 65 | type: "number", 66 | desc: "The index of the task to mark as finished.", 67 | required: true, 68 | }, 69 | }, 70 | }, 71 | retvals: { 72 | status: { 73 | type: "string", 74 | desc: "The status of the operation.", 75 | required: true, 76 | }, 77 | }, 78 | }, 79 | { 80 | fn: async (args: Dict) => { 81 | args.indices.forEach((index: number) => { 82 | this.tasks[index].finished = true; 83 | }); 84 | return { status: "success" }; 85 | }, 86 | }, 87 | ); 88 | athena.registerTool( 89 | { 90 | name: "stm/clear-tasks", 91 | desc: "Clear all tasks.", 92 | args: {}, 93 | retvals: { 94 | status: { 95 | type: "string", 96 | desc: "The status of the operation.", 97 | required: true, 98 | }, 99 | }, 100 | }, 101 | { 102 | fn: async () => { 103 | this.tasks = []; 104 | return { status: "success" }; 105 | }, 106 | }, 107 | ); 108 | } 109 | 110 | async unload(athena: Athena) { 111 | athena.deregisterTool("stm/append-tasks"); 112 | athena.deregisterTool("stm/mark-task-finished"); 113 | athena.deregisterTool("stm/clear-tasks"); 114 | } 115 | 116 | state() { 117 | return { tasks: this.tasks }; 118 | } 119 | 120 | setState(state: Dict) { 121 | this.tasks = state.tasks; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/plugins/webapp-ui/init.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import mime from "mime-types"; 4 | import { createClient, SupabaseClient } from "@supabase/supabase-js"; 5 | import { WebSocket, WebSocketServer } from "ws"; 6 | 7 | import { Athena, Dict } from "../../core/athena.js"; 8 | import WebappUITransport from "./logger.js"; 9 | import { IWebappUIMessage } from "./message.js"; 10 | import { PluginBase } from "../plugin-base.js"; 11 | import { fileDigest } from "../../utils/crypto.js"; 12 | import logger from "../../utils/logger.js"; 13 | 14 | export default class WebappUI extends PluginBase { 15 | athena!: Athena; 16 | supabase!: SupabaseClient; 17 | userId: string = ""; 18 | accessToken: string = ""; 19 | wss?: WebSocketServer; 20 | connections: WebSocket[] = []; 21 | shutdownTimeout?: NodeJS.Timeout; 22 | logTransport!: WebappUITransport; 23 | boundAthenaPrivateEventHandler!: (event: string, args: Dict) => void; 24 | 25 | desc() { 26 | return "You can interact with the user using UI tools and events. When the user asks you to do something, think about what information and/or details you need to do that. If you need something only the user can provide, you need to ask the user for that information. Ask the users about task details if the request is vague. Be proactive and update the user on your progress, milestones, and obstacles and how you are going to overcome them."; 27 | } 28 | 29 | async load(athena: Athena) { 30 | this.athena = athena; 31 | this.supabase = createClient( 32 | this.config.supabase.url, 33 | this.config.supabase.anon_key, 34 | ); 35 | this.supabase.auth.onAuthStateChange((event, session) => { 36 | if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") { 37 | this.userId = session?.user?.id ?? ""; 38 | this.accessToken = session?.access_token ?? ""; 39 | this.logger.info(`Token refreshed: ${this.accessToken}`); 40 | this.athena.emitPrivateEvent("webapp-ui/token-refreshed", { 41 | token: this.accessToken, 42 | }); 43 | } 44 | }); 45 | const { error } = await this.supabase.auth.verifyOtp({ 46 | email: this.config.supabase.email, 47 | token: this.config.supabase.otp, 48 | type: "email", 49 | }); 50 | if (error) { 51 | this.logger.error(error); 52 | throw new Error("Failed to verify OTP"); 53 | } 54 | this.logTransport = new WebappUITransport( 55 | this.supabase, 56 | this.config.context_id, 57 | ); 58 | logger.add(this.logTransport); 59 | const { data, error: error2 } = await this.supabase 60 | .from("contexts") 61 | .select("states") 62 | .eq("id", this.config.context_id); 63 | if (error2) { 64 | this.logger.error(`Failed to get context states: ${error2.message}`); 65 | } else if (data && data[0].states !== null) { 66 | this.athena.states = data[0].states; 67 | this.logger.info( 68 | `Loaded context states: ${JSON.stringify(data[0].states)}`, 69 | ); 70 | } 71 | this.boundAthenaPrivateEventHandler = 72 | this.athenaPrivateEventHandler.bind(this); 73 | athena.on("private-event", this.boundAthenaPrivateEventHandler); 74 | this.enableShutdownTimeout(); 75 | athena.registerEvent({ 76 | name: "ui/message-received", 77 | desc: "Triggered when a message is received from the user.", 78 | args: { 79 | content: { 80 | type: "string", 81 | desc: "The message received from the user.", 82 | required: true, 83 | }, 84 | files: { 85 | type: "array", 86 | desc: "Files received from the user.", 87 | required: false, 88 | of: { 89 | type: "object", 90 | desc: "A file received from the user.", 91 | required: true, 92 | of: { 93 | name: { 94 | type: "string", 95 | desc: "The name of the file.", 96 | required: true, 97 | }, 98 | location: { 99 | type: "string", 100 | desc: "The location of the file.", 101 | required: true, 102 | }, 103 | }, 104 | }, 105 | }, 106 | time: { 107 | type: "string", 108 | desc: "The time the message was sent.", 109 | required: true, 110 | }, 111 | }, 112 | }); 113 | athena.registerTool( 114 | { 115 | name: "ui/send-message", 116 | desc: "Sends a message to the user.", 117 | args: { 118 | content: { 119 | type: "string", 120 | desc: "The message to send to the user. This should be a valid Markdown message.", 121 | required: true, 122 | }, 123 | files: { 124 | type: "array", 125 | desc: "Files to send to the user. Whenever the user requests a file or a download link to a file, you should use this argument to send the file to the user.", 126 | required: false, 127 | of: { 128 | type: "object", 129 | desc: "A file to send to the user.", 130 | required: true, 131 | of: { 132 | name: { 133 | type: "string", 134 | desc: "The name of the file.", 135 | required: true, 136 | }, 137 | location: { 138 | type: "string", 139 | desc: "The location of the file. Send URL or absolute path. Don't send relative paths.", 140 | required: true, 141 | }, 142 | }, 143 | }, 144 | }, 145 | }, 146 | retvals: { 147 | status: { 148 | type: "string", 149 | desc: "Status of the operation.", 150 | required: true, 151 | }, 152 | }, 153 | }, 154 | { 155 | fn: async (args: Dict) => { 156 | if (args.files) { 157 | for (const file of args.files) { 158 | if ( 159 | file.location.startsWith( 160 | "https://oaidalleapiprodscus.blob.core.windows.net", 161 | ) 162 | ) { 163 | // This is an image from DALL-E. Download it and upload it to Supabase to avoid expiration. 164 | const response = await fetch(file.location); 165 | const buffer = await response.arrayBuffer(); 166 | const tempPath = `./${Date.now()}-${file.name}`; 167 | await fs.promises.writeFile(tempPath, Buffer.from(buffer)); 168 | file.location = tempPath; 169 | } 170 | if (file.location.startsWith("http")) { 171 | continue; 172 | } 173 | const digest = await fileDigest(file.location); 174 | const storagePath = `${this.userId}/${digest.slice( 175 | 0, 176 | 2, 177 | )}/${digest.slice(2, 12)}/${encodeURIComponent(file.name).replace( 178 | /%/g, 179 | "_", 180 | )}`; 181 | const contentType = mime.lookup(file.location); 182 | const { error } = await this.supabase.storage 183 | .from(this.config.supabase.files_bucket) 184 | .upload(storagePath, fs.createReadStream(file.location), { 185 | upsert: true, 186 | contentType: contentType 187 | ? contentType 188 | : "application/octet-stream", 189 | duplex: "half", 190 | }); 191 | if (error) { 192 | throw new Error( 193 | `Error uploading file ${file.name}: ${error.message}`, 194 | ); 195 | } 196 | file.location = this.supabase.storage 197 | .from(this.config.supabase.files_bucket) 198 | .getPublicUrl(storagePath).data.publicUrl; 199 | } 200 | } 201 | const message: IWebappUIMessage = { 202 | type: "message", 203 | data: { 204 | role: "assistant", 205 | content: args.content, 206 | files: args.files, 207 | timestamp: Date.now(), 208 | }, 209 | }; 210 | this.supabase 211 | .from("messages") 212 | .insert({ 213 | context_id: this.config.context_id, 214 | message, 215 | }) 216 | .then(({ error }) => { 217 | if (error) { 218 | this.logger.error(error); 219 | } 220 | }); 221 | await this.sendMessage(message); 222 | return { status: "success" }; 223 | }, 224 | }, 225 | ); 226 | athena.once("plugins-loaded", async () => { 227 | this.wss = new WebSocketServer({ port: this.config.port }); 228 | this.wss.on("connection", (ws, req) => { 229 | this.disableShutdownTimeout(); 230 | const ipPort = `${req.socket.remoteAddress}:${req.socket.remotePort}`; 231 | this.logger.info(`Client connected: ${ipPort}`); 232 | this.connections.push(ws); 233 | ws.on("message", (message) => { 234 | try { 235 | this.handleMessage(ws, message.toString()); 236 | } catch (e) {} 237 | }); 238 | const pingInterval = setInterval(() => { 239 | this.sendMessage({ 240 | type: "ping", 241 | data: {}, 242 | }); 243 | }, 30000); 244 | ws.on("close", () => { 245 | this.logger.info(`Client disconnected: ${ipPort}`); 246 | clearInterval(pingInterval); 247 | const index = this.connections.indexOf(ws); 248 | if (index !== -1) { 249 | this.connections.splice(index, 1); 250 | } 251 | if (this.connections.length === 0) { 252 | this.enableShutdownTimeout(); 253 | } 254 | }); 255 | ws.on("error", (error) => { 256 | this.logger.error(`WebSocket error from ${ipPort}: ${error}`); 257 | clearInterval(pingInterval); 258 | ws.terminate(); 259 | const index = this.connections.indexOf(ws); 260 | if (index !== -1) { 261 | this.connections.splice(index, 1); 262 | } 263 | if (this.connections.length === 0) { 264 | this.enableShutdownTimeout(); 265 | } 266 | }); 267 | }); 268 | this.logger.info(`WebSocket server started on port ${this.config.port}`); 269 | }); 270 | } 271 | 272 | async unload(athena: Athena) { 273 | athena.off("private-event", this.boundAthenaPrivateEventHandler); 274 | for (const ws of this.connections) { 275 | ws.terminate(); 276 | } 277 | await new Promise((resolve) => { 278 | if (!this.wss) { 279 | resolve(); 280 | return; 281 | } 282 | this.wss.close(() => { 283 | resolve(); 284 | }); 285 | }); 286 | this.athena.gatherStates(); 287 | const { data, error } = await this.supabase 288 | .from("contexts") 289 | .update({ 290 | states: this.athena.states, 291 | }) 292 | .eq("id", this.config.context_id) 293 | .select(); 294 | if (error) { 295 | this.logger.error(`Failed to update context states: ${error.message}`); 296 | } else { 297 | this.logger.info(`Updated context states: ${JSON.stringify(data)}`); 298 | } 299 | logger.remove(this.logTransport); 300 | await this.supabase.auth.signOut({ 301 | scope: "local", 302 | }); 303 | this.disableShutdownTimeout(); 304 | athena.deregisterTool("ui/send-message"); 305 | athena.deregisterEvent("ui/message-received"); 306 | } 307 | 308 | enableShutdownTimeout() { 309 | this.disableShutdownTimeout(); 310 | this.logger.info("Enabling shutdown timeout"); 311 | this.shutdownTimeout = setTimeout(async () => { 312 | this.logger.warn("Timeout reached. Shutting down..."); 313 | await this.athena.unloadPlugins(); 314 | process.exit(0); 315 | }, this.config.shutdown_timeout * 1000); 316 | } 317 | 318 | disableShutdownTimeout() { 319 | if (this.shutdownTimeout) { 320 | this.logger.info("Disabling shutdown timeout"); 321 | clearTimeout(this.shutdownTimeout); 322 | this.shutdownTimeout = undefined; 323 | } 324 | } 325 | 326 | handleMessage(ws: WebSocket, message: string) { 327 | for (const connection of this.connections) { 328 | if (connection === ws) { 329 | continue; 330 | } 331 | connection.send(message); 332 | } 333 | const obj = JSON.parse(message) as IWebappUIMessage; 334 | if (obj.type === "message") { 335 | this.supabase 336 | .from("messages") 337 | .insert({ 338 | context_id: this.config.context_id, 339 | message: { 340 | type: "message", 341 | data: { 342 | role: "user", 343 | content: obj.data.content, 344 | files: obj.data.files, 345 | timestamp: Date.now(), 346 | }, 347 | }, 348 | }) 349 | .then(({ error }) => { 350 | if (error) { 351 | this.logger.error(error); 352 | } 353 | }); 354 | this.athena.emitEvent("ui/message-received", { 355 | content: obj.data.content, 356 | files: obj.data.files, 357 | time: new Date().toISOString(), 358 | }); 359 | } else if (obj.type === "ping") { 360 | this.sendMessage({ 361 | type: "pong", 362 | data: {}, 363 | }); 364 | } 365 | } 366 | 367 | async sendMessage(message: IWebappUIMessage) { 368 | const promises = []; 369 | for (const connection of this.connections) { 370 | promises.push( 371 | new Promise((resolve, reject) => { 372 | connection.send(JSON.stringify(message), (error) => { 373 | if (error) { 374 | reject(error); 375 | } else { 376 | resolve(); 377 | } 378 | }); 379 | }), 380 | ); 381 | } 382 | await Promise.all(promises); 383 | } 384 | 385 | athenaPrivateEventHandler(event: string, args: Dict) { 386 | if (event === "cerebrum/thinking") { 387 | const message: IWebappUIMessage = { 388 | type: "thinking", 389 | data: { 390 | content: args.content, 391 | timestamp: Date.now(), 392 | }, 393 | }; 394 | this.supabase 395 | .from("messages") 396 | .insert({ 397 | context_id: this.config.context_id, 398 | message, 399 | }) 400 | .then(({ error }) => { 401 | if (error) { 402 | this.logger.error(error); 403 | } 404 | }); 405 | this.sendMessage(message); 406 | } else if (event === "athena/tool-call") { 407 | const message: IWebappUIMessage = { 408 | type: "tool_call", 409 | data: { 410 | summary: args.summary, 411 | details: args.details, 412 | timestamp: Date.now(), 413 | }, 414 | }; 415 | this.supabase 416 | .from("messages") 417 | .insert({ 418 | context_id: this.config.context_id, 419 | message, 420 | }) 421 | .then(({ error }) => { 422 | if (error) { 423 | this.logger.error(error); 424 | } 425 | }); 426 | this.sendMessage(message); 427 | } else if (event === "athena/tool-result") { 428 | const message: IWebappUIMessage = { 429 | type: "tool_result", 430 | data: { 431 | summary: args.summary, 432 | details: args.details, 433 | timestamp: Date.now(), 434 | }, 435 | }; 436 | this.supabase 437 | .from("messages") 438 | .insert({ 439 | context_id: this.config.context_id, 440 | message, 441 | }) 442 | .then(({ error }) => { 443 | if (error) { 444 | this.logger.error(error); 445 | } 446 | }); 447 | this.sendMessage(message); 448 | } else if (event === "athena/event") { 449 | const message: IWebappUIMessage = { 450 | type: "event", 451 | data: { 452 | summary: args.summary, 453 | details: args.details, 454 | timestamp: Date.now(), 455 | }, 456 | }; 457 | this.supabase 458 | .from("messages") 459 | .insert({ 460 | context_id: this.config.context_id, 461 | message, 462 | }) 463 | .then(({ error }) => { 464 | if (error) { 465 | this.logger.error(error); 466 | } 467 | }); 468 | this.sendMessage(message); 469 | } else if (event === "cerebrum/busy") { 470 | this.sendMessage({ 471 | type: "busy", 472 | data: { 473 | busy: args.busy, 474 | }, 475 | }); 476 | } else if (event === "webapp-ui/request-token") { 477 | this.athena.emitPrivateEvent("webapp-ui/token-refreshed", { 478 | token: this.accessToken, 479 | }); 480 | } 481 | } 482 | } 483 | -------------------------------------------------------------------------------- /src/plugins/webapp-ui/logger.ts: -------------------------------------------------------------------------------- 1 | import { SupabaseClient } from "@supabase/supabase-js"; 2 | import { format } from "winston"; 3 | import Transport from "winston-transport"; 4 | 5 | export default class WebappUITransport extends Transport { 6 | supabase: SupabaseClient; 7 | contextId: string; 8 | formatter: any; 9 | 10 | constructor(supabase: SupabaseClient, contextId: string) { 11 | super(); 12 | this.supabase = supabase; 13 | this.contextId = contextId; 14 | this.formatter = format.combine(format.timestamp(), format.json()); 15 | } 16 | 17 | log(info: any, callback: () => void) { 18 | const formattedInfo = this.formatter.transform(info); 19 | this.supabase 20 | .from("logs") 21 | .insert({ 22 | context_id: this.contextId, 23 | log: formattedInfo, 24 | }) 25 | .then(() => { 26 | callback(); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/plugins/webapp-ui/message.ts: -------------------------------------------------------------------------------- 1 | export interface IWebappUIMessageMessageFile { 2 | name: string; 3 | location: string; 4 | } 5 | 6 | export interface IWebappUIMessageMessage { 7 | type: "message"; 8 | data: { 9 | role: "assistant" | "user"; 10 | content: string; 11 | files?: IWebappUIMessageMessageFile[]; 12 | timestamp: number; 13 | }; 14 | } 15 | 16 | export interface IWebappUIMessageThinking { 17 | type: "thinking"; 18 | data: { 19 | content: string; 20 | timestamp: number; 21 | }; 22 | } 23 | 24 | export interface IWebappUIMessageToolCall { 25 | type: "tool_call"; 26 | data: { 27 | summary: string; 28 | details?: string; 29 | timestamp: number; 30 | }; 31 | } 32 | 33 | export interface IWebappUIMessageToolResult { 34 | type: "tool_result"; 35 | data: { 36 | summary: string; 37 | details?: string; 38 | timestamp: number; 39 | }; 40 | } 41 | 42 | export interface IWebappUIMessageEvent { 43 | type: "event"; 44 | data: { 45 | summary: string; 46 | details?: string; 47 | timestamp: number; 48 | }; 49 | } 50 | 51 | export interface IWebappUIMessageBusy { 52 | type: "busy"; 53 | data: { 54 | busy: boolean; 55 | }; 56 | } 57 | 58 | export interface IWebappUIMessagePing { 59 | type: "ping"; 60 | data: Record; 61 | } 62 | 63 | export interface IWebappUIMessagePong { 64 | type: "pong"; 65 | data: Record; 66 | } 67 | 68 | export interface IWebappUIMessageConnected { 69 | type: "connected"; 70 | data: Record; 71 | } 72 | 73 | export interface IWebappUIMessageDisconnected { 74 | type: "disconnected"; 75 | data: Record; 76 | } 77 | 78 | export interface IWebappUIMessageError { 79 | type: "error"; 80 | data: { 81 | message: string; 82 | }; 83 | } 84 | 85 | export interface IWebappUIMessageInsufficientBalance { 86 | type: "insufficient_balance"; 87 | data: Record; 88 | } 89 | 90 | export type IWebappUIMessage = 91 | | IWebappUIMessageMessage 92 | | IWebappUIMessageThinking 93 | | IWebappUIMessageToolCall 94 | | IWebappUIMessageToolResult 95 | | IWebappUIMessageEvent 96 | | IWebappUIMessageBusy 97 | | IWebappUIMessagePing 98 | | IWebappUIMessagePong 99 | | IWebappUIMessageConnected 100 | | IWebappUIMessageDisconnected 101 | | IWebappUIMessageError 102 | | IWebappUIMessageInsufficientBalance; 103 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const openaiDefaultHeaders = { 2 | "HTTP-Referer": "https://athenalab.ai/", 3 | "X-Title": "Athena", 4 | }; 5 | -------------------------------------------------------------------------------- /src/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import fs from "fs"; 3 | 4 | export const fileDigest = async ( 5 | filePath: string, 6 | algorithm: string = "sha256", 7 | ) => { 8 | return new Promise((resolve, reject) => { 9 | const hash = crypto.createHash(algorithm); 10 | fs.createReadStream(filePath) 11 | .on("error", reject) 12 | .on("data", (chunk) => hash.update(chunk)) 13 | .on("end", () => resolve(hash.digest("hex"))); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from "winston"; 2 | 3 | const logger = createLogger({ 4 | transports: [ 5 | new transports.Console({ 6 | format: format.combine( 7 | format.timestamp(), 8 | format.colorize(), 9 | format.printf((info) => { 10 | return `[${info.timestamp}] [${info.level}] ${info.message}`; 11 | }), 12 | ), 13 | }), 14 | ], 15 | }); 16 | 17 | export default logger; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "outDir": "dist", 11 | "rootDir": "src", 12 | "jsx": "react-jsx", 13 | "lib": ["ESNext"], 14 | "types": ["node"] 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | --------------------------------------------------------------------------------