diff --git a/crates/mimir-core/src/doctor.rs b/crates/mimir-core/src/doctor.rs index 9e10a73..0db62bb 100644 --- a/crates/mimir-core/src/doctor.rs +++ b/crates/mimir-core/src/doctor.rs @@ -74,6 +74,10 @@ pub async fn run(home: &Path) -> Vec { } out.push(account(&b.name, &b.provider, &seen[&b.provider])); } + for b in &bots { + let (line, warnings) = context(&db, b).await; + out.push(if warnings.is_empty() { ok(format!("{}: {line}", b.name)) } else { bad(State::Warn, format!("{}: {line}", b.name), warnings.join("\n ")) }); + } for b in &bots { out.extend(channels(&b.name, crate::host::channels_of(b.id)).await); } @@ -223,6 +227,46 @@ fn account(bot: &str, kind: &str, v: &serde_json::Value) -> Check { } } +/// How much of every turn's context a bot's tools and standing memory take, and what's wrong with that: tools past +/// about 8k tokens, or a local model whose window they (nearly) fill. For `mimir doctor` and the bot page. +pub async fn context(db: &Db, bot: &crate::db::Bot) -> (String, Vec) { + let ctx = crate::tools::ToolCtx::new(db.clone(), bot.id, crate::host::LOCAL_CHAT); + let (count, tools, servers) = crate::tools::weight(&ctx); + // The standing memory this bot's prompt gets (as in lib.rs): this chat's, and the household's when it's in one. + let mut scopes = vec![ctx.scope()]; + if db.in_household(ctx.chat_id) { + scopes.push(crate::HOUSEHOLD.to_string()); + } + let chars = scopes.iter().flat_map(|s| db.bootstrap(s, bot.id).unwrap_or_default()).map(|e| e.line().len()).sum(); + let memory = crate::tools::tokens(chars); + let line = format!("tools offered: {count}, about {}; standing memory about {}", k(tools), k(memory)); + let mut warnings = vec![]; + if tools > 8000 { + let big: Vec = servers.iter().take(3).map(|(s, t)| format!("{s} ({})", k(*t))).collect(); + let big = if big.is_empty() { String::new() } else { format!(" The biggest tool servers: {}.", big.join(", ")) }; + warnings.push(format!("That's a lot of tools for every message.{big} Switch off what it doesn't need under What it can do.")); + } + if bot.provider == "local" { + let base = Some(bot.base_url.trim()).filter(|b| !b.is_empty()).unwrap_or(crate::provider::OLLAMA); + // Leave about 4k for its instructions and the conversation itself. + let need = tools + memory + 4096; + if let Some(window) = crate::provider::ollama_context(base, &bot.model).await.filter(|w| (*w as usize) < need) { + let want = need.next_power_of_two().max(16384); + warnings.push(format!( + "Ollama is set to {window} tokens for {}; Mimir's tools and memory take about {} of that before the conversation starts. Start Ollama with OLLAMA_CONTEXT_LENGTH={want}, or switch off tools it doesn't need.", + bot.model, + k(tools + memory) + )); + } + } + (line, warnings) +} + +/// Tokens as "3.7k tokens". +fn k(tokens: usize) -> String { + format!("{:.1}k tokens", tokens as f64 / 1000.0) +} + /// Each connected channel answers with its token (who the bot is there). async fn channels(bot: &str, ch: crate::runtime::Channels) -> Vec { async fn within(f: impl std::future::Future>) -> anyhow::Result { @@ -334,4 +378,29 @@ mod tests { assert_eq!(account("Ava", "claude", &v(r#"{"installed":true,"signed_in":null}"#)).state, State::Warn); assert_eq!(account("Ava", "openai", &v(r#"{"installed":true,"signed_in":true}"#)).state, State::Ok); } + + #[tokio::test] + async fn context_counts_tools_and_memory() { + let p = std::env::temp_dir().join(format!("mimir-doctor-context-{}.db", std::process::id())); + let _ = std::fs::remove_file(&p); + let db = Db::open(&p).unwrap(); + // A local bot whose Ollama doesn't answer: no window known, so no warning about it. + let bot = serde_json::json!({"id":0,"name":"Ava","persona":"","provider":"local","model":"qwen3","base_url":"http://127.0.0.1:9/v1","tools":""}); + let id = db.bot_save(&serde_json::from_value(bot).unwrap()).unwrap(); + let (line, warnings) = context(&db, &db.bot(id).unwrap()).await; + assert!(line.starts_with("tools offered: ") && line.contains("k tokens; standing memory about 0.0k tokens"), "{line}"); + assert!(warnings.is_empty(), "{warnings:?}"); + // Another bot's own memory isn't in this bot's prompt, so it isn't counted. + let m = db.store("chat:0", "knowledge", "Theirs", &"x".repeat(400), "", None).unwrap(); + assert!(db.set_for_bot("chat:0", m, id + 1).unwrap()); + assert_eq!(context(&db, &db.bot(id).unwrap()).await.0, line); + assert!(db.set_for_bot("chat:0", m, id).unwrap()); + assert!(context(&db, &db.bot(id).unwrap()).await.0.contains("standing memory about 0.1k tokens")); + // Only a few tools: fewer tokens. + let few = serde_json::json!({"id":id,"name":"Ava","persona":"","provider":"local","model":"qwen3","base_url":"http://127.0.0.1:9/v1","tools":"web_fetch"}); + db.bot_save(&serde_json::from_value(few).unwrap()).unwrap(); + let (fewer, _) = context(&db, &db.bot(id).unwrap()).await; + assert!(fewer.starts_with("tools offered: 1, about 0."), "{fewer}"); + let _ = std::fs::remove_file(&p); + } } diff --git a/crates/mimir-core/src/host.rs b/crates/mimir-core/src/host.rs index e1acbe2..1a6b9f3 100644 --- a/crates/mimir-core/src/host.rs +++ b/crates/mimir-core/src/host.rs @@ -447,6 +447,12 @@ impl Host { let (reply, secs) = boxed(|| p.check(&self.db)).await?; ok(json!({"reply": crate::tools::truncate(&reply, 200), "seconds": (secs * 10.0).round() / 10.0, "provider": p.name()})) } + // How much of each turn its tools and standing memory take, with any warning (the bot page). + "bot_context" => { + let bot = self.db.bot(arg(a, "bot_id")?)?; + let (line, warnings) = boxed(|| crate::doctor::context(&self.db, &bot)).await; + ok(json!({"line": line, "warnings": warnings})) + } "statuses" => { // A bot another bot made (bot_create, in the AI CLI's own tool process) starts here, so its routines run. // Not while this app's bots are switched off (it shows a server's). diff --git a/crates/mimir-core/src/provider.rs b/crates/mimir-core/src/provider.rs index daa363d..8dbdf86 100644 --- a/crates/mimir-core/src/provider.rs +++ b/crates/mimir-core/src/provider.rs @@ -1013,6 +1013,29 @@ async fn ollama_tools(http: &reqwest::Client, base: &str, model: &str) -> Option Some(v["capabilities"].as_array()?.iter().any(|c| c == "tools")) } +/// The context window Ollama gives `model`, in tokens: what it's loaded with now (`/api/ps`), else what its Modelfile +/// sets (`num_ctx`). None when it can't say: not Ollama, or not loaded and not set (Ollama then picks by GPU memory, +/// 4k under 24 GB, or OLLAMA_CONTEXT_LENGTH). +pub async fn ollama_context(base: &str, model: &str) -> Option { + let root = base.trim_end_matches('/').strip_suffix("/v1")?; + let http = reqwest::Client::builder().timeout(Duration::from_secs(2)).build().ok()?; + let same = |n: &str| n == model || n.strip_suffix(":latest") == Some(model); + if let Ok(r) = http.get(format!("{root}/api/ps")).send().await { + let ps: Value = r.json().await.unwrap_or_default(); + let loaded = ps["models"].as_array().into_iter().flatten().find(|m| m["name"].as_str().is_some_and(same) || m["model"].as_str().is_some_and(same)); + if let Some(n) = loaded.and_then(|m| m["context_length"].as_u64()) { + return Some(n); + } + } + let show: Value = http.post(format!("{root}/api/show")).json(&json!({"model": model})).send().await.ok()?.json().await.ok()?; + num_ctx(show["parameters"].as_str()?) +} + +/// `num_ctx` from `/api/show`'s parameters ("num_ctx 8192\nstop ..."). +fn num_ctx(params: &str) -> Option { + params.lines().find_map(|l| l.trim().strip_prefix("num_ctx")?.trim().parse().ok()) +} + impl Provider { /// One tiny real round-trip, for "Test" buttons. Returns (reply, seconds). pub async fn check(&self, db: &crate::db::Db) -> Result<(String, f64)> { @@ -1588,6 +1611,12 @@ mod tests { use super::*; + #[test] + fn ollama_num_ctx() { + assert_eq!(num_ctx("stop \"<|im_end|>\"\nnum_ctx 8192\ntemperature 0.7"), Some(8192)); + assert_eq!(num_ctx("temperature 0.7"), None); + } + #[test] fn api_accounts() { let home = PathBuf::from("/tmp"); diff --git a/crates/mimir-core/src/tools/mod.rs b/crates/mimir-core/src/tools/mod.rs index c481edb..6f8842d 100644 --- a/crates/mimir-core/src/tools/mod.rs +++ b/crates/mimir-core/src/tools/mod.rs @@ -645,6 +645,14 @@ pub(crate) fn looks_secret(db: &Db, text: &str) -> Option<&'static str> { #[cfg(test)] mod tests { + /// Every built-in tool's definition goes to the model each turn (a local model's whole context can be 4k tokens): + /// growing past this is a choice, made by raising it here. + #[test] + fn builtin_tools_stay_within_budget() { + let size = serde_json::to_string(&super::specs()).unwrap().len(); + assert!(size < 28_000, "the built-in tools' definitions are {size} characters, over the 28,000 budget"); + } + #[test] fn needs_you_suggests_a_rule_after_five_plain_allows_and_stops_when_dismissed() { let p = std::env::temp_dir().join(format!("mimir-rule-hints-{}.db", std::process::id())); diff --git a/crates/mimir-core/src/tools/specs.rs b/crates/mimir-core/src/tools/specs.rs index aa0139b..4a95fc0 100644 --- a/crates/mimir-core/src/tools/specs.rs +++ b/crates/mimir-core/src/tools/specs.rs @@ -246,30 +246,24 @@ pub fn specs() -> Vec<(&'static str, &'static str, Value)> { ( "routine_create", "Schedule a task: recurring with cron (5-field, the user's local time, e.g. '0 8 * * Mon-Fri'), or once with at \ - ('2026-10-12 15:00', the user's local time) for reminders and anything for later; a one-off is gone after it runs. \ - For something tied to the user's calendar, cron can be calendar:, optionally :meetings (only \ - events with someone else invited) or :outside (only ones with someone from another company): \ - 'calendar:60:outside' runs an hour before each meeting with someone outside, and each run is told the event. \ - For \"tell me when X arrives\" by email, cron can be mail: instead: from:
, \ - subject:, and any other words the email must contain, e.g. 'mail:from:acme.com subject:invoice'; \ - a|b is either one (subject:invoice|quote). \ - Mimir checks new mail every 2 minutes itself and runs you only for a match, with that email. It ends after the \ - first match unless keep=true (\"every time…\"). For a message in a Slack channel you're in, cron can be \ - slack:# with optional from:@ and words the message must contain, e.g. 'slack:#deploys failed'; \ - it works the same way (each run is told the message). \ - prompt is what you will be asked to do each time it fires; the result is sent to this chat. \ - Set quiet=true for watches/check-ins (\"keep an eye on…\", \"only tell me if…\"): it runs read-only and \ - messages the user only when something needs their attention. A quiet check about one web page or feed, or \ - about new email, should set watch (the page's address, or \"mail\"): Mimir then looks itself before each \ - run and only runs you when it changed, with what changed, which costs far less. \ - For someone else in the household (\"remind Sam at 6 to…\"), set for to their name: it runs in their own chat \ - (write prompt as what to tell them), and the first time for each person the user is asked. \ - A repeating one with an end (\"every morning until the 30th\", \"for two weeks\") sets until: after its last run \ - before then, it's gone. \ - When you tell the person you'll check back on something yourself (\"I'll check Friday whether they replied\"), \ - book it: at, with followup=true, and prompt as what to check and tell them. Say in your reply when, and that \ - they can cancel it. It counts like any routine.", - json!({"type":"object","properties":{"until":{"type":"string","description":"YYYY-MM-DD (through that day) or YYYY-MM-DD HH:MM: when a repeating routine ends"},"followup":{"type":"boolean","description":"with at: a check-back you booked on your own, not one the person asked for"},"cron":{"type":"string"},"at":{"type":"string","description":"YYYY-MM-DD HH:MM, for a one-off instead of cron"},"prompt":{"type":"string","description":"up to 1000 characters"},"quiet":{"type":"boolean"},"watch":{"type":"string","description":"quiet checks only: a page or feed URL, or \"mail\""},"keep":{"type":"boolean","description":"mail: and slack: only: run for every match, not just the first"},"for":{"type":"string","description":"a household member's name, to send it to their chat instead of this one"}},"required":["prompt"]}), + for reminders and anything for later (gone after it runs). prompt is what you'll be asked each time; the result \ + comes to this chat. cron can also be an event: calendar:[:meetings|:outside] ('calendar:60:outside': \ + an hour before each meeting with someone from another company); mail: for \"tell me when X arrives\" \ + (from:
, subject:, other words it must contain, a|b for either; e.g. \ + 'mail:from:acme.com subject:invoice|quote'); slack:# [from:@] [words]. Mimir watches for these \ + itself and runs you only on a match, told what matched. quiet=true for watches and check-ins (\"only tell me \ + if…\"): runs read-only and messages only when something needs attention.", + json!({"type":"object","properties":{ + "prompt":{"type":"string","description":"up to 1000 characters"}, + "cron":{"type":"string"}, + "at":{"type":"string","description":"YYYY-MM-DD HH:MM, for a one-off instead of cron"}, + "quiet":{"type":"boolean"}, + "watch":{"type":"string","description":"quiet, about one page/feed URL or \"mail\": runs only when it changed (far cheaper)"}, + "keep":{"type":"boolean","description":"mail:/slack: run on every match, not just the first"}, + "for":{"type":"string","description":"a household member's name: runs in their chat (prompt = what to tell them)"}, + "until":{"type":"string","description":"YYYY-MM-DD or YYYY-MM-DD HH:MM: a repeating one ends then"}, + "followup":{"type":"boolean","description":"with at: a check-back you promised (\"I'll check Friday\"); say when, and that they can cancel"} + },"required":["prompt"]}), ), ("routine_list", "List this chat's routines.", json!({"type":"object","properties":{}})), ( @@ -499,6 +493,28 @@ pub fn specs_for(ctx: &ToolCtx) -> Vec<(String, String, Value)> { all_specs(&ctx.db).into_iter().filter(|(n, ..)| bot.as_ref().is_none_or(|b| b.allows(n)) && usable(ctx, n)).collect() } +/// About how many tokens a text takes: ~4 characters each. +pub fn tokens(chars: usize) -> usize { + chars.div_ceil(4) +} + +/// What the tools offered to this bot take of the context: (how many, about how many tokens, each tool server's share +/// as (server, tokens), biggest first). +pub fn weight(ctx: &ToolCtx) -> (usize, usize, Vec<(String, usize)>) { + let tools = specs_for(ctx); + let (mut total, mut servers) = (0, std::collections::BTreeMap::::new()); + for (n, d, s) in &tools { + let t = tokens(n.len() + d.len() + s.to_string().len()); + total += t; + if let Some((server, _)) = n.split_once("__") { + *servers.entry(server.to_string()).or_default() += t; + } + } + let mut servers: Vec<_> = servers.into_iter().collect(); + servers.sort_by_key(|s| std::cmp::Reverse(s.1)); + (tools.len(), total, servers) +} + #[cfg(test)] mod tests { #[test] diff --git a/docs/ai-accounts.md b/docs/ai-accounts.md index 3c5155d..5943f41 100644 --- a/docs/ai-accounts.md +++ b/docs/ai-accounts.md @@ -100,6 +100,11 @@ The app finds a running Ollama or LM Studio and lists its models. Models that ca chat but can't remember things or run routines. Ollama's default 4k-token context drops older messages in a long conversation (instructions and memory are kept); start it with `OLLAMA_CONTEXT_LENGTH=16384` for more. +Mimir's tools and standing memory go with every message: the bot page (under What it can do) and `mimir doctor` +show about how many tokens they take. On an Ollama bot they also warn when Ollama's window, as `/api/ps` or the +model's `num_ctx` reports it, leaves too little for the conversation. Switching off tools a bot doesn't need makes +room too. + ## What a run costs The run log shows each reply's model, tokens and cost. For Claude it's the API list-price equivalent that `claude -p` diff --git a/ui/src/views/bot.tsx b/ui/src/views/bot.tsx index 2351ca3..e44353f 100644 --- a/ui/src/views/bot.tsx +++ b/ui/src/views/bot.tsx @@ -165,6 +165,8 @@ export function BotPage({ bot, status, canDelete, onSaved, onDeleted, onChat, on invoke('set_hands_off', { botId: bot!.id, on }).then(handsOff.reload, e => setErr(String(e))) } const computer = useLoad(() => invoke('computer'), [], 60000) + // Reloads when the saved bot changes (its tools or model), not as the form is edited. + const context = useLoad(() => bot ? invoke<{ line: string; warnings: string[] }>('bot_context', { botId: bot.id }) : Promise.resolve(null), [bot?.id, bot?.tools, bot?.model, bot?.base_url, bot?.provider], 60000) const serverTools: [string, string][] = (toolServers.data?.servers ?? []).filter(s => !s.preset).map(s => [`${s.name}__*`, `Use ${s.name}`]) const ALL = [...Object.keys(TOOL_LABEL), 'browser__*', ...serverTools.map(([t]) => t)] // What each tool does for this bot once it's on: Settings → Permissions (the same for every bot), defaults included. @@ -337,6 +339,8 @@ export function BotPage({ bot, status, canDelete, onSaved, onDeleted, onChat, on toggle(tool)} /> ))} + {context.data &&
{context.data.line[0].toUpperCase() + context.data.line.slice(1)}, as of the last save. + {context.data.warnings.map(w =>

{w}

)}
} Where its files go (file_list, file_read, file_write), on {pickHere ? 'this computer' : folder.data?.machine ?? 'the machine Mimir runs on'}. Pick a folder of yours to see and edit them in {platform === 'mac' ? 'Finder' : 'your file manager'}; paths stay inside it (no ../, no links out). Empty: Mimir's own workspace for each chat.}>