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 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" | "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 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 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 != ¤t_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 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) => {} 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 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 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 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
772fn 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 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 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}