Skip to main content

lean_ctx/tools/
ctx_session.rs

1use crate::core::session::SessionState;
2
3#[derive(Clone, Copy, Debug)]
4pub struct SessionToolOptions<'a> {
5    pub format: Option<&'a str>,
6    pub path: Option<&'a str>,
7    pub write: bool,
8    pub privacy: Option<&'a str>,
9    /// For `action=configure`: set terse output mode when `Some`.
10    pub terse: Option<bool>,
11    pub agent_id: Option<&'a str>,
12}
13
14pub fn handle(
15    session: &mut SessionState,
16    tool_calls: &[(String, u64)],
17    action: &str,
18    value: Option<&str>,
19    session_id: Option<&str>,
20    opts: SessionToolOptions<'_>,
21) -> String {
22    match action {
23        "status" | "show" => handle_status(session),
24        "load" => handle_load(session, session_id),
25        "save" => handle_save(session),
26        "export" => handle_export(session, value, opts),
27        "import" => handle_import(session, value, opts),
28        "task" => handle_task(session, tool_calls, value, opts),
29        "finding" => handle_finding(session, value),
30        "decision" => handle_decision(session, value),
31        "reset" => handle_reset(session),
32        "list" => handle_list(),
33        "cleanup" => handle_cleanup(),
34        "configure" => handle_configure(session, opts),
35        "snapshot" => handle_snapshot(session),
36        "restore" => handle_restore(session_id),
37        "resume" => handle_resume(session),
38        "profile" => handle_profile(value),
39        "budget" => handle_budget(),
40        "role" => handle_role(value),
41        "diff" => handle_diff(value),
42        "slo" => handle_slo(value),
43        "output_stats" => handle_output_stats(),
44        "verify" => handle_verify(),
45        "episodes" => handle_episodes(session, tool_calls, value, opts),
46        "procedures" => handle_procedures(session, value),
47        _ => handle_unknown(action),
48    }
49}
50
51fn handle_status(session: &mut SessionState) -> String {
52    session.format_compact()
53}
54
55fn handle_load(session: &mut SessionState, session_id: Option<&str>) -> String {
56    {
57        let loaded = if let Some(id) = session_id {
58            SessionState::load_by_id(id)
59        } else {
60            SessionState::load_latest()
61        };
62
63        if let Some(prev) = loaded {
64            let summary = prev.format_compact();
65            *session = prev;
66            format!("Session loaded.\n{summary}")
67        } else {
68            let id_str = session_id.unwrap_or("latest");
69            format!("No session found (id: {id_str}). Starting fresh.")
70        }
71    }
72}
73
74fn handle_save(session: &mut SessionState) -> String {
75    match session.save() {
76        Ok(()) => format!("Session {} saved (v{}).", session.id, session.version),
77        Err(e) => format!("Save failed: {e}"),
78    }
79}
80
81fn handle_export(
82    session: &mut SessionState,
83    value: Option<&str>,
84    opts: SessionToolOptions<'_>,
85) -> String {
86    {
87        let requested_privacy =
88            crate::core::ccp_session_bundle::BundlePrivacyV1::parse(opts.privacy);
89        if requested_privacy == crate::core::ccp_session_bundle::BundlePrivacyV1::Full
90            && crate::core::roles::active_role_name() != "admin"
91        {
92            return "ERROR: privacy=full requires role 'admin'.".to_string();
93        }
94
95        let bundle = crate::core::ccp_session_bundle::build_bundle_v1(session, requested_privacy);
96        let json = match crate::core::ccp_session_bundle::serialize_bundle_v1_pretty(&bundle) {
97            Ok(s) => s,
98            Err(e) => return e,
99        };
100
101        let format = opts
102            .format
103            .unwrap_or(if opts.write { "summary" } else { "json" });
104        let root = session.project_root.clone().unwrap_or_else(|| {
105            std::env::current_dir()
106                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
107        });
108        let root_path = std::path::PathBuf::from(&root);
109
110        let mut written: Option<String> = None;
111        if opts.write || opts.path.is_some() {
112            let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
113            let candidate = if let Some(p) = opts.path.or(value) {
114                let p = std::path::PathBuf::from(p);
115                if p.is_absolute() {
116                    p
117                } else {
118                    root_path.join(p)
119                }
120            } else {
121                root_path
122                    .join(".lean-ctx")
123                    .join("session_bundles")
124                    .join(format!(
125                        "ccp-session-bundle-v1_{}_{}.json",
126                        bundle.session.id, ts
127                    ))
128            };
129
130            let jailed = match crate::core::io_boundary::jail_and_check_path(
131                "ctx_session.export",
132                candidate.as_path(),
133                root_path.as_path(),
134            ) {
135                Ok((p, _warning)) => p,
136                Err(e) => return e,
137            };
138
139            // Read-only-roots choke point (#475): export must not write a
140            // bundle into a read-only root even when the jail allows reads.
141            if let Err(e) = crate::core::pathjail::enforce_writable(&jailed) {
142                return format!("Export write failed: {e}");
143            }
144            if let Err(e) = crate::core::ccp_session_bundle::write_bundle_v1(&jailed, &json) {
145                return format!("Export write failed: {e}");
146            }
147            written = Some(jailed.to_string_lossy().to_string());
148        }
149
150        match format {
151            "summary" => {
152                let mut out = format!(
153                    "CCP session bundle exported (v{}).\n\
154schema_version: {}\n\
155session_id: {}\n\
156bytes: {}\n",
157                    bundle.session.version,
158                    bundle.schema_version,
159                    bundle.session.id,
160                    json.len()
161                );
162                if let Some(p) = written {
163                    out.push_str(&format!("path: {p}\n"));
164                }
165                if let Some(h) = bundle.project.project_root_hash {
166                    out.push_str(&format!("project_root_hash: {h}\n"));
167                }
168                if let Some(h) = bundle.project.project_identity_hash {
169                    out.push_str(&format!("project_identity_hash: {h}\n"));
170                }
171                out
172            }
173            _ => {
174                if let Some(p) = written {
175                    format!("{json}\n\npath: {p}")
176                } else {
177                    json
178                }
179            }
180        }
181    }
182}
183
184fn handle_import(
185    session: &mut SessionState,
186    value: Option<&str>,
187    opts: SessionToolOptions<'_>,
188) -> String {
189    {
190        let root = session.project_root.clone().unwrap_or_else(|| {
191            std::env::current_dir()
192                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
193        });
194        let root_path = std::path::PathBuf::from(&root);
195
196        let Some(p) = opts.path.or(value) else {
197            return "ERROR: path is required for action=import".to_string();
198        };
199
200        let candidate = {
201            let p = std::path::PathBuf::from(p);
202            if p.is_absolute() {
203                p
204            } else {
205                root_path.join(p)
206            }
207        };
208        let jailed = match crate::core::io_boundary::jail_and_check_path(
209            "ctx_session.import",
210            candidate.as_path(),
211            root_path.as_path(),
212        ) {
213            Ok((p, _warning)) => p,
214            Err(e) => return e,
215        };
216
217        let bundle = match crate::core::ccp_session_bundle::read_bundle_v1(&jailed) {
218            Ok(b) => b,
219            Err(e) => return format!("Import failed: {e}"),
220        };
221
222        // Replayability hint: compare project identity hashes (best-effort).
223        let current_root_hash = crate::core::project_hash::hash_project_root(&root);
224        let current_identity_hash = crate::core::project_hash::project_identity(&root)
225            .as_deref()
226            .map(|s| {
227                use md5::{Digest, Md5};
228                let mut h = Md5::new();
229                h.update(s.as_bytes());
230                crate::core::agent_identity::hex_encode(&h.finalize())
231            });
232
233        let mut warning: Option<String> = None;
234        if let Some(ref exported) = bundle.project.project_root_hash
235            && exported != &current_root_hash
236        {
237            warning = Some(
238                "WARNING: project_root_hash mismatch (importing into different project root)."
239                    .to_string(),
240            );
241        }
242        if let (Some(exported), Some(current)) = (
243            bundle.project.project_identity_hash.as_ref(),
244            current_identity_hash.as_ref(),
245        ) && exported != current
246        {
247            warning = Some("WARNING: project_identity_hash mismatch (importing into different project identity).".to_string());
248        }
249
250        let report = crate::core::ccp_session_bundle::import_bundle_v1_into_session(
251            session,
252            &bundle,
253            Some(&root),
254        );
255        let _ = session.save();
256
257        let mut out = format!(
258            "CCP session bundle imported.\n\
259session_id: {}\n\
260version: {}\n\
261files_touched: {}\n\
262stale_files: {}\n",
263            report.session_id, report.version, report.files_touched, report.stale_files
264        );
265        if let Some(w) = warning {
266            out.push_str(&format!("{w}\n"));
267        }
268        out
269    }
270}
271
272fn handle_task(
273    session: &mut SessionState,
274    tool_calls: &[(String, u64)],
275    value: Option<&str>,
276    opts: SessionToolOptions<'_>,
277) -> String {
278    {
279        let desc = value.unwrap_or("(no description)");
280        session.set_task(desc, None);
281        // Auto-record an episode when the task is marked complete.
282        // Without this, Episodes only fill via an explicit
283        // `action=episodes value=record` call that nobody makes (#477).
284        let lower = desc.to_lowercase();
285        let completed =
286            desc.contains("[100%]") || lower.contains("[done]") || lower.contains("[complete]");
287        let mut note = String::new();
288        if completed {
289            match auto_record_episode(session, tool_calls, opts.agent_id) {
290                Ok(Some(id)) => {
291                    note = format!("\nEpisode auto-recorded: {id}");
292                }
293                Ok(None) => {} // same task already recorded — skip duplicate
294                Err(e) => {
295                    note = format!("\n(episode auto-record skipped: {e})");
296                }
297            }
298        }
299        format!("Task set: {desc}{note}")
300    }
301}
302
303fn handle_finding(session: &mut SessionState, value: Option<&str>) -> String {
304    {
305        let summary = value.unwrap_or("(no summary)");
306        let (file, line, text) = parse_finding_value(summary);
307        session.add_finding(file.as_deref(), line, text);
308        format!("Finding added: {summary}")
309    }
310}
311
312fn handle_decision(session: &mut SessionState, value: Option<&str>) -> String {
313    {
314        let desc = value.unwrap_or("(no description)");
315        session.add_decision(desc, None);
316        format!("Decision recorded: {desc}")
317    }
318}
319
320fn handle_reset(session: &mut SessionState) -> String {
321    {
322        let _ = session.save();
323        let old_id = session.id.clone();
324        *session = SessionState::new();
325        crate::core::budget_tracker::BudgetTracker::global().reset();
326        // Clear the persistent context ledger so pressure resets to 0%
327        let mut ledger = crate::core::context_ledger::ContextLedger::load();
328        ledger.reset();
329        ledger.save();
330        if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
331            let radar_path = data_dir.join("context_radar.jsonl");
332            let prev = data_dir.join("context_radar.prev.jsonl");
333            let _ = std::fs::rename(&radar_path, &prev);
334        }
335        format!(
336            "Session reset. Previous: {old_id}. New: {}. Ledger cleared (0% pressure).",
337            session.id
338        )
339    }
340}
341
342fn handle_list() -> String {
343    {
344        let sessions = SessionState::list_sessions();
345        if sessions.is_empty() {
346            return "No sessions found.".to_string();
347        }
348        let mut lines = vec![format!("Sessions ({}):", sessions.len())];
349        for s in sessions.iter().take(10) {
350            let task = s.task.as_deref().unwrap_or("(no task)");
351            let task_short: String = task.chars().take(40).collect();
352            lines.push(format!(
353                "  {} v{} | {} calls | {} tok | {}",
354                s.id, s.version, s.tool_calls, s.tokens_saved, task_short
355            ));
356        }
357        if sessions.len() > 10 {
358            lines.push(format!("  ... +{} more", sessions.len() - 10));
359        }
360        lines.join("\n")
361    }
362}
363
364fn handle_cleanup() -> String {
365    {
366        let removed = SessionState::cleanup_old_sessions(7);
367        format!("Cleaned up {removed} old session(s) (>7 days).")
368    }
369}
370
371fn handle_configure(session: &mut SessionState, opts: SessionToolOptions<'_>) -> String {
372    match opts.terse {
373        Some(enabled) => {
374            session.terse_mode = enabled;
375            session.increment();
376            format!("Session configured: terse_mode={enabled}")
377        }
378        None => format!("Session config: terse_mode={}", session.terse_mode),
379    }
380}
381
382fn handle_snapshot(session: &mut SessionState) -> String {
383    match session.save_compaction_snapshot() {
384        Ok(snapshot) => {
385            format!(
386                "Compaction snapshot saved ({} bytes).\n{snapshot}",
387                snapshot.len()
388            )
389        }
390        Err(e) => format!("Snapshot failed: {e}"),
391    }
392}
393
394fn handle_restore(session_id: Option<&str>) -> String {
395    {
396        let snapshot = if let Some(id) = session_id {
397            SessionState::load_compaction_snapshot(id)
398        } else {
399            SessionState::load_latest_snapshot()
400        };
401        match snapshot {
402            Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
403            None => "No compaction snapshot found. Session continues fresh.".to_string(),
404        }
405    }
406}
407
408fn handle_resume(session: &mut SessionState) -> String {
409    session.build_resume_block()
410}
411
412fn handle_profile(value: Option<&str>) -> String {
413    {
414        use crate::core::profiles;
415        if let Some(name) = value {
416            if let Ok(p) = profiles::set_active_profile(name) {
417                format!(
418                    "Profile switched to '{name}'.\n\
419                         Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
420                    p.read.default_mode_effective(),
421                    p.budget.max_context_tokens_effective(),
422                    p.compression.crp_mode_effective(),
423                    p.compression.output_density_effective(),
424                )
425            } else {
426                let available: Vec<String> = profiles::list_profiles()
427                    .iter()
428                    .map(|p| p.name.clone())
429                    .collect();
430                format!(
431                    "Profile '{name}' not found. Available: {}",
432                    available.join(", ")
433                )
434            }
435        } else {
436            let name = profiles::active_profile_name();
437            let p = profiles::active_profile();
438            let list = profiles::list_profiles();
439            let mut out = format!(
440                "Active profile: {name}\n\
441                     Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
442                     Available profiles:",
443                p.read.default_mode_effective(),
444                p.budget.max_context_tokens_effective(),
445                p.compression.crp_mode_effective(),
446                p.compression.output_density_effective(),
447            );
448            for info in &list {
449                let marker = if info.name == name { " *" } else { "  " };
450                out.push_str(&format!(
451                    "\n{marker} {:<14} ({}) {}",
452                    info.name, info.source, info.description
453                ));
454            }
455            out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
456            out
457        }
458    }
459}
460
461fn handle_budget() -> String {
462    {
463        use crate::core::budget_tracker::BudgetTracker;
464        let snap = BudgetTracker::global().check();
465        let mut out = snap.format_compact();
466
467        if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
468            let window = crate::core::context_radar::default_window_for_client("cursor");
469            let radar = crate::core::context_radar::ContextRadar::load(&data_dir, window);
470            let radar_display = radar.format_display();
471            if !radar_display.is_empty() {
472                out.push_str("\n\n");
473                out.push_str(&radar_display);
474            }
475        }
476        out
477    }
478}
479
480fn handle_role(value: Option<&str>) -> String {
481    {
482        use crate::core::roles;
483        if let Some(name) = value {
484            match roles::set_active_role(name) {
485                Ok(r) => {
486                    crate::core::budget_tracker::BudgetTracker::global().reset();
487                    format!(
488                        "Role switched to '{name}'.\n\
489                             Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
490                             Tools: {}",
491                        r.role.shell_policy,
492                        r.limits.max_context_tokens,
493                        r.limits.max_shell_invocations,
494                        r.limits.max_cost_usd,
495                        if r.tools.allowed.iter().any(|a| a == "*") {
496                            let denied = if r.tools.denied.is_empty() {
497                                "none".to_string()
498                            } else {
499                                format!("denied: {}", r.tools.denied.join(", "))
500                            };
501                            format!("* (all), {denied}")
502                        } else {
503                            r.tools.allowed.join(", ")
504                        }
505                    )
506                }
507                Err(e) => {
508                    let available: Vec<String> =
509                        roles::list_roles().iter().map(|r| r.name.clone()).collect();
510                    format!("{e}. Available: {}", available.join(", "))
511                }
512            }
513        } else {
514            let name = roles::active_role_name();
515            let r = roles::active_role();
516            let list = roles::list_roles();
517            let mut out = format!(
518                "Active role: {name}\n\
519                     Description: {}\n\
520                     Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
521                     Available roles:",
522                r.role.description,
523                r.role.shell_policy,
524                r.limits.max_context_tokens,
525                r.limits.max_shell_invocations,
526                r.limits.max_cost_usd,
527            );
528            for info in &list {
529                let marker = if info.is_active { " *" } else { "  " };
530                out.push_str(&format!(
531                    "\n{marker} {:<14} ({}) {}",
532                    info.name, info.source, info.description
533                ));
534            }
535            out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
536            out
537        }
538    }
539}
540
541fn handle_diff(value: Option<&str>) -> String {
542    {
543        let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
544        if parts.len() < 2 {
545            return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
546                        Formats: summary (default), json\n\
547                        Example: ctx_session diff abc123 def456 json"
548                .to_string();
549        }
550        let id_a = parts[0];
551        let id_b = parts[1];
552        let format = parts.get(2).copied().unwrap_or("summary");
553
554        let sess_a = SessionState::load_by_id(id_a);
555        let sess_b = SessionState::load_by_id(id_b);
556
557        match (sess_a, sess_b) {
558            (Some(a), Some(b)) => {
559                let d = crate::core::session_diff::diff_sessions(&a, &b);
560                match format {
561                    "json" => d.format_json(),
562                    _ => d.format_summary(),
563                }
564            }
565            (None, _) => format!("Session not found: {id_a}"),
566            (_, None) => format!("Session not found: {id_b}"),
567        }
568    }
569}
570
571fn handle_slo(value: Option<&str>) -> String {
572    match value {
573        Some("reload") => {
574            crate::core::slo::reload();
575            "SLO definitions reloaded from disk.".to_string()
576        }
577        Some("history") => {
578            let hist = crate::core::slo::violation_history(20);
579            if hist.is_empty() {
580                "No SLO violations recorded.".to_string()
581            } else {
582                let mut out = format!("SLO violations (last {}):\n", hist.len());
583                for v in &hist {
584                    out.push_str(&format!(
585                        "  {} {} ({}) {:.2} vs {:.2} → {}\n",
586                        v.timestamp, v.slo_name, v.metric, v.actual, v.threshold, v.action
587                    ));
588                }
589                out
590            }
591        }
592        Some("clear") => {
593            crate::core::slo::clear_violations();
594            "SLO violation history cleared.".to_string()
595        }
596        _ => {
597            let snap = crate::core::slo::evaluate_quiet();
598            snap.format_compact()
599        }
600    }
601}
602
603fn handle_output_stats() -> String {
604    {
605        let snap = crate::core::output_verification::stats_snapshot();
606        let mut out = snap.format_compact();
607        // Agent-output echo summary (#501).
608        let echo = crate::core::output_echo::load_stats();
609        if !echo.reports.is_empty() {
610            out.push_str(&format!(
611                "\nOutput echo: {:.0}% avg over last {} replies ({} analyzed total)",
612                echo.avg_ratio(50) * 100.0,
613                echo.reports.len(),
614                echo.total_analyzed
615            ));
616        }
617        out
618    }
619}
620
621fn handle_verify() -> String {
622    {
623        let snap = crate::core::output_verification::stats_snapshot();
624        format!(
625            "DEPRECATION: action=\"verify\" is renamed to action=\"output_stats\" (ctx_verify is the full observability stack).\n{}",
626            snap.format_compact()
627        )
628    }
629}
630
631fn handle_episodes(
632    session: &mut SessionState,
633    tool_calls: &[(String, u64)],
634    value: Option<&str>,
635    opts: SessionToolOptions<'_>,
636) -> String {
637    {
638        let project_root = session.project_root.clone().unwrap_or_else(|| {
639            std::env::current_dir().map_or_else(
640                |_| "unknown".to_string(),
641                |p| p.to_string_lossy().to_string(),
642            )
643        });
644        let policy = match crate::core::config::Config::load().memory_policy_effective() {
645            Ok(p) => p,
646            Err(e) => {
647                let path = crate::core::config::Config::path().map_or_else(
648                    || "~/.lean-ctx/config.toml".to_string(),
649                    |p| p.display().to_string(),
650                );
651                return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
652            }
653        };
654        let hash = crate::core::project_hash::hash_project_root(&project_root);
655        match value {
656            Some("record") => {
657                let id = match crate::core::episodic_memory::record_session_episode(
658                    &hash,
659                    session,
660                    tool_calls,
661                    opts.agent_id,
662                    &policy.episodic,
663                    false,
664                ) {
665                    Ok(Some(id)) => id,
666                    Ok(None) => return "Episode already recorded.".to_string(),
667                    Err(e) => return format!("Episode record failed: {e}"),
668                };
669                crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
670                    category: "episodic".to_string(),
671                    key: id.clone(),
672                    action: "record".to_string(),
673                });
674                // Auto-learning (GL #478): every new episode re-runs workflow
675                // detection, so Procedures fill themselves over time.
676                let learned = crate::core::procedural_memory::auto_detect_from_episodes(
677                    &hash,
678                    &policy.procedural,
679                );
680                match learned {
681                    Some(n) if n > 0 => {
682                        format!(
683                            "Episode recorded: {id} (procedures auto-updated: {n} known workflows)"
684                        )
685                    }
686                    _ => format!("Episode recorded: {id}"),
687                }
688            }
689            Some(v) if v.starts_with("search ") => {
690                let q = v.trim_start_matches("search ").trim();
691                let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
692                let hits = store.search(q);
693                if hits.is_empty() {
694                    return "No episodes matched.".to_string();
695                }
696                let mut out = format!("Episodes matched ({}):", hits.len());
697                for ep in hits.into_iter().take(10) {
698                    let task: String = ep.task_description.chars().take(50).collect();
699                    out.push_str(&format!(
700                        "\n  {} | {} | {} | {}",
701                        ep.id,
702                        ep.timestamp,
703                        ep.outcome.label(),
704                        task
705                    ));
706                }
707                out
708            }
709            Some(v) if v.starts_with("file ") => {
710                let f = v.trim_start_matches("file ").trim();
711                let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
712                let hits = store.by_file(f);
713                let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
714                for ep in hits.into_iter().take(10) {
715                    let task: String = ep.task_description.chars().take(50).collect();
716                    out.push_str(&format!(
717                        "\n  {} | {} | {} | {}",
718                        ep.id,
719                        ep.timestamp,
720                        ep.outcome.label(),
721                        task
722                    ));
723                }
724                out
725            }
726            Some(v) if v.starts_with("outcome ") => {
727                let label = v.trim_start_matches("outcome ").trim();
728                let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
729                let hits = store.by_outcome(label);
730                let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
731                for ep in hits.into_iter().take(10) {
732                    let task: String = ep.task_description.chars().take(50).collect();
733                    out.push_str(&format!("\n  {} | {} | {}", ep.id, ep.timestamp, task));
734                }
735                out
736            }
737            _ => {
738                let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
739                let stats = store.stats();
740                let recent = store.recent(10);
741                let mut out = format!(
742                    "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
743                    stats.total_episodes,
744                    stats.success_rate * 100.0,
745                    stats.total_tokens
746                );
747                for ep in recent {
748                    let task: String = ep.task_description.chars().take(60).collect();
749                    out.push_str(&format!(
750                        "\n  {} | {} | {} | {}",
751                        ep.id,
752                        ep.timestamp,
753                        ep.outcome.label(),
754                        task
755                    ));
756                }
757                out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
758                out
759            }
760        }
761    }
762}
763
764fn handle_procedures(session: &mut SessionState, value: Option<&str>) -> String {
765    {
766        let project_root = session.project_root.clone().unwrap_or_else(|| {
767            std::env::current_dir().map_or_else(
768                |_| "unknown".to_string(),
769                |p| p.to_string_lossy().to_string(),
770            )
771        });
772        let policy = match crate::core::config::Config::load().memory_policy_effective() {
773            Ok(p) => p,
774            Err(e) => {
775                let path = crate::core::config::Config::path().map_or_else(
776                    || "~/.lean-ctx/config.toml".to_string(),
777                    |p| p.display().to_string(),
778                );
779                return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
780            }
781        };
782        let hash = crate::core::project_hash::hash_project_root(&project_root);
783        let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
784        let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
785
786        match value {
787            Some("detect") => {
788                procs.detect_patterns(&episodes.episodes, &policy.procedural);
789                if let Err(e) = procs.save() {
790                    return format!("Procedure detect failed: {e}");
791                }
792                crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
793                    category: "procedural".to_string(),
794                    key: hash.clone(),
795                    action: "detect".to_string(),
796                });
797                format!(
798                    "Procedures updated. Total procedures: {} (episodes: {}).",
799                    procs.procedures.len(),
800                    episodes.episodes.len()
801                )
802            }
803            Some(v) if v.starts_with("suggest ") => {
804                let task = v.trim_start_matches("suggest ").trim();
805                let hits = procs.suggest(task);
806                if hits.is_empty() {
807                    return "No procedures matched.".to_string();
808                }
809                let mut out = format!("Procedures suggested ({}):", hits.len());
810                for p in hits.into_iter().take(10) {
811                    out.push_str(&format!(
812                        "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
813                        p.name,
814                        p.confidence * 100.0,
815                        p.success_rate() * 100.0,
816                        p.steps.len()
817                    ));
818                }
819                out
820            }
821            _ => {
822                let task = session
823                    .task
824                    .as_ref()
825                    .map(|t| t.description.clone())
826                    .unwrap_or_default();
827                let suggestions = if task.is_empty() {
828                    Vec::new()
829                } else {
830                    procs.suggest(&task)
831                };
832
833                let mut out = format!(
834                    "Procedural memory: {} procedures (episodes: {})",
835                    procs.procedures.len(),
836                    episodes.episodes.len()
837                );
838
839                if !task.is_empty() {
840                    out.push_str(&format!(
841                        "\nTask: {}",
842                        task.chars().take(80).collect::<String>()
843                    ));
844                    if !suggestions.is_empty() {
845                        out.push_str("\n\nSuggested:");
846                        for p in suggestions.into_iter().take(5) {
847                            out.push_str(&format!(
848                                "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
849                                p.name,
850                                p.confidence * 100.0,
851                                p.success_rate() * 100.0,
852                                p.steps.len()
853                            ));
854                        }
855                    }
856                }
857
858                out.push_str(
859                    "\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"",
860                );
861                out
862            }
863        }
864    }
865}
866
867fn handle_unknown(action: &str) -> String {
868    format!(
869        "Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, configure, profile, role, budget, slo, diff, output_stats, verify, export, import, episodes, procedures"
870    )
871}
872
873/// Records an episode from the current session when a task completes.
874///
875/// Returns `Ok(Some(id))` on record, `Ok(None)` when the latest episode
876/// already covers the same task (duplicate guard), `Err` on policy/IO issues.
877fn auto_record_episode(
878    session: &SessionState,
879    tool_calls: &[(String, u64)],
880    agent_id: Option<&str>,
881) -> Result<Option<String>, String> {
882    let project_root = session.project_root.clone().unwrap_or_else(|| {
883        std::env::current_dir().map_or_else(
884            |_| "unknown".to_string(),
885            |p| p.to_string_lossy().to_string(),
886        )
887    });
888    let policy = crate::core::config::Config::load()
889        .memory_policy_effective()
890        .map_err(|e| format!("invalid memory policy: {e}"))?;
891    let hash = crate::core::project_hash::hash_project_root(&project_root);
892    let Some(id) = crate::core::episodic_memory::record_session_episode(
893        &hash,
894        session,
895        tool_calls,
896        agent_id,
897        &policy.episodic,
898        true,
899    )?
900    else {
901        return Ok(None);
902    };
903    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
904        category: "episodic".to_string(),
905        key: id.clone(),
906        action: "auto_record".to_string(),
907    });
908
909    // Each new episode is a chance to learn a procedure: mine the episode
910    // history for repeated tool sequences. Best-effort — pattern detection
911    // must never fail the task update itself (#478).
912    let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
913    let episodes: Vec<crate::core::episodic_memory::Episode> =
914        store.recent(50).into_iter().cloned().collect();
915    let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
916    let before = procs.procedures.len();
917    procs.detect_patterns(&episodes, &policy.procedural);
918    if procs.procedures.len() > before && procs.save().is_ok() {
919        crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
920            category: "procedural".to_string(),
921            key: format!("{} new", procs.procedures.len() - before),
922            action: "auto_learn".to_string(),
923        });
924    }
925
926    Ok(Some(id))
927}
928
929fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
930    const EM_DASH_SEP: &str = " \u{2014} ";
931    const ASCII_SEP: &str = " - ";
932
933    let (dash_pos, sep) = if let Some(p) = value.find(EM_DASH_SEP) {
934        (Some(p), EM_DASH_SEP)
935    } else {
936        (value.find(ASCII_SEP), ASCII_SEP)
937    };
938
939    if let Some(pos) = dash_pos {
940        let location = &value[..pos];
941        let text = &value[pos + sep.len()..];
942
943        if let Some(colon_pos) = location.rfind(':') {
944            let file = &location[..colon_pos];
945            if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
946                return (Some(file.to_string()), Some(line), text);
947            }
948        }
949        return (Some(location.to_string()), None, text);
950    }
951    (None, None, value)
952}
953
954#[cfg(test)]
955mod tests {
956    use super::parse_finding_value;
957
958    #[test]
959    fn finding_with_em_dash_and_file_line() {
960        let (file, line, text) =
961            parse_finding_value("auth.rs:42 \u{2014} missing token validation");
962        assert_eq!(file.as_deref(), Some("auth.rs"));
963        assert_eq!(line, Some(42));
964        assert_eq!(text, "missing token validation");
965    }
966
967    #[test]
968    fn finding_with_ascii_dash_and_file_line() {
969        let (file, line, text) = parse_finding_value("auth.rs:42 - missing token validation");
970        assert_eq!(file.as_deref(), Some("auth.rs"));
971        assert_eq!(line, Some(42));
972        assert_eq!(text, "missing token validation");
973    }
974
975    #[test]
976    fn finding_with_em_dash_no_line() {
977        let (file, line, text) = parse_finding_value("auth module \u{2014} needs refactoring");
978        assert_eq!(file.as_deref(), Some("auth module"));
979        assert_eq!(line, None);
980        assert_eq!(text, "needs refactoring");
981    }
982
983    #[test]
984    fn finding_plain_text() {
985        let (file, line, text) = parse_finding_value("plain text finding");
986        assert_eq!(file, None);
987        assert_eq!(line, None);
988        assert_eq!(text, "plain text finding");
989    }
990
991    #[test]
992    fn finding_cyrillic_with_em_dash_issue_272() {
993        let value = "ruff: pyproject.toml dev-group \u{2014} >=0.15.14,<0.16.0 (был 0.14.x)";
994        let (file, line, text) = parse_finding_value(value);
995        assert_eq!(file.as_deref(), Some("ruff: pyproject.toml dev-group"));
996        assert_eq!(line, None);
997        assert_eq!(text, ">=0.15.14,<0.16.0 (был 0.14.x)");
998    }
999
1000    #[test]
1001    fn finding_em_dash_at_start() {
1002        let (file, line, text) = parse_finding_value("src/main.rs:1 \u{2014} entry point");
1003        assert_eq!(file.as_deref(), Some("src/main.rs"));
1004        assert_eq!(line, Some(1));
1005        assert_eq!(text, "entry point");
1006    }
1007}