use crate::event::Event;
use std::collections::HashSet;
pub struct RedactionFilter {
secrets: HashSet<String>,
patterns: Vec<String>,
replacement: String,
}
impl RedactionFilter {
pub fn new() -> Self {
Self {
secrets: HashSet::new(),
patterns: vec![
"sk-".into(),
"sk-ant-".into(),
"xai-".into(),
"ghp_".into(),
"gho_".into(),
"ghu_".into(),
"ghs_".into(),
"ghr_".into(),
"hf_".into(),
"nvapi-".into(),
"gsk_".into(),
"org-".into(),
"proj-".into(),
],
replacement: "[REDACTED]".into(),
}
}
pub fn load_secrets(&mut self, secrets: Vec<String>) {
for s in secrets {
if !s.is_empty() {
self.secrets.insert(s);
}
}
}
pub fn redact_str(&self, text: &str) -> String {
let mut result = text.to_string();
for secret in &self.secrets {
if !secret.is_empty() {
result = result.replace(secret.as_str(), &self.replacement);
}
}
for pattern in &self.patterns {
let lower = result.to_lowercase();
if let Some(pos) = lower.find(pattern) {
let end = result[pos..]
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.map(|e| pos + e)
.unwrap_or(result.len());
result.replace_range(pos..end, &self.replacement);
}
}
result
}
pub fn redact_event(&self, event: &Event) -> Event {
let mut e = event.clone();
match &mut e {
Event::ThinkingDelta { text, .. } => {
*text = self.redact_str(text);
}
Event::ReasoningDelta { text, .. } => {
*text = self.redact_str(text);
}
Event::Message { text, .. } => {
*text = self.redact_str(text);
}
Event::ApprovalRequested { summary, .. } => {
*summary = self.redact_str(summary);
}
Event::ToolOutput { blocks, .. } => {
for block in blocks {
match block {
crate::event::Block::Text(t) => {
*t = self.redact_str(t);
}
_ => {}
}
}
}
Event::Error { message, .. } => {
*message = self.redact_str(message);
}
_ => {}
}
e
}
pub fn contains_secret(&self, text: &str) -> bool {
for secret in &self.secrets {
if !secret.is_empty() && text.contains(secret.as_str()) {
return true;
}
}
for pattern in &self.patterns {
if text.to_lowercase().contains(pattern) {
return true;
}
}
false
}
}
impl Default for RedactionFilter {
fn default() -> Self {
Self::new()
}
}
use crate::memory::RepoMap;
use crate::provider::Msg;
pub struct ContextManager {
max_tokens: u64,
tokens_per_char: f64,
}
impl ContextManager {
pub fn new(max_tokens: u64) -> Self {
Self {
max_tokens,
tokens_per_char: 0.25, }
}
pub fn estimate_tokens(&self, text: &str) -> u64 {
(text.len() as f64 * self.tokens_per_char) as u64
}
pub fn needs_compaction(&self, total_chars: usize, reserve_tokens: u64) -> bool {
let used = self.estimate_tokens(&"x".repeat(total_chars));
used + reserve_tokens > self.max_tokens
}
pub fn compact_messages(
&self,
messages: &[Msg],
system_prompt_len: usize,
keep_last: usize,
) -> Vec<Msg> {
let system_tokens = self.estimate_tokens(&"x".repeat(system_prompt_len));
let available = self.max_tokens.saturating_sub(system_tokens);
if messages.len() <= keep_last {
return messages.to_vec();
}
let mut compacted = Vec::new();
let mut used = 0u64;
if let Some(first) = messages.first() {
compacted.push(first.clone());
used += self.estimate_tokens(&serde_json::to_string(first).unwrap_or_default());
}
let middle: Vec<&Msg> = messages[1..messages.len() - keep_last].iter().collect();
if !middle.is_empty() {
let mut tools_used = std::collections::HashSet::new();
let mut files_mentioned = std::collections::HashSet::new();
let mut error_count = 0u32;
for msg in &middle {
for block in &msg.content {
if let crate::provider::ContentBlock::Text { text } = block {
for tool in &[
"fs_read", "fs_write", "edit", "exec", "git", "search", "test",
] {
if text.contains(tool) {
tools_used.insert(*tool);
}
}
for word in text.split_whitespace() {
if word.ends_with(".rs")
|| word.ends_with(".toml")
|| word.ends_with(".md")
|| word.ends_with(".py")
|| word.ends_with(".js")
|| word.ends_with(".ts")
{
files_mentioned.insert(word.to_string());
}
}
if text.contains("error")
|| text.contains("Error")
|| text.contains("FAILED")
{
error_count += 1;
}
}
}
}
let mut topics = Vec::new();
if !tools_used.is_empty() {
let mut tools: Vec<_> = tools_used.into_iter().collect();
tools.sort();
topics.push(format!("tools: {}", tools.join(", ")));
}
if !files_mentioned.is_empty() {
let mut files: Vec<_> = files_mentioned.into_iter().collect();
files.sort();
topics.push(format!("files: {}", files.join(", ")));
}
if error_count > 0 {
topics.push(format!("errors encountered: {}", error_count));
}
let summary_str = if topics.is_empty() {
format!("[{} messages summarized]", middle.len())
} else {
format!(
"[{} messages summarized. {}]",
middle.len(),
topics.join("; ")
)
};
compacted.push(Msg {
role: "user".into(),
content: vec![crate::provider::ContentBlock::Text {
text: summary_str.clone(),
}],
});
used += self.estimate_tokens(&summary_str);
}
for msg in messages.iter().rev().take(keep_last).rev() {
let tokens = self.estimate_tokens(&serde_json::to_string(msg).unwrap_or_default());
if used + tokens > available {
break;
}
compacted.push(msg.clone());
used += tokens;
}
compacted
}
pub fn repo_map_summary(&self, map: &RepoMap, max_files: usize) -> String {
let mut lines = vec![format!(
"Workspace: {} files, {} symbols",
map.files.len(),
map.symbols.len()
)];
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for f in &map.files {
if let Some(first) = f.path.split('/').next() {
dirs.insert(first.to_string());
}
}
lines.push("Top-level:".into());
for d in dirs.iter().take(max_files) {
lines.push(format!(" {}", d));
}
if !map.symbols.is_empty() {
lines.push("Key symbols:".into());
for s in map.symbols.iter().take(20) {
lines.push(format!(" {} ({}) in {}", s.name, s.kind, s.file));
}
}
lines.join("\n")
}
}
impl Default for ContextManager {
fn default() -> Self {
Self::new(128_000)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redact_api_key() {
let mut filter = RedactionFilter::new();
filter.load_secrets(vec!["sk-ant-api03-abcdef123456".into()]);
let input = "Using key sk-ant-api03-abcdef123456 for auth";
let redacted = filter.redact_str(input);
assert!(!redacted.contains("sk-ant-api03-abcdef123456"));
assert!(redacted.contains("[REDACTED]"));
}
#[test]
fn test_redact_event() {
let mut filter = RedactionFilter::new();
filter.load_secrets(vec!["mysecret123".into()]);
let event = Event::ThinkingDelta {
run: crate::event::RunId("test".into()),
text: "The secret is mysecret123".into(),
};
let redacted = filter.redact_event(&event);
match redacted {
Event::ThinkingDelta { text, .. } => {
assert!(!text.contains("mysecret123"));
assert!(text.contains("[REDACTED]"));
}
_ => panic!("wrong event type"),
}
}
#[test]
fn test_context_compaction() {
let cm = ContextManager::new(1000);
let messages = vec![
Msg {
role: "user".into(),
content: vec![],
},
Msg {
role: "assistant".into(),
content: vec![],
},
Msg {
role: "user".into(),
content: vec![],
},
Msg {
role: "assistant".into(),
content: vec![],
},
Msg {
role: "user".into(),
content: vec![],
},
];
let compacted = cm.compact_messages(&messages, 100, 2);
assert!(compacted.len() <= 4);
}
}