swarm / office/src/main.rs

raw file 19759 bytes
//! Office — an LLM-run company. Straight top-down, axis-aligned, barrier
//! stepping: every round all agents decide via LLM while the scene freezes,
//! then the whole scene steps 30 sim-minutes at once.
//!
//! The engine lives in lib.rs (src/*). This binary is the graphic window;
//! `--headless` runs the same loop with no renderer, paired with the TUI.

use office::{company, llm, panel, people, round, scene, sim, shared, tasks, verify};
use office::shared::{HumanInbox, UIFont};

use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;

use company::{CompanyCfg, Workspace};
use llm::LlmPool;
use people::{Memory, Person, Selected, Walking};
use round::{collect_results, gather_due};
use scene::{spawn_office, Spots};
use sim::*;
use tasks::TaskBoard;

fn main() {
    let mut company_dir = std::path::PathBuf::from("companies/starter");
    let mut use_remote = true;
    let mut split = false;
    let mut no_local = true; // home GPU idle by default; --local re-enables
    let mut coder_url = llm::LLAMA_CODER_URL.to_string();
    let mut coder_model = llm::LLAMA_CODER_MODEL.to_string();
    let mut headless = false;
    let mut all_27b = false;
    let mut args = std::env::args().skip(1);
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--no-remote" => use_remote = false,
            // remote-only is the default now; "--no-local" kept for old habits
            "--no-local" => no_local = true,
            // run models on the home machine (9070 / llama.cpp / local ollama)
            "--local" => no_local = false,
            // 2060 talks (decisions), local big model writes work logs.
            "--split" => split = true,
            // EVERYTHING on the home llama.cpp 27B (decide + write).
            "--27b" => {
                use_remote = false;
                split = true;
                no_local = false;
                all_27b = true;
            }
            "--coder-url" => coder_url = args.next().unwrap_or_default(),
            "--coder-model" => coder_model = args.next().unwrap_or_default(),
                    "--company" => {
                if let Some(dir) = args.next() {
                    company_dir = dir.into();
                }
            }
            "--headless" => headless = true,
            other if !other.starts_with("--") => company_dir = other.into(),
            other => {
                eprintln!(
                    "unknown flag: {other}\nusage: office [--company <dir>] [--split] [--no-local] \
                     [--no-remote]"
                );
                std::process::exit(2);
            }
        }
    }

    let cfg = CompanyCfg::load(&resolve_company_dir(&company_dir)).unwrap_or_else(|e| {
        eprintln!("error: {e}");
        std::process::exit(1);
    });
    let company_dir = resolve_company_dir(&company_dir);
    let workspace = Workspace::new(&company_dir).unwrap_or_else(|e| {
        eprintln!("error creating workspace: {e}");
        std::process::exit(1);
    });
    let board = TaskBoard::load_or_init(&workspace.root.join("tasks.json"), &cfg);
    info!(
        "company: {} — {} people, {} backlog tasks",
        cfg.company.name,
        cfg.people.len(),
        board.tasks.len()
    );

    // Day 1 starts with everyone gathered for standup.
    let mut events = CompanyEvents::default();
    events.meeting = Some(Meeting {
        topic: "Morning standup".into(),
        until: DAY_START + 30.0,
    });

    // Route: where each kind of job may run.
    let route = if all_27b {
        // Full 27B mode: every decision AND every write is the home llama.cpp
        // server (its own queue handles concurrency: 3 decide + 2 write).
        llm::Route {
            decide: vec![llm::Endpoint::new(&coder_url, &coder_model, 3)],
            write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)],
        }
    } else if split && use_remote {
        // Split mode: the 2060 does ALL talking/deciding, the local 27b
        // only writes work logs (heavier, fewer calls).
        llm::Route {
            decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3)],
            write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)],
        }
    } else if split && no_local {
        // Split, home GPU off: BOTH queues ride the 2060 (its Ollama queues
        // concurrent calls internally, so one endpoint is fine).
        llm::Route {
            decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3)],
            write: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 2)],
        }
    } else if split {
        // Split without remote: everything local (spark decides, 27b writes).
        llm::Route {
            decide: vec![llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 4)],
            write: vec![llm::Endpoint::new(&coder_url, &coder_model, 2)],
        }
    } else if use_remote && no_local {
        llm::Route {
            decide: vec![llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 6)],
            write: vec![],
        }
    } else if use_remote {
        // Shared: round-robin across both machines for every job.
        llm::Route {
            decide: vec![
                llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 4),
                llm::Endpoint::new(llm::REMOTE_URL, llm::REMOTE_MODEL, 3),
            ],
            write: vec![],
        }
    } else {
        llm::Route {
            decide: vec![llm::Endpoint::new(llm::LOCAL_URL, llm::LOCAL_MODEL, 6)],
            write: vec![],
        }
    };
    info!(
        "route: {} | {}",
        route
            .decide
            .iter()
            .map(|e| format!("decide ← {}", e.label()))
            .collect::<Vec<_>>()
            .join(", "),
        if route.write.is_empty() {
            "shared mode (work notes inline)".to_string()
        } else {
            route
                .write
                .iter()
                .map(|e| format!("write ← {}", e.label()))
                .collect::<Vec<_>>()
                .join(", ")
        }
    );

    // ── headless: same loop, no renderer — pairs with the office-tui bin ──
    if headless {
        let spots0 = scene::headless_spots(&cfg);
        App::new()
            .add_plugins(bevy::MinimalPlugins.set(bevy::app::ScheduleRunnerPlugin::run_loop(
                std::time::Duration::from_millis(50),
            )))
            .add_plugins(bevy::log::LogPlugin::default())
            .insert_resource(spots0)
            .insert_resource(SimClock(if board.sim_minutes > 0.01 {
                board.sim_minutes
            } else {
                DAY_START
            }))
            .insert_resource(RoundState {
                step_until: 0.0,
                ..default()
            })
            .insert_resource(events)
            .insert_resource(Phase::Stepping)
            .insert_resource(cfg)
            .insert_resource(workspace)
            .insert_resource(board)
            .insert_resource(Selected(None))
            .insert_resource(crate::shared::Bonds::default())
            .insert_resource(route)
            .add_systems(
                Startup,
                (people::spawn_people_headless, start_pool, start_stdin).chain(),
            )
            .add_systems(
                Update,
                (gather_due, collect_results, round::human_commands).chain(),
            )
            .run();
        return;
    }

    App::new()
        .insert_resource(ClearColor(Color::srgb(0.12, 0.12, 0.14)))
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: format!("Office — {}", cfg.company.name),
                resolution: (1600, 950).into(),
                ..default()
            }),
            ..default()
        }))
        .add_plugins(FrameTimeDiagnosticsPlugin::default())
        .insert_resource(SimClock(if board.sim_minutes > 0.01 {
            board.sim_minutes
        } else {
            DAY_START
        }))
        .insert_resource(RoundState {
            step_until: 0.0, // first gather fires immediately
            ..default()
        })
        .insert_resource(events)
        .insert_resource(Phase::Stepping)
        .insert_resource(cfg)
        .insert_resource(workspace)
        .insert_resource(board)
        .insert_resource(Selected(None))
        .insert_resource(crate::shared::Bonds::default())
        .init_resource::<panel::PanelState>()
        .insert_resource(route)
        .add_systems(
            Startup,
            (
                start_font,
                spawn_office,
                people::spawn_people,
                snap_to_standup_seats,
                start_pool,
                start_stdin,
            )
                .chain(),
        )
        .add_systems(
            Update,
            (
                gather_due,
                collect_results,
                crate::round::human_commands,
                move_people.after(gather_due),
                people::update_bubbles,
                click_select,
                hud,
                panel::panel,
            )
                .chain(),
        )
        .run();
}

