Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions codex-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions cursor-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/agents/hermes/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ _LCM_CONTRACT_KEYS = frozenset((
"matches",
"needs_synthesis",
"replay_messages",
"context_recovery_hint",
"should_compress",
"status",
"summary_request",
Expand Down Expand Up @@ -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",
):
Expand Down
59 changes: 57 additions & 2 deletions src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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::<Value>(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::<String>()
.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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
}
1 change: 1 addition & 0 deletions src/mcp/tools/handlers/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/sessions/lcm/compression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>,
Expand Down Expand Up @@ -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(),
Expand All @@ -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<String> {
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()
Expand Down
2 changes: 2 additions & 0 deletions src/sessions/lcm/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_status: Option<String>,
pub frontier: LcmLifecycleState,
pub summary_request: Option<LcmSummaryRequest>,
Expand Down
4 changes: 4 additions & 0 deletions tests/mcp_handler_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
16 changes: 16 additions & 0 deletions tests/session_lcm_compression_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]),
Expand Down
Loading