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