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
253 changes: 253 additions & 0 deletions crates/cc-compact/src/context_analysis.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//! Context window analysis — what actually reaches the API after the
//! pre-send pipeline.

use serde::Serialize;

use cc_types::message::Message;
use cc_utils::tokens;

use super::auto_compact;
use super::microcompact;
use super::snip;

const DEFAULT_SNIP_MAX_TURNS: usize = 200;

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ContextCategory {
pub label: String,
pub tokens: u64,
pub percent: f32,
}

#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ContextAnalysis {
pub model: String,
pub context_window: u64,
pub total_used: u64,
pub total_percent: f32,
pub compacted: bool,
pub messages_in: usize,
pub messages_out: usize,
pub categories: Vec<ContextCategory>,
}

#[derive(Debug, Default)]
pub struct ContextAnalysisInput<'a> {
pub messages: &'a [Message],
pub system_prompt: Option<&'a str>,
pub skills_manifest: Option<&'a str>,
pub cached_files_chars: u64,
pub tools_schema: Option<&'a str>,
pub hook_results: Option<&'a str>,
pub model: &'a str,
}

pub fn analyze_context_usage(input: ContextAnalysisInput<'_>) -> ContextAnalysis {
let context_window = auto_compact::get_context_window_size(input.model);
let messages_in = input.messages.len();
let mut compacted = false;
let snipped = snip::snip_compact_if_needed(input.messages.to_vec(), DEFAULT_SNIP_MAX_TURNS);
if snipped.tokens_freed > 0 { compacted = true; }
let micro = microcompact::microcompact_messages(snipped.messages);
if micro.tokens_freed > 0 { compacted = true; }
let effective = micro.messages;
let messages_out = effective.len();

let messages_tokens = tokens::estimate_messages_tokens(&effective);
let system_tokens = input.system_prompt.map(tokens::estimate_tokens).unwrap_or(0);
let skills_tokens = input.skills_manifest.map(tokens::estimate_tokens).unwrap_or(0);
let tools_tokens = input.tools_schema.map(tokens::estimate_tokens).unwrap_or(0);
let hooks_tokens = input.hook_results.map(tokens::estimate_tokens).unwrap_or(0);
let files_tokens = if input.cached_files_chars == 0 {
0
} else {
((input.cached_files_chars as f64) / 4.0).ceil() as u64
};

let total_used = messages_tokens
.saturating_add(system_tokens)
.saturating_add(skills_tokens)
.saturating_add(tools_tokens)
.saturating_add(hooks_tokens)
.saturating_add(files_tokens);
let capped_used = total_used.min(context_window);
let free = context_window.saturating_sub(capped_used);

let mut rows: Vec<ContextCategory> = vec![
row("messages", messages_tokens, context_window),
row("system prompt", system_tokens, context_window),
row("skills", skills_tokens, context_window),
row("files cached", files_tokens, context_window),
row("tools schema", tools_tokens, context_window),
row("hook results", hooks_tokens, context_window),
];
rows.sort_by(|a, b| b.tokens.cmp(&a.tokens));
rows.push(row("free", free, context_window));

let total_percent = percent_of(capped_used, context_window);

ContextAnalysis {
model: input.model.to_string(),
context_window,
total_used,
total_percent,
compacted,
messages_in,
messages_out,
categories: rows,
}
}

fn row(label: &str, tokens: u64, window: u64) -> ContextCategory {
ContextCategory {
label: label.to_string(),
tokens,
percent: percent_of(tokens, window),
}
}

fn percent_of(n: u64, total: u64) -> f32 {
if total == 0 { return 0.0; }
((n as f64 / total as f64) * 100.0) as f32
}

