1use lean_ctx::core::session::SessionState;
4
5#[derive(Clone, Copy, Debug)]
6pub struct SessionToolOptions<'a> {
7 pub format: Option<&'a str>,
8 pub path: Option<&'a str>,
9 pub write: bool,
10 pub privacy: Option<&'a str>,
11 pub terse: Option<bool>,
13 pub agent_id: Option<&'a str>,
14}
15
16pub fn handle(
17 session: &mut SessionState,
18 tool_calls: &[(String, u64)],
19 action: &str,
20 value: Option<&str>,
21 session_id: Option<&str>,
22 opts: SessionToolOptions<'_>,
23) -> String {
24 match prepare_admitted_action(session, tool_calls, action, value, session_id, opts) {
25 Err(_) => "ERROR: session action withheld by current project or content policy.".into(),
26 Ok(prepared) => {
27 if !prepared.store_admission.is_current() {
28 return "ERROR: session policy changed; retry the operation.".into();
29 }
30 let PreparedSessionAction {
31 candidate, output, ..
32 } = prepared;
33 *session = candidate;
34 lean_ctx::core::session::admission::admit_tool_value(session, &output).unwrap_or_else(
35 |error| {
36 if error.starts_with("[lean-ctx") {
43 "[lean-ctx gateway: session content withheld]".into()
44 } else {
45 "ERROR: session action withheld by current project or content policy."
46 .into()
47 }
48 },
49 )
50 }
51 }
52}
53
54pub struct PreparedSessionAction {
55 pub candidate: SessionState,
56 pub output: String,
57 pub store_admission: lean_ctx::core::context_admission::stores::StoreAdmission,
58}
59
60pub fn prepare_admitted_action(
63 session: &SessionState,
64 tool_calls: &[(String, u64)],
65 action: &str,
66 value: Option<&str>,
67 session_id: Option<&str>,
68 opts: SessionToolOptions<'_>,
69) -> Result<PreparedSessionAction, String> {
70 let store_admission = lean_ctx::core::context_admission::stores::StoreAdmission::current();
71 let mut candidate = session.clone();
72 let output = match action {
73 "task" | "finding" | "decision" => {
74 let fallback = match action {
75 "finding" => "(no summary)",
76 _ => "(no description)",
77 };
78 let admitted = lean_ctx::core::session::admission::admit_storable_session_text(
79 session,
80 value.unwrap_or(fallback),
81 )?;
82 match action {
83 "task" => handle_task(&mut candidate, tool_calls, Some(&admitted), opts),
84 "finding" => handle_finding(&mut candidate, Some(&admitted)),
85 _ => handle_decision(&mut candidate, Some(&admitted)),
86 }
87 }
88 "list" => handle_scoped_list(session)?,
89 "restore" => handle_scoped_restore(session, session_id)?,
90 _ => handle_legacy(&mut candidate, tool_calls, action, value, session_id, opts),
91 };
92 if !store_admission.is_current() {
93 return Err("session admission policy changed; retry".into());
94 }
95 Ok(PreparedSessionAction {
96 candidate,
97 output,
98 store_admission,
99 })
100}
101
102fn handle_legacy(
103 session: &mut SessionState,
104 tool_calls: &[(String, u64)],
105 action: &str,
106 value: Option<&str>,
107 session_id: Option<&str>,
108 opts: SessionToolOptions<'_>,
109) -> String {
110 if action == "handoff" {
111 return handle_handoff(session);
112 }
113
114 match action {
115 "status" | "show" => handle_status(session),
116 "load" => handle_load(session, session_id),
117 "save" => handle_save(session),
118 "export" => handle_export(session, value, opts),
119 "import" => handle_import(session, value, opts),
120 "task" => handle_task(session, tool_calls, value, opts),
121 "finding" => handle_finding(session, value),
122 "decision" => handle_decision(session, value),
123 "reset" => handle_reset(session),
124 "list" => handle_list(session),
125 "cleanup" => handle_cleanup(),
126 "configure" => handle_configure(session, opts),
127 "snapshot" => handle_snapshot(session),
128 "restore" => handle_restore(session, session_id),
129 "resume" => handle_resume(session),
130 "profile" => handle_profile(value),
131 "role" => handle_role(value),
132 "diff" => handle_diff(value),
133 "output_stats" => handle_output_stats(),
134 "verify" => handle_verify(),
135 "episodes" | "procedures" => handle_retired_memory(action),
136 _ => handle_unknown(action),
137 }
138}
139
140pub fn handle_without_session(
145 action: &str,
146 value: Option<&str>,
147 _session_id: Option<&str>,
148) -> Option<String> {
149 match action {
150 "cleanup" => Some(handle_cleanup()),
151 "profile" => Some(handle_profile(value)),
152 "role" => Some(handle_role(value)),
153 "diff" => Some(handle_diff(value)),
154 "output_stats" => Some(handle_output_stats()),
155 "verify" => Some(handle_verify()),
156 "episodes" | "procedures" => Some(handle_retired_memory(action)),
157 _ => None,
158 }
159}
160
161pub fn handle_read_only(session: &SessionState, action: &str) -> Option<String> {
164 let Ok(projected) = lean_ctx::core::session::admission::for_delivery(session) else {
165 return Some("[lean-ctx gateway: session content withheld]".into());
166 };
167 let text = match action {
168 "status" | "show" => handle_status(&projected),
169 "handoff" => handle_handoff(&projected),
170 _ => return None,
171 };
172 Some(
173 lean_ctx::core::session::admission::admit_tool_value(&projected, &text)
174 .unwrap_or_else(|_| "[lean-ctx gateway: session content withheld]".into()),
175 )
176}
177
178pub fn handle_snapshot_read_action(
181 session: &SessionState,
182 action: &str,
183 value: Option<&str>,
184 opts: SessionToolOptions<'_>,
185) -> Option<String> {
186 match action {
187 "export" => {
188 let Ok(projected) = lean_ctx::core::session::admission::for_storage(session) else {
189 return Some("ERROR: session export withheld by current policy.".into());
190 };
191 let output = handle_export(&projected, value, opts);
192 let structural_json =
195 opts.format
196 .unwrap_or(if opts.write { "summary" } else { "json" })
197 != "summary";
198 Some(if structural_json {
199 output
200 } else {
201 lean_ctx::core::session::admission::admit_tool_value(&projected, &output)
202 .unwrap_or_else(|_| "ERROR: session export withheld by current policy.".into())
203 })
204 }
205 "resume" => {
206 let Ok(projected) = lean_ctx::core::session::admission::for_delivery(session) else {
207 return Some("[lean-ctx gateway: session content withheld]".into());
208 };
209 let output = projected.build_resume_block();
210 Some(
211 lean_ctx::core::session::admission::admit_tool_value(&projected, &output)
212 .unwrap_or_else(|_| "[lean-ctx gateway: session content withheld]".into()),
213 )
214 }
215 "snapshot" => {
216 let Ok(projected) = lean_ctx::core::session::admission::for_storage(session) else {
217 return Some("ERROR: session snapshot withheld by current policy.".into());
218 };
219 let output = projected.save_compaction_snapshot().map_or_else(
220 |error| format!("Snapshot failed: {error}"),
221 |snapshot| {
222 format!(
223 "Compaction snapshot saved ({} bytes).\n{snapshot}",
224 snapshot.len()
225 )
226 },
227 );
228 Some(
229 lean_ctx::core::session::admission::admit_tool_value(&projected, &output)
230 .unwrap_or_else(|_| {
231 "ERROR: session snapshot withheld by current policy.".into()
232 }),
233 )
234 }
235 _ => None,
236 }
237}
238
239fn handle_status(session: &SessionState) -> String {
246 let summary = session.format_compact_delivered();
247 match lean_ctx::core::workspace_authority::current() {
249 lean_ctx::core::workspace_authority::Authority::Legacy => {}
250 lean_ctx::core::workspace_authority::Authority::Workspace(snapshot) => {
251 return format!("{summary}\n{}", snapshot.status_line());
252 }
253 lean_ctx::core::workspace_authority::Authority::Unavailable(reason) => {
254 return format!(
255 "{summary}\n{}",
256 lean_ctx::core::workspace_authority::unavailable_reason(&reason)
257 );
258 }
259 }
260 match session
261 .project_root
262 .as_deref()
263 .and_then(lean_ctx::core::root_selection::recorded_for)
264 {
265 Some(selection) => format!("{summary}\n{}", selection.notice()),
266 None => summary,
267 }
268}
269
270fn handle_handoff(session: &SessionState) -> String {
271 let changeset = session
274 .project_root
275 .as_deref()
276 .map(|root| crate::tools::ctx_knowledge::session_change_count(root, &session.id))
277 .filter(|count| *count > 0)
278 .map(|count| {
279 format!(
280 "\nKnowledge written by this session: {count} change(s) — ctx_knowledge action=changes query=session:{}",
281 session.id
282 )
283 })
284 .unwrap_or_default();
285 if session.handoff_context.is_empty() {
286 return format!("Handoff context: no recent cross-session decisions.{changeset}");
287 }
288
289 let mut output = format!(
290 "Handoff context ({} recent cross-session decisions):",
291 session.handoff_context.len()
292 );
293 for fact in &session.handoff_context {
294 output.push_str(&format!(
295 "\n [{}] {} (confidence: {:.0}%)",
296 fact.category,
297 fact.value,
298 fact.confidence * 100.0
299 ));
300 }
301 output.push_str(&changeset);
302 output
303}
304
305fn handle_load(session: &mut SessionState, session_id: Option<&str>) -> String {
306 match prepare_load(session, session_id) {
307 Ok((loaded, output)) => {
308 *session = loaded;
309 output
310 }
311 Err(error) => error,
312 }
313}
314
315pub(crate) fn prepare_load(
318 session: &SessionState,
319 session_id: Option<&str>,
320) -> Result<(SessionState, String), String> {
321 const SCOPE_DENIED: &str = "ERROR: session load denied: scoped session unavailable or mismatched; live session unchanged.";
322
323 let receiving_root = session.strict_project_root().ok_or(SCOPE_DENIED)?;
324 let id = session_id.unwrap_or(session.id.as_str());
328 let loaded =
329 SessionState::load_by_id_for_project_root(id, &receiving_root).map_err(|_| SCOPE_DENIED)?;
330
331 let Some(mut loaded) = loaded else {
332 return Err(SCOPE_DENIED.into());
333 };
334 if loaded.strict_project_root().as_deref() != Some(receiving_root.as_str()) {
335 return Err(SCOPE_DENIED.into());
336 }
337
338 loaded.extra_roots.clone_from(&session.extra_roots);
341 loaded.shell_cwd.clone_from(&session.shell_cwd);
342 let admitted = lean_ctx::core::session::admission::for_storage(&loaded)
343 .map_err(|_| "ERROR: session load withheld by current policy; live session unchanged.")?;
344 let summary = handle_status(&admitted);
345 let output = lean_ctx::core::session::admission::admit_tool_value(
346 &admitted,
347 &format!("Session loaded.\n{summary}"),
348 )
349 .map_err(|_| "ERROR: session load withheld by current policy; live session unchanged.")?;
350 Ok((admitted, output))
351}
352
353fn handle_save(session: &mut SessionState) -> String {
354 let result = save_session(session);
355 if result.is_ok()
356 && let Some(root) = session.project_root.as_deref()
357 {
358 lean_ctx_hive::knowledge::draft::on_session_event(
360 root,
361 &session.id,
362 lean_ctx_hive::knowledge::draft::Trigger::Save,
363 );
364 }
365 result.unwrap_or_else(|error| format!("Save failed: {error}"))
366}
367
368pub fn save_session(session: &mut SessionState) -> Result<String, String> {
369 match session.save() {
370 Ok(()) => Ok(format!(
371 "Session {} saved (v{}).",
372 session.id, session.version
373 )),
374 Err(error) => Err(error),
375 }
376}
377
378fn handle_export(
379 session: &SessionState,
380 value: Option<&str>,
381 opts: SessionToolOptions<'_>,
382) -> String {
383 if session.canonical_checkpoint.is_some() {
384 return "ERROR: a canonical checkpoint requires a versioned carrier; legacy bundle export would discard its current lineage.".into();
385 }
386 {
387 let requested_privacy =
388 lean_ctx::core::ccp_session_bundle::BundlePrivacyV1::parse(opts.privacy);
389 if requested_privacy == lean_ctx::core::ccp_session_bundle::BundlePrivacyV1::Full
390 && lean_ctx::core::roles::active_role_name() != "admin"
391 {
392 return "ERROR: privacy=full requires role 'admin'.".to_string();
393 }
394
395 let bundle =
396 lean_ctx::core::ccp_session_bundle::build_bundle_v1(session, requested_privacy);
397 let json = match lean_ctx::core::ccp_session_bundle::serialize_bundle_v1_pretty(&bundle) {
398 Ok(s) => s,
399 Err(e) => return e,
400 };
401
402 let format = opts
403 .format
404 .unwrap_or(if opts.write { "summary" } else { "json" });
405 let root = session.project_root.clone().unwrap_or_else(|| {
406 std::env::current_dir()
407 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
408 });
409 let root_path = std::path::PathBuf::from(&root);
410
411 let mut written: Option<String> = None;
412 if opts.write || opts.path.is_some() {
413 let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
414 let candidate = if let Some(p) = opts.path.or(value) {
415 let p = std::path::PathBuf::from(p);
416 if p.is_absolute() {
417 p
418 } else {
419 root_path.join(p)
420 }
421 } else {
422 root_path
423 .join(".lean-ctx")
424 .join("session_bundles")
425 .join(format!(
426 "ccp-session-bundle-v1_{}_{}.json",
427 bundle.session.id, ts
428 ))
429 };
430
431 let jailed = match lean_ctx::core::io_boundary::jail_and_check_path(
432 "ctx_session.export",
433 candidate.as_path(),
434 root_path.as_path(),
435 ) {
436 Ok((p, _warning)) => p,
437 Err(e) => return e,
438 };
439
440 if let Err(e) = lean_ctx::core::pathjail::enforce_writable(&jailed) {
443 return format!("Export write failed: {e}");
444 }
445 if let Err(e) = lean_ctx::core::ccp_session_bundle::write_bundle_v1(&jailed, &json) {
446 return format!("Export write failed: {e}");
447 }
448 written = Some(jailed.to_string_lossy().to_string());
449 }
450
451 match format {
452 "summary" => {
453 let mut out = format!(
454 "CCP session bundle exported (v{}).\n\
455schema_version: {}\n\
456session_id: {}\n\
457bytes: {}\n",
458 bundle.session.version,
459 bundle.schema_version,
460 bundle.session.id,
461 json.len()
462 );
463 if let Some(p) = written {
464 out.push_str(&format!("path: {p}\n"));
465 }
466 if let Some(h) = bundle.project.project_root_hash {
467 out.push_str(&format!("project_root_hash: {h}\n"));
468 }
469 if let Some(h) = bundle.project.project_identity_hash {
470 out.push_str(&format!("project_identity_hash: {h}\n"));
471 }
472 out
473 }
474 _ => {
475 if let Some(p) = written {
476 format!("{json}\n\npath: {p}")
477 } else {
478 json
479 }
480 }
481 }
482 }
483}
484
485fn handle_import(
486 session: &mut SessionState,
487 value: Option<&str>,
488 opts: SessionToolOptions<'_>,
489) -> String {
490 {
491 let root = session.project_root.clone().unwrap_or_else(|| {
492 std::env::current_dir()
493 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
494 });
495 let root_path = std::path::PathBuf::from(&root);
496
497 let Some(p) = opts.path.or(value) else {
498 return "ERROR: path is required for action=import".to_string();
499 };
500
501 let candidate = {
502 let p = std::path::PathBuf::from(p);
503 if p.is_absolute() {
504 p
505 } else {
506 root_path.join(p)
507 }
508 };
509 let jailed = match lean_ctx::core::io_boundary::jail_and_check_path(
510 "ctx_session.import",
511 candidate.as_path(),
512 root_path.as_path(),
513 ) {
514 Ok((p, _warning)) => p,
515 Err(e) => return e,
516 };
517
518 let bundle = match lean_ctx::core::ccp_session_bundle::read_bundle_v1(&jailed) {
519 Ok(b) => b,
520 Err(e) => return format!("Import failed: {e}"),
521 };
522
523 let current_root_hash = lean_ctx::core::project_hash::hash_project_root(&root);
526 let current_identity_hash = lean_ctx::core::project_hash::project_identity(&root)
527 .as_deref()
528 .map(lean_ctx::core::hasher::hash_str);
529
530 let matching_scope = match (
531 bundle.project.project_identity_hash.as_ref(),
532 current_identity_hash.as_ref(),
533 ) {
534 (Some(exported), Some(current)) => exported == current,
535 _ => bundle.project.project_root_hash.as_ref() == Some(¤t_root_hash),
536 };
537 if !matching_scope {
538 return "Import failed: project scope mismatch; live session unchanged.".to_string();
539 }
540
541 let mut candidate = session.clone();
542 let report = lean_ctx::core::ccp_session_bundle::import_bundle_v1_into_session(
543 &mut candidate,
544 &bundle,
545 Some(&root),
546 );
547 if let Err(error) = candidate.save_new() {
550 return format!(
551 "Import failed: {error}; live session unchanged; recovery candidate: {}.",
552 candidate.id
553 );
554 }
555 *session = candidate;
556
557 format!(
558 "CCP session bundle imported.\n\
559session_id: {}\n\
560source_session_id: {}\n\
561version: {}\n\
562files_touched: {}\n\
563stale_files: {}\n",
564 report.session_id,
565 report.source_session_id,
566 report.version,
567 report.files_touched,
568 report.stale_files
569 )
570 }
571}
572
573fn handle_task(
574 session: &mut SessionState,
575 _tool_calls: &[(String, u64)],
576 value: Option<&str>,
577 _opts: SessionToolOptions<'_>,
578) -> String {
579 {
580 let desc = value.unwrap_or("(no description)");
581 session.set_task(desc, None);
582 format!("Task set: {desc}")
583 }
584}
585
586fn handle_finding(session: &mut SessionState, value: Option<&str>) -> String {
587 {
588 let summary = value.unwrap_or("(no summary)");
589 let (file, line, text) = parse_finding_value(summary);
590 session.add_finding_for_file(file.as_deref(), line, text);
591 format!("Finding added: {summary}")
592 }
593}
594
595fn handle_decision(session: &mut SessionState, value: Option<&str>) -> String {
596 {
597 let desc = value.unwrap_or("(no description)");
598 session.add_decision(desc, None);
599 format!("Decision recorded: {desc}")
600 }
601}
602
603fn handle_reset(session: &mut SessionState) -> String {
604 if let Err(error) = session.save() {
605 return format!("Reset refused: current session was not saved: {error}");
606 }
607 reset_after_save(session)
608}
609
610pub(crate) fn reset_after_save(session: &mut SessionState) -> String {
611 let old_id = session.id.clone();
612 *session = SessionState::new();
613 let mut ledger = lean_ctx::core::context_ledger::ContextLedger::load();
615 ledger.reset();
616 ledger.save();
617 if let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir() {
618 let radar_path = data_dir.join("context_radar.jsonl");
619 let prev = data_dir.join("context_radar.prev.jsonl");
620 let _ = std::fs::rename(&radar_path, &prev);
621 }
622 format!(
623 "Session reset. Previous: {old_id}. New: {}. Ledger cleared (0% pressure).",
624 session.id
625 )
626}
627
628fn handle_list(session: &SessionState) -> String {
629 handle_scoped_list(session).unwrap_or_else(|_| {
630 "ERROR: session list withheld by project scope or content policy.".into()
631 })
632}
633
634fn handle_scoped_list(session: &SessionState) -> Result<String, String> {
635 let text = lean_ctx::core::session::admission::with_store_admission(session, |admission| {
636 let root = session
637 .strict_project_root()
638 .ok_or_else(|| "session project scope unavailable".to_string())?;
639 let mut rows = Vec::new();
640 for summary in SessionState::list_sessions() {
641 let Ok(Some(owner)) = SessionState::load_by_id_for_project_root(&summary.id, &root)
642 else {
643 continue;
644 };
645 let task = owner
646 .task
647 .as_ref()
648 .map(|task| task.description.as_str())
649 .unwrap_or("(no task)");
650 let Some(admitted_task) =
651 lean_ctx::core::session::admission::admit_store_text(admission, task)
652 else {
653 continue;
654 };
655 rows.push((summary, admitted_task));
656 }
657 if rows.is_empty() {
658 return Ok("No sessions found.".into());
659 }
660 let count = rows.len();
661 let mut lines = vec![format!("Sessions ({count}):")];
662 for (summary, task) in rows.iter().take(10) {
663 let task_short: String = task.chars().take(40).collect();
664 lines.push(format!(
665 " {} v{} | {} calls | {} tok | {}",
666 summary.id, summary.version, summary.tool_calls, summary.tokens_saved, task_short
667 ));
668 }
669 if count > 10 {
670 lines.push(format!(" ... +{} more", count - 10));
671 }
672 Ok(lines.join("\n"))
673 })?;
674 lean_ctx::core::session::admission::admit_tool_value(session, &text)
675}
676
677fn handle_cleanup() -> String {
678 let days = lean_ctx::core::config::Config::load().session_retention_days_effective();
679 let removed = SessionState::cleanup_old_sessions(i64::from(days));
680 format!("Cleaned up {removed} old session(s) (>{days} days).")
681}
682
683fn handle_configure(session: &mut SessionState, opts: SessionToolOptions<'_>) -> String {
684 match opts.terse {
685 Some(enabled) => {
686 session.terse_mode = enabled;
687 session.increment();
688 format!("Session configured: terse_mode={enabled}")
689 }
690 None => format!("Session config: terse_mode={}", session.terse_mode),
691 }
692}
693
694fn handle_snapshot(session: &mut SessionState) -> String {
695 match session.save_compaction_snapshot() {
696 Ok(snapshot) => {
697 format!(
698 "Compaction snapshot saved ({} bytes).\n{snapshot}",
699 snapshot.len()
700 )
701 }
702 Err(e) => format!("Snapshot failed: {e}"),
703 }
704}
705
706fn handle_restore(session: &SessionState, session_id: Option<&str>) -> String {
707 handle_scoped_restore(session, session_id).unwrap_or_else(|_| {
708 "ERROR: session restore withheld by project scope or content policy.".into()
709 })
710}
711
712fn handle_scoped_restore(
713 session: &SessionState,
714 session_id: Option<&str>,
715) -> Result<String, String> {
716 let root = session
717 .strict_project_root()
718 .ok_or_else(|| "session project scope unavailable".to_string())?;
719 let id = session_id.unwrap_or(session.id.as_str());
722 let owner = SessionState::load_by_id_for_project_root(id, &root)
723 .map_err(|_| "session project scope mismatch".to_string())?;
724 let Some(owner) = owner else {
725 return Ok("No compaction snapshot found. Session continues fresh.".into());
726 };
727 let Some(snapshot) = SessionState::load_compaction_snapshot(&owner.id) else {
728 return Ok("No compaction snapshot found. Session continues fresh.".into());
729 };
730 let output = format!("Session restored from compaction snapshot:\n{snapshot}");
731 lean_ctx::core::session::admission::admit_tool_value(session, &output)
732}
733
734fn handle_resume(session: &mut SessionState) -> String {
735 session.build_resume_block()
736}
737
738fn handle_profile(value: Option<&str>) -> String {
739 {
740 use lean_ctx::core::profiles;
741 if let Some(name) = value {
742 if let Ok(p) = profiles::set_active_profile(name) {
743 format!(
744 "Profile switched to '{name}'.\n\
745 Read mode: {}, CRP: {}",
746 p.read.default_mode_effective(),
747 p.compression.crp_mode_effective(),
748 )
749 } else {
750 let available: Vec<String> = profiles::list_profiles()
751 .iter()
752 .map(|p| p.name.clone())
753 .collect();
754 format!(
755 "Profile '{name}' not found. Available: {}",
756 available.join(", ")
757 )
758 }
759 } else {
760 let name = profiles::active_profile_name();
761 let p = profiles::active_profile();
762 let list = profiles::list_profiles();
763 let mut out = format!(
764 "Active profile: {name}\n\
765 Read: {}, CRP: {}\n\n\
766 Available profiles:",
767 p.read.default_mode_effective(),
768 p.compression.crp_mode_effective(),
769 );
770 for info in &list {
771 let marker = if info.name == name { " *" } else { " " };
772 out.push_str(&format!(
773 "\n{marker} {:<14} ({}) {}",
774 info.name, info.source, info.description
775 ));
776 }
777 out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
778 out
779 }
780 }
781}
782
783fn handle_role(value: Option<&str>) -> String {
784 {
785 use lean_ctx::core::roles;
786 if let Some(name) = value {
787 match roles::set_active_role(name) {
788 Ok(r) => {
789 format!(
790 "Role switched to '{name}'.\n\
791 Shell: {}\n\
792 Tools: {}",
793 r.role.shell_policy,
794 if r.tools.allowed.iter().any(|a| a == "*") {
795 let denied = if r.tools.denied.is_empty() {
796 "none".to_string()
797 } else {
798 format!("denied: {}", r.tools.denied.join(", "))
799 };
800 format!("* (all), {denied}")
801 } else {
802 r.tools.allowed.join(", ")
803 }
804 )
805 }
806 Err(e) => {
807 let available: Vec<String> =
808 roles::list_roles().iter().map(|r| r.name.clone()).collect();
809 format!("{e}. Available: {}", available.join(", "))
810 }
811 }
812 } else {
813 let name = roles::active_role_name();
814 let r = roles::active_role();
815 let list = roles::list_roles();
816 let mut out = format!(
817 "Active role: {name}\n\
818 Description: {}\n\
819 Shell policy: {}\n\n\
820 Available roles:",
821 r.role.description, r.role.shell_policy,
822 );
823 for info in &list {
824 let marker = if info.is_active { " *" } else { " " };
825 out.push_str(&format!(
826 "\n{marker} {:<14} ({}) {}",
827 info.name, info.source, info.description
828 ));
829 }
830 out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
831 out
832 }
833 }
834}
835
836fn handle_diff(value: Option<&str>) -> String {
837 {
838 let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
839 if parts.len() < 2 {
840 return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
841 Formats: summary (default), json\n\
842 Example: ctx_session diff abc123 def456 json"
843 .to_string();
844 }
845 let id_a = parts[0];
846 let id_b = parts[1];
847 let format = parts.get(2).copied().unwrap_or("summary");
848
849 let Some(project) = lean_ctx::core::policy::runtime::REQUEST_PROJECT
852 .try_with(|slot| slot.borrow().clone())
853 .ok()
854 .flatten()
855 else {
856 return "ERROR: project_unresolved — session diff needs the request's project."
857 .to_string();
858 };
859 let project = project.to_string_lossy();
860 let load = |id: &str| {
861 SessionState::load_by_id_for_project_root(id, &project)
862 .ok()
863 .flatten()
864 };
865
866 match (load(id_a), load(id_b)) {
867 (Some(a), Some(b)) => {
868 let d = lean_ctx::core::session_diff::diff_sessions(&a, &b);
869 match format {
870 "json" => d.format_json(),
871 _ => d.format_summary(),
872 }
873 }
874 (None, _) => format!("Session not found: {id_a}"),
875 (_, None) => format!("Session not found: {id_b}"),
876 }
877 }
878}
879
880fn handle_output_stats() -> String {
881 {
882 let snap = lean_ctx::core::output_verification::stats_snapshot();
883 let mut out = snap.format_compact();
884 let echo = lean_ctx::core::output_echo::load_stats();
886 if !echo.reports.is_empty() {
887 out.push_str(&format!(
888 "\nOutput echo: {:.0}% avg over last {} replies ({} analyzed total)",
889 echo.avg_ratio(50) * 100.0,
890 echo.reports.len(),
891 echo.total_analyzed
892 ));
893 }
894 out
895 }
896}
897
898fn handle_verify() -> String {
899 {
900 let snap = lean_ctx::core::output_verification::stats_snapshot();
901 format!(
902 "DEPRECATION: action=\"verify\" is renamed to action=\"output_stats\" (ctx_verify is the full observability stack).\n{}",
903 snap.format_compact()
904 )
905 }
906}
907
908fn handle_retired_memory(action: &str) -> String {
913 format!(
914 "DEPRECATION: action=\"{action}\" is retired with the episode and procedure memory and has no effect. Decisions, findings and gotchas live in ctx_knowledge (action=recall or action=search)."
915 )
916}
917
918fn handle_unknown(action: &str) -> String {
919 format!(
920 "Unknown action: {action}. Use: status, handoff, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, configure, profile, role, diff, output_stats, verify, export, import"
921 )
922}
923
924fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
925 const EM_DASH_SEP: &str = " \u{2014} ";
926 const ASCII_SEP: &str = " - ";
927
928 let (dash_pos, sep) = if let Some(p) = value.find(EM_DASH_SEP) {
929 (Some(p), EM_DASH_SEP)
930 } else {
931 (value.find(ASCII_SEP), ASCII_SEP)
932 };
933
934 if let Some(pos) = dash_pos {
935 let location = &value[..pos];
936 let text = &value[pos + sep.len()..];
937
938 if let Some(colon_pos) = location.rfind(':') {
939 let file = &location[..colon_pos];
940 if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
941 return (Some(file.to_string()), Some(line), text);
942 }
943 }
944 return (Some(location.to_string()), None, text);
945 }
946 (None, None, value)
947}
948
949#[cfg(test)]
950mod tests;