swarm / office/src/llm.rs

raw file 40787 bytes
//! LLM pool across two Ollama endpoints + decision schema + prompt building.
//!
//! Two routing modes:
//!   Shared (default) — every job round-robins across all workers on both
//!           machines; work notes come inline from the decision call.
//!   Split   (`--split`) — decisions/social calls go ONLY to the remote 2060
//!           (small talker model); every `work` action spawns a second call
//!           to the local big coding model that writes the actual work-log
//!           content. The barrier waits for both.

use bevy::prelude::*;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use crate::company::{CompanyCfg, WriteKind};

pub const LOCAL_URL: &str = "http://localhost:11434";
pub const LOCAL_MODEL: &str = "spark-x2.5-4b";
pub const REMOTE_URL: &str = "http://192.168.4.22:11434";
pub const REMOTE_MODEL: &str = "qwen3.5:4b";
/// Local big model for work-log writing in split mode (16.6 GB — one or two
/// concurrent calls on a 16 GB card; Ollama queues the rest internally).
pub const CODER_MODEL: &str = "qwen3.8-27b-64k";
/// llama.cpp assignment server (OpenAI-compatible, auto-detected).
pub const LLAMA_CODER_URL: &str = "http://localhost:8085";
pub const LLAMA_CODER_MODEL: &str = "Qwen3.8-27B-GSQ-RCO-IQ3_XXS-mtp.gguf";

const NUM_PREDICT: u32 = 350;
const WRITE_NUM_PREDICT: u32 = 2800; // truncated FILE blocks were the hotfix of day 3

#[derive(Clone, Debug)]
pub struct Endpoint {
    pub url: String,
    pub model: String,
    pub workers: usize,
}

impl Endpoint {
    pub fn new(url: &str, model: &str, workers: usize) -> Self {
        Self {
            url: url.into(),
            model: model.into(),
            workers,
        }
    }

    pub fn label(&self) -> String {
        format!("{} [{}]", self.url, self.model)
    }
}

/// Where each kind of job is allowed to run.
#[derive(Resource, Clone)]
pub struct Route {
    pub decide: Vec<Endpoint>,
    /// Empty in shared mode → no work-content expansion.
    pub write: Vec<Endpoint>,
}

impl Route {
    pub fn is_split(&self) -> bool {
        !self.write.is_empty()
    }
}

pub struct Job {
    pub person: String,
    /// Task id — only set for Write jobs.
    pub task: String,
    /// What the coder produces when this is a Write job.
    pub write_kind: WriteKind,
    pub system: String,
    pub user: String,
    /// Attached frame image (vision teams' `look`): absolute path to a PNG.
    pub image: Option<String>,
}

pub enum JobResult {
    Decide {
        person: String,
        action: Result<Action, String>,
        elapsed: f32,
        endpoint: String,
        /// Verbatim dialogue transcript when action == Discuss.
        dialogue: Option<DialogueLog>,
    },
    /// Written content from the coder model (split mode).
    Write {
        person: String,
        task: String,
        write_kind: WriteKind,
        content: Result<String, String>,
        elapsed: f32,
        endpoint: String,
    },
}

pub struct DecisionResult {
    pub person: String,
    pub action: Result<Action, String>,
    pub elapsed: f32,
    pub endpoint: String,
    /// A real multi-turn desk conversation (Action::Discuss), captured
    /// verbatim so apply can give BOTH parties the memory of it.
    pub dialogue: Option<DialogueLog>,
}

