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