mindfork 0.11.0

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
//! The one-time backfill of a profile's language-model history (spec §9.14).
//!
//! The history is written forward by the recorder in
//! [`generation`](super::generation) — every landed exchange whose model name
//! is known appends a record when the pair (name, mode) differs from the newest
//! one. That recorder only exists from the release that introduced it, so a
//! profile that has been talking to models for months starts with an **empty**
//! history and stays empty until its next exchange: the tool answers "nothing
//! recorded yet" about a past that is, in fact, written down — every assistant
//! reply already carries the model it was generated by in
//! [`MessageMetadata`](crate::entities::message::MessageMetadata).
//!
//! So at startup, a profile whose history has **no records at all** gets it
//! seeded from that metadata, once ([`Orchestrator::seed_llm_history`]). The
//! rule is a single sentence, and [`derive_llm_history`] is its whole
//! implementation:
//!
//! > the seed is what the recorder would have written, had it existed when
//! > those replies were generated.
//!
//! Which is why the records are ordered by the replies' own timestamps across
//! the profile's chats (the recorder ran per exchange, in time order — chats
//! are not each other's future), carry those timestamps as `changed_at` (a
//! backfill dated "now" would say nothing), and collapse consecutive runs of
//! the same (name, mode) exactly like [`Db::llm_history_note`]'s dedup does.
//!
//! Boundaries, all following from the same sentence:
//!
//! - **one-time, without a flag**: the seed only fills a history with no
//!   records (checked inside the writing transaction —
//!   [`Db::llm_history_seed`]), so the first record ever written, seeded or
//!   recorded, closes the door;
//! - **nothing to derive is not a failure**: replies that predate
//!   `MessageMetadata.model`, or an engine that never named a model, seed
//!   nothing and leave the profile seedable — the scan runs over chats already
//!   in memory, so retrying it next launch costs nothing worth a marker row;
//! - **only the chats the app can see**: soft-deleted ones are gone from every
//!   other read path (spec §5.3), and this is not the place to make an
//!   exception;
//! - **only the parent conversation**: a sub-agent transcript's replies run on
//!   the parent turn's engine (docs/research/language-model-history.md §8), and
//!   they live on a tool-call record rather than in `Chat::messages`, so the
//!   scan skips them by construction.
//!
//! [`Db::llm_history_note`]: crate::shared::storage::Db::llm_history_note
//! [`Db::llm_history_seed`]: crate::shared::storage::Db::llm_history_seed

use uuid::Uuid;

use super::Orchestrator;
use crate::entities::chat::Chat;
use crate::entities::message::MessageRole;
use crate::entities::profile::LlmChange;

impl Orchestrator {
    /// Seeds the language-model history of every loaded profile whose history
    /// is still empty, from the metadata its chats already carry (spec §9.14).
    /// Called once, from `bootstrap`, before anything can record a first
    /// record of its own.
    ///
    /// Best-effort, like the recorder it back-dates for: a profile whose
    /// database call fails is logged and skipped — a history is bookkeeping,
    /// and no part of startup may hang on it.
    pub(super) fn seed_llm_history(&self) {
        for profile in &self.profiles {
            // The cheap question first: a history with records is not seeded,
            // and asking it here is what keeps the scan below off every
            // launch after the first.
            match self.storage.db().llm_history(profile.id) {
                Ok(existing) if !existing.is_empty() => continue,
                Ok(_) => {}
                Err(err) => {
                    tracing::warn!(error = %err, profile = %profile.id,
                        "failed to read the language-model history — not seeding it");
                    continue;
                }
            }
            let records = derive_llm_history(profile.id, &self.chats);
            if records.is_empty() {
                continue;
            }
            match self.storage.db().llm_history_seed(profile.id, &records) {
                Ok(0) => {}
                Ok(n) => tracing::info!(profile = %profile.id, records = n,
                    "seeded the language-model history from the chats' stored metadata"),
                Err(err) => tracing::warn!(error = %err, profile = %profile.id,
                    "failed to seed the language-model history"),
            }
        }
    }
}

