Skip to main content

thndrs_lib/cli/app/
context.rs

1//! Builds the context ledger from prompt fragments, workspace instructions,
2//! skills, pins, compaction summaries, and transcript candidates.
3//!
4//! Pin, drop, and recovery actions append redacted metadata to the session.
5//! Compaction saves the current transcript and applies a provider summary only
6//! after its audit record is written; failed or rejected compactions restore the
7//! saved transcript and any pending user turn.
8
9use crate::context::export::{ContextExport, ContextExportFormat, ExportArtifact, artifact_from_recovery};
10use crate::session::CompactionRisk;
11
12use super::*;
13
14/// Pending compaction request with enough information to atomically replace
15/// active context after a successful configured-model response.
16///
17/// Carries both the manual (`/compact`) and automatic (preflight pressure)
18/// paths. For automatic compaction, `original_user_turn` holds the user turn
19/// to restart after the summary is applied; for manual compaction it is
20/// `None` because `/compact` is a command, not a submitted turn.
21#[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    /// Manual or automatic initiation, written to the audit record.
28    trigger: session::CompactionTrigger,
29    /// The user turn to restart after a successful automatic compaction.
30    /// `None` for manual compaction.
31    original_user_turn: Option<String>,
32}
33
34/// A provider-generated summary waiting for the user to approve or reject its
35/// replacement of the active transcript range.
36#[derive(Clone, Debug)]
37pub struct PendingCompactionReview {
38    pending: PendingManualCompaction,
39    summary: String,
40}
41impl App {
42    /// Recover bounded redacted evidence for a context item or artifact handle.
43    ///
44    /// The recovery action is appended even when the body is missing or
45    /// expired, so the item's audit metadata remains useful across resume.
46    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    /// Rebuild the deterministic context ledger for a turn boundary.
70    ///
71    /// The caller owns discovery, transcript projection, and persistence. The
72    /// agent library receives only typed candidates and returns the policy
73    /// result. This method also stores the latest ledger for bounded inspection.
74    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    /// Open the bounded context inspection surface.
175    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    /// Build the current bounded context export.
182    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    /// Render and atomically write a bounded context export.
222    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
549/// Start an automatic compaction triggered by preflight context pressure.
550///
551/// `original_user_turn` is the user turn that was about to be sent to the
552/// provider; it is restarted after the summary is applied. The user turn is
553/// already in the transcript, so the covered range starts from sequence 1.
554///
555/// Returns `None` (without spawning) when compaction cannot start, leaving
556/// the submitted turn recoverable.
557pub 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
561/// Shared core for manual and automatic compaction.
562///
563/// Saves the active transcript, builds a configured-model summary request,
564/// starts it as an internal turn, and records enough state to atomically
565/// replace active context on success or restore it on failure.
566pub 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
614/// Render active transcript material for the configured compaction model.
615fn 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
632// FIXME: wrapping an option in an option is awful
633pub 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
694/// Apply an approved or review-free summary to the active working set.
695/// FIXME: wrapping an option in an option is awful
696pub 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
735/// Restore active context when a compaction request fails or cannot complete.
736///
737/// For automatic compaction, the submitted user turn is preserved by restoring
738/// `last_input` so the user can resubmit or edit it. For manual compaction,
739/// only the transcript is restored.
740pub 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
750/// Restore the saved transcript and, for automatic compaction, the submitted
751/// user turn.
752pub 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
761/// Map transcript signals to the durable compaction-risk classification.
762fn 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}