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