Skip to main content

lean_ctx/tools/
ctx_session.rs

1use crate::core::session::SessionState;
2
3#[derive(Clone, Copy, Debug)]
4pub struct SessionToolOptions<'a> {
5    pub format: Option<&'a str>,
6    pub path: Option<&'a str>,
7    pub write: bool,
8    pub privacy: Option<&'a str>,
9    /// For `action=configure`: set terse output mode when `Some`.
10    pub terse: Option<bool>,
11}
12
13pub fn handle(
14    session: &mut SessionState,
15    tool_calls: &[(String, u64)],
16    action: &str,
17    value: Option<&str>,
18    session_id: Option<&str>,
19    opts: SessionToolOptions<'_>,
20) -> String {
21    match action {
22        "status" => session.format_compact(),
23
24        "load" => {
25            let loaded = if let Some(id) = session_id {
26                SessionState::load_by_id(id)
27            } else {
28                SessionState::load_latest()
29            };
30
31            if let Some(prev) = loaded {
32                let summary = prev.format_compact();
33                *session = prev;
34                format!("Session loaded.\n{summary}")
35            } else {
36                let id_str = session_id.unwrap_or("latest");
37                format!("No session found (id: {id_str}). Starting fresh.")
38            }
39        }
40
41        "save" => {
42            match session.save() {
43                Ok(()) => format!("Session {} saved (v{}).", session.id, session.version),
44                Err(e) => format!("Save failed: {e}"),
45            }
46        }
47
48        "export" => {
49            let requested_privacy = crate::core::ccp_session_bundle::BundlePrivacyV1::parse(opts.privacy);
50            if requested_privacy == crate::core::ccp_session_bundle::BundlePrivacyV1::Full
51                && crate::core::roles::active_role_name() != "admin"
52            {
53                return "ERROR: privacy=full requires role 'admin'.".to_string();
54            }
55
56            let bundle =
57                crate::core::ccp_session_bundle::build_bundle_v1(session, requested_privacy);
58            let json = match crate::core::ccp_session_bundle::serialize_bundle_v1_pretty(&bundle) {
59                Ok(s) => s,
60                Err(e) => return e,
61            };
62
63            let format = opts
64                .format
65                .unwrap_or(if opts.write { "summary" } else { "json" });
66            let root = session.project_root.clone().unwrap_or_else(|| {
67                std::env::current_dir().map_or_else(
68                    |_| ".".to_string(),
69                    |p| p.to_string_lossy().to_string(),
70                )
71            });
72            let root_path = std::path::PathBuf::from(&root);
73
74            let mut written: Option<String> = None;
75            if opts.write || opts.path.is_some() {
76                let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
77                let candidate = if let Some(p) = opts.path.or(value) {
78                    let p = std::path::PathBuf::from(p);
79                    if p.is_absolute() { p } else { root_path.join(p) }
80                } else {
81                    root_path
82                        .join(".lean-ctx")
83                        .join("session_bundles")
84                        .join(format!("ccp-session-bundle-v1_{}_{}.json", bundle.session.id, ts))
85                };
86
87                let jailed = match crate::core::io_boundary::jail_and_check_path(
88                    "ctx_session.export",
89                    candidate.as_path(),
90                    root_path.as_path(),
91                ) {
92                    Ok((p, _warning)) => p,
93                    Err(e) => return e,
94                };
95
96                if let Err(e) = crate::core::ccp_session_bundle::write_bundle_v1(&jailed, &json) {
97                    return format!("Export write failed: {e}");
98                }
99                written = Some(jailed.to_string_lossy().to_string());
100            }
101
102            match format {
103                "summary" => {
104                    let mut out = format!(
105                        "CCP session bundle exported (v{}).\n\
106schema_version: {}\n\
107session_id: {}\n\
108bytes: {}\n",
109                        bundle.session.version,
110                        bundle.schema_version,
111                        bundle.session.id,
112                        json.len()
113                    );
114                    if let Some(p) = written {
115                        out.push_str(&format!("path: {p}\n"));
116                    }
117                    if let Some(h) = bundle.project.project_root_hash {
118                        out.push_str(&format!("project_root_hash: {h}\n"));
119                    }
120                    if let Some(h) = bundle.project.project_identity_hash {
121                        out.push_str(&format!("project_identity_hash: {h}\n"));
122                    }
123                    out
124                }
125                _ => {
126                    if let Some(p) = written {
127                        format!("{json}\n\npath: {p}")
128                    } else {
129                        json
130                    }
131                }
132            }
133        }
134
135        "import" => {
136            let root = session.project_root.clone().unwrap_or_else(|| {
137                std::env::current_dir().map_or_else(
138                    |_| ".".to_string(),
139                    |p| p.to_string_lossy().to_string(),
140                )
141            });
142            let root_path = std::path::PathBuf::from(&root);
143
144            let Some(p) = opts.path.or(value) else {
145                return "ERROR: path is required for action=import".to_string();
146            };
147
148            let candidate = {
149                let p = std::path::PathBuf::from(p);
150                if p.is_absolute() { p } else { root_path.join(p) }
151            };
152            let jailed = match crate::core::io_boundary::jail_and_check_path(
153                "ctx_session.import",
154                candidate.as_path(),
155                root_path.as_path(),
156            ) {
157                Ok((p, _warning)) => p,
158                Err(e) => return e,
159            };
160
161            let bundle = match crate::core::ccp_session_bundle::read_bundle_v1(&jailed) {
162                Ok(b) => b,
163                Err(e) => return format!("Import failed: {e}"),
164            };
165
166            // Replayability hint: compare project identity hashes (best-effort).
167            let current_root_hash = crate::core::project_hash::hash_project_root(&root);
168            let current_identity_hash = crate::core::project_hash::project_identity(&root)
169                .as_deref()
170                .map(|s| {
171                    use md5::{Digest, Md5};
172                    let mut h = Md5::new();
173                    h.update(s.as_bytes());
174                    format!("{:x}", h.finalize())
175                });
176
177            let mut warning: Option<String> = None;
178            if let Some(ref exported) = bundle.project.project_root_hash {
179                if exported != &current_root_hash {
180                    warning = Some("WARNING: project_root_hash mismatch (importing into different project root).".to_string());
181                }
182            }
183            if let (Some(exported), Some(current)) =
184                (bundle.project.project_identity_hash.as_ref(), current_identity_hash.as_ref())
185            {
186                if exported != current {
187                    warning = Some("WARNING: project_identity_hash mismatch (importing into different project identity).".to_string());
188                }
189            }
190
191            let report = crate::core::ccp_session_bundle::import_bundle_v1_into_session(
192                session,
193                &bundle,
194                Some(&root),
195            );
196            let _ = session.save();
197
198            let mut out = format!(
199                "CCP session bundle imported.\n\
200session_id: {}\n\
201version: {}\n\
202files_touched: {}\n\
203stale_files: {}\n",
204                report.session_id, report.version, report.files_touched, report.stale_files
205            );
206            if let Some(w) = warning {
207                out.push_str(&format!("{w}\n"));
208            }
209            out
210        }
211
212        "task" => {
213            let desc = value.unwrap_or("(no description)");
214            session.set_task(desc, None);
215            format!("Task set: {desc}")
216        }
217
218        "finding" => {
219            let summary = value.unwrap_or("(no summary)");
220            let (file, line, text) = parse_finding_value(summary);
221            session.add_finding(file.as_deref(), line, text);
222            format!("Finding added: {summary}")
223        }
224
225        "decision" => {
226            let desc = value.unwrap_or("(no description)");
227            session.add_decision(desc, None);
228            format!("Decision recorded: {desc}")
229        }
230
231        "reset" => {
232            let _ = session.save();
233            let old_id = session.id.clone();
234            *session = SessionState::new();
235            crate::core::budget_tracker::BudgetTracker::global().reset();
236            format!("Session reset. Previous: {old_id}. New: {}", session.id)
237        }
238
239        "list" => {
240            let sessions = SessionState::list_sessions();
241            if sessions.is_empty() {
242                return "No sessions found.".to_string();
243            }
244            let mut lines = vec![format!("Sessions ({}):", sessions.len())];
245            for s in sessions.iter().take(10) {
246                let task = s.task.as_deref().unwrap_or("(no task)");
247                let task_short: String = task.chars().take(40).collect();
248                lines.push(format!(
249                    "  {} v{} | {} calls | {} tok | {}",
250                    s.id, s.version, s.tool_calls, s.tokens_saved, task_short
251                ));
252            }
253            if sessions.len() > 10 {
254                lines.push(format!("  ... +{} more", sessions.len() - 10));
255            }
256            lines.join("\n")
257        }
258
259        "cleanup" => {
260            let removed = SessionState::cleanup_old_sessions(7);
261            format!("Cleaned up {removed} old session(s) (>7 days).")
262        }
263
264        "configure" => match opts.terse {
265            Some(enabled) => {
266                session.terse_mode = enabled;
267                session.increment();
268                format!("Session configured: terse_mode={enabled}")
269            }
270            None => format!("Session config: terse_mode={}", session.terse_mode),
271        },
272
273        "snapshot" => match session.save_compaction_snapshot() {
274            Ok(snapshot) => {
275                format!(
276                    "Compaction snapshot saved ({} bytes).\n{snapshot}",
277                    snapshot.len()
278                )
279            }
280            Err(e) => format!("Snapshot failed: {e}"),
281        },
282
283        "restore" => {
284            let snapshot = if let Some(id) = session_id {
285                SessionState::load_compaction_snapshot(id)
286            } else {
287                SessionState::load_latest_snapshot()
288            };
289            match snapshot {
290                Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
291                None => "No compaction snapshot found. Session continues fresh.".to_string(),
292            }
293        }
294
295        "resume" => session.build_resume_block(),
296
297        "profile" => {
298            use crate::core::profiles;
299            if let Some(name) = value {
300                if let Ok(p) = profiles::set_active_profile(name) {
301                    format!(
302                        "Profile switched to '{name}'.\n\
303                         Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
304                        p.read.default_mode_effective(),
305                        p.budget.max_context_tokens_effective(),
306                        p.compression.crp_mode_effective(),
307                        p.compression.output_density_effective(),
308                    )
309                } else {
310                    let available: Vec<String> =
311                        profiles::list_profiles().iter().map(|p| p.name.clone()).collect();
312                    format!(
313                        "Profile '{name}' not found. Available: {}",
314                        available.join(", ")
315                    )
316                }
317            } else {
318                let name = profiles::active_profile_name();
319                let p = profiles::active_profile();
320                let list = profiles::list_profiles();
321                let mut out = format!(
322                    "Active profile: {name}\n\
323                     Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
324                     Available profiles:",
325                    p.read.default_mode_effective(),
326                    p.budget.max_context_tokens_effective(),
327                    p.compression.crp_mode_effective(),
328                    p.compression.output_density_effective(),
329                );
330                for info in &list {
331                    let marker = if info.name == name { " *" } else { "  " };
332                    out.push_str(&format!(
333                        "\n{marker} {:<14} ({}) {}",
334                        info.name, info.source, info.description
335                    ));
336                }
337                out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
338                out
339            }
340        }
341
342        "budget" => {
343            use crate::core::budget_tracker::BudgetTracker;
344            let snap = BudgetTracker::global().check();
345            snap.format_compact()
346        }
347
348        "role" => {
349            use crate::core::roles;
350            if let Some(name) = value {
351                match roles::set_active_role(name) {
352                    Ok(r) => {
353                        crate::core::budget_tracker::BudgetTracker::global().reset();
354                        format!(
355                            "Role switched to '{name}'.\n\
356                             Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
357                             Tools: {}",
358                            r.role.shell_policy,
359                            r.limits.max_context_tokens,
360                            r.limits.max_shell_invocations,
361                            r.limits.max_cost_usd,
362                            if r.tools.allowed.iter().any(|a| a == "*") {
363                                let denied = if r.tools.denied.is_empty() {
364                                    "none".to_string()
365                                } else {
366                                    format!("denied: {}", r.tools.denied.join(", "))
367                                };
368                                format!("* (all), {denied}")
369                            } else {
370                                r.tools.allowed.join(", ")
371                            }
372                        )
373                    }
374                    Err(e) => {
375                        let available: Vec<String> =
376                            roles::list_roles().iter().map(|r| r.name.clone()).collect();
377                        format!("{e}. Available: {}", available.join(", "))
378                    }
379                }
380            } else {
381                let name = roles::active_role_name();
382                let r = roles::active_role();
383                let list = roles::list_roles();
384                let mut out = format!(
385                    "Active role: {name}\n\
386                     Description: {}\n\
387                     Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
388                     Available roles:",
389                    r.role.description,
390                    r.role.shell_policy,
391                    r.limits.max_context_tokens,
392                    r.limits.max_shell_invocations,
393                    r.limits.max_cost_usd,
394                );
395                for info in &list {
396                    let marker = if info.is_active { " *" } else { "  " };
397                    out.push_str(&format!(
398                        "\n{marker} {:<14} ({}) {}",
399                        info.name, info.source, info.description
400                    ));
401                }
402                out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
403                out
404            }
405        }
406
407        "diff" => {
408            let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
409            if parts.len() < 2 {
410                return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
411                        Formats: summary (default), json\n\
412                        Example: ctx_session diff abc123 def456 json"
413                    .to_string();
414            }
415            let id_a = parts[0];
416            let id_b = parts[1];
417            let format = parts.get(2).copied().unwrap_or("summary");
418
419            let sess_a = SessionState::load_by_id(id_a);
420            let sess_b = SessionState::load_by_id(id_b);
421
422            match (sess_a, sess_b) {
423                (Some(a), Some(b)) => {
424                    let d = crate::core::session_diff::diff_sessions(&a, &b);
425                    match format {
426                        "json" => d.format_json(),
427                        _ => d.format_summary(),
428                    }
429                }
430                (None, _) => format!("Session not found: {id_a}"),
431                (_, None) => format!("Session not found: {id_b}"),
432            }
433        }
434
435        "slo" => {
436            match value {
437                Some("reload") => {
438                    crate::core::slo::reload();
439                    "SLO definitions reloaded from disk.".to_string()
440                }
441                Some("history") => {
442                    let hist = crate::core::slo::violation_history(20);
443                    if hist.is_empty() {
444                        "No SLO violations recorded.".to_string()
445                    } else {
446                        let mut out = format!("SLO violations (last {}):\n", hist.len());
447                        for v in &hist {
448                            out.push_str(&format!(
449                                "  {} {} ({}) {:.2} vs {:.2} → {}\n",
450                                v.timestamp, v.slo_name, v.metric, v.actual, v.threshold, v.action
451                            ));
452                        }
453                        out
454                    }
455                }
456                Some("clear") => {
457                    crate::core::slo::clear_violations();
458                    "SLO violation history cleared.".to_string()
459                }
460                _ => {
461                    let snap = crate::core::slo::evaluate_quiet();
462                    snap.format_compact()
463                }
464            }
465        }
466
467        "output_stats" => {
468            let snap = crate::core::output_verification::stats_snapshot();
469            snap.format_compact()
470        }
471
472        "verify" => {
473            let snap = crate::core::output_verification::stats_snapshot();
474            format!(
475                "DEPRECATION: action=\"verify\" is renamed to action=\"output_stats\" (ctx_verify is the full observability stack).\n{}",
476                snap.format_compact()
477            )
478        }
479
480        "episodes" => {
481            let project_root = session.project_root.clone().unwrap_or_else(|| {
482                std::env::current_dir().map_or_else(
483                    |_| "unknown".to_string(),
484                    |p| p.to_string_lossy().to_string(),
485                )
486            });
487            let policy = match crate::core::config::Config::load().memory_policy_effective() {
488                Ok(p) => p,
489                Err(e) => {
490                    let path = crate::core::config::Config::path().map_or_else(
491                        || "~/.lean-ctx/config.toml".to_string(),
492                        |p| p.display().to_string(),
493                    );
494                    return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
495                }
496            };
497            let hash = crate::core::project_hash::hash_project_root(&project_root);
498            let mut store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
499
500            match value {
501                Some("record") => {
502                    let ep = crate::core::episodic_memory::create_episode_from_session(
503                        session,
504                        tool_calls,
505                    );
506                    let id = ep.id.clone();
507                    store.record_episode(ep, &policy.episodic);
508                    if let Err(e) = store.save() {
509                        return format!("Episode record failed: {e}");
510                    }
511                    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
512                        category: "episodic".to_string(),
513                        key: id.clone(),
514                        action: "record".to_string(),
515                    });
516                    format!("Episode recorded: {id}")
517                }
518                Some(v) if v.starts_with("search ") => {
519                    let q = v.trim_start_matches("search ").trim();
520                    let hits = store.search(q);
521                    if hits.is_empty() {
522                        return "No episodes matched.".to_string();
523                    }
524                    let mut out = format!("Episodes matched ({}):", hits.len());
525                    for ep in hits.into_iter().take(10) {
526                        let task: String = ep.task_description.chars().take(50).collect();
527                        out.push_str(&format!(
528                            "\n  {} | {} | {} | {}",
529                            ep.id,
530                            ep.timestamp,
531                            ep.outcome.label(),
532                            task
533                        ));
534                    }
535                    out
536                }
537                Some(v) if v.starts_with("file ") => {
538                    let f = v.trim_start_matches("file ").trim();
539                    let hits = store.by_file(f);
540                    let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
541                    for ep in hits.into_iter().take(10) {
542                        let task: String = ep.task_description.chars().take(50).collect();
543                        out.push_str(&format!(
544                            "\n  {} | {} | {} | {}",
545                            ep.id,
546                            ep.timestamp,
547                            ep.outcome.label(),
548                            task
549                        ));
550                    }
551                    out
552                }
553                Some(v) if v.starts_with("outcome ") => {
554                    let label = v.trim_start_matches("outcome ").trim();
555                    let hits = store.by_outcome(label);
556                    let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
557                    for ep in hits.into_iter().take(10) {
558                        let task: String = ep.task_description.chars().take(50).collect();
559                        out.push_str(&format!("\n  {} | {} | {}", ep.id, ep.timestamp, task));
560                    }
561                    out
562                }
563                _ => {
564                    let stats = store.stats();
565                    let recent = store.recent(10);
566                    let mut out = format!(
567                        "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
568                        stats.total_episodes,
569                        stats.success_rate * 100.0,
570                        stats.total_tokens
571                    );
572                    for ep in recent {
573                        let task: String = ep.task_description.chars().take(60).collect();
574                        out.push_str(&format!(
575                            "\n  {} | {} | {} | {}",
576                            ep.id,
577                            ep.timestamp,
578                            ep.outcome.label(),
579                            task
580                        ));
581                    }
582                    out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
583                    out
584                }
585            }
586        }
587
588        "procedures" => {
589            let project_root = session.project_root.clone().unwrap_or_else(|| {
590                std::env::current_dir().map_or_else(
591                    |_| "unknown".to_string(),
592                    |p| p.to_string_lossy().to_string(),
593                )
594            });
595            let policy = match crate::core::config::Config::load().memory_policy_effective() {
596                Ok(p) => p,
597                Err(e) => {
598                    let path = crate::core::config::Config::path().map_or_else(
599                        || "~/.lean-ctx/config.toml".to_string(),
600                        |p| p.display().to_string(),
601                    );
602                    return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
603                }
604            };
605            let hash = crate::core::project_hash::hash_project_root(&project_root);
606            let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
607            let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
608
609            match value {
610                Some("detect") => {
611                    procs.detect_patterns(&episodes.episodes, &policy.procedural);
612                    if let Err(e) = procs.save() {
613                        return format!("Procedure detect failed: {e}");
614                    }
615                    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
616                        category: "procedural".to_string(),
617                        key: hash.clone(),
618                        action: "detect".to_string(),
619                    });
620                    format!(
621                        "Procedures updated. Total procedures: {} (episodes: {}).",
622                        procs.procedures.len(),
623                        episodes.episodes.len()
624                    )
625                }
626                Some(v) if v.starts_with("suggest ") => {
627                    let task = v.trim_start_matches("suggest ").trim();
628                    let hits = procs.suggest(task);
629                    if hits.is_empty() {
630                        return "No procedures matched.".to_string();
631                    }
632                    let mut out = format!("Procedures suggested ({}):", hits.len());
633                    for p in hits.into_iter().take(10) {
634                        out.push_str(&format!(
635                            "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
636                            p.name,
637                            p.confidence * 100.0,
638                            p.success_rate() * 100.0,
639                            p.steps.len()
640                        ));
641                    }
642                    out
643                }
644                _ => {
645                    let task = session
646                        .task
647                        .as_ref()
648                        .map(|t| t.description.clone())
649                        .unwrap_or_default();
650                    let suggestions = if task.is_empty() {
651                        Vec::new()
652                    } else {
653                        procs.suggest(&task)
654                    };
655
656                    let mut out = format!(
657                        "Procedural memory: {} procedures (episodes: {})",
658                        procs.procedures.len(),
659                        episodes.episodes.len()
660                    );
661
662                    if !task.is_empty() {
663                        out.push_str(&format!("\nTask: {}", task.chars().take(80).collect::<String>()));
664                        if !suggestions.is_empty() {
665                            out.push_str("\n\nSuggested:");
666                            for p in suggestions.into_iter().take(5) {
667                                out.push_str(&format!(
668                                    "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
669                                    p.name,
670                                    p.confidence * 100.0,
671                                    p.success_rate() * 100.0,
672                                    p.steps.len()
673                                ));
674                            }
675                        }
676                    }
677
678                    out.push_str("\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"");
679                    out
680                }
681            }
682        }
683
684        _ => format!("Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, configure, profile, role, budget, slo, diff, output_stats, verify, export, import, episodes, procedures"),
685    }
686}
687
688fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
689    // Format: "file.rs:42 — summary text" or just "summary text"
690    if let Some(dash_pos) = value.find(" \u{2014} ").or_else(|| value.find(" - ")) {
691        let location = &value[..dash_pos];
692        let sep_len = 3;
693        let text = &value[dash_pos + sep_len..];
694
695        if let Some(colon_pos) = location.rfind(':') {
696            let file = &location[..colon_pos];
697            if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
698                return (Some(file.to_string()), Some(line), text);
699            }
700        }
701        return (Some(location.to_string()), None, text);
702    }
703    (None, None, value)
704}