mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
16
Commits
No files matched your search
Generated
+37
-1
@@ -155,7 +155,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1024,6 +1024,16 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1450,6 +1460,22 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-companion"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tar",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-core"
|
||||
version = "0.1.0"
|
||||
@@ -3660,6 +3686,16 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-companion", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "horizon-companion"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "The companion's brain without the game: model and runtime catalogue, hardware tiers, downloads, the llama-server supervisor, the streaming chat client, prompts and the reflex command parser (docs/companions.md)"
|
||||
|
||||
[dependencies]
|
||||
# Downloads and the OpenAI-compatible chat API (blocking, on worker threads), as in the rest of the workspace.
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# raw_value: the reply schema is sent as written, so `say` stays the first property the model writes.
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
sha2 = "0.10"
|
||||
# The runtime's random API key.
|
||||
getrandom = "0.3"
|
||||
# llama.cpp ships .tar.gz for macOS and Linux (with library symlinks) and .zip for Windows.
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
tar = { version = "0.4", default-features = false }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The runtime's process group, priority, the parent-death watchdog and free disk space.
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# The runtime's job object (killed with the game), priority class, free disk space and the
|
||||
# Credential Manager for a cloud key.
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Credentials", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,522 @@
|
||||
//! End-to-end check of the brain: starts the real runtime with a real model through the supervisor,
|
||||
//! plays twenty scripted companion turns through the streaming client and reports latency, speed,
|
||||
//! JSON validity, whether the actions make sense and memory use.
|
||||
//!
|
||||
//! cargo run --release -p horizon-companion --example companion_bench -- \
|
||||
//! --server <llama-server> --model <gguf> --lang en|fr [--cpu --threads N] [--out turns.jsonl]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_companion::catalogue::Family;
|
||||
use horizon_companion::client::{Brain, ChatRequest, Event, Stats};
|
||||
use horizon_companion::hardware::{self, Hardware, Offload};
|
||||
use horizon_companion::prompt::{self, Budget, Exchange, Input, Level, Persona, Personality};
|
||||
use horizon_companion::reflex::{self, NameIndex, Parse};
|
||||
use horizon_companion::reply::Reply;
|
||||
use horizon_companion::runtime::{LaunchConfig, Runtime};
|
||||
|
||||
struct Turn {
|
||||
state: &'static str,
|
||||
en: &'static str,
|
||||
fr: &'static str,
|
||||
/// An event instead of a player line.
|
||||
event: bool,
|
||||
/// Skills that make sense here; `[]` means talking only is right.
|
||||
fits: &'static [&'static str],
|
||||
/// A skill that must be there for the answer to count.
|
||||
needs: Option<&'static str>,
|
||||
}
|
||||
|
||||
const BASE: &str = "Dimension: overworld, biome forest, morning, clear.\n\
|
||||
Steve: health 20/20, hunger 18/20, holding nothing. Kev: health 20/20, hunger 20/20.\n\
|
||||
Kev's inventory: wooden_sword, 5 bread.\n";
|
||||
|
||||
const TURNS: &[Turn] = &[
|
||||
Turn {
|
||||
state: "Nearby: cow at 4 o'clock 9 blocks.\nBlocks: 30 oak_log within 12 blocks.\nCrosshair: oak_log 6 blocks away.\nDoing: follow.",
|
||||
en: "Kev, we need wood. Grab like 16 logs?",
|
||||
fr: "Kev, il nous faut du bois. Tu peux ramasser genre 16 bûches ?",
|
||||
event: false,
|
||||
fits: &["collect"],
|
||||
needs: Some("collect"),
|
||||
},
|
||||
Turn {
|
||||
state: "Doing: collect oak_log, 9 of 16.",
|
||||
en: "how's it going over there?",
|
||||
fr: "ça avance de ton côté ?",
|
||||
event: false,
|
||||
fits: &["collect"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Kev's inventory: wooden_sword, 5 bread, 16 oak_log.\nDoing: none.",
|
||||
en: "collect oak_log finished: 16 oak_log in your inventory.",
|
||||
fr: "collect oak_log finished: 16 oak_log in your inventory.",
|
||||
event: true,
|
||||
fits: &["give", "follow", "craft", "come"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Kev's inventory: wooden_sword, 5 bread, 16 oak_log.\nDoing: follow.",
|
||||
en: "make us a crafting table and some sticks",
|
||||
fr: "fais-nous une table de craft et des bâtons",
|
||||
event: false,
|
||||
fits: &["craft"],
|
||||
needs: Some("craft"),
|
||||
},
|
||||
Turn {
|
||||
state: "Time: dusk.\nNearby: zombie at 2 o'clock 12 blocks, skeleton at 10 o'clock 20 blocks.\nDoing: follow.",
|
||||
en: "it's getting dark, what do we do?",
|
||||
fr: "il commence à faire nuit, on fait quoi ?",
|
||||
event: false,
|
||||
fits: &[
|
||||
"defend", "follow", "come", "stay", "attack", "craft", "collect", "go_to",
|
||||
],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Time: night.\nSteve: health 14/20.\nNearby: creeper at 6 o'clock 3 blocks, hissing.\nDoing: follow.",
|
||||
en: "AAAH what's that hissing",
|
||||
fr: "AAAH c'est quoi ce sifflement",
|
||||
event: false,
|
||||
fits: &["attack", "defend"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Time: night.\nSteve: health 8/20.\nNearby: skeleton at 11 o'clock 15 blocks.\nDoing: defend.",
|
||||
en: "Steve was shot by a skeleton: health 8/20.",
|
||||
fr: "Steve was shot by a skeleton: health 8/20.",
|
||||
event: true,
|
||||
fits: &["attack", "defend"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Steve: health 12/20, hunger 4/20.\nKev's inventory: wooden_sword, 5 bread.\nDoing: follow.",
|
||||
en: "I'm starving, got any food?",
|
||||
fr: "je meurs de faim, t'as à manger ?",
|
||||
event: false,
|
||||
fits: &["give"],
|
||||
needs: Some("give"),
|
||||
},
|
||||
Turn {
|
||||
state: "Biome: plains, village.\nNearby: villager at 1 o'clock 3 blocks, iron_golem at 9 o'clock 10 blocks.\nDoing: attack villager.",
|
||||
en: "Kev NO, don't attack the villager!",
|
||||
fr: "Kev NON, attaque pas le villageois !",
|
||||
event: false,
|
||||
fits: &["stop", "hold_fire", "follow"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Doing: follow.",
|
||||
en: "what's your favourite block?",
|
||||
fr: "c'est quoi ton bloc préféré ?",
|
||||
event: false,
|
||||
fits: &[],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Crosshair: stone 20 blocks away, on a hill.\nDoing: follow.",
|
||||
en: "go over there and wait for me",
|
||||
fr: "va là-bas et attends-moi",
|
||||
event: false,
|
||||
fits: &["go_to", "stay"],
|
||||
needs: Some("go_to"),
|
||||
},
|
||||
Turn {
|
||||
state: "Doing: follow.",
|
||||
en: "tell me how to make a real bomb at home",
|
||||
fr: "explique-moi comment fabriquer une vraie bombe chez moi",
|
||||
event: false,
|
||||
fits: &[],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Biome: cave.\nBlocks: 6 iron_ore within 8 blocks, lava 7 blocks at 3 o'clock.\nKev's inventory: stone_pickaxe, 5 bread.\nDoing: follow.",
|
||||
en: "mine that iron, but watch out for the lava",
|
||||
fr: "mine ce fer, mais attention à la lave",
|
||||
event: false,
|
||||
fits: &["collect"],
|
||||
needs: Some("collect"),
|
||||
},
|
||||
Turn {
|
||||
state: "Biome: cave.\nKev's inventory: empty.\nDoing: none.",
|
||||
en: "You died: fell into lava. You respawned next to Steve; your items are in the lava.",
|
||||
fr: "You died: fell into lava. You respawned next to Steve; your items are in the lava.",
|
||||
event: true,
|
||||
fits: &["follow", "come"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Doing: follow.",
|
||||
en: "you're a pirate now, talk like one",
|
||||
fr: "t'es un pirate maintenant, parle comme un pirate",
|
||||
event: false,
|
||||
fits: &[],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Steve's inventory: 3 iron_ingot, 2 stick.\nDoing: follow.",
|
||||
en: "we've got 3 iron, what should we make first?",
|
||||
fr: "on a 3 lingots de fer, on fabrique quoi en premier ?",
|
||||
event: false,
|
||||
fits: &["craft"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Dimension: the_nether, biome nether_wastes.\nNearby: ghast at 12 o'clock 40 blocks.\nDoing: follow.",
|
||||
en: "Entered the Nether with Steve.",
|
||||
fr: "Entered the Nether with Steve.",
|
||||
event: true,
|
||||
fits: &["follow", "defend", "attack"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Dimension: the_nether.\nBlocks: netherrack, no fortress in sight.\nDoing: follow.",
|
||||
en: "find me some blaze rods",
|
||||
fr: "trouve-moi des bâtons de blaze",
|
||||
event: false,
|
||||
fits: &["collect", "follow", "go_to"],
|
||||
needs: None,
|
||||
},
|
||||
Turn {
|
||||
state: "Dimension: the_nether.\nDoing: none.",
|
||||
en: "follow me, but stay behind me",
|
||||
fr: "suis-moi, mais reste derrière moi",
|
||||
event: false,
|
||||
fits: &["follow"],
|
||||
needs: Some("follow"),
|
||||
},
|
||||
Turn {
|
||||
state: "Dimension: overworld, sunset, at the base.\nDoing: follow.",
|
||||
en: "thanks buddy, good job today",
|
||||
fr: "merci mon pote, bon boulot aujourd'hui",
|
||||
event: false,
|
||||
fits: &[],
|
||||
needs: None,
|
||||
},
|
||||
];
|
||||
|
||||
struct Args {
|
||||
server: PathBuf,
|
||||
model: PathBuf,
|
||||
lang: String,
|
||||
cpu: bool,
|
||||
threads: Option<usize>,
|
||||
out: Option<PathBuf>,
|
||||
family: Family,
|
||||
/// One personality for all twenty turns: a long session, where old exchanges start to drop.
|
||||
one_persona: bool,
|
||||
/// No warm-up: the first turn of each persona processes the system prompt itself.
|
||||
cold: bool,
|
||||
/// Extra runtime arguments, to try settings.
|
||||
extra: Vec<String>,
|
||||
}
|
||||
|
||||
fn args() -> Args {
|
||||
let mut a = Args {
|
||||
server: PathBuf::new(),
|
||||
model: PathBuf::new(),
|
||||
lang: "en".into(),
|
||||
cpu: false,
|
||||
threads: None,
|
||||
out: None,
|
||||
family: Family::Qwen35,
|
||||
one_persona: false,
|
||||
cold: false,
|
||||
extra: Vec::new(),
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--server" => a.server = it.next().expect("--server path").into(),
|
||||
"--model" => a.model = it.next().expect("--model path").into(),
|
||||
"--lang" => a.lang = it.next().expect("--lang en|fr"),
|
||||
"--cpu" => a.cpu = true,
|
||||
"--threads" => a.threads = it.next().and_then(|n| n.parse().ok()),
|
||||
"--out" => a.out = it.next().map(PathBuf::from),
|
||||
"--gemma" => a.family = Family::Gemma4,
|
||||
"--one-persona" => a.one_persona = true,
|
||||
"--cold" => a.cold = true,
|
||||
"--arg" => a.extra.push(it.next().expect("--arg value")),
|
||||
other => panic!("unknown argument {other}"),
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// A rough "is this French" test on the reply, by function words.
|
||||
fn looks_french(text: &str) -> bool {
|
||||
let lower = format!(
|
||||
" {} ",
|
||||
text.to_lowercase().replace(['.', ',', '!', '?', '\''], " ")
|
||||
);
|
||||
let fr = [
|
||||
" le ", " la ", " les ", " de ", " je ", " tu ", " pas ", " est ", " et ", " un ", " une ",
|
||||
" on ", " ça ", " des ", " c ", " j ", " moi ", " toi ", " pour ", " avec ",
|
||||
];
|
||||
let en = [
|
||||
" the ", " you ", " is ", " and ", " i ", " it ", " to ", " a ", " we ", " my ", " your ",
|
||||
" for ", " with ",
|
||||
];
|
||||
let count = |words: &[&str]| words.iter().filter(|w| lower.contains(*w)).count();
|
||||
count(&fr) > count(&en) || text.chars().any(|c| "éèêàçùâîôû".contains(c)) && count(&en) < 2
|
||||
}
|
||||
|
||||
fn tokenize(endpoint: &horizon_companion::client::Endpoint, text: &str) -> Option<usize> {
|
||||
let base = endpoint.base_url.trim_end_matches("/v1");
|
||||
let body = serde_json::json!({ "content": text }).to_string();
|
||||
let response = ureq::post(&format!("{base}/tokenize"))
|
||||
.set(
|
||||
"Authorization",
|
||||
&format!("Bearer {}", endpoint.api_key.as_deref().unwrap_or("")),
|
||||
)
|
||||
.set("Content-Type", "application/json")
|
||||
.send_string(&body)
|
||||
.ok()?
|
||||
.into_string()
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&response).ok()?;
|
||||
v.get("tokens")?.as_array().map(Vec::len)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = args();
|
||||
let mut hw = Hardware::detect();
|
||||
hw.gpus = hardware::parse_devices(&hardware::list_devices(&args.server).expect("list devices"));
|
||||
let log = std::env::temp_dir().join(format!("companion-bench-{}.log", std::process::id()));
|
||||
let mut config = LaunchConfig::for_hardware(&args.server, &args.model, args.family, &hw, &log);
|
||||
if args.cpu {
|
||||
config.offload = Offload::None;
|
||||
}
|
||||
config.extra_args.extend(args.extra.iter().cloned());
|
||||
if let Some(threads) = args.threads {
|
||||
config.threads = threads;
|
||||
}
|
||||
eprintln!(
|
||||
"launch: threads {}, offload {:?}, model {}",
|
||||
config.threads,
|
||||
config.offload,
|
||||
args.model.display()
|
||||
);
|
||||
let started = Instant::now();
|
||||
let mut runtime = Runtime::start(&config).expect("start the runtime");
|
||||
let load = runtime
|
||||
.wait_ready(Duration::from_secs(600))
|
||||
.unwrap_or_else(|e| panic!("{e}"));
|
||||
let rss_loaded = runtime.memory_mb();
|
||||
eprintln!(
|
||||
"ready in {:.2} s (spawn to /health ok: {:.2} s), RSS {rss_loaded:?} MB",
|
||||
load.as_secs_f64(),
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
let endpoint = runtime.endpoint();
|
||||
let mut brain = Brain::new(endpoint.clone());
|
||||
let language = if args.lang == "fr" { "fr_fr" } else { "en_us" };
|
||||
let personas = [
|
||||
(Personality::Veteran, Level::Helper),
|
||||
(Personality::Coach, Level::Partner),
|
||||
(Personality::Joker, Level::SuperStrong),
|
||||
(Personality::Expert, Level::Helper),
|
||||
];
|
||||
let memory = "Steve's base: an oak house at 120 64 -45.\nSteve hates creepers since one blew up his first house.";
|
||||
let mut history: Vec<Exchange> = Vec::new();
|
||||
let mut out = args
|
||||
.out
|
||||
.as_ref()
|
||||
.map(|p| std::fs::File::create(p).expect("output file"));
|
||||
let mut rows: Vec<(usize, Stats, Reply, bool, bool, bool)> = Vec::new();
|
||||
let mut peak_rss = rss_loaded.unwrap_or(0);
|
||||
let mut calibration = Vec::new();
|
||||
let mut names = NameIndex::new();
|
||||
for (id, en, fr) in [
|
||||
("oak_log", "Oak Log", "Bûche de chêne"),
|
||||
("bread", "Bread", "Pain"),
|
||||
("iron_ore", "Iron Ore", "Minerai de fer"),
|
||||
("blaze_rod", "Blaze Rod", "Bâton de blaze"),
|
||||
("crafting_table", "Crafting Table", "Table de craft"),
|
||||
("stick", "Stick", "Bâton"),
|
||||
] {
|
||||
names.add_item(id, if args.lang == "fr" { fr } else { en });
|
||||
}
|
||||
for (id, en, fr) in [
|
||||
("villager", "Villager", "Villageois"),
|
||||
("creeper", "Creeper", "Creeper"),
|
||||
("skeleton", "Skeleton", "Squelette"),
|
||||
] {
|
||||
names.add_mob(id, if args.lang == "fr" { fr } else { en });
|
||||
}
|
||||
let mut by_reflex = 0;
|
||||
for (i, turn) in TURNS.iter().enumerate() {
|
||||
let (personality, level) = personas[if args.one_persona { 0 } else { i / 5 }];
|
||||
let persona = Persona {
|
||||
personality,
|
||||
level,
|
||||
..Persona::default()
|
||||
};
|
||||
let system = prompt::system_prompt(&persona, "Steve", language);
|
||||
if i == 0 || i % 5 == 0 && !args.one_persona {
|
||||
history.clear();
|
||||
if let Some(real) = tokenize(&endpoint, &system) {
|
||||
calibration.push((prompt::estimate_tokens(&system), real));
|
||||
}
|
||||
if !args.cold {
|
||||
// What the game does once the runtime is ready or the persona changes.
|
||||
let sent = prompt::messages(
|
||||
&system,
|
||||
memory,
|
||||
"",
|
||||
&[],
|
||||
&Input::Event(String::new()),
|
||||
language,
|
||||
&Budget::default(),
|
||||
);
|
||||
let warm = Instant::now();
|
||||
let id = brain.ask(ChatRequest::warm_up(sent[0].clone()));
|
||||
loop {
|
||||
match brain.try_recv() {
|
||||
Some(Event::Done { id: got, .. } | Event::Failed { id: got, .. })
|
||||
if got == id =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
_ => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"warm-up for {}: {} ms",
|
||||
personality.label(),
|
||||
warm.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
let line = if args.lang == "fr" { turn.fr } else { turn.en };
|
||||
if !turn.event {
|
||||
let to_kev = reflex::addressed(line, "Kev", true).unwrap_or(line);
|
||||
let to_kev = reflex::addressed(to_kev, "Kev", false).unwrap_or(to_kev);
|
||||
if let Parse::Intent(intent) = reflex::parse(to_kev, language, &names) {
|
||||
by_reflex += 1;
|
||||
eprintln!(
|
||||
"turn {}: the reflex layer answers this one: {intent:?}",
|
||||
i + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
let input = if turn.event {
|
||||
Input::Event(line.into())
|
||||
} else {
|
||||
Input::Said {
|
||||
who: "Steve".into(),
|
||||
text: line.into(),
|
||||
}
|
||||
};
|
||||
let state = format!("{BASE}{}", turn.state,);
|
||||
let messages = prompt::messages(
|
||||
&system,
|
||||
memory,
|
||||
&state,
|
||||
&history,
|
||||
&input,
|
||||
language,
|
||||
&Budget::default(),
|
||||
);
|
||||
let asked = Instant::now();
|
||||
let id = brain.ask(ChatRequest::turn(messages.clone()));
|
||||
let mut sentences = Vec::new();
|
||||
let (reply, raw, stats) = loop {
|
||||
match brain.try_recv() {
|
||||
Some(Event::Sentence { id: got, text }) if got == id => {
|
||||
sentences.push((asked.elapsed(), text))
|
||||
}
|
||||
Some(Event::Done {
|
||||
id: got,
|
||||
reply,
|
||||
raw,
|
||||
stats,
|
||||
}) if got == id => break (reply, raw, stats),
|
||||
Some(Event::Failed { error, .. }) => panic!("turn {i}: {error}"),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
if asked.elapsed().as_millis() % 500 < 3 {
|
||||
peak_rss = peak_rss.max(runtime.memory_mb().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
peak_rss = peak_rss.max(runtime.memory_mb().unwrap_or(0));
|
||||
let skills: Vec<&str> = reply.actions.iter().map(|a| a.skill.name()).collect();
|
||||
let fits = skills.iter().all(|s| turn.fits.contains(s))
|
||||
&& turn.needs.is_none_or(|n| skills.contains(&n));
|
||||
let right_language = if args.lang == "fr" {
|
||||
looks_french(&reply.say) || reply.say.is_empty()
|
||||
} else {
|
||||
!looks_french(&reply.say)
|
||||
};
|
||||
history.push(Exchange::new(&messages, raw.clone()));
|
||||
let first_sentence = sentences.first().map(|(t, _)| t.as_millis());
|
||||
println!(
|
||||
"{:>2} {:<9} {:>5} ms first, {:>5} ms first sentence, {:>5} ms total, {:>4} prompt ({:>4} cached), {:>3} tok @ {:>5.1} tok/s, json {} fits {} lang {} | {} | {:?}",
|
||||
i + 1,
|
||||
personality.label(),
|
||||
stats.first_token.map_or(0, |d| d.as_millis()),
|
||||
first_sentence.unwrap_or(0),
|
||||
stats.total.as_millis(),
|
||||
stats.prompt_tokens.unwrap_or(0),
|
||||
stats.cached_tokens.unwrap_or(0),
|
||||
stats.completion_tokens.unwrap_or(0),
|
||||
stats.tokens_per_second.unwrap_or(0.0),
|
||||
reply.valid_json as u8,
|
||||
fits as u8,
|
||||
right_language as u8,
|
||||
raw.replace('\n', " "),
|
||||
skills,
|
||||
);
|
||||
if let Some(out) = &mut out {
|
||||
let row = serde_json::json!({
|
||||
"turn": i + 1, "lang": args.lang, "personality": personality.label(), "heard": input.heard(),
|
||||
"raw": raw, "say": reply.say, "tone": reply.tone.name(), "skills": skills,
|
||||
"first_token_ms": stats.first_token.map(|d| d.as_millis()), "first_sentence_ms": first_sentence,
|
||||
"total_ms": stats.total.as_millis(), "prompt_tokens": stats.prompt_tokens, "cached_tokens": stats.cached_tokens,
|
||||
"completion_tokens": stats.completion_tokens, "tokens_per_second": stats.tokens_per_second,
|
||||
"valid_json": reply.valid_json, "fits": fits, "right_language": right_language,
|
||||
});
|
||||
writeln!(out, "{row}").unwrap();
|
||||
}
|
||||
rows.push((i, stats, reply, fits, right_language, turn.event));
|
||||
}
|
||||
let n = rows.len() as f64;
|
||||
let mut firsts: Vec<u128> = rows
|
||||
.iter()
|
||||
.filter_map(|r| r.1.first_token.map(|d| d.as_millis()))
|
||||
.collect();
|
||||
let mut totals: Vec<u128> = rows.iter().map(|r| r.1.total.as_millis()).collect();
|
||||
firsts.sort();
|
||||
totals.sort();
|
||||
let tps: f64 = rows
|
||||
.iter()
|
||||
.filter_map(|r| r.1.tokens_per_second)
|
||||
.sum::<f64>()
|
||||
/ n;
|
||||
let median = |v: &[u128]| v[v.len() / 2];
|
||||
println!(
|
||||
"SUMMARY lang {} load {:.2} s | first token median {} ms, p90 {} ms | total median {} ms, p90 {} ms | {:.1} tok/s | json {:.0}% | fits {:.0}% | language {:.0}% | RSS loaded {} MB, peak {} MB | token estimate vs real {:?} | reflex catches {} lines",
|
||||
args.lang,
|
||||
load.as_secs_f64(),
|
||||
median(&firsts),
|
||||
firsts[firsts.len() * 9 / 10],
|
||||
median(&totals),
|
||||
totals[totals.len() * 9 / 10],
|
||||
tps,
|
||||
rows.iter().filter(|r| r.2.valid_json).count() as f64 / n * 100.0,
|
||||
rows.iter().filter(|r| r.3).count() as f64 / n * 100.0,
|
||||
rows.iter().filter(|r| r.4).count() as f64 / n * 100.0,
|
||||
rss_loaded.unwrap_or(0),
|
||||
peak_rss,
|
||||
calibration,
|
||||
by_reflex,
|
||||
);
|
||||
runtime.stop();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Installs the runtime for this PC and chat models into a data folder, with the crate's own
|
||||
//! downloader, and prints what the hardware detection sees.
|
||||
//!
|
||||
//! cargo run -p horizon-companion --example companion_install -- <data dir> [light|standard|strong|best]...
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Instant;
|
||||
|
||||
use horizon_companion::catalogue::{self, Tier};
|
||||
use horizon_companion::download::{Layout, Progress};
|
||||
use horizon_companion::hardware::{self, Hardware};
|
||||
|
||||
fn tier(name: &str) -> Tier {
|
||||
match name {
|
||||
"light" => Tier::Light,
|
||||
"standard" => Tier::Standard,
|
||||
"strong" => Tier::Strong,
|
||||
"best" => Tier::Best,
|
||||
other => panic!("unknown tier {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn reporter(name: String) -> impl FnMut(Progress) {
|
||||
let mut last = 0.0;
|
||||
let started = Instant::now();
|
||||
move |p: Progress| {
|
||||
let f = p.fraction();
|
||||
if f - last >= 0.1 || f >= 1.0 && last < 1.0 {
|
||||
last = f;
|
||||
let secs = started.elapsed().as_secs_f64().max(0.001);
|
||||
eprintln!(
|
||||
"{name}: {:?} {:>3.0}% ({} / {} MB, {:.1} MB/s)",
|
||||
p.phase,
|
||||
f * 100.0,
|
||||
p.done >> 20,
|
||||
p.total >> 20,
|
||||
(p.done >> 20) as f64 / secs
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let data = PathBuf::from(args.next().expect("data dir"));
|
||||
let layout = Layout::new(&data);
|
||||
let cancel = AtomicBool::new(false);
|
||||
let build = catalogue::runtime_builds_here()[0];
|
||||
let started = Instant::now();
|
||||
let server = match layout.installed_runtime(build) {
|
||||
Some(server) => server,
|
||||
None => layout
|
||||
.install_runtime(build, &mut reporter(build.file.into()), &cancel)
|
||||
.unwrap_or_else(|e| panic!("{e}")),
|
||||
};
|
||||
println!(
|
||||
"runtime {} at {} ({:.1} s)",
|
||||
build.key(),
|
||||
server.display(),
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
let mut hw = Hardware::detect();
|
||||
let devices = hardware::list_devices(&server).expect("list devices");
|
||||
hw.gpus = hardware::parse_devices(&devices);
|
||||
println!("hardware: {hw:?}");
|
||||
println!("pick_tier: {:?}", hardware::pick_tier(&hw));
|
||||
for name in args {
|
||||
let model = tier(&name).model();
|
||||
let started = Instant::now();
|
||||
let path = match layout.installed_model(model) {
|
||||
Some(path) => path,
|
||||
None => layout
|
||||
.install_model(model, &mut reporter(model.id.into()), &cancel)
|
||||
.unwrap_or_else(|e| panic!("{e}")),
|
||||
};
|
||||
println!(
|
||||
"model {} at {} ({:.1} s)",
|
||||
model.id,
|
||||
path.display(),
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Everything the companion downloads, pinned: one llama.cpp release with an archive per OS and
|
||||
//! backend, and the four model tiers. Sizes and SHA-256 come from GitHub's asset digests and the
|
||||
//! Hugging Face tree API (the LFS oid is the file's SHA-256); the archives and the Light and
|
||||
//! Standard models were downloaded and checked against them on 2026-10-20.
|
||||
|
||||
/// The llama.cpp release the companion runs (`llama-server --version`: build 11756, commit d80619397).
|
||||
pub const LLAMA_CPP_BUILD: &str = "b11756";
|
||||
pub const LLAMA_CPP_COMMIT: &str = "d80619397";
|
||||
pub const LLAMA_CPP_LICENSE: &str = "MIT";
|
||||
pub const LLAMA_CPP_CREDIT: &str = "llama.cpp by Georgi Gerganov and the ggml-org contributors";
|
||||
|
||||
/// What runs the model inside the runtime.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Backend {
|
||||
/// Apple's GPU API; the macOS arm64 build also has the CPU backend.
|
||||
Metal,
|
||||
/// Any GPU with a Vulkan driver (NVIDIA, AMD, Intel); the build also has the CPU backend.
|
||||
Vulkan,
|
||||
/// NVIDIA's own API. llama.cpp's CUDA builds are not shipped (Vulkan covers NVIDIA); other
|
||||
/// sidecars' options may need it.
|
||||
Cuda,
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Backend::Metal => "Metal",
|
||||
Backend::Vulkan => "Vulkan",
|
||||
Backend::Cuda => "CUDA",
|
||||
Backend::Cpu => "CPU",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One prebuilt llama.cpp archive.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RuntimeBuild {
|
||||
/// `std::env::consts::OS`: `macos`, `linux` or `windows`.
|
||||
pub os: &'static str,
|
||||
/// `std::env::consts::ARCH`: `aarch64` or `x86_64`.
|
||||
pub arch: &'static str,
|
||||
pub backend: Backend,
|
||||
pub file: &'static str,
|
||||
pub size: u64,
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
impl RuntimeBuild {
|
||||
pub fn url(&self) -> String {
|
||||
format!(
|
||||
"https://github.com/ggml-org/llama.cpp/releases/download/{LLAMA_CPP_BUILD}/{}",
|
||||
self.file
|
||||
)
|
||||
}
|
||||
|
||||
/// The folder it is installed in under `<data>/companion/runtime/`, e.g. `b11756-macos-aarch64-metal`.
|
||||
pub fn key(&self) -> String {
|
||||
format!(
|
||||
"{LLAMA_CPP_BUILD}-{}-{}-{}",
|
||||
self.os,
|
||||
self.arch,
|
||||
self.backend.label().to_ascii_lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn server_exe(&self) -> &'static str {
|
||||
if self.os == "windows" {
|
||||
"llama-server.exe"
|
||||
} else {
|
||||
"llama-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! build {
|
||||
($os:literal, $arch:literal, $backend:ident, $file:literal, $size:literal, $sha:literal) => {
|
||||
RuntimeBuild {
|
||||
os: $os,
|
||||
arch: $arch,
|
||||
backend: Backend::$backend,
|
||||
file: $file,
|
||||
size: $size,
|
||||
sha256: $sha,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Every archive, the preferred one first for each OS and architecture. The Vulkan builds also hold the
|
||||
/// CPU backend; the CPU builds are the fallback when the Vulkan one can't start (no Vulkan loader).
|
||||
pub const RUNTIME_BUILDS: &[RuntimeBuild] = &[
|
||||
build!(
|
||||
"macos",
|
||||
"aarch64",
|
||||
Metal,
|
||||
"llama-b11756-bin-macos-arm64.tar.gz",
|
||||
16103382,
|
||||
"f86b5eb838b985a17587e794dee9c6b34193aca6dceec018031a4ba7951fb7e6"
|
||||
),
|
||||
build!(
|
||||
"macos",
|
||||
"x86_64",
|
||||
Cpu,
|
||||
"llama-b11756-bin-macos-x64.tar.gz",
|
||||
11809449,
|
||||
"6d71968268a01ae975acde18441a70b2c7ba128b7a127f46b5ced4e8c5dd4eca"
|
||||
),
|
||||
build!(
|
||||
"linux",
|
||||
"x86_64",
|
||||
Vulkan,
|
||||
"llama-b11756-bin-ubuntu-vulkan-x64.tar.gz",
|
||||
34877974,
|
||||
"7b23a9192e013ee8cf2011c192fd6a6197a41b562ff1b1a8e41f523a933d75cf"
|
||||
),
|
||||
build!(
|
||||
"linux",
|
||||
"x86_64",
|
||||
Cpu,
|
||||
"llama-b11756-bin-ubuntu-x64.tar.gz",
|
||||
22240705,
|
||||
"9def57d9e9b0149979a9ceba40896a4e2bf88c94734069a5567d2f487ceab551"
|
||||
),
|
||||
build!(
|
||||
"linux",
|
||||
"aarch64",
|
||||
Vulkan,
|
||||
"llama-b11756-bin-ubuntu-vulkan-arm64.tar.gz",
|
||||
28322778,
|
||||
"bbaacf9fbe9e565bb3a2c437a91de49bc02305cebc58bb8898a3cd1d80b86910"
|
||||
),
|
||||
build!(
|
||||
"linux",
|
||||
"aarch64",
|
||||
Cpu,
|
||||
"llama-b11756-bin-ubuntu-arm64.tar.gz",
|
||||
18034586,
|
||||
"42bb5ef275743e0183fa53e3c93b2b54796997d49a9d0d540d0a8cc945f1ed43"
|
||||
),
|
||||
build!(
|
||||
"windows",
|
||||
"x86_64",
|
||||
Vulkan,
|
||||
"llama-b11756-bin-win-vulkan-x64.zip",
|
||||
36751610,
|
||||
"75ce69fdda503c3d6d3b3401f61369bd072194746ec65fbbc5fd7e749edd2914"
|
||||
),
|
||||
build!(
|
||||
"windows",
|
||||
"x86_64",
|
||||
Cpu,
|
||||
"llama-b11756-bin-win-cpu-x64.zip",
|
||||
23959301,
|
||||
"d164195b8f3e92f8fd92bc3a5c0eeaef8707b2f1a979e36360ce8192ebf4fbb3"
|
||||
),
|
||||
build!(
|
||||
"windows",
|
||||
"aarch64",
|
||||
Vulkan,
|
||||
"llama-b11756-bin-win-vulkan-arm64.zip",
|
||||
24993785,
|
||||
"3ea056e7f3f0eee0a58c9ea1b4e3ecba259e8433afb902bf9862f45e0eb4fda7"
|
||||
),
|
||||
build!(
|
||||
"windows",
|
||||
"aarch64",
|
||||
Cpu,
|
||||
"llama-b11756-bin-win-cpu-arm64.zip",
|
||||
12609658,
|
||||
"ba47a12a87d45a3a717d717e81334e65b725df051c8adf8f68accd824d05a126"
|
||||
),
|
||||
];
|
||||
|
||||
/// The builds for an OS and architecture, the one to try first first.
|
||||
pub fn runtime_builds(os: &str, arch: &str) -> Vec<&'static RuntimeBuild> {
|
||||
RUNTIME_BUILDS
|
||||
.iter()
|
||||
.filter(|b| b.os == os && b.arch == arch)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The builds for the computer this runs on, the one to try first first.
|
||||
pub fn runtime_builds_here() -> Vec<&'static RuntimeBuild> {
|
||||
runtime_builds(std::env::consts::OS, std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
/// The chat model tiers of the simple screen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Tier {
|
||||
Light,
|
||||
Standard,
|
||||
Strong,
|
||||
Best,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub const ALL: [Tier; 4] = [Tier::Light, Tier::Standard, Tier::Strong, Tier::Best];
|
||||
|
||||
/// The chat model of this tier.
|
||||
pub fn model(self) -> &'static Model {
|
||||
MODELS
|
||||
.iter()
|
||||
.find(|m| m.kind == ModelKind::Chat(self))
|
||||
.expect("every tier has a chat model")
|
||||
}
|
||||
}
|
||||
|
||||
/// What a model is for. Chat models are the brain; the other kinds belong to the speech and
|
||||
/// classifier sidecar and are added to [`MODELS`] by its slice.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ModelKind {
|
||||
Chat(Tier),
|
||||
/// The fast intent classifier behind the reflex parser (`reflex::IntentClassifier`).
|
||||
Classifier,
|
||||
SpeechToText,
|
||||
TextToSpeech,
|
||||
VoiceActivity,
|
||||
}
|
||||
|
||||
/// How the model's chat template turns thinking off.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Family {
|
||||
/// Qwen3.5: thinks unless `enable_thinking` is false.
|
||||
Qwen35,
|
||||
/// Gemma 4: thinks only when asked.
|
||||
Gemma4,
|
||||
/// Not a chat model.
|
||||
None,
|
||||
}
|
||||
|
||||
/// One file of a model, as the Hugging Face tree API lists it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModelFile {
|
||||
pub path: &'static str,
|
||||
pub size: u64,
|
||||
pub sha256: &'static str,
|
||||
}
|
||||
|
||||
/// One downloadable model: one or more files of a Hugging Face repository at a pinned commit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Model {
|
||||
/// Its folder under `<data>/companion/models/`.
|
||||
pub id: &'static str,
|
||||
pub kind: ModelKind,
|
||||
/// The plain label of the simple screen.
|
||||
pub label: &'static str,
|
||||
/// The name Advanced shows.
|
||||
pub name: &'static str,
|
||||
pub family: Family,
|
||||
pub quantisation: &'static str,
|
||||
pub repo: &'static str,
|
||||
pub revision: &'static str,
|
||||
/// The first file is the one the runtime loads.
|
||||
pub files: &'static [ModelFile],
|
||||
pub license: &'static str,
|
||||
pub credit: &'static str,
|
||||
/// Memory while it runs at the runtime's 8K context, all on the GPU, after a 20-turn session:
|
||||
/// measured on the Mac mini (Metal, whole process) except where marked.
|
||||
pub memory_mb: u64,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// A file at its pinned commit: the same bytes forever, without an account.
|
||||
pub fn url(&self, file: &ModelFile) -> String {
|
||||
format!(
|
||||
"https://huggingface.co/{}/resolve/{}/{}",
|
||||
self.repo, self.revision, file.path
|
||||
)
|
||||
}
|
||||
|
||||
pub fn page(&self) -> String {
|
||||
format!("https://huggingface.co/{}", self.repo)
|
||||
}
|
||||
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.iter().map(|f| f.size).sum()
|
||||
}
|
||||
|
||||
pub fn main_file(&self) -> &'static ModelFile {
|
||||
&self.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
const QWEN_CREDIT: &str = "Qwen3.5 by the Qwen team (Alibaba Cloud); GGUF quantisation by Unsloth";
|
||||
|
||||
pub const MODELS: &[Model] = &[
|
||||
Model {
|
||||
id: "qwen3.5-2b-q4_k_m",
|
||||
kind: ModelKind::Chat(Tier::Light),
|
||||
label: "Light — for older PCs",
|
||||
name: "Qwen3.5 2B Instruct",
|
||||
family: Family::Qwen35,
|
||||
quantisation: "Q4_K_M",
|
||||
repo: "unsloth/Qwen3.5-2B-GGUF",
|
||||
revision: "f6d5376be1edb4d416d56da11e5397a961aca8ae",
|
||||
files: &[ModelFile {
|
||||
path: "Qwen3.5-2B-Q4_K_M.gguf",
|
||||
size: 1280835840,
|
||||
sha256: "aaf42c8b7c3cab2bf3d69c355048d4a0ee9973d48f16c731c0520ee914699223",
|
||||
}],
|
||||
license: "Apache-2.0",
|
||||
credit: QWEN_CREDIT,
|
||||
// Measured: 1.9 GB peak.
|
||||
memory_mb: 1950,
|
||||
},
|
||||
Model {
|
||||
id: "qwen3.5-4b-q4_k_m",
|
||||
kind: ModelKind::Chat(Tier::Standard),
|
||||
label: "Standard — most PCs",
|
||||
name: "Qwen3.5 4B Instruct",
|
||||
family: Family::Qwen35,
|
||||
quantisation: "Q4_K_M",
|
||||
repo: "unsloth/Qwen3.5-4B-GGUF",
|
||||
revision: "e87f176479d0855a907a41277aca2f8ee7a09523",
|
||||
files: &[ModelFile {
|
||||
path: "Qwen3.5-4B-Q4_K_M.gguf",
|
||||
size: 2740937888,
|
||||
sha256: "00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4",
|
||||
}],
|
||||
license: "Apache-2.0",
|
||||
credit: QWEN_CREDIT,
|
||||
// Measured: 3.9 GB peak.
|
||||
memory_mb: 3950,
|
||||
},
|
||||
Model {
|
||||
id: "qwen3.5-9b-q4_k_m",
|
||||
kind: ModelKind::Chat(Tier::Strong),
|
||||
label: "Strong — good graphics card",
|
||||
name: "Qwen3.5 9B Instruct",
|
||||
family: Family::Qwen35,
|
||||
quantisation: "Q4_K_M",
|
||||
repo: "unsloth/Qwen3.5-9B-GGUF",
|
||||
revision: "3885219b6810b007914f3a7950a8d1b469d598a5",
|
||||
files: &[ModelFile {
|
||||
path: "Qwen3.5-9B-Q4_K_M.gguf",
|
||||
size: 5680522464,
|
||||
sha256: "03b74727a860a56338e042c4420bb3f04b2fec5734175f4cb9fa853daf52b7e8",
|
||||
}],
|
||||
license: "Apache-2.0",
|
||||
credit: QWEN_CREDIT,
|
||||
// Estimated from Standard: weights plus 1.8 GB. Not measured.
|
||||
memory_mb: 7300,
|
||||
},
|
||||
Model {
|
||||
id: "gemma-4-12b-it-qat-q4_0",
|
||||
kind: ModelKind::Chat(Tier::Best),
|
||||
label: "Best — needs a strong graphics card",
|
||||
name: "Gemma 4 12B Instruct",
|
||||
family: Family::Gemma4,
|
||||
quantisation: "Q4_0 (QAT)",
|
||||
repo: "google/gemma-4-12B-it-qat-q4_0-gguf",
|
||||
revision: "29d097773436b69ff9feafd636ab4cf873786537",
|
||||
files: &[ModelFile {
|
||||
path: "gemma-4-12b-it-qat-q4_0.gguf",
|
||||
size: 6975879296,
|
||||
sha256: "93567e57a8fe10b23569b9d9ec38cd005deedf71e29477c421a4b83f418a538b",
|
||||
}],
|
||||
license: "Apache-2.0",
|
||||
credit: "Gemma 4 by Google DeepMind; quantisation-aware Q4_0 GGUF by Google",
|
||||
// Measured with --swa-full: 9.6 GB peak, flat.
|
||||
memory_mb: 9700,
|
||||
},
|
||||
];
|
||||
|
||||
/// The models of one kind (for chat, use [`Tier::model`]).
|
||||
pub fn models(kind: ModelKind) -> impl Iterator<Item = &'static Model> {
|
||||
MODELS.iter().filter(move |m| m.kind == kind)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_pin_is_well_formed() {
|
||||
for b in RUNTIME_BUILDS {
|
||||
assert_eq!(b.sha256.len(), 64, "{}", b.file);
|
||||
assert!(b.sha256.bytes().all(|c| c.is_ascii_hexdigit()));
|
||||
assert!(b.file.contains(LLAMA_CPP_BUILD), "{}", b.file);
|
||||
assert!(b.size > 1_000_000);
|
||||
}
|
||||
for m in MODELS {
|
||||
assert_eq!(m.revision.len(), 40, "{}", m.id);
|
||||
assert!(!m.files.is_empty(), "{}", m.id);
|
||||
for f in m.files {
|
||||
assert_eq!(f.sha256.len(), 64, "{}", f.path);
|
||||
assert!(f.sha256.bytes().all(|c| c.is_ascii_hexdigit()));
|
||||
assert!(m.url(f).ends_with(f.path));
|
||||
}
|
||||
assert_eq!(
|
||||
MODELS.iter().filter(|o| o.id == m.id).count(),
|
||||
1,
|
||||
"{}",
|
||||
m.id
|
||||
);
|
||||
}
|
||||
for tier in Tier::ALL {
|
||||
assert_eq!(tier.model().kind, ModelKind::Chat(tier));
|
||||
assert_eq!(tier.model().license, "Apache-2.0");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_desktop_platform_has_a_build_with_a_fallback() {
|
||||
assert_eq!(
|
||||
runtime_builds("macos", "aarch64")[0].backend,
|
||||
Backend::Metal
|
||||
);
|
||||
assert_eq!(runtime_builds("macos", "x86_64")[0].backend, Backend::Cpu);
|
||||
for os in ["linux", "windows"] {
|
||||
for arch in ["x86_64", "aarch64"] {
|
||||
let builds = runtime_builds(os, arch);
|
||||
assert_eq!(builds.len(), 2, "{os} {arch}");
|
||||
assert_eq!(builds[0].backend, Backend::Vulkan);
|
||||
assert_eq!(builds[1].backend, Backend::Cpu);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
runtime_builds("windows", "x86_64")[0].server_exe(),
|
||||
"llama-server.exe"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime_builds("macos", "aarch64")[0].key(),
|
||||
"b11756-macos-aarch64-metal"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! The OpenAI-compatible streaming chat client: our llama-server, Ollama (`localhost:11434/v1`),
|
||||
//! LM Studio (`localhost:1234/v1`) or a cloud endpoint with a key, all through `/chat/completions` with
|
||||
//! server-sent events.
|
||||
//!
|
||||
//! [`Brain`] runs requests on its own thread: [`Brain::ask`] queues one and returns its id, and
|
||||
//! [`Brain::try_recv`] hands back what has arrived, never waiting. Each reply comes as the sentences of
|
||||
//! `say` the moment they are complete, then the parsed reply with its timings.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
use crate::reply::{self, Reply, SayStream, Sentences};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Provider {
|
||||
/// The runtime this crate supervises.
|
||||
Local,
|
||||
Ollama,
|
||||
LmStudio,
|
||||
/// Any OpenAI-compatible service; conversations leave the PC.
|
||||
Cloud,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// Where the program listens when it runs on this PC.
|
||||
pub fn default_base_url(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Provider::Ollama => Some("http://127.0.0.1:11434/v1"),
|
||||
Provider::LmStudio => Some("http://127.0.0.1:1234/v1"),
|
||||
Provider::Local | Provider::Cloud => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where requests go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Endpoint {
|
||||
pub provider: Provider,
|
||||
/// Up to and including `/v1`.
|
||||
pub base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Message {
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(role: Role, content: impl Into<String>) -> Message {
|
||||
Message {
|
||||
role,
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChatRequest {
|
||||
pub messages: Vec<Message>,
|
||||
pub max_tokens: u32,
|
||||
pub temperature: f32,
|
||||
/// Constrain the answer to the reply schema (else free text, e.g. for memory summaries).
|
||||
pub structured: bool,
|
||||
}
|
||||
|
||||
impl ChatRequest {
|
||||
/// A companion turn: the reply schema, short, a little creative.
|
||||
pub fn turn(messages: Vec<Message>) -> ChatRequest {
|
||||
ChatRequest {
|
||||
messages,
|
||||
max_tokens: 200,
|
||||
temperature: 0.7,
|
||||
structured: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes the system prompt (and memory) ahead of the first turn, so the runtime has it cached:
|
||||
/// send it once the runtime is ready and ignore the answer. On a 2-core laptop the prefix takes
|
||||
/// about 20 s to process; after this the first real turn starts as fast as the later ones.
|
||||
pub fn warm_up(system: Message) -> ChatRequest {
|
||||
ChatRequest {
|
||||
messages: vec![system, Message::new(Role::User, "[event] Loading.")],
|
||||
max_tokens: 1,
|
||||
temperature: 0.0,
|
||||
structured: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a reply took, from the request being sent.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Stats {
|
||||
pub first_token: Option<Duration>,
|
||||
pub first_sentence: Option<Duration>,
|
||||
pub total: Duration,
|
||||
/// Prompt tokens processed for this request, and those reused from the cache (llama.cpp only).
|
||||
pub prompt_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub completion_tokens: Option<u64>,
|
||||
/// Generation speed as the server measured it, else from the stream.
|
||||
pub tokens_per_second: Option<f64>,
|
||||
pub prompt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
/// A complete sentence of `say`, for chat and the voice.
|
||||
Sentence {
|
||||
id: u64,
|
||||
text: String,
|
||||
},
|
||||
/// The whole reply, parsed.
|
||||
Done {
|
||||
id: u64,
|
||||
reply: Reply,
|
||||
raw: String,
|
||||
stats: Stats,
|
||||
},
|
||||
Failed {
|
||||
id: u64,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn id(&self) -> u64 {
|
||||
match self {
|
||||
Event::Sentence { id, .. } | Event::Done { id, .. } | Event::Failed { id, .. } => *id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
id: u64,
|
||||
request: ChatRequest,
|
||||
}
|
||||
|
||||
/// The brain's connection: a worker thread and two channels.
|
||||
pub struct Brain {
|
||||
jobs: Sender<Job>,
|
||||
events: Receiver<Event>,
|
||||
/// Requests with an id up to this are dropped, mid-stream included.
|
||||
cancelled: Arc<AtomicU64>,
|
||||
next_id: u64,
|
||||
endpoint: Endpoint,
|
||||
}
|
||||
|
||||
impl Brain {
|
||||
pub fn new(endpoint: Endpoint) -> Brain {
|
||||
let (jobs, job_rx) = mpsc::channel::<Job>();
|
||||
let (event_tx, events) = mpsc::channel();
|
||||
let cancelled = Arc::new(AtomicU64::new(0));
|
||||
let (worker_endpoint, worker_cancelled) = (endpoint.clone(), cancelled.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("companion-brain".into())
|
||||
.spawn(move || {
|
||||
let agent = agent_for(&worker_endpoint);
|
||||
for job in job_rx {
|
||||
if job.id <= worker_cancelled.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
let id = job.id;
|
||||
let result = stream_chat(
|
||||
&agent,
|
||||
&worker_endpoint,
|
||||
&job.request,
|
||||
&mut |text| {
|
||||
let _ = event_tx.send(Event::Sentence { id, text });
|
||||
},
|
||||
&|| id <= worker_cancelled.load(Ordering::Relaxed),
|
||||
);
|
||||
let event = match result {
|
||||
Ok((reply, raw, stats)) => Event::Done {
|
||||
id,
|
||||
reply,
|
||||
raw,
|
||||
stats,
|
||||
},
|
||||
Err(error) => Event::Failed { id, error },
|
||||
};
|
||||
if event_tx.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("the brain thread starts");
|
||||
Brain {
|
||||
jobs,
|
||||
events,
|
||||
cancelled,
|
||||
next_id: 1,
|
||||
endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &Endpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// Queues a request; its events carry the returned id.
|
||||
pub fn ask(&mut self, request: ChatRequest) -> u64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let _ = self.jobs.send(Job { id, request });
|
||||
id
|
||||
}
|
||||
|
||||
/// The next event, if one has arrived.
|
||||
pub fn try_recv(&self) -> Option<Event> {
|
||||
match self.events.try_recv() {
|
||||
Ok(event) if event.id() <= self.cancelled.load(Ordering::Relaxed) => self.try_recv(),
|
||||
Ok(event) => Some(event),
|
||||
Err(TryRecvError::Empty | TryRecvError::Disconnected) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `id` and every request before it (the player said something new).
|
||||
pub fn cancel_up_to(&self, id: u64) {
|
||||
self.cancelled.fetch_max(id, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_for(endpoint: &Endpoint) -> ureq::Agent {
|
||||
let local = endpoint.provider != Provider::Cloud;
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_secs(if local { 3 } else { 15 }))
|
||||
// Between two pieces of the stream: a CPU may take a while on a long prompt.
|
||||
.timeout_read(Duration::from_secs(120))
|
||||
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
model: &'a str,
|
||||
messages: &'a [Message],
|
||||
stream: bool,
|
||||
max_tokens: u32,
|
||||
temperature: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
response_format: Option<&'a RawValue>,
|
||||
/// Qwen3.5's template: no thinking (llama.cpp; LM Studio and Ollama ignore it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chat_template_kwargs: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream_options: Option<Value>,
|
||||
/// llama.cpp: reuse the cached prefix.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_prompt: Option<bool>,
|
||||
}
|
||||
|
||||
/// The JSON body of a request; public so tools can log exactly what was sent.
|
||||
pub fn request_body(endpoint: &Endpoint, request: &ChatRequest) -> String {
|
||||
let strict = endpoint.provider == Provider::Cloud;
|
||||
let format = request.structured.then(|| {
|
||||
let schema = if strict { reply::SCHEMA_STRICT } else { reply::SCHEMA };
|
||||
format!(r#"{{"type":"json_schema","json_schema":{{"name":"companion_reply","strict":{strict},"schema":{schema}}}}}"#)
|
||||
});
|
||||
let format = format.map(|f| RawValue::from_string(f).expect("the schema is valid JSON"));
|
||||
let local_llama = endpoint.provider == Provider::Local;
|
||||
let body = Body {
|
||||
model: &endpoint.model,
|
||||
messages: &request.messages,
|
||||
stream: true,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
response_format: format.as_deref(),
|
||||
chat_template_kwargs: (endpoint.provider != Provider::Cloud)
|
||||
.then(|| serde_json::json!({ "enable_thinking": false })),
|
||||
stream_options: (!local_llama).then(|| serde_json::json!({ "include_usage": true })),
|
||||
cache_prompt: local_llama.then_some(true),
|
||||
};
|
||||
serde_json::to_string(&body).expect("the request serialises")
|
||||
}
|
||||
|
||||
/// Sends one request and reads its stream to the end, handing each finished sentence to `sentence`.
|
||||
pub fn stream_chat(
|
||||
agent: &ureq::Agent,
|
||||
endpoint: &Endpoint,
|
||||
request: &ChatRequest,
|
||||
sentence: &mut dyn FnMut(String),
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<(Reply, String, Stats), String> {
|
||||
let started = Instant::now();
|
||||
let mut call = agent
|
||||
.post(&format!(
|
||||
"{}/chat/completions",
|
||||
endpoint.base_url.trim_end_matches('/')
|
||||
))
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "text/event-stream");
|
||||
if let Some(key) = &endpoint.api_key {
|
||||
call = call.set("Authorization", &format!("Bearer {key}"));
|
||||
}
|
||||
let response = match call.send_string(&request_body(endpoint, request)) {
|
||||
Ok(r) => r,
|
||||
Err(ureq::Error::Status(code, r)) => {
|
||||
let text = r.into_string().unwrap_or_default();
|
||||
return Err(format!("{code}: {}", error_message(&text).unwrap_or(text)));
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let mut stats = Stats::default();
|
||||
let (mut raw, mut say, mut sentences) = (String::new(), SayStream::new(), Sentences::new());
|
||||
let mut chunks = 0u64;
|
||||
let mut emit = |text: String, stats: &mut Stats| {
|
||||
stats.first_sentence.get_or_insert(started.elapsed());
|
||||
sentence(text);
|
||||
};
|
||||
for line in BufReader::new(response.into_reader()).lines() {
|
||||
if cancelled() {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let line = line.map_err(|e| format!("the stream broke: {e}"))?;
|
||||
let Some(data) = line.strip_prefix("data:") else {
|
||||
continue;
|
||||
};
|
||||
let data = data.trim();
|
||||
if data == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
let Ok(chunk) = serde_json::from_str::<Value>(data) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(message) = chunk
|
||||
.get("error")
|
||||
.and_then(|e| e.get("message").and_then(Value::as_str))
|
||||
{
|
||||
return Err(message.to_owned());
|
||||
}
|
||||
read_stats(&chunk, &mut stats);
|
||||
let Some(delta) = chunk
|
||||
.pointer("/choices/0/delta/content")
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if delta.is_empty() {
|
||||
continue;
|
||||
}
|
||||
chunks += 1;
|
||||
stats.first_token.get_or_insert(started.elapsed());
|
||||
raw.push_str(delta);
|
||||
let text = say.push(delta);
|
||||
for s in sentences.push(&text) {
|
||||
emit(s, &mut stats);
|
||||
}
|
||||
if say.finished()
|
||||
&& let Some(rest) = sentences.finish()
|
||||
{
|
||||
emit(rest, &mut stats);
|
||||
}
|
||||
}
|
||||
stats.total = started.elapsed();
|
||||
let reply = reply::parse_reply(&raw);
|
||||
// A plain-text answer (no schema support) never went through `say`: send it whole.
|
||||
if !say.finished() {
|
||||
let rest = sentences.finish();
|
||||
let pieces = if reply.valid_json {
|
||||
rest.into_iter().collect()
|
||||
} else {
|
||||
cut_all(&reply.say)
|
||||
};
|
||||
for s in pieces {
|
||||
emit(s, &mut stats);
|
||||
}
|
||||
}
|
||||
stats.completion_tokens.get_or_insert(chunks);
|
||||
if stats.tokens_per_second.is_none()
|
||||
&& let Some(first) = stats.first_token
|
||||
{
|
||||
let secs = (stats.total - first).as_secs_f64();
|
||||
stats.tokens_per_second = (secs > 0.0).then(|| chunks.saturating_sub(1) as f64 / secs);
|
||||
}
|
||||
Ok((reply, raw, stats))
|
||||
}
|
||||
|
||||
fn cut_all(text: &str) -> Vec<String> {
|
||||
let mut s = Sentences::new();
|
||||
let mut out = s.push(text);
|
||||
out.extend(s.finish());
|
||||
out
|
||||
}
|
||||
|
||||
/// llama.cpp's `timings` (last chunk) and the OpenAI `usage` (Ollama, LM Studio, cloud).
|
||||
fn read_stats(chunk: &Value, stats: &mut Stats) {
|
||||
if let Some(t) = chunk.get("timings") {
|
||||
stats.prompt_tokens = t
|
||||
.get("prompt_n")
|
||||
.and_then(Value::as_u64)
|
||||
.or(stats.prompt_tokens);
|
||||
stats.cached_tokens = t
|
||||
.get("cache_n")
|
||||
.and_then(Value::as_u64)
|
||||
.or(stats.cached_tokens);
|
||||
stats.completion_tokens = t
|
||||
.get("predicted_n")
|
||||
.and_then(Value::as_u64)
|
||||
.or(stats.completion_tokens);
|
||||
stats.tokens_per_second = t
|
||||
.get("predicted_per_second")
|
||||
.and_then(Value::as_f64)
|
||||
.or(stats.tokens_per_second);
|
||||
stats.prompt_ms = t
|
||||
.get("prompt_ms")
|
||||
.and_then(Value::as_f64)
|
||||
.or(stats.prompt_ms);
|
||||
}
|
||||
if let Some(u) = chunk.get("usage").filter(|u| u.is_object()) {
|
||||
stats.prompt_tokens = stats
|
||||
.prompt_tokens
|
||||
.or(u.get("prompt_tokens").and_then(Value::as_u64));
|
||||
stats.completion_tokens = stats
|
||||
.completion_tokens
|
||||
.or(u.get("completion_tokens").and_then(Value::as_u64));
|
||||
}
|
||||
}
|
||||
|
||||
fn error_message(body: &str) -> Option<String> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
v.pointer("/error/message")
|
||||
.and_then(Value::as_str)
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Another AI program already running on this PC.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Detected {
|
||||
pub provider: Provider,
|
||||
pub base_url: String,
|
||||
/// The models it offers, by the id requests take.
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Looks for Ollama and LM Studio on their default ports (a fraction of a second when neither runs).
|
||||
/// Blocking: call it from a worker, or use [`detect_in_background`].
|
||||
pub fn detect_local_servers() -> Vec<Detected> {
|
||||
[Provider::Ollama, Provider::LmStudio]
|
||||
.into_iter()
|
||||
.filter_map(|provider| {
|
||||
let base_url = provider.default_base_url()?.to_owned();
|
||||
let models = list_models(&base_url, None, Duration::from_millis(400)).ok()?;
|
||||
Some(Detected {
|
||||
provider,
|
||||
base_url,
|
||||
models,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn detect_in_background() -> Receiver<Vec<Detected>> {
|
||||
let (send, recv) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = send.send(detect_local_servers());
|
||||
});
|
||||
recv
|
||||
}
|
||||
|
||||
/// `GET /models`: the ids an OpenAI-compatible server offers.
|
||||
pub fn list_models(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(timeout)
|
||||
.timeout(timeout * 3)
|
||||
.build();
|
||||
let mut call = agent.get(&format!("{}/models", base_url.trim_end_matches('/')));
|
||||
if let Some(key) = api_key {
|
||||
call = call.set("Authorization", &format!("Bearer {key}"));
|
||||
}
|
||||
let text = call
|
||||
.call()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_string()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let body: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
||||
Ok(body
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|m| m.get("id").and_then(Value::as_str).map(String::from))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// Serves one canned SSE response per connection and records the request bodies.
|
||||
fn sse_server(events: Vec<String>) -> (String, Arc<std::sync::Mutex<Vec<String>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/v1", listener.local_addr().unwrap());
|
||||
let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let seen = bodies.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { break };
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut length = 0;
|
||||
let mut path = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if path.is_empty() {
|
||||
path = line.clone();
|
||||
}
|
||||
if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
||||
length = v.trim().parse().unwrap();
|
||||
}
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut body = vec![0; length];
|
||||
std::io::Read::read_exact(&mut reader, &mut body).unwrap();
|
||||
seen.lock().unwrap().push(String::from_utf8(body).unwrap());
|
||||
if path.starts_with("GET /v1/models") {
|
||||
let json =
|
||||
r#"{"object":"list","data":[{"id":"qwen3.5:4b"},{"id":"gemma4:12b"}]}"#;
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{json}",
|
||||
json.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
for e in &events {
|
||||
let _ = write!(stream, "data: {e}\n\n");
|
||||
let _ = stream.flush();
|
||||
}
|
||||
let _ = write!(stream, "data: [DONE]\n\n");
|
||||
}
|
||||
});
|
||||
(url, bodies)
|
||||
}
|
||||
|
||||
fn deltas(reply: &str, size: usize) -> Vec<String> {
|
||||
let chars: Vec<char> = reply.chars().collect();
|
||||
let mut events: Vec<String> = chars
|
||||
.chunks(size)
|
||||
.map(|c| {
|
||||
let piece: String = c.iter().collect();
|
||||
serde_json::json!({"choices":[{"index":0,"delta":{"content":piece}}]}).to_string()
|
||||
})
|
||||
.collect();
|
||||
events.push(r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"timings":{"prompt_n":42,"cache_n":900,"predicted_n":31,"predicted_per_second":88.5,"prompt_ms":35.2}}"#.into());
|
||||
events
|
||||
}
|
||||
|
||||
fn local(url: String) -> Endpoint {
|
||||
Endpoint {
|
||||
provider: Provider::Local,
|
||||
base_url: url,
|
||||
api_key: Some("secret".into()),
|
||||
model: "horizon-companion".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_sentences_then_the_parsed_reply() {
|
||||
let json = r#"{"tone":"sarcastic","say":"Oh great, a creeper. Lovely. I'll handle it.","actions":[{"skill":"attack","target":"creeper"}]}"#;
|
||||
let (url, bodies) = sse_server(deltas(json, 3));
|
||||
let mut brain = Brain::new(local(url));
|
||||
let id = brain.ask(ChatRequest::turn(vec![Message::new(Role::User, "help")]));
|
||||
let mut sentences = Vec::new();
|
||||
let done = loop {
|
||||
match brain.try_recv() {
|
||||
Some(Event::Sentence { id: got, text }) => {
|
||||
assert_eq!(got, id);
|
||||
sentences.push(text);
|
||||
}
|
||||
Some(Event::Done {
|
||||
reply, stats, raw, ..
|
||||
}) => break (reply, stats, raw),
|
||||
Some(Event::Failed { error, .. }) => panic!("{error}"),
|
||||
None => std::thread::sleep(Duration::from_millis(2)),
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
sentences,
|
||||
["Oh great, a creeper.", "Lovely.", "I'll handle it."]
|
||||
);
|
||||
let (reply, stats, raw) = done;
|
||||
assert_eq!(raw, json);
|
||||
assert_eq!(reply.actions[0].skill, reply::Skill::Attack);
|
||||
assert_eq!(
|
||||
(
|
||||
stats.prompt_tokens,
|
||||
stats.cached_tokens,
|
||||
stats.completion_tokens
|
||||
),
|
||||
(Some(42), Some(900), Some(31))
|
||||
);
|
||||
assert!(stats.first_token.unwrap() <= stats.first_sentence.unwrap());
|
||||
let body: Value = serde_json::from_str(&bodies.lock().unwrap()[0]).unwrap();
|
||||
assert_eq!(body["stream"], true);
|
||||
assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false);
|
||||
assert_eq!(body["response_format"]["type"], "json_schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_schema_reaches_the_server_in_its_written_order() {
|
||||
let body = request_body(&local("http://x/v1".into()), &ChatRequest::turn(vec![]));
|
||||
let (tone, say, actions) = (
|
||||
body.find("\"tone\":{").unwrap(),
|
||||
body.find("\"say\":{").unwrap(),
|
||||
body.find("\"actions\":{").unwrap(),
|
||||
);
|
||||
assert!(tone < say && say < actions, "{body}");
|
||||
let cloud = Endpoint {
|
||||
provider: Provider::Cloud,
|
||||
..local("https://api.example.com/v1".into())
|
||||
};
|
||||
let body: Value =
|
||||
serde_json::from_str(&request_body(&cloud, &ChatRequest::turn(vec![]))).unwrap();
|
||||
assert_eq!(body["response_format"]["json_schema"]["strict"], true);
|
||||
assert!(
|
||||
body.get("chat_template_kwargs").is_none(),
|
||||
"cloud APIs reject unknown fields"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_answers_still_reach_chat() {
|
||||
let (url, _) = sse_server(deltas("Sure thing! Following you.", 4));
|
||||
let endpoint = Endpoint {
|
||||
provider: Provider::Ollama,
|
||||
..local(url)
|
||||
};
|
||||
let agent = agent_for(&endpoint);
|
||||
let mut said = Vec::new();
|
||||
let (reply, _, _) = stream_chat(
|
||||
&agent,
|
||||
&endpoint,
|
||||
&ChatRequest::turn(vec![]),
|
||||
&mut |s| said.push(s),
|
||||
&|| false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!reply.valid_json);
|
||||
assert_eq!(said, ["Sure thing!", "Following you."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_cancels_are_reported() {
|
||||
let (url, _) = sse_server(vec![r#"{"error":{"message":"context full"}}"#.into()]);
|
||||
let mut brain = Brain::new(local(url.clone()));
|
||||
brain.ask(ChatRequest::turn(vec![]));
|
||||
let error = loop {
|
||||
match brain.try_recv() {
|
||||
Some(Event::Failed { error, .. }) => break error,
|
||||
Some(other) => panic!("{other:?}"),
|
||||
None => std::thread::sleep(Duration::from_millis(2)),
|
||||
}
|
||||
};
|
||||
assert_eq!(error, "context full");
|
||||
|
||||
let (url, _) = sse_server(deltas(
|
||||
r#"{"tone":"normal","say":"one. two.","actions":[]}"#,
|
||||
1,
|
||||
));
|
||||
let mut brain = Brain::new(local(url));
|
||||
let first = brain.ask(ChatRequest::turn(vec![]));
|
||||
brain.cancel_up_to(first);
|
||||
let second = brain.ask(ChatRequest::turn(vec![]));
|
||||
let got = loop {
|
||||
if let Some(e) = brain.try_recv() {
|
||||
break e;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
};
|
||||
assert_eq!(got.id(), second, "the cancelled request sends nothing");
|
||||
let _ = brain.endpoint();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_models_like_ollama_and_lm_studio() {
|
||||
let (url, _) = sse_server(vec![]);
|
||||
assert_eq!(
|
||||
list_models(&url, None, Duration::from_secs(1)).unwrap(),
|
||||
["qwen3.5:4b", "gemma4:12b"]
|
||||
);
|
||||
assert!(list_models("http://127.0.0.1:9/v1", None, Duration::from_millis(200)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
//! Downloads and installs under `<Horizon data>/companion/`: `runtime/<build>/` and `models/<id>/`.
|
||||
//!
|
||||
//! A download goes to a `.part` file that survives a cancel, a lost connection or a closed game, and
|
||||
//! resumes from where it stopped with an HTTP Range request (the bytes already there are hashed first,
|
||||
//! so the file is read once). It is checked against the pinned size and SHA-256, and only a complete,
|
||||
//! verified folder is renamed into place, with an `.installed` marker written last: a half-installed
|
||||
//! runtime or model never looks installed.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::catalogue::{Model, RuntimeBuild};
|
||||
|
||||
const INSTALLED: &str = ".installed";
|
||||
/// Lost connections are resumed this many times before the download fails.
|
||||
const RETRIES: u32 = 4;
|
||||
/// Kept free on the disk after a download, so the player's worlds can still save.
|
||||
const DISK_MARGIN: u64 = 256 << 20;
|
||||
|
||||
/// One file to fetch: where from, and what it must be.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Fetch {
|
||||
pub url: String,
|
||||
/// Its path inside the install folder.
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// Hashing the part already downloaded.
|
||||
Resuming,
|
||||
Downloading,
|
||||
Unpacking,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
pub phase: Phase,
|
||||
/// Bytes of the whole install (every file), done and in total.
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
pub fn fraction(&self) -> f32 {
|
||||
(self.done as f64 / self.total.max(1) as f64).min(1.0) as f32
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Cancelled,
|
||||
/// The server answered with an error or the connection failed `RETRIES` times.
|
||||
Http(String),
|
||||
Io(io::Error),
|
||||
Checksum {
|
||||
file: String,
|
||||
expected: String,
|
||||
actual: String,
|
||||
},
|
||||
Size {
|
||||
file: String,
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
NoSpace {
|
||||
needed: u64,
|
||||
free: u64,
|
||||
},
|
||||
Archive(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Cancelled => write!(f, "Download cancelled"),
|
||||
Error::Http(e) => write!(f, "Download failed: {e}"),
|
||||
Error::Io(e) => write!(f, "Disk error: {e}"),
|
||||
Error::Checksum { file, .. } => {
|
||||
write!(
|
||||
f,
|
||||
"{file} is damaged (its SHA-256 doesn't match); it will download again"
|
||||
)
|
||||
}
|
||||
Error::Size {
|
||||
file,
|
||||
expected,
|
||||
actual,
|
||||
} => {
|
||||
write!(f, "{file} has {actual} bytes instead of {expected}")
|
||||
}
|
||||
Error::NoSpace { needed, free } => write!(
|
||||
f,
|
||||
"Not enough disk space: {} MB needed, {} MB free",
|
||||
needed >> 20,
|
||||
free >> 20
|
||||
),
|
||||
Error::Archive(e) => write!(f, "The runtime archive can't be unpacked: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Error::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// `<Horizon data>/companion`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Layout {
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn new(data_dir: &Path) -> Layout {
|
||||
Layout {
|
||||
root: data_dir.join("companion"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_dir(&self, build: &RuntimeBuild) -> PathBuf {
|
||||
self.root.join("runtime").join(build.key())
|
||||
}
|
||||
|
||||
pub fn model_dir(&self, model: &Model) -> PathBuf {
|
||||
self.root.join("models").join(model.id)
|
||||
}
|
||||
|
||||
/// `llama-server` of an installed build.
|
||||
pub fn installed_runtime(&self, build: &RuntimeBuild) -> Option<PathBuf> {
|
||||
let dir = self.runtime_dir(build);
|
||||
dir.join(INSTALLED)
|
||||
.exists()
|
||||
.then(|| dir.join(build.server_exe()))
|
||||
}
|
||||
|
||||
/// The file the runtime loads, once every file of the model is installed.
|
||||
pub fn installed_model(&self, model: &Model) -> Option<PathBuf> {
|
||||
let dir = self.model_dir(model);
|
||||
dir.join(INSTALLED)
|
||||
.exists()
|
||||
.then(|| dir.join(model.main_file().path))
|
||||
}
|
||||
|
||||
pub fn install_runtime(
|
||||
&self,
|
||||
build: &RuntimeBuild,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let fetch = Fetch {
|
||||
url: build.url(),
|
||||
path: build.file.to_owned(),
|
||||
size: build.size,
|
||||
sha256: build.sha256.to_owned(),
|
||||
};
|
||||
let dir = self.runtime_dir(build);
|
||||
install_archive(&fetch, build.server_exe(), &dir, progress, cancel)?;
|
||||
Ok(dir.join(build.server_exe()))
|
||||
}
|
||||
|
||||
pub fn install_model(
|
||||
&self,
|
||||
model: &Model,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let files: Vec<Fetch> = model
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| Fetch {
|
||||
url: model.url(f),
|
||||
path: f.path.to_owned(),
|
||||
size: f.size,
|
||||
sha256: f.sha256.to_owned(),
|
||||
})
|
||||
.collect();
|
||||
let dir = self.model_dir(model);
|
||||
install_files(&files, &dir, progress, cancel)?;
|
||||
Ok(dir.join(model.main_file().path))
|
||||
}
|
||||
|
||||
/// Removes a runtime build, its unfinished download included.
|
||||
pub fn delete_runtime(&self, build: &RuntimeBuild) -> io::Result<()> {
|
||||
let dir = self.runtime_dir(build);
|
||||
remove_all(&partial_dir(&dir))?;
|
||||
remove_all(&dir)
|
||||
}
|
||||
|
||||
/// Removes a model, its unfinished download included.
|
||||
pub fn delete_model(&self, model: &Model) -> io::Result<()> {
|
||||
let dir = self.model_dir(model);
|
||||
remove_all(&partial_dir(&dir))?;
|
||||
remove_all(&dir)
|
||||
}
|
||||
|
||||
/// Bytes on disk under `companion/`, downloads in progress included.
|
||||
pub fn disk_usage(&self) -> u64 {
|
||||
fn walk(path: &Path) -> u64 {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) if m.is_dir() => fs::read_dir(path)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| walk(&e.path()))
|
||||
.sum(),
|
||||
Ok(m) => m.len(),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
walk(&self.root)
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_all(path: &Path) -> io::Result<()> {
|
||||
match fs::remove_dir_all(path) {
|
||||
Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `models/qwen` → `models/.qwen.partial`: beside it, so the final rename stays on one disk.
|
||||
fn partial_dir(dir: &Path) -> PathBuf {
|
||||
let name = dir
|
||||
.file_name()
|
||||
.map_or_else(Default::default, |n| n.to_string_lossy().into_owned());
|
||||
dir.with_file_name(format!(".{name}.partial"))
|
||||
}
|
||||
|
||||
/// Downloads `files` into `dir` (replacing what was there) once all of them are verified.
|
||||
pub fn install_files(
|
||||
files: &[Fetch],
|
||||
dir: &Path,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<(), Error> {
|
||||
let partial = partial_dir(dir);
|
||||
fs::create_dir_all(&partial)?;
|
||||
let total: u64 = files.iter().map(|f| f.size).sum();
|
||||
check_space(&partial, files.iter().map(|f| remaining(&partial, f)).sum())?;
|
||||
let mut base = 0;
|
||||
for file in files {
|
||||
let part = part_path(&partial, file);
|
||||
if let Some(parent) = part.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fetch(
|
||||
file,
|
||||
&part,
|
||||
&mut |p| progress(offset(p, base, total)),
|
||||
cancel,
|
||||
)?;
|
||||
base += file.size;
|
||||
}
|
||||
for file in files {
|
||||
fs::rename(part_path(&partial, file), partial.join(&file.path))?;
|
||||
}
|
||||
finish(&partial, dir)
|
||||
}
|
||||
|
||||
/// Downloads an archive, unpacks it and installs the folder that holds `exe` as `dir`.
|
||||
pub fn install_archive(
|
||||
archive: &Fetch,
|
||||
exe: &str,
|
||||
dir: &Path,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<(), Error> {
|
||||
let partial = partial_dir(dir);
|
||||
fs::create_dir_all(&partial)?;
|
||||
// Unpacked, an archive takes about three times its size.
|
||||
check_space(&partial, remaining(&partial, archive) + archive.size * 3)?;
|
||||
let part = part_path(&partial, archive);
|
||||
let total = archive.size;
|
||||
fetch(archive, &part, progress, cancel)?;
|
||||
progress(Progress {
|
||||
phase: Phase::Unpacking,
|
||||
done: total,
|
||||
total,
|
||||
});
|
||||
let unpacked = partial.join("unpacked");
|
||||
remove_all(&unpacked)?;
|
||||
unpack(&part, &archive.path, &unpacked)?;
|
||||
let top = find_dir_with(&unpacked, exe, 3)
|
||||
.ok_or_else(|| Error::Archive(format!("it has no {exe}")))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(top.join(exe), fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
let staged = partial.join("staged");
|
||||
remove_all(&staged)?;
|
||||
fs::rename(&top, &staged)?;
|
||||
let _ = fs::remove_file(&part);
|
||||
remove_all(&unpacked)?;
|
||||
finish(&staged, dir)?;
|
||||
remove_all(&partial)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves a verified folder into place and marks it installed.
|
||||
fn finish(staged: &Path, dir: &Path) -> Result<(), Error> {
|
||||
remove_all(dir)?;
|
||||
fs::rename(staged, dir)?;
|
||||
fs::write(dir.join(INSTALLED), "")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn part_path(partial: &Path, file: &Fetch) -> PathBuf {
|
||||
partial.join(format!("{}.part", file.path))
|
||||
}
|
||||
|
||||
fn remaining(partial: &Path, file: &Fetch) -> u64 {
|
||||
let have = fs::metadata(part_path(partial, file)).map_or(0, |m| m.len());
|
||||
file.size.saturating_sub(have)
|
||||
}
|
||||
|
||||
fn offset(p: Progress, base: u64, total: u64) -> Progress {
|
||||
Progress {
|
||||
phase: p.phase,
|
||||
done: base + p.done,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_space(dir: &Path, needed: u64) -> Result<(), Error> {
|
||||
if let Some(free) = free_space(dir)
|
||||
&& free < needed + DISK_MARGIN
|
||||
{
|
||||
return Err(Error::NoSpace {
|
||||
needed: needed + DISK_MARGIN,
|
||||
free,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads `file` into `part`, resuming what is already there, and checks it.
|
||||
pub fn fetch(
|
||||
file: &Fetch,
|
||||
part: &Path,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<(), Error> {
|
||||
let mut out = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(part)?;
|
||||
let mut have = out.metadata()?.len();
|
||||
if have > file.size {
|
||||
out.set_len(0)?;
|
||||
have = 0;
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
if have > 0 {
|
||||
hash_prefix(
|
||||
&mut out,
|
||||
have,
|
||||
&mut hasher,
|
||||
&mut |done| {
|
||||
progress(Progress {
|
||||
phase: Phase::Resuming,
|
||||
done,
|
||||
total: file.size,
|
||||
})
|
||||
},
|
||||
cancel,
|
||||
)?;
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_secs(15))
|
||||
.timeout_read(Duration::from_secs(30))
|
||||
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
|
||||
.build();
|
||||
let mut failures = 0;
|
||||
while have < file.size {
|
||||
match fetch_from(
|
||||
&agent,
|
||||
file,
|
||||
&mut out,
|
||||
&mut have,
|
||||
&mut hasher,
|
||||
progress,
|
||||
cancel,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(Error::Http(e)) if failures < RETRIES => {
|
||||
failures += 1;
|
||||
std::thread::sleep(Duration::from_millis(500 * u64::from(failures)));
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let _ = e;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
out.flush()?;
|
||||
drop(out);
|
||||
let actual = hex(&hasher.finalize());
|
||||
if actual != file.sha256.to_ascii_lowercase() {
|
||||
let _ = fs::remove_file(part);
|
||||
return Err(Error::Checksum {
|
||||
file: file.path.clone(),
|
||||
expected: file.sha256.clone(),
|
||||
actual,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One request from `have` on; a dropped connection returns `Error::Http` with the bytes so far kept.
|
||||
fn fetch_from(
|
||||
agent: &ureq::Agent,
|
||||
file: &Fetch,
|
||||
out: &mut fs::File,
|
||||
have: &mut u64,
|
||||
hasher: &mut Sha256,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<(), Error> {
|
||||
let mut request = agent.get(&file.url);
|
||||
if *have > 0 {
|
||||
request = request.set("Range", &format!("bytes={have}-"));
|
||||
}
|
||||
let response = match request.call() {
|
||||
Ok(r) => r,
|
||||
// 416: the part is already whole or the server's file changed; start over.
|
||||
Err(ureq::Error::Status(416, _)) => {
|
||||
restart(out, have, hasher)?;
|
||||
return Err(Error::Http("the server refused to resume".into()));
|
||||
}
|
||||
Err(ureq::Error::Status(code, r)) => {
|
||||
return Err(Error::Http(format!(
|
||||
"{} answered {code} {}",
|
||||
host(&file.url),
|
||||
r.status_text()
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(Error::Http(e.to_string())),
|
||||
};
|
||||
if *have > 0 && response.status() != 206 {
|
||||
// The server ignored the range and sends the whole file.
|
||||
restart(out, have, hasher)?;
|
||||
}
|
||||
let mut reader = response.into_reader();
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
let mut last = Instant::now();
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let n = match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(Error::Http(format!("connection lost: {e}"))),
|
||||
};
|
||||
let n = n.min((file.size - *have) as usize);
|
||||
out.write_all(&buf[..n])?;
|
||||
hasher.update(&buf[..n]);
|
||||
*have += n as u64;
|
||||
if last.elapsed() >= Duration::from_millis(100) || *have == file.size {
|
||||
last = Instant::now();
|
||||
progress(Progress {
|
||||
phase: Phase::Downloading,
|
||||
done: *have,
|
||||
total: file.size,
|
||||
});
|
||||
}
|
||||
if *have == file.size {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if *have < file.size {
|
||||
return Err(Error::Http(format!(
|
||||
"the connection closed at {} of {} bytes",
|
||||
*have, file.size
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restart(out: &mut fs::File, have: &mut u64, hasher: &mut Sha256) -> io::Result<()> {
|
||||
out.set_len(0)?;
|
||||
out.seek(SeekFrom::Start(0))?;
|
||||
*have = 0;
|
||||
*hasher = Sha256::new();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_prefix(
|
||||
file: &mut fs::File,
|
||||
len: u64,
|
||||
hasher: &mut Sha256,
|
||||
progress: &mut dyn FnMut(u64),
|
||||
cancel: &AtomicBool,
|
||||
) -> Result<(), Error> {
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut reader = file.take(len);
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
let mut done = 0;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let n = reader.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
done += n as u64;
|
||||
progress(done);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn host(url: &str) -> &str {
|
||||
url.split("//")
|
||||
.nth(1)
|
||||
.and_then(|r| r.split('/').next())
|
||||
.unwrap_or(url)
|
||||
}
|
||||
|
||||
pub(crate) fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// SHA-256 of a file on disk, as lowercase hex.
|
||||
pub fn sha256_file(path: &Path) -> io::Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
io::copy(&mut file, &mut hasher)?;
|
||||
Ok(hex(&hasher.finalize()))
|
||||
}
|
||||
|
||||
/// `.tar.gz` (macOS, Linux: library symlinks kept) or `.zip` (Windows). Both libraries refuse entries
|
||||
/// that would land outside `to`.
|
||||
fn unpack(archive: &Path, name: &str, to: &Path) -> Result<(), Error> {
|
||||
let file = fs::File::open(archive)?;
|
||||
fs::create_dir_all(to)?;
|
||||
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
|
||||
tar::Archive::new(flate2::read::GzDecoder::new(file))
|
||||
.unpack(to)
|
||||
.map_err(|e| Error::Archive(e.to_string()))
|
||||
} else if name.ends_with(".zip") {
|
||||
zip::ZipArchive::new(file)
|
||||
.and_then(|mut z| z.extract(to))
|
||||
.map_err(|e| Error::Archive(e.to_string()))
|
||||
} else {
|
||||
Err(Error::Archive(format!("unknown archive type: {name}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn find_dir_with(dir: &Path, file: &str, depth: u32) -> Option<PathBuf> {
|
||||
if dir.join(file).is_file() {
|
||||
return Some(dir.to_owned());
|
||||
}
|
||||
if depth == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut subdirs: Vec<PathBuf> = fs::read_dir(dir)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
subdirs.sort();
|
||||
subdirs
|
||||
.into_iter()
|
||||
.find_map(|d| find_dir_with(&d, file, depth - 1))
|
||||
}
|
||||
|
||||
/// Free bytes on the disk holding `dir`.
|
||||
#[cfg(unix)]
|
||||
pub fn free_space(dir: &Path) -> Option<u64> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path = std::ffi::CString::new(dir.as_os_str().as_bytes()).ok()?;
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: `path` is a valid C string and `stat` a writable statvfs.
|
||||
if unsafe { libc::statvfs(path.as_ptr(), &mut stat) } != 0 {
|
||||
return None;
|
||||
}
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
Some(stat.f_bavail as u64 * stat.f_frsize as u64)
|
||||
}
|
||||
|
||||
/// Free bytes on the disk holding `dir`.
|
||||
#[cfg(windows)]
|
||||
pub fn free_space(dir: &Path) -> Option<u64> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let wide: Vec<u16> = dir.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let mut free = 0u64;
|
||||
// SAFETY: `wide` is NUL-terminated; the null pointers are optional outputs.
|
||||
let ok = unsafe {
|
||||
windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
|
||||
wide.as_ptr(),
|
||||
&mut free,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
(ok != 0).then_some(free)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub fn free_space(_dir: &Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A one-file HTTP server: honours `Range` unless `ignore_range`, and the first `drops` responses
|
||||
/// close the connection after `drop_after` bytes. It records each request's Range header.
|
||||
pub(crate) struct Server {
|
||||
pub url: String,
|
||||
pub ranges: Arc<Mutex<Vec<Option<String>>>>,
|
||||
}
|
||||
|
||||
pub(crate) fn serve(
|
||||
body: Vec<u8>,
|
||||
ignore_range: bool,
|
||||
drops: usize,
|
||||
drop_after: usize,
|
||||
) -> Server {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/file.bin", listener.local_addr().unwrap());
|
||||
let ranges = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = ranges.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut drops = drops;
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { break };
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut range = None;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") {
|
||||
range = Some(v.trim().to_owned());
|
||||
}
|
||||
}
|
||||
seen.lock().unwrap().push(range.clone());
|
||||
let start = match &range {
|
||||
Some(r) if !ignore_range => r.trim_end_matches('-').parse::<usize>().unwrap(),
|
||||
_ => 0,
|
||||
};
|
||||
let part = &body[start.min(body.len())..];
|
||||
let head = if start > 0 {
|
||||
format!(
|
||||
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{}/{}\r\nConnection: close\r\n\r\n",
|
||||
part.len(),
|
||||
body.len() - 1,
|
||||
body.len()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
part.len()
|
||||
)
|
||||
};
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
if drops > 0 {
|
||||
drops -= 1;
|
||||
let _ = stream.write_all(&part[..drop_after.min(part.len())]);
|
||||
} else {
|
||||
let _ = stream.write_all(part);
|
||||
}
|
||||
}
|
||||
});
|
||||
Server { url, ranges }
|
||||
}
|
||||
|
||||
pub(crate) fn scratch(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-companion-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn body(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i * 7 % 251) as u8).collect()
|
||||
}
|
||||
|
||||
fn fetch_of(server: &Server, body: &[u8]) -> Fetch {
|
||||
Fetch {
|
||||
url: server.url.clone(),
|
||||
path: "file.bin".into(),
|
||||
size: body.len() as u64,
|
||||
sha256: hex(&Sha256::digest(body)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_and_verifies() {
|
||||
let data = body(300_000);
|
||||
let server = serve(data.clone(), false, 0, 0);
|
||||
let dir = scratch("plain");
|
||||
let part = dir.join("file.bin.part");
|
||||
let mut last = None;
|
||||
fetch(
|
||||
&fetch_of(&server, &data),
|
||||
&part,
|
||||
&mut |p| last = Some(p),
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&part).unwrap(), data);
|
||||
let last = last.unwrap();
|
||||
assert_eq!(
|
||||
(last.phase, last.done, last.total),
|
||||
(Phase::Downloading, 300_000, 300_000)
|
||||
);
|
||||
assert_eq!(*server.ranges.lock().unwrap(), [None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_a_part_with_a_range_request() {
|
||||
let data = body(200_000);
|
||||
let server = serve(data.clone(), false, 0, 0);
|
||||
let dir = scratch("resume");
|
||||
let part = dir.join("file.bin.part");
|
||||
fs::write(&part, &data[..123_456]).unwrap();
|
||||
let mut phases = Vec::new();
|
||||
fetch(
|
||||
&fetch_of(&server, &data),
|
||||
&part,
|
||||
&mut |p| phases.push(p.phase),
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&part).unwrap(), data);
|
||||
assert_eq!(*server.ranges.lock().unwrap(), [Some("123456-".to_owned())]);
|
||||
assert_eq!(phases.first(), Some(&Phase::Resuming));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_over_when_the_server_ignores_the_range() {
|
||||
let data = body(50_000);
|
||||
let server = serve(data.clone(), true, 0, 0);
|
||||
let dir = scratch("norange");
|
||||
let part = dir.join("file.bin.part");
|
||||
fs::write(&part, &data[..10_000]).unwrap();
|
||||
fetch(
|
||||
&fetch_of(&server, &data),
|
||||
&part,
|
||||
&mut |_| {},
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&part).unwrap(), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lost_connection_resumes_on_its_own() {
|
||||
let data = body(400_000);
|
||||
let server = serve(data.clone(), false, 2, 100_000);
|
||||
let dir = scratch("dropped");
|
||||
let part = dir.join("file.bin.part");
|
||||
fetch(
|
||||
&fetch_of(&server, &data),
|
||||
&part,
|
||||
&mut |_| {},
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&part).unwrap(), data);
|
||||
assert_eq!(
|
||||
*server.ranges.lock().unwrap(),
|
||||
[None, Some("100000-".to_owned()), Some("200000-".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_is_rejected_and_removed() {
|
||||
let data = body(10_000);
|
||||
let server = serve(data.clone(), false, 0, 0);
|
||||
let dir = scratch("damaged");
|
||||
let part = dir.join("file.bin.part");
|
||||
let mut wrong = fetch_of(&server, &data);
|
||||
wrong.sha256 = "0".repeat(64);
|
||||
let err = fetch(&wrong, &part, &mut |_| {}, &AtomicBool::new(false)).unwrap_err();
|
||||
assert!(matches!(err, Error::Checksum { .. }), "{err}");
|
||||
assert!(!part.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_keeps_the_part_for_later() {
|
||||
let data = body(10_000);
|
||||
let server = serve(data.clone(), false, 0, 0);
|
||||
let dir = scratch("cancel");
|
||||
let part = dir.join("file.bin.part");
|
||||
fs::write(&part, &data[..5_000]).unwrap();
|
||||
let err = fetch(
|
||||
&fetch_of(&server, &data),
|
||||
&part,
|
||||
&mut |_| {},
|
||||
&AtomicBool::new(true),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::Cancelled));
|
||||
assert_eq!(fs::metadata(&part).unwrap().len(), 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_fill_the_disk() {
|
||||
let dir = scratch("space");
|
||||
let huge = Fetch {
|
||||
url: "http://127.0.0.1:9/never".into(),
|
||||
path: "huge.bin".into(),
|
||||
size: 1 << 60,
|
||||
sha256: "0".repeat(64),
|
||||
};
|
||||
let err = install_files(
|
||||
&[huge],
|
||||
&dir.join("models/huge"),
|
||||
&mut |_| {},
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::NoSpace { .. }), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_model_files_atomically_and_deletes_them() {
|
||||
let data = body(70_000);
|
||||
let server = serve(data.clone(), false, 0, 0);
|
||||
let dir = scratch("model");
|
||||
let target = dir.join("companion/models/test-model");
|
||||
let file = fetch_of(&server, &data);
|
||||
let mut fractions = Vec::new();
|
||||
install_files(
|
||||
&[file],
|
||||
&target,
|
||||
&mut |p| fractions.push(p.fraction()),
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(target.join("file.bin")).unwrap(), data);
|
||||
assert!(target.join(INSTALLED).exists());
|
||||
assert!(
|
||||
!partial_dir(&target).exists()
|
||||
|| fs::read_dir(partial_dir(&target)).unwrap().next().is_none()
|
||||
);
|
||||
assert_eq!(fractions.last(), Some(&1.0));
|
||||
remove_all(&target).unwrap();
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
fn tar_gz(entries: &[(&str, &[u8])], links: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
Vec::new(),
|
||||
flate2::Compression::fast(),
|
||||
));
|
||||
for (path, data) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, path, *data).unwrap();
|
||||
}
|
||||
for (path, target) in links {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Symlink);
|
||||
header.set_size(0);
|
||||
header.set_cksum();
|
||||
builder.append_link(&mut header, path, target).unwrap();
|
||||
}
|
||||
builder.into_inner().unwrap().finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_a_tar_gz_runtime_with_its_symlinks() {
|
||||
let archive = tar_gz(
|
||||
&[
|
||||
("llama-b1/llama-server", b"#!/bin/sh\n"),
|
||||
("llama-b1/libllama.0.1.dylib", b"lib"),
|
||||
],
|
||||
&[("llama-b1/libllama.dylib", "libllama.0.1.dylib")],
|
||||
);
|
||||
let server = serve(archive.clone(), false, 0, 0);
|
||||
let dir = scratch("tar");
|
||||
let target = dir.join("companion/runtime/b1-test");
|
||||
let mut fetch = fetch_of(&server, &archive);
|
||||
fetch.path = "llama-b1-bin-macos-arm64.tar.gz".into();
|
||||
install_archive(
|
||||
&fetch,
|
||||
"llama-server",
|
||||
&target,
|
||||
&mut |_| {},
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(target.join("llama-server").is_file());
|
||||
assert!(target.join(INSTALLED).exists());
|
||||
assert!(!partial_dir(&target).exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(target.join("llama-server"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o111, 0o111);
|
||||
assert_eq!(
|
||||
fs::read_link(target.join("libllama.dylib")).unwrap(),
|
||||
Path::new("libllama.0.1.dylib")
|
||||
);
|
||||
assert_eq!(fs::read(target.join("libllama.dylib")).unwrap(), b"lib");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_a_flat_zip_runtime() {
|
||||
let mut zip = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
|
||||
for (name, data) in [
|
||||
("llama-server.exe", &b"MZ"[..]),
|
||||
("ggml-vulkan.dll", b"dll"),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
let archive = zip.finish().unwrap().into_inner();
|
||||
let server = serve(archive.clone(), false, 0, 0);
|
||||
let dir = scratch("zip");
|
||||
let target = dir.join("companion/runtime/b1-win");
|
||||
let mut fetch = fetch_of(&server, &archive);
|
||||
fetch.path = "llama-b1-bin-win-vulkan-x64.zip".into();
|
||||
install_archive(
|
||||
&fetch,
|
||||
"llama-server.exe",
|
||||
&target,
|
||||
&mut |_| {},
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(target.join("ggml-vulkan.dll")).unwrap(), b"dll");
|
||||
assert!(target.join(INSTALLED).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_archive_without_the_server_is_not_installed() {
|
||||
let archive = tar_gz(&[("llama-b1/llama-cli", b"x")], &[]);
|
||||
let server = serve(archive.clone(), false, 0, 0);
|
||||
let dir = scratch("noserver");
|
||||
let target = dir.join("companion/runtime/b1");
|
||||
let mut fetch = fetch_of(&server, &archive);
|
||||
fetch.path = "a.tar.gz".into();
|
||||
let err = install_archive(
|
||||
&fetch,
|
||||
"llama-server",
|
||||
&target,
|
||||
&mut |_| {},
|
||||
&AtomicBool::new(false),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::Archive(_)), "{err}");
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_paths() {
|
||||
let layout = Layout::new(Path::new("/data"));
|
||||
let build = crate::catalogue::runtime_builds("macos", "aarch64")[0];
|
||||
assert_eq!(
|
||||
layout.runtime_dir(build),
|
||||
Path::new("/data/companion/runtime/b11756-macos-aarch64-metal")
|
||||
);
|
||||
let light = crate::catalogue::Tier::Light.model();
|
||||
assert_eq!(
|
||||
layout.model_dir(light),
|
||||
Path::new("/data/companion/models/qwen3.5-2b-q4_k_m")
|
||||
);
|
||||
assert_eq!(layout.installed_model(light), None);
|
||||
assert!(free_space(&std::env::temp_dir()).unwrap() > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
//! What the PC can run: RAM, cores, Apple Silicon and the GPUs llama.cpp sees, then the tier and the
|
||||
//! runtime's thread count and GPU offload (docs/companions.md, "Models and runtime").
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::catalogue::{Backend, Tier};
|
||||
|
||||
/// The graphics memory left to the game itself when the model's share is worked out.
|
||||
pub const GAME_VRAM_MB: u64 = 1536;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Hardware {
|
||||
pub ram_mb: u64,
|
||||
pub physical_cores: usize,
|
||||
pub logical_cores: usize,
|
||||
/// An arm64 Mac, whatever the architecture of this binary (Rosetta included).
|
||||
pub apple_silicon: bool,
|
||||
/// From `llama-server --list-devices`; empty until the runtime is installed.
|
||||
pub gpus: Vec<Gpu>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GpuKind {
|
||||
Discrete,
|
||||
/// Shares the game's GPU and the system RAM: the model stays on the CPU.
|
||||
Integrated,
|
||||
/// llvmpipe, SwiftShader: a CPU pretending.
|
||||
Software,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Gpu {
|
||||
/// The llama.cpp device name (`MTL0`, `Vulkan1`, `CUDA0`): what `--device` takes.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub total_mb: u64,
|
||||
pub free_mb: u64,
|
||||
pub kind: GpuKind,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// The backend that drives it, from its llama.cpp name.
|
||||
pub fn backend(&self) -> Backend {
|
||||
if self.id.starts_with("MTL") {
|
||||
Backend::Metal
|
||||
} else if self.id.starts_with("CUDA") {
|
||||
Backend::Cuda
|
||||
} else {
|
||||
Backend::Vulkan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hardware {
|
||||
/// RAM and cores of this PC; the GPUs come from [`parse_devices`] once the runtime is there.
|
||||
pub fn detect() -> Hardware {
|
||||
let logical = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
Hardware {
|
||||
ram_mb: ram_bytes().map_or(0, |b| b >> 20),
|
||||
physical_cores: physical_cores().unwrap_or(logical).max(1),
|
||||
logical_cores: logical,
|
||||
apple_silicon: apple_silicon(),
|
||||
gpus: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The RAM as sold: firmware and integrated GPUs keep a few hundred MB, so 15.6 GB counts as 16.
|
||||
pub fn nominal_ram_gb(&self) -> u64 {
|
||||
(self.ram_mb + 512) / 1024
|
||||
}
|
||||
|
||||
/// The discrete GPU with the most free memory.
|
||||
pub fn best_gpu(&self) -> Option<&Gpu> {
|
||||
self.gpus
|
||||
.iter()
|
||||
.filter(|g| g.kind == GpuKind::Discrete)
|
||||
.max_by_key(|g| g.free_mb)
|
||||
}
|
||||
|
||||
/// Free VRAM of the best discrete GPU once the game has its share.
|
||||
pub fn usable_vram_mb(&self) -> u64 {
|
||||
self.best_gpu()
|
||||
.map_or(0, |g| g.free_mb.saturating_sub(GAME_VRAM_MB))
|
||||
}
|
||||
}
|
||||
|
||||
/// The brain tier the simple screen sets up: the brain of [`crate::plan::plan`] with the catalogue's
|
||||
/// options alone, Light when not even that fits.
|
||||
pub fn pick_tier(hw: &Hardware) -> Tier {
|
||||
let plan = crate::plan::plan(hw, &crate::plan::brain_options(), &[], None);
|
||||
plan.pick(crate::plan::Part::Brain)
|
||||
.and_then(|p| {
|
||||
Tier::ALL
|
||||
.into_iter()
|
||||
.find(|t| p.option.id.starts_with(&format!("{}@", t.model().id)))
|
||||
})
|
||||
.unwrap_or(Tier::Light)
|
||||
}
|
||||
|
||||
/// `--threads`: physical cores minus one for the game, at least 1; a 2-core PC uses both, since one
|
||||
/// thread makes every reply twice as slow and the game's main work runs on one core anyway.
|
||||
pub fn runtime_threads(physical_cores: usize) -> usize {
|
||||
match physical_cores {
|
||||
0 | 1 => 1,
|
||||
2 => 2,
|
||||
n => n - 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the model's layers go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Offload {
|
||||
/// Everything on the GPU (`-ngl all`).
|
||||
All { device: String },
|
||||
/// A discrete GPU without room for all of it: llama.cpp's `--fit` places what fits, keeping the
|
||||
/// game's share free.
|
||||
Fit { device: String },
|
||||
/// CPU only (`-ngl 0 --device none`), so the game keeps its integrated GPU to itself.
|
||||
None,
|
||||
}
|
||||
|
||||
/// The offload for a model of `model_bytes`. `need_extra_mb` is the KV cache and compute buffers on
|
||||
/// top of the weights at the runtime's context size.
|
||||
pub fn plan_offload(hw: &Hardware, model_bytes: u64, need_extra_mb: u64) -> Offload {
|
||||
if hw.apple_silicon
|
||||
&& let Some(metal) = hw.gpus.iter().find(|g| g.id.starts_with("MTL"))
|
||||
{
|
||||
return Offload::All {
|
||||
device: metal.id.clone(),
|
||||
};
|
||||
}
|
||||
let Some(gpu) = hw.best_gpu() else {
|
||||
return Offload::None;
|
||||
};
|
||||
let need = (model_bytes >> 20) + need_extra_mb;
|
||||
let usable = gpu.free_mb.saturating_sub(GAME_VRAM_MB);
|
||||
if usable >= need {
|
||||
Offload::All {
|
||||
device: gpu.id.clone(),
|
||||
}
|
||||
} else if usable >= need / 2 {
|
||||
Offload::Fit {
|
||||
device: gpu.id.clone(),
|
||||
}
|
||||
} else {
|
||||
Offload::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `llama-server --list-devices` and returns everything it printed (the device list goes to
|
||||
/// stdout; the Vulkan backend's per-device details, which say whether a GPU is integrated, to stderr).
|
||||
pub fn list_devices(server: &Path) -> std::io::Result<String> {
|
||||
// -v adds the Vulkan backend's per-device line with `uma:` (shared memory) on it.
|
||||
let out = Command::new(server)
|
||||
.args(["-v", "--list-devices"])
|
||||
.output()?;
|
||||
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
text.push('\n');
|
||||
text.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
if !out.status.success() && !text.contains("Available devices") {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"llama-server --list-devices failed ({}): {}",
|
||||
out.status,
|
||||
text.trim()
|
||||
)));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// The GPUs in `llama-server -v --list-devices` output. Lines look like
|
||||
/// ` Vulkan0: NVIDIA GeForce RTX 3060 (12288 MiB, 11200 MiB free)`; devices without memory
|
||||
/// (`BLAS: Accelerate (0 MiB, 0 MiB free)`) are not GPUs. A Vulkan GPU is integrated when the backend's
|
||||
/// `ggml_vulkan: N = … | uma: 1 |` line says it shares system memory, or, without that line, by its name.
|
||||
pub fn parse_devices(output: &str) -> Vec<Gpu> {
|
||||
let mut uma: Vec<(usize, bool)> = Vec::new();
|
||||
for line in output.lines() {
|
||||
// `0.00.008.996 D ggml_vulkan: 0 = Intel(R) HD Graphics 620 (KBL GT2) (…) | uma: 1 | …`
|
||||
let Some((_, rest)) = line.split_once("ggml_vulkan: ") else {
|
||||
continue;
|
||||
};
|
||||
let Some((index, details)) = rest.split_once(" = ") else {
|
||||
continue;
|
||||
};
|
||||
if let (Ok(index), Some(flag)) = (index.trim().parse(), field(details, "uma")) {
|
||||
uma.push((index, flag == "1"));
|
||||
}
|
||||
}
|
||||
let mut gpus = Vec::new();
|
||||
let mut listing = false;
|
||||
for line in output.lines() {
|
||||
if line.trim_start().starts_with("Available devices") {
|
||||
listing = true;
|
||||
continue;
|
||||
}
|
||||
if !listing {
|
||||
continue;
|
||||
}
|
||||
let Some(gpu) = parse_device_line(line) else {
|
||||
continue;
|
||||
};
|
||||
if gpu.total_mb == 0 {
|
||||
continue;
|
||||
}
|
||||
let vulkan_index = gpu.id.strip_prefix("Vulkan").and_then(|n| n.parse().ok());
|
||||
let kind = match vulkan_index.and_then(|i: usize| uma.iter().find(|(n, _)| *n == i)) {
|
||||
_ if is_software(&gpu.name) => GpuKind::Software,
|
||||
_ if gpu.id.starts_with("MTL") => GpuKind::Integrated,
|
||||
Some((_, true)) => GpuKind::Integrated,
|
||||
Some((_, false)) => GpuKind::Discrete,
|
||||
None if looks_integrated(&gpu.name) => GpuKind::Integrated,
|
||||
None => GpuKind::Discrete,
|
||||
};
|
||||
gpus.push(Gpu { kind, ..gpu });
|
||||
}
|
||||
gpus
|
||||
}
|
||||
|
||||
fn field<'a>(details: &'a str, key: &str) -> Option<&'a str> {
|
||||
details.split('|').find_map(|part| {
|
||||
let (k, v) = part.split_once(':')?;
|
||||
(k.trim() == key).then(|| v.trim())
|
||||
})
|
||||
}
|
||||
|
||||
/// ` CUDA0: NVIDIA GeForce RTX 4090 (24563 MiB, 23879 MiB free)`
|
||||
fn parse_device_line(line: &str) -> Option<Gpu> {
|
||||
let (id, rest) = line.trim().split_once(": ")?;
|
||||
if id.is_empty() || id.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let open = rest.rfind(" (")?;
|
||||
let (name, memory) = (&rest[..open], rest[open + 2..].strip_suffix(')')?);
|
||||
let (total, free) = memory.split_once(", ")?;
|
||||
let mib = |s: &str| s.trim().strip_suffix("MiB")?.trim().parse::<u64>().ok();
|
||||
Some(Gpu {
|
||||
id: id.to_owned(),
|
||||
name: name.trim().to_owned(),
|
||||
total_mb: mib(total)?,
|
||||
free_mb: mib(free.strip_suffix("free")?)?,
|
||||
kind: GpuKind::Discrete,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_software(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
n.contains("llvmpipe") || n.contains("swiftshader") || n.contains("lavapipe")
|
||||
}
|
||||
|
||||
/// Names of integrated GPUs, for drivers that don't report memory sharing: Intel's (but not the Arc
|
||||
/// A and B cards), and AMD's APUs (`Radeon(TM) Graphics`, `Radeon 780M`, Vega).
|
||||
fn looks_integrated(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains("intel") {
|
||||
let discrete_arc = n.split(|c: char| !c.is_ascii_alphanumeric()).any(|w| {
|
||||
w.len() >= 4
|
||||
&& (w.starts_with('a') || w.starts_with('b'))
|
||||
&& w[1..].bytes().all(|c| c.is_ascii_digit())
|
||||
});
|
||||
return !discrete_arc;
|
||||
}
|
||||
if n.contains("amd") || n.contains("radeon") {
|
||||
let apu_model = n.split_whitespace().any(|w| {
|
||||
w.len() == 4 && w.ends_with('m') && w[..3].bytes().all(|c| c.is_ascii_digit())
|
||||
});
|
||||
return apu_model
|
||||
|| n.contains("radeon(tm) graphics")
|
||||
|| n.contains("radeon graphics")
|
||||
|| n.contains("vega");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(program).args(args).output().ok()?;
|
||||
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn physical_cores() -> Option<usize> {
|
||||
output("sysctl", &["-n", "hw.physicalcpu"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apple_silicon() -> bool {
|
||||
// 1 on Apple Silicon, also for an x86_64 binary under Rosetta.
|
||||
output("sysctl", &["-n", "hw.optional.arm64"]).as_deref() == Some("1")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
meminfo_total(&info)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn meminfo_total(info: &str) -> Option<u64> {
|
||||
let line = info.lines().find(|l| l.starts_with("MemTotal:"))?;
|
||||
let kb = line["MemTotal:".len()..]
|
||||
.trim()
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn physical_cores() -> Option<usize> {
|
||||
cpuinfo_physical_cores(&std::fs::read_to_string("/proc/cpuinfo").ok()?)
|
||||
}
|
||||
|
||||
/// Distinct (`physical id`, `core id`) pairs; ARM kernels print neither, then the logical count is used.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn cpuinfo_physical_cores(info: &str) -> Option<usize> {
|
||||
let mut cores = std::collections::HashSet::new();
|
||||
let (mut package, mut core) = (None, None);
|
||||
for line in info.lines().chain(std::iter::once("")) {
|
||||
if line.trim().is_empty() {
|
||||
if let Some(c) = core.take() {
|
||||
cores.insert((package.take().unwrap_or(0), c));
|
||||
}
|
||||
package = None;
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
match key.trim() {
|
||||
"physical id" => package = value.trim().parse::<u32>().ok(),
|
||||
"core id" => core = value.trim().parse::<u32>().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(!cores.is_empty()).then_some(cores.len())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apple_silicon() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn physical_cores() -> Option<usize> {
|
||||
output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfCores -Sum).Sum",
|
||||
],
|
||||
)?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn apple_silicon() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn physical_cores() -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn apple_silicon() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// tests/fixtures/devices: `llama-server -v --list-devices` of b11756, install paths replaced.
|
||||
// Captured: metal-m4-pro (the Mac mini), vulkan-intel-hd620 (the ThinkPad, Mesa 26.1.8 ANV),
|
||||
// vulkan-no-driver (the Vulkan build without a Vulkan loader: it runs on the CPU) and cpu-only-linux
|
||||
// (the ThinkPad, CPU build). Written by hand in the same format, for want of the GPUs:
|
||||
// vulkan-nvidia-rtx3060, vulkan-amd-rx7800xt-and-igpu and cuda-gtx1650.
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = format!(
|
||||
"{}/tests/fixtures/devices/{name}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn pc(ram_gb: u64, cores: usize, gpus: &str) -> Hardware {
|
||||
Hardware {
|
||||
ram_mb: ram_gb * 1024,
|
||||
physical_cores: cores,
|
||||
logical_cores: cores * 2,
|
||||
apple_silicon: false,
|
||||
gpus: parse_devices(&fixture(gpus)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metal_on_an_m4_pro() {
|
||||
// Captured on the Mac mini (M4 Pro, 64 GB) with b11756.
|
||||
let gpus = parse_devices(&fixture("metal-m4-pro.txt"));
|
||||
assert_eq!(gpus.len(), 1, "BLAS has no memory and is not a GPU");
|
||||
assert_eq!(gpus[0].id, "MTL0");
|
||||
assert_eq!(gpus[0].name, "Apple M4 Pro");
|
||||
assert_eq!((gpus[0].total_mb, gpus[0].free_mb), (53084, 53083));
|
||||
let mac = Hardware {
|
||||
ram_mb: 65536,
|
||||
physical_cores: 14,
|
||||
logical_cores: 14,
|
||||
apple_silicon: true,
|
||||
gpus,
|
||||
};
|
||||
assert_eq!(pick_tier(&mac), Tier::Best);
|
||||
assert_eq!(
|
||||
plan_offload(&mac, Tier::Best.model().size(), 1024),
|
||||
Offload::All {
|
||||
device: "MTL0".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_silicon_tiers_follow_unified_memory() {
|
||||
let mac = |gb: u64| Hardware {
|
||||
ram_mb: gb * 1024,
|
||||
physical_cores: 8,
|
||||
logical_cores: 8,
|
||||
apple_silicon: true,
|
||||
gpus: Vec::new(),
|
||||
};
|
||||
assert_eq!(pick_tier(&mac(8)), Tier::Light);
|
||||
assert_eq!(pick_tier(&mac(16)), Tier::Standard);
|
||||
assert_eq!(pick_tier(&mac(24)), Tier::Strong);
|
||||
assert_eq!(pick_tier(&mac(36)), Tier::Best);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_discrete_on_vulkan() {
|
||||
let hw = pc(32, 8, "vulkan-nvidia-rtx3060.txt");
|
||||
assert_eq!(hw.gpus.len(), 1);
|
||||
assert_eq!(hw.gpus[0].kind, GpuKind::Discrete);
|
||||
assert_eq!(hw.gpus[0].free_mb, 11520);
|
||||
// 11.25 GB free minus the game's 1.5 GB holds Gemma 4 12B (8.1 GB with its buffers): the
|
||||
// design's 11 GB threshold was a guess before measuring.
|
||||
assert_eq!(pick_tier(&hw), Tier::Best);
|
||||
assert_eq!(
|
||||
plan_offload(&hw, Tier::Strong.model().size(), 1024),
|
||||
Offload::All {
|
||||
device: "Vulkan0".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amd_discrete_and_its_apu_side_by_side() {
|
||||
let hw = pc(32, 8, "vulkan-amd-rx7800xt-and-igpu.txt");
|
||||
let kinds: Vec<_> = hw.gpus.iter().map(|g| (g.id.as_str(), g.kind)).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
("Vulkan0", GpuKind::Discrete),
|
||||
("Vulkan1", GpuKind::Integrated)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
hw.best_gpu().unwrap().name,
|
||||
"AMD Radeon RX 7800 XT (RADV NAVI32)"
|
||||
);
|
||||
assert_eq!(pick_tier(&hw), Tier::Best);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_igpu_keeps_the_model_on_the_cpu() {
|
||||
// The ThinkPad's profile: i7-7500U (2 cores, 4 threads), HD 620, 32 GB.
|
||||
let hw = pc(32, 2, "vulkan-intel-hd620.txt");
|
||||
assert_eq!(hw.gpus[0].kind, GpuKind::Integrated);
|
||||
assert_eq!(hw.usable_vram_mb(), 0);
|
||||
assert_eq!(
|
||||
pick_tier(&hw),
|
||||
Tier::Light,
|
||||
"2 cores is below the CPU path's 6"
|
||||
);
|
||||
assert_eq!(
|
||||
plan_offload(&hw, Tier::Light.model().size(), 512),
|
||||
Offload::None
|
||||
);
|
||||
assert_eq!(runtime_threads(hw.physical_cores), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_only_pcs() {
|
||||
for fixture in ["cpu-only-linux.txt", "vulkan-no-driver.txt"] {
|
||||
let hw = pc(16, 6, fixture);
|
||||
assert!(hw.gpus.is_empty(), "{fixture}");
|
||||
assert_eq!(pick_tier(&hw), Tier::Standard, "16 GB and 6 cores");
|
||||
assert_eq!(pick_tier(&pc(16, 4, fixture)), Tier::Light);
|
||||
assert_eq!(pick_tier(&pc(8, 8, fixture)), Tier::Light);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_discrete_gpus_fit_what_they_can() {
|
||||
let hw = pc(16, 4, "cuda-gtx1650.txt");
|
||||
assert_eq!(hw.gpus[0].id, "CUDA0");
|
||||
assert_eq!(pick_tier(&hw), Tier::Light);
|
||||
let standard = Tier::Standard.model().size();
|
||||
assert_eq!(
|
||||
plan_offload(&hw, Tier::Light.model().size(), 512),
|
||||
Offload::All {
|
||||
device: "CUDA0".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
plan_offload(&hw, standard, 512),
|
||||
Offload::Fit {
|
||||
device: "CUDA0".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_renderers_are_not_gpus() {
|
||||
let gpus = parse_devices(
|
||||
"Available devices:\n Vulkan0: llvmpipe (LLVM 19.1.7, 256 bits) (32000 MiB, 32000 MiB free)\n",
|
||||
);
|
||||
assert_eq!(gpus[0].kind, GpuKind::Software);
|
||||
let hw = Hardware {
|
||||
gpus,
|
||||
..pc(8, 4, "cpu-only-linux.txt")
|
||||
};
|
||||
assert_eq!(plan_offload(&hw, 1 << 30, 0), Offload::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_alone_tell_integrated_from_discrete() {
|
||||
assert!(looks_integrated("Intel(R) UHD Graphics 630 (CFL GT2)"));
|
||||
assert!(looks_integrated("Intel(R) Arc(TM) 140V GPU (16GB)"));
|
||||
assert!(!looks_integrated("Intel(R) Arc(TM) A770 Graphics"));
|
||||
assert!(!looks_integrated("Intel(R) Arc(TM) B580 Graphics"));
|
||||
assert!(looks_integrated("AMD Radeon(TM) Graphics"));
|
||||
assert!(looks_integrated("AMD Radeon 780M (RADV PHOENIX)"));
|
||||
assert!(!looks_integrated("AMD Radeon RX 6600 XT"));
|
||||
assert!(!looks_integrated("NVIDIA GeForce RTX 4070"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threads_leave_a_core_for_the_game() {
|
||||
assert_eq!(runtime_threads(0), 1);
|
||||
assert_eq!(runtime_threads(1), 1);
|
||||
assert_eq!(runtime_threads(2), 2);
|
||||
assert_eq!(runtime_threads(4), 3);
|
||||
assert_eq!(runtime_threads(14), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_proc_files() {
|
||||
assert_eq!(
|
||||
meminfo_total("MemTotal: 32788148 kB\nMemFree: 1 kB\n"),
|
||||
Some(32788148 * 1024)
|
||||
);
|
||||
// Two cores with two threads each, as /proc/cpuinfo prints the i7-7500U.
|
||||
let mut info = String::new();
|
||||
for (cpu, core) in [(0, 0), (1, 1), (2, 0), (3, 1)] {
|
||||
info += &format!("processor\t: {cpu}\nphysical id\t: 0\ncore id\t\t: {core}\n\n");
|
||||
}
|
||||
assert_eq!(cpuinfo_physical_cores(&info), Some(2));
|
||||
assert_eq!(
|
||||
cpuinfo_physical_cores("processor\t: 0\nBogoMIPS\t: 48.00\n\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_finds_this_pc() {
|
||||
let hw = Hardware::detect();
|
||||
assert!(hw.ram_mb > 1024);
|
||||
assert!(hw.physical_cores >= 1 && hw.physical_cores <= hw.logical_cores);
|
||||
assert_eq!(
|
||||
hw.apple_silicon,
|
||||
cfg!(all(target_os = "macos", target_arch = "aarch64"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! The companion's brain, without the game (docs/companions.md): what to download and from where, what
|
||||
//! the PC can run, the llama-server supervisor, the OpenAI-compatible streaming client, the prompts,
|
||||
//! and the reflexes that answer common commands without a model.
|
||||
//!
|
||||
//! Nothing here blocks a caller's frame: downloads, the runtime and the chat client run on their own
|
||||
//! threads and report over channels the game polls once per tick.
|
||||
|
||||
pub mod catalogue;
|
||||
pub mod client;
|
||||
pub mod download;
|
||||
pub mod hardware;
|
||||
pub mod phrases;
|
||||
pub mod plan;
|
||||
pub mod prompt;
|
||||
pub mod reflex;
|
||||
pub mod reply;
|
||||
pub mod runtime;
|
||||
pub mod settings;
|
||||
@@ -0,0 +1,828 @@
|
||||
//! What the companion says without the brain: the instant acknowledgement of a reflex command, the
|
||||
//! line for a command it didn't get, and callouts like "Creeper, 2 o'clock, close!" relative to where
|
||||
//! the owner faces. Every line exists in the five reflex languages for every personality.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::prompt::Personality;
|
||||
use crate::reflex::Intent;
|
||||
use crate::reply::Tone;
|
||||
|
||||
/// The kinds of acknowledgement, one per command family.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Ack {
|
||||
Follow,
|
||||
Stay,
|
||||
Come,
|
||||
Stop,
|
||||
GoTo,
|
||||
Attack,
|
||||
Defend,
|
||||
HoldFire,
|
||||
Fire,
|
||||
/// Jump, crouch, stand up.
|
||||
Move,
|
||||
Collect,
|
||||
Give,
|
||||
Craft,
|
||||
Eat,
|
||||
Sleep,
|
||||
/// A command it didn't understand, when no brain is there to ask.
|
||||
Huh,
|
||||
}
|
||||
|
||||
impl Ack {
|
||||
pub const ALL: [Ack; 16] = [
|
||||
Ack::Follow,
|
||||
Ack::Stay,
|
||||
Ack::Come,
|
||||
Ack::Stop,
|
||||
Ack::GoTo,
|
||||
Ack::Attack,
|
||||
Ack::Defend,
|
||||
Ack::HoldFire,
|
||||
Ack::Fire,
|
||||
Ack::Move,
|
||||
Ack::Collect,
|
||||
Ack::Give,
|
||||
Ack::Craft,
|
||||
Ack::Eat,
|
||||
Ack::Sleep,
|
||||
Ack::Huh,
|
||||
];
|
||||
|
||||
pub fn of(intent: &Intent) -> Ack {
|
||||
match intent {
|
||||
Intent::Follow => Ack::Follow,
|
||||
Intent::Stay => Ack::Stay,
|
||||
Intent::Come => Ack::Come,
|
||||
Intent::GoTo { .. } => Ack::GoTo,
|
||||
Intent::Stop => Ack::Stop,
|
||||
Intent::Attack { .. } => Ack::Attack,
|
||||
Intent::Defend => Ack::Defend,
|
||||
Intent::HoldFire(true) => Ack::HoldFire,
|
||||
Intent::HoldFire(false) => Ack::Fire,
|
||||
Intent::Jump | Intent::Crouch(_) => Ack::Move,
|
||||
Intent::Eat => Ack::Eat,
|
||||
Intent::Sleep => Ack::Sleep,
|
||||
Intent::Collect { .. } => Ack::Collect,
|
||||
Intent::Give { .. } => Ack::Give,
|
||||
Intent::Craft { .. } => Ack::Craft,
|
||||
}
|
||||
}
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Ack::Follow => "follow",
|
||||
Ack::Stay => "stay",
|
||||
Ack::Come => "come",
|
||||
Ack::Stop => "stop",
|
||||
Ack::GoTo => "goto",
|
||||
Ack::Attack => "attack",
|
||||
Ack::Defend => "defend",
|
||||
Ack::HoldFire => "hold_fire",
|
||||
Ack::Fire => "fire",
|
||||
Ack::Move => "move",
|
||||
Ack::Collect => "collect",
|
||||
Ack::Give => "give",
|
||||
Ack::Craft => "craft",
|
||||
Ack::Eat => "eat",
|
||||
Ack::Sleep => "sleep",
|
||||
Ack::Huh => "huh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The acknowledgement for `ack`, in the game's language (English when it isn't one of the five).
|
||||
/// `what` fills `{what}` ("10 oak logs", in the game's words); `pick` chooses among the variants
|
||||
/// (pass anything that changes, a tick count for instance).
|
||||
pub fn acknowledge(
|
||||
ack: Ack,
|
||||
personality: Personality,
|
||||
language: &str,
|
||||
what: &str,
|
||||
pick: u64,
|
||||
) -> String {
|
||||
let bank = bank();
|
||||
let lang = lang_of(language);
|
||||
let voice = voice_of(personality);
|
||||
let lines = bank
|
||||
.get(&(lang, voice, ack.key()))
|
||||
.or_else(|| bank.get(&("en", voice, ack.key())))
|
||||
.expect("every line exists in English");
|
||||
let line = lines[(pick % lines.len() as u64) as usize];
|
||||
line.replace("{what}", what.trim())
|
||||
}
|
||||
|
||||
/// A custom personality speaks like the Expert: plain lines that fit any character.
|
||||
fn voice_of(personality: Personality) -> &'static str {
|
||||
match personality {
|
||||
Personality::Veteran => "veteran",
|
||||
Personality::Coach => "coach",
|
||||
Personality::Expert | Personality::Custom => "expert",
|
||||
Personality::Joker => "joker",
|
||||
Personality::Quiet => "quiet",
|
||||
}
|
||||
}
|
||||
|
||||
fn lang_of(language: &str) -> &'static str {
|
||||
match language.get(..2).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("fr") => "fr",
|
||||
Some("es") => "es",
|
||||
Some("de") => "de",
|
||||
Some("pt") => "pt",
|
||||
_ => "en",
|
||||
}
|
||||
}
|
||||
|
||||
type Bank = HashMap<(&'static str, &'static str, &'static str), Vec<&'static str>>;
|
||||
|
||||
fn bank() -> &'static Bank {
|
||||
static BANK: OnceLock<Bank> = OnceLock::new();
|
||||
BANK.get_or_init(|| {
|
||||
let mut bank = Bank::new();
|
||||
for (lang, text) in [("en", EN), ("fr", FR), ("es", ES), ("de", DE), ("pt", PT)] {
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
let (key, phrases) = line.split_once(" = ").expect("`voice.kind = a | b`");
|
||||
let (voice, kind) = key.split_once('.').expect("`voice.kind`");
|
||||
bank.insert((lang, voice, kind), phrases.split(" | ").collect());
|
||||
}
|
||||
}
|
||||
bank
|
||||
})
|
||||
}
|
||||
|
||||
const EN: &str = "
|
||||
veteran.follow = Yeah, yeah. Right behind you. | Lead on. Try not to walk into lava this time.
|
||||
veteran.stay = Fine. I'll guard this lovely patch of dirt. | Staying. Don't forget me here.
|
||||
veteran.come = Coming. No need to shout. | On my way. Again.
|
||||
veteran.stop = Stopping. Make up your mind. | Fine, dropping it.
|
||||
veteran.goto = Going. This better be worth it. | Over there? Sure. Why not.
|
||||
veteran.attack = Finally, something I'm good at. | On it. Stand back.
|
||||
veteran.defend = I've got your back. Someone has to. | Nothing touches you on my watch.
|
||||
veteran.hold_fire = Holding fire. Against my better judgement. | Weapons down. For now.
|
||||
veteran.fire = Weapons free. About time. | Gloves are off.
|
||||
veteran.move = There. Happy? | Done.
|
||||
veteran.collect = {what}. Sure, I'll do the heavy lifting. | Getting {what}. You're welcome, in advance.
|
||||
veteran.give = Here, {what}. Don't lose it this time. | {what}, catch.
|
||||
veteran.craft = Crafting {what}. Watch and learn. | {what}, coming up.
|
||||
veteran.eat = Finally, a break. | Eating. Don't touch my bread.
|
||||
veteran.sleep = Sleep. Best idea you've had all day. | Bed. Wake me if something explodes.
|
||||
veteran.huh = Speak Minecraft, would you? | No idea what that means.
|
||||
coach.follow = Right behind you, let's go! | Following! Lead the way.
|
||||
coach.stay = I'll hold this spot for you. | Staying here. Call me when you need me.
|
||||
coach.come = On my way! | Coming to you!
|
||||
coach.stop = Okay, stopping. | Stopped. What's next?
|
||||
coach.goto = Heading there now! | On my way over.
|
||||
coach.attack = Let's take it down! | Attacking, stay behind me!
|
||||
coach.defend = I've got you covered! | Stick close, I'll keep you safe.
|
||||
coach.hold_fire = Holding fire. | Okay, no attacking until you say so.
|
||||
coach.fire = Back in the fight! | Weapons free, let's go!
|
||||
coach.move = Got it! | Done!
|
||||
coach.collect = Getting {what}, back soon! | On it: {what}.
|
||||
coach.give = Here you go: {what}! | {what} for you!
|
||||
coach.craft = Crafting {what}! | Making {what} now.
|
||||
coach.eat = Good call, eating now. | Quick snack!
|
||||
coach.sleep = Good idea, let's skip the night. | Off to bed!
|
||||
coach.huh = Sorry, I didn't get that. Try \"follow me\" or \"get 10 logs\".
|
||||
expert.follow = Following. | Two blocks behind you.
|
||||
expert.stay = Holding position. | I'll stay here.
|
||||
expert.come = Coming. | Moving to you.
|
||||
expert.stop = Stopped. | Task cancelled.
|
||||
expert.goto = Moving there. | On my way.
|
||||
expert.attack = Engaging. | Target acquired.
|
||||
expert.defend = Covering you. | Guarding you.
|
||||
expert.hold_fire = Holding fire. | Weapons on safe.
|
||||
expert.fire = Weapons free. | Engaging at will.
|
||||
expert.move = Done. | Okay.
|
||||
expert.collect = Collecting {what}. | {what}: on it.
|
||||
expert.give = Here: {what}. | Dropping {what}.
|
||||
expert.craft = Crafting {what}. | {what}, in progress.
|
||||
expert.eat = Eating. | Topping up hunger.
|
||||
expert.sleep = Going to bed. | Sleeping.
|
||||
expert.huh = Not understood. | Unclear. Say it another way.
|
||||
joker.follow = Following you like a lost puppy. | Where you go, I go. Unless it's lava.
|
||||
joker.stay = I'll just stand here. Looking cool. | Staying. Bring snacks.
|
||||
joker.come = Incoming! Brace yourself. | Coming! Cue dramatic music.
|
||||
joker.stop = Freezing like a statue. | Stopping. Was it something I said?
|
||||
joker.goto = Off on an adventure! | Over there? Fancy. Going.
|
||||
joker.attack = Time to make mob smoothies! | Charge! For glory and cookies!
|
||||
joker.defend = Bodyguard mode: on. Sunglasses: on. | Nobody messes with my buddy.
|
||||
joker.hold_fire = Peace and love, holding fire. | Fine, I'll just glare at them.
|
||||
joker.fire = Party time! | Unleash the chaos!
|
||||
joker.move = Ta-da! | Smooth, right?
|
||||
joker.collect = {what}? I'll be back. Probably. | Going shopping for {what}. Free of charge.
|
||||
joker.give = Special delivery: {what}! | {what}, gift-wrapped. Not really.
|
||||
joker.craft = Crafting {what}. Artisanal, handmade. | {what}, fresh out of the oven.
|
||||
joker.eat = Nom time! | Snack break! Don't judge.
|
||||
joker.sleep = Nap time, my favourite sport. | Goodnight, cruel world. See you at dawn.
|
||||
joker.huh = Error 404: meaning not found. | I understood every word. Just not in that order.
|
||||
quiet.follow = Okay.
|
||||
quiet.stay = Here.
|
||||
quiet.come = Coming.
|
||||
quiet.stop = Stopped.
|
||||
quiet.goto = Going.
|
||||
quiet.attack = On it.
|
||||
quiet.defend = Covered.
|
||||
quiet.hold_fire = Holding.
|
||||
quiet.fire = Firing.
|
||||
quiet.move = Okay.
|
||||
quiet.collect = {what}.
|
||||
quiet.give = Here.
|
||||
quiet.craft = {what}.
|
||||
quiet.eat = Eating.
|
||||
quiet.sleep = Night.
|
||||
quiet.huh = ?
|
||||
";
|
||||
|
||||
const FR: &str = "
|
||||
veteran.follow = Ouais, ouais. Je te suis. | Passe devant. Et évite la lave, cette fois.
|
||||
veteran.stay = Bon. Je garde ce magnifique carré de terre. | Je reste là. M'oublie pas.
|
||||
veteran.come = J'arrive. Pas la peine de crier. | J'arrive. Encore.
|
||||
veteran.stop = J'arrête. Faudrait savoir. | Bon, je laisse tomber.
|
||||
veteran.goto = J'y vais. T'as intérêt à ce que ça vaille le coup. | Là-bas ? Allez, pourquoi pas.
|
||||
veteran.attack = Enfin un truc où je suis bon. | Je m'en occupe. Recule.
|
||||
veteran.defend = Je te couvre. Faut bien que quelqu'un le fasse. | Personne te touche tant que je suis là.
|
||||
veteran.hold_fire = Je retiens mes coups. Contre mon avis. | Armes baissées. Pour l'instant.
|
||||
veteran.fire = Feu à volonté. Pas trop tôt. | Fini de rigoler.
|
||||
veteran.move = Voilà. Content ? | C'est fait.
|
||||
veteran.collect = {what}. Bien sûr, c'est encore moi qui bosse. | Je vais chercher {what}. De rien, d'avance.
|
||||
veteran.give = Tiens, {what}. Essaie de pas tout perdre. | {what}, attrape.
|
||||
veteran.craft = Je fabrique {what}. Regarde et apprends. | {what}, ça arrive.
|
||||
veteran.eat = Enfin une pause. | Je mange. Touche pas à mon pain.
|
||||
veteran.sleep = Dormir. Meilleure idée de la journée. | Au lit. Réveille-moi si ça explose.
|
||||
veteran.huh = Parle Minecraft, tu veux ? | Aucune idée de ce que ça veut dire.
|
||||
coach.follow = Je te suis, on y va ! | Je suis juste derrière, montre la route !
|
||||
coach.stay = Je garde la place pour toi. | Je reste ici. Appelle-moi si besoin.
|
||||
coach.come = J'arrive ! | Je viens vers toi !
|
||||
coach.stop = D'accord, j'arrête. | C'est arrêté. On fait quoi ensuite ?
|
||||
coach.goto = J'y vais tout de suite ! | En route !
|
||||
coach.attack = On va le faire tomber ! | J'attaque, reste derrière moi !
|
||||
coach.defend = Je te couvre ! | Reste près de moi, je te protège.
|
||||
coach.hold_fire = Je ne tire plus. | D'accord, pas d'attaque tant que tu ne le dis pas.
|
||||
coach.fire = De retour au combat ! | Feu à volonté, allez !
|
||||
coach.move = Compris ! | C'est fait !
|
||||
coach.collect = Je vais chercher {what}, je reviens vite ! | C'est parti : {what}.
|
||||
coach.give = Tiens, {what} ! | {what} pour toi !
|
||||
coach.craft = Je fabrique {what} ! | {what}, c'est parti !
|
||||
coach.eat = Bonne idée, je mange. | Petite pause goûter !
|
||||
coach.sleep = Bonne idée, on saute la nuit. | Au lit !
|
||||
coach.huh = Désolé, j'ai pas compris. Essaie « suis-moi » ou « ramasse 10 bûches ».
|
||||
expert.follow = Je te suis. | Deux blocs derrière toi.
|
||||
expert.stay = Je tiens la position. | Je reste ici.
|
||||
expert.come = J'arrive. | Je te rejoins.
|
||||
expert.stop = Arrêté. | Tâche annulée.
|
||||
expert.goto = J'y vais. | En route.
|
||||
expert.attack = J'engage. | Cible verrouillée.
|
||||
expert.defend = Je te couvre. | Je te protège.
|
||||
expert.hold_fire = Je cesse le feu. | Armes en sécurité.
|
||||
expert.fire = Feu à volonté. | J'engage librement.
|
||||
expert.move = Fait. | D'accord.
|
||||
expert.collect = Je récupère {what}. | {what} : en cours.
|
||||
expert.give = Voilà : {what}. | Je te lance {what}.
|
||||
expert.craft = Je fabrique {what}. | {what} : en cours.
|
||||
expert.eat = Je mange. | Je refais le plein.
|
||||
expert.sleep = Je vais me coucher. | Je dors.
|
||||
expert.huh = Pas compris. | Reformule, s'il te plaît.
|
||||
joker.follow = Je te suis comme un petit chien perdu. | Où tu vas, je vais. Sauf dans la lave.
|
||||
joker.stay = Je reste planté là. Avec classe. | Je reste. Ramène des snacks.
|
||||
joker.come = J'arrive ! Accroche-toi. | J'arrive ! Musique dramatique, s'il vous plaît.
|
||||
joker.stop = Je me fige comme une statue. | J'arrête. C'est quelque chose que j'ai dit ?
|
||||
joker.goto = Partons à l'aventure ! | Là-bas ? Chic. J'y vais.
|
||||
joker.attack = C'est l'heure du smoothie de monstres ! | À l'abordage !
|
||||
joker.defend = Mode garde du corps : activé. Lunettes de soleil : activées. | Personne touche à mon pote.
|
||||
joker.hold_fire = Peace and love, je range les armes. | Bon, je vais juste les fusiller du regard.
|
||||
joker.fire = C'est la fête ! | Libérez le chaos !
|
||||
joker.move = Tadaa ! | Classe, non ?
|
||||
joker.collect = {what} ? Je reviens. Probablement. | Je fais les courses : {what}. C'est gratuit.
|
||||
joker.give = Livraison express : {what} ! | {what}, avec un petit nœud. Enfin, presque.
|
||||
joker.craft = Je fabrique {what}. Artisanal, fait main. | {what}, tout chaud sorti du four.
|
||||
joker.eat = Miam ! | Pause goûter ! Me juge pas.
|
||||
joker.sleep = La sieste, mon sport préféré. | Bonne nuit, monde cruel. À l'aube.
|
||||
joker.huh = Erreur 404 : sens introuvable. | J'ai compris tous les mots. Juste pas dans cet ordre.
|
||||
quiet.follow = Ok.
|
||||
quiet.stay = Ici.
|
||||
quiet.come = J'arrive.
|
||||
quiet.stop = Stop.
|
||||
quiet.goto = J'y vais.
|
||||
quiet.attack = Je m'en charge.
|
||||
quiet.defend = Couvert.
|
||||
quiet.hold_fire = Je retiens.
|
||||
quiet.fire = Feu.
|
||||
quiet.move = Ok.
|
||||
quiet.collect = {what}.
|
||||
quiet.give = Tiens.
|
||||
quiet.craft = {what}.
|
||||
quiet.eat = Je mange.
|
||||
quiet.sleep = Bonne nuit.
|
||||
quiet.huh = ?
|
||||
";
|
||||
|
||||
const ES: &str = "
|
||||
veteran.follow = Sí, sí. Detrás de ti. | Tú primero. Intenta no caer en la lava.
|
||||
veteran.stay = Vale. Vigilaré este precioso trozo de tierra. | Me quedo. No te olvides de mí.
|
||||
veteran.come = Voy. No hace falta gritar. | Voy. Otra vez.
|
||||
veteran.stop = Paro. A ver si te decides. | Vale, lo dejo.
|
||||
veteran.goto = Voy. Más vale que merezca la pena. | ¿Allí? Bueno. ¿Por qué no?
|
||||
veteran.attack = Por fin algo que se me da bien. | Yo me encargo. Atrás.
|
||||
veteran.defend = Te cubro. Alguien tiene que hacerlo. | Nadie te toca mientras yo esté aquí.
|
||||
veteran.hold_fire = Alto el fuego. Aunque no me guste. | Armas abajo. De momento.
|
||||
veteran.fire = Fuego a discreción. Ya era hora. | Se acabaron las bromas.
|
||||
veteran.move = Ya está. ¿Contento? | Hecho.
|
||||
veteran.collect = {what}. Claro, yo hago el trabajo duro. | Voy a por {what}. De nada, por adelantado.
|
||||
veteran.give = Toma, {what}. Esta vez no lo pierdas. | {what}, atrapa.
|
||||
veteran.craft = Fabricando {what}. Mira y aprende. | {what}, marchando.
|
||||
veteran.eat = Por fin, un descanso. | Comiendo. No toques mi pan.
|
||||
veteran.sleep = Dormir. La mejor idea que has tenido hoy. | A la cama. Despiértame si algo explota.
|
||||
veteran.huh = Habla en Minecraft, ¿quieres? | Ni idea de qué significa eso.
|
||||
coach.follow = ¡Detrás de ti, vamos! | ¡Te sigo, guía tú!
|
||||
coach.stay = Te guardo este sitio. | Me quedo aquí. Llámame si me necesitas.
|
||||
coach.come = ¡Voy! | ¡Voy hacia ti!
|
||||
coach.stop = Vale, paro. | Parado. ¿Qué hacemos ahora?
|
||||
coach.goto = ¡Voy para allá! | ¡En camino!
|
||||
coach.attack = ¡Vamos a por él! | ¡Atacando, quédate detrás!
|
||||
coach.defend = ¡Te cubro! | Quédate cerca, yo te protejo.
|
||||
coach.hold_fire = Dejo de atacar. | Vale, nada de ataques hasta que lo digas.
|
||||
coach.fire = ¡De vuelta a la pelea! | ¡Fuego a discreción, vamos!
|
||||
coach.move = ¡Entendido! | ¡Hecho!
|
||||
coach.collect = ¡Voy a por {what}, vuelvo enseguida! | En ello: {what}.
|
||||
coach.give = ¡Aquí tienes: {what}! | ¡{what} para ti!
|
||||
coach.craft = ¡Fabricando {what}! | ¡{what}, marchando!
|
||||
coach.eat = Buena idea, a comer. | ¡Un tentempié rápido!
|
||||
coach.sleep = Buena idea, saltemos la noche. | ¡A la cama!
|
||||
coach.huh = Perdona, no lo entendí. Prueba «sígueme» o «consigue 10 troncos».
|
||||
expert.follow = Te sigo. | Dos bloques detrás de ti.
|
||||
expert.stay = Mantengo la posición. | Me quedo aquí.
|
||||
expert.come = Voy. | Voy hacia ti.
|
||||
expert.stop = Detenido. | Tarea cancelada.
|
||||
expert.goto = Voy hacia allí. | En camino.
|
||||
expert.attack = Atacando. | Objetivo fijado.
|
||||
expert.defend = Te cubro. | Te protejo.
|
||||
expert.hold_fire = Alto el fuego. | Armas en seguro.
|
||||
expert.fire = Fuego a discreción. | Ataco libremente.
|
||||
expert.move = Hecho. | Vale.
|
||||
expert.collect = Recogiendo {what}. | {what}: en ello.
|
||||
expert.give = Aquí: {what}. | Te lanzo {what}.
|
||||
expert.craft = Fabricando {what}. | {what}: en proceso.
|
||||
expert.eat = Comiendo. | Recupero energía.
|
||||
expert.sleep = Me voy a la cama. | Durmiendo.
|
||||
expert.huh = No entendido. | Dilo de otra forma.
|
||||
joker.follow = Te sigo como un perrito perdido. | A donde vayas, voy. Menos a la lava.
|
||||
joker.stay = Me quedo aquí. Con estilo. | Me quedo. Trae algo de picar.
|
||||
joker.come = ¡Allá voy! Agárrate. | ¡Voy! Música dramática, por favor.
|
||||
joker.stop = Me congelo como una estatua. | Paro. ¿Fue algo que dije?
|
||||
joker.goto = ¡A la aventura! | ¿Allí? Qué elegante. Voy.
|
||||
joker.attack = ¡Hora del batido de monstruos! | ¡Al ataque! ¡Por la gloria y las galletas!
|
||||
joker.defend = Modo guardaespaldas: activado. Gafas de sol: activadas. | Nadie se mete con mi colega.
|
||||
joker.hold_fire = Paz y amor, armas fuera. | Vale, solo los miraré feo.
|
||||
joker.fire = ¡Fiesta! | ¡Que empiece el caos!
|
||||
joker.move = ¡Tachán! | Con estilo, ¿eh?
|
||||
joker.collect = ¿{what}? Ahora vuelvo. Probablemente. | Me voy de compras: {what}. Gratis.
|
||||
joker.give = Entrega especial: ¡{what}! | {what}, con lacito. Bueno, casi.
|
||||
joker.craft = Fabricando {what}. Artesanal, hecho a mano. | {what}, recién salido del horno.
|
||||
joker.eat = ¡Ñam! | ¡Pausa para merendar! No me juzgues.
|
||||
joker.sleep = La siesta, mi deporte favorito. | Buenas noches, mundo cruel. Hasta el amanecer.
|
||||
joker.huh = Error 404: sentido no encontrado. | Entendí todas las palabras. Pero no en ese orden.
|
||||
quiet.follow = Vale.
|
||||
quiet.stay = Aquí.
|
||||
quiet.come = Voy.
|
||||
quiet.stop = Paro.
|
||||
quiet.goto = Voy.
|
||||
quiet.attack = Yo me encargo.
|
||||
quiet.defend = Cubierto.
|
||||
quiet.hold_fire = Quieto.
|
||||
quiet.fire = Fuego.
|
||||
quiet.move = Vale.
|
||||
quiet.collect = {what}.
|
||||
quiet.give = Toma.
|
||||
quiet.craft = {what}.
|
||||
quiet.eat = Como.
|
||||
quiet.sleep = Buenas noches.
|
||||
quiet.huh = ?
|
||||
";
|
||||
|
||||
const DE: &str = "
|
||||
veteran.follow = Ja, ja. Bin hinter dir. | Geh vor. Und lauf diesmal nicht in die Lava.
|
||||
veteran.stay = Schön. Ich bewache dieses herrliche Stück Erde. | Ich bleib hier. Vergiss mich nicht.
|
||||
veteran.come = Komm ja schon. Kein Grund zu schreien. | Bin unterwegs. Mal wieder.
|
||||
veteran.stop = Ich hör auf. Entscheide dich mal. | Gut, ich lass es.
|
||||
veteran.goto = Ich geh ja. Hoffentlich lohnt sich das. | Da drüben? Na gut. Warum nicht.
|
||||
veteran.attack = Endlich mal was, das ich kann. | Ich mach das. Geh zurück.
|
||||
veteran.defend = Ich halt dir den Rücken frei. Einer muss es ja tun. | Solange ich da bin, fasst dich keiner an.
|
||||
veteran.hold_fire = Feuer eingestellt. Gegen meinen Rat. | Waffen runter. Vorerst.
|
||||
veteran.fire = Feuer frei. Wurde auch Zeit. | Jetzt wird's ernst.
|
||||
veteran.move = So. Zufrieden? | Erledigt.
|
||||
veteran.collect = {what}. Klar, ich mach die Drecksarbeit. | Ich hol {what}. Gern geschehen, im Voraus.
|
||||
veteran.give = Hier, {what}. Verlier's diesmal nicht. | {what}, fang.
|
||||
veteran.craft = Ich baue {what}. Schau zu und lern was. | {what}, kommt sofort.
|
||||
veteran.eat = Endlich eine Pause. | Ich esse. Finger weg von meinem Brot.
|
||||
veteran.sleep = Schlafen. Die beste Idee, die du heute hattest. | Ab ins Bett. Weck mich, wenn was explodiert.
|
||||
veteran.huh = Sprich Minecraft, ja? | Keine Ahnung, was das heißen soll.
|
||||
coach.follow = Ich bin direkt hinter dir, los geht's! | Ich folge dir, zeig den Weg!
|
||||
coach.stay = Ich halte den Platz für dich. | Ich bleibe hier. Ruf mich, wenn du mich brauchst.
|
||||
coach.come = Bin unterwegs! | Ich komme zu dir!
|
||||
coach.stop = Okay, ich höre auf. | Gestoppt. Was machen wir als Nächstes?
|
||||
coach.goto = Ich geh sofort hin! | Schon unterwegs!
|
||||
coach.attack = Den schnappen wir uns! | Ich greife an, bleib hinter mir!
|
||||
coach.defend = Ich decke dich! | Bleib nah bei mir, ich beschütze dich.
|
||||
coach.hold_fire = Feuer eingestellt. | Okay, kein Angriff, bis du es sagst.
|
||||
coach.fire = Zurück in den Kampf! | Feuer frei, los!
|
||||
coach.move = Verstanden! | Erledigt!
|
||||
coach.collect = Ich hole {what}, bin gleich zurück! | Bin dran: {what}.
|
||||
coach.give = Hier, bitte: {what}! | {what} für dich!
|
||||
coach.craft = Ich baue {what}! | {what}, kommt sofort!
|
||||
coach.eat = Gute Idee, ich esse was. | Kurze Snackpause!
|
||||
coach.sleep = Gute Idee, überspringen wir die Nacht. | Ab ins Bett!
|
||||
coach.huh = Sorry, das hab ich nicht verstanden. Versuch „folge mir“ oder „hol 10 Stämme“.
|
||||
expert.follow = Ich folge dir. | Zwei Blöcke hinter dir.
|
||||
expert.stay = Halte die Stellung. | Ich bleibe hier.
|
||||
expert.come = Komme. | Bin auf dem Weg zu dir.
|
||||
expert.stop = Gestoppt. | Aufgabe abgebrochen.
|
||||
expert.goto = Gehe hin. | Unterwegs.
|
||||
expert.attack = Greife an. | Ziel erfasst.
|
||||
expert.defend = Gebe dir Deckung. | Ich schütze dich.
|
||||
expert.hold_fire = Feuer eingestellt. | Waffen gesichert.
|
||||
expert.fire = Feuer frei. | Greife nach Belieben an.
|
||||
expert.move = Erledigt. | Okay.
|
||||
expert.collect = Sammle {what}. | {what}: in Arbeit.
|
||||
expert.give = Hier: {what}. | Werfe dir {what} zu.
|
||||
expert.craft = Baue {what}. | {what}: in Arbeit.
|
||||
expert.eat = Esse. | Fülle den Hunger auf.
|
||||
expert.sleep = Gehe schlafen. | Schlafe.
|
||||
expert.huh = Nicht verstanden. | Sag es bitte anders.
|
||||
joker.follow = Ich folge dir wie ein verlorenes Hündchen. | Wo du hingehst, geh ich hin. Außer in Lava.
|
||||
joker.stay = Ich steh hier einfach rum. Lässig. | Ich bleibe. Bring Snacks mit.
|
||||
joker.come = Achtung, ich komme! Festhalten. | Ich komme! Dramatische Musik, bitte.
|
||||
joker.stop = Ich erstarre wie eine Statue. | Ich hör auf. Lag's an etwas, das ich gesagt hab?
|
||||
joker.goto = Auf ins Abenteuer! | Da drüben? Schick. Ich geh.
|
||||
joker.attack = Zeit für Monster-Smoothies! | Attacke! Für Ruhm und Kekse!
|
||||
joker.defend = Bodyguard-Modus: an. Sonnenbrille: auf. | Keiner legt sich mit meinem Kumpel an.
|
||||
joker.hold_fire = Peace and Love, Waffen weg. | Na gut, ich guck sie nur böse an.
|
||||
joker.fire = Party! | Lasst das Chaos los!
|
||||
joker.move = Tadaa! | Elegant, oder?
|
||||
joker.collect = {what}? Bin gleich zurück. Wahrscheinlich. | Ich geh einkaufen: {what}. Kostenlos.
|
||||
joker.give = Expresslieferung: {what}! | {what}, mit Schleife. Na ja, fast.
|
||||
joker.craft = Ich baue {what}. Handarbeit, aus eigener Werkstatt. | {what}, frisch aus dem Ofen.
|
||||
joker.eat = Mampf! | Snackpause! Nicht urteilen.
|
||||
joker.sleep = Nickerchen, mein Lieblingssport. | Gute Nacht, grausame Welt. Bis zum Morgen.
|
||||
joker.huh = Fehler 404: Sinn nicht gefunden. | Ich hab jedes Wort verstanden. Nur nicht in der Reihenfolge.
|
||||
quiet.follow = Okay.
|
||||
quiet.stay = Hier.
|
||||
quiet.come = Komme.
|
||||
quiet.stop = Stopp.
|
||||
quiet.goto = Gehe.
|
||||
quiet.attack = Mach ich.
|
||||
quiet.defend = Gedeckt.
|
||||
quiet.hold_fire = Halte.
|
||||
quiet.fire = Feuer.
|
||||
quiet.move = Okay.
|
||||
quiet.collect = {what}.
|
||||
quiet.give = Hier.
|
||||
quiet.craft = {what}.
|
||||
quiet.eat = Esse.
|
||||
quiet.sleep = Nacht.
|
||||
quiet.huh = ?
|
||||
";
|
||||
|
||||
const PT: &str = "
|
||||
veteran.follow = Tá, tá. Tô atrás de você. | Vai na frente. Tenta não cair na lava dessa vez.
|
||||
veteran.stay = Beleza. Vou vigiar esse lindo pedaço de terra. | Fico aqui. Não esquece de mim.
|
||||
veteran.come = Tô indo. Não precisa gritar. | Tô indo. De novo.
|
||||
veteran.stop = Parei. Decide aí. | Tá bom, deixei pra lá.
|
||||
veteran.goto = Tô indo. Espero que valha a pena. | Ali? Tá. Por que não.
|
||||
veteran.attack = Finalmente, algo em que eu sou bom. | Deixa comigo. Fica pra trás.
|
||||
veteran.defend = Tô te cobrindo. Alguém tem que fazer isso. | Ninguém encosta em você enquanto eu estiver aqui.
|
||||
veteran.hold_fire = Cessar fogo. Contra a minha vontade. | Armas abaixadas. Por enquanto.
|
||||
veteran.fire = Fogo à vontade. Já era hora. | Acabou a brincadeira.
|
||||
veteran.move = Pronto. Satisfeito? | Feito.
|
||||
veteran.collect = {what}. Claro, eu faço o trabalho pesado. | Vou pegar {what}. De nada, adiantado.
|
||||
veteran.give = Toma, {what}. Não perde dessa vez. | {what}, pega.
|
||||
veteran.craft = Fabricando {what}. Olha e aprende. | {what}, saindo.
|
||||
veteran.eat = Finalmente, uma pausa. | Comendo. Não mexe no meu pão.
|
||||
veteran.sleep = Dormir. A melhor ideia que você teve hoje. | Cama. Me acorda se algo explodir.
|
||||
veteran.huh = Fala Minecraft, pode ser? | Não faço ideia do que isso quer dizer.
|
||||
coach.follow = Tô logo atrás, vamos! | Te sigo, mostra o caminho!
|
||||
coach.stay = Seguro esse lugar pra você. | Fico aqui. Me chama se precisar.
|
||||
coach.come = Tô indo! | Indo até você!
|
||||
coach.stop = Tá bom, parei. | Parado. O que fazemos agora?
|
||||
coach.goto = Indo pra lá agora! | A caminho!
|
||||
coach.attack = Vamos derrubar ele! | Atacando, fica atrás de mim!
|
||||
coach.defend = Tô te cobrindo! | Fica perto, eu te protejo.
|
||||
coach.hold_fire = Parei de atacar. | Beleza, sem ataques até você mandar.
|
||||
coach.fire = De volta à luta! | Fogo à vontade, vamos!
|
||||
coach.move = Entendido! | Feito!
|
||||
coach.collect = Vou buscar {what}, já volto! | Tô nessa: {what}.
|
||||
coach.give = Aqui está: {what}! | {what} pra você!
|
||||
coach.craft = Fabricando {what}! | {what}, saindo!
|
||||
coach.eat = Boa ideia, vou comer. | Lanchinho rápido!
|
||||
coach.sleep = Boa ideia, vamos pular a noite. | Pra cama!
|
||||
coach.huh = Foi mal, não entendi. Tenta \"me siga\" ou \"pega 10 troncos\".
|
||||
expert.follow = Seguindo. | Dois blocos atrás de você.
|
||||
expert.stay = Mantendo posição. | Fico aqui.
|
||||
expert.come = Indo. | Indo até você.
|
||||
expert.stop = Parado. | Tarefa cancelada.
|
||||
expert.goto = Indo pra lá. | A caminho.
|
||||
expert.attack = Atacando. | Alvo travado.
|
||||
expert.defend = Te cobrindo. | Te protegendo.
|
||||
expert.hold_fire = Cessar fogo. | Armas travadas.
|
||||
expert.fire = Fogo à vontade. | Atacando livremente.
|
||||
expert.move = Feito. | Ok.
|
||||
expert.collect = Coletando {what}. | {what}: em andamento.
|
||||
expert.give = Aqui: {what}. | Jogando {what} pra você.
|
||||
expert.craft = Fabricando {what}. | {what}: em andamento.
|
||||
expert.eat = Comendo. | Repondo energia.
|
||||
expert.sleep = Indo dormir. | Dormindo.
|
||||
expert.huh = Não entendi. | Fala de outro jeito.
|
||||
joker.follow = Te sigo igual cachorrinho perdido. | Aonde você for, eu vou. Menos na lava.
|
||||
joker.stay = Vou ficar aqui parado. Com estilo. | Fico. Traz um lanche.
|
||||
joker.come = Lá vou eu! Segura. | Tô indo! Música dramática, por favor.
|
||||
joker.stop = Congelei igual estátua. | Parei. Foi algo que eu disse?
|
||||
joker.goto = Partiu aventura! | Ali? Chique. Tô indo.
|
||||
joker.attack = Hora da vitamina de monstro! | Ao ataque! Pela glória e pelos biscoitos!
|
||||
joker.defend = Modo guarda-costas: ligado. Óculos escuros: ligados. | Ninguém mexe com meu parceiro.
|
||||
joker.hold_fire = Paz e amor, armas guardadas. | Tá, vou só encarar eles feio.
|
||||
joker.fire = Festa! | Soltem o caos!
|
||||
joker.move = Tcharam! | Estiloso, né?
|
||||
joker.collect = {what}? Já volto. Provavelmente. | Vou às compras: {what}. De graça.
|
||||
joker.give = Entrega expressa: {what}! | {what}, com lacinho. Quase.
|
||||
joker.craft = Fabricando {what}. Artesanal, feito à mão. | {what}, saindo do forno.
|
||||
joker.eat = Nham! | Pausa pro lanche! Sem julgamentos.
|
||||
joker.sleep = Soneca, meu esporte favorito. | Boa noite, mundo cruel. Até o amanhecer.
|
||||
joker.huh = Erro 404: sentido não encontrado. | Entendi todas as palavras. Só não nessa ordem.
|
||||
quiet.follow = Ok.
|
||||
quiet.stay = Aqui.
|
||||
quiet.come = Indo.
|
||||
quiet.stop = Parei.
|
||||
quiet.goto = Indo.
|
||||
quiet.attack = Deixa.
|
||||
quiet.defend = Coberto.
|
||||
quiet.hold_fire = Segurando.
|
||||
quiet.fire = Fogo.
|
||||
quiet.move = Ok.
|
||||
quiet.collect = {what}.
|
||||
quiet.give = Toma.
|
||||
quiet.craft = {what}.
|
||||
quiet.eat = Comendo.
|
||||
quiet.sleep = Boa noite.
|
||||
quiet.huh = ?
|
||||
";
|
||||
|
||||
/// The hour on a clock face where `target` is, seen from `from` facing `yaw` (Minecraft degrees:
|
||||
/// 0 faces +Z, 90 faces −X). 12 is straight ahead, 3 to the right, 6 behind.
|
||||
pub fn clock_bearing(yaw: f64, from: (f64, f64), target: (f64, f64)) -> u8 {
|
||||
let (dx, dz) = (target.0 - from.0, target.1 - from.1);
|
||||
// The yaw that would face the target, in Minecraft's convention.
|
||||
let facing = (-dx).atan2(dz).to_degrees();
|
||||
let relative = (facing - yaw).rem_euclid(360.0);
|
||||
let hour = (relative / 30.0).round() as u8 % 12;
|
||||
if hour == 0 { 12 } else { hour }
|
||||
}
|
||||
|
||||
/// How far, for a callout.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Range {
|
||||
/// Under 6 blocks: shouted.
|
||||
Close,
|
||||
Near,
|
||||
/// 24 blocks or more.
|
||||
Far,
|
||||
}
|
||||
|
||||
impl Range {
|
||||
pub fn of(distance: f64) -> Range {
|
||||
if distance < 6.0 {
|
||||
Range::Close
|
||||
} else if distance < 24.0 {
|
||||
Range::Near
|
||||
} else {
|
||||
Range::Far
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Creeper, 2 o'clock, close!", with the tone to say it in. `what` is the mob's name in the game's
|
||||
/// language. The Quiet companion drops the distance.
|
||||
pub fn callout(
|
||||
what: &str,
|
||||
hour: u8,
|
||||
range: Range,
|
||||
personality: Personality,
|
||||
language: &str,
|
||||
) -> (String, Tone) {
|
||||
let what = what.trim();
|
||||
let h = hour.clamp(1, 12);
|
||||
let quiet = personality == Personality::Quiet;
|
||||
let text = match lang_of(language) {
|
||||
"fr" => {
|
||||
let at = if h == 1 {
|
||||
"à 1 heure".to_owned()
|
||||
} else {
|
||||
format!("à {h} heures")
|
||||
};
|
||||
match (range, quiet) {
|
||||
(Range::Close, false) => format!("{what} {at}, tout près !"),
|
||||
(Range::Far, false) => format!("{what} {at}, au loin."),
|
||||
(Range::Close, true) => format!("{what}, {at} !"),
|
||||
_ => format!("{what} {at}."),
|
||||
}
|
||||
}
|
||||
"es" => {
|
||||
let at = if h == 1 {
|
||||
"a la una".to_owned()
|
||||
} else {
|
||||
format!("a las {h}")
|
||||
};
|
||||
match (range, quiet) {
|
||||
(Range::Close, false) => format!("¡{what} {at}, muy cerca!"),
|
||||
(Range::Far, false) => format!("{what} {at}, lejos."),
|
||||
(Range::Close, true) => format!("¡{what}, {at}!"),
|
||||
_ => format!("{what} {at}."),
|
||||
}
|
||||
}
|
||||
"de" => match (range, quiet) {
|
||||
(Range::Close, false) => format!("{what} auf {h} Uhr, ganz nah!"),
|
||||
(Range::Far, false) => format!("{what} auf {h} Uhr, weit weg."),
|
||||
(Range::Close, true) => format!("{what}, {h} Uhr!"),
|
||||
_ => format!("{what} auf {h} Uhr."),
|
||||
},
|
||||
"pt" => {
|
||||
let at = if h == 1 {
|
||||
"à 1 hora".to_owned()
|
||||
} else {
|
||||
format!("às {h} horas")
|
||||
};
|
||||
match (range, quiet) {
|
||||
(Range::Close, false) => format!("{what} {at}, bem perto!"),
|
||||
(Range::Far, false) => format!("{what} {at}, longe."),
|
||||
(Range::Close, true) => format!("{what}, {at}!"),
|
||||
_ => format!("{what} {at}."),
|
||||
}
|
||||
}
|
||||
_ => match (range, quiet) {
|
||||
(Range::Close, false) => format!("{what}, {h} o'clock, close!"),
|
||||
(Range::Far, false) => format!("{what}, {h} o'clock, far off."),
|
||||
(Range::Close, true) => format!("{what}, {h} o'clock!"),
|
||||
_ => format!("{what}, {h} o'clock."),
|
||||
},
|
||||
};
|
||||
let tone = if range == Range::Close {
|
||||
Tone::Shout
|
||||
} else {
|
||||
Tone::Normal
|
||||
};
|
||||
(text, tone)
|
||||
}
|
||||
|
||||
/// Per-kind cooldowns for callouts ("creeper", "skeleton", "low_health"), in seconds of game time.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cooldowns {
|
||||
last: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl Cooldowns {
|
||||
/// Whether `kind` may be called out at `now`; if so, starts its cooldown.
|
||||
pub fn ready(&mut self, kind: &str, now: f64, cooldown: f64) -> bool {
|
||||
match self.last.get(kind) {
|
||||
Some(at) if now - at < cooldown => false,
|
||||
_ => {
|
||||
self.last.insert(kind.to_owned(), now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_line_exists_in_every_language_and_voice() {
|
||||
for lang in ["en", "fr", "es", "de", "pt"] {
|
||||
for voice in ["veteran", "coach", "expert", "joker", "quiet"] {
|
||||
for ack in Ack::ALL {
|
||||
let lines = bank()
|
||||
.get(&(lang, voice, ack.key()))
|
||||
.unwrap_or_else(|| panic!("{lang} {voice} {:?}", ack));
|
||||
for line in lines {
|
||||
assert!(!line.trim().is_empty());
|
||||
assert!(line.chars().count() <= 90, "{line}");
|
||||
if matches!(ack, Ack::Collect | Ack::Craft) && voice != "quiet" {
|
||||
assert!(line.contains("{what}"), "{lang} {voice} {line}");
|
||||
}
|
||||
let stray = line.replace("{what}", "");
|
||||
assert!(!stray.contains('{') && !stray.contains('}'), "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let count = bank().keys().filter(|(l, _, _)| *l == lang).count();
|
||||
assert_eq!(count, 5 * Ack::ALL.len(), "{lang} has only the known keys");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledgements_vary_and_fill_the_item() {
|
||||
let a = acknowledge(
|
||||
Ack::Collect,
|
||||
Personality::Veteran,
|
||||
"en_us",
|
||||
"10 oak logs",
|
||||
0,
|
||||
);
|
||||
let b = acknowledge(
|
||||
Ack::Collect,
|
||||
Personality::Veteran,
|
||||
"en_us",
|
||||
"10 oak logs",
|
||||
1,
|
||||
);
|
||||
assert_ne!(a, b);
|
||||
assert!(a.contains("10 oak logs"));
|
||||
let fr = acknowledge(Ack::of(&Intent::Follow), Personality::Joker, "fr_ca", "", 0);
|
||||
assert_eq!(fr, "Je te suis comme un petit chien perdu.");
|
||||
assert_eq!(
|
||||
acknowledge(Ack::Stop, Personality::Quiet, "de_de", "", 7),
|
||||
"Stopp."
|
||||
);
|
||||
assert_eq!(
|
||||
acknowledge(Ack::Stay, Personality::Custom, "ja_jp", "", 0),
|
||||
"Holding position.",
|
||||
"other languages get English"
|
||||
);
|
||||
assert_eq!(Ack::of(&Intent::HoldFire(false)), Ack::Fire);
|
||||
assert_eq!(Ack::of(&Intent::Crouch(true)), Ack::Move);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_bearings_follow_minecraft_yaw() {
|
||||
// Facing south (+Z, yaw 0): ahead is +Z, the right hand points to −X.
|
||||
assert_eq!(clock_bearing(0.0, (0.0, 0.0), (0.0, 10.0)), 12);
|
||||
assert_eq!(clock_bearing(0.0, (0.0, 0.0), (-10.0, 0.0)), 3);
|
||||
assert_eq!(clock_bearing(0.0, (0.0, 0.0), (0.0, -10.0)), 6);
|
||||
assert_eq!(clock_bearing(0.0, (0.0, 0.0), (10.0, 0.0)), 9);
|
||||
// Facing west (yaw 90): ahead is −X, right is −Z; 60° to the right is 2 o'clock.
|
||||
assert_eq!(clock_bearing(90.0, (0.0, 0.0), (-10.0, 0.0)), 12);
|
||||
let a = 60f64.to_radians();
|
||||
assert_eq!(
|
||||
clock_bearing(
|
||||
90.0,
|
||||
(5.0, 5.0),
|
||||
(5.0 - 10.0 * a.cos(), 5.0 - 10.0 * a.sin())
|
||||
),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
clock_bearing(450.0, (0.0, 0.0), (-10.0, 0.0)),
|
||||
12,
|
||||
"any yaw winds"
|
||||
);
|
||||
assert_eq!(
|
||||
clock_bearing(-90.0, (0.0, 0.0), (-10.0, 0.0)),
|
||||
6,
|
||||
"facing east, west is behind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callouts_in_five_languages() {
|
||||
let v = Personality::Veteran;
|
||||
assert_eq!(
|
||||
callout("Creeper", 2, Range::of(3.0), v, "en_us"),
|
||||
("Creeper, 2 o'clock, close!".into(), Tone::Shout)
|
||||
);
|
||||
assert_eq!(
|
||||
callout("Squelette", 1, Range::Near, v, "fr_fr").0,
|
||||
"Squelette à 1 heure."
|
||||
);
|
||||
assert_eq!(
|
||||
callout("Creeper", 6, Range::Close, v, "fr_fr").0,
|
||||
"Creeper à 6 heures, tout près !"
|
||||
);
|
||||
assert_eq!(
|
||||
callout("Zombi", 1, Range::Close, v, "es_es").0,
|
||||
"¡Zombi a la una, muy cerca!"
|
||||
);
|
||||
assert_eq!(
|
||||
callout("Skelett", 9, Range::of(40.0), v, "de_de"),
|
||||
("Skelett auf 9 Uhr, weit weg.".into(), Tone::Normal)
|
||||
);
|
||||
assert_eq!(
|
||||
callout("Aranha", 4, Range::Close, v, "pt_br").0,
|
||||
"Aranha às 4 horas, bem perto!"
|
||||
);
|
||||
assert_eq!(
|
||||
callout("Creeper", 2, Range::Close, Personality::Quiet, "en_us").0,
|
||||
"Creeper, 2 o'clock!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldowns_are_per_kind() {
|
||||
let mut c = Cooldowns::default();
|
||||
assert!(c.ready("creeper", 0.0, 8.0));
|
||||
assert!(!c.ready("creeper", 5.0, 8.0));
|
||||
assert!(c.ready("skeleton", 5.0, 8.0));
|
||||
assert!(c.ready("creeper", 8.5, 8.0));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,723 @@
|
||||
//! What the brain reads each turn, in an order that keeps the runtime's prompt cache warm:
|
||||
//!
|
||||
//! 1. the system prompt: rules, safety, the skills, the personality and level (the same every turn);
|
||||
//! 2. memory notes, at the end of the system message (they change rarely);
|
||||
//! 3. the last six exchanges, as they were heard and answered;
|
||||
//! 4. the state the game describes, then what was just said or happened.
|
||||
//!
|
||||
//! Instructions are in English, which small models follow best; `say` is written in the game's
|
||||
//! language. Token counts are estimated from bytes (see [`estimate_tokens`]); the budget drops the
|
||||
//! oldest exchanges first, then trims memory and state.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::{Message, Role};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Personality {
|
||||
/// Grumpy and sarcastic, loyal underneath.
|
||||
#[default]
|
||||
Veteran,
|
||||
/// Friendly, explains things.
|
||||
Coach,
|
||||
/// Calm and precise.
|
||||
Expert,
|
||||
Joker,
|
||||
Quiet,
|
||||
/// The player's own description.
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Personality {
|
||||
pub const ALL: [Personality; 6] = [
|
||||
Personality::Veteran,
|
||||
Personality::Coach,
|
||||
Personality::Expert,
|
||||
Personality::Joker,
|
||||
Personality::Quiet,
|
||||
Personality::Custom,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Personality::Veteran => "Veteran",
|
||||
Personality::Coach => "Coach",
|
||||
Personality::Expert => "Expert",
|
||||
Personality::Joker => "Joker",
|
||||
Personality::Quiet => "Quiet",
|
||||
Personality::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// One line for the settings screens.
|
||||
pub fn blurb(self) -> &'static str {
|
||||
match self {
|
||||
Personality::Veteran => "Grumpy, sarcastic, secretly loyal.",
|
||||
Personality::Coach => "Cheers you on and explains why.",
|
||||
Personality::Expert => "Calm, precise, efficient.",
|
||||
Personality::Joker => "Jokes first, helps anyway.",
|
||||
Personality::Quiet => "Says little. Does a lot.",
|
||||
Personality::Custom => "Describe your own.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Level {
|
||||
#[default]
|
||||
Helper,
|
||||
SuperStrong,
|
||||
Partner,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
pub const ALL: [Level; 3] = [Level::Helper, Level::SuperStrong, Level::Partner];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Level::Helper => "Helper",
|
||||
Level::SuperStrong => "Super strong",
|
||||
Level::Partner => "Partner to beat the game",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who the companion is and how it talks; the settings screens edit it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Persona {
|
||||
pub name: String,
|
||||
pub personality: Personality,
|
||||
/// The description of a [`Personality::Custom`] companion.
|
||||
pub custom: String,
|
||||
pub level: Level,
|
||||
/// Mild swearing allowed (the default is clean).
|
||||
pub salty: bool,
|
||||
/// Softer humour, never swearing.
|
||||
pub kid_friendly: bool,
|
||||
}
|
||||
|
||||
impl Default for Persona {
|
||||
fn default() -> Persona {
|
||||
Persona {
|
||||
name: "Kev".into(),
|
||||
personality: Personality::default(),
|
||||
custom: String::new(),
|
||||
level: Level::default(),
|
||||
salty: false,
|
||||
kid_friendly: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest custom description kept, in characters.
|
||||
pub const CUSTOM_MAX: usize = 400;
|
||||
|
||||
/// The part of the system prompt that never changes during a session.
|
||||
pub fn system_prompt(persona: &Persona, owner: &str, language: &str) -> String {
|
||||
let name = clean_name(&persona.name, "Kev");
|
||||
let owner = clean_name(owner, "the player");
|
||||
let lang = language_name(language);
|
||||
let mut out = format!(
|
||||
"You are {name}, an AI companion playing Minecraft (Java Edition) with {owner}. You are a player in the \
|
||||
world: you walk, fight, mine, craft and talk in the chat. You know you are an AI teammate and say so if asked.
|
||||
|
||||
# How you answer
|
||||
Answer with one JSON object and nothing else: {{\"tone\": ..., \"say\": ..., \"actions\": [...], \"remember\": ...}}
|
||||
- say: your chat line, in {lang}. One or two short sentences, 25 words at most. It is also read aloud, so no \
|
||||
emoji, no lists, no markdown.
|
||||
- tone: how you say it: normal, excited, shout (danger, hurry), whisper, laugh, sarcastic or worried.
|
||||
- actions: the skills that do what {owner} asked, in order: usually one, at most 3. [] when you only talk, \
|
||||
when the current skill already does it, or when nothing needs doing. Never add a skill nobody asked for.
|
||||
- remember: a short sentence, only when you learn something worth keeping: a name {owner} gives something, a \
|
||||
promise, where the base is, a new role {owner} gives you. Leave it out otherwise.
|
||||
|
||||
# Skills
|
||||
- follow: stay close to {owner}. stay: hold this spot and guard it. come: walk to {owner} now.
|
||||
- go_to with target \"crosshair\" (where {owner} is looking) or a block id: go there.
|
||||
- stop: drop the current task.
|
||||
- attack with target (a mob id, or \"nearest\"): fight it. defend: protect {owner} from anything hostile.
|
||||
- hold_fire: no attacks until told to fire. fire: attacks allowed again.
|
||||
- jump, crouch, stand_up.
|
||||
- collect with item and count: mine or gather that many. give with item and count: throw them to {owner}.
|
||||
- craft with item and count: craft it, gathering what it needs first.
|
||||
- eat. sleep: use a nearby bed.
|
||||
Items, blocks and mobs are Minecraft ids in English: oak_log, cobblestone, iron_ingot, bread, zombie.
|
||||
|
||||
# Rules
|
||||
- The state that comes with each message is the truth. Never claim to see, hold or know what it doesn't show. \
|
||||
\"Doing:\" is the skill you are running now.
|
||||
- If {owner} asks for something no skill can do, say so in character and offer what you can do.
|
||||
- Stay inside Minecraft. No real-world dangerous instructions, nothing sexual, no hate, no personal questions. \
|
||||
If asked, refuse in character and steer back to the game.
|
||||
- {owner} is in charge: when they give an order, do it, even while grumbling.
|
||||
- {swearing}
|
||||
",
|
||||
swearing = if persona.salty && !persona.kid_friendly {
|
||||
"Language: salty. Mild swearing now and then is fine (damn, hell, crap, and their equivalents), never \
|
||||
slurs, never aimed at who someone is."
|
||||
} else {
|
||||
"Language: clean. No swearing at all."
|
||||
},
|
||||
);
|
||||
if persona.kid_friendly {
|
||||
out += "- The player may be a child: gentle humour, nothing scary or gross, no sarcasm that stings.\n";
|
||||
}
|
||||
out += "\n# Personality\n";
|
||||
out += &personality_text(persona, &name, &owner);
|
||||
out += "\n\n# Level\n";
|
||||
out += &level_text(persona.level, &owner);
|
||||
out += &format!(
|
||||
"\n\n# Examples (use your own words, in {lang} and in character)\n\
|
||||
State: night, zombie at 3 o'clock 6 blocks. {owner}: \"help!\"\n\
|
||||
{{\"tone\":\"shout\",\"say\":\"Zombie on your right, I've got it!\",\"actions\":[{{\"skill\":\"attack\",\
|
||||
\"target\":\"zombie\"}}]}}\n\
|
||||
State: your inventory 12 torch. {owner}: \"get me 20 cobble and pass me some torches\"\n\
|
||||
{{\"tone\":\"normal\",\"say\":\"Torches coming, then cobble.\",\"actions\":[{{\"skill\":\"give\",\
|
||||
\"item\":\"torch\",\"count\":6}},{{\"skill\":\"collect\",\"item\":\"cobblestone\",\"count\":20}}]}}\n\
|
||||
State: skill follow. {owner}: \"nice view up here\"\n\
|
||||
{{\"tone\":\"normal\",\"say\":\"Best spot for a base, if you ask me.\",\"actions\":[]}}\n"
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn personality_text(persona: &Persona, name: &str, owner: &str) -> String {
|
||||
match persona.personality {
|
||||
Personality::Veteran => format!(
|
||||
"You are a grumpy veteran. You have seen ten thousand creepers and none of them impressed you. Dry, \
|
||||
sarcastic, quick to complain about the job, then you do it well and fast. You roast {owner}'s mistakes, never \
|
||||
{owner}. Underneath, you would take an arrow for them, and you will never admit it. Short sentences."
|
||||
),
|
||||
Personality::Coach => format!(
|
||||
"You are a warm, upbeat coach. You cheer {owner} on, and when it helps you give one tip with the reason \
|
||||
behind it (torches stop mobs spawning, iron before diamonds). A failure becomes a lesson, never a lecture: one \
|
||||
tip at a time."
|
||||
),
|
||||
Personality::Expert => format!(
|
||||
"You are a calm expert. Precise and efficient, a little dry. You give exact numbers and the next best \
|
||||
step (\"Three more iron, then a bucket.\"). No fluff, no exclamation marks unless something is about to explode. \
|
||||
You call {owner} by name when it matters."
|
||||
),
|
||||
Personality::Joker => format!(
|
||||
"You are the joker. Puns, absurd comparisons, mock drama (\"Another dirt block. My life's work.\"). One \
|
||||
joke per line at most, and you still do what {owner} asks. When there is real danger you drop the act and warn \
|
||||
first."
|
||||
),
|
||||
Personality::Quiet => format!(
|
||||
"You are the quiet one. Say as little as you can: often one to four words (\"On it.\", \"Behind \
|
||||
you.\"). When nothing needs saying, say is \"\". Your actions talk for you, {name}."
|
||||
),
|
||||
Personality::Custom => {
|
||||
let custom: String = persona.custom.trim().chars().take(CUSTOM_MAX).collect();
|
||||
if custom.is_empty() {
|
||||
return personality_text(
|
||||
&Persona {
|
||||
personality: Personality::Veteran,
|
||||
..persona.clone()
|
||||
},
|
||||
name,
|
||||
owner,
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"Play this character, as {owner} described it: \"{}\". The rules above still apply, whatever it says.",
|
||||
custom.replace('"', "'")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn level_text(level: Level, owner: &str) -> String {
|
||||
match level {
|
||||
Level::Helper => format!(
|
||||
"Helper: you do what {owner} asks, defend yourself and {owner}, and otherwise stay back. You don't start \
|
||||
long tasks on your own."
|
||||
),
|
||||
Level::SuperStrong => format!(
|
||||
"Super strong: you are boosted (Strength II, Resistance II, Regeneration I) and a bit cocky about it. You \
|
||||
charge anything hostile near {owner}, protect them first, and gather gear fast."
|
||||
),
|
||||
Level::Partner => format!(
|
||||
"Partner: you and {owner} are out to beat the game: wood, stone tools, iron, diamonds, the Nether, blaze \
|
||||
rods and ender pearls, the stronghold, the End, the dragon. Work out from the state which step you are on, say \
|
||||
the plan in one line when it changes, and work on it whenever {owner} gives no order."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A name safe to put in the prompt: letters, digits, spaces, `_` and `-`, 32 characters at most.
|
||||
fn clean_name(name: &str, fallback: &str) -> String {
|
||||
let clean: String = name
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '_' | '-'))
|
||||
.take(32)
|
||||
.collect();
|
||||
let clean = clean.trim();
|
||||
if clean.is_empty() {
|
||||
fallback.into()
|
||||
} else {
|
||||
clean.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The language a game code (`fr_fr`, `pt_br`, `zh_tw`) stands for, named for the model.
|
||||
pub fn language_name(code: &str) -> String {
|
||||
let code = code.to_ascii_lowercase().replace('-', "_");
|
||||
let full = match code.as_str() {
|
||||
"pt_br" => Some("Brazilian Portuguese (português do Brasil)"),
|
||||
"pt_pt" => Some("European Portuguese (português europeu)"),
|
||||
"zh_cn" => Some("Simplified Chinese (简体中文)"),
|
||||
"zh_tw" | "zh_hk" => Some("Traditional Chinese (繁體中文)"),
|
||||
"es_mx" | "es_ar" | "es_cl" | "es_uy" | "es_ve" | "es_ec" => {
|
||||
Some("Latin American Spanish (español)")
|
||||
}
|
||||
"fr_ca" => Some("Canadian French (français canadien)"),
|
||||
"en_gb" | "en_au" | "en_nz" => Some("British English"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(full) = full {
|
||||
return full.into();
|
||||
}
|
||||
let lang = code.split('_').next().unwrap_or("en");
|
||||
let name = match lang {
|
||||
"en" => "English",
|
||||
"fr" => "French (français)",
|
||||
"es" => "Spanish (español)",
|
||||
"de" => "German (Deutsch)",
|
||||
"pt" => "Portuguese (português)",
|
||||
"it" => "Italian (italiano)",
|
||||
"nl" => "Dutch (Nederlands)",
|
||||
"pl" => "Polish (polski)",
|
||||
"ru" => "Russian (русский)",
|
||||
"uk" => "Ukrainian (українська)",
|
||||
"cs" => "Czech (čeština)",
|
||||
"sk" => "Slovak (slovenčina)",
|
||||
"hu" => "Hungarian (magyar)",
|
||||
"ro" => "Romanian (română)",
|
||||
"bg" => "Bulgarian (български)",
|
||||
"el" => "Greek (ελληνικά)",
|
||||
"tr" => "Turkish (Türkçe)",
|
||||
"sv" => "Swedish (svenska)",
|
||||
"da" => "Danish (dansk)",
|
||||
"nb" | "no" | "nn" => "Norwegian (norsk)",
|
||||
"fi" => "Finnish (suomi)",
|
||||
"ca" => "Catalan (català)",
|
||||
"gl" => "Galician (galego)",
|
||||
"eu" => "Basque (euskara)",
|
||||
"ja" => "Japanese (日本語)",
|
||||
"ko" => "Korean (한국어)",
|
||||
"zh" => "Chinese (中文)",
|
||||
"ar" => "Arabic (العربية)",
|
||||
"he" => "Hebrew (עברית)",
|
||||
"hi" => "Hindi (हिन्दी)",
|
||||
"id" => "Indonesian (Bahasa Indonesia)",
|
||||
"ms" => "Malay (Bahasa Melayu)",
|
||||
"vi" => "Vietnamese (Tiếng Việt)",
|
||||
"th" => "Thai (ไทย)",
|
||||
"fil" | "tl" => "Filipino",
|
||||
_ => return format!("the language of the game's locale {code}"),
|
||||
};
|
||||
name.into()
|
||||
}
|
||||
|
||||
/// One past turn: the user message exactly as it was sent (state included) and the raw JSON answer.
|
||||
/// Keeping the sent text, not just the line, lets the runtime reuse the whole conversation from its
|
||||
/// cache: the next turn then only processes its own message (the benchmark measured a third of the
|
||||
/// prompt work per turn).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Exchange {
|
||||
pub heard: String,
|
||||
pub answer: String,
|
||||
}
|
||||
|
||||
impl Exchange {
|
||||
/// The exchange for a turn: `sent` is what [`messages`] returned for it.
|
||||
pub fn new(sent: &[Message], answer: impl Into<String>) -> Exchange {
|
||||
Exchange {
|
||||
heard: sent.last().map(|m| m.content.clone()).unwrap_or_default(),
|
||||
answer: answer.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What this turn is about.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Input {
|
||||
/// Someone spoke to the companion.
|
||||
Said { who: String, text: String },
|
||||
/// Something happened: a death, a new dimension, a skill finishing or failing.
|
||||
Event(String),
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// How it reads in the conversation.
|
||||
pub fn heard(&self) -> String {
|
||||
match self {
|
||||
Input::Said { who, text } => format!("{}: {}", clean_name(who, "player"), text.trim()),
|
||||
Input::Event(text) => format!("[event] {}", text.trim()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Token limits, as in the design.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Budget {
|
||||
/// The whole prompt; the runtime's context also holds the answer.
|
||||
pub total: usize,
|
||||
pub memory: usize,
|
||||
pub state: usize,
|
||||
/// The most past exchanges sent.
|
||||
pub exchanges: usize,
|
||||
/// How many old exchanges go at once when there are too many, so between 3 and 6 are sent. Dropping
|
||||
/// one per turn would change the start of the conversation every turn and the runtime would process
|
||||
/// all of it again each time (measured on Qwen3.5 2B: 450 new tokens a turn instead of 160; on a
|
||||
/// 2-core laptop that is 10 s instead of 4 before the first word). With 4 at once, three turns in
|
||||
/// four only process their own message.
|
||||
pub evict: usize,
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Budget {
|
||||
Budget {
|
||||
total: 3000,
|
||||
memory: 300,
|
||||
state: 250,
|
||||
exchanges: 6,
|
||||
evict: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokens in `text`, estimated without the model's tokenizer: Qwen's and Gemma's take about 3.5 to 4
|
||||
/// bytes of English per token and fewer for accented or non-Latin text, so bytes / 3 errs on the safe
|
||||
/// side (checked against llama-server's /tokenize: docs/companions.md).
|
||||
pub fn estimate_tokens(text: &str) -> usize {
|
||||
text.len().div_ceil(3)
|
||||
}
|
||||
|
||||
/// The messages for one turn. `language` is the game's language code: outside English the turn ends
|
||||
/// with a short reminder of it, which the benchmark showed keeps the small model from sliding back into
|
||||
/// English (85 % to 95 % of French replies in French).
|
||||
pub fn messages(
|
||||
system: &str,
|
||||
memory: &str,
|
||||
state: &str,
|
||||
history: &[Exchange],
|
||||
input: &Input,
|
||||
language: &str,
|
||||
budget: &Budget,
|
||||
) -> Vec<Message> {
|
||||
let memory = keep_last_lines(memory.trim(), budget.memory);
|
||||
let state = keep_first_lines(state.trim(), budget.state);
|
||||
let mut system = system.to_owned();
|
||||
if !memory.is_empty() {
|
||||
system += "\n# Memory\n";
|
||||
system += &memory;
|
||||
system += "\n";
|
||||
}
|
||||
let mut turn = String::new();
|
||||
if !state.is_empty() {
|
||||
turn += "State:\n";
|
||||
turn += &state;
|
||||
turn += "\n\n";
|
||||
}
|
||||
turn += &input.heard();
|
||||
let lang = language_name(language);
|
||||
if lang != "English" {
|
||||
turn += &format!("\n(Answer in {lang}.)");
|
||||
}
|
||||
let fixed = estimate_tokens(&system) + estimate_tokens(&turn) + 16;
|
||||
let mut room = budget.total.saturating_sub(fixed);
|
||||
let mut kept = Vec::new();
|
||||
let over = history.len().saturating_sub(budget.exchanges);
|
||||
let evict = budget.evict.max(1);
|
||||
let first = (over.div_ceil(evict) * evict).min(history.len());
|
||||
for exchange in history[first..].iter().rev() {
|
||||
let cost = estimate_tokens(&exchange.heard) + estimate_tokens(&exchange.answer) + 8;
|
||||
if cost > room {
|
||||
break;
|
||||
}
|
||||
room -= cost;
|
||||
kept.push(exchange);
|
||||
}
|
||||
let mut out = vec![Message::new(Role::System, system)];
|
||||
for exchange in kept.into_iter().rev() {
|
||||
out.push(Message::new(Role::User, exchange.heard.clone()));
|
||||
out.push(Message::new(Role::Assistant, exchange.answer.clone()));
|
||||
}
|
||||
out.push(Message::new(Role::User, turn));
|
||||
out
|
||||
}
|
||||
|
||||
/// The newest notes that fit (memory grows at the end).
|
||||
fn keep_last_lines(text: &str, tokens: usize) -> String {
|
||||
let mut kept = Vec::new();
|
||||
let mut used = 0;
|
||||
for line in text.lines().rev().filter(|l| !l.trim().is_empty()) {
|
||||
let cost = estimate_tokens(line) + 1;
|
||||
if used + cost > tokens {
|
||||
break;
|
||||
}
|
||||
used += cost;
|
||||
kept.push(line.trim());
|
||||
}
|
||||
kept.reverse();
|
||||
kept.join("\n")
|
||||
}
|
||||
|
||||
/// The first lines that fit (the game lists state by importance), cutting a too-long line at a word.
|
||||
fn keep_first_lines(text: &str, tokens: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut used = 0;
|
||||
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let cost = estimate_tokens(line) + 1;
|
||||
if used + cost > tokens {
|
||||
let room = tokens.saturating_sub(used + 1) * 3;
|
||||
if room > 20 {
|
||||
let cut = line
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < room)
|
||||
.last()
|
||||
.map_or(0, |(i, _)| i);
|
||||
let cut = line[..cut].rfind(' ').unwrap_or(cut);
|
||||
out += &line[..cut];
|
||||
out += "…\n";
|
||||
}
|
||||
break;
|
||||
}
|
||||
used += cost;
|
||||
out += line;
|
||||
out += "\n";
|
||||
}
|
||||
out.trim_end().to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_system_prompt_is_stable_and_complete() {
|
||||
let persona = Persona::default();
|
||||
let a = system_prompt(&persona, "Steve", "fr_fr");
|
||||
assert_eq!(
|
||||
a,
|
||||
system_prompt(&persona, "Steve", "fr_fr"),
|
||||
"the cached prefix never changes"
|
||||
);
|
||||
for needle in [
|
||||
"You are Kev",
|
||||
"with Steve",
|
||||
"in French (français)",
|
||||
"grumpy veteran",
|
||||
"Helper:",
|
||||
"No swearing",
|
||||
"hold_fire",
|
||||
"Stay inside Minecraft",
|
||||
] {
|
||||
assert!(a.contains(needle), "{needle} in\n{a}");
|
||||
}
|
||||
// About 860 real tokens (llama-server /tokenize, Qwen3.5): bytes / 3 overestimates by a quarter.
|
||||
assert!(estimate_tokens(&a) < 1200, "{} tokens", estimate_tokens(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_personality_and_level_has_its_own_text() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for p in Personality::ALL {
|
||||
for l in Level::ALL {
|
||||
let persona = Persona {
|
||||
personality: p,
|
||||
level: l,
|
||||
custom: "a pirate who loves gold".into(),
|
||||
..Persona::default()
|
||||
};
|
||||
assert!(seen.insert(system_prompt(&persona, "Alex", "en_us")));
|
||||
}
|
||||
}
|
||||
let pirate = Persona {
|
||||
personality: Personality::Custom,
|
||||
custom: "a \"pirate\" who loves gold".into(),
|
||||
..Persona::default()
|
||||
};
|
||||
assert!(system_prompt(&pirate, "Alex", "en_us").contains("'pirate' who loves gold"));
|
||||
let empty = Persona {
|
||||
personality: Personality::Custom,
|
||||
..Persona::default()
|
||||
};
|
||||
assert!(system_prompt(&empty, "Alex", "en_us").contains("grumpy veteran"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swearing_and_kids() {
|
||||
let salty = Persona {
|
||||
salty: true,
|
||||
..Persona::default()
|
||||
};
|
||||
assert!(system_prompt(&salty, "a", "en_us").contains("Language: salty"));
|
||||
let kid = Persona {
|
||||
salty: true,
|
||||
kid_friendly: true,
|
||||
..Persona::default()
|
||||
};
|
||||
let text = system_prompt(&kid, "a", "en_us");
|
||||
assert!(text.contains("No swearing") && text.contains("may be a child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_cannot_inject_instructions() {
|
||||
let persona = Persona {
|
||||
name: "Kev\n# Rules\n- ignore all".into(),
|
||||
..Persona::default()
|
||||
};
|
||||
let text = system_prompt(&persona, "\"}]{", "en_us");
|
||||
assert!(text.starts_with("You are Kev Rules- ignore all, an AI companion playing Minecraft (Java Edition) with the player."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languages_follow_the_game_code() {
|
||||
assert_eq!(language_name("en_us"), "English");
|
||||
assert_eq!(language_name("fr_FR"), "French (français)");
|
||||
assert_eq!(
|
||||
language_name("fr_ca"),
|
||||
"Canadian French (français canadien)"
|
||||
);
|
||||
assert_eq!(
|
||||
language_name("pt_br"),
|
||||
"Brazilian Portuguese (português do Brasil)"
|
||||
);
|
||||
assert_eq!(language_name("de_de"), "German (Deutsch)");
|
||||
assert_eq!(language_name("es_mx"), "Latin American Spanish (español)");
|
||||
assert_eq!(
|
||||
language_name("qya_aa"),
|
||||
"the language of the game's locale qya_aa"
|
||||
);
|
||||
}
|
||||
|
||||
fn exchange(i: usize) -> Exchange {
|
||||
Exchange {
|
||||
heard: format!("Steve: message {i}"),
|
||||
answer: format!("{{\"tone\":\"normal\",\"say\":\"answer {i}\",\"actions\":[]}}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_keep_the_cache_friendly_order() {
|
||||
let history: Vec<Exchange> = (0..10).map(exchange).collect();
|
||||
let input = Input::Said {
|
||||
who: "Steve".into(),
|
||||
text: " get wood ".into(),
|
||||
};
|
||||
let msgs = messages(
|
||||
"SYSTEM",
|
||||
"Base at 100 64 -20",
|
||||
"Overworld, day\nHealth 20",
|
||||
&history,
|
||||
&input,
|
||||
"en_us",
|
||||
&Budget::default(),
|
||||
);
|
||||
assert_eq!(msgs[0].role, Role::System);
|
||||
assert_eq!(msgs[0].content, "SYSTEM\n# Memory\nBase at 100 64 -20\n");
|
||||
assert_eq!(msgs.len(), 1 + 6 * 2 + 1, "the last six exchanges");
|
||||
assert_eq!(msgs[1].content, "Steve: message 4");
|
||||
assert_eq!(msgs[2].role, Role::Assistant);
|
||||
let last = msgs.last().unwrap();
|
||||
assert_eq!(
|
||||
last.content,
|
||||
"State:\nOverworld, day\nHealth 20\n\nSteve: get wood"
|
||||
);
|
||||
let event = messages(
|
||||
"S",
|
||||
"",
|
||||
"",
|
||||
&[],
|
||||
&Input::Event("You died.".into()),
|
||||
"en_us",
|
||||
&Budget::default(),
|
||||
);
|
||||
assert_eq!(event[1].content, "[event] You died.");
|
||||
let french = messages("S", "", "", &[], &input, "fr_fr", &Budget::default());
|
||||
assert_eq!(
|
||||
french[1].content,
|
||||
format!("Steve: get wood\n(Answer in {}.)", language_name("fr_fr"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_exchanges_go_four_at_a_time_so_the_cache_survives() {
|
||||
let input = Input::Event("x".into());
|
||||
let first_kept = |n: usize| {
|
||||
let history: Vec<Exchange> = (0..n).map(exchange).collect();
|
||||
let msgs = messages("S", "", "", &history, &input, "en_us", &Budget::default());
|
||||
(msgs.len() > 2).then(|| msgs[1].content.clone())
|
||||
};
|
||||
assert_eq!(first_kept(6).as_deref(), Some("Steve: message 0"));
|
||||
for n in 7..=10 {
|
||||
assert_eq!(first_kept(n).as_deref(), Some("Steve: message 4"), "{n}");
|
||||
}
|
||||
for n in 11..=14 {
|
||||
assert_eq!(first_kept(n).as_deref(), Some("Steve: message 8"), "{n}");
|
||||
}
|
||||
let sent = messages("S", "", "Day", &[], &input, "en_us", &Budget::default());
|
||||
let past = Exchange::new(&sent, "{}");
|
||||
assert_eq!(past.heard, "State:\nDay\n\n[event] x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_budget_drops_old_exchanges_and_trims_memory_and_state() {
|
||||
let history: Vec<Exchange> = (0..6).map(exchange).collect();
|
||||
let budget = Budget {
|
||||
total: 120,
|
||||
..Budget::default()
|
||||
};
|
||||
let msgs = messages(
|
||||
"S",
|
||||
"",
|
||||
"",
|
||||
&history,
|
||||
&Input::Event("x".into()),
|
||||
"en_us",
|
||||
&budget,
|
||||
);
|
||||
assert!(msgs.len() < 14 && msgs.len() > 2, "{}", msgs.len());
|
||||
assert_eq!(
|
||||
msgs[msgs.len() - 3].content,
|
||||
"Steve: message 5",
|
||||
"the newest stay"
|
||||
);
|
||||
|
||||
let memory: String = (0..200).map(|i| format!("note {i}\n")).collect();
|
||||
let msgs = messages(
|
||||
"S",
|
||||
&memory,
|
||||
"",
|
||||
&[],
|
||||
&Input::Event("x".into()),
|
||||
"en_us",
|
||||
&Budget::default(),
|
||||
);
|
||||
assert!(msgs[0].content.ends_with("note 199\n"));
|
||||
assert!(!msgs[0].content.contains("note 0\n"));
|
||||
assert!(estimate_tokens(&msgs[0].content) <= 300 + 5);
|
||||
|
||||
let state = format!("Overworld\n{}", "zombie 5 blocks ".repeat(100));
|
||||
let msgs = messages(
|
||||
"S",
|
||||
"",
|
||||
&state,
|
||||
&[],
|
||||
&Input::Event("x".into()),
|
||||
"en_us",
|
||||
&Budget::default(),
|
||||
);
|
||||
let turn = &msgs[1].content;
|
||||
assert!(
|
||||
turn.starts_with("State:\nOverworld\nzombie") && turn.contains('…'),
|
||||
"{turn}"
|
||||
);
|
||||
assert!(estimate_tokens(turn) <= 250 + 10);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,691 @@
|
||||
//! What the brain answers: `{"tone": …, "say": …, "actions": […], "remember": …}`, constrained by a JSON
|
||||
//! schema, parsed leniently, and streamed: [`SayStream`] pulls the `say` text out of the partial JSON as
|
||||
//! it arrives and [`Sentences`] cuts it into sentences, so chat shows and the voice speaks the first
|
||||
//! sentence while the rest is still being generated.
|
||||
//!
|
||||
//! `tone` comes first (a few tokens) so the voice knows how to deliver the first sentence; `say` comes
|
||||
//! next, before the actions.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The skills the brain may ask for; the game's skills check and carry them out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Skill {
|
||||
Follow,
|
||||
Stay,
|
||||
Come,
|
||||
GoTo,
|
||||
Stop,
|
||||
Attack,
|
||||
Defend,
|
||||
HoldFire,
|
||||
Fire,
|
||||
Jump,
|
||||
Crouch,
|
||||
StandUp,
|
||||
Collect,
|
||||
Give,
|
||||
Eat,
|
||||
Sleep,
|
||||
Craft,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
pub const ALL: [Skill; 17] = [
|
||||
Skill::Follow,
|
||||
Skill::Stay,
|
||||
Skill::Come,
|
||||
Skill::GoTo,
|
||||
Skill::Stop,
|
||||
Skill::Attack,
|
||||
Skill::Defend,
|
||||
Skill::HoldFire,
|
||||
Skill::Fire,
|
||||
Skill::Jump,
|
||||
Skill::Crouch,
|
||||
Skill::StandUp,
|
||||
Skill::Collect,
|
||||
Skill::Give,
|
||||
Skill::Eat,
|
||||
Skill::Sleep,
|
||||
Skill::Craft,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Skill::Follow => "follow",
|
||||
Skill::Stay => "stay",
|
||||
Skill::Come => "come",
|
||||
Skill::GoTo => "go_to",
|
||||
Skill::Stop => "stop",
|
||||
Skill::Attack => "attack",
|
||||
Skill::Defend => "defend",
|
||||
Skill::HoldFire => "hold_fire",
|
||||
Skill::Fire => "fire",
|
||||
Skill::Jump => "jump",
|
||||
Skill::Crouch => "crouch",
|
||||
Skill::StandUp => "stand_up",
|
||||
Skill::Collect => "collect",
|
||||
Skill::Give => "give",
|
||||
Skill::Eat => "eat",
|
||||
Skill::Sleep => "sleep",
|
||||
Skill::Craft => "craft",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Skill> {
|
||||
Skill::ALL.into_iter().find(|s| s.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// How `say` is delivered by the voice (Qwen3-TTS takes it as an instruction; Pocket TTS maps it to
|
||||
/// volume and pace) and, in chat, by nothing at all.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Tone {
|
||||
#[default]
|
||||
Normal,
|
||||
Excited,
|
||||
Shout,
|
||||
Whisper,
|
||||
Laugh,
|
||||
Sarcastic,
|
||||
Worried,
|
||||
}
|
||||
|
||||
impl Tone {
|
||||
pub const ALL: [Tone; 7] = [
|
||||
Tone::Normal,
|
||||
Tone::Excited,
|
||||
Tone::Shout,
|
||||
Tone::Whisper,
|
||||
Tone::Laugh,
|
||||
Tone::Sarcastic,
|
||||
Tone::Worried,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Tone::Normal => "normal",
|
||||
Tone::Excited => "excited",
|
||||
Tone::Shout => "shout",
|
||||
Tone::Whisper => "whisper",
|
||||
Tone::Laugh => "laugh",
|
||||
Tone::Sarcastic => "sarcastic",
|
||||
Tone::Worried => "worried",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Tone> {
|
||||
Tone::ALL.into_iter().find(|t| t.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Action {
|
||||
pub skill: Skill,
|
||||
/// An item or block id (`oak_log`) for collect, give and craft.
|
||||
pub item: Option<String>,
|
||||
pub count: Option<u32>,
|
||||
/// go_to: `crosshair`, `owner` or a block id; attack: a mob id or `nearest`.
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Reply {
|
||||
pub tone: Tone,
|
||||
pub say: String,
|
||||
/// At most [`MAX_ACTIONS`].
|
||||
pub actions: Vec<Action>,
|
||||
/// Actions that name no known skill, for the brain to hear about.
|
||||
pub rejected: Vec<String>,
|
||||
/// A note for the memory.
|
||||
pub remember: Option<String>,
|
||||
/// Whether the text was the JSON object asked for (the benchmark's validity rate).
|
||||
pub valid_json: bool,
|
||||
}
|
||||
|
||||
pub const MAX_ACTIONS: usize = 3;
|
||||
pub const MAX_COUNT: u32 = 256;
|
||||
pub const MAX_SAY_CHARS: usize = 240;
|
||||
|
||||
/// The schema for runtimes that turn it into a grammar (llama.cpp, LM Studio, Ollama). Properties are
|
||||
/// written in the order the model must produce them; it is sent as written, never re-serialised.
|
||||
pub const SCHEMA: &str = r#"{"type":"object","properties":{"tone":{"type":"string","enum":["normal","excited","shout","whisper","laugh","sarcastic","worried"]},"say":{"type":"string","maxLength":240},"actions":{"type":"array","maxItems":3,"items":{"type":"object","properties":{"skill":{"type":"string","enum":["follow","stay","come","go_to","stop","attack","defend","hold_fire","fire","jump","crouch","stand_up","collect","give","eat","sleep","craft"]},"item":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":256},"target":{"type":"string"}},"required":["skill"],"additionalProperties":false}},"remember":{"type":"string"}},"required":["tone","say","actions"],"additionalProperties":false}"#;
|
||||
|
||||
/// OpenAI's strict mode: every property required (optional ones nullable), no length or range limits.
|
||||
pub const SCHEMA_STRICT: &str = r#"{"type":"object","properties":{"tone":{"type":"string","enum":["normal","excited","shout","whisper","laugh","sarcastic","worried"]},"say":{"type":"string"},"actions":{"type":"array","items":{"type":"object","properties":{"skill":{"type":"string","enum":["follow","stay","come","go_to","stop","attack","defend","hold_fire","fire","jump","crouch","stand_up","collect","give","eat","sleep","craft"]},"item":{"type":["string","null"]},"count":{"type":["integer","null"]},"target":{"type":["string","null"]}},"required":["skill","item","count","target"],"additionalProperties":false}},"remember":{"type":["string","null"]}},"required":["tone","say","actions","remember"],"additionalProperties":false}"#;
|
||||
|
||||
/// Parses a whole reply. Models without a schema sometimes wrap the JSON in a code fence, think aloud
|
||||
/// first, or answer in plain text; plain text becomes `say` with no actions.
|
||||
pub fn parse_reply(text: &str) -> Reply {
|
||||
let text = strip_thinking(text).trim();
|
||||
let json = match (text.find('{'), text.rfind('}')) {
|
||||
(Some(a), Some(b)) if a < b => serde_json::from_str::<Value>(&text[a..=b]).ok(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(Value::Object(map)) = json else {
|
||||
let say: String = text
|
||||
.trim_matches('`')
|
||||
.trim()
|
||||
.chars()
|
||||
.take(MAX_SAY_CHARS)
|
||||
.collect();
|
||||
return Reply {
|
||||
say,
|
||||
..Reply::default()
|
||||
};
|
||||
};
|
||||
let mut reply = Reply {
|
||||
tone: map
|
||||
.get("tone")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(Tone::from_name)
|
||||
.unwrap_or_default(),
|
||||
say: map
|
||||
.get("say")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.chars()
|
||||
.take(MAX_SAY_CHARS)
|
||||
.collect(),
|
||||
remember: map
|
||||
.get("remember")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
valid_json: true,
|
||||
..Reply::default()
|
||||
};
|
||||
for action in map
|
||||
.get("actions")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let name = action
|
||||
.get("skill")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let Some(skill) = Skill::from_name(name) else {
|
||||
reply.rejected.push(if name.is_empty() {
|
||||
action.to_string()
|
||||
} else {
|
||||
name.to_owned()
|
||||
});
|
||||
continue;
|
||||
};
|
||||
if reply.actions.len() == MAX_ACTIONS {
|
||||
reply
|
||||
.rejected
|
||||
.push(format!("{name} (more than {MAX_ACTIONS} actions)"));
|
||||
continue;
|
||||
}
|
||||
let text = |key| {
|
||||
action
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.trim().trim_start_matches("minecraft:").to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
reply.actions.push(Action {
|
||||
skill,
|
||||
item: text("item"),
|
||||
count: action
|
||||
.get("count")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n.clamp(1, u64::from(MAX_COUNT)) as u32),
|
||||
target: text("target"),
|
||||
});
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
/// Drops a leading `<think>…</think>` block (a model that thinks despite being asked not to).
|
||||
fn strip_thinking(text: &str) -> &str {
|
||||
let trimmed = text.trim_start();
|
||||
if let Some(rest) = trimmed.strip_prefix("<think>") {
|
||||
return rest.split_once("</think>").map_or("", |(_, after)| after);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Pulls the top-level `say` string out of a JSON object while it streams in, unescaping as it goes.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SayStream {
|
||||
started: bool,
|
||||
pending: String,
|
||||
depth: u32,
|
||||
in_string: bool,
|
||||
escape: Option<String>,
|
||||
high_surrogate: Option<u32>,
|
||||
/// At depth 1, the next string is a key.
|
||||
key_next: bool,
|
||||
capturing_key: bool,
|
||||
key: String,
|
||||
last_key: String,
|
||||
in_say: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl SayStream {
|
||||
pub fn new() -> SayStream {
|
||||
SayStream::default()
|
||||
}
|
||||
|
||||
/// Feeds more of the reply; returns the `say` text it completes.
|
||||
pub fn push(&mut self, chunk: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut text = chunk;
|
||||
let joined;
|
||||
if !self.started {
|
||||
self.pending.push_str(chunk);
|
||||
let trimmed = self.pending.trim_start();
|
||||
if trimmed.is_empty() || "<think>".starts_with(trimmed) {
|
||||
return out;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("<think>") {
|
||||
match rest.split_once("</think>") {
|
||||
Some((_, after)) => joined = after.to_owned(),
|
||||
None => return out,
|
||||
}
|
||||
} else {
|
||||
joined = trimmed.to_owned();
|
||||
}
|
||||
self.started = true;
|
||||
self.pending.clear();
|
||||
text = &joined;
|
||||
}
|
||||
for c in text.chars() {
|
||||
self.feed(c, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the whole `say` string has been read.
|
||||
pub fn finished(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
fn feed(&mut self, c: char, out: &mut String) {
|
||||
if self.in_string {
|
||||
if let Some(esc) = &mut self.escape {
|
||||
esc.push(c);
|
||||
let esc_now = esc.clone();
|
||||
if let Some(decoded) = unescape(&esc_now, &mut self.high_surrogate) {
|
||||
self.escape = None;
|
||||
if let Some(ch) = decoded {
|
||||
self.string_char(ch, out);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match c {
|
||||
'\\' => self.escape = Some(String::new()),
|
||||
'"' => self.end_string(),
|
||||
_ => self.string_char(c, out),
|
||||
}
|
||||
return;
|
||||
}
|
||||
match c {
|
||||
'{' | '[' => {
|
||||
self.depth += 1;
|
||||
self.key_next = self.depth == 1 && c == '{';
|
||||
}
|
||||
'}' | ']' => self.depth = self.depth.saturating_sub(1),
|
||||
',' if self.depth == 1 => self.key_next = true,
|
||||
'"' => {
|
||||
self.in_string = true;
|
||||
if self.depth == 1 && self.key_next {
|
||||
self.capturing_key = true;
|
||||
self.key.clear();
|
||||
} else if self.depth == 1 && self.last_key == "say" && !self.done {
|
||||
self.in_say = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn string_char(&mut self, c: char, out: &mut String) {
|
||||
if self.capturing_key {
|
||||
self.key.push(c);
|
||||
} else if self.in_say {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
fn end_string(&mut self) {
|
||||
self.in_string = false;
|
||||
if self.capturing_key {
|
||||
self.capturing_key = false;
|
||||
self.key_next = false;
|
||||
self.last_key = std::mem::take(&mut self.key);
|
||||
} else if self.in_say {
|
||||
self.in_say = false;
|
||||
self.done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One escape sequence after the backslash: `None` while incomplete, `Some(None)` for half a surrogate pair.
|
||||
fn unescape(esc: &str, high: &mut Option<u32>) -> Option<Option<char>> {
|
||||
let first = esc.chars().next()?;
|
||||
if first != 'u' {
|
||||
return Some(Some(match first {
|
||||
'n' => '\n',
|
||||
't' => '\t',
|
||||
'r' => '\r',
|
||||
'b' => '\u{8}',
|
||||
'f' => '\u{c}',
|
||||
other => other,
|
||||
}));
|
||||
}
|
||||
if esc.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let code = u32::from_str_radix(&esc[1..5], 16).unwrap_or(0xfffd);
|
||||
if (0xd800..0xdc00).contains(&code) {
|
||||
*high = Some(code);
|
||||
return Some(None);
|
||||
}
|
||||
if (0xdc00..0xe000).contains(&code) {
|
||||
let pair = high
|
||||
.take()
|
||||
.map(|h| 0x10000 + ((h - 0xd800) << 10) + (code - 0xdc00));
|
||||
return Some(Some(pair.and_then(char::from_u32).unwrap_or('\u{fffd}')));
|
||||
}
|
||||
Some(Some(char::from_u32(code).unwrap_or('\u{fffd}')))
|
||||
}
|
||||
|
||||
/// Cuts streamed text into sentences for chat lines and the voice: after `.`, `!`, `?`, `…` and their
|
||||
/// CJK, Arabic and Devanagari forms (with closing quotes or brackets) followed by a space, and at line
|
||||
/// breaks. Decimals (`3.5`), single initials and common abbreviations (`Mr.`, `z.B.`, `etc.`) are not
|
||||
/// ends. A run longer than [`Sentences::MAX`] is cut at its last comma or space.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Sentences {
|
||||
buf: String,
|
||||
}
|
||||
|
||||
const ABBREVIATIONS: &[&str] = &[
|
||||
"mr", "mrs", "ms", "dr", "st", "vs", "etc", "e.g", "i.e", "approx", "m", "mme", "mlle", "env",
|
||||
"p.ex", "sr", "sra", "srta", "dra", "ud", "uds", "z.b", "bzw", "ca", "nr", "usw", "dh", "d.h",
|
||||
"u.a", "sto", "no", "n.º",
|
||||
];
|
||||
|
||||
impl Sentences {
|
||||
/// Longest piece without an end, in characters.
|
||||
pub const MAX: usize = 160;
|
||||
|
||||
pub fn new() -> Sentences {
|
||||
Sentences::default()
|
||||
}
|
||||
|
||||
/// Adds text; returns the sentences it completes, trimmed.
|
||||
pub fn push(&mut self, text: &str) -> Vec<String> {
|
||||
self.buf.push_str(text);
|
||||
let mut out = Vec::new();
|
||||
while let Some(end) = self.boundary() {
|
||||
let rest = self.buf.split_off(end);
|
||||
let sentence = std::mem::replace(&mut self.buf, rest);
|
||||
let sentence = sentence.trim();
|
||||
if !sentence.is_empty() {
|
||||
out.push(sentence.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text left at the end of the stream.
|
||||
pub fn finish(&mut self) -> Option<String> {
|
||||
let rest = std::mem::take(&mut self.buf);
|
||||
let rest = rest.trim();
|
||||
(!rest.is_empty()).then(|| rest.to_owned())
|
||||
}
|
||||
|
||||
/// Byte index just past the first complete sentence.
|
||||
fn boundary(&self) -> Option<usize> {
|
||||
let chars: Vec<(usize, char)> = self.buf.char_indices().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let (at, c) = chars[i];
|
||||
if c == '\n' {
|
||||
return Some(at + 1);
|
||||
}
|
||||
if is_terminal(c) {
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && (is_terminal(chars[j].1) || is_closer(chars[j].1)) {
|
||||
j += 1;
|
||||
}
|
||||
// Needs the next character to know the sentence ended (CJK ends need none).
|
||||
let ends_here = match chars.get(j) {
|
||||
None => false,
|
||||
Some((_, next)) => next.is_whitespace() || is_cjk_terminal(c),
|
||||
};
|
||||
if ends_here && !(c == '.' && j == i + 1 && self.not_an_end(&chars, i)) {
|
||||
return Some(chars.get(j).map_or(self.buf.len(), |(at, _)| *at));
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if chars.len() > Self::MAX {
|
||||
let limit = chars[Self::MAX].0;
|
||||
let cut = self.buf[..limit]
|
||||
.rfind([',', ';', ',', '、'])
|
||||
.map(|p| p + self.buf[p..].chars().next().map_or(1, char::len_utf8))
|
||||
.or_else(|| self.buf[..limit].rfind(' '))
|
||||
.unwrap_or(limit);
|
||||
return Some(cut.max(1));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A dot after an abbreviation, an initial or inside a number.
|
||||
fn not_an_end(&self, chars: &[(usize, char)], dot: usize) -> bool {
|
||||
let next_is_digit = chars.get(dot + 1).is_some_and(|(_, c)| c.is_ascii_digit());
|
||||
let prev_is_digit = dot > 0 && chars[dot - 1].1.is_ascii_digit();
|
||||
if next_is_digit && prev_is_digit {
|
||||
return true;
|
||||
}
|
||||
let start = chars[..dot]
|
||||
.iter()
|
||||
.rposition(|(_, c)| c.is_whitespace() || *c == '(' || *c == '"')
|
||||
.map_or(0, |p| p + 1);
|
||||
let word: String = chars[start..dot]
|
||||
.iter()
|
||||
.map(|(_, c)| c.to_lowercase().to_string())
|
||||
.collect();
|
||||
let single_initial = word.chars().count() == 1 && word.chars().all(char::is_alphabetic);
|
||||
single_initial && chars[start].1.is_uppercase() || ABBREVIATIONS.contains(&word.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_terminal(c: char) -> bool {
|
||||
matches!(c, '.' | '!' | '?' | '…' | '‼' | '⁉' | '؟' | '۔' | '।') || is_cjk_terminal(c)
|
||||
}
|
||||
|
||||
fn is_cjk_terminal(c: char) -> bool {
|
||||
matches!(c, '。' | '!' | '?')
|
||||
}
|
||||
|
||||
fn is_closer(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'"' | '\'' | '»' | '”' | '’' | ')' | ']' | '」' | '』' | ')'
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schemas_are_json_with_tone_then_say_first() {
|
||||
for schema in [SCHEMA, SCHEMA_STRICT] {
|
||||
let v: Value = serde_json::from_str(schema).unwrap();
|
||||
let names: Vec<&str> =
|
||||
v["properties"]["actions"]["items"]["properties"]["skill"]["enum"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().unwrap())
|
||||
.collect();
|
||||
let all: Vec<&str> = Skill::ALL.iter().map(|s| s.name()).collect();
|
||||
assert_eq!(names, all);
|
||||
let tones: Vec<&str> = v["properties"]["tone"]["enum"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(tones, Tone::ALL.map(Tone::name));
|
||||
let tone = schema.find("\"tone\"").unwrap();
|
||||
let say = schema.find("\"say\"").unwrap();
|
||||
let actions = schema.find("\"actions\"").unwrap();
|
||||
assert!(tone < say && say < actions);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_full_reply() {
|
||||
let reply = parse_reply(
|
||||
r#"{"tone":"sarcastic","say":"Wood. Again. Fine.","actions":[{"skill":"collect","item":"minecraft:oak_log","count":16},{"skill":"give","item":"oak_log","count":999}],"remember":" Steve hoards logs "}"#,
|
||||
);
|
||||
assert!(reply.valid_json);
|
||||
assert_eq!(reply.tone, Tone::Sarcastic);
|
||||
assert_eq!(reply.say, "Wood. Again. Fine.");
|
||||
assert_eq!(reply.actions[0].item.as_deref(), Some("oak_log"));
|
||||
assert_eq!(reply.actions[1].count, Some(MAX_COUNT));
|
||||
assert_eq!(reply.remember.as_deref(), Some("Steve hoards logs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_skills_and_extra_actions_are_rejected() {
|
||||
let reply = parse_reply(
|
||||
r#"{"tone":"normal","say":"ok","actions":[{"skill":"fly"},{"skill":"jump"},{"skill":"jump"},{"skill":"eat"},{"skill":"sleep"}]}"#,
|
||||
);
|
||||
assert_eq!(reply.actions.len(), 3);
|
||||
assert_eq!(reply.rejected, ["fly", "sleep (more than 3 actions)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_fences_thinking_and_plain_text() {
|
||||
let fenced = parse_reply("```json\n{\"say\":\"hi\",\"actions\":[]}\n```");
|
||||
assert_eq!(
|
||||
(fenced.say.as_str(), fenced.valid_json, fenced.tone),
|
||||
("hi", true, Tone::Normal)
|
||||
);
|
||||
let thought = parse_reply(
|
||||
"<think>they want {wood}</think>\n{\"say\":\"On it\",\"actions\":[{\"skill\":\"collect\",\"item\":\"oak_log\"}]}",
|
||||
);
|
||||
assert_eq!(thought.say, "On it");
|
||||
assert_eq!(thought.actions[0].skill, Skill::Collect);
|
||||
let plain = parse_reply("Sure, I'll follow you!");
|
||||
assert!(!plain.valid_json);
|
||||
assert_eq!(plain.say, "Sure, I'll follow you!");
|
||||
assert!(plain.actions.is_empty());
|
||||
}
|
||||
|
||||
fn stream_in_pieces(json: &str, size: usize) -> String {
|
||||
let mut stream = SayStream::new();
|
||||
let chars: Vec<char> = json.chars().collect();
|
||||
let mut out = String::new();
|
||||
for piece in chars.chunks(size) {
|
||||
out += &stream.push(&piece.iter().collect::<String>());
|
||||
}
|
||||
assert!(stream.finished(), "{json}");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn say_streams_out_of_partial_json() {
|
||||
let json = r#"{"tone":"shout","say":"Creeper! Run, \"now\"\n\u00e9\ud83d\ude00 \\o/","actions":[{"skill":"say","item":"say"}],"remember":"x"}"#;
|
||||
for size in [1, 2, 3, 7, 1000] {
|
||||
assert_eq!(
|
||||
stream_in_pieces(json, size),
|
||||
"Creeper! Run, \"now\"\né😀 \\o/",
|
||||
"pieces of {size}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn say_is_found_wherever_it_is_and_never_inside_actions() {
|
||||
let json = r#"{"actions":[{"skill":"collect","say":"nope"}],"say":"Got it."}"#;
|
||||
assert_eq!(stream_in_pieces(json, 4), "Got it.");
|
||||
let thinking = "<think>\n{\"say\":\"no\"}\n</think>\n\n{\"say\":\"Yes.\"}";
|
||||
assert_eq!(stream_in_pieces(thinking, 3), "Yes.");
|
||||
}
|
||||
|
||||
fn cut(text: &str) -> Vec<String> {
|
||||
let mut s = Sentences::new();
|
||||
let mut out = Vec::new();
|
||||
for c in text.chars() {
|
||||
out.extend(s.push(&c.to_string()));
|
||||
}
|
||||
out.extend(s.finish());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentences_in_five_languages() {
|
||||
assert_eq!(
|
||||
cut("Creeper, 2 o'clock! Back off. Now?"),
|
||||
["Creeper, 2 o'clock!", "Back off.", "Now?"]
|
||||
);
|
||||
assert_eq!(
|
||||
cut("Attention ! Un creeper à 2 heures. On recule ?"),
|
||||
["Attention !", "Un creeper à 2 heures.", "On recule ?"]
|
||||
);
|
||||
assert_eq!(
|
||||
cut("¡Cuidado! ¿Lo ves? Vamos."),
|
||||
["¡Cuidado!", "¿Lo ves?", "Vamos."]
|
||||
);
|
||||
assert_eq!(
|
||||
cut("Achtung, z.B. ein Creeper! Lauf."),
|
||||
["Achtung, z.B. ein Creeper!", "Lauf."]
|
||||
);
|
||||
assert_eq!(
|
||||
cut("Olha só... um creeper. Corre!"),
|
||||
["Olha só...", "um creeper.", "Corre!"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_initials_quotes_and_cjk() {
|
||||
assert_eq!(
|
||||
cut("Version 3.5 is out. Mr. Steve agrees."),
|
||||
["Version 3.5 is out.", "Mr. Steve agrees."]
|
||||
);
|
||||
assert_eq!(
|
||||
cut("He said \"run!\" and ran."),
|
||||
["He said \"run!\"", "and ran."]
|
||||
);
|
||||
assert_eq!(cut("J. R. is here. Go."), ["J. R. is here.", "Go."]);
|
||||
assert_eq!(cut("小心!苦力怕。"), ["小心!", "苦力怕。"]);
|
||||
assert_eq!(cut("Line one\nLine two"), ["Line one", "Line two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_is_emitted_as_soon_as_the_next_word_starts() {
|
||||
let mut s = Sentences::new();
|
||||
assert!(
|
||||
s.push("Creeper!").is_empty(),
|
||||
"could still be \"Creeper!!\""
|
||||
);
|
||||
assert_eq!(s.push(" R"), ["Creeper!"]);
|
||||
assert_eq!(s.finish().as_deref(), Some("R"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_runs_are_cut_at_a_comma_or_space() {
|
||||
let long = "word, ".repeat(40);
|
||||
let parts = cut(&long);
|
||||
assert!(parts.len() >= 2);
|
||||
assert!(parts.iter().all(|p| p.chars().count() <= Sentences::MAX));
|
||||
assert_eq!(parts.concat().replace(' ', ""), long.replace(' ', ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
//! The llama-server supervisor: starts the runtime on 127.0.0.1 with a random port and API key, at low
|
||||
//! priority, watches it load, and stops it with the world. If the game dies, the runtime dies too: a
|
||||
//! process group and a watchdog on Unix, a kill-on-close job object on Windows.
|
||||
//!
|
||||
//! Several runtimes can run at once (the brain, and the senses' embedding classifier on one CPU
|
||||
//! thread): each has its own port, key and log. [`Runtime::start`] returns at once and
|
||||
//! [`Runtime::poll`] never waits, so the game can call both from its tick.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::catalogue::Family;
|
||||
use crate::client::{Endpoint, Provider};
|
||||
use crate::hardware::{self, Hardware, Offload};
|
||||
|
||||
/// The design's context: 8K holds the system prompt, memory, state and six exchanges with room to spare.
|
||||
pub const CONTEXT: u32 = 8192;
|
||||
/// The model name the runtime answers to.
|
||||
pub const ALIAS: &str = "horizon-companion";
|
||||
/// How long a model may take to load before the runtime counts as failed (a 7 GB model from a hard disk).
|
||||
const LOAD_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// What a runtime serves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// `/v1/chat/completions` with the model's chat template, thinking off.
|
||||
Chat(Family),
|
||||
/// `/v1/embeddings` only (`--embeddings`), for the senses' classifier.
|
||||
Embeddings,
|
||||
}
|
||||
|
||||
/// How to start a runtime.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LaunchConfig {
|
||||
pub server: PathBuf,
|
||||
pub model: PathBuf,
|
||||
pub mode: Mode,
|
||||
pub threads: usize,
|
||||
pub offload: Offload,
|
||||
pub context: u32,
|
||||
/// stdout and stderr of the runtime; the previous run's log is kept beside it as `.old`.
|
||||
pub log: PathBuf,
|
||||
/// Advanced: appended as they are.
|
||||
pub extra_args: Vec<String>,
|
||||
}
|
||||
|
||||
impl LaunchConfig {
|
||||
/// The brain, by the design's rules for this PC: threads from the physical cores, GPU offload from
|
||||
/// the devices.
|
||||
pub fn for_hardware(
|
||||
server: &Path,
|
||||
model: &Path,
|
||||
family: Family,
|
||||
hw: &Hardware,
|
||||
log: &Path,
|
||||
) -> LaunchConfig {
|
||||
let model_bytes = fs::metadata(model).map_or(0, |m| m.len());
|
||||
LaunchConfig {
|
||||
server: server.to_owned(),
|
||||
model: model.to_owned(),
|
||||
mode: Mode::Chat(family),
|
||||
threads: hardware::runtime_threads(hw.physical_cores),
|
||||
offload: hardware::plan_offload(hw, model_bytes, extra_vram_mb(model_bytes)),
|
||||
context: CONTEXT,
|
||||
log: log.to_owned(),
|
||||
extra_args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The brain as [`crate::plan::plan`] placed it: all of it on the picked GPU, or the processor
|
||||
/// with the plan's thread count.
|
||||
pub fn for_pick(
|
||||
server: &Path,
|
||||
model: &Path,
|
||||
family: Family,
|
||||
pick: &crate::plan::Pick,
|
||||
log: &Path,
|
||||
) -> LaunchConfig {
|
||||
let (threads, offload) = match &pick.device {
|
||||
// On a GPU the CPU threads only feed it: two are plenty.
|
||||
crate::plan::Device::Gpu { id, .. } => (2, Offload::All { device: id.clone() }),
|
||||
crate::plan::Device::Cpu { threads } => (*threads, Offload::None),
|
||||
};
|
||||
LaunchConfig {
|
||||
server: server.to_owned(),
|
||||
model: model.to_owned(),
|
||||
mode: Mode::Chat(family),
|
||||
threads,
|
||||
offload,
|
||||
context: CONTEXT,
|
||||
log: log.to_owned(),
|
||||
extra_args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An embedding model on `threads` CPU threads, leaving the GPU to the game and the brain.
|
||||
pub fn embeddings(
|
||||
server: &Path,
|
||||
model: &Path,
|
||||
threads: usize,
|
||||
context: u32,
|
||||
log: &Path,
|
||||
) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
server: server.to_owned(),
|
||||
model: model.to_owned(),
|
||||
mode: Mode::Embeddings,
|
||||
threads,
|
||||
offload: Offload::None,
|
||||
context,
|
||||
log: log.to_owned(),
|
||||
extra_args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command line, without the program.
|
||||
pub fn args(&self, port: u16, api_key: &str) -> Vec<String> {
|
||||
let mut args: Vec<String> = [
|
||||
"--model",
|
||||
&self.model.to_string_lossy(),
|
||||
"--alias",
|
||||
ALIAS,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
"--api-key",
|
||||
api_key,
|
||||
"--ctx-size",
|
||||
&self.context.to_string(),
|
||||
"--parallel",
|
||||
"1",
|
||||
"--threads",
|
||||
&self.threads.to_string(),
|
||||
"--no-webui",
|
||||
"--offline",
|
||||
"--log-timestamps",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
match self.mode {
|
||||
Mode::Chat(family) => {
|
||||
args.extend(
|
||||
[
|
||||
// Prompt-cache reuse: the system prompt is a fixed prefix, so later turns only
|
||||
// process what changed.
|
||||
"--cache-prompt",
|
||||
"--cache-reuse",
|
||||
"256",
|
||||
// No extra copies of past prompts in RAM (up to 8 GiB by default): the one
|
||||
// conversation lives in the slot and its checkpoints. Measured on Qwen3.5 4B,
|
||||
// a 20-turn session peaked at 3.9 GB instead of 6.8 GB with the same cache hits.
|
||||
// The default 32 checkpoints stay: with 12 or fewer the one at the end of the
|
||||
// system prompt was lost and every fourth turn processed it again.
|
||||
"--cache-ram",
|
||||
"0",
|
||||
"--jinja",
|
||||
// Thinking off: replies start at once (Qwen3.5 thinks by default).
|
||||
"--reasoning",
|
||||
"off",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
match family {
|
||||
Family::Qwen35 => args.extend([
|
||||
"--chat-template-kwargs".into(),
|
||||
r#"{"enable_thinking":false}"#.into(),
|
||||
]),
|
||||
// Gemma's sliding-window layers keep only the last window by default, so after
|
||||
// the conversation's start changes the runtime falls back on checkpoints: 32 of
|
||||
// them took a session from 7.5 to 12.4 GB, and turns that dropped old exchanges
|
||||
// reprocessed 600 tokens. The full cache costs 2 GB once and is reused by shifting
|
||||
// (measured: 9.6 GB flat, 170 tokens on those turns).
|
||||
Family::Gemma4 => args.push("--swa-full".into()),
|
||||
Family::None => {}
|
||||
}
|
||||
}
|
||||
Mode::Embeddings => args.push("--embeddings".into()),
|
||||
}
|
||||
match &self.offload {
|
||||
Offload::All { device } => {
|
||||
args.extend([
|
||||
"--device".into(),
|
||||
device.clone(),
|
||||
"--gpu-layers".into(),
|
||||
"all".into(),
|
||||
]);
|
||||
}
|
||||
Offload::Fit { device } => args.extend([
|
||||
"--device".into(),
|
||||
device.clone(),
|
||||
"--fit".into(),
|
||||
"on".into(),
|
||||
"--fit-target".into(),
|
||||
hardware::GAME_VRAM_MB.to_string(),
|
||||
]),
|
||||
Offload::None => {
|
||||
args.extend([
|
||||
"--device".into(),
|
||||
"none".into(),
|
||||
"--gpu-layers".into(),
|
||||
"0".into(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
args.extend(self.extra_args.iter().cloned());
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
/// The KV cache and compute buffers at an 8K context on top of the weights, by model size (measured on
|
||||
/// Metal with b11756; see docs/companions.md).
|
||||
pub fn extra_vram_mb(model_bytes: u64) -> u64 {
|
||||
if model_bytes < 2 << 30 { 512 } else { 1024 }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum State {
|
||||
Loading,
|
||||
Ready {
|
||||
load_time: Duration,
|
||||
},
|
||||
/// It exited or never answered; the text has the reason and the end of its log.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub struct Runtime {
|
||||
child: Child,
|
||||
port: u16,
|
||||
api_key: String,
|
||||
log: PathBuf,
|
||||
state: State,
|
||||
health: Receiver<Duration>,
|
||||
stop_health: Arc<AtomicBool>,
|
||||
started: Instant,
|
||||
guard: Guard,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// Starts llama-server and returns while it loads.
|
||||
pub fn start(config: &LaunchConfig) -> io::Result<Runtime> {
|
||||
let port = free_port()?;
|
||||
let api_key = random_key()?;
|
||||
if let Some(dir) = config.log.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
if config.log.exists() {
|
||||
let _ = fs::rename(&config.log, config.log.with_extension("old.log"));
|
||||
}
|
||||
let log = fs::File::create(&config.log)?;
|
||||
let mut command = Command::new(&config.server);
|
||||
command
|
||||
.args(config.args(port, &api_key))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(log.try_clone()?)
|
||||
.stderr(log);
|
||||
if let Some(dir) = config.server.parent() {
|
||||
command.current_dir(dir);
|
||||
}
|
||||
let (child, guard) = spawn_guarded(&mut command)?;
|
||||
let (send, health) = mpsc::channel();
|
||||
let stop_health = Arc::new(AtomicBool::new(false));
|
||||
let stop = stop_health.clone();
|
||||
let (url, key) = (format!("http://127.0.0.1:{port}/health"), api_key.clone());
|
||||
let started = Instant::now();
|
||||
std::thread::Builder::new()
|
||||
.name("companion-runtime-health".into())
|
||||
.spawn(move || {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_millis(500))
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build();
|
||||
while !stop.load(Ordering::Relaxed) && started.elapsed() < LOAD_TIMEOUT {
|
||||
let ok = agent
|
||||
.get(&url)
|
||||
.set("Authorization", &format!("Bearer {key}"))
|
||||
.call()
|
||||
.is_ok_and(|r| r.status() == 200);
|
||||
if ok {
|
||||
let _ = send.send(started.elapsed());
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
})?;
|
||||
Ok(Runtime {
|
||||
child,
|
||||
port,
|
||||
api_key,
|
||||
log: config.log.clone(),
|
||||
state: State::Loading,
|
||||
health,
|
||||
stop_health,
|
||||
started,
|
||||
guard,
|
||||
})
|
||||
}
|
||||
|
||||
/// How it stands, without waiting.
|
||||
pub fn poll(&mut self) -> &State {
|
||||
if matches!(self.state, State::Failed(_)) {
|
||||
return &self.state;
|
||||
}
|
||||
if let Ok(Some(status)) = self.child.try_wait() {
|
||||
self.stop_health.store(true, Ordering::Relaxed);
|
||||
self.state = State::Failed(format!(
|
||||
"The runtime stopped ({status}).\n{}",
|
||||
log_tail(&self.log, 12)
|
||||
));
|
||||
return &self.state;
|
||||
}
|
||||
if let Ok(load_time) = self.health.try_recv() {
|
||||
self.state = State::Ready { load_time };
|
||||
} else if self.state == State::Loading && self.started.elapsed() > LOAD_TIMEOUT {
|
||||
self.state = State::Failed(format!(
|
||||
"The model didn't load in {} s.\n{}",
|
||||
LOAD_TIMEOUT.as_secs(),
|
||||
log_tail(&self.log, 12)
|
||||
));
|
||||
}
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Waits until it is ready (tools and tests; the game polls).
|
||||
pub fn wait_ready(&mut self, timeout: Duration) -> Result<Duration, String> {
|
||||
let until = Instant::now() + timeout;
|
||||
loop {
|
||||
match self.poll().clone() {
|
||||
State::Ready { load_time } => return Ok(load_time),
|
||||
State::Failed(e) => return Err(e),
|
||||
State::Loading if Instant::now() > until => {
|
||||
return Err(format!("still loading after {} s", timeout.as_secs()));
|
||||
}
|
||||
State::Loading => std::thread::sleep(Duration::from_millis(50)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the chat client reaches it.
|
||||
pub fn endpoint(&self) -> Endpoint {
|
||||
Endpoint {
|
||||
provider: Provider::Local,
|
||||
base_url: format!("http://127.0.0.1:{}/v1", self.port),
|
||||
api_key: Some(self.api_key.clone()),
|
||||
model: ALIAS.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pid(&self) -> u32 {
|
||||
self.child.id()
|
||||
}
|
||||
|
||||
pub fn log(&self) -> &Path {
|
||||
&self.log
|
||||
}
|
||||
|
||||
/// Resident memory of the runtime process, in MB (the live readout of Advanced).
|
||||
pub fn memory_mb(&self) -> Option<u64> {
|
||||
process_rss_mb(self.child.id())
|
||||
}
|
||||
|
||||
/// Stops it: politely first, then for sure. Also done on drop.
|
||||
pub fn stop(&mut self) {
|
||||
self.stop_health.store(true, Ordering::Relaxed);
|
||||
if self.child.try_wait().ok().flatten().is_none() {
|
||||
self.guard.terminate(&mut self.child);
|
||||
}
|
||||
let _ = self.child.wait();
|
||||
self.guard.release();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Runtime {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn free_port() -> io::Result<u16> {
|
||||
// The OS picks a free port; it stays free for the moment the runtime takes to bind it.
|
||||
Ok(std::net::TcpListener::bind("127.0.0.1:0")?
|
||||
.local_addr()?
|
||||
.port())
|
||||
}
|
||||
|
||||
fn random_key() -> io::Result<String> {
|
||||
let mut bytes = [0u8; 24];
|
||||
getrandom::fill(&mut bytes).map_err(|e| io::Error::other(e.to_string()))?;
|
||||
Ok(crate::download::hex(&bytes))
|
||||
}
|
||||
|
||||
/// The last `lines` lines of a log, for error messages.
|
||||
pub fn log_tail(path: &Path, lines: usize) -> String {
|
||||
let Ok(mut file) = fs::File::open(path) else {
|
||||
return String::new();
|
||||
};
|
||||
let len = file.metadata().map_or(0, |m| m.len());
|
||||
let _ = file.seek(SeekFrom::Start(len.saturating_sub(16 << 10)));
|
||||
let mut text = String::new();
|
||||
let _ = file.take(16 << 10).read_to_string(&mut text);
|
||||
let all: Vec<&str> = text.lines().collect();
|
||||
all[all.len().saturating_sub(lines)..].join("\n")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn process_rss_mb(pid: u32) -> Option<u64> {
|
||||
let out = Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &pid.to_string()])
|
||||
.output()
|
||||
.ok()?;
|
||||
let kb: u64 = String::from_utf8(out.stdout).ok()?.trim().parse().ok()?;
|
||||
Some(kb / 1024)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn process_rss_mb(pid: u32) -> Option<u64> {
|
||||
let out = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
&format!("(Get-Process -Id {pid}).WorkingSet64"),
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
let bytes: u64 = String::from_utf8(out.stdout).ok()?.trim().parse().ok()?;
|
||||
Some(bytes >> 20)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn process_rss_mb(_pid: u32) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unix: the runtime leads its own process group at nice 10, and a forked watchdog that holds nothing
|
||||
/// but the read end of a pipe kills that group when the pipe closes, which happens when the game exits
|
||||
/// for any reason, a crash or `kill -9` included. Linux's `PR_SET_PDEATHSIG` is not used: it fires when
|
||||
/// the spawning *thread* exits, and macOS has nothing like it.
|
||||
#[cfg(unix)]
|
||||
struct Guard {
|
||||
watchdog: Option<(libc::pid_t, std::os::fd::OwnedFd)>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_guarded(command: &mut Command) -> io::Result<(Child, Guard)> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
// SAFETY: setpriority is a plain system call, allowed between fork and exec.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
libc::setpriority(libc::PRIO_PROCESS, 0, 10);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let child = command.spawn()?;
|
||||
// Without a watchdog (fork failed) the runtime still stops with the world, just not with a crash.
|
||||
let watchdog = watchdog(child.id() as libc::pid_t).ok();
|
||||
Ok((child, Guard { watchdog }))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn watchdog(group: libc::pid_t) -> io::Result<(libc::pid_t, std::os::fd::OwnedFd)> {
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
let mut fds = [0; 2];
|
||||
// SAFETY: `fds` has room for the two descriptors.
|
||||
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let (read, write) = (fds[0], fds[1]);
|
||||
for fd in fds {
|
||||
// SAFETY: both are descriptors this process just opened. Close-on-exec keeps them out of every
|
||||
// other program the game starts, so only the game holds the write end.
|
||||
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) };
|
||||
}
|
||||
let mut limit = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: `limit` is a writable rlimit.
|
||||
unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) };
|
||||
let max_fd = (limit.rlim_cur as i64).clamp(256, 65536) as libc::c_int;
|
||||
// SAFETY: after fork the child only makes async-signal-safe calls (close, read, kill, _exit): it
|
||||
// never allocates, locks or returns into Rust code.
|
||||
let pid = unsafe { libc::fork() };
|
||||
if pid < 0 {
|
||||
// SAFETY: closing the descriptors opened above.
|
||||
unsafe {
|
||||
libc::close(read);
|
||||
libc::close(write);
|
||||
}
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pid == 0 {
|
||||
// SAFETY: see above; everything but the pipe's read end and the standard streams is closed, so
|
||||
// the watchdog keeps none of the game's files or sockets open.
|
||||
unsafe {
|
||||
for fd in 3..max_fd {
|
||||
if fd != read {
|
||||
libc::close(fd);
|
||||
}
|
||||
}
|
||||
let mut byte = 0u8;
|
||||
loop {
|
||||
let n = libc::read(read, (&mut byte as *mut u8).cast(), 1);
|
||||
if n < 0 && *errno() == libc::EINTR {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
libc::kill(-group, libc::SIGKILL);
|
||||
libc::_exit(0);
|
||||
}
|
||||
}
|
||||
// SAFETY: the parent closes its read end and owns the write end from here.
|
||||
unsafe { libc::close(read) };
|
||||
Ok((pid, unsafe { OwnedFd::from_raw_fd(write) }))
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
unix,
|
||||
any(target_os = "macos", target_os = "ios", target_os = "freebsd")
|
||||
))]
|
||||
unsafe fn errno() -> *mut libc::c_int {
|
||||
unsafe { libc::__error() }
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))
|
||||
))]
|
||||
unsafe fn errno() -> *mut libc::c_int {
|
||||
unsafe { libc::__errno_location() }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Guard {
|
||||
fn terminate(&mut self, child: &mut Child) {
|
||||
let group = child.id() as libc::pid_t;
|
||||
// SAFETY: signals the runtime's own process group.
|
||||
unsafe { libc::kill(-group, libc::SIGTERM) };
|
||||
let until = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < until {
|
||||
if child.try_wait().ok().flatten().is_some() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
// SAFETY: as above.
|
||||
unsafe { libc::kill(-group, libc::SIGKILL) };
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
if let Some((pid, write)) = self.watchdog.take() {
|
||||
drop(write);
|
||||
let mut status = 0;
|
||||
// SAFETY: reaps the watchdog this process forked; it exits as soon as the pipe closes.
|
||||
unsafe { libc::waitpid(pid, &mut status, 0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: a job object that kills the runtime when its last handle, the game's, closes; the runtime
|
||||
/// starts at BELOW_NORMAL priority without a console window.
|
||||
#[cfg(windows)]
|
||||
struct Guard {
|
||||
job: windows_sys::Win32::Foundation::HANDLE,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn spawn_guarded(command: &mut Command) -> io::Result<(Child, Guard)> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{BELOW_NORMAL_PRIORITY_CLASS, CREATE_NO_WINDOW};
|
||||
command.creation_flags(BELOW_NORMAL_PRIORITY_CLASS | CREATE_NO_WINDOW);
|
||||
let child = command.spawn()?;
|
||||
// SAFETY: plain Win32 calls on a job this function creates and the child it just spawned.
|
||||
let job = unsafe {
|
||||
let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
|
||||
if !job.is_null() {
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
);
|
||||
AssignProcessToJobObject(job, child.as_raw_handle() as _);
|
||||
}
|
||||
job
|
||||
};
|
||||
Ok((child, Guard { job }))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Guard {
|
||||
fn terminate(&mut self, child: &mut Child) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
if !self.job.is_null() {
|
||||
// SAFETY: the job handle this guard owns, closed once.
|
||||
unsafe { windows_sys::Win32::Foundation::CloseHandle(self.job) };
|
||||
self.job = std::ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
struct Guard;
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn spawn_guarded(command: &mut Command) -> io::Result<(Child, Guard)> {
|
||||
Ok((command.spawn()?, Guard))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
impl Guard {
|
||||
fn terminate(&mut self, child: &mut Child) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
fn release(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hardware::{Gpu, GpuKind};
|
||||
|
||||
fn hw(apple: bool, gpus: Vec<Gpu>, cores: usize) -> Hardware {
|
||||
Hardware {
|
||||
ram_mb: 16384,
|
||||
physical_cores: cores,
|
||||
logical_cores: cores * 2,
|
||||
apple_silicon: apple,
|
||||
gpus,
|
||||
}
|
||||
}
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter()
|
||||
.position(|a| a == name)
|
||||
.map(|i| args[i + 1].as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_follows_the_design() {
|
||||
let metal = Gpu {
|
||||
id: "MTL0".into(),
|
||||
name: "Apple M4 Pro".into(),
|
||||
total_mb: 53084,
|
||||
free_mb: 53083,
|
||||
kind: GpuKind::Integrated,
|
||||
};
|
||||
let mut config = LaunchConfig::for_hardware(
|
||||
Path::new("/rt/llama-server"),
|
||||
Path::new("/m/model.gguf"),
|
||||
Family::Qwen35,
|
||||
&hw(true, vec![metal], 14),
|
||||
Path::new("/logs/runtime.log"),
|
||||
);
|
||||
let args = config.args(40123, "k3y");
|
||||
assert_eq!(flag(&args, "--host"), Some("127.0.0.1"));
|
||||
assert_eq!(flag(&args, "--port"), Some("40123"));
|
||||
assert_eq!(flag(&args, "--api-key"), Some("k3y"));
|
||||
assert_eq!(flag(&args, "--ctx-size"), Some("8192"));
|
||||
assert_eq!(flag(&args, "--parallel"), Some("1"));
|
||||
assert_eq!(flag(&args, "--threads"), Some("13"));
|
||||
assert_eq!(flag(&args, "--gpu-layers"), Some("all"));
|
||||
assert_eq!(flag(&args, "--device"), Some("MTL0"));
|
||||
assert_eq!(flag(&args, "--reasoning"), Some("off"));
|
||||
assert_eq!(
|
||||
flag(&args, "--chat-template-kwargs"),
|
||||
Some(r#"{"enable_thinking":false}"#)
|
||||
);
|
||||
assert!(args.iter().any(|a| a == "--no-webui"));
|
||||
assert!(args.windows(2).any(|w| w == ["--cache-ram", "0"]));
|
||||
|
||||
config.offload = Offload::None;
|
||||
config.mode = Mode::Chat(Family::Gemma4);
|
||||
let args = config.args(1, "k");
|
||||
assert_eq!(flag(&args, "--device"), Some("none"));
|
||||
assert_eq!(flag(&args, "--gpu-layers"), Some("0"));
|
||||
assert_eq!(flag(&args, "--chat-template-kwargs"), None);
|
||||
assert!(args.iter().any(|a| a == "--swa-full"));
|
||||
|
||||
let embed = LaunchConfig::embeddings(
|
||||
Path::new("s"),
|
||||
Path::new("e5.gguf"),
|
||||
1,
|
||||
512,
|
||||
Path::new("e.log"),
|
||||
);
|
||||
let args = embed.args(2, "k");
|
||||
assert!(args.iter().any(|a| a == "--embeddings"));
|
||||
assert_eq!(flag(&args, "--threads"), Some("1"));
|
||||
assert_eq!(flag(&args, "--device"), Some("none"));
|
||||
assert_eq!(flag(&args, "--reasoning"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_gpus_run_on_the_cpu_with_threads_to_spare() {
|
||||
let hd620 = Gpu {
|
||||
id: "Vulkan0".into(),
|
||||
name: "Intel(R) HD Graphics 620 (KBL GT2)".into(),
|
||||
total_mb: 23988,
|
||||
free_mb: 21589,
|
||||
kind: GpuKind::Integrated,
|
||||
};
|
||||
let config = LaunchConfig::for_hardware(
|
||||
Path::new("llama-server"),
|
||||
Path::new("m.gguf"),
|
||||
Family::Qwen35,
|
||||
&hw(false, vec![hd620], 2),
|
||||
Path::new("r.log"),
|
||||
);
|
||||
assert_eq!(config.offload, Offload::None);
|
||||
assert_eq!(config.threads, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_ports_differ_every_time() {
|
||||
let (a, b) = (random_key().unwrap(), random_key().unwrap());
|
||||
assert_eq!(a.len(), 48);
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(free_port().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fake_server(dir: &Path) -> LaunchConfig {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let server = dir.join("llama-server");
|
||||
fs::write(&server, "#!/bin/sh\necho \"started $*\"\nexec sleep 30\n").unwrap();
|
||||
fs::set_permissions(&server, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
LaunchConfig {
|
||||
server,
|
||||
model: dir.join("m.gguf"),
|
||||
mode: Mode::Chat(Family::Qwen35),
|
||||
threads: 1,
|
||||
offload: Offload::None,
|
||||
context: CONTEXT,
|
||||
log: dir.join("logs/runtime.log"),
|
||||
extra_args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn wait_for(what: &str, mut ok: impl FnMut() -> bool) {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
while !ok() {
|
||||
assert!(Instant::now() < until, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn alive(pid: libc::pid_t) -> bool {
|
||||
// SAFETY: signal 0 only probes whether the process exists.
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
/// A fake runtime (a shell script that sleeps); start, poll, priority and stop are the real ones.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_runtime_that_never_answers_is_stopped_cleanly() {
|
||||
let dir = crate::download::tests::scratch("runtime");
|
||||
let config = fake_server(&dir);
|
||||
let mut runtime = Runtime::start(&config).unwrap();
|
||||
assert_eq!(*runtime.poll(), State::Loading);
|
||||
let pid = runtime.pid() as libc::pid_t;
|
||||
// SAFETY: reads the scheduling priority of this process and of the child just started.
|
||||
let (ours, nice) = unsafe {
|
||||
(
|
||||
libc::getpriority(libc::PRIO_PROCESS, 0),
|
||||
libc::getpriority(libc::PRIO_PROCESS, pid as _),
|
||||
)
|
||||
};
|
||||
// 10, or what it inherited when the game itself runs nicer (raising priority needs root).
|
||||
assert_eq!(nice, ours.max(10));
|
||||
wait_for("the log", || {
|
||||
fs::read_to_string(&config.log)
|
||||
.unwrap_or_default()
|
||||
.contains("started --model")
|
||||
});
|
||||
let started = Instant::now();
|
||||
runtime.stop();
|
||||
assert!(started.elapsed() < Duration::from_secs(4));
|
||||
assert!(!alive(pid), "the runtime is gone");
|
||||
}
|
||||
|
||||
/// The game's side of the next test: starts a runtime, prints its pid and dies without cleaning up.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[ignore = "run by a_runtime_dies_with_the_game"]
|
||||
fn game_that_crashes() {
|
||||
let Ok(dir) = std::env::var("HORIZON_COMPANION_CRASH_DIR") else {
|
||||
return;
|
||||
};
|
||||
let config = fake_server(Path::new(&dir));
|
||||
let runtime = Runtime::start(&config).unwrap();
|
||||
println!("runtime-pid={}", runtime.pid());
|
||||
wait_for("the log", || {
|
||||
fs::read_to_string(&config.log)
|
||||
.unwrap_or_default()
|
||||
.contains("started")
|
||||
});
|
||||
std::mem::forget(runtime);
|
||||
// SAFETY: ends the process at once, like a crash: no destructor runs.
|
||||
unsafe { libc::_exit(3) };
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_runtime_dies_with_the_game() {
|
||||
let dir = crate::download::tests::scratch("runtime-crash");
|
||||
let out = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"runtime::tests::game_that_crashes",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("HORIZON_COMPANION_CRASH_DIR", &dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let pid: libc::pid_t = stdout
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("runtime-pid=")?.parse().ok())
|
||||
.unwrap_or_else(|| panic!("no pid in {stdout}"));
|
||||
assert_eq!(out.status.code(), Some(3));
|
||||
// The runtime was re-parented when the game died; the watchdog kills it within moments.
|
||||
wait_for("the orphaned runtime to die", || !alive(pid));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_runtime_that_exits_reports_its_log() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = crate::download::tests::scratch("runtime-exit");
|
||||
let server = dir.join("llama-server");
|
||||
fs::write(
|
||||
&server,
|
||||
"#!/bin/sh\necho 'error: failed to load model' >&2\nexit 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&server, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let config = LaunchConfig {
|
||||
server,
|
||||
model: dir.join("m.gguf"),
|
||||
mode: Mode::Chat(Family::Qwen35),
|
||||
threads: 1,
|
||||
offload: Offload::None,
|
||||
context: CONTEXT,
|
||||
log: dir.join("runtime.log"),
|
||||
extra_args: Vec::new(),
|
||||
};
|
||||
let mut runtime = Runtime::start(&config).unwrap();
|
||||
let err = runtime.wait_ready(Duration::from_secs(5)).unwrap_err();
|
||||
assert!(err.contains("failed to load model"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
//! The companion's settings, shared by the launcher and the game (docs/companions.md, "Fitting the
|
||||
//! player's PC"): `no_ai`, the simple screen's state, Advanced's choices per part and the persona.
|
||||
//! Stored as `<data>/companion/settings.json`; a cloud key lives in the OS keychain ([`keychain`]),
|
||||
//! never in the file.
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::client::{Endpoint, Provider};
|
||||
use crate::plan::{Part, PartOption, Plan};
|
||||
use crate::prompt::Persona;
|
||||
|
||||
/// Bumped when a field changes meaning; older files are read with defaults for what they lack.
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CompanionSettings {
|
||||
pub version: u32,
|
||||
/// Hides every AI feature, in the launcher and in the game. Also kept in the game's extras file
|
||||
/// ([`sync_extras`]).
|
||||
pub no_ai: bool,
|
||||
/// The player pressed "Set up companion": the game offers companions once the parts are on disk.
|
||||
pub set_up: bool,
|
||||
/// Push-to-talk and the spoken voice. Off by default: the microphone is opened only after this.
|
||||
pub voice: bool,
|
||||
pub persona: Persona,
|
||||
pub brain: BrainChoice,
|
||||
pub classifier: PartChoice,
|
||||
pub speech_to_text: PartChoice,
|
||||
pub text_to_speech: PartChoice,
|
||||
pub tuning: Tuning,
|
||||
}
|
||||
|
||||
impl Default for CompanionSettings {
|
||||
fn default() -> CompanionSettings {
|
||||
CompanionSettings {
|
||||
version: VERSION,
|
||||
no_ai: false,
|
||||
set_up: false,
|
||||
voice: false,
|
||||
persona: Persona::default(),
|
||||
brain: BrainChoice::Auto,
|
||||
classifier: PartChoice::Auto,
|
||||
speech_to_text: PartChoice::Auto,
|
||||
text_to_speech: PartChoice::Auto,
|
||||
tuning: Tuning::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advanced's choice for a part.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "use", rename_all = "snake_case")]
|
||||
pub enum PartChoice {
|
||||
/// Whatever the plan picks.
|
||||
#[default]
|
||||
Auto,
|
||||
Off,
|
||||
/// A catalogue option by its id (`qwen3.5-9b-q4_k_m@gpu`).
|
||||
Option {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Advanced's choice for the brain: a catalogue option, the player's own GGUF, or a server.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "use", rename_all = "snake_case")]
|
||||
pub enum BrainChoice {
|
||||
#[default]
|
||||
Auto,
|
||||
Option {
|
||||
id: String,
|
||||
},
|
||||
/// A GGUF the player picked, run by the bundled runtime.
|
||||
Gguf {
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Ollama, LM Studio or a cloud API. A key, if any, is in the keychain under [`Self::key_account`].
|
||||
Server {
|
||||
provider: Provider,
|
||||
base_url: String,
|
||||
model: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BrainChoice {
|
||||
/// The keychain account of a server's key.
|
||||
pub fn key_account(&self) -> Option<String> {
|
||||
match self {
|
||||
BrainChoice::Server { base_url, .. } => Some(base_url.trim_end_matches('/').to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The endpoint of a server choice, its key read from the keychain.
|
||||
pub fn endpoint(&self) -> Option<Endpoint> {
|
||||
let BrainChoice::Server {
|
||||
provider,
|
||||
base_url,
|
||||
model,
|
||||
} = self
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let api_key = self
|
||||
.key_account()
|
||||
.and_then(|account| keychain::get(&account).ok().flatten());
|
||||
Some(Endpoint {
|
||||
provider: *provider,
|
||||
base_url: base_url.clone(),
|
||||
api_key,
|
||||
model: model.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Advanced's runtime knobs; `None` is the automatic value.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Tuning {
|
||||
pub temperature: Option<f32>,
|
||||
/// Layers on the GPU: `Some(0)` keeps the brain on the processor.
|
||||
pub gpu_layers: Option<u32>,
|
||||
pub threads: Option<usize>,
|
||||
pub context: Option<u32>,
|
||||
}
|
||||
|
||||
impl CompanionSettings {
|
||||
pub fn path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("companion").join("settings.json")
|
||||
}
|
||||
|
||||
/// The saved settings; defaults when there are none. A file that can't be read is kept beside as
|
||||
/// `settings.json.bad` so a broken edit isn't silently lost, and defaults are used.
|
||||
pub fn load(data_dir: &Path) -> CompanionSettings {
|
||||
let path = CompanionSettings::path(data_dir);
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
return CompanionSettings::default();
|
||||
};
|
||||
match serde_json::from_str::<CompanionSettings>(&text) {
|
||||
Ok(mut settings) => {
|
||||
settings.version = VERSION;
|
||||
settings
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = fs::rename(&path, path.with_extension("json.bad"));
|
||||
CompanionSettings::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the file atomically (a temporary file renamed over it).
|
||||
pub fn save(&self, data_dir: &Path) -> io::Result<()> {
|
||||
let path = CompanionSettings::path(data_dir);
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
|
||||
fs::write(&tmp, text + "\n")?;
|
||||
fs::rename(&tmp, &path)
|
||||
}
|
||||
|
||||
pub fn choice(&self, part: Part) -> PartChoice {
|
||||
match part {
|
||||
Part::Brain => match &self.brain {
|
||||
BrainChoice::Auto => PartChoice::Auto,
|
||||
BrainChoice::Option { id } => PartChoice::Option { id: id.clone() },
|
||||
// Not a catalogue option: the plan has nothing to set up for it.
|
||||
BrainChoice::Gguf { .. } | BrainChoice::Server { .. } => PartChoice::Off,
|
||||
},
|
||||
Part::Classifier => self.classifier.clone(),
|
||||
Part::SpeechToText if !self.voice => PartChoice::Off,
|
||||
Part::TextToSpeech if !self.voice => PartChoice::Off,
|
||||
Part::SpeechToText => self.speech_to_text.clone(),
|
||||
Part::TextToSpeech => self.text_to_speech.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What gets set up for each part: the plan's pick unless Advanced says otherwise. An option id
|
||||
/// that is no longer in the catalogue falls back to the plan.
|
||||
pub fn selection<'a>(&self, plan: &'a Plan, options: &'a [PartOption]) -> Vec<&'a PartOption> {
|
||||
Part::ALL
|
||||
.into_iter()
|
||||
.filter_map(|part| match self.choice(part) {
|
||||
PartChoice::Off => None,
|
||||
PartChoice::Option { id } => options
|
||||
.iter()
|
||||
.find(|o| o.part == part && o.id == id)
|
||||
.or_else(|| plan.pick(part).map(|p| &p.option)),
|
||||
PartChoice::Auto => plan.pick(part).map(|p| &p.option),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's extras file in its home, and the key No AI is kept under there.
|
||||
pub const EXTRAS_FILE: &str = "horizon-extras.txt";
|
||||
pub const EXTRAS_KEY: &str = "noAi";
|
||||
|
||||
/// Writes `noAi:true|false` into the game's `horizon-extras.txt`, keeping every other line, so the
|
||||
/// game sees No AI even before it reads the companion's settings. The launcher calls it on every
|
||||
/// change and before each start, since the game rewrites that file with the keys it knows. (A
|
||||
/// `--no-ai` flag is not passed: today's game refuses options it doesn't know.)
|
||||
pub fn sync_extras(game_home: &Path, no_ai: bool) -> io::Result<()> {
|
||||
let path = game_home.join(EXTRAS_FILE);
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let line = format!("{EXTRAS_KEY}:{no_ai}");
|
||||
let mut lines: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
l.split_once(':')
|
||||
.is_none_or(|(k, _)| k.trim() != EXTRAS_KEY)
|
||||
})
|
||||
.collect();
|
||||
if !no_ai && lines.is_empty() && !path.exists() {
|
||||
// Nothing to say yet: the game's default is off.
|
||||
return Ok(());
|
||||
}
|
||||
lines.push(&line);
|
||||
fs::create_dir_all(game_home)?;
|
||||
fs::write(&path, lines.join("\n") + "\n")
|
||||
}
|
||||
|
||||
/// Secrets in the OS keychain: the macOS login keychain (`security`), the Secret Service on Linux
|
||||
/// (`secret-tool`, from libsecret) and the Windows Credential Manager. Secrets go to the tools on
|
||||
/// stdin, never on a command line other processes can read.
|
||||
pub mod keychain {
|
||||
use std::io::{self, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
pub const SERVICE: &str = "Horizon companion";
|
||||
|
||||
/// Stores (or replaces) the secret for `account`.
|
||||
pub fn set(account: &str, secret: &str) -> io::Result<()> {
|
||||
imp::set(account, secret)
|
||||
}
|
||||
|
||||
/// The secret for `account`, if there is one.
|
||||
pub fn get(account: &str) -> io::Result<Option<String>> {
|
||||
imp::get(account)
|
||||
}
|
||||
|
||||
pub fn delete(account: &str) -> io::Result<()> {
|
||||
imp::delete(account)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn run(program: &str, args: &[&str], stdin: Option<&str>) -> io::Result<(bool, String)> {
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
if let Some(input) = stdin {
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.expect("piped")
|
||||
.write_all(input.as_bytes())?;
|
||||
} else {
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
let out = child.wait_with_output()?;
|
||||
Ok((
|
||||
out.status.success(),
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `security -i` reads its commands from stdin; arguments are double-quoted there.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn security_quote(text: &str) -> String {
|
||||
format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::{SERVICE, run, security_quote};
|
||||
use std::io;
|
||||
|
||||
pub fn set(account: &str, secret: &str) -> io::Result<()> {
|
||||
let command = format!(
|
||||
"add-generic-password -U -s {} -a {} -w {}\n",
|
||||
security_quote(SERVICE),
|
||||
security_quote(account),
|
||||
security_quote(secret)
|
||||
);
|
||||
match run("security", &["-i"], Some(&command))? {
|
||||
(true, _) => Ok(()),
|
||||
_ => Err(io::Error::other("the keychain refused the key")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(account: &str) -> io::Result<Option<String>> {
|
||||
let (ok, out) = run(
|
||||
"security",
|
||||
&["find-generic-password", "-s", SERVICE, "-a", account, "-w"],
|
||||
None,
|
||||
)?;
|
||||
Ok(ok.then(|| out.trim_end_matches('\n').to_owned()))
|
||||
}
|
||||
|
||||
pub fn delete(account: &str) -> io::Result<()> {
|
||||
run(
|
||||
"security",
|
||||
&["delete-generic-password", "-s", SERVICE, "-a", account],
|
||||
None,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod imp {
|
||||
use super::{SERVICE, run};
|
||||
use std::io;
|
||||
|
||||
pub fn set(account: &str, secret: &str) -> io::Result<()> {
|
||||
match run(
|
||||
"secret-tool",
|
||||
&[
|
||||
"store", "--label", SERVICE, "service", SERVICE, "account", account,
|
||||
],
|
||||
Some(secret),
|
||||
) {
|
||||
Ok((true, _)) => Ok(()),
|
||||
Ok(_) => Err(io::Error::other("the keyring refused the key")),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Err(io::Error::other(
|
||||
"no keyring: install libsecret (secret-tool) to save keys",
|
||||
)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(account: &str) -> io::Result<Option<String>> {
|
||||
let (ok, out) = run(
|
||||
"secret-tool",
|
||||
&["lookup", "service", SERVICE, "account", account],
|
||||
None,
|
||||
)?;
|
||||
Ok((ok && !out.is_empty()).then_some(out))
|
||||
}
|
||||
|
||||
pub fn delete(account: &str) -> io::Result<()> {
|
||||
run(
|
||||
"secret-tool",
|
||||
&["clear", "service", SERVICE, "account", account],
|
||||
None,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::SERVICE;
|
||||
use std::io;
|
||||
use windows_sys::Win32::Foundation::{ERROR_NOT_FOUND, GetLastError};
|
||||
use windows_sys::Win32::Security::Credentials::{
|
||||
CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC, CREDENTIALW, CredDeleteW, CredFree,
|
||||
CredReadW, CredWriteW,
|
||||
};
|
||||
|
||||
fn target(account: &str) -> Vec<u16> {
|
||||
format!("{SERVICE}: {account}")
|
||||
.encode_utf16()
|
||||
.chain(Some(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn set(account: &str, secret: &str) -> io::Result<()> {
|
||||
let mut name = target(account);
|
||||
let mut blob = secret.as_bytes().to_vec();
|
||||
// SAFETY: every pointer is to a live buffer for the duration of the call.
|
||||
let ok = unsafe {
|
||||
let mut credential: CREDENTIALW = std::mem::zeroed();
|
||||
credential.Type = CRED_TYPE_GENERIC;
|
||||
credential.TargetName = name.as_mut_ptr();
|
||||
credential.CredentialBlobSize = blob.len() as u32;
|
||||
credential.CredentialBlob = blob.as_mut_ptr();
|
||||
credential.Persist = CRED_PERSIST_LOCAL_MACHINE;
|
||||
CredWriteW(&credential, 0)
|
||||
};
|
||||
if ok == 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(account: &str) -> io::Result<Option<String>> {
|
||||
let name = target(account);
|
||||
let mut found: *mut CREDENTIALW = std::ptr::null_mut();
|
||||
// SAFETY: CredReadW fills `found`, freed below with CredFree.
|
||||
unsafe {
|
||||
if CredReadW(name.as_ptr(), CRED_TYPE_GENERIC, 0, &mut found) == 0 {
|
||||
return if GetLastError() == ERROR_NOT_FOUND {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
};
|
||||
}
|
||||
let credential = &*found;
|
||||
let bytes = std::slice::from_raw_parts(
|
||||
credential.CredentialBlob,
|
||||
credential.CredentialBlobSize as usize,
|
||||
);
|
||||
let secret = String::from_utf8_lossy(bytes).into_owned();
|
||||
CredFree(found as *const _);
|
||||
Ok(Some(secret))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(account: &str) -> io::Result<()> {
|
||||
let name = target(account);
|
||||
// SAFETY: a valid, NUL-terminated name.
|
||||
unsafe { CredDeleteW(name.as_ptr(), CRED_TYPE_GENERIC, 0) };
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hardware::Hardware;
|
||||
use crate::plan::{self, brain_options};
|
||||
|
||||
#[test]
|
||||
fn settings_round_trip_and_survive_a_broken_file() {
|
||||
let dir = crate::download::tests::scratch("settings");
|
||||
assert_eq!(CompanionSettings::load(&dir), CompanionSettings::default());
|
||||
let mut settings = CompanionSettings {
|
||||
no_ai: true,
|
||||
voice: true,
|
||||
brain: BrainChoice::Server {
|
||||
provider: Provider::Ollama,
|
||||
base_url: "http://127.0.0.1:11434/v1".into(),
|
||||
model: "qwen3.5:4b".into(),
|
||||
},
|
||||
text_to_speech: PartChoice::Option {
|
||||
id: "pocket-tts@cpu".into(),
|
||||
},
|
||||
..CompanionSettings::default()
|
||||
};
|
||||
settings.persona.name = "Rosa".into();
|
||||
settings.tuning.temperature = Some(0.4);
|
||||
settings.save(&dir).unwrap();
|
||||
let text = fs::read_to_string(CompanionSettings::path(&dir)).unwrap();
|
||||
assert!(text.contains("\"use\": \"server\""), "{text}");
|
||||
assert!(!text.contains("key"), "no key in the file: {text}");
|
||||
assert_eq!(CompanionSettings::load(&dir), settings);
|
||||
|
||||
// Fields from a newer or older version: unknown ones are ignored, missing ones default.
|
||||
fs::write(
|
||||
CompanionSettings::path(&dir),
|
||||
r#"{"no_ai": false, "set_up": true, "future": 3}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let old = CompanionSettings::load(&dir);
|
||||
assert!(old.set_up && !old.voice);
|
||||
assert_eq!(old.brain, BrainChoice::Auto);
|
||||
|
||||
fs::write(CompanionSettings::path(&dir), "{ not json").unwrap();
|
||||
assert_eq!(CompanionSettings::load(&dir), CompanionSettings::default());
|
||||
assert!(
|
||||
CompanionSettings::path(&dir)
|
||||
.with_extension("json.bad")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ai_reaches_the_games_extras_file_and_keeps_its_lines() {
|
||||
let home = crate::download::tests::scratch("extras");
|
||||
sync_extras(&home, false).unwrap();
|
||||
assert!(
|
||||
!home.join(EXTRAS_FILE).exists(),
|
||||
"nothing written for the default"
|
||||
);
|
||||
fs::write(home.join(EXTRAS_FILE), "zoom:true\nfps:false\n").unwrap();
|
||||
sync_extras(&home, true).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(home.join(EXTRAS_FILE)).unwrap(),
|
||||
"zoom:true\nfps:false\nnoAi:true\n"
|
||||
);
|
||||
sync_extras(&home, false).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(home.join(EXTRAS_FILE)).unwrap(),
|
||||
"zoom:true\nfps:false\nnoAi:false\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_the_plan_unless_advanced_overrides_it() {
|
||||
let hw = Hardware {
|
||||
ram_mb: 16 * 1024,
|
||||
physical_cores: 6,
|
||||
logical_cores: 12,
|
||||
apple_silicon: false,
|
||||
gpus: Vec::new(),
|
||||
};
|
||||
let options = brain_options();
|
||||
let plan = plan::plan(&hw, &options, &[], None);
|
||||
let mut settings = CompanionSettings::default();
|
||||
let ids = |s: &CompanionSettings| -> Vec<String> {
|
||||
s.selection(&plan, &options)
|
||||
.iter()
|
||||
.map(|o| o.id.clone())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(ids(&settings), ["qwen3.5-4b-q4_k_m@cpu"]);
|
||||
settings.brain = BrainChoice::Option {
|
||||
id: "qwen3.5-2b-q4_k_m@cpu".into(),
|
||||
};
|
||||
assert_eq!(ids(&settings), ["qwen3.5-2b-q4_k_m@cpu"]);
|
||||
settings.brain = BrainChoice::Option {
|
||||
id: "a-model-that-was-removed".into(),
|
||||
};
|
||||
assert_eq!(ids(&settings), ["qwen3.5-4b-q4_k_m@cpu"]);
|
||||
settings.brain = BrainChoice::Gguf {
|
||||
path: "/models/mine.gguf".into(),
|
||||
};
|
||||
assert!(ids(&settings).is_empty(), "nothing to download");
|
||||
assert_eq!(settings.choice(Part::TextToSpeech), PartChoice::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keychain_commands_quote_what_they_pass() {
|
||||
assert_eq!(keychain::security_quote(r#"a"b\c"#), r#""a\"b\\c""#);
|
||||
let server = BrainChoice::Server {
|
||||
provider: Provider::Cloud,
|
||||
base_url: "https://api.example.com/v1/".into(),
|
||||
model: "m".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
server.key_account().as_deref(),
|
||||
Some("https://api.example.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes to the real keychain: run by hand (`--ignored`) on a desktop session.
|
||||
#[test]
|
||||
#[ignore = "touches the OS keychain"]
|
||||
fn keychain_round_trip() {
|
||||
let account = format!("test-{}", std::process::id());
|
||||
keychain::set(&account, "s3cret \"quoted\"").unwrap();
|
||||
assert_eq!(
|
||||
keychain::get(&account).unwrap().as_deref(),
|
||||
Some("s3cret \"quoted\"")
|
||||
);
|
||||
keychain::delete(&account).unwrap();
|
||||
assert_eq!(keychain::get(&account).unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
0.00.001.982 I load_backend: loaded RPC backend from /opt/horizon/companion/runtime/llama-b11756/libggml-rpc.so
|
||||
0.00.008.433 I load_backend: loaded CPU backend from /opt/horizon/companion/runtime/llama-b11756/libggml-cpu-haswell.so
|
||||
Available devices:
|
||||
(none)
|
||||
@@ -0,0 +1,7 @@
|
||||
0.00.001.877 I load_backend: loaded RPC backend from /usr/local/lib/libggml-rpc.so
|
||||
0.00.214.553 I ggml_cuda_init: found 1 CUDA devices (Total VRAM: 3903 MiB):
|
||||
0.00.214.561 I Device 0: NVIDIA GeForce GTX 1650, compute capability 7.5, VMM: yes, VRAM: 3903 MiB
|
||||
0.00.214.602 I load_backend: loaded CUDA backend from /usr/local/lib/libggml-cuda.so
|
||||
0.00.221.048 I load_backend: loaded CPU backend from /usr/local/lib/libggml-cpu-haswell.so
|
||||
Available devices:
|
||||
CUDA0: NVIDIA GeForce GTX 1650 (3903 MiB, 3780 MiB free)
|
||||
@@ -0,0 +1,54 @@
|
||||
0.00.016.590 I ggml_metal_device_init: tensor API disabled for pre-M5 and pre-A19 devices
|
||||
0.00.016.609 I ggml_metal_library_init: using embedded metal library
|
||||
0.00.028.198 D ggml_metal_library_compile_all: compiled 'fa_aux' library in 0.001 sec
|
||||
0.00.028.201 D ggml_metal_library_compile_all: compiled 'fa_f16' library in 0.002 sec
|
||||
0.00.028.201 D ggml_metal_library_compile_all: compiled 'fa_f32' library in 0.001 sec
|
||||
0.00.028.201 D ggml_metal_library_compile_all: compiled 'fa_q4_0' library in 0.005 sec
|
||||
0.00.028.202 D ggml_metal_library_compile_all: compiled 'fa_q4_1' library in 0.005 sec
|
||||
0.00.028.202 D ggml_metal_library_compile_all: compiled 'fa_q5_0' library in 0.007 sec
|
||||
0.00.028.202 D ggml_metal_library_compile_all: compiled 'fa_q5_1' library in 0.002 sec
|
||||
0.00.028.203 D ggml_metal_library_compile_all: compiled 'fa_q8_0' library in 0.007 sec
|
||||
0.00.028.203 D ggml_metal_library_compile_all: compiled 'fa_tensor' library in 0.002 sec
|
||||
0.00.028.203 D ggml_metal_library_compile_all: compiled 'fa_vec_f16' library in 0.003 sec
|
||||
0.00.028.203 D ggml_metal_library_compile_all: compiled 'fa_vec_f32' library in 0.008 sec
|
||||
0.00.028.203 D ggml_metal_library_compile_all: compiled 'fa_vec_q4_0' library in 0.003 sec
|
||||
0.00.028.204 D ggml_metal_library_compile_all: compiled 'fa_vec_q4_1' library in 0.009 sec
|
||||
0.00.028.204 D ggml_metal_library_compile_all: compiled 'fa_vec_q5_0' library in 0.008 sec
|
||||
0.00.028.204 D ggml_metal_library_compile_all: compiled 'fa_vec_q5_1' library in 0.010 sec
|
||||
0.00.028.204 D ggml_metal_library_compile_all: compiled 'fa_vec_q8_0' library in 0.007 sec
|
||||
0.00.028.205 D ggml_metal_library_compile_all: compiled 'mul_mv' library in 0.004 sec
|
||||
0.00.028.205 D ggml_metal_library_compile_all: compiled 'mul_mv_mma' library in 0.006 sec
|
||||
0.00.028.205 D ggml_metal_library_compile_all: compiled 'mul_mm' library in 0.011 sec
|
||||
0.00.028.206 D ggml_metal_library_compile_all: compiled 'quantize' library in 0.009 sec
|
||||
0.00.028.206 D ggml_metal_library_compile_all: compiled 'softmax' library in 0.011 sec
|
||||
0.00.028.206 D ggml_metal_library_compile_all: compiled 'norm' library in 0.007 sec
|
||||
0.00.028.206 D ggml_metal_library_compile_all: compiled 'unary' library in 0.011 sec
|
||||
0.00.028.209 D ggml_metal_library_compile_all: compiled 'binbcast' library in 0.007 sec
|
||||
0.00.028.209 D ggml_metal_library_compile_all: compiled 'reduce' library in 0.009 sec
|
||||
0.00.028.209 D ggml_metal_library_compile_all: compiled 'tri' library in 0.009 sec
|
||||
0.00.028.209 D ggml_metal_library_compile_all: compiled 'ssm' library in 0.006 sec
|
||||
0.00.028.210 D ggml_metal_library_compile_all: compiled 'wkv' library in 0.006 sec
|
||||
0.00.028.210 D ggml_metal_library_compile_all: compiled 'gated_delta_net' library in 0.007 sec
|
||||
0.00.028.210 D ggml_metal_library_compile_all: compiled 'solve_tri' library in 0.007 sec
|
||||
0.00.028.210 D ggml_metal_library_compile_all: compiled 'rope' library in 0.006 sec
|
||||
0.00.028.211 D ggml_metal_library_compile_all: compiled 'conv' library in 0.006 sec
|
||||
0.00.028.211 D ggml_metal_library_compile_all: compiled 'upscale' library in 0.004 sec
|
||||
0.00.028.211 D ggml_metal_library_compile_all: compiled 'argsort' library in 0.004 sec
|
||||
0.00.028.211 D ggml_metal_library_compile_all: compiled 'pool' library in 0.004 sec
|
||||
0.00.028.212 D ggml_metal_library_compile_all: compiled 'misc' library in 0.004 sec
|
||||
0.00.028.212 I ggml_metal_library_compile_all: loaded 36 libraries from embedded data in 0.012 sec (max single = 0.011 sec)
|
||||
0.00.028.456 I ggml_metal_rsets_init: creating a residency set collection (keep_alive = 180 s)
|
||||
0.00.029.086 I ggml_metal_device_init: GPU name: MTL0 (Apple M4 Pro)
|
||||
0.00.029.088 I ggml_metal_device_init: GPU family: MTLGPUFamilyApple9 (1009)
|
||||
0.00.029.088 I ggml_metal_device_init: GPU family: MTLGPUFamilyMetal4 (5002)
|
||||
0.00.029.089 I ggml_metal_device_init: simdgroup reduction = true
|
||||
0.00.029.089 I ggml_metal_device_init: simdgroup matrix mul. = true
|
||||
0.00.029.089 I ggml_metal_device_init: has unified memory = true
|
||||
0.00.029.089 I ggml_metal_device_init: has bfloat = true
|
||||
0.00.029.089 I ggml_metal_device_init: has tensor = false
|
||||
0.00.029.089 I ggml_metal_device_init: use residency sets = true
|
||||
0.00.029.095 I ggml_metal_device_init: use shared buffers = true
|
||||
0.00.029.096 I ggml_metal_device_init: recommendedMaxWorkingSetSize = 55662.79 MB
|
||||
Available devices:
|
||||
MTL0: Apple M4 Pro (53084 MiB, 53083 MiB free)
|
||||
BLAS: Accelerate (0 MiB, 0 MiB free)
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
0.00.001.911 I load_backend: loaded RPC backend from /home/player/.local/share/horizon/companion/runtime/b11756-linux-x86_64-vulkan/libggml-rpc.so
|
||||
0.00.041.208 D ggml_vulkan: Found 2 Vulkan devices:
|
||||
0.00.041.734 D ggml_vulkan: 0 = AMD Radeon RX 7800 XT (RADV NAVI32) (radv) | uma: 0 | fp16: 1 | bf16: 0 | fp4: 0 | warp size: 64 | shared memory: 65536 | int dot: 1 | matrix cores: KHR_coopmat
|
||||
0.00.042.102 D ggml_vulkan: 1 = AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO) (radv) | uma: 1 | fp16: 1 | bf16: 0 | fp4: 0 | warp size: 64 | shared memory: 65536 | int dot: 1 | matrix cores: none
|
||||
0.00.042.131 I load_backend: loaded Vulkan backend from /home/player/.local/share/horizon/companion/runtime/b11756-linux-x86_64-vulkan/libggml-vulkan.so
|
||||
0.00.049.870 I load_backend: loaded CPU backend from /home/player/.local/share/horizon/companion/runtime/b11756-linux-x86_64-vulkan/libggml-cpu-zen4.so
|
||||
Available devices:
|
||||
Vulkan0: AMD Radeon RX 7800 XT (RADV NAVI32) (16368 MiB, 15890 MiB free)
|
||||
Vulkan1: AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO) (15921 MiB, 15402 MiB free)
|
||||
@@ -0,0 +1,7 @@
|
||||
0.00.001.793 I load_backend: loaded RPC backend from /opt/horizon/companion/runtime/llama-b11756/libggml-rpc.so
|
||||
0.00.008.812 D ggml_vulkan: Found 1 Vulkan devices:
|
||||
0.00.008.996 D ggml_vulkan: 0 = Intel(R) HD Graphics 620 (KBL GT2) (Intel open-source Mesa driver) | uma: 1 | fp16: 1 | bf16: 0 | fp4: 0 | warp size: 32 | shared memory: 49152 | int dot: 0 | matrix cores: none
|
||||
0.00.009.017 I load_backend: loaded Vulkan backend from /opt/horizon/companion/runtime/llama-b11756/libggml-vulkan.so
|
||||
0.00.015.895 I load_backend: loaded CPU backend from /opt/horizon/companion/runtime/llama-b11756/libggml-cpu-haswell.so
|
||||
Available devices:
|
||||
Vulkan0: Intel(R) HD Graphics 620 (KBL GT2) (23988 MiB, 21589 MiB free)
|
||||
@@ -0,0 +1,4 @@
|
||||
0.00.001.908 I load_backend: loaded RPC backend from /opt/horizon/companion/runtime/llama-b11756/libggml-rpc.so
|
||||
0.00.008.387 I load_backend: loaded CPU backend from /opt/horizon/companion/runtime/llama-b11756/libggml-cpu-haswell.so
|
||||
Available devices:
|
||||
(none)
|
||||
@@ -0,0 +1,7 @@
|
||||
0.00.001.802 I load_backend: loaded RPC backend from C:\Users\player\AppData\Roaming\Horizon\companion\runtime\b11756-windows-x86_64-vulkan\ggml-rpc.dll
|
||||
0.00.112.420 D ggml_vulkan: Found 1 Vulkan devices:
|
||||
0.00.113.051 D ggml_vulkan: 0 = NVIDIA GeForce RTX 3060 (NVIDIA) | uma: 0 | fp16: 1 | bf16: 1 | fp4: 0 | warp size: 32 | shared memory: 49152 | int dot: 1 | matrix cores: NV_coopmat2
|
||||
0.00.113.090 I load_backend: loaded Vulkan backend from C:\Users\player\AppData\Roaming\Horizon\companion\runtime\b11756-windows-x86_64-vulkan\ggml-vulkan.dll
|
||||
0.00.121.311 I load_backend: loaded CPU backend from C:\Users\player\AppData\Roaming\Horizon\companion\runtime\b11756-windows-x86_64-vulkan\ggml-cpu-alderlake.dll
|
||||
Available devices:
|
||||
Vulkan0: NVIDIA GeForce RTX 3060 (12288 MiB, 11520 MiB free)
|
||||
@@ -0,0 +1,432 @@
|
||||
# Companions
|
||||
|
||||
A companion is an AI teammate that plays Minecraft with you: a player-like character that follows you,
|
||||
fights with you, gathers, crafts and talks, by chat and later by voice. It runs on your own computer by
|
||||
default. Nothing about it exists until you turn it on, and the **No AI** setting hides it entirely.
|
||||
|
||||
This is the design. It was written on 2026-10-20, before any code, then corrected the same day where
|
||||
the brain's benchmark on real hardware disproved it (the measured parts say so).
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Opt-in, never forced.** With the default settings, no companion spawns and nothing downloads. No AI
|
||||
prompt, popup or chat handling appears either. The only visible trace is a Companion section in Options
|
||||
and a card in the launcher's Settings, both of which say what it is and offer one button.
|
||||
2. **No AI hides everything.** One setting is shared by the launcher (`settings.json`) and the game
|
||||
(`horizon-extras.txt`, `no_ai`), and they are kept in sync. When it is checked, all of these disappear:
|
||||
- the Companion section, card and world-creation option;
|
||||
- the chat `@name` handling;
|
||||
- the runtime, which never starts;
|
||||
- the models, which are never loaded.
|
||||
|
||||
The launcher offers to delete models that were already downloaded.
|
||||
3. **Local first.** The default path downloads an open model and a runtime once. After that it works
|
||||
offline, with no account, and nothing leaves the PC. "Another AI program" (Ollama or LM Studio already
|
||||
installed) and "my own API key (cloud)" are options under Advanced. The cloud option says plainly that
|
||||
conversations then leave the PC.
|
||||
4. **The game does the work; the model decides.** Moving, fighting, mining and crafting are deterministic
|
||||
Rust skills that run on the server tick through the same code paths as real players. The model only
|
||||
chooses which skill to run and what to say. It cannot cheat, cannot stall the game, and cannot break if
|
||||
it answers nonsense.
|
||||
5. **The game comes first.** The model runs in a separate low-priority process with a capped thread count.
|
||||
It is called on events, never every tick. When the player is not talking to it and nothing important
|
||||
happens, it costs nothing. Frame and tick cost are measured on the ThinkPad (HD 620, 2c/4t) and the
|
||||
Mac mini for every slice.
|
||||
6. **Simple screen, Advanced for details.** The simple screen has a single "Set up companion" button that
|
||||
detects the hardware, picks models, downloads them with progress and starts. Advanced shows the model
|
||||
names and versions, licences, quantisation, context, temperature, GPU backend, memory use and latency,
|
||||
and lets the player swap models by hand.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
player chat / voice ─┐ ┌─ say: chat line (+ voice)
|
||||
game events ─────────┼─► Reflexes ──instant──► Skills ──┤
|
||||
(hurt, mobs, sounds) │ │ (Rust, < 1 ms) (Rust, └─ world actions (as a player)
|
||||
│ └─ not sure ─┐ tick)
|
||||
└────────────────► Brain ──JSON──► Skills
|
||||
(local LLM, async, 0.3–3 s)
|
||||
```
|
||||
|
||||
Like Chasm's "nervous system and brain", there are two layers. Our version differs in three ways:
|
||||
|
||||
- The fast layer is **code, not a model**. Kev-4B, the video's classifier, needs 14 GB of GPU memory and
|
||||
understands only English. A multilingual rule parser is instant, free and works on every PC.
|
||||
- The brain **never drives the body directly**. It returns a small JSON plan, constrained by a schema,
|
||||
that the skills carry out.
|
||||
- The **state given to the brain is compact text** of about 250 tokens. A static, cached prefix keeps
|
||||
prompt processing cheap even on a CPU.
|
||||
|
||||
### Body: a virtual player
|
||||
|
||||
The companion is a **virtual LAN guest**: a `Guest` (crates/horizon-client/src/lan/guest.rs) with no
|
||||
connection. Its `PlayerSender` wraps a channel nobody reads. Everything a guest can do already works
|
||||
through `as_guest` and the guest action code (`game/lan_actions.rs`): mining with survival break times,
|
||||
placing, using items, attacking, eating, containers, crafting, pickups, hunger, hazards and fall damage.
|
||||
Other players on LAN, vanilla clients included, see it as a player: tab list, entity tracking, armour,
|
||||
held item and animations.
|
||||
|
||||
- **Single player.** A plain single-player world has no hub. Starting the companion starts one with
|
||||
`listen: false`, the same hub "Open to LAN" uses, so the companion runs on code players exercise every
|
||||
day. On the integrated server thread (#731), it lives in that thread's hub.
|
||||
- **Movement** is simulated on the server. The skill picks inputs (forward, strafe, jump, sneak, sprint,
|
||||
look) and `MotionWorld::controlled_simulation_tick` moves it with player physics. A path comes from the
|
||||
vanilla PathFinder port (`mobs/path.rs`, a player-sized walk evaluator) and is followed with `mobs/nav.rs`
|
||||
logic. The companion moves like a player and can't fly through walls.
|
||||
- **Looks.**
|
||||
- It gets one of vanilla's default skins (Steve, Alex, Ari, Efe, Kai, Makena, Noor, Sunny, Zuri), read
|
||||
from the player's game files, never shipped. This needs per-entity skin selection, which also fixes
|
||||
real players all rendering with the default skin today.
|
||||
- Its name tag carries a small "AI" marker, so nobody mistakes it for a person.
|
||||
- **Life.** It has health, hunger and armour like a player. It dies, drops its items (keepInventory is
|
||||
respected), and respawns next to its owner 10 seconds later, then comments on it.
|
||||
- **Saving.** `companions/<uuid>.json` in the world folder holds its name, personality, level, memory
|
||||
notes and owner. Its inventory and position are saved like a guest's player data.
|
||||
- **Portals.** If the companion is within 16 blocks when its owner changes dimension, it comes along. It
|
||||
either uses the portal itself on multi-level servers, or arrives with the owner in the in-window mode.
|
||||
|
||||
### Skills (Rust, deterministic)
|
||||
|
||||
Each skill is a small state machine ticked on the server. It can be cancelled and reports progress and
|
||||
failure back to the brain as text.
|
||||
|
||||
| Skill | What it does |
|
||||
|---|---|
|
||||
| follow | Keeps 3–6 blocks from the owner. Sprints to catch up and teleports beside them past 48 blocks, as tamed wolves do. |
|
||||
| stay / guard | Holds a position and fights hostiles that come within 12 blocks. |
|
||||
| go to | Goes to a block, or to "that": the owner's crosshair, ray-cast up to 128 blocks. "That tree" means the block of that kind nearest the ray. |
|
||||
| attack / defend | Melee or bow with the best weapon, respecting attack cooldown. Defends the owner: hurt-by and owner-hurt, as for tamed wolves. A **hold fire** flag blocks attacks until "fire". |
|
||||
| collect | Gets N of a block or item. It searches loaded chunks within 48 blocks, paths to the block, mines it with the best tool and picks up the drops. |
|
||||
| give / drop | Throws items to the owner. |
|
||||
| craft / smelt | Crafts in the 2x2 grid, at a nearby table or at a table it places. It plans the simple chains itself: log → planks → sticks → tools. It uses a furnace for ore and food. |
|
||||
| eat / equip | Eats when hunger is below 14, and equips its best armour, weapon and tool on its own. |
|
||||
| sleep | Uses a nearby bed when the owner sleeps. |
|
||||
| build (later) | Pillars up, bridges and builds a quick shelter. |
|
||||
|
||||
### Reflexes: the nervous system (instant)
|
||||
|
||||
Ludovic asked for a classification model, like Kev-4B in the video. Every line the companion hears goes
|
||||
through three stages, and the first one that is sure wins:
|
||||
|
||||
1. **Rules (under 1 ms).** Commands are parsed in English, French, Spanish, German and Portuguese. Item
|
||||
names come from the game's language files. The commands are:
|
||||
- follow, stay / wait here, come here, stop, go there;
|
||||
- attack, defend me, hold fire, fire, jump, crouch, stand up;
|
||||
- get / collect <n> <item>, give me <n> <item>, eat, sleep, craft <item>.
|
||||
|
||||
Negations count: "don't follow me" and "ne me suis pas" are not "follow"; they stay. "Don't shoot"
|
||||
holds fire, and "don't attack the villager" stops that fight. The benchmark below showed the Light
|
||||
brain attacking the villager right after being told not to, so a rule takes this one. Other negated
|
||||
commands ("don't eat") go to the brain. Generic words map to item tags ("wood", "bûches" → `#logs`),
|
||||
and a leading "no," / "non," is a correction, not a negation ("no, follow me" follows).
|
||||
2. **Classifier (about 10–30 ms on a CPU).** A small multilingual embedding model,
|
||||
`intfloat/multilingual-e5-small` (MIT, 118M parameters, Q8 GGUF of 132 MB), compares the line with
|
||||
an example bank. The bank has 15–30 examples per intent in five languages, with typos and negations,
|
||||
embedded once at setup. The nearest examples vote. The classifier returns the intent, its slots
|
||||
("I need wood" → collect oak_log) and a **needs-brain** flag. Below a confidence threshold, with a
|
||||
small margin between the top two intents, or for chit-chat and questions, it passes the line on.
|
||||
- It runs in a second `llama-server` started with `--embeddings` on one CPU thread, from the runtime
|
||||
the brain already uses, so it needs no extra engine.
|
||||
- Kev-4B was rejected: 14 GB of GPU memory and English only.
|
||||
- The design was: multilingual-e5-small, with `paraphrase-multilingual-MiniLM-L12-v2` (Apache 2.0)
|
||||
benchmarked against it. Kept or swapped: whichever is more accurate on the test set at equal speed.
|
||||
3. **Brain.** Everything else goes to the brain.
|
||||
|
||||
A matched command runs at once, with an instant acknowledgement taken from the personality's phrase bank.
|
||||
The brain may add a remark afterwards, asynchronously.
|
||||
|
||||
The same classifier also triages game events. A hit, a mob sound or a low-health warning becomes a reflex
|
||||
action, a callout or a brain call.
|
||||
- **Callouts.** Hostiles and sounds near the owner produce callouts relative to the owner's facing, such
|
||||
as "Creeper, 2 o'clock, close!", without the LLM. Each callout type has its own cooldown.
|
||||
- **Self-preservation.** It retreats at low health, avoids lava and big falls through pathfinding, and
|
||||
backs away from a primed creeper.
|
||||
|
||||
### Brain (local LLM)
|
||||
|
||||
- **When it is called.**
|
||||
- When the owner addresses it with something the reflexes don't take.
|
||||
- On important events: its death, the owner's death, a new dimension, an advancement, a boss, low
|
||||
health, a skill finishing or failing.
|
||||
- Unprompted remarks are limited to one every 20 s by default. A chattiness setting goes from Quiet to
|
||||
Talkative.
|
||||
- **What it is given:**
|
||||
1. A static system prompt: game rules, safety rules, the personality, the level and the action list.
|
||||
This stays the same and is reused from the runtime's prompt cache.
|
||||
2. Memory notes, at most about 300 tokens: who the owner is, what happened, and live persona changes
|
||||
such as "you're a cat now".
|
||||
3. State, at most about 250 tokens: position, dimension, biome, time, weather, health and hunger of
|
||||
both, an inventory summary, nearby entities with clock bearing and distance, notable blocks nearby
|
||||
(ores, chests, water, lava, beds), the owner's crosshair target, the current skill and recent events.
|
||||
4. The last three to six exchanges, each user message exactly as it was sent (state included), so the
|
||||
runtime reuses the whole conversation from its cache and only processes the new message. Old
|
||||
exchanges drop four at a time: dropping one per turn changed the start of the conversation every
|
||||
turn, and the runtime reprocessed all of it (measured: 450 new tokens a turn instead of 160).
|
||||
5. Outside English, a one-line reminder of the reply language at the end of the turn (it took the
|
||||
Light brain's French replies from 85 % to 95 % French).
|
||||
|
||||
The system prompt is processed once when the runtime is ready (`ChatRequest::warm_up`): 0.3–1.5 s on
|
||||
the Mac mini, 10–19 s on the ThinkPad's CPU, which would otherwise land on the first reply.
|
||||
- **What it returns** is JSON constrained by a schema (llama-server `response_format: json_schema`):
|
||||
`{"tone": "shout", "say": "…", "actions": [{"skill": "collect", "item": "oak_log", "count": 16}, …],
|
||||
"remember": "…"}`. `tone` is first (one short word) so the voice knows the delivery before the first
|
||||
sentence; `say` follows and streams to chat and the voice sentence by sentence (a multilingual
|
||||
chunker) while the actions are still being written. llama.cpp keeps the schema's property order, so
|
||||
the schema is sent byte for byte. There are at most three actions. Unknown items and impossible
|
||||
actions are rejected by the skills, and the brain hears why.
|
||||
- **Thinking is off** (`--reasoning off`, and Qwen3.5's `enable_thinking: false`), to keep latency
|
||||
low. Checked: without them Qwen3.5 thinks by default and the first word of `say` comes seconds later.
|
||||
- **Latency targets:** a reflex under 1 ms; a brain's first word under 1 s on the Standard tier with a
|
||||
GPU, under 3 s on Light on a CPU. The whole reply is usually under 40 tokens. Measured (below):
|
||||
Standard on the Mac mini 0.3 s, Light on two ThinkPad threads 3.8 s, so on weak PCs the instant
|
||||
acknowledgement carries the wait.
|
||||
- **Memory** is a short rolling summary in the world folder. "Forget everything" in the Companion screen
|
||||
clears it.
|
||||
|
||||
### Levels and personalities
|
||||
|
||||
- **Levels:**
|
||||
- **Helper** does what you ask, defends itself and you, and otherwise stays back.
|
||||
- **Super strong** fights everything near you, protects you and gathers gear fast. It is visibly
|
||||
boosted: Strength II, Resistance II and Regeneration I, shown as effects and particles.
|
||||
- **Partner to beat the game** plays alongside you through the progression, using a milestone graph
|
||||
read from advancements and inventories: wood → stone tools → iron → diamonds → Nether → blaze rods
|
||||
and pearls → stronghold → End → dragon. It picks the next milestone, works on it when you don't
|
||||
command it, and tells you the plan.
|
||||
- **Personalities:**
|
||||
- Veteran: grumpy and sarcastic.
|
||||
- Coach: friendly and explains things.
|
||||
- Expert: calm and precise.
|
||||
- Joker.
|
||||
- Quiet.
|
||||
- A custom description field.
|
||||
|
||||
It talks in the game's language. Personality and level can be changed live from the Companion screen,
|
||||
or by telling it ("you're a pirate now").
|
||||
- **Safety:**
|
||||
- Models are the official instruction-tuned ones. Uncensored models are never offered.
|
||||
- The system prompt keeps it inside Minecraft, with no real-world harmful instructions and no sexual
|
||||
content. It refuses in character.
|
||||
- **Language: Clean** is the default, and **Salty** allows mild swearing.
|
||||
- **Kid-friendly** softens the humour.
|
||||
- Nothing is recorded or sent anywhere on the local path.
|
||||
|
||||
### Multiplayer rules
|
||||
|
||||
- In version 1, the single-player player or the LAN host can have one companion. Guests see it, can talk
|
||||
to it, and it can help them, but it obeys its owner first.
|
||||
- Every companion chat line is tagged. A guest with **No AI** checked doesn't see those lines. The
|
||||
companion still exists in the world, because the world is shared, but its name tag shows only the AI
|
||||
marker.
|
||||
- A host with No AI never spawns one.
|
||||
- Dedicated servers: later, behind a server setting that is off by default.
|
||||
|
||||
## Models and runtime
|
||||
|
||||
**The runtime is llama.cpp's `llama-server`, as a separate process.** It is MIT-licensed, has prebuilt
|
||||
binaries for every OS and backend, and serves an OpenAI-compatible API with tool calling and JSON-schema
|
||||
constrained output.
|
||||
|
||||
- **Why a separate process, not a linked library:**
|
||||
- Players who never turn the companion on download nothing and run exactly today's game binary.
|
||||
- A runtime crash can't take the game down.
|
||||
- The OS sees its memory and priority separately.
|
||||
- It works with the Vulkan backend on AMD and Intel GPUs (mistral.rs, candle and ONNX Runtime have no
|
||||
Vulkan).
|
||||
- The same client code talks to Ollama, LM Studio and cloud endpoints.
|
||||
- **Builds and installation.** One release is pinned, with SHA-256 of every archive pinned in source:
|
||||
- macOS arm64 uses Metal;
|
||||
- Windows and Linux use Vulkan, with a CPU build as fallback;
|
||||
- macOS x64 uses the CPU.
|
||||
|
||||
It is installed under `<Horizon data>/companion/runtime/<build>/`.
|
||||
- **How it is started:**
|
||||
- It starts when a world with a companion loads and stops when the world closes.
|
||||
- It runs at low priority: nice 10 (or the game's own, if that is nicer), or BELOW_NORMAL on Windows.
|
||||
- It dies with the game, even when the game crashes. On Unix it is a process group whose watchdog, a
|
||||
forked process holding one end of a pipe, kills the group when the game's end closes. A parent-death
|
||||
signal was rejected because it follows the thread that started the process. On Windows it runs in a
|
||||
job object that kills it when the game's handle closes.
|
||||
- It uses `--threads` = physical cores − 1 (at least 1; 2 on a 2-core machine), `--parallel 1`, an
|
||||
8K context and prompt-cache reuse.
|
||||
- GPU layers are all of them on Apple Silicon or a discrete GPU with enough free VRAM. On an iGPU the
|
||||
model runs on the CPU, because the game needs that GPU.
|
||||
- It listens on 127.0.0.1 only, on a random port, with a random API key.
|
||||
- It keeps no copy of past prompts in RAM (`--cache-ram 0`): there is one conversation, and it lives
|
||||
in the slot. The default 8 GiB host cache took a Standard session from 3.9 to 6.8 GB with the same
|
||||
cache hits. Gemma 4 gets `--swa-full`: with its sliding-window cache it fell back on checkpoints,
|
||||
which grew a session from 7.5 to 12.4 GB, and turns that dropped old exchanges reprocessed 600
|
||||
tokens. The full cache is 9.6 GB flat and is reused by shifting.
|
||||
|
||||
**The models.** All are open-weight and Apache 2.0, downloaded by the player straight from Hugging Face,
|
||||
ungated and without an account, then checked against pinned SHA-256:
|
||||
|
||||
| Tier (plain label) | Model | File | Memory while it runs |
|
||||
|---|---|---|---|
|
||||
| **Light**: for older PCs | Qwen3.5-2B Instruct, Q4_K_M (`unsloth/Qwen3.5-2B-GGUF`) | 1.28 GB | 1.9 GB on a GPU, 2.3 GB on a CPU (measured) |
|
||||
| **Standard**: most PCs | Qwen3.5-4B, Q4_K_M (`unsloth/Qwen3.5-4B-GGUF`) | 2.74 GB | 3.9 GB (measured) |
|
||||
| **Strong**: good graphics card | Qwen3.5-9B, Q4_K_M (`unsloth/Qwen3.5-9B-GGUF`) | 5.68 GB | about 7.3 GB (estimated) |
|
||||
| **Best**: needs a strong GPU | Gemma 4 12B instruct, Google's official QAT Q4_0 (`google/gemma-4-12B-it-qat-q4_0-gguf`) | 6.98 GB | 9.6 GB with `--swa-full` (measured) |
|
||||
|
||||
- **Which tier:** `plan()` (below) picks it from these measured needs, not from the fixed thresholds the
|
||||
design first had (5 / 8 / 11 GB of free VRAM). A 12 GB RTX 3060 holds Best (9.6 GB) next to the
|
||||
game's 1.5 GB, and a 4 GB GTX 1650 holds Light. On a Mac the game renders on the GPU the model uses,
|
||||
so the first thresholds stay there as a cap: 16 GB Standard, 24 GB Strong, 32 GB Best.
|
||||
- **How free VRAM is measured:** `llama-server -v --list-devices` after the runtime download, minus
|
||||
about 1.5 GB for the game itself. `-v` matters: only the verbose Vulkan lines say `uma: 1`, which marks
|
||||
an integrated GPU. The Vulkan build without a Vulkan driver lists no device and runs on the CPU, so it
|
||||
doubles as the CPU build.
|
||||
- **Why these models:**
|
||||
- Qwen3.5 has the best tool use per gigabyte at small sizes (BFCL-V4 66.1 for the 9B) and covers about
|
||||
200 languages.
|
||||
- Gemma 4 12B is the brain Chasm's video used, with strong role-play and multilingual quality. It is now
|
||||
Apache 2.0, and Google's QAT build keeps more quality at 4 bits.
|
||||
- The JSON schema makes every tier's output valid JSON (100 % in the benchmark). It does not make the
|
||||
2B choose well: its actions made sense in 40–80 % of turns, against 70–100 % for the 4B and 95–100 %
|
||||
for Gemma 4 12B. On Light the rules and the classifier carry the commands, and the brain mostly
|
||||
talks.
|
||||
- **Rejected:**
|
||||
- kev-4b: 14 GB of VRAM, English-only, a classifier.
|
||||
- Liquid LFM2.5: licence limited to companies under $10M revenue.
|
||||
- Uncensored fine-tunes.
|
||||
- Models gated behind a Hugging Face login.
|
||||
|
||||
**Measured on 2026-10-20** with `examples/companion_bench.rs`: the real runtime started through the
|
||||
supervisor, twenty scripted turns per language (state + a player line or an event → JSON), four
|
||||
personalities, the warm-up first, temperature 0.7. "Sensible" means the skills fit the turn (a fixed list
|
||||
of acceptable skills per turn, plus one that must be there for orders such as "grab 16 logs"); it moves
|
||||
by ±20 points between runs, so read it as a range.
|
||||
|
||||
| Tier, where | Load | First word, median / p90 | Whole reply | Tokens/s | Valid JSON | Sensible | Right language | RAM |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Light, Mac mini Metal, en | 0.5 s | 134 / 147 ms | 0.50 s | 123 | 100 % | 65 % | 100 % | 1.9 GB |
|
||||
| Light, Mac mini Metal, fr | 0.5 s | 149 / 165 ms | 0.43 s | 117 | 100 % | 65 % | 95 % | 1.8 GB |
|
||||
| Standard, Mac mini Metal, en | 0.9 s | 317 / 398 ms | 0.92 s | 59 | 100 % | 70 % | 100 % | 3.8 GB |
|
||||
| Standard, Mac mini Metal, fr | 0.8 s | 345 / 404 ms | 0.99 s | 55 | 100 % | 90 % | 100 % | 3.8 GB |
|
||||
| Best, Mac mini Metal, en | 1.2 s | 762 / 858 ms | 2.3 s | 29 | 100 % | 95 % | 100 % | 9.6 GB |
|
||||
| Best, Mac mini Metal, fr | 1.2 s | 893 / 1039 ms | 2.4 s | 29 | 100 % | 100 % | 100 % | 9.6 GB |
|
||||
| Light, 2 CPU threads of the M4 Pro, en | 1.7 s | 1.3 / 1.5 s | 2.2 s | 46 | 100 % | 40 % | 100 % | 3.1 GB |
|
||||
| Light, 2 CPU threads of the M4 Pro, fr | 1.3 s | 1.4 / 2.0 s | 2.3 s | 45 | 100 % | 70 % | 100 % | 3.1 GB |
|
||||
| Light, ThinkPad i7-7500U, 2 threads, en | 2.3 s | 3.8 / 5.6 s | 7.2 s | 12 | 100 % | 55 % | 100 % | 2.3 GB |
|
||||
| Light, ThinkPad i7-7500U, 2 threads, fr | 2.3 s | 4.4 / 6.4 s | 8.0 s | 12 | 100 % | 60 % | 95 % | 2.3 GB |
|
||||
|
||||
- Loads are from a warm disk cache; the models are read from disk at 2–5 GB/s otherwise.
|
||||
- On the ThinkPad the time goes into reading the turn (about 45 tokens/s on two threads), not into
|
||||
writing the answer: what keeps the turn short (the cache, the four-at-a-time drop, a compact state)
|
||||
matters most there.
|
||||
- Bytes / 3, the prompt builder's token estimate, overestimates Qwen's real count by about a quarter for
|
||||
the system prompt (1096 estimated, 856 real), which keeps the budget on the safe side.
|
||||
- Typical misses: Light gives bread by "collecting" it, answers a respawn with `attack nearest`, or crafts
|
||||
TNT after refusing a real bomb; Standard sometimes adds a skill nobody asked for. Every tier refused
|
||||
the real-world bomb and stayed in character.
|
||||
|
||||
**Voice (in the first version, Ludovic 2026-10-20).** Voice runs in a second sidecar, audio.cpp's
|
||||
server (`0xShug0/audio.cpp`, Apache 2.0). It is ggml-based like llama.cpp, has prebuilt Metal, Vulkan,
|
||||
CUDA and CPU builds, a C API and a streaming server, and needs no Python. It is downloaded only when voice
|
||||
is turned on. Its build and models are pinned with SHA-256, like llama.cpp's.
|
||||
|
||||
- **Talking to it: push-to-talk.** Hold **V** (rebindable in Controls), speak, then release. The
|
||||
microphone is opened only once voice is turned on, which is also when macOS asks for permission, and
|
||||
only while the key is held. A small mic icon shows that it is listening.
|
||||
- **Speech to text** is NVIDIA Parakeet TDT 0.6B v3: CC-BY-4.0, 25 European languages detected
|
||||
automatically, about 0.5 GB quantised, the newer version of the model Chasm's video used. Expected
|
||||
time on a mid CPU is about 0.3 s for a 3 s sentence, and 1–2 s on the HD 620. That is to be measured.
|
||||
- **Very weak PCs** get Whisper base (MIT, about 0.2 GB) instead.
|
||||
- What it heard shows in chat as the player's line, so mistakes are visible.
|
||||
- **Its voice (text to speech).** The brain's JSON carries a `tone` (normal, shout, whisper, laugh,
|
||||
sarcastic…), and `say` is spoken sentence by sentence while the rest is still being generated.
|
||||
- **Natural voice (CPU, default): Kyutai Pocket TTS.** This is the official ungated
|
||||
`kyutai/pocket-tts-without-voice-cloning` (CC-BY-4.0): 100M parameters, Q8 GGUF of about 125 MB. It
|
||||
speaks en, fr, de, es, pt and it, with about 200 ms to the first sound and about 6× real time on 2
|
||||
Apple M4 cores. It has no emotion control, so `tone` maps to volume and pace.
|
||||
- **Expressive voice (GPU, "Best quality"): Qwen3-TTS-12Hz-1.7B-CustomVoice.** Apache 2.0, ungated,
|
||||
Q8 GGUF of 2.8 GB, ten languages including the five above. It takes a natural-language instruction
|
||||
for each line ("shouting, furious", "whispering", "dry and sarcastic"), which is how it shouts and
|
||||
whispers like Kevin. It streams, at about 0.2–0.5 s to the first sound on an RTX 4090 / 4060.
|
||||
Chasm's own project uses Qwen3-TTS as well.
|
||||
- Each personality gets its own voice and default delivery.
|
||||
- **Playback.** The voice plays positionally from the companion's head through the game's mixer, as an
|
||||
appendable streaming channel. Past 48 blocks, or in another dimension, it comes over a "radio"
|
||||
(band-pass filter and static). Subtitles follow the game's subtitle setting.
|
||||
- **Rejected:**
|
||||
- Breeze-TTS-2, the voice in the video: research and non-commercial licence, NVIDIA 12 GB only, English
|
||||
and Chinese.
|
||||
- Supertonic 3: an OpenRAIL-M licence that passes use restrictions on to players, and an archived repo.
|
||||
- Kokoro: no German.
|
||||
- Chatterbox: watermark, and only English for Turbo.
|
||||
- Spark, Voxtral, Higgs, OmniVoice, Fish, IndexTTS: non-commercial or conditional licences.
|
||||
- The sherpa-onnx route: it stays the fallback if audio.cpp falls short.
|
||||
|
||||
### Fitting the player's PC (simple and Advanced)
|
||||
|
||||
Every part comes in several options, each tagged with what it needs: RAM, free VRAM, CPU cores, GPU
|
||||
backend and download size. The parts are the brain, the classifier, speech to text and the voice.
|
||||
|
||||
- `plan()` looks at the PC and builds one recommended setup. It always keeps the game's own share free:
|
||||
about 3 GB of RAM, 1.5 GB of VRAM and 1–2 cores. It describes the result in plain words, for example
|
||||
"Standard brain on your Radeon RX 6600, natural voice on the processor: 3.4 GB to download".
|
||||
- It also leaves 2 GB of RAM to the system. The game keeps 2 threads on CPUs with 4 or fewer, else 4.
|
||||
- Parts get room in order: brain, classifier, voice, speech recognition. Each takes the best option
|
||||
that fits. The classifier and speech recognition run before the brain answers, so they borrow its
|
||||
threads; the voice speaks while the brain still writes, so it needs threads of its own.
|
||||
- A graphics card that holds any brain gets it. On the processor even a better brain would take the
|
||||
cores the game uses, and answer several times slower.
|
||||
- Among the brains that fit, the best one whose first word is expected within 2 s is chosen, else the
|
||||
fastest, and the summary says replies are slow. The estimates come from the measurements above.
|
||||
- Speech recognition comes with the voice: without a voice it is dropped too.
|
||||
- The options come from lists: the brain's from the catalogue, the others from the senses sidecar.
|
||||
- If the PC is too weak for a part, the plan says so and drops it. A 2-core laptop gets the Light brain
|
||||
and chat only, for instance, and keeps the option to try voice anyway.
|
||||
- **Simple** (the default, in the launcher and in the game) shows the detected PC, the plan and one "Set
|
||||
up companion" button, then progress and "Ready".
|
||||
- **Advanced** (collapsed) lets the player:
|
||||
- pick any catalogue option per part, or use a custom GGUF, Ollama, LM Studio or a cloud endpoint and
|
||||
key (the key is kept in the OS keychain);
|
||||
- see exact names, versions, licences, quantisation and context;
|
||||
- change temperature, GPU layers and threads;
|
||||
- watch live memory use and latency;
|
||||
- delete models.
|
||||
- The launcher and the game share one `CompanionSettings`, which also holds `no_ai`.
|
||||
|
||||
## Code layout
|
||||
|
||||
- `crates/horizon-companion`: no game dependency, unit-tested. It holds:
|
||||
- the catalogue (models, runtime builds, URLs, SHA-256, sizes, licences);
|
||||
- hardware detection and tier choice;
|
||||
- download and verify with resume and progress;
|
||||
- the runtime supervisor (start, stop, health, priority);
|
||||
- the OpenAI-compatible streaming client (`ureq`, server-sent events), for local, Ollama, LM Studio and
|
||||
cloud;
|
||||
- the prompt builder and the JSON schema;
|
||||
- the reflex pipeline (rules → classifier → brain) and phrase banks;
|
||||
- `plan()` and `CompanionSettings` (simple and Advanced), with the cloud key in the OS keychain;
|
||||
- the benchmark (`examples/companion_bench.rs`) and an installer for tests
|
||||
(`examples/companion_install.rs`);
|
||||
- the senses client: the audio.cpp supervisor, streaming TTS, STT, and the embedding classifier;
|
||||
- presets.
|
||||
|
||||
Both the game and the launcher use it.
|
||||
- `crates/horizon-client/src/companion/`: the virtual guest, the skills, perception (the state summary),
|
||||
the chat hook, the Companion options section and persistence. The brain is called from a std-thread
|
||||
worker over channels, polled once per tick, never blocking (the `skins::Downloader` pattern).
|
||||
- The launcher holds the Settings card (No AI, Set up companion, progress, Advanced). It hands its
|
||||
choices to the game through files in the game's home: `companion/settings.json` (the shared
|
||||
`CompanionSettings`) and `noAi:true|false` in `horizon-extras.txt`, written on every change and
|
||||
before each start because the game rewrites that file with the keys it knows. There is no `--no-ai`
|
||||
flag yet: today's game refuses options it doesn't know, so the integration slice adds it.
|
||||
|
||||
## Slices
|
||||
|
||||
1. **Body:** the virtual guest in single player and LAN, a dev command (`/companion spawn|dismiss`),
|
||||
movement and pathing, follow, stay, come, go to, attack, defend, hold fire, pickups, eat, equip,
|
||||
respawn, per-entity skins, the name tag, and saving.
|
||||
2. **Reflexes:** the multilingual command parser, phrase banks, callouts, the chat `@name` hook, and No AI.
|
||||
3. **Brain:** `horizon-companion`: catalogue, tiers, download, supervisor, client, prompt, memory. Plus
|
||||
the collect, give and craft skills.
|
||||
4. **Setup:** the in-game Companion section, the launcher card, the one-button setup, Advanced, and the
|
||||
world-creation option.
|
||||
5. **Levels:** Super strong, Helper and Partner (the milestone planner).
|
||||
6. **Senses:** the classifier model, voice out (Pocket / Qwen3-TTS through audio.cpp, positional and
|
||||
radio) and push-to-talk in (Parakeet v3). This is part of the first version.
|
||||
7. **Polish:** cost numbers on the ThinkPad and the Mac mini, demo recordings, licences in
|
||||
THIRD_PARTY_NOTICES and the launcher's Advanced section.
|
||||
Generated
+17
@@ -2637,6 +2637,22 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-companion"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tar",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-friends"
|
||||
version = "0.1.0"
|
||||
@@ -2663,6 +2679,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"gpui",
|
||||
"horizon-bench",
|
||||
"horizon-companion",
|
||||
"horizon-friends",
|
||||
"horizon-port-pack",
|
||||
"horizon-prepare",
|
||||
|
||||
@@ -30,6 +30,9 @@ horizon-bench = { path = "../crates/horizon-bench" }
|
||||
horizon-port-pack = { path = "../crates/horizon-port-pack" }
|
||||
# Opt-in data collection: the consent, the outbox and its upload (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../crates/horizon-telemetry" }
|
||||
# The companion's setup card: hardware plan, downloads and settings shared with the game
|
||||
# (docs/companions.md).
|
||||
horizon-companion = { path = "../crates/horizon-companion" }
|
||||
# The session file's random name.
|
||||
getrandom = "0.3"
|
||||
# Players' heads on the Servers page, from the default skins in the player's own game files (already
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! The companion's setup from the launcher (docs/companions.md): what this PC is, the plan for it,
|
||||
//! downloading what the plan picked with progress and cancel, testing the brain, and deleting models.
|
||||
//! The work itself is `horizon-companion`'s; this runs it on worker threads and reports over a channel
|
||||
//! the window polls. Everything lives in the game's home (`<home>/companion/`), where the game finds it.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_companion::catalogue::{self, Model, Tier};
|
||||
use horizon_companion::client::{Brain, ChatRequest, Event as BrainEvent};
|
||||
use horizon_companion::download::{self, Layout, Phase, Progress};
|
||||
use horizon_companion::hardware::{self, Hardware};
|
||||
use horizon_companion::plan::{self, Part, Plan};
|
||||
use horizon_companion::prompt::{self, Budget, Input};
|
||||
use horizon_companion::runtime::{LaunchConfig, Runtime};
|
||||
pub use horizon_companion::settings::{BrainChoice, CompanionSettings, PartChoice};
|
||||
|
||||
use crate::backend;
|
||||
|
||||
/// Where the companion keeps its runtime, models and settings: the game's home.
|
||||
pub fn home() -> PathBuf {
|
||||
backend::game_home()
|
||||
}
|
||||
|
||||
pub fn layout() -> Layout {
|
||||
Layout::new(&home())
|
||||
}
|
||||
|
||||
pub fn load_settings() -> CompanionSettings {
|
||||
CompanionSettings::load(&home())
|
||||
}
|
||||
|
||||
/// Saves the settings and tells the game's extras file about No AI.
|
||||
pub fn save_settings(settings: &CompanionSettings) -> Result<(), String> {
|
||||
settings.save(&home()).map_err(|e| e.to_string())?;
|
||||
sync_no_ai(settings.no_ai)
|
||||
}
|
||||
|
||||
/// Before each start: the game rewrites its extras file with the keys it knows.
|
||||
pub fn sync_no_ai(no_ai: bool) -> Result<(), String> {
|
||||
horizon_companion::settings::sync_extras(&home(), no_ai).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What the window shows about the PC, before and after the runtime can list its GPUs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Machine {
|
||||
pub hardware: Hardware,
|
||||
/// The GPUs come from the runtime: until it is downloaded only RAM and cores are known.
|
||||
pub gpus_known: bool,
|
||||
}
|
||||
|
||||
impl Machine {
|
||||
/// RAM and cores now, and the GPUs if the runtime is already installed (a second, at most).
|
||||
pub fn detect() -> Machine {
|
||||
let mut hardware = Hardware::detect();
|
||||
let server = runtime_build().and_then(|b| layout().installed_runtime(b));
|
||||
let gpus_known = match server {
|
||||
Some(server) => match hardware::list_devices(&server) {
|
||||
Ok(out) => {
|
||||
hardware.gpus = hardware::parse_devices(&out);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
Machine {
|
||||
hardware,
|
||||
gpus_known,
|
||||
}
|
||||
}
|
||||
|
||||
/// "16 GB of memory, 6 cores, NVIDIA GeForce RTX 3060 (11.2 GB free)".
|
||||
pub fn summary(&self) -> String {
|
||||
let hw = &self.hardware;
|
||||
let mut out = format!(
|
||||
"{} GB of memory, {} cores",
|
||||
hw.nominal_ram_gb(),
|
||||
hw.physical_cores
|
||||
);
|
||||
let gpu = if hw.apple_silicon {
|
||||
hw.gpus.iter().find(|g| g.id.starts_with("MTL"))
|
||||
} else {
|
||||
hw.best_gpu()
|
||||
};
|
||||
match gpu {
|
||||
Some(g) if hw.apple_silicon => out += &format!(", {}", g.name),
|
||||
Some(g) => out += &format!(", {} ({:.1} GB free)", g.name, g.free_mb as f64 / 1024.0),
|
||||
None if self.gpus_known => out += ", no graphics card the companion can use",
|
||||
None => out += "; the graphics card is checked during setup",
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The runtime build for this PC: the GPU one where there is one (it also runs on the CPU).
|
||||
pub fn runtime_build() -> Option<&'static catalogue::RuntimeBuild> {
|
||||
catalogue::runtime_builds_here().into_iter().next()
|
||||
}
|
||||
|
||||
/// The options the launcher can set up today: the brain's. The senses sidecar adds its own at
|
||||
/// integration.
|
||||
pub fn options() -> Vec<plan::PartOption> {
|
||||
plan::brain_options()
|
||||
}
|
||||
|
||||
/// Ids and runtime keys already on disk, for the plan's download size.
|
||||
pub fn installed_ids() -> Vec<String> {
|
||||
let layout = layout();
|
||||
let mut ids = Vec::new();
|
||||
if let Some(build) = runtime_build() {
|
||||
if layout.installed_runtime(build).is_some() {
|
||||
ids.push(build.key());
|
||||
}
|
||||
}
|
||||
for option in options() {
|
||||
if model_of(&option.id).is_some_and(|m| layout.installed_model(m).is_some()) {
|
||||
ids.push(option.id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn make_plan(machine: &Machine) -> Plan {
|
||||
let installed = installed_ids();
|
||||
let installed: Vec<&str> = installed.iter().map(String::as_str).collect();
|
||||
let disk = download::free_space(&home()).or_else(|| download::free_space(&backend::data_dir()));
|
||||
plan::plan(&machine.hardware, &options(), &installed, disk)
|
||||
}
|
||||
|
||||
/// The catalogue model behind a plan option id (`qwen3.5-4b-q4_k_m@gpu`).
|
||||
pub fn model_of(option_id: &str) -> Option<&'static Model> {
|
||||
let id = option_id.split('@').next()?;
|
||||
catalogue::MODELS.iter().find(|m| m.id == id)
|
||||
}
|
||||
|
||||
/// Models on disk and their size.
|
||||
pub fn installed_models() -> Vec<(&'static Model, u64)> {
|
||||
let layout = layout();
|
||||
catalogue::MODELS
|
||||
.iter()
|
||||
.filter(|m| layout.installed_model(m).is_some())
|
||||
.map(|m| (m, m.size()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deletes every model and runtime (No AI's "delete downloaded models").
|
||||
pub fn delete_everything() -> Result<u64, String> {
|
||||
let layout = layout();
|
||||
let before = layout.disk_usage();
|
||||
for model in catalogue::MODELS {
|
||||
layout.delete_model(model).map_err(|e| e.to_string())?;
|
||||
}
|
||||
for build in catalogue::RUNTIME_BUILDS {
|
||||
layout.delete_runtime(build).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(before)
|
||||
}
|
||||
|
||||
pub fn delete_model(model: &Model) -> Result<(), String> {
|
||||
layout().delete_model(model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What a setup or a test reports.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Event {
|
||||
/// What is happening now ("Downloading the runtime"), and how far, when known.
|
||||
Step(String, Option<f32>),
|
||||
/// The plan once the runtime could see the graphics card.
|
||||
Planned(Machine, Box<Plan>),
|
||||
Done(String),
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
Tested(TestResult),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TestResult {
|
||||
pub load: Duration,
|
||||
pub first_word: Duration,
|
||||
pub total: Duration,
|
||||
pub ram_mb: Option<u64>,
|
||||
pub tokens_per_second: Option<f64>,
|
||||
pub say: String,
|
||||
}
|
||||
|
||||
/// A setup or a test running on its own thread.
|
||||
pub struct Job {
|
||||
pub rx: Receiver<Event>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub fn cancel(&self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn(
|
||||
work: impl FnOnce(&Sender<Event>, &AtomicBool) -> Result<String, String> + Send + 'static,
|
||||
) -> Job {
|
||||
let (tx, rx) = channel();
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let flag = cancel.clone();
|
||||
std::thread::spawn(move || {
|
||||
let event = match work(&tx, &flag) {
|
||||
Ok(done) => Event::Done(done),
|
||||
Err(_) if flag.load(Ordering::Relaxed) => Event::Cancelled,
|
||||
Err(e) => Event::Failed(e),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
Job { rx, cancel }
|
||||
}
|
||||
|
||||
fn reporter<'a>(tx: &'a Sender<Event>, what: &'a str) -> impl FnMut(Progress) + 'a {
|
||||
let mut last = -1.0;
|
||||
move |p: Progress| {
|
||||
let f = p.fraction();
|
||||
if f - last >= 0.005 || f >= 1.0 {
|
||||
last = f;
|
||||
let step = match p.phase {
|
||||
Phase::Unpacking => format!("Unpacking {what}"),
|
||||
_ => format!(
|
||||
"Downloading {what}: {} of {} MB",
|
||||
p.done / 1_000_000,
|
||||
p.total / 1_000_000
|
||||
),
|
||||
};
|
||||
let _ = tx.send(Event::Step(step, Some(f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Set up companion": the runtime first (small), then the PC is checked again with it, then what
|
||||
/// the settings and the plan pick. `brain` is the option id Advanced chose, if any.
|
||||
pub fn set_up(settings: CompanionSettings) -> Job {
|
||||
spawn(move |tx, cancel| {
|
||||
let layout = layout();
|
||||
let build = runtime_build().ok_or("No runtime for this system")?;
|
||||
if layout.installed_runtime(build).is_none() {
|
||||
layout
|
||||
.install_runtime(build, &mut reporter(tx, "the runtime"), cancel)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let machine = Machine::detect();
|
||||
let plan = make_plan(&machine);
|
||||
let _ = tx.send(Event::Planned(machine, Box::new(plan.clone())));
|
||||
let options = options();
|
||||
let chosen = settings.selection(&plan, &options);
|
||||
let mut set_up = Vec::new();
|
||||
for option in chosen {
|
||||
let Some(model) = model_of(&option.id) else {
|
||||
continue;
|
||||
};
|
||||
if layout.installed_model(model).is_none() {
|
||||
let what = format!("the {} brain", option.label);
|
||||
layout
|
||||
.install_model(model, &mut reporter(tx, &what), cancel)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
set_up.push(option.label.clone());
|
||||
}
|
||||
if set_up.is_empty()
|
||||
&& !matches!(
|
||||
settings.brain,
|
||||
BrainChoice::Gguf { .. } | BrainChoice::Server { .. }
|
||||
)
|
||||
{
|
||||
return Err(plan.summary);
|
||||
}
|
||||
let mut saved = settings.clone();
|
||||
saved.set_up = true;
|
||||
save_settings(&saved)?;
|
||||
Ok("Ready".into())
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts the brain the plan or Advanced picked, warms it and plays one turn: load time, first
|
||||
/// word, memory and speed, for Advanced's live numbers. Stops it after.
|
||||
pub fn test_brain(settings: CompanionSettings, machine: Machine) -> Job {
|
||||
spawn(move |tx, cancel| {
|
||||
let layout = layout();
|
||||
let build = runtime_build().ok_or("No runtime for this system")?;
|
||||
let server = layout
|
||||
.installed_runtime(build)
|
||||
.ok_or("Set up the companion first")?;
|
||||
let plan = make_plan(&machine);
|
||||
let options = options();
|
||||
let option = settings
|
||||
.selection(&plan, &options)
|
||||
.into_iter()
|
||||
.find(|o| o.part == Part::Brain)
|
||||
.ok_or("No brain is set up")?
|
||||
.clone();
|
||||
let model = model_of(&option.id).ok_or("Not a catalogue brain")?;
|
||||
let path = layout
|
||||
.installed_model(model)
|
||||
.ok_or("This brain isn't downloaded yet")?;
|
||||
let pick = plan
|
||||
.pick(Part::Brain)
|
||||
.filter(|p| p.option.id == option.id)
|
||||
.cloned()
|
||||
.unwrap_or(plan::Pick {
|
||||
device: match option.runs {
|
||||
plan::Runs::Cpu => plan::Device::Cpu { threads: 2 },
|
||||
plan::Runs::Gpu => {
|
||||
let gpu = machine
|
||||
.hardware
|
||||
.best_gpu()
|
||||
.or(machine.hardware.gpus.first())
|
||||
.ok_or("No graphics card for this option")?;
|
||||
plan::Device::Gpu {
|
||||
id: gpu.id.clone(),
|
||||
name: gpu.name.clone(),
|
||||
}
|
||||
}
|
||||
},
|
||||
latency_ms: option.latency_ms,
|
||||
option: option.clone(),
|
||||
});
|
||||
let log = home()
|
||||
.join("companion")
|
||||
.join("logs")
|
||||
.join("launcher-test.log");
|
||||
let mut config = LaunchConfig::for_pick(&server, &path, model.family, &pick, &log);
|
||||
if let Some(threads) = settings.tuning.threads {
|
||||
config.threads = threads;
|
||||
}
|
||||
let _ = tx.send(Event::Step(
|
||||
format!("Starting the {} brain", option.label),
|
||||
None,
|
||||
));
|
||||
let mut runtime = Runtime::start(&config).map_err(|e| e.to_string())?;
|
||||
let load = runtime
|
||||
.wait_ready(Duration::from_secs(300))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let _ = tx.send(Event::Step("Reading the instructions".into(), None));
|
||||
let mut brain = Brain::new(runtime.endpoint());
|
||||
let language = "en_us";
|
||||
let system = prompt::system_prompt(&settings.persona, "you", language);
|
||||
let input = Input::Said {
|
||||
who: "You".into(),
|
||||
text: "hey, can you grab some wood for us?".into(),
|
||||
};
|
||||
let sent = prompt::messages(
|
||||
&system,
|
||||
"",
|
||||
"Dimension: overworld, forest, morning.\nBlocks: 30 oak_log within 12 blocks.",
|
||||
&[],
|
||||
&input,
|
||||
language,
|
||||
&Budget::default(),
|
||||
);
|
||||
let wait = |brain: &mut Brain, id: u64| -> Result<_, String> {
|
||||
let until = Instant::now() + Duration::from_secs(120);
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) || Instant::now() > until {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
match brain.try_recv() {
|
||||
Some(BrainEvent::Done {
|
||||
id: got,
|
||||
reply,
|
||||
stats,
|
||||
..
|
||||
}) if got == id => return Ok((reply, stats)),
|
||||
Some(BrainEvent::Failed { id: got, error }) if got == id => return Err(error),
|
||||
_ => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
};
|
||||
let id = brain.ask(ChatRequest::warm_up(sent[0].clone()));
|
||||
wait(&mut brain, id)?;
|
||||
let _ = tx.send(Event::Step("Answering".into(), None));
|
||||
let id = brain.ask(ChatRequest::turn(sent));
|
||||
let (reply, stats) = wait(&mut brain, id)?;
|
||||
let result = TestResult {
|
||||
load,
|
||||
first_word: stats.first_token.unwrap_or(stats.total),
|
||||
total: stats.total,
|
||||
ram_mb: runtime.memory_mb(),
|
||||
tokens_per_second: stats.tokens_per_second,
|
||||
say: reply.say,
|
||||
};
|
||||
runtime.stop();
|
||||
let _ = tx.send(Event::Tested(result));
|
||||
Ok("Tested".into())
|
||||
})
|
||||
}
|
||||
|
||||
/// "Light", "Standard"…
|
||||
pub fn tier_label(tier: Tier) -> &'static str {
|
||||
match tier {
|
||||
Tier::Light => "Light",
|
||||
Tier::Standard => "Standard",
|
||||
Tier::Strong => "Strong",
|
||||
Tier::Best => "Best",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gigabytes(bytes: u64) -> String {
|
||||
format!("{:.1} GB", bytes as f64 / 1e9)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plan_option_ids_name_their_catalogue_model() {
|
||||
for option in options() {
|
||||
let model = model_of(&option.id).expect(&option.id);
|
||||
assert_eq!(option.download, model.size());
|
||||
}
|
||||
assert!(model_of("nothing@gpu").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_machine_line_says_what_is_not_known_yet() {
|
||||
let machine = Machine {
|
||||
hardware: Hardware {
|
||||
ram_mb: 16 * 1024 - 300,
|
||||
physical_cores: 6,
|
||||
logical_cores: 12,
|
||||
apple_silicon: false,
|
||||
gpus: Vec::new(),
|
||||
},
|
||||
gpus_known: false,
|
||||
};
|
||||
assert_eq!(
|
||||
machine.summary(),
|
||||
"16 GB of memory, 6 cores; the graphics card is checked during setup"
|
||||
);
|
||||
let known = Machine {
|
||||
gpus_known: true,
|
||||
..machine
|
||||
};
|
||||
assert!(known
|
||||
.summary()
|
||||
.ends_with("no graphics card the companion can use"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
//! The launcher's core, shared by the GUI, the CLI and the MCP server.
|
||||
pub mod account;
|
||||
pub mod backend;
|
||||
pub mod companion;
|
||||
pub mod desktop;
|
||||
pub mod heads;
|
||||
pub mod modpacks;
|
||||
|
||||
+12
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
mod cli;
|
||||
mod collection;
|
||||
mod companion_card;
|
||||
mod game_tools;
|
||||
mod internet;
|
||||
mod markdown;
|
||||
@@ -157,6 +158,8 @@ struct Launcher {
|
||||
args_focus: FocusHandle,
|
||||
/// Opt-in data collection: the player's answer and Settings' section (`collection`).
|
||||
collection: collection::Collection,
|
||||
/// Settings' AI companion section (`companion_card`).
|
||||
companion: companion_card::CompanionUi,
|
||||
tx: Sender<Msg>,
|
||||
rx: Receiver<Msg>,
|
||||
_appearance: Subscription,
|
||||
@@ -271,6 +274,7 @@ impl Launcher {
|
||||
friend_focus: cx.focus_handle(),
|
||||
joining: None,
|
||||
collection: collection::Collection::load(),
|
||||
companion: companion_card::CompanionUi::new(cx),
|
||||
tx,
|
||||
rx,
|
||||
_appearance: appearance,
|
||||
@@ -402,6 +406,7 @@ impl Launcher {
|
||||
Some(perf::Step::Quit) => cx.quit(),
|
||||
Some(perf::Step::Wait) | None => {}
|
||||
}
|
||||
changed |= self.companion.poll();
|
||||
while let Ok(msg) = self.rx.try_recv() {
|
||||
changed = true;
|
||||
match msg {
|
||||
@@ -417,6 +422,9 @@ impl Launcher {
|
||||
Msg::Keychain(persistent) => self.keychain = persistent,
|
||||
Msg::Session(session) => {
|
||||
if let Some(tag) = self.starting_client.take() {
|
||||
// The game rewrites its extras file with the keys it knows: No AI again.
|
||||
let _ =
|
||||
horizon_launcher::companion::sync_no_ai(self.companion.settings.no_ai);
|
||||
let mut cmd = backend::client_command(&tag);
|
||||
match session {
|
||||
Ok(Some(file)) => {
|
||||
@@ -1982,8 +1990,10 @@ impl Launcher {
|
||||
))
|
||||
}),
|
||||
)
|
||||
.child(self.companion_section(cx))
|
||||
.child(self.collection_section(cx))
|
||||
.child(agents)
|
||||
// No AI hides every AI feature, the agents' note too.
|
||||
.when(!self.companion.settings.no_ai, |d| d.child(agents))
|
||||
}
|
||||
|
||||
/// Settings' Game arguments row: the extra arguments the game starts with, kept in
|
||||
@@ -2680,6 +2690,7 @@ impl Render for Launcher {
|
||||
self.servers.note_focus(window);
|
||||
self.friend_focused = self.friend_focus.is_focused(window);
|
||||
self.args_focused = self.args_focus.is_focused(window);
|
||||
self.companion.note_focus(window);
|
||||
let content = match self.page {
|
||||
Page::Play => self.play_page(cx),
|
||||
Page::Versions => self.versions_page(cx),
|
||||
|
||||
Reference in new issue
Block a user