/// Resolve a company dir relative to cwd first, then relative to the
/// executable (walking up a few levels) — so the sim runs from any cwd.
fn resolve_company_dir(given: &std::path::Path) -> std::path::PathBuf {
    if given.join("company.toml").exists() {
        return given.to_path_buf();
    }
    if let Ok(exe) = std::env::current_exe() {
        let mut dir = exe
            .parent()
            .map(|d| d.to_path_buf())
            .unwrap_or_else(|| std::path::PathBuf::from("/"));
        for _ in 0..6 {
            let candidate = dir.join(given);
            if candidate.join("company.toml").exists() {
                info!("company dir: {}", candidate.display());
                return candidate;
            }
            match dir.parent() {
                Some(p) => dir = p.to_path_buf(),
                None => break,
            }
        }
    }
    given.to_path_buf() // fall through; load() reports the real error
}

fn start_font(mut commands: Commands, server: Res<AssetServer>) {
    // AssetServer paths are relative to ./assets — copy the system font in,
    // then load it normally (absolute paths silently fail to resolve).
    let candidates = [
        "/usr/share/fonts/TTF/DejaVuSans.ttf",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "/usr/share/fonts/dejavu/DejaVuSans.ttf",
    ];
    let best = candidates
        .iter()
        .find(|p| std::path::Path::new(p).exists());
    match best {
        Some(font_path) => {
            // Bevy's asset root sits next to the executable, not the cwd.
            let exe_dir = std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|d| d.to_path_buf()))
                .unwrap_or_else(|| std::path::PathBuf::from("."));
            let local = exe_dir.join("assets/fonts/DejaVuSans.ttf");
            let _ = std::fs::create_dir_all(local.parent().unwrap());
            if !local.exists() {
                match std::fs::copy(font_path, &local) {
                    Ok(_) => info!("font copied to {}", local.display()),
                    Err(e) => warn!("font copy failed: {e}"),
                }
            }
            info!("font: {}", local.display());
            commands.insert_resource(UIFont(server.load("fonts/DejaVuSans.ttf")));
        }
        None => {
            warn!("DejaVu not found — non-ASCII glyphs will render as boxes");
            commands.insert_resource(UIFont(server.load("fonts/none.ttf")));
        }
    }
}

