├── .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 |
3 |
4 |
5 | Athena
6 | A General-Purpose AI Agent ✨
7 |
8 |
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 |
--------------------------------------------------------------------------------