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        tracing::warn!(
221            task_id = %id,
222            path = %memory_file_path.display(),
223            "task_id_to_resume requested but no saved context found; starting fresh"
224        );
225        return Ok(None);
226    }
227
228    let memory_data =
229        fs::read_to_string(&memory_file_path).map_err(|e| WinxError::FileAccessError {
230            path: memory_file_path.clone(),
231            message: format!("Failed to read saved context: {e}"),
232        })?;
233    // Cap the injected context so a large saved memory can't blow the Initialize
234    // response past the model's window. Keep the head (project root + plan).
235    let memory_data = truncate_memory_to_tokens(memory_data, memory_token_budget());
236    let state = load_bash_state_from_path(&state_file_path).map_err(|e| {
237        WinxError::SerializationError(format!("Failed to load saved bash state: {e}"))
238    })?;
239
240    Ok(Some((memory_data, state)))
241}
242
243/// Saved memory is treated as non-code prose; cap it like wcgw's
244/// `noncoding_max_tokens` so resuming a task never floods the context window.
245const MEMORY_MAX_TOKENS: usize = 8_000;
246
247/// Memory token budget, overridable via `WINX_NONCODING_TOKEN_BUDGET` (saved
248/// memory is prose, so it shares the non-coding budget with `ReadFiles`).
249fn memory_token_budget() -> usize {
250    crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", MEMORY_MAX_TOKENS)
251}
252
253/// Floor on each file's per-file token slice, so even a glob of many files still
254/// shows a usable head of each one rather than a useless few tokens.
255const MIN_PER_FILE_SAVE_TOKENS: usize = 500;
256
257/// Keep the first `max_tokens` of a file's content and note what was dropped.
258/// Used so a single large file in a `ContextSave` glob doesn't crowd out the rest.
259fn cap_file_to_tokens(content: &str, max_tokens: usize) -> String {
260    match crate::utils::encoder::encode_ids(content) {
261        Some(ids) if ids.len() > max_tokens => {
262            let omitted = ids.len() - max_tokens;
263            let head = crate::utils::encoder::decode_ids(&ids[..max_tokens])
264                .unwrap_or_else(|| content.chars().take(max_tokens.saturating_mul(4)).collect());
265            format!("{head}\n[... {omitted} tokens truncated for context save ...]")
266        }
267        _ => content.to_string(),
268    }
269}
270
271/// Truncate saved memory to `max_tokens`, keeping the head (where the project
272/// root and the plan/status live) and appending a marker. Falls back to the
273/// untouched text if the tokenizer is unavailable.
274fn truncate_memory_to_tokens(text: String, max_tokens: usize) -> String {
275    let Some(ids) = crate::utils::encoder::encode_ids(&text) else {
276        return text;
277    };
278    if ids.len() <= max_tokens {
279        return text;
280    }
281    let keep = max_tokens.saturating_sub(8);
282    match crate::utils::encoder::decode_ids(&ids[..keep]) {
283        Some(decoded) => format!("{decoded}\n(...truncated saved context)"),
284        None => text,
285    }
286}
287
288/// Extract the project root recorded at the top of a saved memory file, if any.
289/// Matches the `Project root path: <path>` header written by `format_memory`.
290pub(crate) fn extract_project_root(memory: &str) -> Option<PathBuf> {
291    memory
292        .lines()
293        .find_map(|line| line.strip_prefix("Project root path:").map(str::trim))
294        .filter(|root| !root.is_empty())
295        .map(PathBuf::from)
296}
297
298fn write_memory_file(
299    memory_file_path: &Path,
300    memory_data: &str,
301    context: &ContextSave,
302) -> Result<Option<String>> {
303    match File::create(memory_file_path) {
304        Ok(mut file) => {
305            if let Err(e) = file.write_all(memory_data.as_bytes()) {
306                warn!("Failed to write memory data: {}", e);
307                return Ok(Some(save_to_temp_file(memory_data, context)?));
308            }
309        }
310        Err(e) => {
311            warn!("Failed to create memory file: {}", e);
312            return Ok(Some(save_to_temp_file(memory_data, context)?));
313        }
314    }
315
316    Ok(None)
317}
318
319fn write_bash_state_file(state_file_path: &Path, bash_state: &BashState) {
320    // Try to create and write state file, but don't fail if it doesn't work
321    if let Err(e) = save_bash_state_to_path(state_file_path, &bash_state.snapshot()) {
322        warn!("Failed to write bash state data: {}", e);
323    }
324}
325
326fn context_save_response(
327    relevant_files: &[PathBuf],
328    warnings: &[String],
329    context: &ContextSave,
330    memory_file_path: &Path,
331) -> String {
332    let memory_file_path_str = memory_file_path.to_string_lossy().to_string();
333    if !relevant_files.is_empty() || context.relevant_file_globs.is_empty() {
334        if warnings.is_empty() {
335            memory_file_path_str
336        } else {
337            format!(
338                "{}\n\nContext file successfully saved at {}",
339                warnings.join("\n"),
340                memory_file_path_str
341            )
342        }
343    } else {
344        format!(
345            "Error: No files found for the given globs. Context file successfully saved at \"{memory_file_path_str}\", but please fix the error."
346        )
347    }
348}
349
350/// Format the memory data for saving
351///
352/// # Arguments
353///
354/// * `context` - The `ContextSave` parameters
355/// * `relevant_files_data` - The content of the relevant files
356///
357/// # Returns
358///
359/// A formatted string containing the memory data
360/// Make a glob portable for resume: if it's absolute and under `project_root`,
361/// store it relative to the root (recorded separately in the memory); otherwise
362/// keep it verbatim. A clone or another machine then still resolves it.
363fn relativize_glob(glob: &str, project_root: &str) -> String {
364    if project_root.is_empty() {
365        return glob.to_string();
366    }
367    Path::new(glob)
368        .strip_prefix(project_root)
369        .map_or_else(|_| glob.to_string(), |rel| rel.to_string_lossy().to_string())
370}
371
372fn format_memory(context: &ContextSave, relevant_files_data: &str) -> String {
373    let mut memory_data = String::new();
374
375    // Add project root path if provided
376    if !context.project_root_path.is_empty() {
377        let _ = write!(memory_data, "Project root path: {}\n\n", context.project_root_path);
378    }
379
380    // Add the description
381    memory_data.push_str(&context.description);
382    memory_data.push_str("\n\n");
383
384    // Add the relevant file globs, made portable: an absolute glob under the
385    // project root is stored relative to it (the root is recorded above), so the
386    // saved memory resumes in a clone — or on another machine — where the
387    // absolute path no longer exists. Already-relative globs are kept as-is.
388    let portable_globs = context
389        .relevant_file_globs
390        .iter()
391        .map(|glob| relativize_glob(glob, &context.project_root_path))
392        .collect::<Vec<_>>();
393    let _ = write!(memory_data, "Relevant file globs: {}\n\n", portable_globs.join(", "));
394
395    // Add the content of the relevant files
396    memory_data.push_str("File contents:\n\n");
397    memory_data.push_str(relevant_files_data);
398
399    memory_data
400}
401
402/// Get the application directory for storing data
403///
404/// This function tries multiple locations in order of preference:
405/// 1. `XDG_DATA_HOME/winx` if `XDG_DATA_HOME` is set
406/// 2. HOME/.local/share/winx if HOME is set
407/// 3. Current directory/.winx-data as a fallback
408/// 4. Temporary directory as a last resort
409///
410/// # Returns
411///
412/// A Result with the path to the app directory
413fn get_app_dir_xdg() -> Result<PathBuf> {
414    // Try multiple locations in order of preference
415    let app_dir = get_primary_app_dir().or_else(|_| get_fallback_app_dir())?;
416
417    debug!("Using app directory: {}", app_dir.display());
418    Ok(app_dir)
419}
420
421/// Try to get the primary application directory
422fn get_primary_app_dir() -> Result<PathBuf> {
423    // Try XDG_DATA_HOME first
424    if let Ok(xdg_path) = std::env::var("XDG_DATA_HOME") {
425        let app_dir = PathBuf::from(xdg_path).join("winx");
426        if let Ok(()) = fs::create_dir_all(&app_dir) {
427            return Ok(app_dir);
428        }
429    }
430
431    // Try HOME/.local/share next
432    if let Ok(home) = std::env::var("HOME") {
433        let app_dir = PathBuf::from(home).join(".local/share/winx");
434        if let Ok(()) = fs::create_dir_all(&app_dir) {
435            return Ok(app_dir);
436        }
437    }
438
439    // No successful primary location
440    Err(WinxError::FileAccessError {
441        path: PathBuf::from("<primary-paths>"),
442        message: "Could not create app directory in primary locations".to_string(),
443    })
444}
445
446/// Get a fallback application directory when primary ones fail
447fn get_fallback_app_dir() -> Result<PathBuf> {
448    // Try current directory
449    let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
450
451    let app_dir = current_dir.join(".winx-data");
452    if let Ok(()) = fs::create_dir_all(&app_dir) {
453        return Ok(app_dir);
454    }
455
456    // Try temporary directory as a last resort
457    let temp_dir = std::env::temp_dir().join("winx-data");
458    fs::create_dir_all(&temp_dir).map_err(|e| WinxError::FileAccessError {
459        path: temp_dir.clone(),
460        message: format!("Failed to create app directory in any location: {e}"),
461    })?;
462
463    Ok(temp_dir)
464}
465
466/// Read the content of multiple files
467///
468/// # Arguments
469///
470/// * `file_paths` - List of paths to the files to read
471/// * `max_files` - Maximum number of files to read
472///
473/// # Returns
474///
475/// A Result with the content of the files, or an error
476fn read_files_content(file_paths: &[PathBuf], max_files: usize) -> Result<String> {
477    let mut result = String::new();
478    let mut skipped_binary = Vec::new();
479
480    // Give each file a fair slice of the memory budget so one huge file (a 200KB
481    // log) can't eat the whole window and bury the rest. Before this, files were
482    // inlined whole and the global cap afterward kept only the head — usually
483    // just the first file, losing every later file and each file's tail.
484    let shown = file_paths.len().min(max_files).max(1);
485    let per_file_tokens = (memory_token_budget() / shown).max(MIN_PER_FILE_SAVE_TOKENS);
486
487    for (i, path) in file_paths.iter().take(max_files).enumerate() {
488        // Try to read as UTF-8 text, skip binary files
489        match fs::read_to_string(path) {
490            Ok(file_content) => {
491                let _ = writeln!(result, "--- File {}: {} ---", i + 1, path.display());
492                result.push_str(&cap_file_to_tokens(&file_content, per_file_tokens));
493                result.push_str("\n\n");
494            }
495            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
496                // Binary file or invalid UTF-8 - skip with note
497                skipped_binary.push(path.display().to_string());
498            }
499            Err(e) => {
500                return Err(WinxError::FileAccessError {
501                    path: path.clone(),
502                    message: format!("Failed to read file: {e}"),
503                });
504            }
505        }
506    }
507
508    // Add note about skipped binary files
509    if !skipped_binary.is_empty() {
510        let _ = write!(
511            result,
512            "Note: Skipped {} binary/non-text file(s): {}\n\n",
513            skipped_binary.len(),
514            skipped_binary.join(", ")
515        );
516    }
517
518    if file_paths.len() > max_files {
519        let _ = writeln!(
520            result,
521            "Note: Only showing the first {} files out of {}.",
522            max_files,
523            file_paths.len()
524        );
525    }
526
527    Ok(result)
528}
529
530/// Try to open a file with the default application
531///
532/// # Arguments
533///
534/// * `file_path` - Path to the file to open
535///
536/// # Returns
537///
538/// A Result indicating success or failure
539fn try_open_file(file_path: &str) -> Result<()> {
540    if std::env::consts::OS != "macos" && std::env::consts::OS != "linux" {
541        // Skip on unsupported platforms
542        return Ok(());
543    }
544
545    // Get the command to use based on the OS
546    let cmd = if std::env::consts::OS == "macos" {
547        "open"
548    } else {
549        // Try to find which command is available on Linux
550        for cmd in &["xdg-open", "gnome-open", "kde-open"] {
551            let status = std::process::Command::new("which")
552                .arg(cmd)
553                .stdout(std::process::Stdio::null())
554                .stderr(std::process::Stdio::null())
555                .status();
556
557            if let Ok(status) = status {
558                if status.success() {
559                    // Found an available command, use it
560                    let _ = std::process::Command::new(cmd)
561                        .arg(file_path)
562                        .stdout(std::process::Stdio::null())
563                        .stderr(std::process::Stdio::null())
564                        .spawn()
565                        .map_err(|e| {
566                            WinxError::CommandExecutionError(format!(
567                                "Failed to spawn open command: {e}"
568                            ))
569                        })?;
570
571                    // We don't wait for the command to complete
572                    return Ok(());
573                }
574            }
575        }
576
577        // If no command is available, just return success
578        return Ok(());
579    };
580
581    // Try to open the file
582    let _ = std::process::Command::new(cmd)
583        .arg(file_path)
584        .stdout(std::process::Stdio::null())
585        .stderr(std::process::Stdio::null())
586        .spawn()
587        .map_err(|e| {
588            WinxError::CommandExecutionError(format!("Failed to spawn open command: {e}"))
589        })?;
590
591    // We don't actually need to wait for the command to complete
592    // Just let it run in the background
593    // (This mimics the Python implementation)
594
595    Ok(())
596}
597
598fn should_open_context_file(file_path: &str) -> bool {
599    std::env::var("WINX_OPEN_CONTEXT").is_ok_and(|value| value == "1" || value == "true")
600        && Path::new(file_path).is_file()
601        && !cfg!(test)
602}
603
604/// Save context data to a temporary file as a last resort
605fn save_to_temp_file(memory_data: &str, context: &ContextSave) -> Result<String> {
606    let temp_dir = std::env::temp_dir();
607    let safe_id = sanitize_filename(&context.id);
608    let temp_file_path = temp_dir.join(format!("winx-{safe_id}.txt"));
609
610    let mut file = File::create(&temp_file_path).map_err(|e| WinxError::FileAccessError {
611        path: temp_file_path.clone(),
612        message: format!("Failed to create temporary file: {e}"),
613    })?;
614
615    file.write_all(memory_data.as_bytes()).map_err(|e| WinxError::FileAccessError {
616        path: temp_file_path.clone(),
617        message: format!("Failed to write to temporary file: {e}"),
618    })?;
619
620    let path_str = temp_file_path.to_string_lossy().to_string();
621
622    Ok(format!(
623        "Context was saved to temporary file at {path_str} due to permission issues with regular locations."
624    ))
625}
626
627/// Sanitize a filename to ensure it's valid on all platforms
628fn sanitize_filename(input: &str) -> String {
629    let invalid_chars = vec!['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
630    let mut result = input.to_string();
631
632    for c in invalid_chars {
633        result = result.replace(c, "_");
634    }
635
636    // Limit length to avoid issues. Slice by chars, not bytes: a multibyte
637    // task id whose 45th byte lands mid-code-point would panic on `&result[0..45]`.
638    if result.chars().count() > 50 {
639        use rand::RngExt;
640        let head: String = result.chars().take(45).collect();
641        result = format!("{head}-{}", rand::rng().random_range(1000..9999));
642    }
643
644    result
645}
646
647#[cfg(test)]
648mod tests {
649    use super::{cap_file_to_tokens, relativize_glob};
650
651    #[test]
652    fn relativize_glob_makes_absolute_portable() {
653        // Absolute glob under the root -> relative.
654        assert_eq!(relativize_glob("/home/u/proj/src/*.rs", "/home/u/proj"), "src/*.rs");
655        // Already relative -> untouched.
656        assert_eq!(relativize_glob("src/*.rs", "/home/u/proj"), "src/*.rs");
657        // Outside the root -> kept verbatim.
658        assert_eq!(relativize_glob("/etc/*.conf", "/home/u/proj"), "/etc/*.conf");
659        // No project root -> unchanged.
660        assert_eq!(relativize_glob("/abs/x", ""), "/abs/x");
661    }
662
663    #[test]
664    fn small_file_is_untouched() {
665        let content = "def add(a, b):\n    return a + b\n";
666        assert_eq!(cap_file_to_tokens(content, 4_000), content);
667    }
668
669    #[test]
670    fn large_file_is_capped_with_a_note() {
671        // ~5k tokens of distinct words, capped to 100.
672        use std::fmt::Write as _;
673        let mut big = String::new();
674        for i in 0..5_000 {
675            let _ = write!(big, "word{i} ");
676        }
677        let capped = cap_file_to_tokens(&big, 100);
678        assert!(capped.len() < big.len(), "should shrink");
679        assert!(capped.contains("truncated for context save"), "should note truncation");
680        assert!(capped.starts_with("word0"), "should keep the head");
681    }
682}