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