Skip to main content

nexus_core/app/
compaction.rs

1// Casts here are on bounded values: token counts, byte sizes, and
2// selection indices — never on unbounded input. JSON-derived indices in
3// provider/tools go through try_from instead.
4#![allow(
5    clippy::cast_possible_truncation,
6    clippy::cast_possible_wrap,
7    clippy::cast_precision_loss,
8    clippy::cast_sign_loss
9)]
10use anyhow::Result;
11use tokio::sync::mpsc;
12
13use super::{App, ContextBreakdown};
14use crate::db::Message;
15use crate::provider::{ChatMessage, ChatParams};
16
17impl App {
18    // --- auto-compaction ---
19
20    /// Rows that must never reach a model — neither in the raw history
21    /// (`build_history`) nor in a compaction digest: background-job scratch
22    /// (research stage/plan/survey rows), transport failures, session links,
23    /// per-persona swarm round replies (the turn's synthesis carries the
24    /// context), gate replies — the survey/plan sections they answer are
25    /// excluded too, so a bare "drop Q2" must not leak into later turns via
26    /// a digest — and the compaction digest row itself (the digest is
27    /// already fed to the model via `compact_summary`, so a transcript row
28    /// must never be sent twice).
29    pub fn excluded_from_model_history(m: &Message) -> bool {
30        m.role == "compaction"
31            || m.role == "research_stage"
32            || m.role == "research_plan"
33            || m.role == "survey"
34            || m.role == "gate_reply"
35            || m.role == "session_link"
36            || m.role == "error"
37            || m.persona.is_some()
38    }
39
40    /// The messages actually sent on the next turn: everything after the
41    /// session's compaction boundary, or all of them if it hasn't compacted
42    /// (yet). The full, uncompacted history stays in `self.messages`/the db
43    /// for scrollback — only what's sent shrinks.
44    pub fn effective_messages(&self) -> &[Message] {
45        let through = self
46            .session
47            .as_ref()
48            .and_then(|s| usize::try_from(s.compact_through).ok())
49            .unwrap_or(0)
50            .min(self.messages.len());
51        &self.messages[through..]
52    }
53
54    /// Whether `id` is the session whose background compaction is still running.
55    #[must_use]
56    pub fn is_compacting_session(&self, id: &str) -> bool {
57        self.compact_rx.is_some() && self.compacting_session_id.as_deref() == Some(id)
58    }
59
60    /// Whether the session currently on screen is being compacted.
61    #[must_use]
62    pub fn is_compacting_current_session(&self) -> bool {
63        self.session
64            .as_ref()
65            .is_some_and(|s| self.is_compacting_session(&s.id))
66    }
67
68    /// After a reply, auto-compact once context usage crosses the configured
69    /// threshold (0 disables it).
70    pub fn maybe_compact(&mut self) {
71        if self.settings.compact_threshold == 0 || self.compact_rx.is_some() || self.is_streaming()
72        {
73            return;
74        }
75        let Some(limit) = self.context_limit() else {
76            return;
77        };
78        let used = self.context_used();
79        let pct = used
80            .checked_mul(100)
81            .and_then(|v| v.checked_div(limit))
82            .unwrap_or(0);
83        if pct < u64::from(self.settings.compact_threshold) {
84            return;
85        }
86        self.start_compaction(pct);
87    }
88
89    /// Manually trigger compaction right now (`/compact`), ignoring the
90    /// threshold. No-ops with a status message if there's nothing to compact.
91    pub fn force_compact(&mut self) {
92        if self.compact_rx.is_some() {
93            self.push_status("already compacting…".to_string());
94            return;
95        }
96        if self.is_streaming() {
97            self.push_status("wait for the current response to finish".to_string());
98            return;
99        }
100        let Some(session) = self.session.as_ref() else {
101            self.push_status("no active session to compact".to_string());
102            return;
103        };
104        let through = usize::try_from(session.compact_through)
105            .unwrap_or(0)
106            .min(self.messages.len());
107        if compaction_tail(&self.messages, through).trim().is_empty() {
108            self.push_status("nothing new to compact".to_string());
109            return;
110        }
111        let pct = self.context_limit().filter(|&l| l > 0).map_or(0, |l| {
112            self.context_used()
113                .checked_mul(100)
114                .and_then(|v| v.checked_div(l))
115                .unwrap_or(0)
116        });
117        self.start_compaction(pct);
118    }
119
120    /// Kick off the background compaction job on the memory model (falling
121    /// back to the session model), same pattern as memory extraction.
122    /// `before_pct` is only used to report the before/after status on completion.
123    fn start_compaction(&mut self, before_pct: u64) {
124        let (session_id, through, prior_summary) = {
125            let Some(session) = self.session.as_ref() else {
126                return;
127            };
128            let through = usize::try_from(session.compact_through)
129                .unwrap_or(0)
130                .min(self.messages.len());
131            (session.id.clone(), through, session.compact_summary.clone())
132        };
133        let tail = compaction_tail(&self.messages, through);
134        if tail.trim().is_empty() {
135            return; // only the existing digest or excluded UI rows remain
136        }
137
138        // A saved utility-model id may belong to a backend that is no longer
139        // configured (especially for sessions created before the backend
140        // prefixes were introduced). Resolve it like every other background
141        // utility job, falling back to the active session backend/model.
142        let requested_model = if self.memory_model.trim().is_empty() {
143            let Some(model) = self.current_model.clone() else {
144                self.push_status("pick a model first with /model".to_string());
145                return;
146            };
147            model
148        } else {
149            self.memory_model.trim().to_string()
150        };
151        let Some((provider, raw_model)) = self.resolve_utility_model_backend(&requested_model)
152        else {
153            self.push_status(format!(
154                "model backend unavailable: {requested_model} — pick another with /model"
155            ));
156            return;
157        };
158        let new_through = self.messages.len() as i64;
159        let prompt_cache_key = format!("compaction:{}", self.prompt_cache_key_for(&session_id));
160        let (tx, rx) = mpsc::unbounded_channel();
161        self.compact_rx = Some(rx);
162        self.compacting_session_id = Some(session_id.clone());
163        // The history pane gets a transient "compacting" block immediately;
164        // the input bar also keeps its compact status while the request runs.
165        tokio::spawn(async move {
166            let mut prompt = String::new();
167            if let Some(s) = &prior_summary {
168                prompt.push_str("Existing summary of earlier conversation:\n");
169                prompt.push_str(s);
170                prompt.push_str("\n\n");
171            }
172            prompt.push_str("New messages since that summary:\n");
173            prompt.push_str(&tail);
174            prompt.push_str(
175                "\n\nCompress ALL of the above into one ultra-dense technical digest: cut \
176                 every pleasantry, filler word, and repeated explanation, but keep every \
177                 decision, fact, file/function name, code snippet, number, and open thread — \
178                 nothing substantive may be lost. Terse fragments are fine. No headers, no \
179                 meta-commentary about summarizing. Reply with ONLY the digest.",
180            );
181            let msgs = vec![ChatMessage::text("user", prompt)];
182            let params = ChatParams {
183                prompt_cache_key: Some(prompt_cache_key),
184                ..ChatParams::default()
185            };
186            if let Ok(completion) = provider
187                .complete_with_params(&raw_model, msgs, &params)
188                .await
189            {
190                let summary = completion.text.trim().to_string();
191                if !summary.is_empty() {
192                    let _ = tx.send((session_id, summary, new_through, before_pct));
193                }
194            }
195        });
196    }
197
198    /// Apply a compaction digest to the matching session (in memory + db):
199    /// the digest itself becomes a visible `compaction` transcript row at the
200    /// compaction boundary, so what was folded away is shown in the chat
201    /// instead of being reachable only through the context popup's editor.
202    /// A later compaction updates that row in place (one digest row per
203    /// session, at the same boundary). Clears the exact usage total — it
204    /// reflects the pre-compaction request, so `context_used` should fall
205    /// back to the (now accurate) estimate until the next real response
206    /// reports fresh usage.
207    pub fn on_compact_result(&mut self, result: Option<(String, String, i64, u64)>) {
208        self.compact_rx = None;
209        self.compacting_session_id = None;
210        let Some((id, summary, through, before_pct)) = result else {
211            self.push_status("compaction failed — no digest returned".to_string());
212            return;
213        };
214        let _ = self.db.set_compaction(&id, &summary, through);
215        if let Some(s) = self.session.as_mut().filter(|s| s.id == id) {
216            s.compact_summary = Some(summary.clone());
217            s.compact_through = through;
218        }
219        // Surface the digest in the transcript: update the existing row (a
220        // re-compaction folds new messages into the same digest), or insert
221        // one at the boundary — after the last message it covers, before
222        // anything the user says next. The db row is anchored to that last
223        // message's timestamp so reloads keep the same position.
224        let in_view = self.session.as_ref().is_some_and(|s| s.id == id);
225        if in_view {
226            self.bump_cache_epoch();
227        }
228        let mut history_invalidated = false;
229        if in_view {
230            if let Some(row) = self.messages.iter_mut().find(|m| m.role == "compaction") {
231                row.content.clone_from(&summary);
232                history_invalidated = true;
233            } else {
234                let through = usize::try_from(through)
235                    .unwrap_or(0)
236                    .min(self.messages.len());
237                let anchor = self
238                    .messages
239                    .get(through.saturating_sub(1))
240                    .and_then(|m| m.created_at.clone());
241                self.messages.insert(
242                    through,
243                    crate::db::Message {
244                        role: "compaction".to_string(),
245                        content: summary.clone(),
246                        model: None,
247                        reasoning: None,
248                        tokens: None,
249                        secs: None,
250                        cost: None,
251                        phrase: None,
252                        persona: None,
253                        created_at: anchor,
254                    },
255                );
256                history_invalidated = true;
257            }
258        }
259        if history_invalidated {
260            self.push_history_invalidated();
261        }
262        if self
263            .db
264            .update_compaction_message(&id, &summary)
265            .is_ok_and(|n| n == 0)
266        {
267            // Anchor: the in-memory row we just placed (viewing the session),
268            // else the boundary message's timestamp straight from the db
269            // (job finished after the user switched away), else now.
270            let anchor = in_view
271                .then(|| {
272                    self.messages
273                        .iter()
274                        .find(|m| m.role == "compaction")
275                        .and_then(|m| m.created_at.clone())
276                })
277                .flatten()
278                .or_else(|| {
279                    self.db
280                        .message_created_at(
281                            &id,
282                            usize::try_from(through).unwrap_or(0).saturating_sub(1),
283                        )
284                        .ok()
285                        .flatten()
286                })
287                .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
288            let _ = self.db.add_compaction_message(&id, &summary, &anchor);
289        }
290        self.context_total = None;
291        let after_pct = self
292            .context_limit()
293            .filter(|&l| l > 0)
294            .map(|l| self.context_used() * 100 / l);
295        self.push_status(match after_pct {
296            Some(after) => format!("compacted: {before_pct}% → {after}%"),
297            None => "compacted".to_string(),
298        });
299    }
300
301    /// Sessions compacted before compaction rows existed (or loaded from a
302    /// db written by such a version) carry the digest only in
303    /// `compact_summary`. Surface it as a transcript row at the boundary,
304    /// exactly like a fresh compaction would, so the digest is never hidden
305    /// behind the context popup. Idempotent: no-ops once a compaction row
306    /// exists. Called after every session load.
307    pub fn backfill_compaction_row(&mut self) {
308        let Some(s) = self.session.as_ref() else {
309            return;
310        };
311        let Some(summary) = s.compact_summary.clone() else {
312            return;
313        };
314        if self.messages.iter().any(|m| m.role == "compaction") {
315            return;
316        }
317        let through = usize::try_from(s.compact_through)
318            .unwrap_or(0)
319            .min(self.messages.len());
320        let anchor = self
321            .messages
322            .get(through.saturating_sub(1))
323            .and_then(|m| m.created_at.clone())
324            .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
325        let id = s.id.clone();
326        let _ = self.db.add_compaction_message(&id, &summary, &anchor);
327        self.messages.insert(
328            through,
329            crate::db::Message {
330                role: "compaction".to_string(),
331                content: summary,
332                model: None,
333                reasoning: None,
334                tokens: None,
335                secs: None,
336                cost: None,
337                phrase: None,
338                persona: None,
339                created_at: Some(anchor),
340            },
341        );
342        self.push_history_invalidated();
343    }
344
345    /// System/memory/conversation token estimate for the context breakdown
346    /// popup (Ctrl+I). Each bucket is a ~4-chars/token estimate, same method
347    /// `context_used` falls back to, so the parts add up to (roughly) the whole.
348    pub fn context_breakdown(&self) -> ContextBreakdown {
349        let mut instructions_chars = self.resolved_base_system_prompt().chars().count();
350        instructions_chars +=
351            std::fs::read_to_string(self.space.instructions_path(&self.active_space.name))
352                .map_or(0, |s| s.trim().chars().count());
353        let memory_chars = self.memory_snapshot().chars().count();
354        let mut skills_chars: usize = self
355            .skills
356            .iter()
357            .map(|s| s.name.chars().count() + s.description.chars().count())
358            .sum();
359        if let Some(name) = &self.forced_skill
360            && let Some(skill) = self.skills.iter().find(|s| &s.name == name)
361        {
362            skills_chars += std::fs::read_to_string(skill.dir.join("SKILL.md"))
363                .map_or(0, |md| crate::skills::skill_body(&md).chars().count());
364        }
365        let mut conversation_chars: usize = self
366            .effective_messages()
367            .iter()
368            // The digest transcript row is the same text as `compact_summary`
369            // (counted below) — never double-count it.
370            .filter(|m| m.role != "compaction")
371            .map(|m| m.content.chars().count())
372            .sum();
373        if let Some(s) = self
374            .session
375            .as_ref()
376            .and_then(|s| s.compact_summary.as_deref())
377        {
378            conversation_chars += s.chars().count();
379        }
380        if let Some(buf) = self.active_streaming_text() {
381            conversation_chars += buf.chars().count();
382        }
383        ContextBreakdown {
384            system_tokens: (instructions_chars / 4) as u64,
385            memory_tokens: (memory_chars / 4) as u64,
386            skills_tokens: (skills_chars / 4) as u64,
387            conversation_tokens: (conversation_chars / 4) as u64,
388            limit: self.context_limit(),
389            compacted: self
390                .session
391                .as_ref()
392                .is_some_and(|s| s.compact_summary.is_some()),
393        }
394    }
395
396    /// Path to a temp file holding the active session's compaction digest, so
397    /// it can be viewed/edited in `$EDITOR` from the context popup (Ctrl+G, `v`).
398    /// `None` if the session hasn't been compacted yet.
399    pub fn compact_summary_path(&self) -> Option<std::path::PathBuf> {
400        let session = self.session.as_ref()?;
401        let summary = session.compact_summary.as_ref()?;
402        let path = std::env::temp_dir().join(format!("nexus-chat-compact-{}.md", session.id));
403        std::fs::write(&path, summary).ok()?;
404        Some(path)
405    }
406
407    /// Read `path` (from `compact_summary_path`) back after `$EDITOR` closes —
408    /// hand-edits to the digest persist (db + in-memory), same as any other
409    /// file-backed edit in the app.
410    pub fn reload_compact_summary(&mut self, path: &std::path::Path) -> Result<()> {
411        let Some(session) = self.session.as_ref() else {
412            return Ok(());
413        };
414        let Ok(text) = std::fs::read_to_string(path) else {
415            return Ok(());
416        };
417        let text = text.trim().to_string();
418        if text.is_empty() || Some(&text) == session.compact_summary.as_ref() {
419            return Ok(());
420        }
421        let id = session.id.clone();
422        let through = session.compact_through;
423        self.db.set_compaction(&id, &text, through)?;
424        self.bump_cache_epoch();
425        if let Some(row) = self.messages.iter_mut().find(|m| m.role == "compaction") {
426            row.content.clone_from(&text);
427            self.push_history_invalidated();
428        }
429        if let Some(s) = self.session.as_mut() {
430            s.compact_summary = Some(text);
431        }
432        self.push_status("compaction digest updated".to_string());
433        Ok(())
434    }
435}
436
437/// The message tail handed to the compaction model: everything since the
438/// last digest except rows that must never reach a model via
439/// `App::excluded_from_model_history`. Tool-call rows are retained so a
440/// compaction cannot silently erase the model's tool findings.
441fn compaction_tail(messages: &[Message], through: usize) -> String {
442    messages[through.min(messages.len())..]
443        .iter()
444        .filter(|m| !App::excluded_from_model_history(m))
445        .map(|m| format!("{}: {}", m.role, m.content))
446        .collect::<Vec<_>>()
447        .join("\n\n")
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::db::Db;
454    use crate::space::Space;
455
456    fn msg(role: &str, content: &str) -> Message {
457        Message {
458            role: role.into(),
459            content: content.into(),
460            model: None,
461            reasoning: None,
462            tokens: None,
463            secs: None,
464            cost: None,
465            phrase: None,
466            persona: None,
467            created_at: None,
468        }
469    }
470
471    fn test_app() -> App {
472        let db = Db::open_in_memory().unwrap();
473        let root =
474            std::env::temp_dir().join(format!("nexus-compact-test-{}", uuid::Uuid::new_v4()));
475        std::fs::create_dir_all(root.join("spaces")).unwrap();
476        App::new(db, Some("k"), Space { root })
477    }
478
479    /// A fresh session with `n` user/assistant pairs loaded as the active one.
480    fn app_with_session(n: usize) -> (App, String) {
481        let mut a = test_app();
482        let sid =
483            a.db.create_session("t", "m", &a.active_space.id, "chat")
484                .unwrap()
485                .id;
486        for i in 0..n {
487            a.db.add_user_message(&sid, &format!("u{i}")).unwrap();
488            a.db.add_assistant_message(&sid, &format!("a{i}"), None, None, None, None, None, None)
489                .unwrap();
490        }
491        a.messages = a.db.load_messages(&sid).unwrap();
492        a.session = a.db.get_session(&sid).unwrap();
493        (a, sid)
494    }
495
496    #[test]
497    fn on_compact_result_surfaces_the_digest_at_the_boundary() {
498        let (mut a, sid) = app_with_session(2);
499
500        a.on_compact_result(Some((sid.clone(), "digest text".to_string(), 3, 42)));
501
502        // The digest row sits at the boundary: after the 3 compacted
503        // messages, before anything the user says next.
504        assert_eq!(a.messages.len(), 5);
505        assert_eq!(a.messages[3].role, "compaction");
506        assert_eq!(a.messages[3].content, "digest text");
507        assert_eq!(a.messages[2].content, "u1"); // last compacted message
508        // Session state applied.
509        assert_eq!(a.session.as_ref().unwrap().compact_through, 3);
510        assert_eq!(
511            a.session.as_ref().unwrap().compact_summary.as_deref(),
512            Some("digest text")
513        );
514        // Persisted, anchored to the last compacted message's timestamp so
515        // reloads keep the same position.
516        let stored = a.db.load_messages(&sid).unwrap();
517        assert_eq!(stored.len(), 5);
518        let digest = stored.iter().find(|m| m.role == "compaction").unwrap();
519        assert_eq!(digest.content, "digest text");
520        let last_compacted = stored.iter().find(|m| m.content == "u1").unwrap();
521        assert_eq!(digest.created_at, last_compacted.created_at);
522        assert!(a.last_status().contains("compacted"), "{}", a.last_status());
523    }
524
525    #[test]
526    fn re_compaction_updates_the_digest_row_in_place() {
527        let (mut a, sid) = app_with_session(5);
528
529        a.on_compact_result(Some((sid.clone(), "digest one".to_string(), 4, 50)));
530        assert_eq!(
531            a.messages.iter().filter(|m| m.role == "compaction").count(),
532            1
533        );
534
535        // Second compaction folds the rest in: same single row, new text.
536        a.on_compact_result(Some((sid.clone(), "digest two".to_string(), 10, 60)));
537        assert_eq!(
538            a.messages.iter().filter(|m| m.role == "compaction").count(),
539            1
540        );
541        let row = a.messages.iter().find(|m| m.role == "compaction").unwrap();
542        assert_eq!(row.content, "digest two");
543        let stored = a.db.load_messages(&sid).unwrap();
544        assert_eq!(stored.iter().filter(|m| m.role == "compaction").count(), 1);
545        assert_eq!(
546            stored
547                .iter()
548                .find(|m| m.role == "compaction")
549                .unwrap()
550                .content,
551            "digest two"
552        );
553    }
554
555    #[test]
556    fn backfill_surfaces_a_legacy_digest_at_the_boundary_and_is_idempotent() {
557        let (mut a, sid) = app_with_session(1);
558        // Legacy compaction: digest only in the session row, no transcript
559        // message — the state of sessions compacted before digests rendered.
560        a.db.set_compaction(&sid, "legacy digest", 2).unwrap();
561        a.session = a.db.get_session(&sid).unwrap();
562
563        a.backfill_compaction_row();
564        assert_eq!(a.messages.len(), 3);
565        assert_eq!(a.messages[2].role, "compaction");
566        assert_eq!(a.messages[2].content, "legacy digest");
567        assert_eq!(a.db.load_messages(&sid).unwrap().len(), 3);
568
569        // A second backfill (another session load) adds nothing.
570        a.backfill_compaction_row();
571        assert_eq!(a.messages.len(), 3);
572        assert_eq!(a.db.load_messages(&sid).unwrap().len(), 3);
573    }
574
575    #[test]
576    fn force_compact_ignores_a_backfilled_digest_row() {
577        let (mut a, sid) = app_with_session(1);
578        a.db.set_compaction(&sid, "legacy digest", 2).unwrap();
579        a.session = a.db.get_session(&sid).unwrap();
580        a.backfill_compaction_row();
581
582        // The visible legacy digest is not new conversation content. It must
583        // not cause a second compaction request every time the session opens.
584        a.force_compact();
585        assert!(a.compact_rx.is_none());
586        assert!(a.last_status().contains("nothing new"));
587    }
588
589    #[test]
590    fn compaction_failure_clears_the_running_marker() {
591        let mut a = test_app();
592        let session =
593            a.db.create_session("t", "m", &a.active_space.id, "chat")
594                .unwrap();
595        a.session = Some(session.clone());
596        a.compacting_session_id = Some(session.id.clone());
597        let (_tx, rx) = mpsc::unbounded_channel();
598        a.compact_rx = Some(rx);
599
600        a.on_compact_result(None);
601
602        assert!(a.compact_rx.is_none());
603        assert!(a.compacting_session_id.is_none());
604        assert!(a.last_status().contains("compaction failed"));
605    }
606
607    #[test]
608    fn compaction_tail_skips_rows_that_must_never_reach_the_model() {
609        let mut msgs = vec![
610            msg("user", "what should we research?"),
611            msg("research_stage", "planner: working"),
612            msg("survey", "For \"x\":\n 1. Depth?"),
613            msg("gate_reply", "drop Q2"),
614            msg("research_plan", "Research plan: …"),
615            msg("error", "request failed"),
616            msg("session_link", "sess-1\n↩ from: x"),
617            msg("compaction", "folded-away digest"),
618            msg("user", "the final question"),
619            msg(
620                "tool_call",
621                r#"{"name":"search","result":"important finding"}"#,
622            ),
623        ];
624        let mut persona = msg("assistant", "round reply");
625        persona.persona = Some("Optimist".into());
626        msgs.push(persona);
627
628        let tail = compaction_tail(&msgs, 0);
629        assert!(tail.contains("what should we research?"), "{tail}");
630        assert!(tail.contains("the final question"), "{tail}");
631        // Background rows, gate replies, errors, links, the digest row itself
632        // (already fed via compact_summary), and persona round replies never
633        // enter a digest — the compacted history would otherwise leak
634        // contextless "drop Q2" to later models.
635        for banned in [
636            "planner: working",
637            "Depth?",
638            "drop Q2",
639            "Research plan",
640            "request failed",
641            "sess-1",
642            "folded-away digest",
643            "round reply",
644        ] {
645            assert!(
646                !tail.contains(banned),
647                "digest must not contain {banned:?}: {tail}"
648            );
649        }
650        assert!(
651            tail.contains("important finding"),
652            "tool findings must survive: {tail}"
653        );
654        // The compaction boundary still applies.
655        let partial = compaction_tail(&msgs, 1);
656        assert!(!partial.contains("what should we research?"), "{partial}");
657        assert!(partial.contains("the final question"), "{partial}");
658    }
659}