Skip to main content

winx_code_agent/tools/
context_save.rs

1use glob::glob;
2use std::fmt::Write as FmtWrite;
3use std::fs::{self, File};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tokio::sync::Mutex;
8use tracing::{debug, warn};
9
10use crate::errors::{Result, WinxError};
11use crate::state::bash_state::BashState;
12use crate::state::persistence::{
13    load_bash_state_from_path, save_bash_state_to_path, BashStateSnapshot,
14};
15use crate::types::ContextSave;
16use crate::utils::path::{expand_user, validate_path_in_workspace};
17
18/// Handle a call to the `ContextSave` tool
19///
20/// This function processes a `ContextSave` request, saves context information about a task,
21/// including file contents from specified globs, to a single file.
22///
23/// # Arguments
24///
25/// * `bash_state` - Shared reference to the bash state
26/// * `args` - Parameters for the `ContextSave` operation
27///
28/// # Returns
29///
30/// A Result with the path where the context file was saved, or an error
31pub async fn handle_tool_call(
32    bash_state: &Arc<Mutex<Option<BashState>>>,
33    args: ContextSave,
34) -> Result<String> {
35    // Ensure bash state is initialized
36    let bash_state_guard = bash_state.lock().await;
37
38    let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
39
40    // Process the ContextSave request
41    let result = save_context(bash_state, args)?;
42
43    if should_open_context_file(&result) {
44        if let Err(e) = try_open_file(&result) {
45            debug!("Failed to open the context file: {}", e);
46        }
47    }
48
49    Ok(result)
50}
51
52/// Save the context information to a file
53///
54/// # Arguments
55///
56/// * `bash_state` - Reference to the bash state
57/// * `context` - The `ContextSave` parameters
58///
59/// # Returns
60///
61/// A Result with the path where the context file was saved, or an error
62fn save_context(bash_state: &BashState, mut context: ContextSave) -> Result<String> {
63    normalize_context(&mut context)?;
64
65    let (relevant_files, warnings) = collect_relevant_files(&context, &bash_state.workspace_root)?;
66    let memory_dir = resolve_memory_dir()?;
67    let relevant_files_data = read_files_content(&relevant_files, 10_000)?;
68    let memory_data = format_memory(&context, &relevant_files_data);
69    let safe_id = sanitize_filename(&context.id);
70
71    let (memory_file_path, state_file_path) = context_paths_from_dir(&memory_dir, &safe_id);
72    if let Some(response) = write_memory_file(&memory_file_path, &memory_data, &context)? {
73        return Ok(response);
74    }
75
76    write_bash_state_file(&state_file_path, bash_state);
77
78    Ok(context_save_response(&relevant_files, &warnings, &context, &memory_file_path))
79}
80
81fn normalize_context(context: &mut ContextSave) -> Result<()> {
82    if !context.project_root_path.is_empty() {
83        context.project_root_path = expand_user(&context.project_root_path);
84    }
85
86    if context.id.is_empty() {
87        return Err(WinxError::ArgumentParseError("Task ID cannot be empty".to_string()));
88    }
89
90    Ok(())
91}
92
93fn collect_relevant_files(
94    context: &ContextSave,
95    workspace_root: &Path,
96) -> Result<(Vec<PathBuf>, Vec<String>)> {
97    let mut relevant_files = Vec::new();
98    let mut warnings = Vec::new();
99    let mut skipped_outside = 0usize;
100
101    for glob_pattern in &context.relevant_file_globs {
102        // Expand the glob pattern if it contains a tilde
103        let expanded_glob = expand_user(glob_pattern);
104
105        // If the glob is not absolute and we have a project root, make it relative to the project root
106        let final_glob =
107            if !Path::new(&expanded_glob).is_absolute() && !context.project_root_path.is_empty() {
108                PathBuf::from(&context.project_root_path)
109                    .join(expanded_glob)
110                    .to_string_lossy()
111                    .to_string()
112            } else {
113                expanded_glob
114            };
115
116        debug!("Processing glob pattern: {}", final_glob);
117
118        // Use the glob crate to find matching files
119        let matches = glob(&final_glob).map_err(|e| {
120            WinxError::ArgumentParseError(format!("Invalid glob pattern '{final_glob}': {e}"))
121        })?;
122
123        let mut found_files = false;
124        for entry in matches {
125            match entry {
126                Ok(path) => {
127                    if path.is_file() {
128                        // Confine reads to the workspace. ContextSave inlines file
129                        // contents into the saved memory, so without this an
130                        // absolute glob (`/etc/**`, `~/.ssh/*`) or a `..`-escaping
131                        // pattern would exfiltrate arbitrary files — over HTTP that
132                        // is an authenticated read primitive. The other file tools
133                        // already gate on this; ContextSave was the gap.
134                        if validate_path_in_workspace(&path, workspace_root).is_err() {
135                            skipped_outside += 1;
136                            continue;
137                        }
138                        relevant_files.push(path);
139                        found_files = true;
140                        // Limit to 1000 files per glob to avoid excessive processing
141                        if relevant_files.len() >= 1000 {
142                            warn!("Reached limit of 1000 files for glob '{}'", final_glob);
143                            break;
144                        }
145                    }
146                }
147                Err(e) => {
148                    warn!("Error matching glob '{}': {}", final_glob, e);
149                }
150            }
151        }
152
153        if !found_files {
154            warnings.push(format!("Warning: No files found for the glob: {glob_pattern}"));
155        }
156    }
157
158    if skipped_outside > 0 {
159        warnings.push(format!(
160            "Warning: skipped {skipped_outside} file(s) that resolved outside the workspace root."
161        ));
162    }
163
164    debug!("Found {} relevant files", relevant_files.len());
165    Ok((relevant_files, warnings))
166}
167
168fn resolve_memory_dir() -> Result<PathBuf> {
169    let app_dir = match get_app_dir_xdg() {
170        Ok(dir) => dir,
171        Err(e) => {
172            debug!("Failed to get primary app directory: {:?}", e);
173            // Try using temporary directory directly as a last resort
174            let fallback = std::env::temp_dir().join("winx-memory");
175            debug!("Using fallback directory: {}", fallback.display());
176            fs::create_dir_all(&fallback).map_err(|e2| WinxError::FileAccessError {
177                path: fallback.clone(),
178                message: format!(
179                    "Failed to create fallback directory: {e2} (after previous error: {e:?})"
180                ),
181            })?;
182            fallback
183        }
184    };
185
186    let mut memory_dir = app_dir.join("memory");
187    match fs::create_dir_all(&memory_dir) {
188        Ok(()) => {}
189        Err(e) => {
190            debug!("Failed to create memory directory: {}", e);
191            // If we can't create the memory subdirectory, use the app dir directly as the memory_dir
192            debug!("Using app_dir directly as memory_dir due to failed subdirectory creation");
193            memory_dir = app_dir;
194        }
195    }
196
197    Ok(memory_dir)
198}
199
200pub(crate) fn context_paths(id: &str) -> Result<(PathBuf, PathBuf)> {
201    let safe_id = sanitize_filename(id);
202    let memory_dir = resolve_memory_dir()?;
203    Ok(context_paths_from_dir(&memory_dir, &safe_id))
204}
205
206fn context_paths_from_dir(memory_dir: &Path, safe_id: &str) -> (PathBuf, PathBuf) {
207    (
208        memory_dir.join(format!("{safe_id}.txt")),
209        memory_dir.join(format!("{safe_id}_bash_state.json")),
210    )
211}
212
213pub(crate) fn load_saved_context(id: &str) -> Result<Option<(String, Option<BashStateSnapshot>)>> {
214    if id.is_empty() {
215        return Ok(None);
216    }
217
218    let (memory_file_path, state_file_path) = context_paths(id)?;
219    if !memory_file_path.exists() {
220        return Ok(None);
221    }
222
223    let memory_data =
224        fs::read_to_string(&memory_file_path).map_err(|e| WinxError::FileAccessError {
225            path: memory_file_path.clone(),
226            message: format!("Failed to read saved context: {e}"),
227        })?;
228    // Cap the injected context so a large saved memory can't blow the Initialize
229    // response past the model's window. Keep the head (project root + plan).
230    let memory_data = truncate_memory_to_tokens(memory_data, MEMORY_MAX_TOKENS);
231    let state = load_bash_state_from_path(&state_file_path).map_err(|e| {
232        WinxError::SerializationError(format!("Failed to load saved bash state: {e}"))
233    })?;
234
235    Ok(Some((memory_data, state)))
236}
237
238/// Saved memory is treated as non-code prose; cap it like wcgw's
239/// `noncoding_max_tokens` so resuming a task never floods the context window.
240const MEMORY_MAX_TOKENS: usize = 8_000;
241
242/// Truncate saved memory to `max_tokens`, keeping the head (where the project
243/// root and the plan/status live) and appending a marker. Falls back to the
244/// untouched text if the tokenizer is unavailable.
245fn truncate_memory_to_tokens(text: String, max_tokens: usize) -> String {
246    let Some(ids) = crate::utils::encoder::encode_ids(&text) else {
247        return text;
248    };
249    if ids.len() <= max_tokens {
250        return text;
251    }
252    let keep = max_tokens.saturating_sub(8);
253    match crate::utils::encoder::decode_ids(&ids[..keep]) {
254        Some(decoded) => format!("{decoded}\n(...truncated saved context)"),
255        None => text,
256    }
257}
258
259/// Extract the project root recorded at the top of a saved memory file, if any.
260/// Matches the `Project root path: <path>` header written by `format_memory`.
261pub(crate) fn extract_project_root(memory: &str) -> Option<PathBuf> {
262    memory
263        .lines()
264        .find_map(|line| line.strip_prefix("Project root path:").map(str::trim))
265        .filter(|root| !root.is_empty())
266        .map(PathBuf::from)
267}
268
269fn write_memory_file(
270    memory_file_path: &Path,
271    memory_data: &str,
272    context: &ContextSave,
273) -> Result<Option<String>> {
274    match File::create(memory_file_path) {
275        Ok(mut file) => {
276            if let Err(e) = file.write_all(memory_data.as_bytes()) {
277                warn!("Failed to write memory data: {}", e);
278                return Ok(Some(save_to_temp_file(memory_data, context)?));
279            }
280        }
281        Err(e) => {
282            warn!("Failed to create memory file: {}", e);
283            return Ok(Some(save_to_temp_file(memory_data, context)?));
284        }
285    }
286
287    Ok(None)
288}
289
290fn write_bash_state_file(state_file_path: &Path, bash_state: &BashState) {
291    // Try to create and write state file, but don't fail if it doesn't work
292    if let Err(e) = save_bash_state_to_path(state_file_path, &bash_state.snapshot()) {
293        warn!("Failed to write bash state data: {}", e);
294    }
295}
296
297fn context_save_response(
298    relevant_files: &[PathBuf],
299    warnings: &[String],
300    context: &ContextSave,
301    memory_file_path: &Path,
302) -> String {
303    let memory_file_path_str = memory_file_path.to_string_lossy().to_string();
304    if !relevant_files.is_empty() || context.relevant_file_globs.is_empty() {
305        if warnings.is_empty() {
306            memory_file_path_str
307        } else {
308            format!(
309                "{}\n\nContext file successfully saved at {}",
310                warnings.join("\n"),
311                memory_file_path_str
312            )
313        }
314    } else {
315        format!(
316            "Error: No files found for the given globs. Context file successfully saved at \"{memory_file_path_str}\", but please fix the error."
317        )
318    }
319}
320
321/// Format the memory data for saving
322///
323/// # Arguments
324///
325/// * `context` - The `ContextSave` parameters
326/// * `relevant_files_data` - The content of the relevant files
327///
328/// # Returns
329///
330/// A formatted string containing the memory data
331fn format_memory(context: &ContextSave, relevant_files_data: &str) -> String {
332    let mut memory_data = String::new();
333
334    // Add project root path if provided
335    if !context.project_root_path.is_empty() {
336        let _ = write!(memory_data, "Project root path: {}\n\n", context.project_root_path);
337    }
338
339    // Add the description
340    memory_data.push_str(&context.description);
341    memory_data.push_str("\n\n");
342
343    // Add the relevant file globs
344    let _ =
345        write!(memory_data, "Relevant file globs: {}\n\n", context.relevant_file_globs.join(", "));
346
347    // Add the content of the relevant files
348    memory_data.push_str("File contents:\n\n");
349    memory_data.push_str(relevant_files_data);
350
351    memory_data
352}
353
354/// Get the application directory for storing data
355///
356/// This function tries multiple locations in order of preference:
357/// 1. `XDG_DATA_HOME/winx` if `XDG_DATA_HOME` is set
358/// 2. HOME/.local/share/winx if HOME is set
359/// 3. Current directory/.winx-data as a fallback
360/// 4. Temporary directory as a last resort
361///
362/// # Returns
363///
364/// A Result with the path to the app directory
365fn get_app_dir_xdg() -> Result<PathBuf> {
366    // Try multiple locations in order of preference
367    let app_dir = get_primary_app_dir().or_else(|_| get_fallback_app_dir())?;
368
369    debug!("Using app directory: {}", app_dir.display());
370    Ok(app_dir)
371}
372
373/// Try to get the primary application directory
374fn get_primary_app_dir() -> Result<PathBuf> {
375    // Try XDG_DATA_HOME first
376    if let Ok(xdg_path) = std::env::var("XDG_DATA_HOME") {
377        let app_dir = PathBuf::from(xdg_path).join("winx");
378        if let Ok(()) = fs::create_dir_all(&app_dir) {
379            return Ok(app_dir);
380        }
381    }
382
383    // Try HOME/.local/share next
384    if let Ok(home) = std::env::var("HOME") {
385        let app_dir = PathBuf::from(home).join(".local/share/winx");
386        if let Ok(()) = fs::create_dir_all(&app_dir) {
387            return Ok(app_dir);
388        }
389    }
390
391    // No successful primary location
392    Err(WinxError::FileAccessError {
393        path: PathBuf::from("<primary-paths>"),
394        message: "Could not create app directory in primary locations".to_string(),
395    })
396}
397
398/// Get a fallback application directory when primary ones fail
399fn get_fallback_app_dir() -> Result<PathBuf> {
400    // Try current directory
401    let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
402
403    let app_dir = current_dir.join(".winx-data");
404    if let Ok(()) = fs::create_dir_all(&app_dir) {
405        return Ok(app_dir);
406    }
407
408    // Try temporary directory as a last resort
409    let temp_dir = std::env::temp_dir().join("winx-data");
410    fs::create_dir_all(&temp_dir).map_err(|e| WinxError::FileAccessError {
411        path: temp_dir.clone(),
412        message: format!("Failed to create app directory in any location: {e}"),
413    })?;
414
415    Ok(temp_dir)
416}
417
418/// Read the content of multiple files
419///
420/// # Arguments
421///
422/// * `file_paths` - List of paths to the files to read
423/// * `max_files` - Maximum number of files to read
424///
425/// # Returns
426///
427/// A Result with the content of the files, or an error
428fn read_files_content(file_paths: &[PathBuf], max_files: usize) -> Result<String> {
429    let mut result = String::new();
430    let mut skipped_binary = Vec::new();
431
432    for (i, path) in file_paths.iter().take(max_files).enumerate() {
433        // Try to read as UTF-8 text, skip binary files
434        match fs::read_to_string(path) {
435            Ok(file_content) => {
436                let _ = writeln!(result, "--- File {}: {} ---", i + 1, path.display());
437                result.push_str(&file_content);
438                result.push_str("\n\n");
439            }
440            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
441                // Binary file or invalid UTF-8 - skip with note
442                skipped_binary.push(path.display().to_string());
443            }
444            Err(e) => {
445                return Err(WinxError::FileAccessError {
446                    path: path.clone(),
447                    message: format!("Failed to read file: {e}"),
448                });
449            }
450        }
451    }
452
453    // Add note about skipped binary files
454    if !skipped_binary.is_empty() {
455        let _ = write!(
456            result,
457            "Note: Skipped {} binary/non-text file(s): {}\n\n",
458            skipped_binary.len(),
459            skipped_binary.join(", ")
460        );
461    }
462
463    if file_paths.len() > max_files {
464        let _ = writeln!(
465            result,
466            "Note: Only showing the first {} files out of {}.",
467            max_files,
468            file_paths.len()
469        );
470    }
471
472    Ok(result)
473}
474
475/// Try to open a file with the default application
476///
477/// # Arguments
478///
479/// * `file_path` - Path to the file to open
480///
481/// # Returns
482///
483/// A Result indicating success or failure
484fn try_open_file(file_path: &str) -> Result<()> {
485    if std::env::consts::OS != "macos" && std::env::consts::OS != "linux" {
486        // Skip on unsupported platforms
487        return Ok(());
488    }
489
490    // Get the command to use based on the OS
491    let cmd = if std::env::consts::OS == "macos" {
492        "open"
493    } else {
494        // Try to find which command is available on Linux
495        for cmd in &["xdg-open", "gnome-open", "kde-open"] {
496            let status = std::process::Command::new("which")
497                .arg(cmd)
498                .stdout(std::process::Stdio::null())
499                .stderr(std::process::Stdio::null())
500                .status();
501
502            if let Ok(status) = status {
503                if status.success() {
504                    // Found an available command, use it
505                    let _ = std::process::Command::new(cmd)
506                        .arg(file_path)
507                        .stdout(std::process::Stdio::null())
508                        .stderr(std::process::Stdio::null())
509                        .spawn()
510                        .map_err(|e| {
511                            WinxError::CommandExecutionError(format!(
512                                "Failed to spawn open command: {e}"
513                            ))
514                        })?;
515
516                    // We don't wait for the command to complete
517                    return Ok(());
518                }
519            }
520        }
521
522        // If no command is available, just return success
523        return Ok(());
524    };
525
526    // Try to open the file
527    let _ = std::process::Command::new(cmd)
528        .arg(file_path)
529        .stdout(std::process::Stdio::null())
530        .stderr(std::process::Stdio::null())
531        .spawn()
532        .map_err(|e| {
533            WinxError::CommandExecutionError(format!("Failed to spawn open command: {e}"))
534        })?;
535
536    // We don't actually need to wait for the command to complete
537    // Just let it run in the background
538    // (This mimics the Python implementation)
539
540    Ok(())
541}
542
543fn should_open_context_file(file_path: &str) -> bool {
544    std::env::var("WINX_OPEN_CONTEXT").is_ok_and(|value| value == "1" || value == "true")
545        && Path::new(file_path).is_file()
546        && !cfg!(test)
547}
548
549/// Save context data to a temporary file as a last resort
550fn save_to_temp_file(memory_data: &str, context: &ContextSave) -> Result<String> {
551    let temp_dir = std::env::temp_dir();
552    let safe_id = sanitize_filename(&context.id);
553    let temp_file_path = temp_dir.join(format!("winx-{safe_id}.txt"));
554
555    let mut file = File::create(&temp_file_path).map_err(|e| WinxError::FileAccessError {
556        path: temp_file_path.clone(),
557        message: format!("Failed to create temporary file: {e}"),
558    })?;
559
560    file.write_all(memory_data.as_bytes()).map_err(|e| WinxError::FileAccessError {
561        path: temp_file_path.clone(),
562        message: format!("Failed to write to temporary file: {e}"),
563    })?;
564
565    let path_str = temp_file_path.to_string_lossy().to_string();
566
567    Ok(format!(
568        "Context was saved to temporary file at {path_str} due to permission issues with regular locations."
569    ))
570}
571
572/// Sanitize a filename to ensure it's valid on all platforms
573fn sanitize_filename(input: &str) -> String {
574    let invalid_chars = vec!['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
575    let mut result = input.to_string();
576
577    for c in invalid_chars {
578        result = result.replace(c, "_");
579    }
580
581    // Limit length to avoid issues. Slice by chars, not bytes: a multibyte
582    // task id whose 45th byte lands mid-code-point would panic on `&result[0..45]`.
583    if result.chars().count() > 50 {
584        use rand::RngExt;
585        let head: String = result.chars().take(45).collect();
586        result = format!("{head}-{}", rand::rng().random_range(1000..9999));
587    }
588
589    result
590}