Skip to main content

lean_ctx/tools/
ctx_session.rs

1use crate::core::session::SessionState;
2
3pub fn handle(
4    session: &mut SessionState,
5    action: &str,
6    value: Option<&str>,
7    session_id: Option<&str>,
8) -> String {
9    match action {
10        "status" => session.format_compact(),
11
12        "load" => {
13            let loaded = if let Some(id) = session_id {
14                SessionState::load_by_id(id)
15            } else {
16                SessionState::load_latest()
17            };
18
19            if let Some(prev) = loaded {
20                let summary = prev.format_compact();
21                *session = prev;
22                format!("Session loaded.\n{summary}")
23            } else {
24                let id_str = session_id.unwrap_or("latest");
25                format!("No session found (id: {id_str}). Starting fresh.")
26            }
27        }
28
29        "save" => {
30            match session.save() {
31                Ok(()) => format!("Session {} saved (v{}).", session.id, session.version),
32                Err(e) => format!("Save failed: {e}"),
33            }
34        }
35
36        "task" => {
37            let desc = value.unwrap_or("(no description)");
38            session.set_task(desc, None);
39            format!("Task set: {desc}")
40        }
41
42        "finding" => {
43            let summary = value.unwrap_or("(no summary)");
44            let (file, line, text) = parse_finding_value(summary);
45            session.add_finding(file.as_deref(), line, text);
46            format!("Finding added: {summary}")
47        }
48
49        "decision" => {
50            let desc = value.unwrap_or("(no description)");
51            session.add_decision(desc, None);
52            format!("Decision recorded: {desc}")
53        }
54
55        "reset" => {
56            let _ = session.save();
57            let old_id = session.id.clone();
58            *session = SessionState::new();
59            crate::core::budget_tracker::BudgetTracker::global().reset();
60            format!("Session reset. Previous: {old_id}. New: {}", session.id)
61        }
62
63        "list" => {
64            let sessions = SessionState::list_sessions();
65            if sessions.is_empty() {
66                return "No sessions found.".to_string();
67            }
68            let mut lines = vec![format!("Sessions ({}):", sessions.len())];
69            for s in sessions.iter().take(10) {
70                let task = s.task.as_deref().unwrap_or("(no task)");
71                let task_short: String = task.chars().take(40).collect();
72                lines.push(format!(
73                    "  {} v{} | {} calls | {} tok | {}",
74                    s.id, s.version, s.tool_calls, s.tokens_saved, task_short
75                ));
76            }
77            if sessions.len() > 10 {
78                lines.push(format!("  ... +{} more", sessions.len() - 10));
79            }
80            lines.join("\n")
81        }
82
83        "cleanup" => {
84            let removed = SessionState::cleanup_old_sessions(7);
85            format!("Cleaned up {removed} old session(s) (>7 days).")
86        }
87
88        "snapshot" => match session.save_compaction_snapshot() {
89            Ok(snapshot) => {
90                format!(
91                    "Compaction snapshot saved ({} bytes).\n{snapshot}",
92                    snapshot.len()
93                )
94            }
95            Err(e) => format!("Snapshot failed: {e}"),
96        },
97
98        "restore" => {
99            let snapshot = if let Some(id) = session_id {
100                SessionState::load_compaction_snapshot(id)
101            } else {
102                SessionState::load_latest_snapshot()
103            };
104            match snapshot {
105                Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
106                None => "No compaction snapshot found. Session continues fresh.".to_string(),
107            }
108        }
109
110        "resume" => session.build_resume_block(),
111
112        "profile" => {
113            use crate::core::profiles;
114            if let Some(name) = value {
115                if let Ok(p) = profiles::set_active_profile(name) {
116                    format!(
117                        "Profile switched to '{name}'.\n\
118                         Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
119                        p.read.default_mode,
120                        p.budget.max_context_tokens,
121                        p.compression.crp_mode,
122                        p.compression.output_density,
123                    )
124                } else {
125                    let available: Vec<String> =
126                        profiles::list_profiles().iter().map(|p| p.name.clone()).collect();
127                    format!(
128                        "Profile '{name}' not found. Available: {}",
129                        available.join(", ")
130                    )
131                }
132            } else {
133                let name = profiles::active_profile_name();
134                let p = profiles::active_profile();
135                let list = profiles::list_profiles();
136                let mut out = format!(
137                    "Active profile: {name}\n\
138                     Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
139                     Available profiles:",
140                    p.read.default_mode,
141                    p.budget.max_context_tokens,
142                    p.compression.crp_mode,
143                    p.compression.output_density,
144                );
145                for info in &list {
146                    let marker = if info.name == name { " *" } else { "  " };
147                    out.push_str(&format!(
148                        "\n{marker} {:<14} ({}) {}",
149                        info.name, info.source, info.description
150                    ));
151                }
152                out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
153                out
154            }
155        }
156
157        "budget" => {
158            use crate::core::budget_tracker::BudgetTracker;
159            let snap = BudgetTracker::global().check();
160            snap.format_compact()
161        }
162
163        "role" => {
164            use crate::core::roles;
165            if let Some(name) = value {
166                match roles::set_active_role(name) {
167                    Ok(r) => {
168                        crate::core::budget_tracker::BudgetTracker::global().reset();
169                        format!(
170                            "Role switched to '{name}'.\n\
171                             Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
172                             Tools: {}",
173                            r.role.shell_policy,
174                            r.limits.max_context_tokens,
175                            r.limits.max_shell_invocations,
176                            r.limits.max_cost_usd,
177                            if r.tools.allowed.iter().any(|a| a == "*") {
178                                let denied = if r.tools.denied.is_empty() {
179                                    "none".to_string()
180                                } else {
181                                    format!("denied: {}", r.tools.denied.join(", "))
182                                };
183                                format!("* (all), {denied}")
184                            } else {
185                                r.tools.allowed.join(", ")
186                            }
187                        )
188                    }
189                    Err(e) => {
190                        let available: Vec<String> =
191                            roles::list_roles().iter().map(|r| r.name.clone()).collect();
192                        format!("{e}. Available: {}", available.join(", "))
193                    }
194                }
195            } else {
196                let name = roles::active_role_name();
197                let r = roles::active_role();
198                let list = roles::list_roles();
199                let mut out = format!(
200                    "Active role: {name}\n\
201                     Description: {}\n\
202                     Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
203                     Available roles:",
204                    r.role.description,
205                    r.role.shell_policy,
206                    r.limits.max_context_tokens,
207                    r.limits.max_shell_invocations,
208                    r.limits.max_cost_usd,
209                );
210                for info in &list {
211                    let marker = if info.is_active { " *" } else { "  " };
212                    out.push_str(&format!(
213                        "\n{marker} {:<14} ({}) {}",
214                        info.name, info.source, info.description
215                    ));
216                }
217                out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
218                out
219            }
220        }
221
222        _ => format!("Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, profile, role, budget"),
223    }
224}
225
226fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
227    // Format: "file.rs:42 — summary text" or just "summary text"
228    if let Some(dash_pos) = value.find(" \u{2014} ").or_else(|| value.find(" - ")) {
229        let location = &value[..dash_pos];
230        let sep_len = 3;
231        let text = &value[dash_pos + sep_len..];
232
233        if let Some(colon_pos) = location.rfind(':') {
234            let file = &location[..colon_pos];
235            if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
236                return (Some(file.to_string()), Some(line), text);
237            }
238        }
239        return (Some(location.to_string()), None, text);
240    }
241    (None, None, value)
242}