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