Skip to main content

lean_ctx/tools/
ctx_session.rs

1use crate::core::session::SessionState;
2
3pub fn handle(
4    session: &mut SessionState,
5    tool_calls: &[(String, u64)],
6    action: &str,
7    value: Option<&str>,
8    session_id: Option<&str>,
9) -> String {
10    match action {
11        "status" => session.format_compact(),
12
13        "load" => {
14            let loaded = if let Some(id) = session_id {
15                SessionState::load_by_id(id)
16            } else {
17                SessionState::load_latest()
18            };
19
20            if let Some(prev) = loaded {
21                let summary = prev.format_compact();
22                *session = prev;
23                format!("Session loaded.\n{summary}")
24            } else {
25                let id_str = session_id.unwrap_or("latest");
26                format!("No session found (id: {id_str}). Starting fresh.")
27            }
28        }
29
30        "save" => {
31            match session.save() {
32                Ok(()) => format!("Session {} saved (v{}).", session.id, session.version),
33                Err(e) => format!("Save failed: {e}"),
34            }
35        }
36
37        "task" => {
38            let desc = value.unwrap_or("(no description)");
39            session.set_task(desc, None);
40            format!("Task set: {desc}")
41        }
42
43        "finding" => {
44            let summary = value.unwrap_or("(no summary)");
45            let (file, line, text) = parse_finding_value(summary);
46            session.add_finding(file.as_deref(), line, text);
47            format!("Finding added: {summary}")
48        }
49
50        "decision" => {
51            let desc = value.unwrap_or("(no description)");
52            session.add_decision(desc, None);
53            format!("Decision recorded: {desc}")
54        }
55
56        "reset" => {
57            let _ = session.save();
58            let old_id = session.id.clone();
59            *session = SessionState::new();
60            crate::core::budget_tracker::BudgetTracker::global().reset();
61            format!("Session reset. Previous: {old_id}. New: {}", session.id)
62        }
63
64        "list" => {
65            let sessions = SessionState::list_sessions();
66            if sessions.is_empty() {
67                return "No sessions found.".to_string();
68            }
69            let mut lines = vec![format!("Sessions ({}):", sessions.len())];
70            for s in sessions.iter().take(10) {
71                let task = s.task.as_deref().unwrap_or("(no task)");
72                let task_short: String = task.chars().take(40).collect();
73                lines.push(format!(
74                    "  {} v{} | {} calls | {} tok | {}",
75                    s.id, s.version, s.tool_calls, s.tokens_saved, task_short
76                ));
77            }
78            if sessions.len() > 10 {
79                lines.push(format!("  ... +{} more", sessions.len() - 10));
80            }
81            lines.join("\n")
82        }
83
84        "cleanup" => {
85            let removed = SessionState::cleanup_old_sessions(7);
86            format!("Cleaned up {removed} old session(s) (>7 days).")
87        }
88
89        "snapshot" => match session.save_compaction_snapshot() {
90            Ok(snapshot) => {
91                format!(
92                    "Compaction snapshot saved ({} bytes).\n{snapshot}",
93                    snapshot.len()
94                )
95            }
96            Err(e) => format!("Snapshot failed: {e}"),
97        },
98
99        "restore" => {
100            let snapshot = if let Some(id) = session_id {
101                SessionState::load_compaction_snapshot(id)
102            } else {
103                SessionState::load_latest_snapshot()
104            };
105            match snapshot {
106                Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
107                None => "No compaction snapshot found. Session continues fresh.".to_string(),
108            }
109        }
110
111        "resume" => session.build_resume_block(),
112
113        "profile" => {
114            use crate::core::profiles;
115            if let Some(name) = value {
116                if let Ok(p) = profiles::set_active_profile(name) {
117                    format!(
118                        "Profile switched to '{name}'.\n\
119                         Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
120                        p.read.default_mode,
121                        p.budget.max_context_tokens,
122                        p.compression.crp_mode,
123                        p.compression.output_density,
124                    )
125                } else {
126                    let available: Vec<String> =
127                        profiles::list_profiles().iter().map(|p| p.name.clone()).collect();
128                    format!(
129                        "Profile '{name}' not found. Available: {}",
130                        available.join(", ")
131                    )
132                }
133            } else {
134                let name = profiles::active_profile_name();
135                let p = profiles::active_profile();
136                let list = profiles::list_profiles();
137                let mut out = format!(
138                    "Active profile: {name}\n\
139                     Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
140                     Available profiles:",
141                    p.read.default_mode,
142                    p.budget.max_context_tokens,
143                    p.compression.crp_mode,
144                    p.compression.output_density,
145                );
146                for info in &list {
147                    let marker = if info.name == name { " *" } else { "  " };
148                    out.push_str(&format!(
149                        "\n{marker} {:<14} ({}) {}",
150                        info.name, info.source, info.description
151                    ));
152                }
153                out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
154                out
155            }
156        }
157
158        "budget" => {
159            use crate::core::budget_tracker::BudgetTracker;
160            let snap = BudgetTracker::global().check();
161            snap.format_compact()
162        }
163
164        "role" => {
165            use crate::core::roles;
166            if let Some(name) = value {
167                match roles::set_active_role(name) {
168                    Ok(r) => {
169                        crate::core::budget_tracker::BudgetTracker::global().reset();
170                        format!(
171                            "Role switched to '{name}'.\n\
172                             Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
173                             Tools: {}",
174                            r.role.shell_policy,
175                            r.limits.max_context_tokens,
176                            r.limits.max_shell_invocations,
177                            r.limits.max_cost_usd,
178                            if r.tools.allowed.iter().any(|a| a == "*") {
179                                let denied = if r.tools.denied.is_empty() {
180                                    "none".to_string()
181                                } else {
182                                    format!("denied: {}", r.tools.denied.join(", "))
183                                };
184                                format!("* (all), {denied}")
185                            } else {
186                                r.tools.allowed.join(", ")
187                            }
188                        )
189                    }
190                    Err(e) => {
191                        let available: Vec<String> =
192                            roles::list_roles().iter().map(|r| r.name.clone()).collect();
193                        format!("{e}. Available: {}", available.join(", "))
194                    }
195                }
196            } else {
197                let name = roles::active_role_name();
198                let r = roles::active_role();
199                let list = roles::list_roles();
200                let mut out = format!(
201                    "Active role: {name}\n\
202                     Description: {}\n\
203                     Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
204                     Available roles:",
205                    r.role.description,
206                    r.role.shell_policy,
207                    r.limits.max_context_tokens,
208                    r.limits.max_shell_invocations,
209                    r.limits.max_cost_usd,
210                );
211                for info in &list {
212                    let marker = if info.is_active { " *" } else { "  " };
213                    out.push_str(&format!(
214                        "\n{marker} {:<14} ({}) {}",
215                        info.name, info.source, info.description
216                    ));
217                }
218                out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
219                out
220            }
221        }
222
223        "diff" => {
224            let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
225            if parts.len() < 2 {
226                return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
227                        Formats: summary (default), json\n\
228                        Example: ctx_session diff abc123 def456 json"
229                    .to_string();
230            }
231            let id_a = parts[0];
232            let id_b = parts[1];
233            let format = parts.get(2).copied().unwrap_or("summary");
234
235            let sess_a = SessionState::load_by_id(id_a);
236            let sess_b = SessionState::load_by_id(id_b);
237
238            match (sess_a, sess_b) {
239                (Some(a), Some(b)) => {
240                    let d = crate::core::session_diff::diff_sessions(&a, &b);
241                    match format {
242                        "json" => d.format_json(),
243                        _ => d.format_summary(),
244                    }
245                }
246                (None, _) => format!("Session not found: {id_a}"),
247                (_, None) => format!("Session not found: {id_b}"),
248            }
249        }
250
251        "slo" => {
252            match value {
253                Some("reload") => {
254                    crate::core::slo::reload();
255                    "SLO definitions reloaded from disk.".to_string()
256                }
257                Some("history") => {
258                    let hist = crate::core::slo::violation_history(20);
259                    if hist.is_empty() {
260                        "No SLO violations recorded.".to_string()
261                    } else {
262                        let mut out = format!("SLO violations (last {}):\n", hist.len());
263                        for v in &hist {
264                            out.push_str(&format!(
265                                "  {} {} ({}) {:.2} vs {:.2} → {}\n",
266                                v.timestamp, v.slo_name, v.metric, v.actual, v.threshold, v.action
267                            ));
268                        }
269                        out
270                    }
271                }
272                Some("clear") => {
273                    crate::core::slo::clear_violations();
274                    "SLO violation history cleared.".to_string()
275                }
276                _ => {
277                    let snap = crate::core::slo::evaluate_quiet();
278                    snap.format_compact()
279                }
280            }
281        }
282
283        "verify" => {
284            let snap = crate::core::output_verification::stats_snapshot();
285            snap.format_compact()
286        }
287
288        "episodes" => {
289            let project_root = session.project_root.clone().unwrap_or_else(|| {
290                std::env::current_dir().map_or_else(
291                    |_| "unknown".to_string(),
292                    |p| p.to_string_lossy().to_string(),
293                )
294            });
295            let policy = match crate::core::config::Config::load().memory_policy_effective() {
296                Ok(p) => p,
297                Err(e) => {
298                    let path = crate::core::config::Config::path().map_or_else(
299                        || "~/.lean-ctx/config.toml".to_string(),
300                        |p| p.display().to_string(),
301                    );
302                    return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
303                }
304            };
305            let hash = crate::core::project_hash::hash_project_root(&project_root);
306            let mut store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
307
308            match value {
309                Some("record") => {
310                    let ep = crate::core::episodic_memory::create_episode_from_session(
311                        session,
312                        tool_calls,
313                    );
314                    let id = ep.id.clone();
315                    store.record_episode(ep, &policy.episodic);
316                    if let Err(e) = store.save() {
317                        return format!("Episode record failed: {e}");
318                    }
319                    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
320                        category: "episodic".to_string(),
321                        key: id.clone(),
322                        action: "record".to_string(),
323                    });
324                    format!("Episode recorded: {id}")
325                }
326                Some(v) if v.starts_with("search ") => {
327                    let q = v.trim_start_matches("search ").trim();
328                    let hits = store.search(q);
329                    if hits.is_empty() {
330                        return "No episodes matched.".to_string();
331                    }
332                    let mut out = format!("Episodes matched ({}):", hits.len());
333                    for ep in hits.into_iter().take(10) {
334                        let task: String = ep.task_description.chars().take(50).collect();
335                        out.push_str(&format!(
336                            "\n  {} | {} | {} | {}",
337                            ep.id,
338                            ep.timestamp,
339                            ep.outcome.label(),
340                            task
341                        ));
342                    }
343                    out
344                }
345                Some(v) if v.starts_with("file ") => {
346                    let f = v.trim_start_matches("file ").trim();
347                    let hits = store.by_file(f);
348                    let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
349                    for ep in hits.into_iter().take(10) {
350                        let task: String = ep.task_description.chars().take(50).collect();
351                        out.push_str(&format!(
352                            "\n  {} | {} | {} | {}",
353                            ep.id,
354                            ep.timestamp,
355                            ep.outcome.label(),
356                            task
357                        ));
358                    }
359                    out
360                }
361                Some(v) if v.starts_with("outcome ") => {
362                    let label = v.trim_start_matches("outcome ").trim();
363                    let hits = store.by_outcome(label);
364                    let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
365                    for ep in hits.into_iter().take(10) {
366                        let task: String = ep.task_description.chars().take(50).collect();
367                        out.push_str(&format!("\n  {} | {} | {}", ep.id, ep.timestamp, task));
368                    }
369                    out
370                }
371                _ => {
372                    let stats = store.stats();
373                    let recent = store.recent(10);
374                    let mut out = format!(
375                        "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
376                        stats.total_episodes,
377                        stats.success_rate * 100.0,
378                        stats.total_tokens
379                    );
380                    for ep in recent {
381                        let task: String = ep.task_description.chars().take(60).collect();
382                        out.push_str(&format!(
383                            "\n  {} | {} | {} | {}",
384                            ep.id,
385                            ep.timestamp,
386                            ep.outcome.label(),
387                            task
388                        ));
389                    }
390                    out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
391                    out
392                }
393            }
394        }
395
396        "procedures" => {
397            let project_root = session.project_root.clone().unwrap_or_else(|| {
398                std::env::current_dir().map_or_else(
399                    |_| "unknown".to_string(),
400                    |p| p.to_string_lossy().to_string(),
401                )
402            });
403            let policy = match crate::core::config::Config::load().memory_policy_effective() {
404                Ok(p) => p,
405                Err(e) => {
406                    let path = crate::core::config::Config::path().map_or_else(
407                        || "~/.lean-ctx/config.toml".to_string(),
408                        |p| p.display().to_string(),
409                    );
410                    return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
411                }
412            };
413            let hash = crate::core::project_hash::hash_project_root(&project_root);
414            let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
415            let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
416
417            match value {
418                Some("detect") => {
419                    procs.detect_patterns(&episodes.episodes, &policy.procedural);
420                    if let Err(e) = procs.save() {
421                        return format!("Procedure detect failed: {e}");
422                    }
423                    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
424                        category: "procedural".to_string(),
425                        key: hash.clone(),
426                        action: "detect".to_string(),
427                    });
428                    format!(
429                        "Procedures updated. Total procedures: {} (episodes: {}).",
430                        procs.procedures.len(),
431                        episodes.episodes.len()
432                    )
433                }
434                Some(v) if v.starts_with("suggest ") => {
435                    let task = v.trim_start_matches("suggest ").trim();
436                    let hits = procs.suggest(task);
437                    if hits.is_empty() {
438                        return "No procedures matched.".to_string();
439                    }
440                    let mut out = format!("Procedures suggested ({}):", hits.len());
441                    for p in hits.into_iter().take(10) {
442                        out.push_str(&format!(
443                            "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
444                            p.name,
445                            p.confidence * 100.0,
446                            p.success_rate() * 100.0,
447                            p.steps.len()
448                        ));
449                    }
450                    out
451                }
452                _ => {
453                    let task = session
454                        .task
455                        .as_ref()
456                        .map(|t| t.description.clone())
457                        .unwrap_or_default();
458                    let suggestions = if task.is_empty() {
459                        Vec::new()
460                    } else {
461                        procs.suggest(&task)
462                    };
463
464                    let mut out = format!(
465                        "Procedural memory: {} procedures (episodes: {})",
466                        procs.procedures.len(),
467                        episodes.episodes.len()
468                    );
469
470                    if !task.is_empty() {
471                        out.push_str(&format!("\nTask: {}", task.chars().take(80).collect::<String>()));
472                        if !suggestions.is_empty() {
473                            out.push_str("\n\nSuggested:");
474                            for p in suggestions.into_iter().take(5) {
475                                out.push_str(&format!(
476                                    "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
477                                    p.name,
478                                    p.confidence * 100.0,
479                                    p.success_rate() * 100.0,
480                                    p.steps.len()
481                                ));
482                            }
483                        }
484                    }
485
486                    out.push_str("\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"");
487                    out
488                }
489            }
490        }
491
492        _ => format!("Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, profile, role, budget, slo, diff, verify, episodes, procedures"),
493    }
494}
495
496fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
497    // Format: "file.rs:42 — summary text" or just "summary text"
498    if let Some(dash_pos) = value.find(" \u{2014} ").or_else(|| value.find(" - ")) {
499        let location = &value[..dash_pos];
500        let sep_len = 3;
501        let text = &value[dash_pos + sep_len..];
502
503        if let Some(colon_pos) = location.rfind(':') {
504            let file = &location[..colon_pos];
505            if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
506                return (Some(file.to_string()), Some(line), text);
507            }
508        }
509        return (Some(location.to_string()), None, text);
510    }
511    (None, None, value)
512}