#[cfg(test)]
mod tests {
use super::*;
use cc_types::message::{AssistantMessage, ContentBlock, MessageContent, UserMessage};
use uuid::Uuid;

fn make_user(text: &str) -> Message {
Message::User(UserMessage {
uuid: Uuid::new_v4(),
timestamp: 0,
role: "user".into(),
content: MessageContent::Text(text.into()),
is_meta: false,
tool_use_result: None,
source_tool_assistant_uuid: None,
})
}

fn make_assistant(text: &str) -> Message {
Message::Assistant(AssistantMessage {
uuid: Uuid::new_v4(),
timestamp: 0,
role: "assistant".into(),
content: vec![ContentBlock::Text { text: text.into() }],
usage: None,
stop_reason: Some("end_turn".into()),
is_api_error_message: false,
api_error: None,
cost_usd: 0.0,
})
}

#[test]
fn test_empty_inputs_yield_zero_used() {
let report = analyze_context_usage(ContextAnalysisInput {
messages: &[],
model: "claude-sonnet-4-20250514",
..Default::default()
});
assert_eq!(report.total_used, 0);
assert_eq!(report.total_percent, 0.0);
assert_eq!(report.context_window, 200_000);
assert_eq!(report.categories.len(), 7);
let free = report.categories.last().unwrap();
assert_eq!(free.label, "free");
assert_eq!(free.tokens, 200_000);
}

#[test]
fn test_non_empty_categories_sum_and_percentages() {
let messages = vec![make_user("hello world"), make_assistant("hi!")];
let report = analyze_context_usage(ContextAnalysisInput {
messages: &messages,
system_prompt: Some("You are a helpful assistant."),
skills_manifest: Some("skill a\nskill b"),
cached_files_chars: 400,
tools_schema: Some("bash, edit, grep"),
hook_results: None,
model: "claude-sonnet-4-20250514",
});
let labels: Vec<&str> = report.categories.iter().map(|c| c.label.as_str()).collect();
for expected in ["messages", "system prompt", "skills", "files cached", "tools schema", "hook results", "free"] {
assert!(labels.contains(&expected));
}
let files = report.categories.iter().find(|c| c.label == "files cached").unwrap();
assert_eq!(files.tokens, 100);
let non_free: u64 = report.categories.iter().filter(|c| c.label != "free").map(|c| c.tokens).sum();
assert_eq!(non_free, report.total_used);
let free_tokens = report.categories.iter().find(|c| c.label == "free").unwrap().tokens;
assert!(report.total_used.min(report.context_window) + free_tokens <= report.context_window);
}

#[test]
fn test_percentages_never_exceed_100() {
let huge_text = "a".repeat(1_000_000);
let messages = vec![make_user(&huge_text)];
let report = analyze_context_usage(ContextAnalysisInput {
messages: &messages,
model: "claude-sonnet-4-20250514",
..Default::default()
});
assert!(report.total_used > report.context_window);
assert!(report.total_percent <= 100.0);
let free = report.categories.iter().find(|c| c.label == "free").unwrap();
assert_eq!(free.tokens, 0);
}

#[test]
fn test_categories_sorted_desc_with_free_last() {
let messages = vec![make_user("hello world"), make_assistant("hi!")];
let report = analyze_context_usage(ContextAnalysisInput {
messages: &messages,
system_prompt: Some(&"a".repeat(4_000)),
tools_schema: Some("x"),
model: "claude-sonnet-4-20250514",
..Default::default()
});
assert_eq!(report.categories.last().unwrap().label, "free");
let non_free: Vec<&ContextCategory> = report.categories.iter().filter(|c| c.label != "free").collect();
for pair in non_free.windows(2) {
assert!(pair[0].tokens >= pair[1].tokens);
}
}

#[test]
fn test_transform_is_applied_for_large_conversations() {
let mut msgs = Vec::new();
for i in 0..250 {
msgs.push(make_user(&format!("user turn {}", i)));
msgs.push(make_assistant(&format!("assistant turn {}", i)));
}
let report = analyze_context_usage(ContextAnalysisInput {
messages: &msgs,
model: "claude-sonnet-4-20250514",
..Default::default()
});
assert!(report.compacted);
assert!(report.messages_out < report.messages_in);
}

#[test]
fn test_serialize_json_shape() {
let report = analyze_context_usage(ContextAnalysisInput {
messages: &[],
model: "claude-sonnet-4-20250514",
..Default::default()
});
let json = serde_json::to_value(&report).unwrap();
for k in ["model", "context_window", "total_used", "total_percent", "compacted", "messages_in", "messages_out"] {
assert!(json.get(k).is_some());
}
let cats = json.get("categories").unwrap().as_array().unwrap();
assert!(!cats.is_empty());
for c in cats {
assert!(c.get("label").is_some());
assert!(c.get("tokens").is_some());
assert!(c.get("percent").is_some());
}
}
}
1 change: 1 addition & 0 deletions crates/cc-compact/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

