1#![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 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 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 #[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 #[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 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 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 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; }
137
138 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 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, ¶ms)
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 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 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 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 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 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 .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 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 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
437fn 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 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 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"); 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 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 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 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.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 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 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 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}