1use crate::context::export::{ContextExport, ContextExportFormat, ExportArtifact, artifact_from_recovery};
10use crate::session::CompactionRisk;
11
12use super::*;
13
14#[derive(Clone, Debug)]
22pub struct PendingManualCompaction {
23 original_transcript: Vec<Entry>,
24 covered_start_seq: u64,
25 covered_end_seq: u64,
26 recovery_handle: String,
27 trigger: session::CompactionTrigger,
29 original_user_turn: Option<String>,
32}
33
34#[derive(Clone, Debug)]
37pub struct PendingCompactionReview {
38 pending: PendingManualCompaction,
39 summary: String,
40}
41impl App {
42 pub fn recover_context_evidence(&mut self, reference: &str) -> Result<crate::artifacts::ArtifactRecovery, String> {
47 self.ensure_context_ledger();
48 let item = self
49 .context_ledger
50 .as_ref()
51 .and_then(|ledger| {
52 ledger.items.iter().find(|item| {
53 item.artifact_handle.as_deref() == Some(reference)
54 || item.id == reference
55 || item.id.starts_with(reference)
56 })
57 })
58 .ok_or_else(|| format!("unknown context artifact `{}`", redact_context_display(reference)))?
59 .clone();
60 let handle = item.artifact_handle.as_deref().ok_or_else(|| {
61 format!(
62 "context item `{}` has no recoverable artifact",
63 redact_context_display(&item.id)
64 )
65 })?;
66 self.recover_artifact_for_item(&item, handle, "user requested bounded artifact recovery")
67 }
68
69 pub fn refresh_context_ledger(&mut self, user_turn: Option<&str>) -> agent_context::ContextLedger {
75 let pinned_paths = self
76 .context_pins
77 .iter()
78 .filter_map(|pin| pin.source_path.clone())
79 .collect::<Vec<_>>();
80 let instruction_selection = crate::context::select_instructions(&self.context_sources, &[], &pinned_paths);
81 let applicable_paths = instruction_selection
82 .applicable
83 .iter()
84 .map(|source| source.path.clone())
85 .collect::<std::collections::HashSet<_>>();
86
87 let mut harness = prompt::default_fragments()
88 .into_iter()
89 .map(|fragment| HarnessCandidate::new(fragment.name, fragment.content.len()))
90 .collect::<Vec<_>>();
91 harness.push(HarnessCandidate::new(
92 "tool_catalog",
93 tools::tool_definitions()
94 .into_iter()
95 .map(|tool| tool.name.len() + tool.description.len())
96 .sum(),
97 ));
98
99 let instructions = self
100 .context_sources
101 .iter()
102 .map(|source| InstructionCandidate {
103 path: source.path.clone(),
104 scope: source.scope.clone(),
105 content_hash: source.content_hash,
106 byte_count: source.byte_count,
107 content: Some(source.content.clone()),
108 truncated: source.truncated,
109 applicable: applicable_paths.contains(&source.path),
110 })
111 .collect();
112 let skills = self
113 .skills
114 .iter()
115 .map(|skill| {
116 SkillCandidate::discovered(&skill.name, skill.path.clone(), skill.content_hash, skill.byte_count)
117 })
118 .collect();
119 let transcript = self
120 .transcript
121 .iter()
122 .enumerate()
123 .map(|(index, entry)| TranscriptCandidate {
124 seq: index as u64 + 1,
125 session_id: self.session_id.clone(),
126 label: transcript_candidate_label(entry),
127 bytes: transcript_candidate_bytes(entry),
128 artifact_handle: match entry {
129 Entry::Tool { name, .. } => name
130 .rsplit_once('#')
131 .and_then(|(_, id)| self.tool_artifacts.get(id))
132 .cloned(),
133 _ => None,
134 },
135 ui_only: matches!(entry, Entry::Status { .. } | Entry::Error { .. }),
136 streaming: matches!(
137 entry,
138 Entry::Agent { streaming: true, .. } | Entry::Reasoning { streaming: true, .. }
139 ),
140 })
141 .collect();
142 let selection_input = SelectionInput {
143 harness,
144 user_turn: user_turn.map(|text| UserTurnCandidate::new(&self.session_id, self.turn_count + 1, text.len())),
145 instructions,
146 pins: self.context_pins.clone(),
147 compaction_summaries: self.compaction_summaries.clone(),
148 transcript,
149 skills,
150 dropped_ids: self.context_dropped_ids.clone(),
151 };
152
153 let provider = provider_label(&self.model);
154 let (limits, mut diagnostics) = agent_context::ModelContextLimits::resolve(provider, &self.model, None, None);
155 let mut ledger = agent_context::select_context(&selection_input, limits);
156 diagnostics.extend(
157 self.context_diagnostics
158 .iter()
159 .map(|diagnostic| agent_context::ContextDiagnostic {
160 severity: match diagnostic.severity {
161 crate::context::InstructionSeverity::Info => agent_context::DiagnosticSeverity::Info,
162 crate::context::InstructionSeverity::Warning => agent_context::DiagnosticSeverity::Warning,
163 crate::context::InstructionSeverity::Error => agent_context::DiagnosticSeverity::Error,
164 },
165 code: "instruction_discovery".to_string(),
166 message: diagnostic.summary(),
167 }),
168 );
169 ledger.diagnostics.extend(diagnostics);
170 self.context_ledger = Some(ledger.clone());
171 ledger
172 }
173
174 pub fn open_context_surface(&mut self) {
176 self.refresh_context_ledger(None);
177 self.prompt_accessory = PromptAccessory::Context;
178 self.input.clear();
179 }
180
181 pub fn build_context_export(&mut self, include_artifacts: bool) -> ContextExport {
183 self.ensure_context_ledger();
184 let ledger = self
185 .context_ledger
186 .clone()
187 .unwrap_or_else(|| self.refresh_context_ledger(None));
188 let mut artifacts = Vec::new();
189 let store = self.artifact_store();
190 let mut handles = std::collections::BTreeSet::new();
191 for item in &ledger.items {
192 let Some(handle) = item.artifact_handle.as_deref() else { continue };
193 if !handles.insert(handle.to_string()) {
194 continue;
195 }
196 let artifact = match store.recover(handle) {
197 Ok(recovery) => artifact_from_recovery(recovery, include_artifacts),
198 Err(_) => ExportArtifact {
199 handle: handle.to_string(),
200 metadata: None,
201 body: None,
202 diagnostic: Some("artifact metadata is unavailable".to_string()),
203 },
204 };
205 artifacts.push(artifact);
206 }
207 let diagnostics = ledger
208 .diagnostics
209 .iter()
210 .map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
211 .collect();
212 ContextExport::from_parts(
213 self.session_id.clone(),
214 &ledger,
215 self.last_request_accounting.clone(),
216 artifacts,
217 diagnostics,
218 )
219 }
220
221 pub fn write_context_export(
223 &mut self, path: &Path, format: ContextExportFormat, include_artifacts: bool,
224 ) -> Result<(), String> {
225 let export = self.build_context_export(include_artifacts);
226 let content = match format {
227 ContextExportFormat::Json => export
228 .to_json()
229 .map_err(|error| format!("failed to encode export: {error}"))?,
230 ContextExportFormat::Markdown => export.to_markdown(),
231 };
232 let parent = path
233 .parent()
234 .filter(|parent| !parent.as_os_str().is_empty())
235 .unwrap_or_else(|| Path::new("."));
236 let file_name = path
237 .file_name()
238 .and_then(|name| name.to_str())
239 .unwrap_or("context-export");
240 let temporary = parent.join(format!(".{file_name}.thndrs-export-{}", std::process::id()));
241 if let Err(error) = std::fs::write(&temporary, content.as_bytes()) {
242 return Err(format!("cannot write context export: {error}"));
243 }
244 if let Err(error) = std::fs::rename(&temporary, path) {
245 let _ = std::fs::remove_file(&temporary);
246 return Err(format!("cannot finalize context export: {error}"));
247 }
248 Ok(())
249 }
250
251 fn pin_context_reference(&mut self, reference: &str) -> Result<(), String> {
252 self.ensure_context_ledger();
253 let (candidate, item) = if let Some(item) = self.context_item(reference)? {
254 if item.kind == ContextItemKind::Harness {
255 return Err("harness context is always loaded and cannot be pinned".to_string());
256 }
257 (
258 PinnedCandidate {
259 id: item.id.clone(),
260 kind: item.kind.clone(),
261 label: item.label.clone(),
262 source_path: item.source_path.clone(),
263 scope: item.scope.clone(),
264 content_hash: item.content_hash,
265 artifact_handle: item.artifact_handle.clone(),
266 bytes: item.byte_count,
267 },
268 item.clone(),
269 )
270 } else {
271 let path = self.resolve_context_path(reference)?;
272 let candidate = PinnedCandidate::file(ContextItemKind::PinnedFile, path.clone(), ".", file_size(&path));
273 let item = agent_context::ContextItem {
274 id: candidate.id.clone(),
275 kind: candidate.kind.clone(),
276 label: candidate.label.clone(),
277 source_path: candidate.source_path.clone(),
278 scope: candidate.scope.clone(),
279 content_hash: candidate.content_hash,
280 artifact_handle: candidate.artifact_handle.clone(),
281 byte_count: candidate.bytes,
282 content: None,
283 token_estimate: agent_context::estimate_tokens(candidate.bytes),
284 visibility: ContextVisibility::Pinned,
285 reason_code: "user_pin".to_string(),
286 reason: "user pin".to_string(),
287 };
288 (candidate, item)
289 };
290 if self.context_pins.iter().any(|pin| pin.id == candidate.id) {
291 return Err(format!(
292 "context item `{}` is already pinned",
293 redact_context_display(&candidate.id)
294 ));
295 }
296 if let Some(writer) = self.session_writer.as_mut() {
297 writer
298 .append_context_pin(&item, "user pinned context item")
299 .map_err(|error| format!("failed to record context pin: {error}"))?;
300 }
301 self.context_pins.push(candidate);
302 self.refresh_context_ledger(None);
303 Ok(())
304 }
305
306 fn drop_context_reference(&mut self, reference: &str) -> Result<(), String> {
307 self.ensure_context_ledger();
308 let item = self
309 .context_item(reference)?
310 .ok_or_else(|| format!("unknown context item `{}`", redact_context_display(reference)))?
311 .clone();
312 if item.kind == ContextItemKind::Harness {
313 return Err("harness context cannot be dropped".to_string());
314 }
315 if self.context_dropped_ids.iter().any(|id| id == &item.id) {
316 return Err(format!(
317 "context item `{}` is already dropped",
318 redact_context_display(&item.id)
319 ));
320 }
321 if let Some(writer) = self.session_writer.as_mut() {
322 writer
323 .append_context_drop(&item, "user dropped context item")
324 .map_err(|error| format!("failed to record context drop: {error}"))?;
325 }
326 self.context_dropped_ids.push(item.id);
327 self.refresh_context_ledger(None);
328 Ok(())
329 }
330
331 fn recover_context_reference(&mut self, reference: &str) -> Result<(), String> {
332 self.ensure_context_ledger();
333 let item = self
334 .context_item(reference)?
335 .ok_or_else(|| format!("unknown context item `{}`", redact_context_display(reference)))?
336 .clone();
337 if item.kind == ContextItemKind::Harness {
338 return Err("harness context is always available and needs no recovery".to_string());
339 }
340 let was_dropped = self.context_dropped_ids.iter().any(|id| id == &item.id);
341 let needs_pin = !item.visibility.is_rendered();
342 let artifact_recovered = if let Some(handle) = item.artifact_handle.as_deref() {
343 let recovery = self.recover_artifact_for_item(&item, handle, "user requested bounded artifact recovery")?;
344 if let Some(diagnostic) = recovery.diagnostic {
345 return Err(diagnostic.message);
346 }
347 true
348 } else {
349 false
350 };
351 if !was_dropped && !needs_pin && !artifact_recovered {
352 return Err(format!(
353 "context item `{}` is already active",
354 redact_context_display(&item.id)
355 ));
356 }
357 if !artifact_recovered && let Some(writer) = self.session_writer.as_mut() {
358 writer
359 .append_context_recovery(&item, "user recovered context item")
360 .map_err(|error| format!("failed to record context recovery: {error}"))?;
361 }
362 self.context_dropped_ids.retain(|id| id != &item.id);
363 if needs_pin && !self.context_pins.iter().any(|pin| pin.id == item.id) {
364 self.context_pins.push(PinnedCandidate {
365 id: item.id.clone(),
366 kind: item.kind.clone(),
367 label: item.label.clone(),
368 source_path: item.source_path.clone(),
369 scope: item.scope.clone(),
370 content_hash: item.content_hash,
371 artifact_handle: item.artifact_handle.clone(),
372 bytes: item.byte_count,
373 });
374 }
375 self.refresh_context_ledger(None);
376 Ok(())
377 }
378
379 fn recover_artifact_for_item(
380 &mut self, item: &agent_context::ContextItem, handle: &str, reason: &str,
381 ) -> Result<crate::artifacts::ArtifactRecovery, String> {
382 let recovery = self
383 .artifact_store()
384 .recover(handle)
385 .map_err(|_| format!("artifact `{}` metadata is unavailable", redact_context_display(handle)))?;
386 if let Some(writer) = self.session_writer.as_mut() {
387 writer
388 .append_context_recovery(item, reason)
389 .map_err(|_| "failed to record context recovery".to_string())?;
390 }
391 Ok(recovery)
392 }
393
394 fn reset_context_drops(&mut self) -> Result<(), String> {
395 if self.context_dropped_ids.is_empty() {
396 return Err("no dropped context items to reset".to_string());
397 }
398 self.context_dropped_ids.clear();
399 self.refresh_context_ledger(None);
400 Ok(())
401 }
402
403 fn ensure_context_ledger(&mut self) {
404 if self.context_ledger.is_none() {
405 self.refresh_context_ledger(None);
406 }
407 }
408
409 pub fn restore_context_state(&mut self, records: &[session::SessionRecord]) {
410 self.context_pins.clear();
411 self.context_dropped_ids.clear();
412 self.compaction_summaries.clear();
413 self.tool_artifacts.clear();
414 self.last_compaction_review = None;
415 for record in records {
416 match record {
417 session::SessionRecord::ContextPin { item, .. } => {
418 if item.kind != ContextItemKind::Harness && !self.context_pins.iter().any(|pin| pin.id == item.id) {
419 self.context_pins.push(pinned_candidate_from_meta(item));
420 }
421 }
422 session::SessionRecord::ContextDrop { item, .. } => {
423 if !self.context_dropped_ids.iter().any(|id| id == &item.id) {
424 self.context_dropped_ids.push(item.id.clone());
425 }
426 }
427 session::SessionRecord::ContextRecovery { item, .. } => {
428 self.context_dropped_ids.retain(|id| id != &item.id);
429 if item.kind != ContextItemKind::Harness
430 && !item.visibility.is_rendered()
431 && !self.context_pins.iter().any(|pin| pin.id == item.id)
432 {
433 self.context_pins.push(pinned_candidate_from_meta(item));
434 }
435 }
436 session::SessionRecord::ToolFinished { call_id, artifact: Some(artifact), .. } => {
437 self.tool_artifacts.insert(call_id.clone(), artifact.handle.clone());
438 }
439 session::SessionRecord::Compaction { audit, .. } => {
440 for candidate in &mut self.compaction_summaries {
441 candidate.latest = false;
442 }
443 let mut candidate = CompactionSummaryCandidate::new(
444 &self.session_id,
445 audit.covered_start_seq,
446 audit.covered_end_seq,
447 audit.summary.len(),
448 true,
449 );
450 candidate.content = Some(audit.summary.clone());
451 self.compaction_summaries.push(candidate);
452 self.last_compaction_review = audit.review;
453 }
454 session::SessionRecord::CompactionReview { review, .. } => {
455 self.last_compaction_review = Some(*review);
456 }
457 _ => {}
458 }
459 }
460 self.context_ledger = None;
461 }
462
463 fn context_item(&self, reference: &str) -> Result<Option<&agent_context::ContextItem>, String> {
464 let Some(ledger) = &self.context_ledger else {
465 return Ok(None);
466 };
467 let matches = ledger
468 .items
469 .iter()
470 .filter(|item| item.id == reference || item.id.starts_with(reference))
471 .collect::<Vec<_>>();
472 match matches.as_slice() {
473 [] => Ok(None),
474 [item] => Ok(Some(item)),
475 _ => Err(format!("context reference `{reference}` is ambiguous")),
476 }
477 }
478
479 fn resolve_context_path(&self, value: &str) -> Result<PathBuf, String> {
480 let path = Path::new(value);
481 let path = if path.is_absolute() { path.to_path_buf() } else { self.cwd.join(path) };
482 let canonical = path
483 .canonicalize()
484 .map_err(|error| format!("cannot pin `{}`: {error}", redact_context_display(value)))?;
485 if !canonical.starts_with(&self.cwd) {
486 return Err("context pins must stay inside the workspace".to_string());
487 }
488 if !canonical.is_file() {
489 return Err(format!("context pin is not a file: {}", redact_context_display(value)));
490 }
491 Ok(canonical)
492 }
493}
494pub const CONTEXT_INSPECTION_MAX_ITEMS: usize = 64;
495const CONTEXT_DISPLAY_MAX_BYTES: usize = 160;
496
497fn transcript_candidate_label(entry: &Entry) -> String {
498 match entry {
499 Entry::User { .. } => "user".to_string(),
500 Entry::Agent { .. } => "assistant".to_string(),
501 Entry::Reasoning { .. } => "reasoning".to_string(),
502 Entry::Tool { name, .. } => format!("tool:{name}"),
503 Entry::Status { .. } => "status".to_string(),
504 Entry::Error { .. } => "error".to_string(),
505 }
506}
507
508fn pinned_candidate_from_meta(item: &session::ContextItemMeta) -> PinnedCandidate {
509 PinnedCandidate {
510 id: item.id.clone(),
511 kind: item.kind.clone(),
512 label: item.source_path.clone().unwrap_or_else(|| item.id.clone()),
513 source_path: item.source_path.clone().map(PathBuf::from),
514 scope: item.scope.clone().unwrap_or_else(|| ".".to_string()),
515 content_hash: item.content_hash,
516 artifact_handle: item.artifact_handle.clone(),
517 bytes: item.byte_count,
518 }
519}
520
521fn transcript_candidate_bytes(entry: &Entry) -> usize {
522 match entry {
523 Entry::User { text }
524 | Entry::Agent { text, .. }
525 | Entry::Reasoning { text, .. }
526 | Entry::Status { text }
527 | Entry::Error { text } => text.len(),
528 Entry::Tool { name, arguments, output, .. } => {
529 name.len() + arguments.len() + output.iter().map(String::len).sum::<usize>()
530 }
531 }
532}
533
534fn file_size(path: &Path) -> usize {
535 std::fs::metadata(path)
536 .map(|metadata| metadata.len().min(usize::MAX as u64) as usize)
537 .unwrap_or(0)
538}
539
540fn redact_context_display(value: &str) -> String {
541 let redacted = tools::shell::redact_secrets(value);
542 utils::truncate_ellipsis(&redacted, CONTEXT_DISPLAY_MAX_BYTES)
543}
544
545pub fn compaction_mode_label(app: &App) -> &'static str {
546 app.effective_compaction_policy().mode.label()
547}
548
549pub fn start_auto_compaction(app: &mut App, original_user_turn: String) -> Option<Msg> {
558 start_compaction(app, session::CompactionTrigger::Automatic, Some(original_user_turn))
559}
560
561pub fn start_compaction(
567 app: &mut App, trigger: session::CompactionTrigger, original_user_turn: Option<String>,
568) -> Option<Msg> {
569 let original_transcript = app.transcript.clone();
570 let source = render_compaction_source(&original_transcript);
571 let policy = app.effective_compaction_policy();
572 let covered_start_seq = 1;
573 let covered_end_seq = app
574 .session_writer
575 .as_ref()
576 .map_or(0, |writer| writer.next_sequence().saturating_sub(1));
577 let recovery_handle = format!("session:{}:{covered_start_seq}..{covered_end_seq}", app.session_id);
578 let request = match agent_context::prepare_manual_compaction(policy, &app.model, &source, &recovery_handle) {
579 Ok(request) => request,
580 Err(message) => {
581 app.transcript.push(Entry::Error { text: message });
582 if trigger == session::CompactionTrigger::Automatic
583 && let Some(turn) = original_user_turn
584 {
585 app.last_input = Some(turn);
586 }
587 return None;
588 }
589 };
590
591 let started = match super::input::submit_internal_turn(app, request.prompt) {
592 Some(msg) => msg,
593 None => {
594 app.transcript = original_transcript;
595 if trigger == session::CompactionTrigger::Automatic
596 && let Some(turn) = original_user_turn
597 {
598 app.last_input = Some(turn);
599 }
600 return None;
601 }
602 };
603 app.pending_manual_compaction = Some(PendingManualCompaction {
604 original_transcript,
605 covered_start_seq,
606 covered_end_seq,
607 recovery_handle,
608 trigger,
609 original_user_turn,
610 });
611 Some(started)
612}
613
614fn render_compaction_source(entries: &[Entry]) -> String {
616 entries
617 .iter()
618 .map(|entry| match entry {
619 Entry::User { text } => format!("user: {text}"),
620 Entry::Agent { text, .. } => format!("assistant: {text}"),
621 Entry::Reasoning { text, .. } => format!("reasoning: {text}"),
622 Entry::Tool { name, arguments, output, .. } => {
623 format!("tool {name} {arguments}: {}", output.join("\n"))
624 }
625 Entry::Status { text } => format!("status: {text}"),
626 Entry::Error { text } => format!("error: {text}"),
627 })
628 .collect::<Vec<_>>()
629 .join("\n\n")
630}
631
632pub fn finish_manual_compaction(app: &mut App) -> Option<Option<Msg>> {
634 let pending = app.pending_manual_compaction.take()?;
635 let summary = app.transcript.iter().rev().find_map(|entry| match entry {
636 Entry::Agent { text, .. } if !text.trim().is_empty() => Some(text.clone()),
637 _ => None,
638 });
639 let Some(summary) = summary else {
640 restore_failed_compaction(app, pending);
641 app.transcript
642 .push(Entry::Error { text: "compaction model returned no summary".to_string() });
643 return Some(None);
644 };
645
646 let risk = classify_compaction_risk(&pending.original_transcript);
647 let review = if app.effective_compaction_policy().requires_review(match risk {
648 session::CompactionRisk::Low => agent_context::CompactionRisk::Low,
649 session::CompactionRisk::High => agent_context::CompactionRisk::High,
650 }) {
651 session::CompactionReviewResult::Pending
652 } else {
653 session::CompactionReviewResult::NotRequired
654 };
655 let audit = session::CompactionAudit {
656 summary: summary.clone(),
657 covered_start_seq: pending.covered_start_seq,
658 covered_end_seq: pending.covered_end_seq,
659 source_hashes: Vec::new(),
660 trigger: pending.trigger,
661 risk,
662 review: Some(review),
663 recovery_handles: vec![pending.recovery_handle.clone()],
664 model: app.model.clone(),
665 usage: None,
666 };
667 if let Some(writer) = app.session_writer.as_mut()
668 && let Err(error) = writer.append_compaction(&audit)
669 {
670 restore_failed_compaction(app, pending);
671 app.transcript
672 .push(Entry::Error { text: format!("failed to record compaction audit: {error}") });
673 return Some(None);
674 }
675
676 match review {
677 session::CompactionReviewResult::Pending => {
678 let recovery_handle = pending.recovery_handle.clone();
679 let saved_pending = pending.clone();
680 restore_failed_compaction(app, saved_pending);
681 app.pending_compaction_review = Some(PendingCompactionReview { pending, summary });
682 app.last_compaction_review = Some(review);
683 app.transcript
684 .push(Entry::Status { text: format!("compaction review pending {recovery_handle}") });
685 Some(None)
686 }
687 _ => {
688 app.last_compaction_review = Some(review);
689 apply_compaction(app, pending, summary)
690 }
691 }
692}
693
694pub fn apply_compaction(app: &mut App, pending: PendingManualCompaction, summary: String) -> Option<Option<Msg>> {
697 let is_automatic = pending.trigger == session::CompactionTrigger::Automatic;
698 let original_user_turn = pending.original_user_turn.clone();
699 for candidate in &mut app.compaction_summaries {
700 candidate.latest = false;
701 }
702 let mut summary_candidate = CompactionSummaryCandidate::new(
703 &app.session_id,
704 pending.covered_start_seq,
705 pending.covered_end_seq,
706 summary.len(),
707 true,
708 );
709 summary_candidate.content = Some(summary.clone());
710 app.compaction_summaries.push(summary_candidate);
711
712 if is_automatic {
713 app.transcript.clear();
714 } else {
715 app.transcript = pending.original_transcript;
716 app.transcript
717 .push(Entry::Status { text: format!("compacted {}", pending.recovery_handle) });
718 }
719 let summary_entry = Entry::Agent { text: summary, streaming: false };
720 app.transcript.push(summary_entry.clone());
721 if let Some(writer) = app.session_writer.as_mut() {
722 let turn_id = format!("turn_{}", app.turn_count);
723 let _ = writer.append_entry(&summary_entry, &turn_id);
724 }
725 if is_automatic {
726 app.transcript
727 .push(Entry::Status { text: format!("auto-compacted {}", pending.recovery_handle) });
728 if let Some(turn) = original_user_turn {
729 return Some(super::input::submit_user_turn(app, turn));
730 }
731 }
732 Some(None)
733}
734
735pub fn restore_failed_manual_compaction(app: &mut App) -> bool {
741 match app.pending_manual_compaction.take() {
742 Some(pending) => {
743 restore_failed_compaction(app, pending);
744 true
745 }
746 None => false,
747 }
748}
749
750pub fn restore_failed_compaction(app: &mut App, pending: PendingManualCompaction) {
753 app.transcript = pending.original_transcript;
754 if pending.trigger == session::CompactionTrigger::Automatic
755 && let Some(turn) = pending.original_user_turn
756 {
757 app.last_input = Some(turn);
758 }
759}
760
761fn classify_compaction_risk(entries: &[Entry]) -> CompactionRisk {
763 let signals = agent_context::CompactionRiskSignals {
764 has_tool_output_or_diff: entries.iter().any(|entry| matches!(entry, Entry::Tool { .. })),
765 has_failure_or_permission: entries.iter().any(|entry| matches!(entry, Entry::Error { .. })),
766 has_correction_or_unresolved_work: entries.iter().any(
767 |entry| matches!(entry, Entry::Status { text } if text.contains("permission") || text.contains("failed")),
768 ),
769 };
770 match signals.classify() {
771 agent_context::CompactionRisk::Low => session::CompactionRisk::Low,
772 agent_context::CompactionRisk::High => session::CompactionRisk::High,
773 }
774}
775
776pub fn run_doctor_slash(app: &mut App) {
777 app.refresh_context_ledger(None);
778 let Some(ledger) = app.context_ledger.as_ref() else {
779 app.transcript
780 .push(Entry::Error { text: "context health is unavailable".to_string() });
781 return;
782 };
783 let counts = ledger.counts();
784 let review = app.last_compaction_review.map(|a| a.label()).unwrap_or("none");
785 app.transcript.push(Entry::Status {
786 text: format!(
787 "thndrs doctor (context health)\nsources: {} ({} discovery diagnostics)\npins: {} dropped: {}\nbudget: {} / {} used, {} available, {} auto threshold\nlimits: {} ({})\ncompaction: {} review {}",
788 app.context_sources.len(),
789 app.context_diagnostics.len(),
790 counts.pinned,
791 counts.dropped,
792 ledger.budget.used,
793 ledger.budget.target,
794 ledger.budget.available_input,
795 ledger.budget.auto_compaction_threshold,
796 ledger.budget.limits.source.label(),
797 ledger.budget.limits.confidence.label(),
798 compaction_mode_label(app),
799 review,
800 ),
801 });
802}
803
804pub fn handle_context_command(app: &mut App, command: &str) -> Option<Msg> {
805 let Some((action, reference)) = command.split_once(' ') else {
806 return match command {
807 "show" => {
808 app.open_context_surface();
809 None
810 }
811 "drop --reset" => {
812 match app.reset_context_drops() {
813 Ok(()) => app.input.clear(),
814 Err(error) => app.transcript.push(Entry::Error { text: error }),
815 }
816 None
817 }
818 "review" => {
819 let state = app.last_compaction_review.map(|a| a.label()).unwrap_or("none");
820 app.transcript
821 .push(Entry::Status { text: format!("compaction review: {state}") });
822 app.input.clear();
823 None
824 }
825 "export" => {
826 app.transcript.push(Entry::Error {
827 text: "usage: /context export <path> [json|markdown] [--artifacts]".to_string(),
828 });
829 None
830 }
831 "pin" | "drop" | "recover" => {
832 app.transcript
833 .push(Entry::Error { text: format!("usage: /context {command} <id-or-path>") });
834 None
835 }
836 _ => {
837 app.transcript
838 .push(Entry::Error { text: "usage: /context [show|pin|drop|recover|review]".to_string() });
839 None
840 }
841 };
842 };
843
844 let result = match action {
845 "pin" => app.pin_context_reference(reference.trim()),
846 "drop" if reference.trim() == "--reset" => app.reset_context_drops(),
847 "drop" => app.drop_context_reference(reference.trim()),
848 "recover" => app.recover_context_reference(reference.trim()),
849 "review" => return handle_context_review(app, reference.trim()),
850 "export" => return handle_context_export(app, reference.trim()),
851 _ => Err("usage: /context [show|pin|drop|recover|review|export]".to_string()),
852 };
853 match result {
854 Ok(()) => {
855 app.input.clear();
856 if matches!(action, "pin" | "drop" | "recover") {
857 app.prompt_accessory = PromptAccessory::Context;
858 }
859 }
860 Err(error) => app.transcript.push(Entry::Error { text: error }),
861 }
862 None
863}
864
865fn handle_context_export(app: &mut App, input: &str) -> Option<Msg> {
866 let mut path = None;
867 let mut format = None;
868 let mut include_artifacts = false;
869 for part in input.split_whitespace() {
870 if matches!(part, "--artifacts" | "--include-artifacts") {
871 include_artifacts = true;
872 } else if let Some(parsed) = ContextExportFormat::parse(part) {
873 if format.replace(parsed).is_some() {
874 app.transcript
875 .push(Entry::Error { text: "context export format was specified more than once".to_string() });
876 return None;
877 }
878 } else if path.replace(part).is_some() {
879 app.transcript
880 .push(Entry::Error { text: "usage: /context export <path> [json|markdown] [--artifacts]".to_string() });
881 return None;
882 }
883 }
884 let Some(path) = path else {
885 app.transcript
886 .push(Entry::Error { text: "usage: /context export <path> [json|markdown] [--artifacts]".to_string() });
887 return None;
888 };
889 let path = Path::new(path);
890 let path = if path.is_absolute() { path.to_path_buf() } else { app.cwd.join(path) };
891 let format = format.unwrap_or_else(|| match path.extension().and_then(|extension| extension.to_str()) {
892 Some("md" | "markdown") => ContextExportFormat::Markdown,
893 _ => ContextExportFormat::Json,
894 });
895 match app.write_context_export(&path, format, include_artifacts) {
896 Ok(()) => app.transcript.push(Entry::Status {
897 text: format!(
898 "context exported: {} ({})",
899 redact_context_display(&path.display().to_string()),
900 format.label()
901 ),
902 }),
903 Err(error) => app.transcript.push(Entry::Error { text: error }),
904 }
905 app.input.clear();
906 None
907}
908
909fn handle_context_review(app: &mut App, action: &str) -> Option<Msg> {
910 let Some(pending) = app.pending_compaction_review.as_ref() else {
911 app.transcript
912 .push(Entry::Error { text: "no compaction summary is awaiting review".to_string() });
913 return None;
914 };
915 let review = match action {
916 "approve" => session::CompactionReviewResult::Approved,
917 "reject" => session::CompactionReviewResult::Rejected,
918 _ => {
919 app.transcript
920 .push(Entry::Error { text: "usage: /context review <approve|reject>".to_string() });
921 return None;
922 }
923 };
924 if let Some(writer) = app.session_writer.as_mut()
925 && let Err(error) = writer.append_compaction_review(&pending.pending.recovery_handle, review)
926 {
927 app.transcript
928 .push(Entry::Error { text: format!("failed to record compaction review: {error}") });
929 return None;
930 }
931 let pending = app
932 .pending_compaction_review
933 .take()
934 .expect("review state checked above");
935 app.last_compaction_review = Some(review);
936 app.input.clear();
937 if review == session::CompactionReviewResult::Rejected {
938 let recovery_handle = pending.pending.recovery_handle.clone();
939 let original_user_turn = pending.pending.original_user_turn.clone();
940 restore_failed_compaction(app, pending.pending);
941 if let Some(turn) = original_user_turn {
942 app.input.set_text(&turn);
943 }
944 app.transcript
945 .push(Entry::Status { text: format!("compaction rejected {recovery_handle}") });
946 return None;
947 }
948 apply_compaction(app, pending.pending, pending.summary).flatten()
949}