diff --git a/codex-plugin/README.md b/codex-plugin/README.md index d39a8966..fe28087c 100644 --- a/codex-plugin/README.md +++ b/codex-plugin/README.md @@ -33,3 +33,8 @@ and sets `TRACEDECAY_CODEX_SUMMARY_CHILD=1` to prevent recursive summary hooks. Set `TRACEDECAY_CODEX_BIN` to use a different Codex binary, `TRACEDECAY_CODEX_SUMMARY_MODEL` to pin a model, or `TRACEDECAY_CODEX_SUMMARY_TIMEOUT_SECS` to adjust the child timeout. + +When Codex starts a thread from compacted context (`SessionStart` source +`compact`), the plugin injects a short recovery hint through +`additionalContext` telling the new session to query TraceDecay LCM/session +recall if the compacted summary is missing prior context. diff --git a/cursor-plugin/README.md b/cursor-plugin/README.md index 45e9eca6..9ec9a09f 100644 --- a/cursor-plugin/README.md +++ b/cursor-plugin/README.md @@ -26,6 +26,11 @@ build does not expand it, reinstall with the latest Cursor and run Hook commands derive the active project from Cursor's event payload / `CURSOR_PROJECT_DIR`, not from the plugin directory. +For sessions resumed from compacted context, the `sessionStart` hook adds a +short recovery hint through Cursor's `additional_context` channel so the agent +knows to query TraceDecay LCM/session recall before assuming the compacted +summary is complete. + Slash workflows ship as skills with `disable-model-invocation: true` (`/tracedecay-map-architecture`, `/tracedecay-check-health`, `/tracedecay-curate-memory`, `/tracedecay-review-diff`, …) — Cursor's Commands surface was absorbed into diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index 124426c7..4881cf64 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -724,6 +724,7 @@ _LCM_CONTRACT_KEYS = frozenset(( "matches", "needs_synthesis", "replay_messages", + "context_recovery_hint", "should_compress", "status", "summary_request", @@ -1994,6 +1995,7 @@ def _auxiliary_error_result(first, *, attempts, retry_status, error_classificati "replay_messages", "replay_token_estimate", "replay_over_budget", + "context_recovery_hint", "frontier", "summary_request", ): diff --git a/src/hooks.rs b/src/hooks.rs index 57404fd2..0ec0ccb1 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -433,7 +433,10 @@ pub async fn hook_cursor_session_start() -> i32 { ) .await; let root = cursor_project_root_from_event(&event); - let context = cursor_session_context_for_root(root.as_deref()).await; + let mut context = cursor_session_context_for_root(root.as_deref()).await; + if session_start_from_compaction(&event) { + append_context_recovery_hint(&mut context); + } println!("{}", cursor_session_start_json(root.as_deref(), &context)); 0 } @@ -1198,6 +1201,36 @@ pub fn build_codex_session_context(initialized: bool, staleness_hint: Option<&st s } +fn append_context_recovery_hint(context: &mut String) { + if !context.ends_with('\n') { + context.push('\n'); + } + context.push_str(COMPACTION_CONTEXT_RECOVERY_HINT); + context.push('\n'); +} + +fn session_start_from_compaction(event_json: &str) -> bool { + let Ok(parsed) = serde_json::from_str::(event_json) else { + return false; + }; + ["source", "trigger", "reason", "boundary_reason"] + .iter() + .filter_map(|key| parsed.get(*key).and_then(Value::as_str)) + .any(matches_compaction_source) +} + +fn matches_compaction_source(value: &str) -> bool { + let normalized = value + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase(); + matches!( + normalized.as_str(), + "compact" | "compaction" | "contextcompacted" | "compression" + ) +} + /// Formats a short relative-age staleness hint from a sync age in seconds. pub fn cursor_staleness_hint(age_secs: i64) -> String { let age = age_secs.max(0); @@ -1393,7 +1426,10 @@ fn now_unix_secs() -> i64 { pub async fn hook_codex_session_start() -> i32 { let event = read_hook_event!(); let root = codex_project_root_from_event(&event); - let context = codex_session_context_for_root(root.as_deref()).await; + let mut context = codex_session_context_for_root(root.as_deref()).await; + if session_start_from_compaction(&event) { + append_context_recovery_hint(&mut context); + } println!( "{}", codex_additional_context_json("SessionStart", &context) @@ -1887,6 +1923,7 @@ const CURSOR_PRE_COMPACT_INGEST_BUDGET: Duration = Duration::from_secs(20); const CURSOR_PRE_COMPACT_SUMMARY_BUDGET: Duration = Duration::from_secs(85); /// Overall budget for the `preCompact` hook (registered with a 120s timeout). const CURSOR_PRE_COMPACT_BUDGET: Duration = Duration::from_secs(115); +const COMPACTION_CONTEXT_RECOVERY_HINT: &str = "Context was just compacted. If important prior-session context seems missing, query TraceDecay session context before assuming the compacted summary is complete. Start with `tracedecay_message_search` or `tracedecay_lcm_expand_query`; use `tracedecay_lcm_describe` and `tracedecay_lcm_expand` when you need the summary DAG sources."; fn cursor_pre_compact_lcm_request( session_id: &str, @@ -2240,4 +2277,22 @@ mod tests { "Codex should use shared per-session hint dedupe for prompt hints" ); } + + #[test] + fn compact_session_start_events_get_recovery_hint() { + let event = serde_json::json!({ "source": "compact" }).to_string(); + assert!(session_start_from_compaction(&event)); + + let mut context = build_codex_session_context(true, None); + append_context_recovery_hint(&mut context); + assert!(context.contains("Context was just compacted")); + assert!(context.contains("tracedecay_lcm_expand_query")); + assert!(context.contains("tracedecay_lcm_describe")); + } + + #[test] + fn non_compact_session_start_events_do_not_get_recovery_hint() { + let event = serde_json::json!({ "source": "resume" }).to_string(); + assert!(!session_start_from_compaction(&event)); + } } diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index d1bee91f..ab735127 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -1824,6 +1824,7 @@ pub(super) async fn handle_lcm_compress( "replay_over_budget": response.replay_over_budget, "compression_attempts": response.compression_attempts, "fallback_used": response.fallback_used, + "context_recovery_hint": response.context_recovery_hint, "retry_status": response.retry_status, "frontier": response.frontier, "summary_request": response.summary_request, diff --git a/src/sessions/lcm/compression.rs b/src/sessions/lcm/compression.rs index eb226dbe..46e60c6d 100644 --- a/src/sessions/lcm/compression.rs +++ b/src/sessions/lcm/compression.rs @@ -29,6 +29,7 @@ const PRESERVED_TODO_CONTEXT_PREFIX: &str = "[Your active task list was preserved across context compression]"; const PRESERVED_OBJECTIVE_CONTEXT_PREFIX: &str = "[Current user objective preserved from compacted history]"; +const CONTEXT_RECOVERY_HINT_SUFFIX: &str = "If the replay after compression is missing context, query TraceDecay LCM before assuming the compacted summary is complete. Start with tracedecay_message_search or tracedecay_lcm_expand_query; use tracedecay_lcm_describe and tracedecay_lcm_expand when you need summary DAG sources."; struct IngestedActiveMessages { replay_messages: Vec, @@ -1522,6 +1523,7 @@ fn compression_response_with_attempt_state( retry_status, } = attempt_state; let replay_token_estimate = replay_token_estimate(&replay_messages); + let context_recovery_hint = context_recovery_hint(&summary_nodes); LcmCompressionResponse { status: status.to_string(), reason: reason.to_string(), @@ -1532,12 +1534,21 @@ fn compression_response_with_attempt_state( replay_over_budget: replay_exceeds_budget(replay_token_estimate, max_assembly_tokens), compression_attempts, fallback_used, + context_recovery_hint, retry_status: retry_status.map(str::to_string), frontier, summary_request, } } +fn context_recovery_hint(summary_nodes: &[LcmSummaryNode]) -> Option { + let summary = summary_nodes.first()?; + Some(format!( + "Compacted context is stored in TraceDecay LCM for provider '{}' session '{}'. {CONTEXT_RECOVERY_HINT_SUFFIX}", + summary.provider, summary.session_id + )) +} + fn replay_token_estimate(messages: &[Value]) -> i64 { messages .iter() diff --git a/src/sessions/lcm/types.rs b/src/sessions/lcm/types.rs index acf55805..c41ee4f4 100644 --- a/src/sessions/lcm/types.rs +++ b/src/sessions/lcm/types.rs @@ -816,6 +816,8 @@ pub struct LcmCompressionResponse { pub compression_attempts: usize, pub fallback_used: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_recovery_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_status: Option, pub frontier: LcmLifecycleState, pub summary_request: Option, diff --git a/tests/mcp_handler_test.rs b/tests/mcp_handler_test.rs index a2563729..c9dce259 100644 --- a/tests/mcp_handler_test.rs +++ b/tests/mcp_handler_test.rs @@ -10544,6 +10544,10 @@ async fn lcm_compress_handler_honors_incremental_max_depth_override() { assert_eq!(payload["reason"], "condensed_summary_nodes"); assert_eq!(payload["summary_nodes_created"], 1); assert_eq!(payload["summary_nodes"][0]["depth"], 2); + assert!(payload["context_recovery_hint"] + .as_str() + .unwrap() + .contains("tracedecay_lcm_expand_query")); } #[tokio::test] diff --git a/tests/session_lcm_compression_test.rs b/tests/session_lcm_compression_test.rs index 3d6546f8..0b5a6eb0 100644 --- a/tests/session_lcm_compression_test.rs +++ b/tests/session_lcm_compression_test.rs @@ -316,6 +316,7 @@ async fn assert_compress_baseline_case(case: CompressBaselineCase) { assert_eq!(response.status, "ok", "{case_name}"); assert_eq!(response.reason, "frontier_changed", "{case_name}"); assert_eq!(response.summary_nodes_created, 0, "{case_name}"); + assert!(response.context_recovery_hint.is_none(), "{case_name}"); assert!(response.summary_request.is_none(), "{case_name}"); assert_eq!( response.frontier.current_frontier_store_id, @@ -368,6 +369,7 @@ async fn assert_compress_baseline_case(case: CompressBaselineCase) { "{case_name}" ); assert_eq!(response.summary_nodes_created, 0, "{case_name}"); + assert!(response.context_recovery_hint.is_none(), "{case_name}"); assert_eq!( response .replay_messages @@ -410,6 +412,7 @@ async fn assert_compress_baseline_case(case: CompressBaselineCase) { "{case_name}" ); assert_eq!(response.summary_nodes_created, 0, "{case_name}"); + assert!(response.context_recovery_hint.is_none(), "{case_name}"); let summary_request = response .summary_request .as_ref() @@ -448,6 +451,19 @@ async fn assert_compress_baseline_case(case: CompressBaselineCase) { assert_eq!(response.status, "ok", "{case_name}"); assert_eq!(response.reason, "compressed_backlog", "{case_name}"); assert_eq!(response.summary_nodes_created, 1, "{case_name}"); + let recovery_hint = response + .context_recovery_hint + .as_deref() + .expect("compression should include a recovery hint"); + assert!(recovery_hint.contains("provider 'cursor'"), "{case_name}"); + assert!( + recovery_hint.contains(&format!("session '{session_id}'")), + "{case_name}" + ); + assert!( + recovery_hint.contains("tracedecay_lcm_expand_query"), + "{case_name}" + ); assert_eq!( response.frontier.current_frontier_store_id, Some(store_ids[1]),