Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions crates/mimir-core/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ pub async fn run(home: &Path) -> Vec<Check> {
}
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);
}
Expand Down Expand Up @@ -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<String>) {
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<String> = 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<Check> {
async fn within(f: impl std::future::Future<Output = anyhow::Result<String>>) -> anyhow::Result<String> {
Expand Down Expand Up @@ -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);
}
}
6 changes: 6 additions & 0 deletions crates/mimir-core/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
29 changes: 29 additions & 0 deletions crates/mimir-core/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
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<u64> {
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)> {
Expand Down Expand Up @@ -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");
Expand Down
8 changes: 8 additions & 0 deletions crates/mimir-core/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
64 changes: 40 additions & 24 deletions crates/mimir-core/src/tools/specs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<minutes before>, 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:<rule> instead: from:<address or domain>, \
subject:<word or \"phrase\">, 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:#<channel> with optional from:@<person> 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:<minutes before>[:meetings|:outside] ('calendar:60:outside': \
an hour before each meeting with someone from another company); mail:<rule> for \"tell me when X arrives\" \
(from:<address or domain>, subject:<word or \"phrase\">, other words it must contain, a|b for either; e.g. \
'mail:from:acme.com subject:invoice|quote'); slack:#<channel> [from:@<person>] [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":{}})),
(
Expand Down Expand Up @@ -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::<String, usize>::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]
Expand Down
5 changes: 5 additions & 0 deletions docs/ai-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions ui/src/views/bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComputerStatus>('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.
Expand Down Expand Up @@ -337,6 +339,8 @@ export function BotPage({ bot, status, canDelete, onSaved, onDeleted, onChat, on
<Row key={tool} title={label} desc={enabled.includes(tool) ? effective(tool) : 'off for this bot'}><Switch label={label} checked={enabled.includes(tool)} onChange={() => toggle(tool)} /></Row>
))}
</div>
{context.data && <div className="row-block"><span className="hint">{context.data.line[0].toUpperCase() + context.data.line.slice(1)}, as of the last save.</span>
{context.data.warnings.map(w => <p key={w} className="error">{w}</p>)}</div>}
</Group>

<Group title="Working folder" note={<>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.</>}>
Expand Down
Loading