Skip to main content

sc/cli/commands/
compaction.rs

1//! Compaction command implementation.
2//!
3//! Prepares context for compaction by creating an auto-checkpoint
4//! and returning a summary of critical context items.
5
6use crate::config::{
7    current_git_branch, default_actor, resolve_db_path, resolve_session_or_suggest,
8};
9use crate::error::{Error, Result};
10use crate::storage::SqliteStorage;
11use serde::Serialize;
12use std::path::PathBuf;
13
14/// Limits for compaction context items (matching MCP server)
15const HIGH_PRIORITY_LIMIT: u32 = 50;
16const DECISION_LIMIT: u32 = 20;
17const REMINDER_LIMIT: u32 = 20;
18const PROGRESS_LIMIT: u32 = 10;
19
20/// Output for compaction command.
21#[derive(Serialize)]
22struct CompactionOutput {
23    checkpoint: CheckpointInfo,
24    stats: CompactionStats,
25    git_context: Option<GitContext>,
26    critical_context: CriticalContext,
27    restore_instructions: RestoreInstructions,
28}
29
30#[derive(Serialize)]
31struct CheckpointInfo {
32    id: String,
33    name: String,
34    session_id: String,
35    created_at: i64,
36}
37
38#[derive(Serialize)]
39struct CompactionStats {
40    total_items_saved: i64,
41    critical_items: usize,
42    pending_tasks: usize,
43    decisions_made: usize,
44}
45
46#[derive(Serialize)]
47struct GitContext {
48    branch: String,
49    files: Vec<String>,
50}
51
52#[derive(Serialize)]
53struct CriticalContext {
54    high_priority_items: Vec<ContextSummary>,
55    next_steps: Vec<ContextSummary>,
56    key_decisions: Vec<ContextSummary>,
57    recent_progress: Vec<ContextSummary>,
58}
59
60#[derive(Serialize)]
61struct ContextSummary {
62    key: String,
63    value: String,
64    category: String,
65    priority: String,
66}
67
68#[derive(Serialize)]
69struct RestoreInstructions {
70    tool: String,
71    checkpoint_id: String,
72    message: String,
73    summary: String,
74}
75
76/// Execute compaction command.
77pub fn execute(db_path: Option<&PathBuf>, actor: Option<&str>, session_id: Option<&str>, json: bool) -> Result<()> {
78    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
79
80    if !db_path.exists() {
81        return Err(Error::NotInitialized);
82    }
83
84    let mut storage = SqliteStorage::open(&db_path)?;
85    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
86
87    let sid = resolve_session_or_suggest(session_id, &storage)?;
88    let session = storage
89        .get_session(&sid)?
90        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
91
92    // Generate checkpoint name with timestamp
93    let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
94    let checkpoint_name = format!("pre-compact-{timestamp}");
95
96    // Get git info
97    let git_branch = current_git_branch();
98    let git_status = get_git_status();
99
100    // Generate checkpoint ID
101    let checkpoint_id = format!("ckpt_{}", &uuid::Uuid::new_v4().to_string()[..12]);
102
103    // Create checkpoint
104    storage.create_checkpoint(
105        &checkpoint_id,
106        &session.id,
107        &checkpoint_name,
108        Some("Automatic checkpoint before context compaction"),
109        git_status.as_deref(),
110        git_branch.as_deref(),
111        &actor,
112    )?;
113
114    // Get current context items to include in checkpoint
115    let all_items = storage.get_context_items(&session.id, None, None, Some(1000))?;
116    for item in &all_items {
117        storage.add_checkpoint_item(&checkpoint_id, &item.id, &actor)?;
118    }
119
120    // Analyze critical context
121    let high_priority_items =
122        storage.get_context_items(&session.id, None, Some("high"), Some(HIGH_PRIORITY_LIMIT))?;
123
124    let reminders =
125        storage.get_context_items(&session.id, Some("reminder"), None, Some(REMINDER_LIMIT))?;
126
127    let decisions =
128        storage.get_context_items(&session.id, Some("decision"), None, Some(DECISION_LIMIT))?;
129
130    let progress =
131        storage.get_context_items(&session.id, Some("progress"), None, Some(PROGRESS_LIMIT))?;
132
133    // Identify unfinished reminders (next steps)
134    let next_steps: Vec<_> = reminders
135        .iter()
136        .filter(|t| {
137            let lower = t.value.to_lowercase();
138            !lower.contains("completed")
139                && !lower.contains("done")
140                && !lower.contains("[completed]")
141        })
142        .take(5)
143        .collect();
144
145    // Get checkpoint for stats
146    let checkpoint = storage
147        .get_checkpoint(&checkpoint_id)?
148        .ok_or_else(|| Error::CheckpointNotFound {
149            id: checkpoint_id.clone(),
150        })?;
151
152    // Parse git status for file list
153    let git_files: Vec<String> = git_status
154        .as_ref()
155        .map(|s| {
156            s.lines()
157                .take(10)
158                .map(|line| line.trim().to_string())
159                .collect()
160        })
161        .unwrap_or_default();
162
163    if json {
164        let output = CompactionOutput {
165            checkpoint: CheckpointInfo {
166                id: checkpoint.id.clone(),
167                name: checkpoint.name.clone(),
168                session_id: session.id.clone(),
169                created_at: checkpoint.created_at,
170            },
171            stats: CompactionStats {
172                total_items_saved: checkpoint.item_count,
173                critical_items: high_priority_items.len(),
174                pending_tasks: next_steps.len(),
175                decisions_made: decisions.len(),
176            },
177            git_context: git_branch.as_ref().map(|branch| GitContext {
178                branch: branch.clone(),
179                files: git_files.clone(),
180            }),
181            critical_context: CriticalContext {
182                high_priority_items: high_priority_items
183                    .iter()
184                    .take(5)
185                    .map(|i| ContextSummary {
186                        key: i.key.clone(),
187                        value: i.value.clone(),
188                        category: i.category.clone(),
189                        priority: i.priority.clone(),
190                    })
191                    .collect(),
192                next_steps: next_steps
193                    .iter()
194                    .map(|t| ContextSummary {
195                        key: t.key.clone(),
196                        value: t.value.clone(),
197                        category: t.category.clone(),
198                        priority: t.priority.clone(),
199                    })
200                    .collect(),
201                key_decisions: decisions
202                    .iter()
203                    .take(10)
204                    .map(|d| ContextSummary {
205                        key: d.key.clone(),
206                        value: d.value.clone(),
207                        category: d.category.clone(),
208                        priority: d.priority.clone(),
209                    })
210                    .collect(),
211                recent_progress: progress
212                    .iter()
213                    .take(3)
214                    .map(|p| ContextSummary {
215                        key: p.key.clone(),
216                        value: p.value.clone(),
217                        category: p.category.clone(),
218                        priority: p.priority.clone(),
219                    })
220                    .collect(),
221            },
222            restore_instructions: RestoreInstructions {
223                tool: "sc checkpoint restore".to_string(),
224                checkpoint_id: checkpoint.id.clone(),
225                message: format!(
226                    "To continue this session, restore from checkpoint: {}",
227                    checkpoint.name
228                ),
229                summary: format!(
230                    "Session has {} pending tasks and {} key decisions recorded.",
231                    next_steps.len(),
232                    decisions.len()
233                ),
234            },
235        };
236        println!("{}", serde_json::to_string_pretty(&output)?);
237    } else {
238        println!("Context Compaction Prepared");
239        println!("===========================");
240        println!();
241        println!("Checkpoint: {}", checkpoint.name);
242        println!("  ID: {}", checkpoint.id);
243        println!("  Items saved: {}", checkpoint.item_count);
244        println!();
245
246        if let Some(ref branch) = git_branch {
247            println!("Git Context:");
248            println!("  Branch: {branch}");
249            if !git_files.is_empty() {
250                println!("  Changes:");
251                for file in git_files.iter().take(5) {
252                    println!("    {file}");
253                }
254            }
255            println!();
256        }
257
258        println!("Critical Context:");
259        println!(
260            "  High priority items: {}",
261            high_priority_items.len().min(5)
262        );
263        println!("  Pending tasks: {}", next_steps.len());
264        println!("  Key decisions: {}", decisions.len().min(10));
265        println!("  Recent progress: {}", progress.len().min(3));
266        println!();
267
268        if !next_steps.is_empty() {
269            println!("Next Steps:");
270            for step in next_steps.iter().take(3) {
271                println!("  - {} ({})", step.key, truncate(&step.value, 60));
272            }
273            println!();
274        }
275
276        if !decisions.is_empty() {
277            println!("Key Decisions:");
278            for decision in decisions.iter().take(3) {
279                println!("  - {} ({})", decision.key, truncate(&decision.value, 60));
280            }
281            println!();
282        }
283
284        println!("Restore Instructions:");
285        println!("  sc checkpoint restore {}", checkpoint.id);
286    }
287
288    Ok(())
289}
290
291/// Get current git status output.
292fn get_git_status() -> Option<String> {
293    std::process::Command::new("git")
294        .args(["status", "--porcelain"])
295        .output()
296        .ok()
297        .filter(|output| output.status.success())
298        .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
299}
300
301/// Truncate a string to max length with ellipsis.
302fn truncate(s: &str, max_len: usize) -> String {
303    if s.len() <= max_len {
304        s.to_string()
305    } else {
306        format!("{}...", &s[..max_len.saturating_sub(3)])
307    }
308}