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;
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            .map_or(0, |s| s.compact_through as usize)
49            .min(self.messages.len());
50        &self.messages[through..]
51    }
52
53    /// After a reply, auto-compact once context usage crosses the configured
54    /// threshold (0 disables it).
55    pub fn maybe_compact(&mut self) {
56        if self.settings.compact_threshold == 0 || self.compact_rx.is_some() {
57            return;
58        }
59        let Some(limit) = self.context_limit() else {
60            return;
61        };
62        let used = self.context_used();
63        let pct = used
64            .checked_mul(100)
65            .and_then(|v| v.checked_div(limit))
66            .unwrap_or(0);
67        if pct < u64::from(self.settings.compact_threshold) {
68            return;
69        }
70        self.start_compaction(pct);
71    }
72
73    /// Manually trigger compaction right now (`/compact`), ignoring the
74    /// threshold. No-ops with a status message if there's nothing to compact.
75    pub fn force_compact(&mut self) {
76        if self.compact_rx.is_some() {
77            self.push_status("already compacting…".to_string());
78            return;
79        }
80        if self.is_streaming() {
81            self.push_status("wait for the current response to finish".to_string());
82            return;
83        }
84        let Some(session) = self.session.as_ref() else {
85            self.push_status("no active session to compact".to_string());
86            return;
87        };
88        if session.compact_through as usize >= self.messages.len() {
89            self.push_status("nothing new to compact".to_string());
90            return;
91        }
92        let pct = self.context_limit().filter(|&l| l > 0).map_or(0, |l| {
93            self.context_used()
94                .checked_mul(100)
95                .and_then(|v| v.checked_div(l))
96                .unwrap_or(0)
97        });
98        self.start_compaction(pct);
99    }
100
101    /// Kick off the background compaction job on the memory model (falling
102    /// back to the session model), same pattern as memory extraction.
103    /// `before_pct` is only used to report the before/after status on completion.
104    fn start_compaction(&mut self, before_pct: u64) {
105        let model = if self.memory_model.trim().is_empty() {
106            if let Some(m) = self.current_model.clone() {
107                m
108            } else {
109                self.push_status("pick a model first with /model".to_string());
110                return;
111            }
112        } else {
113            self.memory_model.clone()
114        };
115        let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
116            self.push_status(format!(
117                "model backend unavailable: {model} — pick another with /model"
118            ));
119            return;
120        };
121        let Some(session) = self.session.as_ref() else {
122            return;
123        };
124        let through = session.compact_through as usize;
125        if through >= self.messages.len() {
126            return; // nothing new since the last compaction to fold in
127        }
128        let prior_summary = session.compact_summary.clone();
129        let tail = compaction_tail(&self.messages, through);
130        let session_id = session.id.clone();
131        let new_through = self.messages.len() as i64;
132        let (tx, rx) = mpsc::unbounded_channel();
133        self.compact_rx = Some(rx);
134        // No status write here: the input bar's "⟳ compacting…" hint (driven
135        // by `compact_rx`) is the progress indicator — a status message would
136        // be overwritten by the next event and never seen again.
137        tokio::spawn(async move {
138            let mut prompt = String::new();
139            if let Some(s) = &prior_summary {
140                prompt.push_str("Existing summary of earlier conversation:\n");
141                prompt.push_str(s);
142                prompt.push_str("\n\n");
143            }
144            prompt.push_str("New messages since that summary:\n");
145            prompt.push_str(&tail);
146            prompt.push_str(
147                "\n\nCompress ALL of the above into one ultra-dense technical digest: cut \
148                 every pleasantry, filler word, and repeated explanation, but keep every \
149                 decision, fact, file/function name, code snippet, number, and open thread — \
150                 nothing substantive may be lost. Terse fragments are fine. No headers, no \
151                 meta-commentary about summarizing. Reply with ONLY the digest.",
152            );
153            let msgs = vec![ChatMessage::text("user", prompt)];
154            if let Ok(summary) = provider.complete(&raw_model, msgs).await {
155                let summary = summary.trim().to_string();
156                if !summary.is_empty() {
157                    let _ = tx.send((session_id, summary, new_through, before_pct));
158                }
159            }
160        });
161    }
162
163    /// Apply a compaction digest to the matching session (in memory + db):
164    /// the digest itself becomes a visible `compaction` transcript row at the
165    /// compaction boundary, so what was folded away is shown in the chat
166    /// instead of being reachable only through the context popup's editor.
167    /// A later compaction updates that row in place (one digest row per
168    /// session, at the same boundary). Clears the exact usage total — it
169    /// reflects the pre-compaction request, so `context_used` should fall
170    /// back to the (now accurate) estimate until the next real response
171    /// reports fresh usage.
172    pub fn on_compact_result(&mut self, result: Option<(String, String, i64, u64)>) {
173        self.compact_rx = None;
174        let Some((id, summary, through, before_pct)) = result else {
175            return;
176        };
177        let _ = self.db.set_compaction(&id, &summary, through);
178        if let Some(s) = self.session.as_mut().filter(|s| s.id == id) {
179            s.compact_summary = Some(summary.clone());
180            s.compact_through = through;
181        }
182        // Surface the digest in the transcript: update the existing row (a
183        // re-compaction folds new messages into the same digest), or insert
184        // one at the boundary — after the last message it covers, before
185        // anything the user says next. The db row is anchored to that last
186        // message's timestamp so reloads keep the same position.
187        if self.session.as_ref().is_some_and(|s| s.id == id) {
188            if let Some(row) = self.messages.iter_mut().find(|m| m.role == "compaction") {
189                row.content.clone_from(&summary);
190            } else {
191                let through = (through as usize).min(self.messages.len());
192                let anchor = self
193                    .messages
194                    .get(through.saturating_sub(1))
195                    .and_then(|m| m.created_at.clone());
196                self.messages.insert(
197                    through,
198                    crate::db::Message {
199                        role: "compaction".to_string(),
200                        content: summary.clone(),
201                        model: None,
202                        reasoning: None,
203                        tokens: None,
204                        secs: None,
205                        cost: None,
206                        phrase: None,
207                        persona: None,
208                        created_at: anchor,
209                    },
210                );
211                self.push_history_invalidated();
212            }
213        }
214        if self
215            .db
216            .update_compaction_message(&id, &summary)
217            .is_ok_and(|n| n == 0)
218        {
219            // Anchor: the in-memory row we just placed (viewing the session),
220            // else the boundary message's timestamp straight from the db
221            // (job finished after the user switched away), else now.
222            let anchor = self
223                .messages
224                .iter()
225                .find(|m| m.role == "compaction")
226                .and_then(|m| m.created_at.clone())
227                .or_else(|| {
228                    self.db
229                        .message_created_at(&id, (through as usize).saturating_sub(1))
230                        .ok()
231                        .flatten()
232                })
233                .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
234            let _ = self.db.add_compaction_message(&id, &summary, &anchor);
235        }
236        self.context_total = None;
237        let after_pct = self
238            .context_limit()
239            .filter(|&l| l > 0)
240            .map(|l| self.context_used() * 100 / l);
241        self.push_status(match after_pct {
242            Some(after) => format!("compacted: {before_pct}% → {after}%"),
243            None => "compacted".to_string(),
244        });
245    }
246
247    /// Sessions compacted before compaction rows existed (or loaded from a
248    /// db written by such a version) carry the digest only in
249    /// `compact_summary`. Surface it as a transcript row at the boundary,
250    /// exactly like a fresh compaction would, so the digest is never hidden
251    /// behind the context popup. Idempotent: no-ops once a compaction row
252    /// exists. Called after every session load.
253    pub fn backfill_compaction_row(&mut self) {
254        let Some(s) = self.session.as_ref() else {
255            return;
256        };
257        let Some(summary) = s.compact_summary.clone() else {
258            return;
259        };
260        if self.messages.iter().any(|m| m.role == "compaction") {
261            return;
262        }
263        let through = (s.compact_through as usize).min(self.messages.len());
264        let anchor = self
265            .messages
266            .get(through.saturating_sub(1))
267            .and_then(|m| m.created_at.clone())
268            .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
269        let id = s.id.clone();
270        let _ = self.db.add_compaction_message(&id, &summary, &anchor);
271        self.messages.insert(
272            through,
273            crate::db::Message {
274                role: "compaction".to_string(),
275                content: summary,
276                model: None,
277                reasoning: None,
278                tokens: None,
279                secs: None,
280                cost: None,
281                phrase: None,
282                persona: None,
283                created_at: Some(anchor),
284            },
285        );
286        self.push_history_invalidated();
287    }
288
289    /// System/memory/conversation token estimate for the context breakdown
290    /// popup (Ctrl+I). Each bucket is a ~4-chars/token estimate, same method
291    /// `context_used` falls back to, so the parts add up to (roughly) the whole.
292    pub fn context_breakdown(&self) -> ContextBreakdown {
293        let mut instructions_chars = self.resolved_base_system_prompt().chars().count();
294        instructions_chars +=
295            std::fs::read_to_string(self.space.instructions_path(&self.active_space.name))
296                .map_or(0, |s| s.trim().chars().count());
297        let memory_chars = self.read_memory().chars().count();
298        let mut skills_chars: usize = self
299            .skills
300            .iter()
301            .map(|s| s.name.chars().count() + s.description.chars().count())
302            .sum();
303        if let Some(name) = &self.forced_skill
304            && let Some(skill) = self.skills.iter().find(|s| &s.name == name)
305        {
306            skills_chars += std::fs::read_to_string(skill.dir.join("SKILL.md"))
307                .map_or(0, |md| crate::skills::skill_body(&md).chars().count());
308        }
309        let mut conversation_chars: usize = self
310            .effective_messages()
311            .iter()
312            // The digest transcript row is the same text as `compact_summary`
313            // (counted below) — never double-count it.
314            .filter(|m| m.role != "compaction")
315            .map(|m| m.content.chars().count())
316            .sum();
317        if let Some(s) = self
318            .session
319            .as_ref()
320            .and_then(|s| s.compact_summary.as_deref())
321        {
322            conversation_chars += s.chars().count();
323        }
324        if let Some(buf) = self.active_streaming_text() {
325            conversation_chars += buf.chars().count();
326        }
327        ContextBreakdown {
328            system_tokens: (instructions_chars / 4) as u64,
329            memory_tokens: (memory_chars / 4) as u64,
330            skills_tokens: (skills_chars / 4) as u64,
331            conversation_tokens: (conversation_chars / 4) as u64,
332            limit: self.context_limit(),
333            compacted: self
334                .session
335                .as_ref()
336                .is_some_and(|s| s.compact_summary.is_some()),
337        }
338    }
339
340    /// Path to a temp file holding the active session's compaction digest, so
341    /// it can be viewed/edited in `$EDITOR` from the context popup (Ctrl+G, `v`).
342    /// `None` if the session hasn't been compacted yet.
343    pub fn compact_summary_path(&self) -> Option<std::path::PathBuf> {
344        let session = self.session.as_ref()?;
345        let summary = session.compact_summary.as_ref()?;
346        let path = std::env::temp_dir().join(format!("nexus-chat-compact-{}.md", session.id));
347        std::fs::write(&path, summary).ok()?;
348        Some(path)
349    }
350
351    /// Read `path` (from `compact_summary_path`) back after `$EDITOR` closes —
352    /// hand-edits to the digest persist (db + in-memory), same as any other
353    /// file-backed edit in the app.
354    pub fn reload_compact_summary(&mut self, path: &std::path::Path) -> Result<()> {
355        let Some(session) = self.session.as_ref() else {
356            return Ok(());
357        };
358        let Ok(text) = std::fs::read_to_string(path) else {
359            return Ok(());
360        };
361        let text = text.trim().to_string();
362        if text.is_empty() || Some(&text) == session.compact_summary.as_ref() {
363            return Ok(());
364        }
365        let id = session.id.clone();
366        let through = session.compact_through;
367        self.db.set_compaction(&id, &text, through)?;
368        if let Some(s) = self.session.as_mut() {
369            s.compact_summary = Some(text);
370        }
371        self.push_status("compaction digest updated".to_string());
372        Ok(())
373    }
374}
375
376/// The message tail handed to the compaction model: everything since the
377/// last digest except rows that must never reach a model — tool-call blocks
378/// and `App::excluded_from_model_history`. Without this, a digest could
379/// carry contextless gate replies ("drop Q2") into later history even
380/// though `build_history` skips them.
381fn compaction_tail(messages: &[Message], through: usize) -> String {
382    messages[through..]
383        .iter()
384        .filter(|m| m.role != "tool_call" && !App::excluded_from_model_history(m))
385        .map(|m| format!("{}: {}", m.role, m.content))
386        .collect::<Vec<_>>()
387        .join("\n\n")
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::db::Db;
394    use crate::space::Space;
395
396    fn msg(role: &str, content: &str) -> Message {
397        Message {
398            role: role.into(),
399            content: content.into(),
400            model: None,
401            reasoning: None,
402            tokens: None,
403            secs: None,
404            cost: None,
405            phrase: None,
406            persona: None,
407            created_at: None,
408        }
409    }
410
411    fn test_app() -> App {
412        let db = Db::open_in_memory().unwrap();
413        let root =
414            std::env::temp_dir().join(format!("nexus-compact-test-{}", uuid::Uuid::new_v4()));
415        std::fs::create_dir_all(root.join("spaces")).unwrap();
416        App::new(db, Some("k"), Space { root })
417    }
418
419    /// A fresh session with `n` user/assistant pairs loaded as the active one.
420    fn app_with_session(n: usize) -> (App, String) {
421        let mut a = test_app();
422        let sid =
423            a.db.create_session("t", "m", &a.active_space.id, "chat")
424                .unwrap()
425                .id;
426        for i in 0..n {
427            a.db.add_user_message(&sid, &format!("u{i}")).unwrap();
428            a.db.add_assistant_message(&sid, &format!("a{i}"), None, None, None, None, None, None)
429                .unwrap();
430        }
431        a.messages = a.db.load_messages(&sid).unwrap();
432        a.session = a.db.get_session(&sid).unwrap();
433        (a, sid)
434    }
435
436    #[test]
437    fn on_compact_result_surfaces_the_digest_at_the_boundary() {
438        let (mut a, sid) = app_with_session(2);
439
440        a.on_compact_result(Some((sid.clone(), "digest text".to_string(), 3, 42)));
441
442        // The digest row sits at the boundary: after the 3 compacted
443        // messages, before anything the user says next.
444        assert_eq!(a.messages.len(), 5);
445        assert_eq!(a.messages[3].role, "compaction");
446        assert_eq!(a.messages[3].content, "digest text");
447        assert_eq!(a.messages[2].content, "u1"); // last compacted message
448        // Session state applied.
449        assert_eq!(a.session.as_ref().unwrap().compact_through, 3);
450        assert_eq!(
451            a.session.as_ref().unwrap().compact_summary.as_deref(),
452            Some("digest text")
453        );
454        // Persisted, anchored to the last compacted message's timestamp so
455        // reloads keep the same position.
456        let stored = a.db.load_messages(&sid).unwrap();
457        assert_eq!(stored.len(), 5);
458        let digest = stored.iter().find(|m| m.role == "compaction").unwrap();
459        assert_eq!(digest.content, "digest text");
460        let last_compacted = stored.iter().find(|m| m.content == "u1").unwrap();
461        assert_eq!(digest.created_at, last_compacted.created_at);
462        assert!(a.last_status().contains("compacted"), "{}", a.last_status());
463    }
464
465    #[test]
466    fn re_compaction_updates_the_digest_row_in_place() {
467        let (mut a, sid) = app_with_session(5);
468
469        a.on_compact_result(Some((sid.clone(), "digest one".to_string(), 4, 50)));
470        assert_eq!(
471            a.messages.iter().filter(|m| m.role == "compaction").count(),
472            1
473        );
474
475        // Second compaction folds the rest in: same single row, new text.
476        a.on_compact_result(Some((sid.clone(), "digest two".to_string(), 10, 60)));
477        assert_eq!(
478            a.messages.iter().filter(|m| m.role == "compaction").count(),
479            1
480        );
481        let row = a.messages.iter().find(|m| m.role == "compaction").unwrap();
482        assert_eq!(row.content, "digest two");
483        let stored = a.db.load_messages(&sid).unwrap();
484        assert_eq!(stored.iter().filter(|m| m.role == "compaction").count(), 1);
485        assert_eq!(
486            stored
487                .iter()
488                .find(|m| m.role == "compaction")
489                .unwrap()
490                .content,
491            "digest two"
492        );
493    }
494
495    #[test]
496    fn backfill_surfaces_a_legacy_digest_at_the_boundary_and_is_idempotent() {
497        let (mut a, sid) = app_with_session(1);
498        // Legacy compaction: digest only in the session row, no transcript
499        // message — the state of sessions compacted before digests rendered.
500        a.db.set_compaction(&sid, "legacy digest", 2).unwrap();
501        a.session = a.db.get_session(&sid).unwrap();
502
503        a.backfill_compaction_row();
504        assert_eq!(a.messages.len(), 3);
505        assert_eq!(a.messages[2].role, "compaction");
506        assert_eq!(a.messages[2].content, "legacy digest");
507        assert_eq!(a.db.load_messages(&sid).unwrap().len(), 3);
508
509        // A second backfill (another session load) adds nothing.
510        a.backfill_compaction_row();
511        assert_eq!(a.messages.len(), 3);
512        assert_eq!(a.db.load_messages(&sid).unwrap().len(), 3);
513    }
514
515    #[test]
516    fn compaction_tail_skips_rows_that_must_never_reach_the_model() {
517        let mut msgs = vec![
518            msg("user", "what should we research?"),
519            msg("research_stage", "planner: working"),
520            msg("survey", "For \"x\":\n 1. Depth?"),
521            msg("gate_reply", "drop Q2"),
522            msg("research_plan", "Research plan: …"),
523            msg("error", "request failed"),
524            msg("session_link", "sess-1\n↩ from: x"),
525            msg("compaction", "folded-away digest"),
526            msg("user", "the final question"),
527            msg("tool_call", r#"{"name":"search"}"#),
528        ];
529        let mut persona = msg("assistant", "round reply");
530        persona.persona = Some("Optimist".into());
531        msgs.push(persona);
532
533        let tail = compaction_tail(&msgs, 0);
534        assert!(tail.contains("what should we research?"), "{tail}");
535        assert!(tail.contains("the final question"), "{tail}");
536        // Background rows, gate replies, errors, links, the digest row itself
537        // (already fed via compact_summary), and persona round replies never
538        // enter a digest — the compacted history would otherwise leak
539        // contextless "drop Q2" to later models.
540        for banned in [
541            "planner: working",
542            "Depth?",
543            "drop Q2",
544            "Research plan",
545            "request failed",
546            "sess-1",
547            "folded-away digest",
548            "round reply",
549            "tool_call",
550        ] {
551            assert!(
552                !tail.contains(banned),
553                "digest must not contain {banned:?}: {tail}"
554            );
555        }
556        // The compaction boundary still applies.
557        let partial = compaction_tail(&msgs, 1);
558        assert!(!partial.contains("what should we research?"), "{partial}");
559        assert!(partial.contains("the final question"), "{partial}");
560    }
561}