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
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,28 @@

What changed for people using Mimir. The newest release is at the top; "Unreleased" is what the next one brings.

## Unreleased
## 0.8.0

### The app
- **The chat in the middle:** a new layout. The sidebar lists your bots, then your chats, with search (⌘K jumps to a
bot, a chat or a place) and new bot at the top, and Needs you, Meetings, Computer and Settings at the bottom.
- **Replies as bubbles:** one face and name per run of replies, your own messages tinted in the bot's colour, and a
time on a new day or after an hour's gap. The composer has attach and hold-to-talk beside it.
- **Everything else opens over the chat:** Memory, Skills, Routines and Activity are icons in the chat's corner and
share one sheet with tabs; Settings, Needs you, Meetings, Computer and a bot's page open the same way. Esc, × or a
click beside the sheet closes it, and the sidebar stays live meanwhile. On a phone, sheets fill the screen.
- **Settings by category:** a list of sections that follows your scroll.

### Skills
- **A fix that drops a rule says so:** when a proposed fix or merge of a skill leaves out a line with "never",
"only", "always", "unless", "don't" or "must", its card warns you and quotes the rule. It also says when the
skill is one you saved or added from the library yourself.

### AI accounts
- **What tools and memory cost:** `mimir doctor` and a bot's page (under *What it can do*) show about how many tokens
the offered tools and the standing memory take in every request, and warn when tool servers take a lot.
- **Ollama's window too small:** a bot on Ollama warns when the model's context window is smaller than Mimir's tools
and memory need, with the `OLLAMA_CONTEXT_LENGTH` to start Ollama with.

### Routines
- **What I noticed:** a new library skill for a daily quiet check: mail older than two days that asks you something,
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ members = ["crates/mimir-core", "crates/mimir", "crates/mimir-desktop"]
# The one place the version lives: every crate inherits it, and the desktop app takes it from its crate (tauri.conf.json
# has none). The release workflow checks the tag against it.
[workspace.package]
version = "0.7.0"
version = "0.8.0"

