1 Commits

Author SHA1 Message Date
Yeachan-Heo
549deb9a89 Preserve local project context across compaction and todo updates
This change makes compaction summaries durable under .claude/memory,
feeds those saved memory files back into prompt context, updates /memory
to report both instruction and project-memory files, and moves TodoWrite
persistence to a human-readable .claude/todos.md file.

Constraint: Reuse existing compaction, prompt loading, and slash-command plumbing rather than add a new subsystem
Constraint: Keep persisted project state under Claude-local .claude/ paths
Rejected: Introduce a dedicated memory service module | larger diff with no clear user benefit for this task
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Project memory files are loaded as prompt context, so future format changes must preserve concise readable content
Tested: cargo fmt --all --manifest-path rust/Cargo.toml
Tested: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings
Tested: cargo test --manifest-path rust/Cargo.toml --all
Not-tested: Long-term retention/cleanup policy for .claude/memory growth
2026-04-01 00:58:36 +00:00
8 changed files with 349 additions and 413 deletions

View File

@@ -11,7 +11,7 @@ pub use error::ApiError;
pub use sse::{parse_frame, SseParser}; pub use sse::{parse_frame, SseParser};
pub use types::{ pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
ImageSource, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
}; };

View File

@@ -64,9 +64,6 @@ pub enum InputContentBlock {
Text { Text {
text: String, text: String,
}, },
Image {
source: ImageSource,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -80,14 +77,6 @@ pub enum InputContentBlock {
}, },
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub kind: String,
pub media_type: String,
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolResultContentBlock { pub enum ToolResultContentBlock {

View File

@@ -4,8 +4,8 @@ use std::time::Duration;
use api::{ use api::{
AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
ImageSource, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
OutputContentBlock, StreamEvent, ToolChoice, ToolDefinition, StreamEvent, ToolChoice, ToolDefinition,
}; };
use serde_json::json; use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -75,39 +75,6 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(body["tool_choice"]["type"], json!("auto")); assert_eq!(body["tool_choice"]["type"], json!("auto"));
} }
#[test]
fn image_content_blocks_serialize_with_base64_source() {
let request = MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Image {
source: ImageSource {
kind: "base64".to_string(),
media_type: "image/png".to_string(),
data: "AQID".to_string(),
},
}],
}],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
let json = serde_json::to_value(request).expect("request should serialize");
assert_eq!(json["messages"][0]["content"][0]["type"], json!("image"));
assert_eq!(
json["messages"][0]["content"][0]["source"],
json!({
"type": "base64",
"media_type": "image/png",
"data": "AQID"
})
);
}
#[tokio::test] #[tokio::test]
async fn stream_message_parses_sse_events_with_tool_use() { async fn stream_message_parses_sse_events_with_tool_use() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new())); let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -1,3 +1,6 @@
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -90,6 +93,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
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 formatted_summary = format_compact_summary(&summary);
persist_compact_summary(&formatted_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 {
@@ -110,6 +114,35 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
} }
} }
fn persist_compact_summary(formatted_summary: &str) {
if formatted_summary.trim().is_empty() {
return;
}
let Ok(cwd) = std::env::current_dir() else {
return;
};
let memory_dir = cwd.join(".claude").join("memory");
if fs::create_dir_all(&memory_dir).is_err() {
return;
}
let path = memory_dir.join(compact_summary_filename());
let _ = fs::write(path, render_memory_file(formatted_summary));
}
fn compact_summary_filename() -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("summary-{timestamp}.md")
}
fn render_memory_file(formatted_summary: &str) -> String {
format!("# Project memory\n\n{}\n", formatted_summary.trim())
}
fn summarize_messages(messages: &[ConversationMessage]) -> String { fn summarize_messages(messages: &[ConversationMessage]) -> String {
let user_messages = messages let user_messages = messages
.iter() .iter()
@@ -378,14 +411,21 @@ fn collapse_blank_lines(content: &str) -> String {
mod tests { mod tests {
use super::{ use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary, collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
infer_pending_work, should_compact, CompactionConfig, infer_pending_work, render_memory_file, should_compact, CompactionConfig,
}; };
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
#[test] #[test]
fn formats_compact_summary_like_upstream() { fn formats_compact_summary_like_upstream() {
let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>"; let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
assert_eq!(format_compact_summary(summary), "Summary:\nKept work"); assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
assert_eq!(
render_memory_file("Summary:\nKept work"),
"# Project memory\n\nSummary:\nKept work\n"
);
} }
#[test] #[test]
@@ -402,6 +442,63 @@ mod tests {
assert!(result.formatted_summary.is_empty()); assert!(result.formatted_summary.is_empty());
} }
#[test]
fn persists_compacted_summaries_under_dot_claude_memory() {
let _guard = crate::test_env_lock();
let temp = std::env::temp_dir().join(format!(
"runtime-compact-memory-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time after epoch")
.as_nanos()
));
fs::create_dir_all(&temp).expect("temp dir");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&temp).expect("set cwd");
let session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
],
};
let result = compact_session(
&session,
CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
},
);
let memory_dir = temp.join(".claude").join("memory");
let files = fs::read_dir(&memory_dir)
.expect("memory dir exists")
.flatten()
.map(|entry| entry.path())
.collect::<Vec<_>>();
assert_eq!(result.removed_message_count, 2);
assert_eq!(files.len(), 1);
let persisted = fs::read_to_string(&files[0]).expect("memory file readable");
std::env::set_current_dir(previous).expect("restore cwd");
fs::remove_dir_all(temp).expect("cleanup temp dir");
assert!(persisted.contains("# Project memory"));
assert!(persisted.contains("Summary:"));
}
#[test] #[test]
fn compacts_older_messages_into_a_system_summary() { fn compacts_older_messages_into_a_system_summary() {
let session = Session { let session = Session {

View File

@@ -415,6 +415,7 @@ mod tests {
current_date: "2026-03-31".to_string(), current_date: "2026-03-31".to_string(),
git_status: None, git_status: None,
instruction_files: Vec::new(), instruction_files: Vec::new(),
memory_files: Vec::new(),
}) })
.with_os("linux", "6.8") .with_os("linux", "6.8")
.build(); .build();

View File

@@ -51,6 +51,7 @@ pub struct ProjectContext {
pub current_date: String, pub current_date: String,
pub git_status: Option<String>, pub git_status: Option<String>,
pub instruction_files: Vec<ContextFile>, pub instruction_files: Vec<ContextFile>,
pub memory_files: Vec<ContextFile>,
} }
impl ProjectContext { impl ProjectContext {
@@ -60,11 +61,13 @@ impl ProjectContext {
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
let cwd = cwd.into(); let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd)?; let instruction_files = discover_instruction_files(&cwd)?;
let memory_files = discover_memory_files(&cwd)?;
Ok(Self { Ok(Self {
cwd, cwd,
current_date: current_date.into(), current_date: current_date.into(),
git_status: None, git_status: None,
instruction_files, instruction_files,
memory_files,
}) })
} }
@@ -144,6 +147,9 @@ impl SystemPromptBuilder {
if !project_context.instruction_files.is_empty() { if !project_context.instruction_files.is_empty() {
sections.push(render_instruction_files(&project_context.instruction_files)); sections.push(render_instruction_files(&project_context.instruction_files));
} }
if !project_context.memory_files.is_empty() {
sections.push(render_memory_files(&project_context.memory_files));
}
} }
if let Some(config) = &self.config { if let Some(config) = &self.config {
sections.push(render_config_section(config)); sections.push(render_config_section(config));
@@ -186,7 +192,7 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect() items.into_iter().map(|item| format!(" - {item}")).collect()
} }
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> { fn discover_context_directories(cwd: &Path) -> Vec<PathBuf> {
let mut directories = Vec::new(); let mut directories = Vec::new();
let mut cursor = Some(cwd); let mut cursor = Some(cwd);
while let Some(dir) = cursor { while let Some(dir) = cursor {
@@ -194,6 +200,11 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
cursor = dir.parent(); cursor = dir.parent();
} }
directories.reverse(); directories.reverse();
directories
}
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
let directories = discover_context_directories(cwd);
let mut files = Vec::new(); let mut files = Vec::new();
for dir in directories { for dir in directories {
@@ -209,6 +220,26 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
Ok(dedupe_instruction_files(files)) Ok(dedupe_instruction_files(files))
} }
fn discover_memory_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
let mut files = Vec::new();
for dir in discover_context_directories(cwd) {
let memory_dir = dir.join(".claude").join("memory");
let Ok(entries) = fs::read_dir(&memory_dir) else {
continue;
};
let mut paths = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file())
.collect::<Vec<_>>();
paths.sort();
for path in paths {
push_context_file(&mut files, path)?;
}
}
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<()> {
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
Ok(content) if !content.trim().is_empty() => { Ok(content) if !content.trim().is_empty() => {
@@ -251,6 +282,12 @@ fn render_project_context(project_context: &ProjectContext) -> String {
project_context.instruction_files.len() project_context.instruction_files.len()
)); ));
} }
if !project_context.memory_files.is_empty() {
bullets.push(format!(
"Project memory files discovered: {}.",
project_context.memory_files.len()
));
}
lines.extend(prepend_bullets(bullets)); 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());
@@ -261,7 +298,15 @@ 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()]; render_context_file_section("# Claude instructions", files)
}
fn render_memory_files(files: &[ContextFile]) -> String {
render_context_file_section("# Project memory", files)
}
fn render_context_file_section(title: &str, files: &[ContextFile]) -> String {
let mut sections = vec![title.to_string()];
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS; let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
for file in files { for file in files {
if remaining_chars == 0 { if remaining_chars == 0 {
@@ -453,8 +498,9 @@ fn get_actions_section() -> String {
mod tests { mod tests {
use super::{ use super::{
collapse_blank_lines, display_context_path, normalize_instruction_content, collapse_blank_lines, display_context_path, normalize_instruction_content,
render_instruction_content, render_instruction_files, truncate_instruction_content, render_instruction_content, render_instruction_files, render_memory_files,
ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, truncate_instruction_content, ContextFile, ProjectContext, SystemPromptBuilder,
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
}; };
use crate::config::ConfigLoader; use crate::config::ConfigLoader;
use std::fs; use std::fs;
@@ -519,6 +565,35 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
#[test]
fn discovers_project_memory_files_from_ancestor_chain() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(root.join(".claude").join("memory")).expect("root memory dir");
fs::create_dir_all(nested.join(".claude").join("memory")).expect("nested memory dir");
fs::write(
root.join(".claude").join("memory").join("2026-03-30.md"),
"root memory",
)
.expect("write root memory");
fs::write(
nested.join(".claude").join("memory").join("2026-03-31.md"),
"nested memory",
)
.expect("write nested memory");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let contents = context
.memory_files
.iter()
.map(|file| file.content.as_str())
.collect::<Vec<_>>();
assert_eq!(contents, vec!["root memory", "nested memory"]);
assert!(render_memory_files(&context.memory_files).contains("# Project memory"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test] #[test]
fn dedupes_identical_instruction_content_across_scopes() { fn dedupes_identical_instruction_content_across_scopes() {
let root = temp_dir(); let root = temp_dir();

View File

@@ -11,8 +11,8 @@ use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use api::{ use api::{
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, ImageSource, resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
}; };
@@ -41,7 +41,6 @@ const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
type AllowedToolSet = BTreeSet<String>; type AllowedToolSet = BTreeSet<String>;
const IMAGE_REF_PREFIX: &str = "@";
fn main() { fn main() {
if let Err(error) = run() { if let Err(error) = run() {
@@ -1043,7 +1042,9 @@ impl LiveCli {
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
messages: vec![InputMessage { messages: vec![InputMessage {
role: "user".to_string(), role: "user".to_string(),
content: prompt_to_content_blocks(input, &env::current_dir()?)?, content: vec![InputContentBlock::Text {
text: input.to_string(),
}],
}], }],
system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")), system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")),
tools: None, tools: None,
@@ -1541,7 +1542,8 @@ fn status_context(
session_path: session_path.map(Path::to_path_buf), session_path: session_path.map(Path::to_path_buf),
loaded_config_files: runtime_config.loaded_entries().len(), loaded_config_files: runtime_config.loaded_entries().len(),
discovered_config_files, discovered_config_files,
memory_file_count: project_context.instruction_files.len(), memory_file_count: project_context.instruction_files.len()
+ project_context.memory_files.len(),
project_root, project_root,
git_branch, git_branch,
}) })
@@ -1686,39 +1688,58 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let mut lines = vec![format!( let mut lines = vec![format!(
"Memory "Memory
Working directory {} Working directory {}
Instruction files {}", Instruction files {}
Project memory files {}",
cwd.display(), cwd.display(),
project_context.instruction_files.len() project_context.instruction_files.len(),
project_context.memory_files.len()
)]; )];
if project_context.instruction_files.is_empty() { append_memory_section(
lines.push("Discovered files".to_string()); &mut lines,
lines.push( "Instruction files",
" No CLAUDE instruction files discovered in the current directory ancestry." &project_context.instruction_files,
.to_string(), "No CLAUDE instruction files discovered in the current directory ancestry.",
); );
} else { append_memory_section(
lines.push("Discovered files".to_string()); &mut lines,
for (index, file) in project_context.instruction_files.iter().enumerate() { "Project memory files",
let preview = file.content.lines().next().unwrap_or("").trim(); &project_context.memory_files,
let preview = if preview.is_empty() { "No persisted project memory files discovered in .claude/memory.",
"<empty>" );
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
Ok(lines.join( Ok(lines.join(
" "
", ",
)) ))
} }
fn append_memory_section(
lines: &mut Vec<String>,
title: &str,
files: &[runtime::ContextFile],
empty_message: &str,
) {
lines.push(title.to_string());
if files.is_empty() {
lines.push(format!(" {empty_message}"));
return;
}
for (index, file) in files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() {
"<empty>"
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display()));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> { fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let claude_md = cwd.join("CLAUDE.md"); let claude_md = cwd.join("CLAUDE.md");
@@ -2020,7 +2041,7 @@ impl ApiClient for AnthropicRuntimeClient {
let message_request = MessageRequest { let message_request = MessageRequest {
model: self.model.clone(), model: self.model.clone(),
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
messages: convert_messages(&request.messages)?, messages: convert_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: self.enable_tools.then(|| { tools: self.enable_tools.then(|| {
filter_tool_specs(self.allowed_tools.as_ref()) filter_tool_specs(self.allowed_tools.as_ref())
@@ -2299,10 +2320,7 @@ fn tool_permission_specs() -> Vec<ToolSpec> {
mvp_tool_specs() mvp_tool_specs()
} }
fn convert_messages(messages: &[ConversationMessage]) -> Result<Vec<InputMessage>, RuntimeError> { fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
let cwd = env::current_dir().map_err(|error| {
RuntimeError::new(format!("failed to resolve current directory: {error}"))
})?;
messages messages
.iter() .iter()
.filter_map(|message| { .filter_map(|message| {
@@ -2313,224 +2331,36 @@ fn convert_messages(messages: &[ConversationMessage]) -> Result<Vec<InputMessage
let content = message let content = message
.blocks .blocks
.iter() .iter()
.try_fold(Vec::new(), |mut acc, block| { .map(|block| match block {
match block { ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Text { text } => { ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
if message.role == MessageRole::User { id: id.clone(),
acc.extend( name: name.clone(),
prompt_to_content_blocks(text, &cwd) input: serde_json::from_str(input)
.map_err(RuntimeError::new)?, .unwrap_or_else(|_| serde_json::json!({ "raw": input })),
); },
} else { ContentBlock::ToolResult {
acc.push(InputContentBlock::Text { text: text.clone() }); tool_use_id,
} output,
} is_error,
ContentBlock::ToolUse { id, name, input } => { ..
acc.push(InputContentBlock::ToolUse { } => InputContentBlock::ToolResult {
id: id.clone(), tool_use_id: tool_use_id.clone(),
name: name.clone(), content: vec![ToolResultContentBlock::Text {
input: serde_json::from_str(input) text: output.clone(),
.unwrap_or_else(|_| serde_json::json!({ "raw": input })), }],
}); is_error: *is_error,
} },
ContentBlock::ToolResult { })
tool_use_id, .collect::<Vec<_>>();
output, (!content.is_empty()).then(|| InputMessage {
is_error, role: role.to_string(),
.. content,
} => acc.push(InputContentBlock::ToolResult { })
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
}),
}
Ok::<_, RuntimeError>(acc)
});
match content {
Ok(content) if !content.is_empty() => Some(Ok(InputMessage {
role: role.to_string(),
content,
})),
Ok(_) => None,
Err(error) => Some(Err(error)),
}
}) })
.collect() .collect()
} }
fn prompt_to_content_blocks(input: &str, cwd: &Path) -> Result<Vec<InputContentBlock>, String> {
let mut blocks = Vec::new();
let mut text_buffer = String::new();
let mut chars = input.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
if ch == '!' && input[index..].starts_with("![") {
if let Some((alt_end, path_start, path_end)) = parse_markdown_image_ref(input, index) {
let _ = alt_end;
flush_text_block(&mut blocks, &mut text_buffer);
let path = &input[path_start..path_end];
blocks.push(load_image_block(path, cwd)?);
while let Some((next_index, _)) = chars.peek() {
if *next_index < path_end + 1 {
let _ = chars.next();
} else {
break;
}
}
continue;
}
}
if ch == '@' && is_ref_boundary(input[..index].chars().next_back()) {
let path_end = find_path_end(input, index + 1);
if path_end > index + 1 {
let candidate = &input[index + 1..path_end];
if looks_like_image_ref(candidate, cwd) {
flush_text_block(&mut blocks, &mut text_buffer);
blocks.push(load_image_block(candidate, cwd)?);
while let Some((next_index, _)) = chars.peek() {
if *next_index < path_end {
let _ = chars.next();
} else {
break;
}
}
continue;
}
}
}
text_buffer.push(ch);
}
flush_text_block(&mut blocks, &mut text_buffer);
if blocks.is_empty() {
blocks.push(InputContentBlock::Text {
text: input.to_string(),
});
}
Ok(blocks)
}
fn parse_markdown_image_ref(input: &str, start: usize) -> Option<(usize, usize, usize)> {
let after_bang = input.get(start + 2..)?;
let alt_end_offset = after_bang.find("](")?;
let path_start = start + 2 + alt_end_offset + 2;
let remainder = input.get(path_start..)?;
let path_end_offset = remainder.find(')')?;
let path_end = path_start + path_end_offset;
Some((start + 2 + alt_end_offset, path_start, path_end))
}
fn is_ref_boundary(ch: Option<char>) -> bool {
ch.is_none_or(char::is_whitespace)
}
fn find_path_end(input: &str, start: usize) -> usize {
input[start..]
.char_indices()
.find_map(|(offset, ch)| (ch.is_whitespace()).then_some(start + offset))
.unwrap_or(input.len())
}
fn looks_like_image_ref(candidate: &str, cwd: &Path) -> bool {
let resolved = resolve_prompt_path(candidate, cwd);
media_type_for_path(Path::new(candidate)).is_some()
|| resolved.is_file()
|| candidate.contains(std::path::MAIN_SEPARATOR)
|| candidate.starts_with("./")
|| candidate.starts_with("../")
}
fn flush_text_block(blocks: &mut Vec<InputContentBlock>, text_buffer: &mut String) {
if text_buffer.is_empty() {
return;
}
blocks.push(InputContentBlock::Text {
text: std::mem::take(text_buffer),
});
}
fn load_image_block(path_ref: &str, cwd: &Path) -> Result<InputContentBlock, String> {
let resolved = resolve_prompt_path(path_ref, cwd);
let media_type = media_type_for_path(&resolved).ok_or_else(|| {
format!(
"unsupported image format for reference {IMAGE_REF_PREFIX}{path_ref}; supported: png, jpg, jpeg, gif, webp"
)
})?;
let bytes = fs::read(&resolved).map_err(|error| {
format!(
"failed to read image reference {}: {error}",
resolved.display()
)
})?;
Ok(InputContentBlock::Image {
source: ImageSource {
kind: "base64".to_string(),
media_type: media_type.to_string(),
data: encode_base64(&bytes),
},
})
}
fn resolve_prompt_path(path_ref: &str, cwd: &Path) -> PathBuf {
let path = Path::new(path_ref);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
fn media_type_for_path(path: &Path) -> Option<&'static str> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
match extension.as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
fn encode_base64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::new();
let mut index = 0;
while index + 3 <= bytes.len() {
let block = (u32::from(bytes[index]) << 16)
| (u32::from(bytes[index + 1]) << 8)
| u32::from(bytes[index + 2]);
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
output.push(TABLE[(block & 0x3F) as usize] as char);
index += 3;
}
match bytes.len().saturating_sub(index) {
1 => {
let block = u32::from(bytes[index]) << 16;
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push('=');
output.push('=');
}
2 => {
let block = (u32::from(bytes[index]) << 16) | (u32::from(bytes[index + 1]) << 8);
output.push(TABLE[((block >> 18) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 12) & 0x3F) as usize] as char);
output.push(TABLE[((block >> 6) & 0x3F) as usize] as char);
output.push('=');
}
_ => {}
}
output
}
fn print_help() { fn print_help() {
println!("rusty-claude-cli v{VERSION}"); println!("rusty-claude-cli v{VERSION}");
println!(); println!();
@@ -2587,10 +2417,8 @@ mod tests {
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use api::InputContentBlock;
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
@@ -2964,7 +2792,7 @@ mod tests {
assert!(report.contains("Memory")); assert!(report.contains("Memory"));
assert!(report.contains("Working directory")); assert!(report.contains("Working directory"));
assert!(report.contains("Instruction files")); assert!(report.contains("Instruction files"));
assert!(report.contains("Discovered files")); assert!(report.contains("Project memory files"));
} }
#[test] #[test]
@@ -3073,110 +2901,11 @@ mod tests {
}, },
]; ];
let converted = super::convert_messages(&messages).expect("messages should convert"); let converted = super::convert_messages(&messages);
assert_eq!(converted.len(), 3); assert_eq!(converted.len(), 3);
assert_eq!(converted[1].role, "assistant"); assert_eq!(converted[1].role, "assistant");
assert_eq!(converted[2].role, "user"); assert_eq!(converted[2].role, "user");
} }
#[test]
fn prompt_to_content_blocks_keeps_text_only_prompt() {
let blocks = super::prompt_to_content_blocks("hello world", Path::new("."))
.expect("text prompt should parse");
assert_eq!(
blocks,
vec![InputContentBlock::Text {
text: "hello world".to_string()
}]
);
}
#[test]
fn prompt_to_content_blocks_embeds_at_image_refs() {
let temp = temp_fixture_dir("at-image-ref");
let image_path = temp.join("sample.png");
std::fs::write(&image_path, [1_u8, 2, 3]).expect("fixture write");
let prompt = format!("describe @{} please", image_path.display());
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect("image ref should parse");
assert!(matches!(
&blocks[0],
InputContentBlock::Text { text } if text == "describe "
));
assert!(matches!(
&blocks[1],
InputContentBlock::Image { source }
if source.kind == "base64"
&& source.media_type == "image/png"
&& source.data == "AQID"
));
assert!(matches!(
&blocks[2],
InputContentBlock::Text { text } if text == " please"
));
}
#[test]
fn prompt_to_content_blocks_embeds_markdown_image_refs() {
let temp = temp_fixture_dir("markdown-image-ref");
let image_path = temp.join("sample.webp");
std::fs::write(&image_path, [255_u8]).expect("fixture write");
let prompt = format!("see ![asset]({}) now", image_path.display());
let blocks = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect("markdown image ref should parse");
assert!(matches!(
&blocks[1],
InputContentBlock::Image { source }
if source.media_type == "image/webp" && source.data == "/w=="
));
}
#[test]
fn prompt_to_content_blocks_rejects_unsupported_formats() {
let temp = temp_fixture_dir("unsupported-image-ref");
let image_path = temp.join("sample.bmp");
std::fs::write(&image_path, [1_u8]).expect("fixture write");
let prompt = format!("describe @{}", image_path.display());
let error = super::prompt_to_content_blocks(&prompt, Path::new("."))
.expect_err("unsupported image ref should fail");
assert!(error.contains("unsupported image format"));
}
#[test]
fn convert_messages_expands_user_text_image_refs() {
let temp = temp_fixture_dir("convert-message-image-ref");
let image_path = temp.join("sample.gif");
std::fs::write(&image_path, [71_u8, 73, 70]).expect("fixture write");
let messages = vec![ConversationMessage::user_text(format!(
"inspect @{}",
image_path.display()
))];
let converted = super::convert_messages(&messages).expect("messages should convert");
assert_eq!(converted.len(), 1);
assert!(matches!(
&converted[0].content[1],
InputContentBlock::Image { source }
if source.media_type == "image/gif" && source.data == "R0lG"
));
}
fn temp_fixture_dir(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should advance")
.as_nanos();
let path = std::env::temp_dir().join(format!("rusty-claude-cli-{label}-{unique}"));
std::fs::create_dir_all(&path).expect("temp dir should exist");
path
}
#[test] #[test]
fn repl_help_mentions_history_completion_and_multiline() { fn repl_help_mentions_history_completion_and_multiline() {
let help = render_repl_help(); let help = render_repl_help();

View File

@@ -1199,10 +1199,9 @@ fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String>
validate_todos(&input.todos)?; validate_todos(&input.todos)?;
let store_path = todo_store_path()?; let store_path = todo_store_path()?;
let old_todos = if store_path.exists() { let old_todos = if store_path.exists() {
serde_json::from_str::<Vec<TodoItem>>( parse_todo_markdown(
&std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?, &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
) )?
.map_err(|error| error.to_string())?
} else { } else {
Vec::new() Vec::new()
}; };
@@ -1220,11 +1219,8 @@ fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String>
if let Some(parent) = store_path.parent() { if let Some(parent) = store_path.parent() {
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
} }
std::fs::write( std::fs::write(&store_path, render_todo_markdown(&persisted))
&store_path, .map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let verification_nudge_needed = (all_done let verification_nudge_needed = (all_done
&& input.todos.len() >= 3 && input.todos.len() >= 3
@@ -1282,7 +1278,58 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
return Ok(std::path::PathBuf::from(path)); return Ok(std::path::PathBuf::from(path));
} }
let cwd = std::env::current_dir().map_err(|error| error.to_string())?; let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
Ok(cwd.join(".clawd-todos.json")) Ok(cwd.join(".claude").join("todos.md"))
}
fn render_todo_markdown(todos: &[TodoItem]) -> String {
let mut lines = vec!["# Todo list".to_string(), String::new()];
for todo in todos {
let marker = match todo.status {
TodoStatus::Pending => "[ ]",
TodoStatus::InProgress => "[~]",
TodoStatus::Completed => "[x]",
};
lines.push(format!(
"- {marker} {} :: {}",
todo.content, todo.active_form
));
}
lines.push(String::new());
lines.join("\n")
}
fn parse_todo_markdown(content: &str) -> Result<Vec<TodoItem>, String> {
let mut todos = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some(rest) = trimmed.strip_prefix("- [") else {
continue;
};
let mut chars = rest.chars();
let status = match chars.next() {
Some(' ') => TodoStatus::Pending,
Some('~') => TodoStatus::InProgress,
Some('x' | 'X') => TodoStatus::Completed,
Some(other) => return Err(format!("unsupported todo status marker: {other}")),
None => return Err(String::from("malformed todo line")),
};
let remainder = chars.as_str();
let Some(body) = remainder.strip_prefix("] ") else {
return Err(String::from("malformed todo line"));
};
let Some((content, active_form)) = body.split_once(" :: ") else {
return Err(String::from("todo line missing active form separator"));
};
todos.push(TodoItem {
content: content.trim().to_string(),
active_form: active_form.trim().to_string(),
status,
});
}
Ok(todos)
} }
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> { fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
@@ -2638,6 +2685,37 @@ mod tests {
assert!(second_output["verificationNudgeNeeded"].is_null()); assert!(second_output["verificationNudgeNeeded"].is_null());
} }
#[test]
fn todo_write_persists_markdown_in_claude_directory() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = temp_path("todos-md-dir");
std::fs::create_dir_all(&temp).expect("temp dir");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&temp).expect("set cwd");
execute_tool(
"TodoWrite",
&json!({
"todos": [
{"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
{"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
]
}),
)
.expect("TodoWrite should succeed");
let persisted = std::fs::read_to_string(temp.join(".claude").join("todos.md"))
.expect("todo markdown exists");
std::env::set_current_dir(previous).expect("restore cwd");
let _ = std::fs::remove_dir_all(temp);
assert!(persisted.contains("# Todo list"));
assert!(persisted.contains("- [~] Add tool :: Adding tool"));
assert!(persisted.contains("- [ ] Run tests :: Running tests"));
}
#[test] #[test]
fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() { fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() {
let _guard = env_lock() let _guard = env_lock()