fn start_stdin(mut commands: Commands) {
    let (tx, rx) = std::sync::mpsc::channel::<String>();
    std::thread::spawn(move || {
        use std::io::BufRead;
        for line in std::io::stdin().lock().lines() {
            match line {
                Ok(l) => {
                    if tx.send(l).is_err() {
                        break;
                    }
                }
                Err(_) => break,
            }
        }
    });
    commands.insert_resource(HumanInbox(std::sync::Mutex::new(rx)));
    println!("manager console ready — type help for commands (sim runs without you)");
}

fn start_pool(mut commands: Commands, route: Res<llm::Route>, cfg: Res<CompanyCfg>) {
    let roster: Vec<(String, String, String)> = cfg
        .people
        .iter()
        .map(|p| (p.name.clone(), p.role.clone(), p.personality.clone()))
        .collect();
    llm::start_pool(&mut commands, route.clone(), roster);
}

/// One-shot: send everyone to their meeting seat for day-1 standup.
fn snap_to_standup_seats(spots: Res<Spots>, mut query: Query<(&Person, &mut Walking)>, mut done: Local<bool>) {
    if *done {
        return;
    }
    let mut seat = 0;
    for (_, mut walking) in &mut query {
        walking.0 = spots
            .meeting_seats
            .get(seat % spots.meeting_seats.len().max(1))
            .copied();
        seat += 1;
    }
    *done = true;
}

// ---------------------------------------------------------------------------
// Movement / selection / HUD
// ---------------------------------------------------------------------------

fn move_people(
    time: Res<Time>,
    phase: Res<Phase>,
    mut query: Query<(&mut Transform, &mut Walking)>,
) {
    if *phase != Phase::Stepping {
        return;
    }
    let dt = time.delta_secs();
    for (mut transform, mut walking) in query.iter_mut() {
        let Some(target) = walking.0 else { continue };
        let current = Vec2::new(transform.translation.x, transform.translation.z);
        let delta = target - current;
        let dist = delta.length();
        let speed = 300.0; // units/sec
        if dist <= speed * dt {
            transform.translation.x = target.x;
            transform.translation.z = target.y;
            walking.0 = None;
        } else {
            let step = delta.normalize() * speed * dt;
            transform.translation.x += step.x;
            transform.translation.z += step.y;
        }
    }
}



fn click_select(
    mouse: Res<ButtonInput<MouseButton>>,
    windows: Query<&Window, With<PrimaryWindow>>,
    camera: Query<(&Camera, &GlobalTransform), With<Camera3d>>,
    people: Query<(Entity, &Transform), With<Person>>,
    monitors: Query<(&Transform, &scene::DeskOwner), Without<Person>>,
    cfg: Res<CompanyCfg>,
    names: Res<people::Names>,
    mut selected: ResMut<Selected>,
) {
    if !mouse.just_pressed(MouseButton::Left) {
        return;
    }
    let Ok(window) = windows.single() else { return };
    let Some(cursor) = window.cursor_position() else { return };
    let Ok((cam, cam_t)) = camera.single() else { return };
    let Ok(ray) = cam.viewport_to_world(cam_t, cursor) else {
        return;
    };
    // Intersect the capsule's mid-height plane.
    let t = (scene::AGENT_Y - ray.origin.y) / ray.direction.y;
    if !t.is_finite() || t < 0.0 {
        return;
    }
    let point = ray.origin + *ray.direction * t;
    let mut best: Option<(Entity, f32)> = None;
    for (entity, transform) in &people {
        let d = scene::flat(transform.translation).distance(scene::flat(point));
        if d < 30.0 && best.map(|(_, bd)| d < bd).unwrap_or(true) {
            best = Some((entity, d));
        }
    }
    // Desk monitors are click targets too: clicking a machine selects the
    // person it belongs to (work browser folder 7 = their computer).
    for (transform, owner) in &monitors {
        let d = scene::flat(transform.translation).distance(scene::flat(point));
        if d < 34.0 && best.map(|(_, bd)| d < bd).unwrap_or(true) {
            // resolve person name by index
            if let Some(p) = cfg.people.get(owner.0) {
                if let Some(&entity) = names.0.get(&p.name.to_lowercase()) {
                    best = Some((entity, d));
                }
            }
        }
    }
    selected.0 = best.map(|(e, _)| e);
}

