Add initial project structure and core functionality for Agent M
- Created .gitignore to exclude virtual environment, logs, and database files. - Updated README.md with project description, features, folder structure, requirements, and usage instructions. - Implemented versioning and developer ID in agentm/__init__.py. - Developed main application logic in agentm/app.py, including credential handling and screen navigation. - Added database initialization and ROM management logic in agentm/logic/db.py and agentm/logic/db_functions.py. - Integrated DIAMBRA login functionality in agentm/logic/diambra_login.py. - Created ROM verification and caching system in agentm/logic/roms.py. - Designed user interface components for home and login screens in agentm/views/home.py and agentm/views/login.py. - Added logging utility in agentm/utils/logger.py for better debugging and tracking. - Included assets such as game images, styles, and logos. - Updated requirements.txt with necessary dependencies for the project.
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.venv/*
|
||||
.diambra/credentials
|
||||
logs/agentm.log
|
||||
*.pyc
|
||||
agentm/data/agentM.db
|
||||
184
README.md
@ -1,92 +1,92 @@
|
||||
<p align="center">
|
||||
<img src="logo.png" alt="Agent M Logo" width="200"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">🕹️ Agent M</h1>
|
||||
<h3 align="center">A DIAMBRA Utility by mscrnt</h3>
|
||||
|
||||
---
|
||||
|
||||
**Agent M** is a retro-styled launcher for [DIAMBRA Arena](https://www.diambra.ai/), designed to make agent training and packaging effortless. It wraps the official `diambra` CLI with a powerful terminal UI (TUI) powered by [Textual](https://textual.textualize.io/), giving both beginners and power users a guided experience for environment setup, wrapper configuration, and agent submission.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- 📦 **Project scaffolding**: Quickly generate ready-to-train agent folders
|
||||
- 🎮 **Environment launcher**: Easily configure and run DIAMBRA games
|
||||
- 🧠 **Training config wizard**: Guide users through wrappers, hyperparameters, and PPO settings
|
||||
- 📊 **Evaluation screen**: Run saved agents interactively
|
||||
- 🛠️ **Docker + submission helper**: Build, tag, and submit your agent
|
||||
- 🔒 **Developer ID tracking**: All submissions include a unique developer ID (`mscrnt-0001`)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Folder Structure
|
||||
|
||||
```text
|
||||
agent_m/
|
||||
├── agentm/
|
||||
│ ├── __init__.py # Contains __version__, __developer_id__
|
||||
│ ├── main.py # Entry point
|
||||
│ ├── app.py # Textual App class
|
||||
│ ├── views/ # Screens: home, config, training, eval
|
||||
│ ├── components/ # Reusable widgets
|
||||
│ ├── logic/ # Non-UI logic: Docker, config, submit
|
||||
│ └── assets/ # ASCII, icons, splash art
|
||||
├── logo.png # Project icon
|
||||
├── README.md
|
||||
├── requirements.txt
|
||||
├── pyproject.toml (optional)
|
||||
└── dist/ # Built .exe from PyInstaller
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Requirements
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
> Includes: `textual`, `rich`, `diambra-arena`, `pyyaml`, `typer`
|
||||
|
||||
---
|
||||
|
||||
## ▶️ Running Agent M
|
||||
|
||||
```bash
|
||||
python -m agentm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Build as Executable
|
||||
|
||||
Using [PyInstaller](https://pyinstaller.org/):
|
||||
|
||||
```bash
|
||||
pyinstaller --onefile agentm/main.py --name agentm --add-data "agentm/assets:splash"
|
||||
```
|
||||
|
||||
The final `.exe` will be located in the `dist/` directory.
|
||||
|
||||
---
|
||||
|
||||
## 🧾 Developer Attribution
|
||||
|
||||
All agents submitted through Agent M include a hardcoded developer ID:
|
||||
|
||||
```python
|
||||
__developer_id__ = "mscrnt-0001"
|
||||
```
|
||||
|
||||
This allows future reward attribution or referral tracking.
|
||||
|
||||
---
|
||||
|
||||
## 📣 License
|
||||
|
||||
[MIT](LICENSE) — Created by [mscrnt](https://github.com/mscrnt)
|
||||
<p align="center">
|
||||
<img src="logo.png" alt="Agent M Logo" width="200"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">🕹️ Agent M</h1>
|
||||
<h3 align="center">A DIAMBRA Utility by mscrnt</h3>
|
||||
|
||||
---
|
||||
|
||||
**Agent M** is a retro-styled launcher for [DIAMBRA Arena](https://www.diambra.ai/), designed to make agent training and packaging effortless. It wraps the official `diambra` CLI with a powerful terminal UI (TUI) powered by [Textual](https://textual.textualize.io/), giving both beginners and power users a guided experience for environment setup, wrapper configuration, and agent submission.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- 📦 **Project scaffolding**: Quickly generate ready-to-train agent folders
|
||||
- 🎮 **Environment launcher**: Easily configure and run DIAMBRA games
|
||||
- 🧠 **Training config wizard**: Guide users through wrappers, hyperparameters, and PPO settings
|
||||
- 📊 **Evaluation screen**: Run saved agents interactively
|
||||
- 🛠️ **Docker + submission helper**: Build, tag, and submit your agent
|
||||
- 🔒 **Developer ID tracking**: All submissions include a unique developer ID (`mscrnt-0001`)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Folder Structure
|
||||
|
||||
```text
|
||||
agent_m/
|
||||
├── agentm/
|
||||
│ ├── __init__.py # Contains __version__, __developer_id__
|
||||
│ ├── main.py # Entry point
|
||||
│ ├── app.py # Textual App class
|
||||
│ ├── views/ # Screens: home, config, training, eval
|
||||
│ ├── components/ # Reusable widgets
|
||||
│ ├── logic/ # Non-UI logic: Docker, config, submit
|
||||
│ └── assets/ # ASCII, icons, splash art
|
||||
├── logo.png # Project icon
|
||||
├── README.md
|
||||
├── requirements.txt
|
||||
├── pyproject.toml (optional)
|
||||
└── dist/ # Built .exe from PyInstaller
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Requirements
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
> Includes: `textual`, `rich`, `diambra-arena`, `pyyaml`, `typer`
|
||||
|
||||
---
|
||||
|
||||
## ▶️ Running Agent M
|
||||
|
||||
```bash
|
||||
python -m agentm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Build as Executable
|
||||
|
||||
Using [PyInstaller](https://pyinstaller.org/):
|
||||
|
||||
```bash
|
||||
pyinstaller --onefile agentm/main.py --name agentm --add-data "agentm/assets:splash"
|
||||
```
|
||||
|
||||
The final `.exe` will be located in the `dist/` directory.
|
||||
|
||||
---
|
||||
|
||||
## 🧾 Developer Attribution
|
||||
|
||||
All agents submitted through Agent M include a hardcoded developer ID:
|
||||
|
||||
```python
|
||||
__developer_id__ = "mscrnt-0001"
|
||||
```
|
||||
|
||||
This allows future reward attribution or referral tracking.
|
||||
|
||||
---
|
||||
|
||||
## 📣 License
|
||||
|
||||
[MIT](LICENSE) — Created by [mscrnt](https://github.com/mscrnt)
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__developer_id__ = "mscrnt-0001"
|
||||
|
||||
# Always resolve to the root of the project (1 level up from /agentm/)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
DIAMBRA_CREDENTIALS_PATH = PROJECT_ROOT / ".diambra" / "credentials"
|
||||
DIAMBRA_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not DIAMBRA_CREDENTIALS_PATH.exists():
|
||||
DIAMBRA_CREDENTIALS_PATH.touch()
|
||||
@ -0,0 +1,34 @@
|
||||
from textual.app import App
|
||||
from agentm.views.home import HomeView
|
||||
from agentm.views.login import LoginView
|
||||
from agentm import DIAMBRA_CREDENTIALS_PATH
|
||||
from agentm.utils.logger import log_with_caller
|
||||
from agentm.logic.db import initialize_database
|
||||
|
||||
class AgentMApp(App):
|
||||
CSS_PATH = "assets/styles.tcss"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Called when the app starts."""
|
||||
log_with_caller("debug", "App mounted. Initializing database...")
|
||||
initialize_database()
|
||||
|
||||
log_with_caller("debug", "Checking for credentials...")
|
||||
|
||||
if DIAMBRA_CREDENTIALS_PATH.exists():
|
||||
token = DIAMBRA_CREDENTIALS_PATH.read_text().strip()
|
||||
|
||||
if token and len(token) > 10:
|
||||
log_with_caller("info", "Found populated DIAMBRA credentials. Launching Home.")
|
||||
self.push_screen(HomeView())
|
||||
return
|
||||
else:
|
||||
log_with_caller("warning", "Token file exists but appears empty or malformed.")
|
||||
|
||||
log_with_caller("info", "No valid credentials found. Launching Login screen.")
|
||||
self.push_screen(LoginView())
|
||||
|
||||
async def on_login_view_login_success(self, message: LoginView.LoginSuccess) -> None:
|
||||
"""Handle successful login event and switch to Home screen."""
|
||||
log_with_caller("info", "Handling LoginSuccess event. Launching Home.")
|
||||
self.push_screen(HomeView())
|
||||
BIN
agentm/assets/game_images/doapp.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
agentm/assets/game_images/kof98umh.jpg
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
agentm/assets/game_images/mvsc.jpg
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
agentm/assets/game_images/samsh5sp.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
agentm/assets/game_images/sfiii3n.jpg
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
agentm/assets/game_images/soulclbr.jpg
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
agentm/assets/game_images/tektagt.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
agentm/assets/game_images/umk3.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
agentm/assets/game_images/xmvsf.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
55
agentm/assets/logo.txt
Normal file
@ -0,0 +1,55 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
%@%%%%%@%#
|
||||
#%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
%%%%%%%%%%*++++++++++++%%%%%%%%%%@
|
||||
%%%%%%%++++++++++++++++++++++++++%%%%%%%
|
||||
%%%%%%++++++++++++++++++++++++++++++++++%%%%%@
|
||||
%%%%%+++++++++++++++++++++++++++++++++++++++*%%%%%
|
||||
%%%%%+++++++++@@@@@@@@@@@++++++++++++++++++++++++%%%%%
|
||||
%%%%%+++++++++++@@@@@@@@@@@++++++++++++++++++++++++++%%%%@
|
||||
%%%%++++++++++@@@@@@@@@@@@@@++*@@@@@@@@+++++++++++++++++%%%%
|
||||
%%%%++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@++++++++++++++++++%%%%#
|
||||
%%%%+++++++++++++@@@@@@@@@@@@@@+=@@@@@@@@@+++@@@@@@+++++++++++%%%@
|
||||
%%%%++++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@+++@@@@@@++++++++++++%%%%
|
||||
%%%%+++++++++++++++++++++++++++++++@@@@@@@@@++@@@@@@@++@@@@@@+++++%%%@
|
||||
@%%%++++++%@@@@@@@@@@@@@@@@@@@@@@@+*@@@@@@@@@+*@@@@@@@+*@@@@@@@+++++%%%
|
||||
%%%+++++++@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@++@@@@@@@++++++%%%
|
||||
%%%%++++++*@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+=@@@@@@@++++++%%%%
|
||||
%%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+@@@@@@@@+++++++%%%
|
||||
%%%%++++++=@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++%%%@
|
||||
%%%+++++++@@@@@@@@@@@@+++++++++++++++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++#%%%
|
||||
%%%++++++=@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@+=@@@@@@%+@@@@@@@@++++++++%%%
|
||||
%%%++++++@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@++@@@@@@@++@@@@@@@@++++++++%%%
|
||||
#%%%+++++@@@@@@@@@@@@@@++++++++*+++==@@@@@@@@=++@@@@@@@++@@@@@@@++++++++++%%%
|
||||
%%%++++++@@@@@@@@@@@@@@@@@@@@@@@@@#++++++++++++++++++++++++++++++++++++++%%%
|
||||
%%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%
|
||||
%%%+++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%%
|
||||
%%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%%
|
||||
%%%+++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%
|
||||
%%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++%%%%
|
||||
%%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%%
|
||||
@%%%++++++++++++++%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%
|
||||
%%%%+++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++++%%%@
|
||||
%%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%@
|
||||
%%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++++%%%@
|
||||
@%%%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%%#
|
||||
%%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%%
|
||||
%%%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%%@
|
||||
%%%%%*+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++%%%%%
|
||||
%%%%%%+++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++%%%%%%
|
||||
%%%%%%%+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%@
|
||||
%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%%
|
||||
@%%%%%%%%%%%@@@@@@@@@@%%%%%%%%%%%@
|
||||
#%%%%%%%%%%%%%%%%%%%%%%%%#
|
||||
*@%@@@@*
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
150
agentm/assets/styles.tcss
Normal file
@ -0,0 +1,150 @@
|
||||
/* === Global App Styles === */
|
||||
|
||||
Screen {
|
||||
background: #0e0e0e;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* === Resets === */
|
||||
|
||||
* {
|
||||
padding: 0 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* === Headers === */
|
||||
|
||||
Header, .header {
|
||||
dock: top;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
background: #1f1f1f;
|
||||
color: #ed7d3a;
|
||||
text-style: bold;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
/* === Buttons === */
|
||||
|
||||
Button {
|
||||
background: #1f1f1f;
|
||||
color: #ed7d3a;
|
||||
border: solid #ed7d3a;
|
||||
padding: 1 2;
|
||||
margin: 1;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
Button:hover {
|
||||
background: #ed7d3a;
|
||||
color: #0e0e0e;
|
||||
}
|
||||
|
||||
Button:focus {
|
||||
background: #2a2a2a;
|
||||
border: solid #ed7d3a;
|
||||
}
|
||||
|
||||
/* === Inputs === */
|
||||
|
||||
Input {
|
||||
background: #1f1f1f;
|
||||
color: #f0f0f0;
|
||||
border: solid #3a9bed;
|
||||
}
|
||||
|
||||
Input:focus {
|
||||
background: #2a2a2a;
|
||||
border: solid #ed7d3a;
|
||||
}
|
||||
|
||||
/* === Alerts === */
|
||||
|
||||
.success {
|
||||
color: #4cd964;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #ed7d3a;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* === Login Form === */
|
||||
|
||||
#login_form {
|
||||
layout: vertical;
|
||||
align-horizontal: center;
|
||||
align-vertical: middle;
|
||||
width: 60%;
|
||||
height: auto;
|
||||
padding: 2 4;
|
||||
border: solid #3a9bed;
|
||||
}
|
||||
|
||||
#status_message {
|
||||
color: #ed7d3a;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#pw_row {
|
||||
layout: horizontal;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#pw_row > * {
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#pw_row > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* === Loading Overlay === */
|
||||
|
||||
#loading_overlay {
|
||||
dock: top;
|
||||
background: #1f1f1f;
|
||||
color: #f0f0f0;
|
||||
padding: 1 2;
|
||||
text-style: bold;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
/* === Game Layout === */
|
||||
|
||||
.centered_layout {
|
||||
layout: vertical;
|
||||
align-horizontal: center;
|
||||
align-vertical: middle;
|
||||
padding: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rom_rows_container {
|
||||
layout: vertical;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
.rom_row {
|
||||
layout: horizontal;
|
||||
align-horizontal: center;
|
||||
align-vertical: middle;
|
||||
padding: 1 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm_button {
|
||||
align-horizontal: center;
|
||||
align-vertical: top;
|
||||
margin-top: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.game_info {
|
||||
padding: 1 2;
|
||||
width: 100%;
|
||||
color: #ed7d3a;
|
||||
}
|
||||
27
agentm/logic/db.py
Normal file
@ -0,0 +1,27 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
CACHE_DB_PATH = Path("agentm/data/agentM.db")
|
||||
CACHE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_db_conn():
|
||||
return sqlite3.connect(CACHE_DB_PATH)
|
||||
|
||||
def initialize_database():
|
||||
with get_db_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS roms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
rom_file TEXT NOT NULL UNIQUE,
|
||||
game_id TEXT NOT NULL,
|
||||
sha256 TEXT,
|
||||
difficulty_min INTEGER,
|
||||
difficulty_max INTEGER,
|
||||
characters TEXT,
|
||||
keywords TEXT,
|
||||
verified BOOLEAN NOT NULL DEFAULT 0,
|
||||
verified_at TEXT
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
120
agentm/logic/db_functions.py
Normal file
@ -0,0 +1,120 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from agentm.logic.db import get_db_conn
|
||||
|
||||
|
||||
def get_cached_rom(rom_file: str) -> dict | None:
|
||||
"""
|
||||
Retrieve verified ROM metadata from the database by ROM filename.
|
||||
|
||||
Args:
|
||||
rom_file: The filename of the ROM (e.g., 'sfiii3n.zip').
|
||||
|
||||
Returns:
|
||||
A dictionary of ROM metadata if verified, otherwise None.
|
||||
"""
|
||||
with get_db_conn() as conn:
|
||||
cur = conn.execute("""
|
||||
SELECT sha256, verified_at, title, game_id,
|
||||
difficulty_min, difficulty_max, characters, keywords
|
||||
FROM roms WHERE rom_file = ? AND verified = 1
|
||||
""", (rom_file,))
|
||||
row = cur.fetchone()
|
||||
|
||||
if row:
|
||||
return {
|
||||
"sha256": row[0],
|
||||
"verified": True,
|
||||
"verified_at": row[1],
|
||||
"title": row[2],
|
||||
"rom_file": rom_file,
|
||||
"game_id": row[3],
|
||||
"difficulty_min": row[4],
|
||||
"difficulty_max": row[5],
|
||||
"characters": json.loads(row[6]) if row[6] else [],
|
||||
"keywords": json.loads(row[7]) if row[7] else [],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_all_verified_roms() -> list[dict]:
|
||||
"""
|
||||
Return a list of all verified ROMs as dictionaries.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries containing ROM metadata.
|
||||
"""
|
||||
with get_db_conn() as conn:
|
||||
cur = conn.execute("""
|
||||
SELECT sha256, verified_at, title, game_id, rom_file,
|
||||
difficulty_min, difficulty_max, characters, keywords
|
||||
FROM roms WHERE verified = 1
|
||||
ORDER BY title ASC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"sha256": row[0],
|
||||
"verified": True,
|
||||
"verified_at": row[1],
|
||||
"title": row[2],
|
||||
"game_id": row[3],
|
||||
"rom_file": row[4],
|
||||
"difficulty_min": row[5],
|
||||
"difficulty_max": row[6],
|
||||
"characters": json.loads(row[7]) if row[7] else [],
|
||||
"keywords": json.loads(row[8]) if row[8] else [],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def upsert_rom_record(
|
||||
title: str,
|
||||
rom_file: str,
|
||||
game_id: str,
|
||||
sha256: str,
|
||||
difficulty_min: int = None,
|
||||
difficulty_max: int = None,
|
||||
characters: list[str] = None,
|
||||
keywords: list[str] = None
|
||||
):
|
||||
"""
|
||||
Insert or replace a verified ROM entry in the database.
|
||||
|
||||
Args:
|
||||
title: Game title.
|
||||
rom_file: ROM file name.
|
||||
game_id: Game ID used by DIAMBRA.
|
||||
sha256: SHA256 checksum of the ROM.
|
||||
difficulty_min: Minimum difficulty.
|
||||
difficulty_max: Maximum difficulty.
|
||||
characters: List of characters.
|
||||
keywords: List of keywords.
|
||||
"""
|
||||
with get_db_conn() as conn:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO roms (
|
||||
title,
|
||||
rom_file,
|
||||
game_id,
|
||||
sha256,
|
||||
difficulty_min,
|
||||
difficulty_max,
|
||||
characters,
|
||||
keywords,
|
||||
verified,
|
||||
verified_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
""", (
|
||||
title,
|
||||
rom_file,
|
||||
game_id,
|
||||
sha256,
|
||||
difficulty_min,
|
||||
difficulty_max,
|
||||
json.dumps(characters or []),
|
||||
json.dumps(keywords or []),
|
||||
datetime.utcnow().isoformat()
|
||||
))
|
||||
28
agentm/logic/diambra_login.py
Normal file
@ -0,0 +1,28 @@
|
||||
import requests
|
||||
from agentm import DIAMBRA_CREDENTIALS_PATH
|
||||
from agentm.utils.logger import log_with_caller
|
||||
|
||||
LOGIN_API = "https://api.diambra.ai/api/auth/token/"
|
||||
|
||||
def login_to_diambra(email: str, password: str) -> str:
|
||||
"""Log in to DIAMBRA and retrieve API token from /v1alpha1/token."""
|
||||
payload = {
|
||||
"username": email,
|
||||
"password": password
|
||||
}
|
||||
|
||||
response = requests.post("https://api.diambra.ai/api/v1alpha1/token", json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
token = response.json().get("token")
|
||||
if not token:
|
||||
raise Exception("Login succeeded but no token returned.")
|
||||
|
||||
log_with_caller("info", f"Successfully retrieved DIAMBRA token: {token[:6]}...")
|
||||
return token
|
||||
|
||||
def save_diambra_token(token: str):
|
||||
"""Writes the token to the .diambra/credentials file with no trailing newline."""
|
||||
DIAMBRA_CREDENTIALS_PATH.write_bytes(token.encode("utf-8"))
|
||||
log_with_caller("info", f"Saved DIAMBRA token to: {DIAMBRA_CREDENTIALS_PATH}")
|
||||
|
||||
127
agentm/logic/roms.py
Normal file
@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
agentm.logic.roms
|
||||
This module handles the verification and caching of ROM files for the AgentM application.
|
||||
It interacts with the DIAMBRA CLI to check ROM validity and updates the database accordingly.
|
||||
It also provides functions to extract game information from the DIAMBRA CLI output.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
from agentm.logic.db_functions import get_cached_rom, upsert_rom_record, get_all_verified_roms
|
||||
from agentm.utils.logger import log_with_caller
|
||||
|
||||
ROM_FOLDER = "agentm/roms"
|
||||
|
||||
GAME_FILES = {
|
||||
"Dead Or Alive ++": "doapp.zip",
|
||||
"The King of Fighters '98 UM": "kof98umh.zip",
|
||||
"Marvel vs. Capcom": "mvsc.zip",
|
||||
"Samurai Shodown V Special": "samsh5sp.zip",
|
||||
"Street Fighter III: 3rd Strike": "sfiii3n.zip",
|
||||
"Soul Calibur": "soulclbr.zip",
|
||||
"Tekken Tag Tournament": "tektagt.zip",
|
||||
"Ultimate Mortal Kombat 3": "umk3.zip",
|
||||
"X-Men vs. Street Fighter": "xmvsf.zip",
|
||||
}
|
||||
|
||||
def get_image_path(rom_file: str) -> str:
|
||||
"""Returns relative image path for a given ROM zip."""
|
||||
base_name = os.path.splitext(rom_file)[0]
|
||||
return f"agentm/assets/game_images/{base_name}.jpg"
|
||||
|
||||
def get_verified_roms():
|
||||
verified = []
|
||||
|
||||
log_with_caller("debug", f"Starting ROM verification loop for {len(GAME_FILES)} files")
|
||||
|
||||
# Call `list-roms` once for efficiency
|
||||
list_result = subprocess.run(["diambra", "arena", "list-roms"], capture_output=True, text=True)
|
||||
full_list_output = list_result.stdout
|
||||
|
||||
for title, rom_file in GAME_FILES.items():
|
||||
rom_path = os.path.join(ROM_FOLDER, rom_file)
|
||||
log_with_caller("debug", f"Checking ROM path: {rom_path}")
|
||||
|
||||
if not os.path.exists(rom_path):
|
||||
log_with_caller("warning", f"ROM not found: {rom_file} (expected at {rom_path})")
|
||||
continue
|
||||
|
||||
cached = get_cached_rom(rom_file)
|
||||
if cached and cached["verified"]:
|
||||
log_with_caller("info", f"✓ Cached ROM is valid: {title} ({rom_file})")
|
||||
verified.append(cached)
|
||||
continue
|
||||
|
||||
log_with_caller("debug", f"No valid cache found. Verifying with DIAMBRA CLI: {rom_file}")
|
||||
result = subprocess.run(
|
||||
["diambra", "arena", "check-roms", os.path.abspath(rom_path)],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
log_with_caller("debug", f"DIAMBRA output for {rom_file}:\n{result.stdout.strip()}")
|
||||
|
||||
if "Correct ROM file" in result.stdout:
|
||||
sha256_match = re.search(r"sha256\s*=\s*([a-f0-9]{64})", result.stdout, re.IGNORECASE)
|
||||
sha256 = sha256_match.group(1) if sha256_match else ""
|
||||
game_id = rom_file.replace(".zip", "")
|
||||
|
||||
block = extract_game_block(full_list_output, game_id)
|
||||
difficulty_min = extract_line_int(block, "Difficulty levels", index=0)
|
||||
difficulty_max = extract_line_int(block, "Difficulty levels", index=1)
|
||||
characters = extract_line_list(block, "Characters list")
|
||||
keywords = extract_line_list(block, "Search keywords")
|
||||
|
||||
upsert_rom_record(
|
||||
title=title,
|
||||
rom_file=rom_file,
|
||||
game_id=game_id,
|
||||
sha256=sha256,
|
||||
difficulty_min=difficulty_min,
|
||||
difficulty_max=difficulty_max,
|
||||
characters=characters,
|
||||
keywords=keywords
|
||||
)
|
||||
|
||||
log_with_caller("info", f"✓ Verified and cached: {title} ({rom_file})")
|
||||
else:
|
||||
log_with_caller("error", f"✗ Invalid ROM: {title} ({rom_file})")
|
||||
|
||||
verified = get_all_verified_roms()
|
||||
log_with_caller("info", f"ROM verification completed: {len(verified)} valid game(s)")
|
||||
return verified
|
||||
|
||||
|
||||
def extract_game_block(output: str, game_id: str) -> str:
|
||||
"""Extracts the block of text for the given game_id from `list-roms` output."""
|
||||
lines = output.splitlines()
|
||||
block = []
|
||||
inside = False
|
||||
for line in lines:
|
||||
if f"game_id: {game_id.lower()}" in line.lower():
|
||||
inside = True
|
||||
elif inside and line.strip() == "":
|
||||
break
|
||||
if inside:
|
||||
block.append(line)
|
||||
return "\n".join(block)
|
||||
|
||||
|
||||
def extract_line_list(block: str, label: str) -> list[str]:
|
||||
match = re.search(rf"{re.escape(label)}:\s*(\[[^\]]*\])", block)
|
||||
if not match:
|
||||
return []
|
||||
try:
|
||||
return eval(match.group(1), {"__builtins__": None}, {})
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def extract_line_int(block: str, label: str, index: int = 0) -> int:
|
||||
match = re.search(rf"{re.escape(label)}:\s*Min\s*(\d+)\s*-\s*Max\s*(\d+)", block)
|
||||
if match:
|
||||
return int(match.group(index + 1))
|
||||
return 0
|
||||
@ -0,0 +1,7 @@
|
||||
# agentm/main.py
|
||||
|
||||
from agentm.app import AgentMApp
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = AgentMApp()
|
||||
app.run()
|
||||
BIN
agentm/roms/doapp.zip
Normal file
BIN
agentm/roms/kof98umh.zip
Normal file
BIN
agentm/roms/mvsc.zip
Normal file
BIN
agentm/roms/qsound_hle.zip
Normal file
BIN
agentm/roms/samsh5sp.zip
Normal file
BIN
agentm/roms/sfiii3n.zip
Normal file
BIN
agentm/roms/soulclbr.zip
Normal file
BIN
agentm/roms/tektagt.zip
Normal file
BIN
agentm/roms/umk3.zip
Normal file
BIN
agentm/roms/xmvsf.zip
Normal file
81
agentm/utils/logger.py
Normal file
@ -0,0 +1,81 @@
|
||||
import os
|
||||
import logging
|
||||
import inspect
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from rich.logging import RichHandler
|
||||
|
||||
DEFAULT_UNIFIED_LOGFILE = "logs/agentm.log"
|
||||
DEFAULT_LOG_LEVEL = os.environ.get("AGENTM_LOG_LEVEL", "DEBUG")
|
||||
|
||||
|
||||
def get_logger(name="AGENTM", level=None, max_bytes=5 * 1024 * 1024, backup_count=5):
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if logger.handlers:
|
||||
return logger # Already configured
|
||||
|
||||
level = level or DEFAULT_LOG_LEVEL
|
||||
if isinstance(level, str):
|
||||
level = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
logger.setLevel(level)
|
||||
|
||||
# 🎛 Rich Console Handler
|
||||
console_handler = RichHandler(
|
||||
rich_tracebacks=True,
|
||||
markup=True,
|
||||
show_time=True,
|
||||
show_level=True,
|
||||
show_path=False,
|
||||
)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 🛡 Unified File Logger
|
||||
log_dir = os.getenv("AGENTM_LOG_DIR", "logs")
|
||||
try:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
unified_path = os.path.join(log_dir, "agentm.log")
|
||||
file_handler = RotatingFileHandler(unified_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8")
|
||||
file_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", "%Y-%m-%d %H:%M:%S")
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception:
|
||||
logger.warning("⚠️ File logging disabled: could not create log directory or file.")
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_module_logger(level=None):
|
||||
module_name = inspect.stack()[1].frame.f_globals.get("__name__", "unknown")
|
||||
return get_logger(name="AGENTM", level=level) # Use unified logger name
|
||||
|
||||
|
||||
def log_with_caller(level: str, message: str):
|
||||
stack = inspect.stack()
|
||||
|
||||
callee = stack[1]
|
||||
callee_func = callee.function
|
||||
callee_module = callee.frame.f_globals.get("__name__", "unknown")
|
||||
|
||||
caller_func = "unknown"
|
||||
caller_module = "unknown"
|
||||
for frame in stack[2:]:
|
||||
if frame.function not in {"wrapper", "inner", "<lambda>"}:
|
||||
caller_func = frame.function
|
||||
caller_module = frame.frame.f_globals.get("__name__", "unknown")
|
||||
break
|
||||
|
||||
logger = get_logger("AGENTM")
|
||||
full_message = (
|
||||
f"{message} ← {callee_module}.{callee_func} "
|
||||
f"→ called by {caller_module}.{caller_func}"
|
||||
)
|
||||
getattr(logger, level.lower())(full_message)
|
||||
|
||||
|
||||
def set_global_log_level(level: str):
|
||||
resolved_level = getattr(logging, level.upper(), logging.INFO)
|
||||
logging.getLogger().setLevel(resolved_level)
|
||||
for name in logging.root.manager.loggerDict:
|
||||
logging.getLogger(name).setLevel(resolved_level)
|
||||
log_with_caller("info", f"🔧 Global log level set to {level.upper()}")
|
||||
@ -0,0 +1,152 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Static, Button
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
import asyncio
|
||||
from math import ceil
|
||||
|
||||
from agentm.utils.logger import log_with_caller
|
||||
from agentm.logic.roms import get_verified_roms, GAME_FILES
|
||||
|
||||
|
||||
class GameSelected(Message):
|
||||
def __init__(self, sender: Widget, metadata: dict):
|
||||
self.metadata = metadata
|
||||
super().__init__(sender)
|
||||
|
||||
|
||||
class ProgressWidget(Widget):
|
||||
message = reactive("Loading...")
|
||||
|
||||
def render(self) -> str:
|
||||
return f"[bold cyan]{self.message}[/bold cyan]"
|
||||
|
||||
|
||||
class GameAccordion(Vertical):
|
||||
def __init__(self, title: str, rom_file: str, metadata: dict, parent_view):
|
||||
safe_id = rom_file.replace(".", "_").replace("-", "_")
|
||||
super().__init__(id=f"accordion_{safe_id}")
|
||||
self.title = title
|
||||
self.rom_file = rom_file
|
||||
self.safe_id = safe_id
|
||||
self.metadata = metadata
|
||||
self.parent_view = parent_view
|
||||
|
||||
def compose(self):
|
||||
yield Button(f"{self.title}", id=f"btn_{self.safe_id}", classes="game_button")
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == f"btn_{self.safe_id}":
|
||||
await self.display_info()
|
||||
|
||||
async def display_info(self):
|
||||
log_with_caller("debug", f"Showing shared info for {self.rom_file}")
|
||||
meta = self.metadata
|
||||
|
||||
info_lines = [
|
||||
f"[b]Title:[/b] {meta['title']}",
|
||||
f"[b]Game ID:[/b] {meta['game_id']}",
|
||||
f"[b]Difficulty:[/b] {meta.get('difficulty_min')} - {meta.get('difficulty_max')}",
|
||||
f"[b]Characters:[/b] {', '.join(meta.get('characters', []))}",
|
||||
f"[b]Keywords:[/b] {', '.join(meta.get('keywords', []))}",
|
||||
f"[b]SHA256:[/b] {meta['sha256']}",
|
||||
]
|
||||
|
||||
self.parent_view.shared_info_box.update("\n".join(info_lines))
|
||||
self.parent_view.shared_confirm_button.label = f"✅ Confirm {meta['title']}"
|
||||
self.parent_view.selected_game = meta
|
||||
|
||||
|
||||
class HomeView(Screen):
|
||||
BINDINGS = [("escape", "app.quit", "Quit")]
|
||||
|
||||
def compose(self):
|
||||
self.logo = Static(
|
||||
"[b bright_magenta]\n\n"
|
||||
" █████╗ ██████╗ ███████╗ ███╗ ██╗ ████████╗ ███╗ ███╗\n"
|
||||
"██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ████╗ ████║\n"
|
||||
"███████║ ██║ ███╗ █████╗ ██╔██╗██║ ██║ ██╔████╔██║\n"
|
||||
"██╔══██║ ██║ ██║ ██╔══╝ ██║╚████║ ██║ ██║╚██╔╝██║\n"
|
||||
"██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚███║ ██║ ██║ ██║ ██║\n"
|
||||
"╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝\n[/]",
|
||||
classes="header",
|
||||
expand=True,
|
||||
)
|
||||
self.progress_text = ProgressWidget()
|
||||
|
||||
yield Vertical(
|
||||
self.logo,
|
||||
Static("[bold green]LOADING...[/bold green]", expand=True),
|
||||
self.progress_text,
|
||||
id="loading_container"
|
||||
)
|
||||
|
||||
async def on_mount(self):
|
||||
log_with_caller("debug", "HomeView mounted. Starting ROM verification.")
|
||||
self.selected_game = None
|
||||
self.run_worker(self.run_verification, thread=True, exclusive=True, name="rom-verification")
|
||||
|
||||
def run_verification(self):
|
||||
total = len(GAME_FILES)
|
||||
verified_roms = get_verified_roms()
|
||||
|
||||
for idx, rom in enumerate(verified_roms, start=1):
|
||||
self.app.call_from_thread(
|
||||
lambda title=rom['title'], idx=idx: setattr(
|
||||
self.progress_text, "message",
|
||||
f"Processing {title} ({idx}/{total})"
|
||||
)
|
||||
)
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
|
||||
self.app.call_from_thread(lambda: self.display_verified_roms(verified_roms))
|
||||
|
||||
async def display_verified_roms(self, verified_roms):
|
||||
log_with_caller("info", f"ROM verification complete. Total: {len(verified_roms)}")
|
||||
self.query_one("#loading_container").remove()
|
||||
|
||||
self.shared_info_box = Static("", id="game_info_box", classes="game_info")
|
||||
self.shared_confirm_button = Button("✅ Confirm", id="confirm_button", classes="confirm_button")
|
||||
|
||||
await self.mount(
|
||||
Vertical(
|
||||
Static("🎮 Welcome to Agent M", classes="header"),
|
||||
Static(
|
||||
"This is an unofficial DIAMBRA launcher to help you easily train, evaluate, and submit RL agents for fighting games.\n\n"
|
||||
"Verified ROMs are shown below. Click one to view game info and confirm selection.",
|
||||
classes="body"
|
||||
),
|
||||
Static(f"✅ Found {len(verified_roms)} valid ROM(s).", id="status_message"),
|
||||
Vertical(
|
||||
Horizontal(id="rom_row_1", classes="rom_row"),
|
||||
Horizontal(id="rom_row_2", classes="rom_row"),
|
||||
id="rom_rows",
|
||||
classes="rom_rows_container"
|
||||
),
|
||||
self.shared_info_box,
|
||||
self.shared_confirm_button,
|
||||
id="home_screen_container",
|
||||
classes="centered_layout"
|
||||
)
|
||||
)
|
||||
|
||||
per_row = ceil(len(verified_roms) / 2)
|
||||
rows = [verified_roms[:per_row], verified_roms[per_row:]]
|
||||
|
||||
for i, row in enumerate(rows, start=1):
|
||||
rom_row = self.query_one(f"#rom_row_{i}", Horizontal)
|
||||
for rom in row:
|
||||
await rom_row.mount(GameAccordion(
|
||||
title=rom["title"],
|
||||
rom_file=rom["rom_file"],
|
||||
metadata=rom,
|
||||
parent_view=self
|
||||
))
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "confirm_button" and self.selected_game:
|
||||
await self.app.push_screen("training", self.selected_game)
|
||||
|
||||
68
agentm/views/login.py
Normal file
@ -0,0 +1,68 @@
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Input, Button, Static
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.message import Message
|
||||
from textual import events
|
||||
from rich.text import Text
|
||||
from agentm.logic.diambra_login import login_to_diambra, save_diambra_token
|
||||
from agentm.utils.logger import log_with_caller
|
||||
|
||||
|
||||
class LoginView(Screen):
|
||||
class LoginSuccess(Message):
|
||||
"""Message indicating login success."""
|
||||
|
||||
def compose(self):
|
||||
yield Vertical(
|
||||
Static("🔒 Login to DIAMBRA", classes="header"),
|
||||
Input(placeholder="Email", id="email_input"),
|
||||
Horizontal(
|
||||
Input(placeholder="Password", password=True, id="password_input"),
|
||||
Button("👁", id="toggle_pw", variant="primary"),
|
||||
id="pw_row"
|
||||
),
|
||||
Button("Login", id="login_button"),
|
||||
Static("", id="status_message"),
|
||||
Static(
|
||||
Text.from_markup(
|
||||
"Don't have an account? [bold blue link=https://old.dev.diambra.ai/register]Register here[/]"
|
||||
),
|
||||
id="register_link"
|
||||
),
|
||||
id="login_form"
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "toggle_pw":
|
||||
password_input = self.query_one("#password_input", Input)
|
||||
password_input.password = not password_input.password
|
||||
password_input.refresh() # Force UI to update
|
||||
return
|
||||
|
||||
if event.button.id == "login_button":
|
||||
email_input = self.query_one("#email_input", Input)
|
||||
password_input = self.query_one("#password_input", Input)
|
||||
status = self.query_one("#status_message", Static)
|
||||
|
||||
email = email_input.value.strip()
|
||||
password = password_input.value.strip()
|
||||
|
||||
if not email or not password:
|
||||
status.update("[red]❌ Email and password required.[/red]")
|
||||
return
|
||||
|
||||
status.update("🔐 Logging in...")
|
||||
self.set_interval(0.1, lambda: self.perform_login(email, password), name="login_task")
|
||||
|
||||
def perform_login(self, email: str, password: str):
|
||||
status = self.query_one("#status_message", Static)
|
||||
|
||||
try:
|
||||
token = login_to_diambra(email, password)
|
||||
save_diambra_token(token)
|
||||
status.update("[green]✅ Login successful![/green]")
|
||||
log_with_caller("info", "User logged in successfully.")
|
||||
self.post_message(self.LoginSuccess())
|
||||
except Exception as e:
|
||||
status.update(f"[red]❌ {str(e)}[/red]")
|
||||
log_with_caller("error", f"Login failed: {e}")
|
||||
@ -0,0 +1,10 @@
|
||||
textual
|
||||
rich
|
||||
typer
|
||||
diambra
|
||||
diambra-arena
|
||||
pyyaml
|
||||
stable-baselines3
|
||||
tensorboard
|
||||
requests
|
||||
sqlite3
|
||||