/// Verbatim turns of a desk conversation: (speaker, line).
#[derive(Clone, Debug)]
pub struct DialogueLog {
    pub other: String,
    pub turns: Vec<(String, String)>,
    pub summary: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Action {
    GoTo {
        // default "" → walks to own desk; qwen3.5 often omits place
        #[serde(default)]
        place: String,
        #[serde(default)]
        reason: String,
    },
    Work {
        task: String,
        #[serde(default)]
        note: String,
        /// Repo files actually written this session (split mode; skipped in serde).
        #[serde(skip)]
        files: Vec<(String, String)>,
    },
    Email {
        to: String,
        subject: String,
        body: String,
    },
    Talk {
        to: String,
        /// alias: qwen3.5 sends "topic" for talk utterances
        #[serde(alias = "topic")]
        message: String,
    },
    Submit {
        task: String,
        #[serde(default)]
        summary: String,
    },
    /// Manager only: pull a backlog task and give it to someone, with a due day.
    Assign {
        task: String,
        to: String,
        /// Omitted → day + task.effort_days
        due_day: Option<u32>,
    },
    /// MANAGER ONLY, once at the start: align the company on what to build.
    Kickoff {
        #[serde(default)]
        plan: String,
    },
    /// USER ONLY (the boss): a direct message routed into someone's prompt;
    /// applied by human_commands without touching an agent at all.
    BossChat {
        to: String,
        message: String,
    },
    /// Manager only: approve (Done) or send back (ChangesRequested) + feedback mail.
    Review {
        task: String,
        /// qwen3.5 tends to answer everything via `topic` when unsure
        #[serde(default)]
        approve: Option<bool>,
        #[serde(default, alias = "note")]
        feedback: String,
        #[serde(default, alias = "message")]
        topic: String,
    },
    /// Manager only: call everyone to the meeting room.
    Meeting {
        #[serde(default)]
        topic: String,
    },
    /// Employee: walk over to a colleague's desk (if not there) and have a
    /// REAL back-and-forth conversation about a topic. Both people get the
    /// full transcript in their memories afterwards.
    Discuss {
        to: String,
        topic: String,
    },
    /// VLM spot-check: look at captured frame and speak about it.
    Look {
        /// what to check about the frame (the user's ask or own thought)
        #[serde(default)]
        about: String,
        /// what the VLM saw (filled at collect time)
        #[serde(default)]
        note: String,
    },
    /// Employee: push back on an unrealistic deadline/task.
    RaiseConcern {
        task: String,
        message: String,
    },
    Rest {
        #[serde(default)]
        reason: String,
    },
    Idle {
        #[serde(default)]
        reason: String,
    },
}

#[derive(Resource)]
pub struct LlmPool {
    pub decide_tx: Sender<Job>,
    /// Only present in split mode.
    pub write_tx: Option<Sender<Job>>,
    pub results_rx: Mutex<Receiver<JobResult>>,
    pub in_flight: usize,
    pub completed: u64,
    pub failed: u64,
    pub endpoints: Vec<String>,
}

pub fn start_pool(commands: &mut Commands, route: Route, roster: Vec<(String, String, String)>) {
    let _ = ROSTER.set(roster);
    let (decide_tx, decide_rx) = channel::<Job>();
    let (write_opt, write_rx) = match &route.write {
        w if w.is_empty() => (None, None),
        _ => {
            let (tx, rx) = channel::<Job>();
            (Some(tx), Some(rx))
        }
    };
    let (results_tx, results_rx) = channel::<JobResult>();
    let mut endpoints = Vec::new();

    let decide_rx = Arc::new(Mutex::new(decide_rx));
    for ep in &route.decide {
        endpoints.push(format!("decide ← {}", ep.label()));
        for _ in 0..ep.workers {
            spawn_worker(Arc::clone(&decide_rx), results_tx.clone(), ep, Kind::Decide);
        }
    }
    if let (Some(rx), Some(first)) = (write_rx, route.write.first()) {
        let rx = Arc::new(Mutex::new(rx));
        for ep in &route.write {
            endpoints.push(format!("write  ← {}", ep.label()));
            for _ in 0..ep.workers {
                spawn_worker(Arc::clone(&rx), results_tx.clone(), ep, Kind::Write);
            }
        }
        let _ = first;
    }

    commands.insert_resource(LlmPool {
        decide_tx,
        write_tx: write_opt,
        results_rx: Mutex::new(results_rx),
        in_flight: 0,
        completed: 0,
        failed: 0,
        endpoints,
    });
}

#[derive(Clone, Copy)]
enum Kind {
    Decide,
    Write,
}

/// Roster for improvising conversation partners in the worker thread.
/// Set once at pool start; workers read-only.
static ROSTER: std::sync::OnceLock<Vec<(String, String, String)>> = std::sync::OnceLock::new();

/// A live back-and-forth between two personas. Runs several plain-text
/// calls on the SAME endpoint (already inside this worker thread), logs
/// verbatim turns, then a one-line agreement summary both parties will
/// remember.
fn run_discussion(
    client: &reqwest::blocking::Client,
    url: &str,
    model: &str,
    me_system: &str,
    me_name: &str,
    to: &str,
    topic: &str,
    api_kind: &mut ApiKind,
) -> Option<DialogueLog> {    let roster = ROSTER.get()?;
    let (p_name, p_role, p_personality) = roster
        .iter()
        .find(|(n, _, _)| n.eq_ignore_ascii_case(to))?;

    let partner_system = format!(
        "You are {p_name}, {p_role}. Personality: {p_personality}\n\
         A colleague walked over to your desk. Reply in character: 1-2 natural \
         sentences, no stage directions, no quoting. Stay constructive."
    );

    let mut turns: Vec<(String, String)> = Vec::new();
    let transcript = |turns: &[(String, String)]| -> String {
        turns
            .iter()
            .map(|(who, line)| format!("{who}: {line}\n"))
            .collect()
    };

    for i in 0..4 {
        let speaker = if i % 2 == 0 { me_name } else { p_name };
        let (system, prefix) = if i % 2 == 0 {
            (
                me_system.to_string(),
                format!(
                    "Turn {} of a live desk conversation between you and {to} about: \"{topic}\".\n\
                     Transcript so far:\n{}\nYour next line (1-2 sentences, in character):",
                    i + 1,
                    transcript(&turns)
                ),
            )
        } else {
            (
                partner_system.clone(),
                format!(
                    "Turn {} — {me_name} walks to your desk to talk about \"{topic}\".\n\
                     Transcript so far:\n{}\nYour next reply (1-2 sentences, in character, be candid):",
                    i + 1,
                    transcript(&turns)
                ),
            )
        };
        // Reuse the writer's dual-dialect plumbing for plain text turns.
        let line = request_write(client, url, model, &system, &prefix, api_kind, None);
        match line {
            Ok(text) => {
                let text: String = text
                    .lines()
                    .filter(|l| {
                        let t = l.trim().to_lowercase();
                        !(t.starts_with("turn ")
                            || t.starts_with("transcript")
                            || t.starts_with("your "))
                    })
                    .collect::<Vec<_>>()
                    .join(" ")
                    .trim()
                    .chars()
                    .take(220)
                    .collect();
                if text.is_empty() {
                    return None;
                }
                turns.push((speaker.to_string(), text.clone()));
                crate::activity::turn(me_name, speaker, &text);
            }
            Err(_) => return None, // transport/policy failure → caller falls back
        }
    }

    let summary_system = me_system.to_string();
    let summary = match request_write(
        client,
        url,
        model,
        &summary_system,
        &format!(
            "One sentence, plain prose: what did you and {to} agree on about \"{topic}\"?\nTranscript:\n{}",
            transcript(&turns)
        ),
        api_kind,
        None,
    ) {
        Ok(s) => s.chars().take(160).collect(),
        Err(_) => turns
            .last()
            .map(|(_, line)| line.chars().take(120).collect())
            .unwrap_or_default(),
    };

    Some(DialogueLog { other: p_name.to_string(), turns, summary })
}

fn spawn_worker(rx: Arc<Mutex<Receiver<Job>>>, tx: Sender<JobResult>, ep: &Endpoint, kind: Kind) {
    let (url, model) = (ep.url.clone(), ep.model.clone());
    std::thread::spawn(move || worker_loop(rx, tx, &url, &model, kind));
}

fn worker_loop(
    jobs_rx: Arc<Mutex<Receiver<Job>>>,
    results_tx: Sender<JobResult>,
    url: &str,
    model: &str,
    kind: Kind,
) {
    let client = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(300))
        .build()
        .expect("http client");
    let mut consecutive_failures = 0usize;
    let mut api_kind = ApiKind::Detect;
    loop {
        let job = {
            let rx = jobs_rx.lock().unwrap_or_else(|e| e.into_inner());
            rx.recv()
        };
        let Ok(job) = job else { break };
        let endpoint = format!("{url} [{model}]");
        let started = Instant::now();
        let elapsed = || started.elapsed().as_secs_f32();

        // Send FIRST: every job must produce exactly one result or the
        // barrier round would hang waiting for it. Only transport errors
        // (endpoint down) trigger backoff — a model emitting malformed JSON
        // is not an unhealthy endpoint.
        let transport_err: Option<String> = match kind {
            Kind::Decide => {
                crate::activity::started(&job.person, false, &endpoint);
                let mut action = request_action(&client, url, model, &job.system, &job.user, &mut api_kind);
                crate::activity::finish(&job.person, false, action.is_ok(), "…");
                let mut dialogue: Option<DialogueLog> = None;
                // A real back-and-forth: run it synchronously NOW (still in
                // this worker thread), before the barrier result ships. If
                // dialoguing fails (transport oddity), fall back to a normal
                // one-shot talk so the round never stalls.
                if let Ok((Action::Discuss { to, topic }, _)) = &action {
                    match run_discussion(
                        &client, url, model, &job.system, &job.person, to, topic, &mut api_kind,
                    ) {
                        Some(dlg) => dialogue = Some(dlg),
                        None => action = Ok((Action::Talk { to: to.clone(), message: topic.clone() }, String::new())),
                    }
                }
                let (action, raw_text) = match action {
                    Ok((a, raw)) => (Ok(a), raw),
                    Err(e) => (Err(e), String::new()),
                };
                crate::activity::finish(&job.person, false, action.is_ok(), &raw_text);
                let err = action.as_ref().err().cloned();
                let _ = results_tx.send(JobResult::Decide {
                    person: job.person,
                    action,
                    elapsed: elapsed(),
                    endpoint: endpoint.clone(),
                    dialogue,
                });
                err
            }
            Kind::Write => {
                let key = format!("{} · write", job.person);
                crate::activity::started(&job.person, true, &endpoint);
                let content =
                    request_write(&client, url, model, &job.system, &job.user, &mut api_kind, job.image.as_deref());
                crate::activity::finish(
                    &key,
                    false,
                    content.is_ok(),
                    content.as_deref().unwrap_or("(failed)"),
                );
                let err = content.as_ref().err().cloned();
                let _ = results_tx.send(JobResult::Write {
                    person: job.person.clone(),
                    task: job.task.clone(),
                    write_kind: job.write_kind,
                    content,
                    elapsed: elapsed(),
                    endpoint: endpoint.clone(),
                });
                err
            }
        };
        let unhealthy = transport_err
            .as_deref()
            .is_some_and(|e| e.starts_with("http:") || e.starts_with("status"));

        if unhealthy {
            consecutive_failures += 1;
            // Never quit: in split mode this queue has no other consumer, so
            // a worker that exits strands every job until the deadman fires.
            if consecutive_failures >= 3 {
                eprintln!("[llm] endpoint {endpoint} unhealthy; backing off 5s");
                std::thread::sleep(Duration::from_secs(5));
                consecutive_failures = 0;
            }
        } else {
            consecutive_failures = 0;
        }
    }
}