pub mod auto_compact;
pub mod compaction;
pub mod context_analysis;
pub mod messages;
pub mod microcompact;
pub mod pipeline;
Expand Down
100 changes: 100 additions & 0 deletions crates/cc-config/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ pub fn memory_dir_global() -> PathBuf {
data_root().join("memory")
}

/// `{data_root}/auto_memory/` — auto-captured memories (issue #45).
///
/// Separated from the primary `memory/` directory so users can inspect
/// or purge auto-captured notes independently of curated entries.
/// Creation is deferred to `memdir::ensure_memory_dir` on first write.
pub fn auto_memory_dir() -> PathBuf {
data_root().join("auto_memory")
}

pub fn session_insights_dir() -> PathBuf {
data_root().join("session-insights")
}
Expand Down Expand Up @@ -144,6 +153,38 @@ pub fn project_cc_rust_dir(cwd: &Path) -> PathBuf {
cwd.join(".cc-rust")
}

// ----- Plan file (issue #46) -----------------------------------------------

/// `{cwd}/.cc-rust/plan.md` — project-scoped plan file.
pub fn plan_file_path_project(cwd: &Path) -> PathBuf {
cwd.join(".cc-rust").join("plan.md")
}

/// `{data_root}/plan.md` — fallback global plan file used outside a project.
pub fn plan_file_path_global() -> PathBuf {
data_root().join("plan.md")
}

/// Resolve the plan file the current session should read/write.
///
/// Priority:
/// 1. If `{cwd}/.cc-rust/plan.md` already exists → use it (idempotent).
/// 2. Else if `{cwd}/.cc-rust/` or `{cwd}/CLAUDE.md` is present → project path.
/// 3. Else → global `{data_root}/plan.md`.
pub fn current_plan_file_path(cwd: &Path) -> PathBuf {
let project = plan_file_path_project(cwd);
if project.exists() {
return project;
}
let has_project_marker =
cwd.join(".cc-rust").is_dir() || cwd.join("CLAUDE.md").is_file();
if has_project_marker {
project
} else {
plan_file_path_global()
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -245,6 +286,7 @@ mod tests {
assert_eq!(audits_dir(), base.join("audits"));
assert_eq!(transcripts_dir(), base.join("transcripts"));
assert_eq!(memory_dir_global(), base.join("memory"));
assert_eq!(auto_memory_dir(), base.join("auto_memory"));
assert_eq!(session_insights_dir(), base.join("session-insights"));
assert_eq!(plugins_dir(), base.join("plugins"));
assert_eq!(skills_dir_global(), base.join("skills"));
Expand Down Expand Up @@ -287,4 +329,62 @@ mod tests {
PathBuf::from("/foo/bar/.cc-rust")
);
}

// Plan file path helpers (issue #46) ----------------------------------

#[test]
#[serial]
fn plan_file_path_project_is_cwd_relative() {
let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/ignored");
assert_eq!(
plan_file_path_project(Path::new("/foo/bar")),
PathBuf::from("/foo/bar/.cc-rust/plan.md")
);
}

#[test]
#[serial]
fn plan_file_path_global_is_under_data_root() {
let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/cc-plan-global");
assert_eq!(
plan_file_path_global(),
PathBuf::from("/tmp/cc-plan-global/plan.md")
);
}

#[test]
#[serial]
fn current_plan_prefers_project_when_markers_present() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".cc-rust")).unwrap();
let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/should-not-be-used");
assert_eq!(
current_plan_file_path(tmp.path()),
tmp.path().join(".cc-rust").join("plan.md")
);
}

#[test]
#[serial]
fn current_plan_falls_back_to_global_without_markers() {
let tmp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _g = EnvGuard::set("CC_RUST_HOME", home.path().to_str().unwrap());
assert_eq!(
current_plan_file_path(tmp.path()),
home.path().join("plan.md")
);
}

#[test]
#[serial]
fn current_plan_is_idempotent_once_plan_exists() {
let tmp = tempfile::tempdir().unwrap();
let plan = tmp.path().join(".cc-rust").join("plan.md");
std::fs::create_dir_all(plan.parent().unwrap()).unwrap();
std::fs::write(&plan, "# Plan\n").unwrap();
// Even without other markers, an existing plan.md sticks.
let _g = EnvGuard::set("CC_RUST_HOME", "/tmp/unused");
assert_eq!(current_plan_file_path(tmp.path()), plan);
}
}
Loading
Loading