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(
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        // A legacy identity hash is a scope check, not signer admission. Never
526        // overwrite a foreign project's session after only displaying a warning.
527        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(&current_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        // Import into a new identity, preserving both the current session and
550        // any existing archive bearing the untrusted source identity.
551        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    // Clear the persistent context ledger so pressure resets to 0%
616    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    // Without an id: the caller's own snapshot, never the project's latest
722    // session's (I07/S03).
723    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        // Only sessions of the request's own project are compared; a foreign
852        // session reads as absent rather than confirming it exists (AP04-G8).
853        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        // Agent-output echo summary (#501).
887        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
910/// `ctx_session action=episodes|procedures` (any value): retired with the
911/// episode and procedure memory. Accepted as a no-op until its removal
912/// (`rust/data/DEPRECATIONS.toml`); files under `memory/episodes` and
913/// `memory/procedures` stay on disk untouched.
914fn 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;