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(
446 &root_path, &candidate, &jailed, &json,
447 ) {
448 return format!("Export write failed: {e}");
449 }
450 written = Some(jailed.to_string_lossy().to_string());
451 }
452
453 match format {
454 "summary" => {
455 let mut out = format!(
456 "CCP session bundle exported (v{}).\n\
457schema_version: {}\n\
458session_id: {}\n\
459bytes: {}\n",
460 bundle.session.version,
461 bundle.schema_version,
462 bundle.session.id,
463 json.len()
464 );
465 if let Some(p) = written {
466 out.push_str(&format!("path: {p}\n"));
467 }
468 if let Some(h) = bundle.project.project_root_hash {
469 out.push_str(&format!("project_root_hash: {h}\n"));
470 }
471 if let Some(h) = bundle.project.project_identity_hash {
472 out.push_str(&format!("project_identity_hash: {h}\n"));
473 }
474 out
475 }
476 _ => {
477 if let Some(p) = written {
478 format!("{json}\n\npath: {p}")
479 } else {
480 json
481 }
482 }
483 }
484 }
485}
486
487fn handle_import(
488 session: &mut SessionState,
489 value: Option<&str>,
490 opts: SessionToolOptions<'_>,
491) -> String {
492 {
493 let root = session.project_root.clone().unwrap_or_else(|| {
494 std::env::current_dir()
495 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
496 });
497 let root_path = std::path::PathBuf::from(&root);
498
499 let Some(p) = opts.path.or(value) else {
500 return "ERROR: path is required for action=import".to_string();
501 };
502
503 let candidate = {
504 let p = std::path::PathBuf::from(p);
505 if p.is_absolute() {
506 p
507 } else {
508 root_path.join(p)
509 }
510 };
511 let jailed = match lean_ctx::core::io_boundary::jail_and_check_path(
512 "ctx_session.import",
513 candidate.as_path(),
514 root_path.as_path(),
515 ) {
516 Ok((p, _warning)) => p,
517 Err(e) => return e,
518 };
519
520 let bundle = match lean_ctx::core::ccp_session_bundle::read_bundle_v1(&jailed) {
521 Ok(b) => b,
522 Err(e) => return format!("Import failed: {e}"),
523 };
524
525 let current_root_hash = lean_ctx::core::project_hash::hash_project_root(&root);
528 let current_identity_hash = lean_ctx::core::project_hash::project_identity(&root)
529 .as_deref()
530 .map(lean_ctx::core::hasher::hash_str);
531
532 let matching_scope = match (
533 bundle.project.project_identity_hash.as_ref(),
534 current_identity_hash.as_ref(),
535 ) {
536 (Some(exported), Some(current)) => exported == current,
537 _ => bundle.project.project_root_hash.as_ref() == Some(¤t_root_hash),
538 };
539 if !matching_scope {
540 return "Import failed: project scope mismatch; live session unchanged.".to_string();
541 }
542
543 let mut candidate = session.clone();
544 let report = lean_ctx::core::ccp_session_bundle::import_bundle_v1_into_session(
545 &mut candidate,
546 &bundle,
547 Some(&root),
548 );
549 if let Err(error) = candidate.save_new() {
552 return format!(
553 "Import failed: {error}; live session unchanged; recovery candidate: {}.",
554 candidate.id
555 );
556 }
557 *session = candidate;
558
559 format!(
560 "CCP session bundle imported.\n\
561session_id: {}\n\
562source_session_id: {}\n\
563version: {}\n\
564files_touched: {}\n\
565stale_files: {}\n",
566 report.session_id,
567 report.source_session_id,
568 report.version,
569 report.files_touched,
570 report.stale_files
571 )
572 }
573}
574
575fn handle_task(
576 session: &mut SessionState,
577 _tool_calls: &[(String, u64)],
578 value: Option<&str>,
579 _opts: SessionToolOptions<'_>,
580) -> String {
581 {
582 let desc = value.unwrap_or("(no description)");
583 session.set_task(desc, None);
584 format!("Task set: {desc}")
585 }
586}
587
588fn handle_finding(session: &mut SessionState, value: Option<&str>) -> String {
589 {
590 let summary = value.unwrap_or("(no summary)");
591 let (file, line, text) = parse_finding_value(summary);
592 session.add_finding_for_file(file.as_deref(), line, text);
593 format!("Finding added: {summary}")
594 }
595}
596
597fn handle_decision(session: &mut SessionState, value: Option<&str>) -> String {
598 {
599 let desc = value.unwrap_or("(no description)");
600 session.add_decision(desc, None);
601 format!("Decision recorded: {desc}")
602 }
603}
604
605fn handle_reset(session: &mut SessionState) -> String {
606 if let Err(error) = session.save() {
607 return format!("Reset refused: current session was not saved: {error}");
608 }
609 reset_after_save(session)
610}
611
612pub(crate) fn reset_after_save(session: &mut SessionState) -> String {
613 let old_id = session.id.clone();
614 *session = SessionState::new();
615 let mut ledger = lean_ctx::core::context_ledger::ContextLedger::load();
617 ledger.reset();
618 ledger.save();
619 if let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir() {
620 let radar_path = data_dir.join("context_radar.jsonl");
621 let prev = data_dir.join("context_radar.prev.jsonl");
622 let _ = std::fs::rename(&radar_path, &prev);
623 }
624 format!(
625 "Session reset. Previous: {old_id}. New: {}. Ledger cleared (0% pressure).",
626 session.id
627 )
628}
629
630fn handle_list(session: &SessionState) -> String {
631 handle_scoped_list(session).unwrap_or_else(|_| {
632 "ERROR: session list withheld by project scope or content policy.".into()
633 })
634}
635
636fn handle_scoped_list(session: &SessionState) -> Result<String, String> {
637 let text = lean_ctx::core::session::admission::with_store_admission(session, |admission| {
638 let root = session
639 .strict_project_root()
640 .ok_or_else(|| "session project scope unavailable".to_string())?;
641 let mut rows = Vec::new();
642 for summary in SessionState::list_sessions() {
643 let Ok(Some(owner)) = SessionState::load_by_id_for_project_root(&summary.id, &root)
644 else {
645 continue;
646 };
647 let task = owner
648 .task
649 .as_ref()
650 .map(|task| task.description.as_str())
651 .unwrap_or("(no task)");
652 let Some(admitted_task) =
653 lean_ctx::core::session::admission::admit_store_text(admission, task)
654 else {
655 continue;
656 };
657 rows.push((summary, admitted_task));
658 }
659 if rows.is_empty() {
660 return Ok("No sessions found.".into());
661 }
662 let count = rows.len();
663 let mut lines = vec![format!("Sessions ({count}):")];
664 for (summary, task) in rows.iter().take(10) {
665 let task_short: String = task.chars().take(40).collect();
666 lines.push(format!(
667 " {} v{} | {} calls | {} tok | {}",
668 summary.id, summary.version, summary.tool_calls, summary.tokens_saved, task_short
669 ));
670 }
671 if count > 10 {
672 lines.push(format!(" ... +{} more", count - 10));
673 }
674 Ok(lines.join("\n"))
675 })?;
676 lean_ctx::core::session::admission::admit_tool_value(session, &text)
677}
678
679fn handle_cleanup() -> String {
680 let days = lean_ctx::core::config::Config::load().session_retention_days_effective();
681 let removed = SessionState::cleanup_old_sessions(i64::from(days));
682 format!("Cleaned up {removed} old session(s) (>{days} days).")
683}
684
685fn handle_configure(session: &mut SessionState, opts: SessionToolOptions<'_>) -> String {
686 match opts.terse {
687 Some(enabled) => {
688 session.terse_mode = enabled;
689 session.increment();
690 format!("Session configured: terse_mode={enabled}")
691 }
692 None => format!("Session config: terse_mode={}", session.terse_mode),
693 }
694}
695
696fn handle_snapshot(session: &mut SessionState) -> String {
697 match session.save_compaction_snapshot() {
698 Ok(snapshot) => {
699 format!(
700 "Compaction snapshot saved ({} bytes).\n{snapshot}",
701 snapshot.len()
702 )
703 }
704 Err(e) => format!("Snapshot failed: {e}"),
705 }
706}
707
708fn handle_restore(session: &SessionState, session_id: Option<&str>) -> String {
709 handle_scoped_restore(session, session_id).unwrap_or_else(|_| {
710 "ERROR: session restore withheld by project scope or content policy.".into()
711 })
712}
713
714fn handle_scoped_restore(
715 session: &SessionState,
716 session_id: Option<&str>,
717) -> Result<String, String> {
718 let root = session
719 .strict_project_root()
720 .ok_or_else(|| "session project scope unavailable".to_string())?;
721 let id = session_id.unwrap_or(session.id.as_str());
724 let owner = SessionState::load_by_id_for_project_root(id, &root)
725 .map_err(|_| "session project scope mismatch".to_string())?;
726 let Some(owner) = owner else {
727 return Ok("No compaction snapshot found. Session continues fresh.".into());
728 };
729 let Some(snapshot) = SessionState::load_compaction_snapshot(&owner.id) else {
730 return Ok("No compaction snapshot found. Session continues fresh.".into());
731 };
732 let output = format!("Session restored from compaction snapshot:\n{snapshot}");
733 lean_ctx::core::session::admission::admit_tool_value(session, &output)
734}
735
736fn handle_resume(session: &mut SessionState) -> String {
737 session.build_resume_block()
738}
739
740fn handle_profile(value: Option<&str>) -> String {
741 {
742 use lean_ctx::core::profiles;
743 if let Some(name) = value {
744 if let Ok(p) = profiles::set_active_profile(name) {
745 format!(
746 "Profile switched to '{name}'.\n\
747 Read mode: {}, CRP: {}",
748 p.read.default_mode_effective(),
749 p.compression.crp_mode_effective(),
750 )
751 } else {
752 let available: Vec<String> = profiles::list_profiles()
753 .iter()
754 .map(|p| p.name.clone())
755 .collect();
756 format!(
757 "Profile '{name}' not found. Available: {}",
758 available.join(", ")
759 )
760 }
761 } else {
762 let name = profiles::active_profile_name();
763 let p = profiles::active_profile();
764 let list = profiles::list_profiles();
765 let mut out = format!(
766 "Active profile: {name}\n\
767 Read: {}, CRP: {}\n\n\
768 Available profiles:",
769 p.read.default_mode_effective(),
770 p.compression.crp_mode_effective(),
771 );
772 for info in &list {
773 let marker = if info.name == name { " *" } else { " " };
774 out.push_str(&format!(
775 "\n{marker} {:<14} ({}) {}",
776 info.name, info.source, info.description
777 ));
778 }
779 out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
780 out
781 }
782 }
783}
784
785fn handle_role(value: Option<&str>) -> String {
786 {
787 use lean_ctx::core::roles;
788 if let Some(name) = value {
789 match roles::set_active_role(name) {
790 Ok(r) => {
791 format!(
792 "Role switched to '{name}'.\n\
793 Shell: {}\n\
794 Tools: {}",
795 r.role.shell_policy,
796 if r.tools.allowed.iter().any(|a| a == "*") {
797 let denied = if r.tools.denied.is_empty() {
798 "none".to_string()
799 } else {
800 format!("denied: {}", r.tools.denied.join(", "))
801 };
802 format!("* (all), {denied}")
803 } else {
804 r.tools.allowed.join(", ")
805 }
806 )
807 }
808 Err(e) => {
809 let available: Vec<String> =
810 roles::list_roles().iter().map(|r| r.name.clone()).collect();
811 format!("{e}. Available: {}", available.join(", "))
812 }
813 }
814 } else {
815 let name = roles::active_role_name();
816 let r = roles::active_role();
817 let list = roles::list_roles();
818 let mut out = format!(
819 "Active role: {name}\n\
820 Description: {}\n\
821 Shell policy: {}\n\n\
822 Available roles:",
823 r.role.description, r.role.shell_policy,
824 );
825 for info in &list {
826 let marker = if info.is_active { " *" } else { " " };
827 out.push_str(&format!(
828 "\n{marker} {:<14} ({}) {}",
829 info.name, info.source, info.description
830 ));
831 }
832 out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
833 out
834 }
835 }
836}
837
838fn handle_diff(value: Option<&str>) -> String {
839 {
840 let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
841 if parts.len() < 2 {
842 return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
843 Formats: summary (default), json\n\
844 Example: ctx_session diff abc123 def456 json"
845 .to_string();
846 }
847 let id_a = parts[0];
848 let id_b = parts[1];
849 let format = parts.get(2).copied().unwrap_or("summary");
850
851 let Some(project) = lean_ctx::core::policy::runtime::REQUEST_PROJECT
854 .try_with(|slot| slot.borrow().clone())
855 .ok()
856 .flatten()
857 else {
858 return "ERROR: project_unresolved — session diff needs the request's project."
859 .to_string();
860 };
861 let project = project.to_string_lossy();
862 let load = |id: &str| {
863 SessionState::load_by_id_for_project_root(id, &project)
864 .ok()
865 .flatten()
866 };
867
868 match (load(id_a), load(id_b)) {
869 (Some(a), Some(b)) => {
870 let d = lean_ctx::core::session_diff::diff_sessions(&a, &b);
871 match format {
872 "json" => d.format_json(),
873 _ => d.format_summary(),
874 }
875 }
876 (None, _) => format!("Session not found: {id_a}"),
877 (_, None) => format!("Session not found: {id_b}"),
878 }
879 }
880}
881
882fn handle_output_stats() -> String {
883 {
884 let snap = lean_ctx::core::output_verification::stats_snapshot();
885 let mut out = snap.format_compact();
886 let echo = lean_ctx::core::output_echo::load_stats();
888 if !echo.reports.is_empty() {
889 out.push_str(&format!(
890 "\nOutput echo: {:.0}% avg over last {} replies ({} analyzed total)",
891 echo.avg_ratio(50) * 100.0,
892 echo.reports.len(),
893 echo.total_analyzed
894 ));
895 }
896 out
897 }
898}
899
900fn handle_verify() -> String {
901 {
902 let snap = lean_ctx::core::output_verification::stats_snapshot();
903 format!(
904 "DEPRECATION: action=\"verify\" is renamed to action=\"output_stats\" (ctx_verify is the full observability stack).\n{}",
905 snap.format_compact()
906 )
907 }
908}
909
910fn handle_retired_memory(action: &str) -> String {
915 format!(
916 "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)."
917 )
918}
919
920fn handle_unknown(action: &str) -> String {
921 format!(
922 "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"
923 )
924}
925
926fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
927 const EM_DASH_SEP: &str = " \u{2014} ";
928 const ASCII_SEP: &str = " - ";
929
930 let (dash_pos, sep) = if let Some(p) = value.find(EM_DASH_SEP) {
931 (Some(p), EM_DASH_SEP)
932 } else {
933 (value.find(ASCII_SEP), ASCII_SEP)
934 };
935
936 if let Some(pos) = dash_pos {
937 let location = &value[..pos];
938 let text = &value[pos + sep.len()..];
939
940 if let Some(colon_pos) = location.rfind(':') {
941 let file = &location[..colon_pos];
942 if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
943 return (Some(file.to_string()), Some(line), text);
944 }
945 }
946 return (Some(location.to_string()), None, text);
947 }
948 (None, None, value)
949}
950
951#[cfg(test)]
952mod tests;