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;
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 .map_or(0, |s| s.compact_through as usize)
49 .min(self.messages.len());
50 &self.messages[through..]
51 }
52
53 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 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 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; }
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 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 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 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 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 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 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 .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 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 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
376fn 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 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 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"); 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 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 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 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.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 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 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}