#[derive(Component)]
struct HudText;

#[allow(clippy::too_many_arguments)]
fn hud(
    mut commands: Commands,
    font: Res<UIFont>,
    diagnostics: Res<DiagnosticsStore>,
    clock: Res<SimClock>,
    phase: Res<Phase>,
    round: Res<RoundState>,
    pool: Res<LlmPool>,
    cfg: Res<CompanyCfg>,
    board: Res<TaskBoard>,
    events: Res<CompanyEvents>,
    selected: Res<Selected>,
    people: Query<(&Person, &Memory, &Transform)>,
    mut texts: Query<&mut Text, With<HudText>>,
) {
    let fps = diagnostics
        .get(&FrameTimeDiagnosticsPlugin::FPS)
        .and_then(|d| d.smoothed())
        .unwrap_or(0.0);

    let phase_str = match *phase {
        Phase::Computing => {
            format!("COMPUTING — {} pending, scene frozen", round.pending.len())
        }
        Phase::Stepping => "STEPPING — scene live".to_string(),
    };

    let mut text = format!(
        "{} | Day {} {} | {}\nround {} | fps {:.0} | decisions ok {} fail {}\n",
        cfg.company.name,
        clock.day(),
        clock.hhmm(),
        phase_str,
        round.rounds,
        fps,
        pool.completed,
        pool.failed,
    );
    if let Some(m) = &events.meeting {
        text.push_str(&format!("meeting: \"{}\"\n", m.topic));
    }
    text.push_str(
        "you (as manager): assign T7 \"Priya Nair\" 3 | review T3 approve ok | meeting topic\n",
    );
    text.push('\n');

    let done = board
        .tasks
        .iter()
        .filter(|t| t.status == tasks::TaskStatus::Done)
        .count();
    text.push_str(&format!("tasks: {}/{} done\n", done, board.tasks.len()));
    for t in board
        .tasks
        .iter()
        .filter(|t| !matches!(t.status, tasks::TaskStatus::Done | tasks::TaskStatus::Backlog))
    {
        let who = t.assignee.as_deref().unwrap_or("-");
        text.push_str(&format!(
            "  {} {} {}% {} (due d{}){}\n",
            t.id,
            who,
            t.progress,
            t.status.label(),
            t.due_day.unwrap_or(0),
            t.concern
                .as_ref()
                .map(|c| format!("\n      ⚠ {c}"))
                .unwrap_or_default()
        ));
    }

    if let Some(entity) = selected.0 {
        if let Ok((person, memory, transform)) = people.get(entity) {
            text.push_str(&format!(
                "\n── {} ({}) @ {},{} ──\n",
                person.name,
                person.role,
                transform.translation.x as i32,
                transform.translation.z as i32
            ));
            text.push_str(&board.my_work(&person.name));
            text.push('\n');
            for m in memory.recent(6) {
                text.push_str(&format!("  • {m}\n"));
            }
        }
    }

    match texts.single_mut() {
        Ok(mut t) => t.0 = text,
        Err(_) => {
            commands.spawn((
                Text::new(text),
                TextFont {
                    font: font.0.clone().into(),
                    font_size: 13.0.into(),
                    ..default()
                },
                TextColor(Color::srgba(0.92, 0.95, 1.0, 0.96)),
                BackgroundColor(Color::srgba(0.05, 0.06, 0.1, 0.85)),
                Node {
                    position_type: PositionType::Absolute,
                    left: Val::Px(10.0),
                    top: Val::Px(10.0),
                    padding: UiRect::all(Val::Px(10.0)),
                    max_width: Val::Px(470.0),
                    ..default()
                },
                HudText,
            ));
        }
    }
}