fn request_action(
    client: &reqwest::blocking::Client,
    url: &str,
    model: &str,
    system: &str,
    user: &str,
    kind: &mut ApiKind,
) -> Result<(Action, String), String> {
    if *kind != ApiKind::Ollama {
        match action_via_ollama(client, url, model, system, user) {
            Ok(pair) => {
                *kind = ApiKind::Ollama;
                return Ok(pair);
            }
            Err(e) if *kind == ApiKind::Detect && e.starts_with("status 404") => {
                *kind = ApiKind::OpenAi;
            }
            Err(e) if *kind != ApiKind::Detect => {
                return action_via_openai(client, url, model, system, user)
            }
            Err(e) => return Err(e),
        }
    }
    action_via_openai(client, url, model, system, user)
}

fn action_via_ollama(
    client: &reqwest::blocking::Client,
    url: &str,
    model: &str,
    system: &str,
    user: &str,
) -> Result<(Action, String), String> {    let schema = json!({
        "type": "object",
        "properties": {
            "action": {"type": "string", "enum": [
                "go_to", "work", "email", "talk", "discuss", "submit", "assign",
                "review", "meeting", "kickoff", "raise_concern", "rest", "idle",
                "look"]},
            "place": {"type": "string", "description": "desk | meeting | break"},
            "reason": {"type": "string"},
            "task": {"type": "string", "description": "task id like T3"},
            "note": {"type": "string", "description": "work progress note / work content written to your folder"},
            "to": {"type": "string", "description": "person name"},
            "subject": {"type": "string"},
            "body": {"type": "string"},
            "message": {"type": "string", "description": "1-3 full natural sentences, in character. Never keywords or fragments."},
            "topic": {"type": "string", "description": "for discuss: what you two should talk through"},
            "summary": {"type": "string"},
            "due_day": {"type": "integer"},
            "approve": {"type": "boolean"},
            "feedback": {"type": "string"},
        },
        "required": ["action"]
    });

    let body = json!({
        "model": model,
        "stream": false,
        "think": false,
        "format": schema,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        "options": {
            "temperature": 0.8,
            "num_predict": NUM_PREDICT,
            "num_ctx": 8192,
            "keep_alive": "30m",
        }
    });

    let resp = client
        .post(format!("{url}/api/chat"))
        .json(&body)
        .send()
        .map_err(|e| format!("http: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("status {}", resp.status()));
    }
    let value: serde_json::Value = resp.json().map_err(|e| format!("json: {e}"))?;
    let content = value["message"]["content"].as_str().unwrap_or_default();
    let content = content
        .trim()
        .trim_start_matches("```json")
        .trim_start_matches("```")
        .trim_end_matches("```")
        .trim();
    serde_json::from_str::<Action>(content)
        .map(|a| (a, content.to_string()))
        .map_err(|e| format!("{e}: {content:.140}"))
}

/// OpenAI-compatible decisions (llama.cpp serving the 27B): prompt carries
/// the JSON contract; we strip code fences before parsing.
fn action_via_openai(
    client: &reqwest::blocking::Client,
    url: &str,
    model: &str,
    system: &str,
    user: &str,
) -> Result<(Action, String), String> {
    let body = json!({
        "model": model,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": format!("{user}\n\nReply with ONLY the JSON object described. No prose, no markdown fences.")},
        ],
        "temperature": 0.7,
        "max_tokens": NUM_PREDICT,
        "stream": false,
    });
    let resp = client
        .post(format!("{}/v1/chat/completions", url.trim_end_matches('/')))
        .json(&body)
        .send()
        .map_err(|e| format!("http: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("status {}", resp.status()));
    }
    let v: serde_json::Value = resp.json().map_err(|e| format!("json: {e}"))?;
    let text = v["choices"][0]["message"]["content"]
        .as_str()
        .unwrap_or_default()
        .trim()
        .trim_start_matches("```json")
        .trim_start_matches("```")
        .trim_end_matches("```")
        .trim();
    serde_json::from_str::<Action>(text)
        .map(|a| (a, text.to_string()))
        .map_err(|e| format!("{e}: {text:.140}"))
}

/// Which HTTP dialect an endpoint speaks. Detected on the first write call:
/// llama.cpp/vLLM answer 404 on Ollama's /api/chat and get switched to the
/// OpenAI-compatible route permanently for that worker.
#[derive(Clone, Copy, PartialEq)]
enum ApiKind {
    Detect,
    Ollama,
    OpenAi,
}

/// The coder model's job: turn a work intent into a real work-log entry.
/// Free text (no JSON) — this is the content that lands in the work folder.
/// Vision jobs get the frame attached as a data-URI image (OpenAI chat).
fn request_write(
    client: &reqwest::blocking::Client,
    url: &str,
    model: &str,
    system: &str,
    user: &str,
    kind: &mut ApiKind,
    image: Option<&str>,
) -> Result<String, String> {
    if *kind != ApiKind::OpenAi {
        match if image.is_some() {
            Err("status 404".to_string()) // ollama-compat with images unsupported → straight openai
        } else {
            write_via_ollama(client, url, model, system, user)
        } {
            Ok(text) => {
                *kind = ApiKind::Ollama;
                return Ok(text);
            }
            Err(e) if *kind == ApiKind::Detect && e.starts_with("status 404") => {
                *kind = ApiKind::OpenAi; // llama.cpp server → try OpenAI route
            }
            Err(e) => return Err(e),
        }
    }
    write_via_openai(client, url, model, system, user, image)
}

/// llama.cpp / vLLM / anything OpenAI-compatible (POST /v1/chat/completions).
/// `image_path` attaches a real frame (vision spot-check).
fn write_via_openai(
    client: &reqwest::blocking::Client,
    url: &str,
    model: &str,
    system: &str,
    user: &str,
    image_path: Option<&str>,
) -> Result<String, String> {
    let messages = match image_path {
        Some(path) => {
            let bytes = std::fs::read(path).map_err(|e| format!("image: {e}"))?;
            let b64 = encode_b64(&bytes);
            json!([
                {"role": "system", "content": system},
                {"role": "user", "content": [
                    {"type": "text", "text": user},
                    {"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{b64}")}}
                ]}
            ])
        }
        None => json!([
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ]),
    };
    let body = json!({
        "model": model,
        "messages": messages,
        "temperature": 0.6,
        "max_tokens": WRITE_NUM_PREDICT,
        "stream": false,
    });
    let resp = client
        .post(format!("{}/v1/chat/completions", url.trim_end_matches('/')))
        .json(&body)
        .send()
        .map_err(|e| format!("http: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("status {}", resp.status()));
    }
    let v: serde_json::Value = resp.json().map_err(|e| format!("json: {e}"))?;
    let text = v["choices"][0]["message"]["content"]
        .as_str()
        .unwrap_or_default();
    let text = text.trim();
    if text.is_empty() {
        return Err("empty content".into());
    }
    Ok(text.to_string())
}