[profile.release]
strip = true
Expand Down
10 changes: 10 additions & 0 deletions crates/mimir-core/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,16 @@ mod tests {
let b = d.skill_propose(s, 1, "Pay invoices", "When paying invoices", "1. Open bank\n2. Pay", None).unwrap().unwrap();
d.skill_decide(s, b, true).unwrap();
assert!(d.skill_merge_propose(s, &[a, b], "Book padel", "x", "y", "r").is_err(), "Book padel is another active skill");
// A fix that drops a rule says so; one of a skill you saved says you chose it.
let saved = d.skill_saved_propose(s, 1, "Pay safely", "When paying", "Never pay before checking the invoice.\nOpen the bank app.", "you asked", None).unwrap().unwrap();
d.skill_decide(s, saved, true).unwrap();
let fix = d.skill_patch_propose(s, saved, "Never pay before checking the invoice.\nOpen the bank app.", "Open the bank app.", "faster").unwrap().unwrap();
let reason = d.skills(s).unwrap().into_iter().find(|k| k.id == fix).unwrap().reason;
assert!(reason.contains("drops a rule: “Never pay before checking the invoice.”") && reason.contains("You chose “Pay safely”"), "{reason}");
let merged = d.skill_merge_propose(s, &[a, b], "Pay invoices", "When paying", "1. Open bank\n2. Pay the bill", "Same job").unwrap().unwrap();
let reason = d.skills(s).unwrap().into_iter().find(|k| k.id == merged).unwrap().reason;
assert!(!reason.contains("drops a rule") && !reason.contains("You chose"), "{reason}");
assert_eq!(super::skills::dropped_rules("Don’t pay twice.\nOnly on weekdays", "Only on weekdays, mornings"), vec!["Don’t pay twice."]);

// Facts nobody looks up can leave standing memory; looking one up counts.
let e = d.store(s, "knowledge", "Old wifi hint", "the cat is called Bob", "user said", None).unwrap();
Expand Down
37 changes: 35 additions & 2 deletions crates/mimir-core/src/db/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,27 @@ impl Db {
if body.chars().count() > limit || body.trim() == k.body.trim() {
bail!("the fixed skill would be too long, or unchanged");
}
self.skill_insert(scope, k.bot_id, &k.name, &k.description, &body, None, "patch", reason, &[])
let reason = self.fix_reason(scope, std::slice::from_ref(&k), &k.body, &body, reason)?;
self.skill_insert(scope, k.bot_id, &k.name, &k.description, &body, None, "patch", &reason, &[])
}

/// A fix's reason, with what a quick approval could miss: rules it drops, and that you chose the skill yourself
/// (saved it or added it from the library, in any version of it).
fn fix_reason(&self, scope: &str, skills: &[Skill], before: &str, after: &str, reason: &str) -> Result<String> {
let mut out = reason.trim().to_string();
for rule in dropped_rules(before, after) {
out.push_str(&format!(" Warning: this fix drops a rule: “{rule}”."));
}
for k in skills {
let chosen = self
.c()
.prepare("SELECT 1 FROM skills WHERE scope=?1 AND bot_id=?2 AND lower(name)=lower(?3) AND kind IN ('saved','library') AND status IN ('active','removed')")?
.exists(params![scope, k.bot_id, k.name])?;
Comment on lines +301 to +302

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve chosen-skill provenance across renamed merges

When a saved or library skill is merged into a skill with a different name, subsequent fixes lose the “You chose” notice. skill_merge_propose allows the merged skill to have a new name and records its source IDs in replaces, but this query only checks historical rows with the current name. For example, merging a library skill “Pay safely” into “Pay invoices”, accepting it, and then patching “Pay invoices” produces no notice even though it incorporates a skill the user explicitly chose. Follow the merge ancestry (or persist the chosen provenance) so this approval context survives accepted merges.

Useful? React with 👍 / 👎.

if chosen {
out.push_str(&format!(" You chose “{}” yourself.", k.name));
}
}
Ok(out)
}

/// What this conversation actually did lately: the successful actions of the bot's last few runs here (not memory,
Expand Down Expand Up @@ -364,7 +384,9 @@ impl Db {
if self.skills_active(scope, skills[0].bot_id)?.iter().any(|k| k.name.eq_ignore_ascii_case(name.trim()) && !ids.contains(&k.id)) {
bail!("another skill already has that name");
}
self.skill_insert(scope, skills[0].bot_id, name, description, body, None, "merge", reason, ids)
let before = skills.iter().map(|k| k.body.as_str()).collect::<Vec<_>>().join("\n");
let reason = self.fix_reason(scope, &skills, &before, body, reason)?;
self.skill_insert(scope, skills[0].bot_id, name, description, body, None, "merge", &reason, ids)
}

/// Propose retiring a skill nobody has used for a while (it's kept, and can come back).
Expand Down Expand Up @@ -423,3 +445,14 @@ impl Db {
Ok(self.c().execute("UPDATE skills SET status='removed' WHERE id=?1 AND scope=?2 AND status='active'", params![id, scope])? > 0)
}
}

/// Rule lines of `before` ("never", "only", "always", "unless", "don't", "do not", "must") that `after` no longer has.
/// A line kept inside a longer one ("Never pay" → "Never pay twice") isn't dropped.
pub fn dropped_rules(before: &str, after: &str) -> Vec<String> {
let rule = |line: &str| {
let lower = line.to_lowercase().replace('’', "'");
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric() && c != '\'').collect();
words.iter().any(|w| matches!(*w, "never" | "only" | "always" | "unless" | "don't" | "must")) || words.windows(2).any(|p| p == ["do", "not"])
};
before.lines().map(str::trim).filter(|l| !l.is_empty() && rule(l) && !after.contains(*l)).map(str::to_string).collect()
}
Loading