Skip to main content

lean_ctx_server/tools/
ctx_session.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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    /// For `action=configure`: set terse output mode when `Some`.
12    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                    // Two different refusals. The gateway withheld the reply's
37                    // content: its notice (`[lean-ctx …]`). Or no policy scope
38                    // could be established for this session at all (no usable
39                    // project root): that is a failed action, reported in the
40                    // tool's own `ERROR:` form like every other scope denial —
41                    // never the action's unadmitted output.
42                    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
60/// Prepare content-bearing actions from a detached snapshot. Callers with a
61/// live session lock commit candidate only after checking snapshot identity.
62pub 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
140/// Handle actions that do not access live-session state. Keeping them outside
141/// the live session lock prevents an explicit `list`/`cleanup` request from
142/// stalling unrelated reads and shells behind an O(session-store) operation.
143/// Some actions intentionally update independent process-global configuration.
144pub 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
161/// Handle session reads after rechecking all recalled content under the
162/// current project and Gateway policies.
163pub 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
178/// Run snapshot/export work after the caller cloned live state and released its
179/// lock. Persisted projections are admitted before they reach an archive writer.
180pub 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            // The default export is structural JSON from the already-admitted
193            // typed projection; masking that serialization would corrupt it.
194            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
239/// The session summary as a delivery: findings publish their sources or mark
240/// the call's capture, and any other session text marks it incomplete
241/// (`SessionState::format_compact_delivered`).
242///
243/// When the session's root was chosen among several workspace roots, a line
244/// names the choice, the other roots and how to change it (AP04-G7).
245fn handle_status(session: &SessionState) -> String {
246    let summary = session.format_compact_delivered();
247    // Under a launcher Workspace its binding is the root line (#2120).
248    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    // What this session changed in the knowledge store, from its journal:
272    // the successor reads the changeset instead of diffing the store.
273    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
315/// Load and admit a detached candidate. The caller may commit it only after
316/// confirming that the live session identity and version still match.
317pub(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    // Without an id: the caller's own saved session — never the project's
325    // latest, which may be another agent's (I07/S03). Another session is
326    // loaded only by naming it, e.g. the id a handoff gave.
327    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    // These fields come from live MCP roots/shell and must not be replaced by
339    // persisted state. Validate root scope before this policy lookup.
340    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        // Queue learnings for Hive, debounced per project (local only).
359        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            // Read-only-roots choke point (#475): export must not write a
441            // bundle into a read-only root even when the jail allows reads.
442            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        // A legacy identity hash is a scope check, not signer admission. Never
524        // overwrite a foreign project's session after only displaying a warning.
525        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(&current_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        // Import into a new identity, preserving both the current session and
548        // any existing archive bearing the untrusted source identity.
549        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    // Clear the persistent context ledger so pressure resets to 0%
614    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    // Without an id: the caller's own snapshot, never the project's latest
720    // session's (I07/S03).
721    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        // Only sessions of the request's own project are compared; a foreign
850        // session reads as absent rather than confirming it exists (AP04-G8).
851        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        // Agent-output echo summary (#501).
885        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
908/// `ctx_session action=episodes|procedures` (any value): retired with the
909/// episode and procedure memory. Accepted as a no-op until its removal
910/// (`rust/data/DEPRECATIONS.toml`); files under `memory/episodes` and
911/// `memory/procedures` stay on disk untouched.
912fn 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;