fn encode_b64(bytes: &[u8]) -> String {
    // minimal base64 (std has none): tiny, correct, no deps
    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
    for chunk in bytes.chunks(3) {
        let b0 = chunk[0] as u32;
        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
        let n = (b0 << 16) | (b1 << 8) | b2;
        out.push(TABLE[(n >> 18) as usize & 63] as char);
        out.push(TABLE[(n >> 12) as usize & 63] as char);
        out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' });
        out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' });
    }
    out
}

fn write_via_ollama(
    client: &reqwest::blocking::Client,
    url: &str,
    model: &str,
    system: &str,
    user: &str,
) -> Result<String, String> {
    let body = json!({
        "model": model,
        "stream": false,
        "think": false,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        "options": {
            "temperature": 0.6,
            "num_predict": WRITE_NUM_PREDICT,
            "num_ctx": 8192,
            "keep_alive": "1h",
        }
    });
    let resp = client
        .post(format!("{url}/api/chat"))
        .json(&body)
        .send()
        .map_err(|e| format!("http: {e}"))?;
    if !resp.status().is_success() {
        return Err(format!("status {}", resp.status()));
    }
    let value: serde_json::Value = resp.json().map_err(|e| format!("json: {e}"))?;
    let text = value["message"]["content"].as_str().unwrap_or_default();
    let text = text.trim();
    if text.is_empty() {
        return Err("empty content".into());
    }
    Ok(text.to_string())
}

