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 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 "verify" => {
468 let snap = crate::core::output_verification::stats_snapshot();
469 snap.format_compact()
470 }
471
472 "episodes" => {
473 let project_root = session.project_root.clone().unwrap_or_else(|| {
474 std::env::current_dir().map_or_else(
475 |_| "unknown".to_string(),
476 |p| p.to_string_lossy().to_string(),
477 )
478 });
479 let policy = match crate::core::config::Config::load().memory_policy_effective() {
480 Ok(p) => p,
481 Err(e) => {
482 let path = crate::core::config::Config::path().map_or_else(
483 || "~/.lean-ctx/config.toml".to_string(),
484 |p| p.display().to_string(),
485 );
486 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
487 }
488 };
489 let hash = crate::core::project_hash::hash_project_root(&project_root);
490 let mut store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
491
492 match value {
493 Some("record") => {
494 let ep = crate::core::episodic_memory::create_episode_from_session(
495 session,
496 tool_calls,
497 );
498 let id = ep.id.clone();
499 store.record_episode(ep, &policy.episodic);
500 if let Err(e) = store.save() {
501 return format!("Episode record failed: {e}");
502 }
503 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
504 category: "episodic".to_string(),
505 key: id.clone(),
506 action: "record".to_string(),
507 });
508 format!("Episode recorded: {id}")
509 }
510 Some(v) if v.starts_with("search ") => {
511 let q = v.trim_start_matches("search ").trim();
512 let hits = store.search(q);
513 if hits.is_empty() {
514 return "No episodes matched.".to_string();
515 }
516 let mut out = format!("Episodes matched ({}):", hits.len());
517 for ep in hits.into_iter().take(10) {
518 let task: String = ep.task_description.chars().take(50).collect();
519 out.push_str(&format!(
520 "\n {} | {} | {} | {}",
521 ep.id,
522 ep.timestamp,
523 ep.outcome.label(),
524 task
525 ));
526 }
527 out
528 }
529 Some(v) if v.starts_with("file ") => {
530 let f = v.trim_start_matches("file ").trim();
531 let hits = store.by_file(f);
532 let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
533 for ep in hits.into_iter().take(10) {
534 let task: String = ep.task_description.chars().take(50).collect();
535 out.push_str(&format!(
536 "\n {} | {} | {} | {}",
537 ep.id,
538 ep.timestamp,
539 ep.outcome.label(),
540 task
541 ));
542 }
543 out
544 }
545 Some(v) if v.starts_with("outcome ") => {
546 let label = v.trim_start_matches("outcome ").trim();
547 let hits = store.by_outcome(label);
548 let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
549 for ep in hits.into_iter().take(10) {
550 let task: String = ep.task_description.chars().take(50).collect();
551 out.push_str(&format!("\n {} | {} | {}", ep.id, ep.timestamp, task));
552 }
553 out
554 }
555 _ => {
556 let stats = store.stats();
557 let recent = store.recent(10);
558 let mut out = format!(
559 "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
560 stats.total_episodes,
561 stats.success_rate * 100.0,
562 stats.total_tokens
563 );
564 for ep in recent {
565 let task: String = ep.task_description.chars().take(60).collect();
566 out.push_str(&format!(
567 "\n {} | {} | {} | {}",
568 ep.id,
569 ep.timestamp,
570 ep.outcome.label(),
571 task
572 ));
573 }
574 out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
575 out
576 }
577 }
578 }
579
580 "procedures" => {
581 let project_root = session.project_root.clone().unwrap_or_else(|| {
582 std::env::current_dir().map_or_else(
583 |_| "unknown".to_string(),
584 |p| p.to_string_lossy().to_string(),
585 )
586 });
587 let policy = match crate::core::config::Config::load().memory_policy_effective() {
588 Ok(p) => p,
589 Err(e) => {
590 let path = crate::core::config::Config::path().map_or_else(
591 || "~/.lean-ctx/config.toml".to_string(),
592 |p| p.display().to_string(),
593 );
594 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
595 }
596 };
597 let hash = crate::core::project_hash::hash_project_root(&project_root);
598 let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
599 let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
600
601 match value {
602 Some("detect") => {
603 procs.detect_patterns(&episodes.episodes, &policy.procedural);
604 if let Err(e) = procs.save() {
605 return format!("Procedure detect failed: {e}");
606 }
607 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
608 category: "procedural".to_string(),
609 key: hash.clone(),
610 action: "detect".to_string(),
611 });
612 format!(
613 "Procedures updated. Total procedures: {} (episodes: {}).",
614 procs.procedures.len(),
615 episodes.episodes.len()
616 )
617 }
618 Some(v) if v.starts_with("suggest ") => {
619 let task = v.trim_start_matches("suggest ").trim();
620 let hits = procs.suggest(task);
621 if hits.is_empty() {
622 return "No procedures matched.".to_string();
623 }
624 let mut out = format!("Procedures suggested ({}):", hits.len());
625 for p in hits.into_iter().take(10) {
626 out.push_str(&format!(
627 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
628 p.name,
629 p.confidence * 100.0,
630 p.success_rate() * 100.0,
631 p.steps.len()
632 ));
633 }
634 out
635 }
636 _ => {
637 let task = session
638 .task
639 .as_ref()
640 .map(|t| t.description.clone())
641 .unwrap_or_default();
642 let suggestions = if task.is_empty() {
643 Vec::new()
644 } else {
645 procs.suggest(&task)
646 };
647
648 let mut out = format!(
649 "Procedural memory: {} procedures (episodes: {})",
650 procs.procedures.len(),
651 episodes.episodes.len()
652 );
653
654 if !task.is_empty() {
655 out.push_str(&format!("\nTask: {}", task.chars().take(80).collect::<String>()));
656 if !suggestions.is_empty() {
657 out.push_str("\n\nSuggested:");
658 for p in suggestions.into_iter().take(5) {
659 out.push_str(&format!(
660 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
661 p.name,
662 p.confidence * 100.0,
663 p.success_rate() * 100.0,
664 p.steps.len()
665 ));
666 }
667 }
668 }
669
670 out.push_str("\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"");
671 out
672 }
673 }
674 }
675
676 _ => format!("Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, configure, profile, role, budget, slo, diff, verify, export, import, episodes, procedures"),
677 }
678}
679
680fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
681 if let Some(dash_pos) = value.find(" \u{2014} ").or_else(|| value.find(" - ")) {
683 let location = &value[..dash_pos];
684 let sep_len = 3;
685 let text = &value[dash_pos + sep_len..];
686
687 if let Some(colon_pos) = location.rfind(':') {
688 let file = &location[..colon_pos];
689 if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
690 return (Some(file.to_string()), Some(line), text);
691 }
692 }
693 return (Some(location.to_string()), None, text);
694 }
695 (None, None, value)
696}