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