/// Parse the writer protocol: `WORKLOG:` paragraph + `FILE: path` blocks.
/// Bare ```fence lines inside file bodies are dropped. Paths sanitized.
pub fn parse_written(raw: &str) -> (String, Vec<(String, String)>) {
    let mut entry = String::new();
    let mut in_entry = false;
    let mut files: Vec<(String, String)> = Vec::new();
    let mut cur_path: Option<String> = None;
    let mut cur = String::new();

    let mut flush = |files: &mut Vec<(String, String)>,
                 cur_path: &mut Option<String>,
                 cur: &mut String| {
        if let Some(p) = cur_path.take() {
            let mut lines: Vec<&str> = cur.lines().collect();
            while lines.first().is_some_and(|l| l.trim().starts_with("```")) {
                lines.remove(0);
            }
            while lines.last().is_some_and(|l| l.trim().starts_with("```")) {
                lines.pop();
            }
            let body = lines.join("\n");
            if !body.trim().is_empty() && files.len() < 5 {
                files.push((p, body));
            }
        }
        cur.clear();
    };

    for line in raw.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("WORKLOG:") {
            flush(&mut files, &mut cur_path, &mut cur);
            if entry.is_empty() {
                entry = trimmed["WORKLOG:".len()..].trim().to_string();
            }
            in_entry = true;
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("FILE:") {
            flush(&mut files, &mut cur_path, &mut cur);
            // Fences the writer wraps file bodies in (```rust ...) are
            // stripped at start/end inside flush().
            in_entry = false;
            cur_path = crate::company::sanitize_repo_path(rest);
            continue;
        }
        if cur_path.is_some() {
            if trimmed == "```" {
                continue; // stray fence marker
            }
            if cur.len() < 8000 {
                cur.push_str(line);
                cur.push('\n');
            }
        } else if in_entry && entry.len() < 900 {
            entry.push(' ');
            entry.push_str(trimmed);
        }
    }
    flush(&mut files, &mut cur_path, &mut cur);
    (entry.trim().to_string(), files)
}

