"""Meeting Room CLI — main entry point. Refactored (v2): run_discussion() now creates a DiscussionEngine and subscribes to events with ANSI-color output instead of calling the legacy backend directly. """ import json import os import sys from datetime import datetime # Force UTF-8 output on Windows (cp1251 can't handle LLM unicode output) if sys.stdout.encoding != "utf-8": sys.stdout.reconfigure(encoding="utf-8", errors="replace") if sys.stderr.encoding != "utf-8": sys.stderr.reconfigure(encoding="utf-8", errors="replace") from meeting_room.api_client import APIClient from meeting_room.config import find_workspace_config, load_config, load_discussion_config from meeting_room.engine import DiscussionEngine from meeting_room.events import ( AGENT_ERROR, AGENT_MESSAGE, AGENT_TURN_START, DISCUSSION_END, DISCUSSION_START, ROUND_END, ROUND_START, TOOL_CALL, TOOL_RESULT, EventEmitter, ) from meeting_room.models import DiscussionConfig, ProviderConfig from meeting_room.scenario_loader import load_scenario from meeting_room.tools import ToolRegistry # ── ANSI colors ── COLORS = { "moderator": "\033[96m", "skeptic": "\033[91m", "idea_generator": "\033[92m", "analyst": "\033[93m", } TOOL_COLOR = "\033[90m" RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" def list_scenarios(scenarios_dir: str | None = None): """Print available scenarios to stdout.""" from meeting_room.scenario_loader import list_scenarios as _list_scenarios if scenarios_dir is None: scenarios_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios") items = _list_scenarios(scenarios_dir) if not items: print("No scenarios found. Create .md or .yaml in scenarios/") return print("\nAvailable scenarios:") print("-" * 40) for s in items: participants = ", ".join(s["participants"]) if s["participants"] else "-" print(f" {s['file']}") print(f" {s['name']}") print(f" Participants: {participants}") print() def list_roles(config: dict): """Print roles and model bindings to stdout.""" roles = config.get("roles", {}) providers = config.get("providers", {}) print("\nRoles and model bindings:") print("-" * 60) for role_id, role in roles.items(): prov = role.get("provider", "?") model = role.get("model", "?") temp = role.get("temperature", 0.7) tools = role.get("tools", "none") prov_url = providers.get(prov, {}).get("base_url", "?") print(f" {role['name']} ({role_id})") print(f" API: {prov} — {prov_url}") print(f" Model: {model}") print(f" Temperature: {temp}") print(f" Tools: {tools}") print() def init_workspace(path: str = "."): """Initialize a .meeting-room workspace in given directory.""" import yaml # noqa: avoid top-level import for speed target = os.path.abspath(path) ws_dir = os.path.join(target, ".meeting-room") if os.path.exists(ws_dir): print(f"Workspace already exists: {ws_dir}") return os.makedirs(os.path.join(ws_dir, "scenarios"), exist_ok=True) os.makedirs(os.path.join(ws_dir, "sessions"), exist_ok=True) # Write default config (overrides package defaults) default_config = { "providers": { "routerai": { "base_url": "${MEETING_ROOM_ROUTERAI_BASE_URL}", "api_key": "${MEETING_ROOM_ROUTERAI_API_KEY}", } }, "roles": { "moderator": { "name": "Moderator", "provider": "routerai", "model": "gpt-4o-mini", "temperature": 0.7, "tools": "none", "system_prompt": "You are the moderator of a discussion.", }, "skeptic": { "name": "Skeptic", "provider": "routerai", "model": "gpt-4o-mini", "temperature": 0.9, "tools": "readonly", "system_prompt": "You are a skeptic and critic.", }, "idea_generator": { "name": "Idea Generator", "provider": "routerai", "model": "gpt-4o-mini", "temperature": 1.0, "tools": "web", "system_prompt": "You are a creative idea generator.", }, "analyst": { "name": "Analyst", "provider": "routerai", "model": "gpt-4o-mini", "temperature": 0.5, "tools": "all", "system_prompt": "You are an analyst.", }, }, "defaults": { "max_rounds": 8, "framework": "custom", "workdir": "..", # parent of .meeting-room }, } config_path = os.path.join(ws_dir, "config.yaml") with open(config_path, "w") as f: yaml.dump(default_config, f, default_flow_style=False, allow_unicode=True) # Write example scenario example = """--- name: "Example Discussion" participants: - moderator - skeptic - idea_generator - analyst max_rounds: 6 --- # Problem Title Describe the problem here in Markdown... ## Context - Factor 1 - Factor 2 ## Questions 1. Question 1? 2. Question 2? """ with open(os.path.join(ws_dir, "scenarios", "example.md"), "w") as f: f.write(example) # Write .gitignore with open(os.path.join(ws_dir, ".gitignore"), "w") as f: f.write("sessions/\n") print(f"Workspace initialized: {ws_dir}") print(f""" Structure: {ws_dir}/ ├── config.yaml — override default config ├── scenarios/ — your .md scenario files │ └── example.md ├── sessions/ — saved discussion results (gitignored) └── .gitignore Usage: cd {ws_dir} meeting-room -s scenarios/example.md meeting-room -s scenarios/example.md --save """) def save_session(conversation: list[dict], scenario_name: str, sessions_dir: str = "sessions"): """Save discussion result to a JSON file.""" os.makedirs(sessions_dir, exist_ok=True) timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") safe_name = scenario_name.replace(" ", "-").replace("—", "-").lower() filename = f"{timestamp}_{safe_name}.json" filepath = os.path.join(sessions_dir, filename) session_data = { "timestamp": datetime.now().isoformat(), "scenario": scenario_name, "messages": conversation, } with open(filepath, "w", encoding="utf-8") as f: json.dump(session_data, f, ensure_ascii=False, indent=2) print(f"Session saved: {filepath}") return filepath # --------------------------------------------------------------------------- # Event handlers — produce the same ANSI output as the legacy backend # --------------------------------------------------------------------------- def _subscribe_cli_handlers(bus: EventEmitter, max_rounds: int) -> None: """Wire up event handlers that print ANSI-colored output to stdout. The output format matches the original backends/custom/run.py exactly. """ def on_discussion_start(event) -> None: data = event.data print(f"\n{BOLD}{'=' * 60}") print(f" MEETING ROOM — {data['name']}") print(f"{'=' * 60}{RESET}\n") # Print problem preview problem = data.get("problem", "") if problem: print(f"{BOLD}Problem:{RESET}") for line in problem.split("\n")[:5]: print(f" {line}") if problem.count("\n") > 5: print(" ...") print() # Print participants participants = data.get("participants", []) if participants: print(f"{BOLD}Participants:{RESET}") for name in participants: print(f" {name}") print() print(f"{BOLD}Rounds:{RESET} {data['rounds']}") print(f"{DIM}{'-' * 60}{RESET}\n") def on_round_start(event) -> None: data = event.data print(f"{BOLD}[Round {data['round_num']}/{data['total_rounds']}]{RESET}\n") def on_agent_turn_start(event) -> None: data = event.data color = COLORS.get(data["role_id"], "") print(f"{color}{BOLD} {data['name']}:{RESET}", end=" ") def on_agent_message(event) -> None: # Content is printed inline after the name; just end the line. print() def on_tool_call(event) -> None: data = event.data print(f"\n{TOOL_COLOR} -> {data['tool_name']}({data['arguments']}){RESET}") def on_tool_result(event) -> None: data = event.data preview = data["result_preview"] suffix = "..." if len(data.get("full_result", "")) > 200 else "" print(f"{TOOL_COLOR} <- {preview}{suffix}{RESET}") def on_agent_error(event) -> None: data = event.data # Error content is already printed inline by the name header; # just note the error type. print(f" [Error: {data['error']}]") def on_round_end(event) -> None: print(f"{DIM}{'-' * 60}{RESET}\n") def on_discussion_end(event) -> None: data = event.data print(f"\n{BOLD}{'=' * 60}") print(f" DISCUSSION COMPLETE — {data['total_messages']} messages") print(f"{'=' * 60}{RESET}\n") bus.on(DISCUSSION_START, on_discussion_start) bus.on(ROUND_START, on_round_start) bus.on(AGENT_TURN_START, on_agent_turn_start) bus.on(AGENT_MESSAGE, on_agent_message) bus.on(TOOL_CALL, on_tool_call) bus.on(TOOL_RESULT, on_tool_result) bus.on(AGENT_ERROR, on_agent_error) bus.on(ROUND_END, on_round_end) bus.on(DISCUSSION_END, on_discussion_end) def run_discussion(scenario: str, config: dict, save: bool = False): """Run a discussion through DiscussionEngine with ANSI event output. Parameters ---------- scenario : str Path or name of the scenario file. config : dict Raw config dict (as returned by :func:`load_config`). save : bool Whether to save the session to a JSON file. Returns ------- list[dict] Conversation in the legacy dict format ``[{role_id, name, content}, ...]``. """ scenario_path = scenario if os.path.isabs(scenario) else os.path.join(os.getcwd(), scenario) if not os.path.exists(scenario_path): # Try bundled scenarios scenario_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "scenarios", scenario ) if not os.path.exists(scenario_path): print(f"Scenario not found: {scenario}") sys.exit(1) # Convert raw config to typed DiscussionConfig discussion_config = load_discussion_config() # Apply any overrides from the raw config (e.g. workdir from CLI) if "defaults" in config and "workdir" in config["defaults"]: discussion_config.defaults.workdir = config["defaults"]["workdir"] # Build engine components providers = { name: ProviderConfig(**vals) for name, vals in config.get("providers", {}).items() } api_client = APIClient(providers=providers) tool_registry = ToolRegistry(workdir=discussion_config.defaults.workdir) event_bus = EventEmitter() # Subscribe CLI output handlers _subscribe_cli_handlers(event_bus, max_rounds=discussion_config.defaults.max_rounds) # Load scenario scenario_data = load_scenario(scenario_path) # Create and run engine engine = DiscussionEngine( config=discussion_config, api_client=api_client, tool_registry=tool_registry, events=event_bus, ) messages = engine.run(scenario_data) # Convert Message objects to legacy dict format conversation = [ {"role_id": m.role_id, "name": m.name, "content": m.content} for m in messages ] if save and conversation: # Determine sessions dir ws_config = find_workspace_config() if ws_config: sessions_dir = os.path.join(os.path.dirname(ws_config), "sessions") else: sessions_dir = os.path.join(os.getcwd(), "sessions") scenario_obj = load_scenario(scenario_path) save_session(conversation, scenario_obj.get("name", "discussion"), sessions_dir) return conversation def main(): """CLI entry point.""" import argparse parser = argparse.ArgumentParser( description="Meeting Room — Multi-Agent Discussion Framework", ) parser.add_argument( "-s", "--scenario", default="example-saas.md", help="Scenario file .md or .yaml", ) parser.add_argument( "-c", "--config", default=None, help="Path to custom config.yaml", ) parser.add_argument( "-w", "--workdir", default=None, help="Working directory for file tools", ) parser.add_argument( "--save", action="store_true", help="Save discussion result to sessions/", ) parser.add_argument( "--list-scenarios", action="store_true", help="List available scenarios", ) parser.add_argument( "--list-roles", action="store_true", help="List roles and model bindings", ) subparsers = parser.add_subparsers(dest="command") init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace") init_parser.add_argument("path", nargs="?", default=".", help="Where to create .meeting-room") serve_parser = subparsers.add_parser("serve", help="Start web server") serve_parser.add_argument("--host", default="0.0.0.0", help="Host to bind (default: 0.0.0.0)") serve_parser.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)") serve_parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development") args = parser.parse_args() # Handle init command if args.command == "init": init_workspace(args.path) return # Handle serve command if args.command == "serve": import uvicorn from meeting_room.server import app uvicorn.run( "meeting_room.server:app" if args.reload else app, host=args.host, port=args.port, reload=args.reload, ) return # Find config: CLI arg > .meeting-room/config.yaml > package default if args.config: config = load_config(args.config) else: ws_config = find_workspace_config() if ws_config: config = load_config(ws_config) else: config = load_config() if args.list_scenarios: list_scenarios() cwd_scenarios = os.path.join(os.getcwd(), "scenarios") if os.path.isdir(cwd_scenarios): list_scenarios(cwd_scenarios) # Also check .meeting-room ws_scenarios = os.path.join(os.getcwd(), ".meeting-room", "scenarios") if os.path.isdir(ws_scenarios): list_scenarios(ws_scenarios) return if args.list_roles: list_roles(config) return if args.workdir: config["defaults"]["workdir"] = args.workdir run_discussion(args.scenario, config, save=args.save) if __name__ == "__main__": main()