diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc6d888 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/* +.diambra/credentials +logs/agentm.log +*.pyc +agentm/data/agentM.db diff --git a/README.md b/README.md index 87a90bb..1b2dd7c 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,92 @@ -

- Agent M Logo -

- -

๐Ÿ•น๏ธ Agent M

-

A DIAMBRA Utility by mscrnt

- ---- - -**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) +

+ Agent M Logo +

+ +

๐Ÿ•น๏ธ Agent M

+

A DIAMBRA Utility by mscrnt

+ +--- + +**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) diff --git a/agentm/__init__.py b/agentm/__init__.py index e69de29..278c861 100644 --- a/agentm/__init__.py +++ b/agentm/__init__.py @@ -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() diff --git a/agentm/app.py b/agentm/app.py index e69de29..19e0e73 100644 --- a/agentm/app.py +++ b/agentm/app.py @@ -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()) diff --git a/agentm/assets/game_images/doapp.jpg b/agentm/assets/game_images/doapp.jpg new file mode 100644 index 0000000..f1375f9 Binary files /dev/null and b/agentm/assets/game_images/doapp.jpg differ diff --git a/agentm/assets/game_images/kof98umh.jpg b/agentm/assets/game_images/kof98umh.jpg new file mode 100644 index 0000000..592d85d Binary files /dev/null and b/agentm/assets/game_images/kof98umh.jpg differ diff --git a/agentm/assets/game_images/mvsc.jpg b/agentm/assets/game_images/mvsc.jpg new file mode 100644 index 0000000..94acb72 Binary files /dev/null and b/agentm/assets/game_images/mvsc.jpg differ diff --git a/agentm/assets/game_images/samsh5sp.jpg b/agentm/assets/game_images/samsh5sp.jpg new file mode 100644 index 0000000..ccb8400 Binary files /dev/null and b/agentm/assets/game_images/samsh5sp.jpg differ diff --git a/agentm/assets/game_images/sfiii3n.jpg b/agentm/assets/game_images/sfiii3n.jpg new file mode 100644 index 0000000..00eb360 Binary files /dev/null and b/agentm/assets/game_images/sfiii3n.jpg differ diff --git a/agentm/assets/game_images/soulclbr.jpg b/agentm/assets/game_images/soulclbr.jpg new file mode 100644 index 0000000..fb5b784 Binary files /dev/null and b/agentm/assets/game_images/soulclbr.jpg differ diff --git a/agentm/assets/game_images/tektagt.jpg b/agentm/assets/game_images/tektagt.jpg new file mode 100644 index 0000000..79c699f Binary files /dev/null and b/agentm/assets/game_images/tektagt.jpg differ diff --git a/agentm/assets/game_images/umk3.jpg b/agentm/assets/game_images/umk3.jpg new file mode 100644 index 0000000..551b185 Binary files /dev/null and b/agentm/assets/game_images/umk3.jpg differ diff --git a/agentm/assets/game_images/xmvsf.jpg b/agentm/assets/game_images/xmvsf.jpg new file mode 100644 index 0000000..3f058df Binary files /dev/null and b/agentm/assets/game_images/xmvsf.jpg differ diff --git a/agentm/assets/logo.txt b/agentm/assets/logo.txt new file mode 100644 index 0000000..90c2f3b --- /dev/null +++ b/agentm/assets/logo.txt @@ -0,0 +1,55 @@ + + + + + + + %@%%%%%@%# + #%%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%*++++++++++++%%%%%%%%%%@ + %%%%%%%++++++++++++++++++++++++++%%%%%%% + %%%%%%++++++++++++++++++++++++++++++++++%%%%%@ + %%%%%+++++++++++++++++++++++++++++++++++++++*%%%%% + %%%%%+++++++++@@@@@@@@@@@++++++++++++++++++++++++%%%%% + %%%%%+++++++++++@@@@@@@@@@@++++++++++++++++++++++++++%%%%@ + %%%%++++++++++@@@@@@@@@@@@@@++*@@@@@@@@+++++++++++++++++%%%% + %%%%++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@++++++++++++++++++%%%%# + %%%%+++++++++++++@@@@@@@@@@@@@@+=@@@@@@@@@+++@@@@@@+++++++++++%%%@ + %%%%++++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@+++@@@@@@++++++++++++%%%% + %%%%+++++++++++++++++++++++++++++++@@@@@@@@@++@@@@@@@++@@@@@@+++++%%%@ + @%%%++++++%@@@@@@@@@@@@@@@@@@@@@@@+*@@@@@@@@@+*@@@@@@@+*@@@@@@@+++++%%% + %%%+++++++@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@++@@@@@@@++++++%%% + %%%%++++++*@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+=@@@@@@@++++++%%%% + %%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+@@@@@@@@+++++++%%% + %%%%++++++=@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++%%%@ + %%%+++++++@@@@@@@@@@@@+++++++++++++++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++#%%% + %%%++++++=@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@+=@@@@@@%+@@@@@@@@++++++++%%% + %%%++++++@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@++@@@@@@@++@@@@@@@@++++++++%%% + #%%%+++++@@@@@@@@@@@@@@++++++++*+++==@@@@@@@@=++@@@@@@@++@@@@@@@++++++++++%%% + %%%++++++@@@@@@@@@@@@@@@@@@@@@@@@@#++++++++++++++++++++++++++++++++++++++%%% + %%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%% + %%%+++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%% + %%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%% + %%%+++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@+++++++++%%% + %%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++%%%% + %%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%% + @%%%++++++++++++++%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%% + %%%%+++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++++%%%@ + %%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%@ + %%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++++%%%@ + @%%%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%%# + %%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%% + %%%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%%@ + %%%%%*+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++%%%%% + %%%%%%+++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++%%%%%% + %%%%%%%+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%@ + %%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%% + @%%%%%%%%%%%@@@@@@@@@@%%%%%%%%%%%@ + #%%%%%%%%%%%%%%%%%%%%%%%%# + *@%@@@@* + + + + + + diff --git a/agentm/assets/styles.tcss b/agentm/assets/styles.tcss new file mode 100644 index 0000000..0faa80e --- /dev/null +++ b/agentm/assets/styles.tcss @@ -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; +} diff --git a/agentm/logic/db.py b/agentm/logic/db.py new file mode 100644 index 0000000..03ba7a2 --- /dev/null +++ b/agentm/logic/db.py @@ -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() \ No newline at end of file diff --git a/agentm/logic/db_functions.py b/agentm/logic/db_functions.py new file mode 100644 index 0000000..fbcd433 --- /dev/null +++ b/agentm/logic/db_functions.py @@ -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() + )) diff --git a/agentm/logic/diambra_login.py b/agentm/logic/diambra_login.py new file mode 100644 index 0000000..8ebd1ec --- /dev/null +++ b/agentm/logic/diambra_login.py @@ -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}") + diff --git a/agentm/logic/roms.py b/agentm/logic/roms.py new file mode 100644 index 0000000..ca59129 --- /dev/null +++ b/agentm/logic/roms.py @@ -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 diff --git a/agentm/main.py b/agentm/main.py index e69de29..e96eb69 100644 --- a/agentm/main.py +++ b/agentm/main.py @@ -0,0 +1,7 @@ +# agentm/main.py + +from agentm.app import AgentMApp + +if __name__ == "__main__": + app = AgentMApp() + app.run() diff --git a/agentm/roms/doapp.zip b/agentm/roms/doapp.zip new file mode 100644 index 0000000..ab234b8 Binary files /dev/null and b/agentm/roms/doapp.zip differ diff --git a/agentm/roms/kof98umh.zip b/agentm/roms/kof98umh.zip new file mode 100644 index 0000000..c57a085 Binary files /dev/null and b/agentm/roms/kof98umh.zip differ diff --git a/agentm/roms/mvsc.zip b/agentm/roms/mvsc.zip new file mode 100644 index 0000000..2fe7b7e Binary files /dev/null and b/agentm/roms/mvsc.zip differ diff --git a/agentm/roms/qsound_hle.zip b/agentm/roms/qsound_hle.zip new file mode 100644 index 0000000..2fa7829 Binary files /dev/null and b/agentm/roms/qsound_hle.zip differ diff --git a/agentm/roms/samsh5sp.zip b/agentm/roms/samsh5sp.zip new file mode 100644 index 0000000..356b3dd Binary files /dev/null and b/agentm/roms/samsh5sp.zip differ diff --git a/agentm/roms/sfiii3n.zip b/agentm/roms/sfiii3n.zip new file mode 100644 index 0000000..911b6a6 Binary files /dev/null and b/agentm/roms/sfiii3n.zip differ diff --git a/agentm/roms/soulclbr.zip b/agentm/roms/soulclbr.zip new file mode 100644 index 0000000..1626ce6 Binary files /dev/null and b/agentm/roms/soulclbr.zip differ diff --git a/agentm/roms/tektagt.zip b/agentm/roms/tektagt.zip new file mode 100644 index 0000000..a366bd7 Binary files /dev/null and b/agentm/roms/tektagt.zip differ diff --git a/agentm/roms/umk3.zip b/agentm/roms/umk3.zip new file mode 100644 index 0000000..1e9e770 Binary files /dev/null and b/agentm/roms/umk3.zip differ diff --git a/agentm/roms/xmvsf.zip b/agentm/roms/xmvsf.zip new file mode 100644 index 0000000..8dbf2d2 Binary files /dev/null and b/agentm/roms/xmvsf.zip differ diff --git a/agentm/utils/logger.py b/agentm/utils/logger.py new file mode 100644 index 0000000..4b87646 --- /dev/null +++ b/agentm/utils/logger.py @@ -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", ""}: + 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()}") diff --git a/agentm/views/home.py b/agentm/views/home.py index e69de29..ab3f2c7 100644 --- a/agentm/views/home.py +++ b/agentm/views/home.py @@ -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) + diff --git a/agentm/views/login.py b/agentm/views/login.py new file mode 100644 index 0000000..e0e77a0 --- /dev/null +++ b/agentm/views/login.py @@ -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}") diff --git a/requirements.txt b/requirements.txt index e69de29..7a6e454 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,10 @@ +textual +rich +typer +diambra +diambra-arena +pyyaml +stable-baselines3 +tensorboard +requests +sqlite3 \ No newline at end of file