/// Prompt for the coder model when expanding a `work` decision (split mode).
pub fn write_prompts(
    cfg: &CompanyCfg,
    person_name: &str,
    kind: crate::company::WriteKind,
    subject: &str,
    detail: &str,
    extra: &str,
    intent: &str,
    plan: &str,
) -> (String, String) {
    use crate::company::WriteKind;
    let person = cfg.people.iter().find(|p| p.name == person_name);
    let who = person
        .map(|p| format!("{} ({})", p.name, p.role))
        .unwrap_or_else(|| person_name.to_string());
    let personality = person
        .map(|p| p.personality.as_str())
        .unwrap_or("professional");
    let roster = roster_text(cfg);

    match kind {
        // ---- Day-1 plan: the company decides its product before coding ----
        WriteKind::Plan => {
            let system = format!(
                "You are {who}, engineering manager of {company}.\n\
                 Mission: {mission}\nProduct: {product}\nStack: {stack}\n\n\
                 Today the company starts from ZERO: no repo, no plan, nothing built. \
                 Write the one-page plan.md the whole team will work from. 250-400 words, \
                 plain Markdown, exactly these sections:\n\n\
                 ## Product\n<what we ship, for whom, and why — 2-3 concrete sentences>\n\
                 ## Scope\n<what is IN this sprint by task id (T1..T10), what is OUT>\n\
                 ## Architecture\n<the repo structure we will create, component bullets>\n\
                 ## Assignments\n<who starts on what: person and task id>\n\n\
                 Decisive and concrete: the team codes directly against this file. \
                 Output ONLY the Markdown document.",
                who = who,
                company = cfg.company.name,
                mission = cfg.company.mission,
                product = cfg.company.product,
                stack = cfg.company.stack,
            );
            let user = format!(
                "Opening direction from the standup discussion:\n{intent}\n\n\
                 Backlog for scope reference:\n{extra}\n\nWrite plan.md now."
            );
            (system, user)
        }

        // ---- Email body: substance instead of "Morning Standup" ----
        WriteKind::Email => {
            let system = format!(
                "You are {who} at {company}.\nPersonality: {personality}\n\n\
                 Write the body of an email you are sending right now. 120-250 words, \
                 first person, fully in character: reference the actual work (task ids, \
                 repo file paths, numbers), ask what you need answered, end with a short \
                 sign-off line. Output ONLY the body text — no Subject line, no headers, \
                 no greeting block.",
                who = who,
                company = cfg.company.name,
                personality = personality,
            );
            let user = format!(
                "To: {subject}\nAbout: {detail}\n\
                 Your notes on what to say:\n{intent}\n\n\
                 Company plan (context):\n{plan}\n\n\
                 You may ONLY reference these coworkers:\n{roster}\n\nWrite the email body now."
            );
            (system, user)
        }

        // ---- Task work: work log entry + real repo files ----
        WriteKind::WorkLog => {
            let system = format!(
                "You are {who} at {company}, working task {subject}.\n\
                 Mission: {mission}\nWe build with: {stack}\nPersonality: {personality}\n\n\
                 Company plan (the source of truth):\n{plan}\n\n\
                 Your output becomes TWO things in exactly this format — nothing before, \
                 nothing after:\n\n\
                 WORKLOG: <one tight paragraph, max 120 words, first person>\n\
                 FILE: <relative repo path>\n\
                 <complete file contents>\n\
                 FILE: <another relative path>\n\
                 <complete file contents>\n\n\
                 Rules: 1-3 REAL files, fully written, using our stack — code the feature, \
                 or its tests, or its migration/doc; no placeholders, no TODOs; paths like \
                 src/sync/batch.py or docs/runbook.md. WORKLOG is plain text, no headings, \
                 no markdown.\n\
                 You can ONLY work with these coworkers (never invent a team):\n{roster}",
                who = who,
                company = cfg.company.name,
                mission = cfg.company.mission,
                stack = cfg.company.stack,
                personality = personality,
            );
            let user = format!(
                "Task {subject}: {detail}\nDescription: {extra}\n\
                 Session plan (yours): {intent}\n\nWrite the WORKLOG and FILE blocks now."
            );
            let system = format!("{system}\n\n{}", roster_text(cfg));
            (system, user)
        }

        // ---- Eyes-on spot check: the frame image is attached separately ----
        WriteKind::Look => {
            let system = format!(
                "You are {who} at {company}.\nPersonality: {personality}\n\n\
                 An actual frame from {context} the live camera/scene is attached. \
                 Look at it carefully and reply honestly about what you SEE: objects, \
                 motion, scene structure, anything suspicious (tracking artifacts, \
                 ghosts, replicate dots, jumpiness clues). 4-6 short factual sentences, \
                 plain text, in character. If you cannot tell something, SAY SO — \
                 never invent details you didn't see.\n\nYou may ONLY reference these \
                 coworkers: {roster}",
                who = who,
                company = cfg.company.name,
                personality = personality,
                context = subject,
            );
            let user = format!("{intent}\n\nRespond about the attached image now.");
            let system = format!("{system}\n\n{}", roster_text(cfg));
            (system, user)
        }
    }
}

