@@ -34,7 +34,9 @@ use crate::global_db::GlobalDb;
3434use crate :: sessions:: cursor:: {
3535 resolve_hermes_profile_session_db_readonly, HermesProfileDbReadOnly ,
3636} ;
37- use crate :: sessions:: lcm:: { LcmGrepRequest , LcmGrepSort , LcmScope } ;
37+ use crate :: sessions:: lcm:: {
38+ LcmGrepRequest , LcmGrepSort , LcmScope , LcmSessionReplayRequest , LcmSessionReplaySlice ,
39+ } ;
3840use crate :: tracedecay:: { current_timestamp, TraceDecay } ;
3941
4042pub use super :: memory_curator:: {
@@ -43,6 +45,14 @@ pub use super::memory_curator::{
4345
4446const SKILL_ANALYTICS_IMPORT_LIMIT : usize = 2_000 ;
4547
48+ /// Bounds for the session-replay evidence channel. Worst case per session is
49+ /// `(4 + 4) * 500 + 3 * 700 = 6_100` snippet chars, so the default three
50+ /// sessions stay under ~5k tokens alongside the grep hits.
51+ const SESSION_REPLAY_HEAD_TURNS : usize = 4 ;
52+ const SESSION_REPLAY_TAIL_TURNS : usize = 4 ;
53+ const SESSION_REPLAY_SNIPPET_CHARS : usize = 500 ;
54+ const SESSION_REPLAY_SUMMARY_NODES : usize = 3 ;
55+ const SESSION_REPLAY_SUMMARY_CHARS : usize = 700 ;
4656#[ derive( Debug , Clone , PartialEq , Serialize , Deserialize ) ]
4757pub struct SessionReflectorAutomationOptions {
4858 #[ serde( default ) ]
@@ -65,6 +75,14 @@ pub struct SessionReflectorAutomationOptions {
6575 pub include_summaries : bool ,
6676 #[ serde( default = "default_session_evidence_limit" ) ]
6777 pub evidence_limit : usize ,
78+ /// When true, include bounded turn-ordered slices of recently active
79+ /// sessions as a primary evidence channel alongside the keyword grep.
80+ #[ serde( default = "default_include_recent_sessions" ) ]
81+ pub include_recent_sessions : bool ,
82+ /// How many recently active sessions to replay when `session_id` is not
83+ /// explicitly set.
84+ #[ serde( default = "default_recent_sessions_limit" ) ]
85+ pub recent_sessions_limit : usize ,
6886 #[ serde( default = "default_lcm_grep_sort" ) ]
6987 pub sort : LcmGrepSort ,
7088 #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
@@ -90,6 +108,8 @@ impl Default for SessionReflectorAutomationOptions {
90108 session_id : None ,
91109 include_summaries : default_include_summaries ( ) ,
92110 evidence_limit : default_session_evidence_limit ( ) ,
111+ include_recent_sessions : default_include_recent_sessions ( ) ,
112+ recent_sessions_limit : default_recent_sessions_limit ( ) ,
93113 sort : default_lcm_grep_sort ( ) ,
94114 source : None ,
95115 role : None ,
@@ -124,6 +144,13 @@ pub struct SkillWriterAutomationOptions {
124144 pub query : String ,
125145 #[ serde( default = "default_skill_writer_evidence_limit" ) ]
126146 pub evidence_limit : usize ,
147+ /// When true, include bounded turn-ordered slices of recently active
148+ /// sessions as a primary evidence channel alongside the keyword grep.
149+ #[ serde( default = "default_include_recent_sessions" ) ]
150+ pub include_recent_sessions : bool ,
151+ /// How many recently active sessions to replay.
152+ #[ serde( default = "default_recent_sessions_limit" ) ]
153+ pub recent_sessions_limit : usize ,
127154 #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
128155 pub profile_root : Option < PathBuf > ,
129156}
@@ -138,6 +165,8 @@ impl Default for SkillWriterAutomationOptions {
138165 provider : default_skill_writer_provider ( ) ,
139166 query : default_skill_writer_query ( ) ,
140167 evidence_limit : default_skill_writer_evidence_limit ( ) ,
168+ include_recent_sessions : default_include_recent_sessions ( ) ,
169+ recent_sessions_limit : default_recent_sessions_limit ( ) ,
141170 profile_root : None ,
142171 }
143172 }
@@ -684,7 +713,28 @@ async fn build_session_reflector_evidence(
684713 . map_err ( |e| TraceDecayError :: Config {
685714 message : format ! ( "failed to build session reflection evidence: {e}" ) ,
686715 } ) ?;
716+ let recent_session_slices = if options. include_recent_sessions
717+ && session_reflector_replay_allowed (
718+ options. scope ,
719+ session_id. as_deref ( ) ,
720+ source. as_deref ( ) ,
721+ role. as_deref ( ) ,
722+ options. start_time ,
723+ options. end_time ,
724+ ) {
725+ recent_session_replay_evidence (
726+ & lcm_db,
727+ & provider,
728+ session_id. as_deref ( ) ,
729+ options. recent_sessions_limit ,
730+ "session_reflector" ,
731+ )
732+ . await ?
733+ } else {
734+ None
735+ } ;
687736 let evidence = json ! ( {
737+ "evidence_mode" : evidence_mode_label( recent_session_slices. is_some( ) ) ,
688738 "storage_scope" : storage_scope,
689739 "hermes_home" : options. hermes_home. as_ref( ) . map( |path| path. display( ) . to_string( ) ) ,
690740 "provider" : provider,
@@ -697,14 +747,19 @@ async fn build_session_reflector_evidence(
697747 "role" : role,
698748 "start_time" : options. start_time,
699749 "end_time" : options. end_time,
750+ "recent_session_slices" : recent_session_slices,
700751 "hits" : hits,
701752 } ) ;
702753 let evidence_hash = Some ( sha256_json ( & evidence) ) ;
703- if evidence
754+ let has_grep_hits = evidence
704755 . get ( "hits" )
705756 . and_then ( Value :: as_array)
706- . is_none_or ( Vec :: is_empty)
707- {
757+ . is_some_and ( |hits| !hits. is_empty ( ) ) ;
758+ let has_replay_sessions = evidence
759+ . pointer ( "/recent_session_slices/sessions" )
760+ . and_then ( Value :: as_array)
761+ . is_some_and ( |sessions| !sessions. is_empty ( ) ) ;
762+ if !has_grep_hits && !has_replay_sessions {
708763 return Ok ( SessionReflectorEvidenceOutcome :: Skipped {
709764 reason : "no_session_evidence" ,
710765 evidence_hash,
@@ -772,6 +827,18 @@ async fn build_skill_writer_evidence(
772827 . map_err ( |e| TraceDecayError :: Config {
773828 message : format ! ( "failed to build skill writer evidence: {e}" ) ,
774829 } ) ?;
830+ let recent_session_slices = if options. include_recent_sessions {
831+ recent_session_replay_evidence (
832+ & lcm_db,
833+ & provider,
834+ None ,
835+ options. recent_sessions_limit ,
836+ "skill_writer" ,
837+ )
838+ . await ?
839+ } else {
840+ None
841+ } ;
775842 let existing_skills = list_managed_skills ( & profile_root) . await ?;
776843 let global_db = GlobalDb :: open ( ) . await ;
777844 ingest_project_analytics_events (
@@ -805,10 +872,12 @@ async fn build_skill_writer_evidence(
805872 & underused_tool_families,
806873 ) ;
807874 let evidence = json ! ( {
875+ "evidence_mode" : evidence_mode_label( recent_session_slices. is_some( ) ) ,
808876 "storage_scope" : storage_scope,
809877 "hermes_home" : options. hermes_home. as_ref( ) . map( |path| path. display( ) . to_string( ) ) ,
810878 "provider" : provider,
811879 "query" : query,
880+ "recent_session_slices" : recent_session_slices,
812881 "hits" : hits,
813882 "skill_usage_summaries" : skill_usage_summaries,
814883 "stale_recommendations" : stale_recommendations,
@@ -834,11 +903,15 @@ async fn build_skill_writer_evidence(
834903 . collect:: <Vec <_>>( ) ,
835904 } ) ;
836905 let evidence_hash = Some ( sha256_json ( & evidence) ) ;
837- if evidence
906+ let has_grep_hits = evidence
838907 . get ( "hits" )
839908 . and_then ( Value :: as_array)
840- . is_none_or ( Vec :: is_empty)
841- {
909+ . is_some_and ( |hits| !hits. is_empty ( ) ) ;
910+ let has_replay_sessions = evidence
911+ . pointer ( "/recent_session_slices/sessions" )
912+ . and_then ( Value :: as_array)
913+ . is_some_and ( |sessions| !sessions. is_empty ( ) ) ;
914+ if !has_grep_hits && !has_replay_sessions {
842915 return Ok ( SkillWriterEvidenceOutcome :: Skipped {
843916 reason : "no_skill_writer_evidence" ,
844917 evidence_hash,
@@ -1236,6 +1309,7 @@ fn build_combined_review_prompt(reflector_evidence: &Value, skill_evidence: &Val
12361309fn build_session_reflector_prompt ( evidence : & Value ) -> String {
12371310 const POLICY : & str = concat ! (
12381311 "Review these bounded TraceDecay session snippets and propose only durable memory facts.\n " ,
1312+ "Evidence has two channels: recent_session_slices holds turn-ordered head/tail turns and summary nodes replayed from recently active sessions, and hits holds keyword search matches; both are citable.\n " ,
12391313 "\n " ,
12401314 "Signals worth capturing (any one is enough):\n " ,
12411315 "- The user revealed durable preferences, persona, expectations, or ways they want the agent to operate.\n " ,
@@ -1262,6 +1336,7 @@ fn build_session_reflector_prompt(evidence: &Value) -> String {
12621336fn build_skill_writer_prompt ( evidence : & Value ) -> String {
12631337 const POLICY : & str = concat ! (
12641338 "Review these bounded TraceDecay session snippets and propose only reusable managed skills for repeated workflows, corrections, or tool-use patterns.\n " ,
1339+ "Evidence has two channels: recent_session_slices holds turn-ordered head/tail turns and summary nodes replayed from recently active sessions, and hits holds keyword search matches.\n " ,
12651340 "\n " ,
12661341 "Target shape of the skill library: CLASS-LEVEL umbrella skills, each with a rich body and support files for session-specific detail — not a long flat list of narrow one-session-one-skill entries. This shapes HOW you update, not WHETHER you update.\n " ,
12671342 "\n " ,
@@ -1302,6 +1377,121 @@ fn normalized_non_empty(value: &str) -> Option<String> {
13021377 }
13031378}
13041379
1380+ struct ReplaySessionTarget {
1381+ provider : String ,
1382+ session_id : String ,
1383+ }
1384+
1385+ /// Builds the "recent completed sessions" replay evidence channel: bounded
1386+ /// turn-ordered head/tail slices plus top summary-DAG nodes for the last N
1387+ /// recently active sessions (or one explicitly requested session).
1388+ ///
1389+ /// Returns `None` when no session has any raw messages, so callers can fall
1390+ /// back to grep-only evidence.
1391+ async fn recent_session_replay_evidence (
1392+ lcm_db : & GlobalDb ,
1393+ provider : & str ,
1394+ explicit_session_id : Option < & str > ,
1395+ sessions_limit : usize ,
1396+ task_name : & str ,
1397+ ) -> Result < Option < Value > > {
1398+ let sessions_limit = sessions_limit. clamp ( 1 , 10 ) ;
1399+ let provider_filter = ( provider != "all" ) . then_some ( provider) ;
1400+ let replay_error = |e : crate :: sessions:: lcm:: LcmError | TraceDecayError :: Config {
1401+ message : format ! ( "failed to build {task_name} session replay evidence: {e}" ) ,
1402+ } ;
1403+ let ( session_selection, targets) = if let Some ( session_id) = explicit_session_id {
1404+ let providers = match provider_filter {
1405+ Some ( provider) => vec ! [ provider. to_string( ) ] ,
1406+ None => lcm_db
1407+ . lcm_session_providers ( session_id)
1408+ . await
1409+ . map_err ( replay_error) ?,
1410+ } ;
1411+ let targets: Vec < ReplaySessionTarget > = providers
1412+ . into_iter ( )
1413+ . map ( |provider| ReplaySessionTarget {
1414+ provider,
1415+ session_id : session_id. to_string ( ) ,
1416+ } )
1417+ . collect ( ) ;
1418+ ( "explicit_session_id" , targets)
1419+ } else {
1420+ let targets: Vec < ReplaySessionTarget > = lcm_db
1421+ . lcm_recent_sessions ( provider_filter, sessions_limit)
1422+ . await
1423+ . map_err ( replay_error) ?
1424+ . into_iter ( )
1425+ . map ( |session| ReplaySessionTarget {
1426+ provider : session. provider ,
1427+ session_id : session. session_id ,
1428+ } )
1429+ . collect ( ) ;
1430+ ( "recent_activity" , targets)
1431+ } ;
1432+
1433+ let mut sessions: Vec < LcmSessionReplaySlice > = Vec :: new ( ) ;
1434+ for target in targets {
1435+ let slice = lcm_db
1436+ . lcm_session_replay_slice ( & LcmSessionReplayRequest {
1437+ provider : target. provider ,
1438+ session_id : target. session_id ,
1439+ head_limit : SESSION_REPLAY_HEAD_TURNS ,
1440+ tail_limit : SESSION_REPLAY_TAIL_TURNS ,
1441+ max_snippet_chars : SESSION_REPLAY_SNIPPET_CHARS ,
1442+ summary_limit : SESSION_REPLAY_SUMMARY_NODES ,
1443+ max_summary_chars : SESSION_REPLAY_SUMMARY_CHARS ,
1444+ } )
1445+ . await
1446+ . map_err ( replay_error) ?;
1447+ if slice. total_messages == 0 && slice. summary_nodes . is_empty ( ) {
1448+ continue ;
1449+ }
1450+ sessions. push ( slice) ;
1451+ }
1452+ if sessions. is_empty ( ) {
1453+ return Ok ( None ) ;
1454+ }
1455+ Ok ( Some ( json ! ( {
1456+ "mode" : "recent_sessions" ,
1457+ "session_selection" : session_selection,
1458+ "sessions_limit" : sessions_limit,
1459+ "bounds" : {
1460+ "head_turns" : SESSION_REPLAY_HEAD_TURNS ,
1461+ "tail_turns" : SESSION_REPLAY_TAIL_TURNS ,
1462+ "snippet_chars" : SESSION_REPLAY_SNIPPET_CHARS ,
1463+ "summary_nodes" : SESSION_REPLAY_SUMMARY_NODES ,
1464+ "summary_chars" : SESSION_REPLAY_SUMMARY_CHARS ,
1465+ } ,
1466+ "sessions" : sessions,
1467+ } ) ) )
1468+ }
1469+
1470+ /// Names the evidence channels actually present so run artifacts can
1471+ /// distinguish replay-backed runs from grep-only runs.
1472+ fn evidence_mode_label ( has_replay : bool ) -> & ' static str {
1473+ if has_replay {
1474+ "session_replay_with_grep"
1475+ } else {
1476+ "grep_only"
1477+ }
1478+ }
1479+
1480+ fn session_reflector_replay_allowed (
1481+ scope : LcmScope ,
1482+ session_id : Option < & str > ,
1483+ source : Option < & str > ,
1484+ role : Option < & str > ,
1485+ start_time : Option < i64 > ,
1486+ end_time : Option < i64 > ,
1487+ ) -> bool {
1488+ if source. is_some ( ) || role. is_some ( ) || start_time. is_some ( ) || end_time. is_some ( ) {
1489+ return false ;
1490+ }
1491+
1492+ matches ! ( scope, LcmScope :: All ) || session_id. is_some ( )
1493+ }
1494+
13051495/// Resolves the LCM sessions database for an automation task, reporting
13061496/// `NotIngested` when the store does not exist yet so callers can skip
13071497/// without re-checking the path.
@@ -1372,6 +1562,14 @@ fn default_session_evidence_limit() -> usize {
13721562 20
13731563}
13741564
1565+ fn default_include_recent_sessions ( ) -> bool {
1566+ true
1567+ }
1568+
1569+ fn default_recent_sessions_limit ( ) -> usize {
1570+ 3
1571+ }
1572+
13751573fn default_skill_writer_query ( ) -> String {
13761574 "workflow correction repeated skill tool pattern" . to_string ( )
13771575}
0 commit comments