Compare commits
31 Commits
rcc/runtim
...
dev/rust
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7fe16edb | ||
|
|
c8f95cd72b | ||
|
|
66dde1b74a | ||
|
|
99b78d6ea4 | ||
|
|
3db3dfa60d | ||
|
|
0ac188caad | ||
|
|
b510387045 | ||
|
|
6e378185e9 | ||
|
|
019e9900ed | ||
|
|
67423d005a | ||
|
|
4db21e9595 | ||
|
|
2ad2ec087f | ||
|
|
0346b7dd3a | ||
|
|
a8f5da6427 | ||
|
|
c996eb7b1b | ||
|
|
14757e0780 | ||
|
|
188c35f8a6 | ||
|
|
c024d8b21f | ||
|
|
321a1a681a | ||
|
|
2d1cade31b | ||
|
|
6fe404329d | ||
|
|
add5513ac5 | ||
|
|
8465b6923b | ||
|
|
32981ffa28 | ||
|
|
cb24430c56 | ||
|
|
071045f556 | ||
|
|
a96bb6c60f | ||
|
|
d6a814258c | ||
|
|
4bae5ee132 | ||
|
|
619ae71866 | ||
|
|
5b106b840d |
19
rust/Cargo.lock
generated
19
rust/Cargo.lock
generated
@@ -250,6 +250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -258,6 +259,18 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -271,7 +284,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
@@ -946,7 +962,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -1412,6 +1430,7 @@ dependencies = [
|
|||||||
name = "tools"
|
name = "tools"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"reqwest",
|
||||||
"runtime",
|
"runtime",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -30,6 +30,168 @@ impl CommandRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SlashCommandSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub summary: &'static str,
|
||||||
|
pub argument_hint: Option<&'static str>,
|
||||||
|
pub resume_supported: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "help",
|
||||||
|
summary: "Show available slash commands",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "status",
|
||||||
|
summary: "Show current session status",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "compact",
|
||||||
|
summary: "Compact local session history",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "model",
|
||||||
|
summary: "Show or switch the active model",
|
||||||
|
argument_hint: Some("[model]"),
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "permissions",
|
||||||
|
summary: "Show or switch the active permission mode",
|
||||||
|
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "clear",
|
||||||
|
summary: "Start a fresh local session",
|
||||||
|
argument_hint: Some("[--confirm]"),
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "cost",
|
||||||
|
summary: "Show cumulative token usage for this session",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "resume",
|
||||||
|
summary: "Load a saved session into the REPL",
|
||||||
|
argument_hint: Some("<session-path>"),
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "config",
|
||||||
|
summary: "Inspect discovered Claude config files",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "memory",
|
||||||
|
summary: "Inspect loaded Claude instruction memory files",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "init",
|
||||||
|
summary: "Create a starter CLAUDE.md for this repo",
|
||||||
|
argument_hint: None,
|
||||||
|
resume_supported: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SlashCommand {
|
||||||
|
Help,
|
||||||
|
Status,
|
||||||
|
Compact,
|
||||||
|
Model { model: Option<String> },
|
||||||
|
Permissions { mode: Option<String> },
|
||||||
|
Clear { confirm: bool },
|
||||||
|
Cost,
|
||||||
|
Resume { session_path: Option<String> },
|
||||||
|
Config,
|
||||||
|
Memory,
|
||||||
|
Init,
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlashCommand {
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse(input: &str) -> Option<Self> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if !trimmed.starts_with('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
|
||||||
|
let command = parts.next().unwrap_or_default();
|
||||||
|
Some(match command {
|
||||||
|
"help" => Self::Help,
|
||||||
|
"status" => Self::Status,
|
||||||
|
"compact" => Self::Compact,
|
||||||
|
"model" => Self::Model {
|
||||||
|
model: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
"permissions" => Self::Permissions {
|
||||||
|
mode: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
"clear" => Self::Clear {
|
||||||
|
confirm: parts.next() == Some("--confirm"),
|
||||||
|
},
|
||||||
|
"cost" => Self::Cost,
|
||||||
|
"resume" => Self::Resume {
|
||||||
|
session_path: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
"config" => Self::Config,
|
||||||
|
"memory" => Self::Memory,
|
||||||
|
"init" => Self::Init,
|
||||||
|
other => Self::Unknown(other.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
|
||||||
|
SLASH_COMMAND_SPECS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
|
||||||
|
slash_command_specs()
|
||||||
|
.iter()
|
||||||
|
.filter(|spec| spec.resume_supported)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_slash_command_help() -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
"Available commands:".to_string(),
|
||||||
|
" (resume-safe commands are marked with [resume])".to_string(),
|
||||||
|
];
|
||||||
|
for spec in slash_command_specs() {
|
||||||
|
let name = match spec.argument_hint {
|
||||||
|
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||||
|
None => format!("/{}", spec.name),
|
||||||
|
};
|
||||||
|
let resume = if spec.resume_supported {
|
||||||
|
" [resume]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SlashCommandResult {
|
pub struct SlashCommandResult {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
@@ -42,13 +204,8 @@ pub fn handle_slash_command(
|
|||||||
session: &Session,
|
session: &Session,
|
||||||
compaction: CompactionConfig,
|
compaction: CompactionConfig,
|
||||||
) -> Option<SlashCommandResult> {
|
) -> Option<SlashCommandResult> {
|
||||||
let trimmed = input.trim();
|
match SlashCommand::parse(input)? {
|
||||||
if !trimmed.starts_with('/') {
|
SlashCommand::Compact => {
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match trimmed.split_whitespace().next() {
|
|
||||||
Some("/compact") => {
|
|
||||||
let result = compact_session(session, compaction);
|
let result = compact_session(session, compaction);
|
||||||
let message = if result.removed_message_count == 0 {
|
let message = if result.removed_message_count == 0 {
|
||||||
"Compaction skipped: session is below the compaction threshold.".to_string()
|
"Compaction skipped: session is below the compaction threshold.".to_string()
|
||||||
@@ -63,15 +220,90 @@ pub fn handle_slash_command(
|
|||||||
session: result.compacted_session,
|
session: result.compacted_session,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
SlashCommand::Help => Some(SlashCommandResult {
|
||||||
|
message: render_slash_command_help(),
|
||||||
|
session: session.clone(),
|
||||||
|
}),
|
||||||
|
SlashCommand::Status
|
||||||
|
| SlashCommand::Model { .. }
|
||||||
|
| SlashCommand::Permissions { .. }
|
||||||
|
| SlashCommand::Clear { .. }
|
||||||
|
| SlashCommand::Cost
|
||||||
|
| SlashCommand::Resume { .. }
|
||||||
|
| SlashCommand::Config
|
||||||
|
| SlashCommand::Memory
|
||||||
|
| SlashCommand::Init
|
||||||
|
| SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::handle_slash_command;
|
use super::{
|
||||||
|
handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
|
||||||
|
slash_command_specs, SlashCommand,
|
||||||
|
};
|
||||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_supported_slash_commands() {
|
||||||
|
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
||||||
|
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/model claude-opus"),
|
||||||
|
Some(SlashCommand::Model {
|
||||||
|
model: Some("claude-opus".to_string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/model"),
|
||||||
|
Some(SlashCommand::Model { model: None })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/permissions read-only"),
|
||||||
|
Some(SlashCommand::Permissions {
|
||||||
|
mode: Some("read-only".to_string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/clear"),
|
||||||
|
Some(SlashCommand::Clear { confirm: false })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/clear --confirm"),
|
||||||
|
Some(SlashCommand::Clear { confirm: true })
|
||||||
|
);
|
||||||
|
assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/resume session.json"),
|
||||||
|
Some(SlashCommand::Resume {
|
||||||
|
session_path: Some("session.json".to_string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config));
|
||||||
|
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||||
|
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_help_from_shared_specs() {
|
||||||
|
let help = render_slash_command_help();
|
||||||
|
assert!(help.contains("resume-safe commands"));
|
||||||
|
assert!(help.contains("/help"));
|
||||||
|
assert!(help.contains("/status"));
|
||||||
|
assert!(help.contains("/compact"));
|
||||||
|
assert!(help.contains("/model [model]"));
|
||||||
|
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
||||||
|
assert!(help.contains("/clear [--confirm]"));
|
||||||
|
assert!(help.contains("/cost"));
|
||||||
|
assert!(help.contains("/resume <session-path>"));
|
||||||
|
assert!(help.contains("/config"));
|
||||||
|
assert!(help.contains("/memory"));
|
||||||
|
assert!(help.contains("/init"));
|
||||||
|
assert_eq!(slash_command_specs().len(), 11);
|
||||||
|
assert_eq!(resume_supported_slash_commands().len(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compacts_sessions_via_slash_command() {
|
fn compacts_sessions_via_slash_command() {
|
||||||
let session = Session {
|
let session = Session {
|
||||||
@@ -103,8 +335,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_unknown_slash_commands() {
|
fn help_command_is_non_mutating() {
|
||||||
|
let session = Session::new();
|
||||||
|
let result = handle_slash_command("/help", &session, CompactionConfig::default())
|
||||||
|
.expect("help command should be handled");
|
||||||
|
assert_eq!(result.session, session);
|
||||||
|
assert!(result.message.contains("Available commands:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||||
let session = Session::new();
|
let session = Session::new();
|
||||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(
|
||||||
|
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
|
||||||
|
);
|
||||||
|
assert!(handle_slash_command(
|
||||||
|
"/permissions read-only",
|
||||||
|
&session,
|
||||||
|
CompactionConfig::default()
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(
|
||||||
|
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(handle_slash_command(
|
||||||
|
"/resume session.json",
|
||||||
|
&session,
|
||||||
|
CompactionConfig::default()
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ impl UpstreamPaths {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
|
.unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
|
||||||
let repo_root = workspace_dir
|
let primary_repo_root = workspace_dir
|
||||||
.parent()
|
.parent()
|
||||||
.map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
|
.map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
|
||||||
|
let repo_root = resolve_upstream_repo_root(&primary_repo_root);
|
||||||
Self { repo_root }
|
Self { repo_root }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,42 @@ pub struct ExtractedManifest {
|
|||||||
pub bootstrap: BootstrapPlan,
|
pub bootstrap: BootstrapPlan,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_upstream_repo_root(primary_repo_root: &Path) -> PathBuf {
|
||||||
|
let candidates = upstream_repo_candidates(primary_repo_root);
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| candidate.join("src/commands.ts").is_file())
|
||||||
|
.unwrap_or_else(|| primary_repo_root.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut candidates = vec![primary_repo_root.to_path_buf()];
|
||||||
|
|
||||||
|
if let Some(explicit) = std::env::var_os("CLAUDE_CODE_UPSTREAM") {
|
||||||
|
candidates.push(PathBuf::from(explicit));
|
||||||
|
}
|
||||||
|
|
||||||
|
for ancestor in primary_repo_root.ancestors().take(4) {
|
||||||
|
candidates.push(ancestor.join("claude-code"));
|
||||||
|
candidates.push(ancestor.join("clawd-code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(
|
||||||
|
primary_repo_root
|
||||||
|
.join("reference-source")
|
||||||
|
.join("claude-code"),
|
||||||
|
);
|
||||||
|
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
|
||||||
|
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for candidate in candidates {
|
||||||
|
if !deduped.iter().any(|seen: &PathBuf| seen == &candidate) {
|
||||||
|
deduped.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> {
|
pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> {
|
||||||
let commands_source = fs::read_to_string(paths.commands_path())?;
|
let commands_source = fs::read_to_string(paths.commands_path())?;
|
||||||
let tools_source = fs::read_to_string(paths.tools_path())?;
|
let tools_source = fs::read_to_string(paths.tools_path())?;
|
||||||
@@ -270,9 +307,19 @@ mod tests {
|
|||||||
UpstreamPaths::from_workspace_dir(workspace_dir)
|
UpstreamPaths::from_workspace_dir(workspace_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_upstream_fixture(paths: &UpstreamPaths) -> bool {
|
||||||
|
paths.commands_path().is_file()
|
||||||
|
&& paths.tools_path().is_file()
|
||||||
|
&& paths.cli_path().is_file()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_non_empty_manifests_from_upstream_repo() {
|
fn extracts_non_empty_manifests_from_upstream_repo() {
|
||||||
let manifest = extract_manifest(&fixture_paths()).expect("manifest should load");
|
let paths = fixture_paths();
|
||||||
|
if !has_upstream_fixture(&paths) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let manifest = extract_manifest(&paths).expect("manifest should load");
|
||||||
assert!(!manifest.commands.entries().is_empty());
|
assert!(!manifest.commands.entries().is_empty());
|
||||||
assert!(!manifest.tools.entries().is_empty());
|
assert!(!manifest.tools.entries().is_empty());
|
||||||
assert!(!manifest.bootstrap.phases().is_empty());
|
assert!(!manifest.bootstrap.phases().is_empty());
|
||||||
@@ -280,9 +327,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_known_upstream_command_symbols() {
|
fn detects_known_upstream_command_symbols() {
|
||||||
let commands = extract_commands(
|
let paths = fixture_paths();
|
||||||
&fs::read_to_string(fixture_paths().commands_path()).expect("commands.ts"),
|
if !paths.commands_path().is_file() {
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
let commands =
|
||||||
|
extract_commands(&fs::read_to_string(paths.commands_path()).expect("commands.ts"));
|
||||||
let names: Vec<_> = commands
|
let names: Vec<_> = commands
|
||||||
.entries()
|
.entries()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -295,8 +345,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_known_upstream_tool_symbols() {
|
fn detects_known_upstream_tool_symbols() {
|
||||||
let tools =
|
let paths = fixture_paths();
|
||||||
extract_tools(&fs::read_to_string(fixture_paths().tools_path()).expect("tools.ts"));
|
if !paths.tools_path().is_file() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tools = extract_tools(&fs::read_to_string(paths.tools_path()).expect("tools.ts"));
|
||||||
let names: Vec<_> = tools
|
let names: Vec<_> = tools
|
||||||
.entries()
|
.entries()
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ impl Default for CompactionConfig {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CompactionResult {
|
pub struct CompactionResult {
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
|
pub formatted_summary: String,
|
||||||
pub compacted_session: Session,
|
pub compacted_session: Session,
|
||||||
pub removed_message_count: usize,
|
pub removed_message_count: usize,
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||||||
if !should_compact(session, config) {
|
if !should_compact(session, config) {
|
||||||
return CompactionResult {
|
return CompactionResult {
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
|
formatted_summary: String::new(),
|
||||||
compacted_session: session.clone(),
|
compacted_session: session.clone(),
|
||||||
removed_message_count: 0,
|
removed_message_count: 0,
|
||||||
};
|
};
|
||||||
@@ -87,6 +89,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||||||
let removed = &session.messages[..keep_from];
|
let removed = &session.messages[..keep_from];
|
||||||
let preserved = session.messages[keep_from..].to_vec();
|
let preserved = session.messages[keep_from..].to_vec();
|
||||||
let summary = summarize_messages(removed);
|
let summary = summarize_messages(removed);
|
||||||
|
let formatted_summary = format_compact_summary(&summary);
|
||||||
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
|
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
|
||||||
|
|
||||||
let mut compacted_messages = vec![ConversationMessage {
|
let mut compacted_messages = vec![ConversationMessage {
|
||||||
@@ -98,6 +101,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||||||
|
|
||||||
CompactionResult {
|
CompactionResult {
|
||||||
summary,
|
summary,
|
||||||
|
formatted_summary,
|
||||||
compacted_session: Session {
|
compacted_session: Session {
|
||||||
version: session.version,
|
version: session.version,
|
||||||
messages: compacted_messages,
|
messages: compacted_messages,
|
||||||
@@ -107,7 +111,73 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||||
let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
|
let user_messages = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|message| message.role == MessageRole::User)
|
||||||
|
.count();
|
||||||
|
let assistant_messages = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|message| message.role == MessageRole::Assistant)
|
||||||
|
.count();
|
||||||
|
let tool_messages = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|message| message.role == MessageRole::Tool)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let mut tool_names = messages
|
||||||
|
.iter()
|
||||||
|
.flat_map(|message| message.blocks.iter())
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||||
|
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||||
|
ContentBlock::Text { .. } => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
tool_names.sort_unstable();
|
||||||
|
tool_names.dedup();
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
"<summary>".to_string(),
|
||||||
|
"Conversation summary:".to_string(),
|
||||||
|
format!(
|
||||||
|
"- Scope: {} earlier messages compacted (user={}, assistant={}, tool={}).",
|
||||||
|
messages.len(),
|
||||||
|
user_messages,
|
||||||
|
assistant_messages,
|
||||||
|
tool_messages
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if !tool_names.is_empty() {
|
||||||
|
lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3);
|
||||||
|
if !recent_user_requests.is_empty() {
|
||||||
|
lines.push("- Recent user requests:".to_string());
|
||||||
|
lines.extend(
|
||||||
|
recent_user_requests
|
||||||
|
.into_iter()
|
||||||
|
.map(|request| format!(" - {request}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending_work = infer_pending_work(messages);
|
||||||
|
if !pending_work.is_empty() {
|
||||||
|
lines.push("- Pending work:".to_string());
|
||||||
|
lines.extend(pending_work.into_iter().map(|item| format!(" - {item}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_files = collect_key_files(messages);
|
||||||
|
if !key_files.is_empty() {
|
||||||
|
lines.push(format!("- Key files referenced: {}.", key_files.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current_work) = infer_current_work(messages) {
|
||||||
|
lines.push(format!("- Current work: {current_work}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("- Key timeline:".to_string());
|
||||||
for message in messages {
|
for message in messages {
|
||||||
let role = match message.role {
|
let role = match message.role {
|
||||||
MessageRole::System => "system",
|
MessageRole::System => "system",
|
||||||
@@ -121,7 +191,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
|||||||
.map(summarize_block)
|
.map(summarize_block)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
lines.push(format!("- {role}: {content}"));
|
lines.push(format!(" - {role}: {content}"));
|
||||||
}
|
}
|
||||||
lines.push("</summary>".to_string());
|
lines.push("</summary>".to_string());
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
@@ -144,6 +214,106 @@ fn summarize_block(block: &ContentBlock) -> String {
|
|||||||
truncate_summary(&raw, 160)
|
truncate_summary(&raw, 160)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_recent_role_summaries(
|
||||||
|
messages: &[ConversationMessage],
|
||||||
|
role: MessageRole,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.filter(|message| message.role == role)
|
||||||
|
.rev()
|
||||||
|
.filter_map(|message| first_text_block(message))
|
||||||
|
.take(limit)
|
||||||
|
.map(|text| truncate_summary(text, 160))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_pending_work(messages: &[ConversationMessage]) -> Vec<String> {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter_map(first_text_block)
|
||||||
|
.filter(|text| {
|
||||||
|
let lowered = text.to_ascii_lowercase();
|
||||||
|
lowered.contains("todo")
|
||||||
|
|| lowered.contains("next")
|
||||||
|
|| lowered.contains("pending")
|
||||||
|
|| lowered.contains("follow up")
|
||||||
|
|| lowered.contains("remaining")
|
||||||
|
})
|
||||||
|
.take(3)
|
||||||
|
.map(|text| truncate_summary(text, 160))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
||||||
|
let mut files = messages
|
||||||
|
.iter()
|
||||||
|
.flat_map(|message| message.blocks.iter())
|
||||||
|
.map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => text.as_str(),
|
||||||
|
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
||||||
|
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
||||||
|
})
|
||||||
|
.flat_map(extract_file_candidates)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
files.sort();
|
||||||
|
files.dedup();
|
||||||
|
files.into_iter().take(8).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.filter_map(first_text_block)
|
||||||
|
.find(|text| !text.trim().is_empty())
|
||||||
|
.map(|text| truncate_summary(text, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
||||||
|
message.blocks.iter().find_map(|block| match block {
|
||||||
|
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
||||||
|
ContentBlock::ToolUse { .. }
|
||||||
|
| ContentBlock::ToolResult { .. }
|
||||||
|
| ContentBlock::Text { .. } => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_interesting_extension(candidate: &str) -> bool {
|
||||||
|
std::path::Path::new(candidate)
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| {
|
||||||
|
["rs", "ts", "tsx", "js", "json", "md"]
|
||||||
|
.iter()
|
||||||
|
.any(|expected| extension.eq_ignore_ascii_case(expected))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_file_candidates(content: &str) -> Vec<String> {
|
||||||
|
content
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|token| {
|
||||||
|
let candidate = token.trim_matches(|char: char| {
|
||||||
|
matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
|
||||||
|
});
|
||||||
|
if candidate.contains('/') && has_interesting_extension(candidate) {
|
||||||
|
Some(candidate.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn truncate_summary(content: &str, max_chars: usize) -> String {
|
fn truncate_summary(content: &str, max_chars: usize) -> String {
|
||||||
if content.chars().count() <= max_chars {
|
if content.chars().count() <= max_chars {
|
||||||
return content.to_string();
|
return content.to_string();
|
||||||
@@ -207,8 +377,8 @@ fn collapse_blank_lines(content: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
compact_session, estimate_session_tokens, format_compact_summary, should_compact,
|
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||||
CompactionConfig,
|
infer_pending_work, should_compact, CompactionConfig,
|
||||||
};
|
};
|
||||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
|
|
||||||
@@ -229,6 +399,7 @@ mod tests {
|
|||||||
assert_eq!(result.removed_message_count, 0);
|
assert_eq!(result.removed_message_count, 0);
|
||||||
assert_eq!(result.compacted_session, session);
|
assert_eq!(result.compacted_session, session);
|
||||||
assert!(result.summary.is_empty());
|
assert!(result.summary.is_empty());
|
||||||
|
assert!(result.formatted_summary.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -268,6 +439,8 @@ mod tests {
|
|||||||
&result.compacted_session.messages[0].blocks[0],
|
&result.compacted_session.messages[0].blocks[0],
|
||||||
ContentBlock::Text { text } if text.contains("Summary:")
|
ContentBlock::Text { text } if text.contains("Summary:")
|
||||||
));
|
));
|
||||||
|
assert!(result.formatted_summary.contains("Scope:"));
|
||||||
|
assert!(result.formatted_summary.contains("Key timeline:"));
|
||||||
assert!(should_compact(
|
assert!(should_compact(
|
||||||
&session,
|
&session,
|
||||||
CompactionConfig {
|
CompactionConfig {
|
||||||
@@ -288,4 +461,25 @@ mod tests {
|
|||||||
assert!(summary.ends_with('…'));
|
assert!(summary.ends_with('…'));
|
||||||
assert!(summary.chars().count() <= 161);
|
assert!(summary.chars().count() <= 161);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_key_files_from_message_content() {
|
||||||
|
let files = collect_key_files(&[ConversationMessage::user_text(
|
||||||
|
"Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
|
||||||
|
)]);
|
||||||
|
assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
|
||||||
|
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infers_pending_work_from_recent_messages() {
|
||||||
|
let pending = infer_pending_work(&[
|
||||||
|
ConversationMessage::user_text("done"),
|
||||||
|
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||||
|
text: "Next: update tests and follow up on remaining CLI polish.".to_string(),
|
||||||
|
}]),
|
||||||
|
]);
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert!(pending[0].contains("Next: update tests"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,6 @@ pub use remote::{
|
|||||||
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
|
||||||
};
|
};
|
||||||
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
|
pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
|
||||||
pub use usage::{TokenUsage, UsageTracker};
|
pub use usage::{
|
||||||
|
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ impl From<ConfigError> for PromptBuildError {
|
|||||||
|
|
||||||
pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
|
pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__";
|
||||||
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
||||||
|
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||||
|
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ContextFile {
|
pub struct ContextFile {
|
||||||
@@ -202,7 +205,7 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
|||||||
push_context_file(&mut files, candidate)?;
|
push_context_file(&mut files, candidate)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(files)
|
Ok(dedupe_instruction_files(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||||
@@ -237,10 +240,17 @@ fn read_git_status(cwd: &Path) -> Option<String> {
|
|||||||
|
|
||||||
fn render_project_context(project_context: &ProjectContext) -> String {
|
fn render_project_context(project_context: &ProjectContext) -> String {
|
||||||
let mut lines = vec!["# Project context".to_string()];
|
let mut lines = vec!["# Project context".to_string()];
|
||||||
lines.extend(prepend_bullets(vec![format!(
|
let mut bullets = vec![
|
||||||
"Today's date is {}.",
|
format!("Today's date is {}.", project_context.current_date),
|
||||||
project_context.current_date
|
format!("Working directory: {}", project_context.cwd.display()),
|
||||||
)]));
|
];
|
||||||
|
if !project_context.instruction_files.is_empty() {
|
||||||
|
bullets.push(format!(
|
||||||
|
"Claude instruction files discovered: {}.",
|
||||||
|
project_context.instruction_files.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.extend(prepend_bullets(bullets));
|
||||||
if let Some(status) = &project_context.git_status {
|
if let Some(status) = &project_context.git_status {
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
lines.push("Git status snapshot:".to_string());
|
lines.push("Git status snapshot:".to_string());
|
||||||
@@ -251,13 +261,105 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
|||||||
|
|
||||||
fn render_instruction_files(files: &[ContextFile]) -> String {
|
fn render_instruction_files(files: &[ContextFile]) -> String {
|
||||||
let mut sections = vec!["# Claude instructions".to_string()];
|
let mut sections = vec!["# Claude instructions".to_string()];
|
||||||
|
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
||||||
for file in files {
|
for file in files {
|
||||||
sections.push(format!("## {}", file.path.display()));
|
if remaining_chars == 0 {
|
||||||
sections.push(file.content.trim().to_string());
|
sections.push(
|
||||||
|
"_Additional instruction content omitted after reaching the prompt budget._"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_content = truncate_instruction_content(&file.content, remaining_chars);
|
||||||
|
let rendered_content = render_instruction_content(&raw_content);
|
||||||
|
let consumed = rendered_content.chars().count().min(remaining_chars);
|
||||||
|
remaining_chars = remaining_chars.saturating_sub(consumed);
|
||||||
|
|
||||||
|
sections.push(format!("## {}", describe_instruction_file(file, files)));
|
||||||
|
sections.push(rendered_content);
|
||||||
}
|
}
|
||||||
sections.join("\n\n")
|
sections.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dedupe_instruction_files(files: Vec<ContextFile>) -> Vec<ContextFile> {
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
let mut seen_hashes = Vec::new();
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
let normalized = normalize_instruction_content(&file.content);
|
||||||
|
let hash = stable_content_hash(&normalized);
|
||||||
|
if seen_hashes.contains(&hash) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen_hashes.push(hash);
|
||||||
|
deduped.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_instruction_content(content: &str) -> String {
|
||||||
|
collapse_blank_lines(content).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stable_content_hash(content: &str) -> u64 {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
content.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe_instruction_file(file: &ContextFile, files: &[ContextFile]) -> String {
|
||||||
|
let path = display_context_path(&file.path);
|
||||||
|
let scope = files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|candidate| candidate.path.parent())
|
||||||
|
.find(|parent| file.path.starts_with(parent))
|
||||||
|
.map_or_else(
|
||||||
|
|| "workspace".to_string(),
|
||||||
|
|parent| parent.display().to_string(),
|
||||||
|
);
|
||||||
|
format!("{path} (scope: {scope})")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_instruction_content(content: &str, remaining_chars: usize) -> String {
|
||||||
|
let hard_limit = MAX_INSTRUCTION_FILE_CHARS.min(remaining_chars);
|
||||||
|
let trimmed = content.trim();
|
||||||
|
if trimmed.chars().count() <= hard_limit {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = trimmed.chars().take(hard_limit).collect::<String>();
|
||||||
|
output.push_str("\n\n[truncated]");
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_instruction_content(content: &str) -> String {
|
||||||
|
truncate_instruction_content(content, MAX_INSTRUCTION_FILE_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_context_path(path: &Path) -> String {
|
||||||
|
path.file_name().map_or_else(
|
||||||
|
|| path.display().to_string(),
|
||||||
|
|name| name.to_string_lossy().into_owned(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collapse_blank_lines(content: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut previous_blank = false;
|
||||||
|
for line in content.lines() {
|
||||||
|
let is_blank = line.trim().is_empty();
|
||||||
|
if is_blank && previous_blank {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push_str(line.trim_end());
|
||||||
|
result.push('\n');
|
||||||
|
previous_blank = is_blank;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_system_prompt(
|
pub fn load_system_prompt(
|
||||||
cwd: impl Into<PathBuf>,
|
cwd: impl Into<PathBuf>,
|
||||||
current_date: impl Into<String>,
|
current_date: impl Into<String>,
|
||||||
@@ -348,9 +450,14 @@ fn get_actions_section() -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY};
|
use super::{
|
||||||
|
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
||||||
|
render_instruction_content, render_instruction_files, truncate_instruction_content,
|
||||||
|
ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
|
};
|
||||||
use crate::config::ConfigLoader;
|
use crate::config::ConfigLoader;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
fn temp_dir() -> std::path::PathBuf {
|
fn temp_dir() -> std::path::PathBuf {
|
||||||
@@ -394,6 +501,45 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedupes_identical_instruction_content_across_scopes() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let nested = root.join("apps").join("api");
|
||||||
|
fs::create_dir_all(&nested).expect("nested dir");
|
||||||
|
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
|
||||||
|
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||||
|
assert_eq!(context.instruction_files.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_instruction_content(&context.instruction_files[0].content),
|
||||||
|
"same rules"
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncates_large_instruction_content_for_rendering() {
|
||||||
|
let rendered = render_instruction_content(&"x".repeat(4500));
|
||||||
|
assert!(rendered.contains("[truncated]"));
|
||||||
|
assert!(rendered.len() < 4_100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_and_collapses_blank_lines() {
|
||||||
|
let normalized = normalize_instruction_content("line one\n\n\nline two\n");
|
||||||
|
assert_eq!(normalized, "line one\n\nline two");
|
||||||
|
assert_eq!(collapse_blank_lines("a\n\n\n\nb\n"), "a\n\nb\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn displays_context_paths_compactly() {
|
||||||
|
assert_eq!(
|
||||||
|
display_context_path(Path::new("/tmp/project/.claude/CLAUDE.md")),
|
||||||
|
"CLAUDE.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discover_with_git_includes_status_snapshot() {
|
fn discover_with_git_includes_status_snapshot() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
@@ -476,4 +622,23 @@ mod tests {
|
|||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncates_instruction_content_to_budget() {
|
||||||
|
let content = "x".repeat(5_000);
|
||||||
|
let rendered = truncate_instruction_content(&content, 4_000);
|
||||||
|
assert!(rendered.contains("[truncated]"));
|
||||||
|
assert!(rendered.chars().count() <= 4_000 + "\n\n[truncated]".chars().count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_instruction_file_metadata() {
|
||||||
|
let rendered = render_instruction_files(&[ContextFile {
|
||||||
|
path: PathBuf::from("/tmp/project/CLAUDE.md"),
|
||||||
|
content: "Project rules".to_string(),
|
||||||
|
}]);
|
||||||
|
assert!(rendered.contains("# Claude instructions"));
|
||||||
|
assert!(rendered.contains("scope: /tmp/project"));
|
||||||
|
assert!(rendered.contains("Project rules"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
|
const DEFAULT_INPUT_COST_PER_MILLION: f64 = 15.0;
|
||||||
|
const DEFAULT_OUTPUT_COST_PER_MILLION: f64 = 75.0;
|
||||||
|
const DEFAULT_CACHE_CREATION_COST_PER_MILLION: f64 = 18.75;
|
||||||
|
const DEFAULT_CACHE_READ_COST_PER_MILLION: f64 = 1.5;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ModelPricing {
|
||||||
|
pub input_cost_per_million: f64,
|
||||||
|
pub output_cost_per_million: f64,
|
||||||
|
pub cache_creation_cost_per_million: f64,
|
||||||
|
pub cache_read_cost_per_million: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelPricing {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn default_sonnet_tier() -> Self {
|
||||||
|
Self {
|
||||||
|
input_cost_per_million: DEFAULT_INPUT_COST_PER_MILLION,
|
||||||
|
output_cost_per_million: DEFAULT_OUTPUT_COST_PER_MILLION,
|
||||||
|
cache_creation_cost_per_million: DEFAULT_CACHE_CREATION_COST_PER_MILLION,
|
||||||
|
cache_read_cost_per_million: DEFAULT_CACHE_READ_COST_PER_MILLION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct TokenUsage {
|
pub struct TokenUsage {
|
||||||
pub input_tokens: u32,
|
pub input_tokens: u32,
|
||||||
@@ -8,6 +33,49 @@ pub struct TokenUsage {
|
|||||||
pub cache_read_input_tokens: u32,
|
pub cache_read_input_tokens: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct UsageCostEstimate {
|
||||||
|
pub input_cost_usd: f64,
|
||||||
|
pub output_cost_usd: f64,
|
||||||
|
pub cache_creation_cost_usd: f64,
|
||||||
|
pub cache_read_cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageCostEstimate {
|
||||||
|
#[must_use]
|
||||||
|
pub fn total_cost_usd(self) -> f64 {
|
||||||
|
self.input_cost_usd
|
||||||
|
+ self.output_cost_usd
|
||||||
|
+ self.cache_creation_cost_usd
|
||||||
|
+ self.cache_read_cost_usd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
||||||
|
let normalized = model.to_ascii_lowercase();
|
||||||
|
if normalized.contains("haiku") {
|
||||||
|
return Some(ModelPricing {
|
||||||
|
input_cost_per_million: 1.0,
|
||||||
|
output_cost_per_million: 5.0,
|
||||||
|
cache_creation_cost_per_million: 1.25,
|
||||||
|
cache_read_cost_per_million: 0.1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if normalized.contains("opus") {
|
||||||
|
return Some(ModelPricing {
|
||||||
|
input_cost_per_million: 15.0,
|
||||||
|
output_cost_per_million: 75.0,
|
||||||
|
cache_creation_cost_per_million: 18.75,
|
||||||
|
cache_read_cost_per_million: 1.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if normalized.contains("sonnet") {
|
||||||
|
return Some(ModelPricing::default_sonnet_tier());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
impl TokenUsage {
|
impl TokenUsage {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn total_tokens(self) -> u32 {
|
pub fn total_tokens(self) -> u32 {
|
||||||
@@ -16,6 +84,79 @@ impl TokenUsage {
|
|||||||
+ self.cache_creation_input_tokens
|
+ self.cache_creation_input_tokens
|
||||||
+ self.cache_read_input_tokens
|
+ self.cache_read_input_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn estimate_cost_usd(self) -> UsageCostEstimate {
|
||||||
|
self.estimate_cost_usd_with_pricing(ModelPricing::default_sonnet_tier())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn estimate_cost_usd_with_pricing(self, pricing: ModelPricing) -> UsageCostEstimate {
|
||||||
|
UsageCostEstimate {
|
||||||
|
input_cost_usd: cost_for_tokens(self.input_tokens, pricing.input_cost_per_million),
|
||||||
|
output_cost_usd: cost_for_tokens(self.output_tokens, pricing.output_cost_per_million),
|
||||||
|
cache_creation_cost_usd: cost_for_tokens(
|
||||||
|
self.cache_creation_input_tokens,
|
||||||
|
pricing.cache_creation_cost_per_million,
|
||||||
|
),
|
||||||
|
cache_read_cost_usd: cost_for_tokens(
|
||||||
|
self.cache_read_input_tokens,
|
||||||
|
pricing.cache_read_cost_per_million,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn summary_lines(self, label: &str) -> Vec<String> {
|
||||||
|
self.summary_lines_for_model(label, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn summary_lines_for_model(self, label: &str, model: Option<&str>) -> Vec<String> {
|
||||||
|
let pricing = model.and_then(pricing_for_model);
|
||||||
|
let cost = pricing.map_or_else(
|
||||||
|
|| self.estimate_cost_usd(),
|
||||||
|
|pricing| self.estimate_cost_usd_with_pricing(pricing),
|
||||||
|
);
|
||||||
|
let model_suffix =
|
||||||
|
model.map_or_else(String::new, |model_name| format!(" model={model_name}"));
|
||||||
|
let pricing_suffix = if pricing.is_some() {
|
||||||
|
""
|
||||||
|
} else if model.is_some() {
|
||||||
|
" pricing=estimated-default"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
format!(
|
||||||
|
"{label}: total_tokens={} input={} output={} cache_write={} cache_read={} estimated_cost={}{}{}",
|
||||||
|
self.total_tokens(),
|
||||||
|
self.input_tokens,
|
||||||
|
self.output_tokens,
|
||||||
|
self.cache_creation_input_tokens,
|
||||||
|
self.cache_read_input_tokens,
|
||||||
|
format_usd(cost.total_cost_usd()),
|
||||||
|
model_suffix,
|
||||||
|
pricing_suffix,
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
" cost breakdown: input={} output={} cache_write={} cache_read={}",
|
||||||
|
format_usd(cost.input_cost_usd),
|
||||||
|
format_usd(cost.output_cost_usd),
|
||||||
|
format_usd(cost.cache_creation_cost_usd),
|
||||||
|
format_usd(cost.cache_read_cost_usd),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cost_for_tokens(tokens: u32, usd_per_million_tokens: f64) -> f64 {
|
||||||
|
f64::from(tokens) / 1_000_000.0 * usd_per_million_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn format_usd(amount: f64) -> String {
|
||||||
|
format!("${amount:.4}")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
@@ -69,7 +210,7 @@ impl UsageTracker {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{TokenUsage, UsageTracker};
|
use super::{format_usd, pricing_for_model, TokenUsage, UsageTracker};
|
||||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -96,6 +237,53 @@ mod tests {
|
|||||||
assert_eq!(tracker.cumulative_usage().total_tokens(), 48);
|
assert_eq!(tracker.cumulative_usage().total_tokens(), 48);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn computes_cost_summary_lines() {
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 500_000,
|
||||||
|
cache_creation_input_tokens: 100_000,
|
||||||
|
cache_read_input_tokens: 200_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cost = usage.estimate_cost_usd();
|
||||||
|
assert_eq!(format_usd(cost.input_cost_usd), "$15.0000");
|
||||||
|
assert_eq!(format_usd(cost.output_cost_usd), "$37.5000");
|
||||||
|
let lines = usage.summary_lines_for_model("usage", Some("claude-sonnet-4-20250514"));
|
||||||
|
assert!(lines[0].contains("estimated_cost=$54.6750"));
|
||||||
|
assert!(lines[0].contains("model=claude-sonnet-4-20250514"));
|
||||||
|
assert!(lines[1].contains("cache_read=$0.3000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_model_specific_pricing() {
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 500_000,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let haiku = pricing_for_model("claude-haiku-4-5-20251001").expect("haiku pricing");
|
||||||
|
let opus = pricing_for_model("claude-opus-4-6").expect("opus pricing");
|
||||||
|
let haiku_cost = usage.estimate_cost_usd_with_pricing(haiku);
|
||||||
|
let opus_cost = usage.estimate_cost_usd_with_pricing(opus);
|
||||||
|
assert_eq!(format_usd(haiku_cost.total_cost_usd()), "$3.5000");
|
||||||
|
assert_eq!(format_usd(opus_cost.total_cost_usd()), "$52.5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_unknown_model_pricing_as_fallback() {
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 100,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
};
|
||||||
|
let lines = usage.summary_lines_for_model("usage", Some("custom-model"));
|
||||||
|
assert!(lines[0].contains("pricing=estimated-default"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reconstructs_usage_from_session_messages() {
|
fn reconstructs_usage_from_session_messages() {
|
||||||
let session = Session {
|
let session = Session {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1
rust/crates/tools/.gitignore
vendored
Normal file
1
rust/crates/tools/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.clawd-agents/
|
||||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user