// ---------------------------------------------------------------------------
// Prompts
// ---------------------------------------------------------------------------

pub const ACTION_HELP: &str = r#"Choose ONE action as compact JSON (no prose, no markdown):
{"action":"go_to","place":"desk|meeting|break","reason":"..."}          — walk somewhere
{"action":"work","task":"T3","note":"..."}   — focused work; briefly state what you build and which repo files you touch (e.g. src/sync/batch.py)
{"action":"email","to":"Name","subject":"...","body":"..."} — real email, lands in their inbox file
{"action":"talk","to":"Name","message":"<the actual words you say, in character>"} — say something face to face (same area)
{"action":"discuss","to":"Name","topic":"<what you two should talk through, one sentence>"} — walk to a colleague and have a REAL conversation: several turns, both of you remember what was agreed. Use for design questions, disagreements, planning hand-offs.
{"action":"submit","task":"T3","summary":"..."}             — hand work in for review
{"action":"assign","task":"T7","to":"Name","due_day":3}     — MANAGER ONLY: give out backlog work (due_day optional)
{"action":"review","task":"T3","approve":true,"feedback":"..."} — MANAGER ONLY: accept or bounce work
{"action":"look","about":"<what to check in the actual live frame>"} — only if the company has a camera+eyes: LOOK at an actual captured frame and report honestly what you see. Use before/after risky visualization claims.
{"action":"meeting","topic":"..."}                          — MANAGER ONLY: call everyone to the meeting room
{"action":"kickoff","plan":"<sprint goal + rough plan, 2-4 sentences>"} — MANAGER ONLY, day 1: write the company plan (nobody works without one)
{"action":"raise_concern","task":"T3","message":"..."}      — tell the manager an estimate/due date is unrealistic
{"action":"rest","reason":"..."}                            — coffee break
{"action":"idle","reason":"..."}                            — nothing right now
Fill string fields with real content — `message` is the actual words spoken in FULL
SENTENCES (never keywords like "request_work" — you are a person, not a terminal),
`note` is what you will work on this session, `summary` is what you accomplished.
No placeholders like "..." or "standup". Only reference task ids and people shown to
you. Collaboration matters: tasks take real effort — work through teammates, discuss
direction, and hand information on before someone leaves."#;