/// The history the recorder would have written for `profile_id`, derived from
/// the assistant replies stored in `chats` (see the module docs): the replies
/// that name a model, in timestamp order across the profile's chats, with
/// consecutive runs of the same (name, mode) collapsed onto the first reply of
/// the run — which is when that model started answering.
fn derive_llm_history(profile_id: Uuid, chats: &[Chat]) -> Vec<LlmChange> {
    let mut records: Vec<LlmChange> = chats
        .iter()
        .filter(|c| c.profile_id == profile_id)
        .flat_map(|c| c.messages.iter())
        .filter(|m| m.role == MessageRole::Assistant)
        .filter_map(|m| {
            let md = m.metadata.as_ref()?;
            Some(LlmChange {
                changed_at: m.timestamp,
                model: md.model.clone()?,
                mode: md.mode,
            })
        })
        .collect();
    // Stable, so replies sharing a timestamp keep the order they were read in
    // (within a chat — the order they were written in). Sub-second precision
    // makes a tie across two chats a curiosity, not a case to design for.
    records.sort_by_key(|r| r.changed_at);
    records.dedup_by(|a, b| a.model == b.model && a.mode == b.mode);
    records
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::entities::message::{Message, MessageMetadata};
    use crate::entities::profile::Profile;
    use crate::entities::sampling::SamplingConfig;
    use crate::shared::config::ServerMode;
    use chrono::{DateTime, Utc};

    /// An assistant reply dated `ts`, carrying the metadata `finalize_message`
    /// writes; `model: None` is a reply whose engine named no model.
    fn reply(ts: &str, model: Option<&str>, mode: ServerMode) -> Message {
        let mut msg = Message::assistant("ответ");
        msg.timestamp = ts.parse::<DateTime<Utc>>().unwrap();
        msg.metadata = Some(MessageMetadata {
            sampling: SamplingConfig::default(),
            mode,
            model: model.map(Into::into),
            finish: None,
        });
        msg
    }

    /// A user message dated `ts` — metadata-free, like every stored user turn.
    fn ask(ts: &str) -> Message {
        let mut msg = Message::user("вопрос");
        msg.timestamp = ts.parse::<DateTime<Utc>>().unwrap();
        msg
    }

    fn chat_of(profile: &Profile, messages: Vec<Message>) -> Chat {
        let mut chat = Chat::from_profile(profile, "t");
        for msg in messages {
            chat.push_message(msg);
        }
        chat
    }

    /// `(date, model, mode)` triples, the shape assertions read in.
    fn triples(records: &[LlmChange]) -> Vec<(String, &str, ServerMode)> {
        records
            .iter()
            .map(|r| {
                (
                    r.changed_at.format("%Y-%m-%d").to_string(),
                    r.model.as_str(),
                    r.mode,
                )
            })
            .collect()
    }

    #[test]
    fn derives_the_sequence_the_recorder_would_have_written() {
        let profile = Profile::new("P", "sys");
        // Two chats of one profile, alternating in time: the history is the
        // profile's, so the two interleave — and each record is dated by the
        // reply that starts its run, not by the seed.
        let a = chat_of(
            &profile,
            vec![
                ask("2026-01-01T10:00:00Z"),
                reply("2026-01-01T10:00:01Z", Some("gemma-4"), ServerMode::Managed),
                // The same model again — one round of an agentic turn and a
                // later exchange alike: no second record.
                reply("2026-01-01T10:00:02Z", Some("gemma-4"), ServerMode::Managed),
                reply("2026-03-01T10:00:00Z", Some("gemma-4"), ServerMode::Managed),
            ],
        );
        let b = chat_of(
            &profile,
            vec![
                reply(
                    "2026-02-01T10:00:00Z",
                    Some("qwen-3.6"),
                    ServerMode::Managed,
                ),
                // The same name through another mode is a change too — the
                // stored mode must not go stale (fork F4).
                reply(
                    "2026-02-02T10:00:00Z",
                    Some("qwen-3.6"),
                    ServerMode::External,
                ),
            ],
        );

        let records = derive_llm_history(profile.id, &[a, b]);
        assert_eq!(
            triples(&records),
            [
                ("2026-01-01".into(), "gemma-4", ServerMode::Managed),
                ("2026-02-01".into(), "qwen-3.6", ServerMode::Managed),
                ("2026-02-02".into(), "qwen-3.6", ServerMode::External),
                // …and back to gemma-4: A→B→A is three records, because that
                // is what happened (the dedup is against the previous record,
                // not against "ever seen").
                ("2026-03-01".into(), "gemma-4", ServerMode::Managed),
            ]
        );
    }

    #[test]
    fn skips_replies_that_name_no_model_and_messages_that_are_not_replies() {
        let profile = Profile::new("P", "sys");
        let mut user = ask("2026-01-01T09:00:00Z");
        // A user message could only carry metadata by accident — and even then
        // it is not evidence of a model having answered.
        user.metadata = Some(MessageMetadata {
            sampling: SamplingConfig::default(),
            mode: ServerMode::OpenAi,
            model: Some("not-a-reply".into()),
            finish: None,
        });
        let chat = chat_of(
            &profile,
            vec![
                user,
                // A reply from before `MessageMetadata` existed at all.
                Message::assistant("старый ответ"),
                // …and one whose engine did not report a name (spec §9.14).
                reply("2026-01-01T10:00:00Z", None, ServerMode::External),
                reply("2026-01-02T10:00:00Z", Some("gemma-4"), ServerMode::Managed),
            ],
        );

        let records = derive_llm_history(profile.id, &[chat]);
        assert_eq!(
            triples(&records),
            [("2026-01-02".into(), "gemma-4", ServerMode::Managed)]
        );
    }

    #[test]
    fn reads_only_the_profiles_own_chats() {
        let (mine, theirs) = (Profile::new("A", "sys"), Profile::new("B", "sys"));
        let chats = [
            chat_of(
                &mine,
                vec![reply(
                    "2026-01-01T10:00:00Z",
                    Some("mine"),
                    ServerMode::Managed,
                )],
            ),
            chat_of(
                &theirs,
                vec![reply(
                    "2026-01-02T10:00:00Z",
                    Some("theirs"),
                    ServerMode::Managed,
                )],
            ),
        ];

        // Per-profile isolation is the invariant every organ carries (spec
        // §9.5) — a shared engine does not make one profile's history the
        // other's.
        assert_eq!(
            derive_llm_history(mine.id, &chats)
                .iter()
                .map(|r| r.model.as_str())
                .collect::<Vec<_>>(),
            ["mine"]
        );
        assert_eq!(
            derive_llm_history(theirs.id, &chats)
                .iter()
                .map(|r| r.model.as_str())
                .collect::<Vec<_>>(),
            ["theirs"]
        );
        assert!(derive_llm_history(Uuid::new_v4(), &chats).is_empty());
    }

    #[test]
    fn a_sub_agent_transcripts_replies_are_not_the_parents_history() {
        // A transcript lives on the tool-call record, not in `Chat::messages`,
        // and runs on the parent turn's engine — so it adds nothing of its own
        // (docs/research/language-model-history.md §8). Pinned here because the
        // scan skips it by construction: a future scan of tool-call records
        // would have to make this decision deliberately.
        let profile = Profile::new("P", "sys");
        let mut run = crate::entities::subagent::SubagentRun::fixture("t", &["вопрос", "ответ"]);
        run.messages = vec![reply(
            "2026-01-01T09:00:00Z",
            Some("nested"),
            ServerMode::Grok,
        )];
        let mut parent = reply("2026-01-01T10:00:00Z", Some("gemma-4"), ServerMode::Managed);
        parent.tool_calls.push(run.on_record());

        let records = derive_llm_history(profile.id, &[chat_of(&profile, vec![parent])]);
        assert_eq!(
            records.iter().map(|r| r.model.as_str()).collect::<Vec<_>>(),
            ["gemma-4"]
        );
    }
}