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 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 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 != ¤t_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 let mut ledger = crate::core::context_ledger::ContextLedger::load();
238 ledger.reset();
239 ledger.save();
240 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
241 let radar_path = data_dir.join("context_radar.jsonl");
242 let prev = data_dir.join("context_radar.prev.jsonl");
243 let _ = std::fs::rename(&radar_path, &prev);
244 }
245 format!(
246 "Session reset. Previous: {old_id}. New: {}. Ledger cleared (0% pressure).",
247 session.id
248 )
249 }
250
251 "list" => {
252 let sessions = SessionState::list_sessions();
253 if sessions.is_empty() {
254 return "No sessions found.".to_string();
255 }
256 let mut lines = vec![format!("Sessions ({}):", sessions.len())];
257 for s in sessions.iter().take(10) {
258 let task = s.task.as_deref().unwrap_or("(no task)");
259 let task_short: String = task.chars().take(40).collect();
260 lines.push(format!(
261 " {} v{} | {} calls | {} tok | {}",
262 s.id, s.version, s.tool_calls, s.tokens_saved, task_short
263 ));
264 }
265 if sessions.len() > 10 {
266 lines.push(format!(" ... +{} more", sessions.len() - 10));
267 }
268 lines.join("\n")
269 }
270
271 "cleanup" => {
272 let removed = SessionState::cleanup_old_sessions(7);
273 format!("Cleaned up {removed} old session(s) (>7 days).")
274 }
275
276 "configure" => match opts.terse {
277 Some(enabled) => {
278 session.terse_mode = enabled;
279 session.increment();
280 format!("Session configured: terse_mode={enabled}")
281 }
282 None => format!("Session config: terse_mode={}", session.terse_mode),
283 },
284
285 "snapshot" => match session.save_compaction_snapshot() {
286 Ok(snapshot) => {
287 format!(
288 "Compaction snapshot saved ({} bytes).\n{snapshot}",
289 snapshot.len()
290 )
291 }
292 Err(e) => format!("Snapshot failed: {e}"),
293 },
294
295 "restore" => {
296 let snapshot = if let Some(id) = session_id {
297 SessionState::load_compaction_snapshot(id)
298 } else {
299 SessionState::load_latest_snapshot()
300 };
301 match snapshot {
302 Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
303 None => "No compaction snapshot found. Session continues fresh.".to_string(),
304 }
305 }
306
307 "resume" => session.build_resume_block(),
308
309 "profile" => {
310 use crate::core::profiles;
311 if let Some(name) = value {
312 if let Ok(p) = profiles::set_active_profile(name) {
313 format!(
314 "Profile switched to '{name}'.\n\
315 Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
316 p.read.default_mode_effective(),
317 p.budget.max_context_tokens_effective(),
318 p.compression.crp_mode_effective(),
319 p.compression.output_density_effective(),
320 )
321 } else {
322 let available: Vec<String> =
323 profiles::list_profiles().iter().map(|p| p.name.clone()).collect();
324 format!(
325 "Profile '{name}' not found. Available: {}",
326 available.join(", ")
327 )
328 }
329 } else {
330 let name = profiles::active_profile_name();
331 let p = profiles::active_profile();
332 let list = profiles::list_profiles();
333 let mut out = format!(
334 "Active profile: {name}\n\
335 Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
336 Available profiles:",
337 p.read.default_mode_effective(),
338 p.budget.max_context_tokens_effective(),
339 p.compression.crp_mode_effective(),
340 p.compression.output_density_effective(),
341 );
342 for info in &list {
343 let marker = if info.name == name { " *" } else { " " };
344 out.push_str(&format!(
345 "\n{marker} {:<14} ({}) {}",
346 info.name, info.source, info.description
347 ));
348 }
349 out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
350 out
351 }
352 }
353
354 "budget" => {
355 use crate::core::budget_tracker::BudgetTracker;
356 let snap = BudgetTracker::global().check();
357 let mut out = snap.format_compact();
358
359 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
360 let window = crate::core::context_radar::default_window_for_client("cursor");
361 let radar = crate::core::context_radar::ContextRadar::load(&data_dir, window);
362 let radar_display = radar.format_display();
363 if !radar_display.is_empty() {
364 out.push_str("\n\n");
365 out.push_str(&radar_display);
366 }
367 }
368 out
369 }
370
371 "role" => {
372 use crate::core::roles;
373 if let Some(name) = value {
374 match roles::set_active_role(name) {
375 Ok(r) => {
376 crate::core::budget_tracker::BudgetTracker::global().reset();
377 format!(
378 "Role switched to '{name}'.\n\
379 Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
380 Tools: {}",
381 r.role.shell_policy,
382 r.limits.max_context_tokens,
383 r.limits.max_shell_invocations,
384 r.limits.max_cost_usd,
385 if r.tools.allowed.iter().any(|a| a == "*") {
386 let denied = if r.tools.denied.is_empty() {
387 "none".to_string()
388 } else {
389 format!("denied: {}", r.tools.denied.join(", "))
390 };
391 format!("* (all), {denied}")
392 } else {
393 r.tools.allowed.join(", ")
394 }
395 )
396 }
397 Err(e) => {
398 let available: Vec<String> =
399 roles::list_roles().iter().map(|r| r.name.clone()).collect();
400 format!("{e}. Available: {}", available.join(", "))
401 }
402 }
403 } else {
404 let name = roles::active_role_name();
405 let r = roles::active_role();
406 let list = roles::list_roles();
407 let mut out = format!(
408 "Active role: {name}\n\
409 Description: {}\n\
410 Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
411 Available roles:",
412 r.role.description,
413 r.role.shell_policy,
414 r.limits.max_context_tokens,
415 r.limits.max_shell_invocations,
416 r.limits.max_cost_usd,
417 );
418 for info in &list {
419 let marker = if info.is_active { " *" } else { " " };
420 out.push_str(&format!(
421 "\n{marker} {:<14} ({}) {}",
422 info.name, info.source, info.description
423 ));
424 }
425 out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
426 out
427 }
428 }
429
430 "diff" => {
431 let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
432 if parts.len() < 2 {
433 return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
434 Formats: summary (default), json\n\
435 Example: ctx_session diff abc123 def456 json"
436 .to_string();
437 }
438 let id_a = parts[0];
439 let id_b = parts[1];
440 let format = parts.get(2).copied().unwrap_or("summary");
441
442 let sess_a = SessionState::load_by_id(id_a);
443 let sess_b = SessionState::load_by_id(id_b);
444
445 match (sess_a, sess_b) {
446 (Some(a), Some(b)) => {
447 let d = crate::core::session_diff::diff_sessions(&a, &b);
448 match format {
449 "json" => d.format_json(),
450 _ => d.format_summary(),
451 }
452 }
453 (None, _) => format!("Session not found: {id_a}"),
454 (_, None) => format!("Session not found: {id_b}"),
455 }
456 }
457
458 "slo" => {
459 match value {
460 Some("reload") => {
461 crate::core::slo::reload();
462 "SLO definitions reloaded from disk.".to_string()
463 }
464 Some("history") => {
465 let hist = crate::core::slo::violation_history(20);
466 if hist.is_empty() {
467 "No SLO violations recorded.".to_string()
468 } else {
469 let mut out = format!("SLO violations (last {}):\n", hist.len());
470 for v in &hist {
471 out.push_str(&format!(
472 " {} {} ({}) {:.2} vs {:.2} → {}\n",
473 v.timestamp, v.slo_name, v.metric, v.actual, v.threshold, v.action
474 ));
475 }
476 out
477 }
478 }
479 Some("clear") => {
480 crate::core::slo::clear_violations();
481 "SLO violation history cleared.".to_string()
482 }
483 _ => {
484 let snap = crate::core::slo::evaluate_quiet();
485 snap.format_compact()
486 }
487 }
488 }
489
490 "output_stats" => {
491 let snap = crate::core::output_verification::stats_snapshot();
492 snap.format_compact()
493 }
494
495 "verify" => {
496 let snap = crate::core::output_verification::stats_snapshot();
497 format!(
498 "DEPRECATION: action=\"verify\" is renamed to action=\"output_stats\" (ctx_verify is the full observability stack).\n{}",
499 snap.format_compact()
500 )
501 }
502
503 "episodes" => {
504 let project_root = session.project_root.clone().unwrap_or_else(|| {
505 std::env::current_dir().map_or_else(
506 |_| "unknown".to_string(),
507 |p| p.to_string_lossy().to_string(),
508 )
509 });
510 let policy = match crate::core::config::Config::load().memory_policy_effective() {
511 Ok(p) => p,
512 Err(e) => {
513 let path = crate::core::config::Config::path().map_or_else(
514 || "~/.lean-ctx/config.toml".to_string(),
515 |p| p.display().to_string(),
516 );
517 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
518 }
519 };
520 let hash = crate::core::project_hash::hash_project_root(&project_root);
521 let mut store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
522
523 match value {
524 Some("record") => {
525 let ep = crate::core::episodic_memory::create_episode_from_session(
526 session,
527 tool_calls,
528 );
529 let id = ep.id.clone();
530 store.record_episode(ep, &policy.episodic);
531 if let Err(e) = store.save() {
532 return format!("Episode record failed: {e}");
533 }
534 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
535 category: "episodic".to_string(),
536 key: id.clone(),
537 action: "record".to_string(),
538 });
539 format!("Episode recorded: {id}")
540 }
541 Some(v) if v.starts_with("search ") => {
542 let q = v.trim_start_matches("search ").trim();
543 let hits = store.search(q);
544 if hits.is_empty() {
545 return "No episodes matched.".to_string();
546 }
547 let mut out = format!("Episodes matched ({}):", hits.len());
548 for ep in hits.into_iter().take(10) {
549 let task: String = ep.task_description.chars().take(50).collect();
550 out.push_str(&format!(
551 "\n {} | {} | {} | {}",
552 ep.id,
553 ep.timestamp,
554 ep.outcome.label(),
555 task
556 ));
557 }
558 out
559 }
560 Some(v) if v.starts_with("file ") => {
561 let f = v.trim_start_matches("file ").trim();
562 let hits = store.by_file(f);
563 let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
564 for ep in hits.into_iter().take(10) {
565 let task: String = ep.task_description.chars().take(50).collect();
566 out.push_str(&format!(
567 "\n {} | {} | {} | {}",
568 ep.id,
569 ep.timestamp,
570 ep.outcome.label(),
571 task
572 ));
573 }
574 out
575 }
576 Some(v) if v.starts_with("outcome ") => {
577 let label = v.trim_start_matches("outcome ").trim();
578 let hits = store.by_outcome(label);
579 let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
580 for ep in hits.into_iter().take(10) {
581 let task: String = ep.task_description.chars().take(50).collect();
582 out.push_str(&format!("\n {} | {} | {}", ep.id, ep.timestamp, task));
583 }
584 out
585 }
586 _ => {
587 let stats = store.stats();
588 let recent = store.recent(10);
589 let mut out = format!(
590 "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
591 stats.total_episodes,
592 stats.success_rate * 100.0,
593 stats.total_tokens
594 );
595 for ep in recent {
596 let task: String = ep.task_description.chars().take(60).collect();
597 out.push_str(&format!(
598 "\n {} | {} | {} | {}",
599 ep.id,
600 ep.timestamp,
601 ep.outcome.label(),
602 task
603 ));
604 }
605 out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
606 out
607 }
608 }
609 }
610
611 "procedures" => {
612 let project_root = session.project_root.clone().unwrap_or_else(|| {
613 std::env::current_dir().map_or_else(
614 |_| "unknown".to_string(),
615 |p| p.to_string_lossy().to_string(),
616 )
617 });
618 let policy = match crate::core::config::Config::load().memory_policy_effective() {
619 Ok(p) => p,
620 Err(e) => {
621 let path = crate::core::config::Config::path().map_or_else(
622 || "~/.lean-ctx/config.toml".to_string(),
623 |p| p.display().to_string(),
624 );
625 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
626 }
627 };
628 let hash = crate::core::project_hash::hash_project_root(&project_root);
629 let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
630 let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
631
632 match value {
633 Some("detect") => {
634 procs.detect_patterns(&episodes.episodes, &policy.procedural);
635 if let Err(e) = procs.save() {
636 return format!("Procedure detect failed: {e}");
637 }
638 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
639 category: "procedural".to_string(),
640 key: hash.clone(),
641 action: "detect".to_string(),
642 });
643 format!(
644 "Procedures updated. Total procedures: {} (episodes: {}).",
645 procs.procedures.len(),
646 episodes.episodes.len()
647 )
648 }
649 Some(v) if v.starts_with("suggest ") => {
650 let task = v.trim_start_matches("suggest ").trim();
651 let hits = procs.suggest(task);
652 if hits.is_empty() {
653 return "No procedures matched.".to_string();
654 }
655 let mut out = format!("Procedures suggested ({}):", hits.len());
656 for p in hits.into_iter().take(10) {
657 out.push_str(&format!(
658 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
659 p.name,
660 p.confidence * 100.0,
661 p.success_rate() * 100.0,
662 p.steps.len()
663 ));
664 }
665 out
666 }
667 _ => {
668 let task = session
669 .task
670 .as_ref()
671 .map(|t| t.description.clone())
672 .unwrap_or_default();
673 let suggestions = if task.is_empty() {
674 Vec::new()
675 } else {
676 procs.suggest(&task)
677 };
678
679 let mut out = format!(
680 "Procedural memory: {} procedures (episodes: {})",
681 procs.procedures.len(),
682 episodes.episodes.len()
683 );
684
685 if !task.is_empty() {
686 out.push_str(&format!("\nTask: {}", task.chars().take(80).collect::<String>()));
687 if !suggestions.is_empty() {
688 out.push_str("\n\nSuggested:");
689 for p in suggestions.into_iter().take(5) {
690 out.push_str(&format!(
691 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
692 p.name,
693 p.confidence * 100.0,
694 p.success_rate() * 100.0,
695 p.steps.len()
696 ));
697 }
698 }
699 }
700
701 out.push_str("\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"");
702 out
703 }
704 }
705 }
706
707 _ => 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"),
708 }
709}
710
711fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
712 if let Some(dash_pos) = value.find(" \u{2014} ").or_else(|| value.find(" - ")) {
714 let location = &value[..dash_pos];
715 let sep_len = 3;
716 let text = &value[dash_pos + sep_len..];
717
718 if let Some(colon_pos) = location.rfind(':') {
719 let file = &location[..colon_pos];
720 if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
721 return (Some(file.to_string()), Some(line), text);
722 }
723 }
724 return (Some(location.to_string()), None, text);
725 }
726 (None, None, value)
727}