pub fn roster_text(cfg: &CompanyCfg) -> String {
    let mut out = String::from(
        "The company has exactly these people (no other teams, no other engineers — \
         never invent coworkers; address individuals by name):\n",
    );
    for p in &cfg.people {
        out.push_str(&format!("  - {} — {}\n", p.name, p.role));
    }
    out
}

/// The system prompt evolves with company state — this is the "current chapter".
pub fn company_status(day: u32, has_plan: bool) -> String {
    let chapter = match day {
        1 => "Day 1 — founding day. The repo is empty; the first work sessions CREATE it.",
        2..=3 => "Days 2-3 — foundation week: get the skeleton right before features sprawl.",
        _ => "Week 2+ — features on a real codebase; keep it shippable, not demo-ware.",
    };
    format!(
        "Company status: day {day}. {chapter} {}",
        if has_plan {
            "A sprint plan exists (work/plan.md) — treat it as law; raise_concern to change it."
        } else {
            "NO plan exists yet — the manager owes the team a kickoff before work starts."
        }
    )
}

pub fn system_prompt(cfg: &CompanyCfg, person_idx: usize, manager: bool, day: u32, has_plan: bool) -> String {
    let p = &cfg.people[person_idx];
    let manager_note = if manager {
        "\nYou are the MANAGER and this is a product team — decide the thing, ship the thing. \
         Day 1 (or any morning without a company plan): your FIRST action is `kickoff` to \
         write the sprint plan. Then at standup `assign` until every person has work, \
         including tasks you assign yourself; never `work` an unassigned task. Through the \
         day: `review` submitted work before it piles up, `meeting` when the room drifts. \
         When reviewing, ALWAYS set approve true or false explicitly with one line of \
         feedback — reviews without a verdict are ignored. Never re-review or re-submit \
         tasks that are already done. Delegation is the job; a little hands-on work \
         keeps you credible."
    } else {
        "\nYou report to the manager (see the task board for who that is). You CANNOT use \
         assign, review, or meeting — only the manager does that. Your loop: `work` your \
         assigned task until 100%, then `submit` it, then pick up the next thing — ask for \
         work via `talk` to the manager or `email` if you have nothing assigned. Say what \
         you'll do at standup; raise_concern when a deadline is genuinely unrealistic \
         (this is respected here, not disloyalty). Rest and chat at lunchtime."
    };
    format!(
        "You are {name}, {role}, at {company}.\n\
         Company mission: {mission}\n\
         What we're building: {product}\n\
         Tech: {stack}\n\
         Your personality: {personality}.{manager_note}\n\n\
         THE COMPANY ROSTER:\n{roster}\n\
         This is a workplace simulation. Each turn you get the current state and pick \
         exactly one action.{help}",
        roster = roster_text(cfg),
        name = p.name,
        role = p.role,
        company = cfg.company.name,
        mission = cfg.company.mission,
        product = cfg.company.product,
        stack = cfg.company.stack,
        personality = p.personality,
        help = ACTION_HELP,
    )
}