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.
This commit is contained in:
mscrnt 2025-05-20 23:20:29 -07:00
parent 2acb0c8b01
commit 5179d425fc
34 changed files with 967 additions and 92 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.venv/*
.diambra/credentials
logs/agentm.log
*.pyc
agentm/data/agentM.db

184
README.md
View File

@ -1,92 +1,92 @@
<p align="center"> <p align="center">
<img src="logo.png" alt="Agent M Logo" width="200"/> <img src="logo.png" alt="Agent M Logo" width="200"/>
</p> </p>
<h1 align="center">🕹️ Agent M</h1> <h1 align="center">🕹️ Agent M</h1>
<h3 align="center">A DIAMBRA Utility by mscrnt</h3> <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. **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 ## 🚀 Features
- 📦 **Project scaffolding**: Quickly generate ready-to-train agent folders - 📦 **Project scaffolding**: Quickly generate ready-to-train agent folders
- 🎮 **Environment launcher**: Easily configure and run DIAMBRA games - 🎮 **Environment launcher**: Easily configure and run DIAMBRA games
- 🧠 **Training config wizard**: Guide users through wrappers, hyperparameters, and PPO settings - 🧠 **Training config wizard**: Guide users through wrappers, hyperparameters, and PPO settings
- 📊 **Evaluation screen**: Run saved agents interactively - 📊 **Evaluation screen**: Run saved agents interactively
- 🛠️ **Docker + submission helper**: Build, tag, and submit your agent - 🛠️ **Docker + submission helper**: Build, tag, and submit your agent
- 🔒 **Developer ID tracking**: All submissions include a unique developer ID (`mscrnt-0001`) - 🔒 **Developer ID tracking**: All submissions include a unique developer ID (`mscrnt-0001`)
--- ---
## 📁 Folder Structure ## 📁 Folder Structure
```text ```text
agent_m/ agent_m/
├── agentm/ ├── agentm/
│ ├── __init__.py # Contains __version__, __developer_id__ │ ├── __init__.py # Contains __version__, __developer_id__
│ ├── main.py # Entry point │ ├── main.py # Entry point
│ ├── app.py # Textual App class │ ├── app.py # Textual App class
│ ├── views/ # Screens: home, config, training, eval │ ├── views/ # Screens: home, config, training, eval
│ ├── components/ # Reusable widgets │ ├── components/ # Reusable widgets
│ ├── logic/ # Non-UI logic: Docker, config, submit │ ├── logic/ # Non-UI logic: Docker, config, submit
│ └── assets/ # ASCII, icons, splash art │ └── assets/ # ASCII, icons, splash art
├── logo.png # Project icon ├── logo.png # Project icon
├── README.md ├── README.md
├── requirements.txt ├── requirements.txt
├── pyproject.toml (optional) ├── pyproject.toml (optional)
└── dist/ # Built .exe from PyInstaller └── dist/ # Built .exe from PyInstaller
```` ````
--- ---
## 🧰 Requirements ## 🧰 Requirements
Install dependencies: Install dependencies:
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
> Includes: `textual`, `rich`, `diambra-arena`, `pyyaml`, `typer` > Includes: `textual`, `rich`, `diambra-arena`, `pyyaml`, `typer`
--- ---
## ▶️ Running Agent M ## ▶️ Running Agent M
```bash ```bash
python -m agentm python -m agentm
``` ```
--- ---
## 📦 Build as Executable ## 📦 Build as Executable
Using [PyInstaller](https://pyinstaller.org/): Using [PyInstaller](https://pyinstaller.org/):
```bash ```bash
pyinstaller --onefile agentm/main.py --name agentm --add-data "agentm/assets:splash" pyinstaller --onefile agentm/main.py --name agentm --add-data "agentm/assets:splash"
``` ```
The final `.exe` will be located in the `dist/` directory. The final `.exe` will be located in the `dist/` directory.
--- ---
## 🧾 Developer Attribution ## 🧾 Developer Attribution
All agents submitted through Agent M include a hardcoded developer ID: All agents submitted through Agent M include a hardcoded developer ID:
```python ```python
__developer_id__ = "mscrnt-0001" __developer_id__ = "mscrnt-0001"
``` ```
This allows future reward attribution or referral tracking. This allows future reward attribution or referral tracking.
--- ---
## 📣 License ## 📣 License
[MIT](LICENSE) — Created by [mscrnt](https://github.com/mscrnt) [MIT](LICENSE) — Created by [mscrnt](https://github.com/mscrnt)

View File

@ -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()

View File

@ -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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

55
agentm/assets/logo.txt Normal file
View File

@ -0,0 +1,55 @@
%@%%%%%@%#
#%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%*++++++++++++%%%%%%%%%%@
%%%%%%%++++++++++++++++++++++++++%%%%%%%
%%%%%%++++++++++++++++++++++++++++++++++%%%%%@
%%%%%+++++++++++++++++++++++++++++++++++++++*%%%%%
%%%%%+++++++++@@@@@@@@@@@++++++++++++++++++++++++%%%%%
%%%%%+++++++++++@@@@@@@@@@@++++++++++++++++++++++++++%%%%@
%%%%++++++++++@@@@@@@@@@@@@@++*@@@@@@@@+++++++++++++++++%%%%
%%%%++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@++++++++++++++++++%%%%#
%%%%+++++++++++++@@@@@@@@@@@@@@+=@@@@@@@@@+++@@@@@@+++++++++++%%%@
%%%%++++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@+++@@@@@@++++++++++++%%%%
%%%%+++++++++++++++++++++++++++++++@@@@@@@@@++@@@@@@@++@@@@@@+++++%%%@
@%%%++++++%@@@@@@@@@@@@@@@@@@@@@@@+*@@@@@@@@@+*@@@@@@@+*@@@@@@@+++++%%%
%%%+++++++@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@++@@@@@@@++++++%%%
%%%%++++++*@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+=@@@@@@@++++++%%%%
%%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+@@@@@@@@+++++++%%%
%%%%++++++=@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++%%%@
%%%+++++++@@@@@@@@@@@@+++++++++++++++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++#%%%
%%%++++++=@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@+=@@@@@@%+@@@@@@@@++++++++%%%
%%%++++++@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@++@@@@@@@++@@@@@@@@++++++++%%%
#%%%+++++@@@@@@@@@@@@@@++++++++*+++==@@@@@@@@=++@@@@@@@++@@@@@@@++++++++++%%%
%%%++++++@@@@@@@@@@@@@@@@@@@@@@@@@#++++++++++++++++++++++++++++++++++++++%%%
%%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%
%%%+++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%%
%%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%%
%%%+++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%
%%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++%%%%
%%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%%
@%%%++++++++++++++%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%
%%%%+++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++++%%%@
%%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%@
%%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++++%%%@
@%%%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%%#
%%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%%
%%%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%%@
%%%%%*+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++%%%%%
%%%%%%+++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++%%%%%%
%%%%%%%+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%@
%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%%
@%%%%%%%%%%%@@@@@@@@@@%%%%%%%%%%%@
#%%%%%%%%%%%%%%%%%%%%%%%%#
*@%@@@@*

150
agentm/assets/styles.tcss Normal file
View 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
View 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()

View 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()
))

View 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
View 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

View File

@ -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

Binary file not shown.

BIN
agentm/roms/kof98umh.zip Normal file

Binary file not shown.

BIN
agentm/roms/mvsc.zip Normal file

Binary file not shown.

BIN
agentm/roms/qsound_hle.zip Normal file

Binary file not shown.

BIN
agentm/roms/samsh5sp.zip Normal file

Binary file not shown.

BIN
agentm/roms/sfiii3n.zip Normal file

Binary file not shown.

BIN
agentm/roms/soulclbr.zip Normal file

Binary file not shown.

BIN
agentm/roms/tektagt.zip Normal file

Binary file not shown.

BIN
agentm/roms/umk3.zip Normal file

Binary file not shown.

BIN
agentm/roms/xmvsf.zip Normal file

Binary file not shown.

81
agentm/utils/logger.py Normal file
View 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()}")

View File

@ -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
View 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}")

View File

@ -0,0 +1,10 @@
textual
rich
typer
diambra
diambra-arena
pyyaml
stable-baselines3
tensorboard
requests
sqlite3