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