1use crate::core::cache::SessionCache;
2use crate::core::heatmap;
3use crate::core::ocla::cache_types::{CacheKeyBuilder, FileReadKey};
4use crate::core::tokens::count_tokens;
5use crate::tools::CrpMode;
6use crate::tools::ctx_read;
7
8pub fn handle(cache: &mut SessionCache, paths: &[String], mode: &str, crp_mode: CrpMode) -> String {
9 handle_with_task(cache, paths, mode, crp_mode, None)
10}
11
12pub fn handle_with_task(
13 cache: &mut SessionCache,
14 paths: &[String],
15 mode: &str,
16 crp_mode: CrpMode,
17 task: Option<&str>,
18) -> String {
19 handle_with_task_fresh(cache, paths, mode, false, crp_mode, task)
20}
21
22const DEFAULT_MAX_MULTI_READ_BYTES: usize = 512 * 1024;
23
24fn max_multi_read_bytes() -> usize {
25 std::env::var("LCTX_MAX_MULTI_READ_BYTES")
26 .ok()
27 .and_then(|v| v.parse().ok())
28 .unwrap_or(DEFAULT_MAX_MULTI_READ_BYTES)
29}
30
31pub fn handle_with_task_fresh(
32 cache: &mut SessionCache,
33 paths: &[String],
34 mode: &str,
35 fresh: bool,
36 crp_mode: CrpMode,
37 task: Option<&str>,
38) -> String {
39 handle_with_task_fresh_result(cache, paths, mode, fresh, crp_mode, task).text
40}
41
42pub struct MultiReadResult {
44 pub text: String,
45 pub original_tokens: usize,
46}
47
48pub fn handle_with_task_fresh_result(
49 cache: &mut SessionCache,
50 paths: &[String],
51 mode: &str,
52 fresh: bool,
53 crp_mode: CrpMode,
54 task: Option<&str>,
55) -> MultiReadResult {
56 let n = paths.len();
57 if n == 0 {
58 return MultiReadResult {
59 text: "Read 0 files | 0 tokens saved".to_string(),
60 original_tokens: 0,
61 };
62 }
63
64 let max_bytes = max_multi_read_bytes();
65 let mut sections: Vec<String> = Vec::with_capacity(n);
66 let mut total_saved: usize = 0;
67 let mut total_original: usize = 0;
68 let mut accumulated_bytes: usize = 0;
69 let mut files_read = 0usize;
70 let mut truncated = false;
71
72 for path in paths {
73 let effective_mode = if ctx_read::is_instruction_file(path) {
74 "full"
75 } else {
76 mode
77 };
78 let cache_key = file_read_cache_key(path, effective_mode, crp_mode, task);
79 let cross_agent = (!fresh)
80 .then(|| {
81 crate::core::ocla::cache_delivery::check(
82 &cache_key.cache_key(),
83 &cache_key.validator(),
84 "ctx_multi_read",
85 )
86 })
87 .flatten();
88 let (chunk, cross_agent_original) = if let Some(entry) = cross_agent {
89 (
90 crate::core::ocla::cache_delivery::stub(&entry, "file read"),
91 Some(entry.token_count as usize),
92 )
93 } else {
94 let chunk = if fresh {
95 ctx_read::handle_fresh_with_task(cache, path, effective_mode, crp_mode, task)
96 } else {
97 ctx_read::handle_with_task(cache, path, effective_mode, crp_mode, task)
98 };
99 if !chunk.contains("[cross-agent") {
100 crate::core::ocla::cache_delivery::record(
101 cache_key.cache_key(),
102 crate::core::ocla::cache_types::DeliveryKind::FileRead,
103 cache_key.validator(),
104 Some(cache_key.path.clone()),
105 &chunk,
106 "ctx_multi_read",
107 );
108 }
109 (chunk, None)
110 };
111 let original = cross_agent_original
112 .or_else(|| cache.get(path).map(|entry| entry.original_tokens))
113 .unwrap_or(0);
114 let sent = count_tokens(&chunk);
115 heatmap::record_file_access(path, original, original.saturating_sub(sent));
116 {
121 use crate::core::savings_ledger as ledger;
122 let (lbase, lsaved) =
123 if ledger::ledger_family() == crate::core::tokens::TokenizerFamily::O200kBase {
124 (original, original.saturating_sub(sent))
125 } else if let Some(raw) = cache
126 .get(path)
127 .and_then(crate::core::cache::CacheEntry::content)
128 {
129 let lo = ledger::count_for_ledger(&raw);
130 (lo, lo.saturating_sub(ledger::count_for_ledger(&chunk)))
131 } else {
132 (original, original.saturating_sub(sent))
133 };
134 ledger::record_read_event(lbase, lsaved, None, None);
135 }
136 total_original = total_original.saturating_add(original);
137 total_saved = total_saved.saturating_add(original.saturating_sub(sent));
138
139 let chunk_bytes = chunk.len();
140 if accumulated_bytes > 0 && accumulated_bytes + chunk_bytes > max_bytes {
141 truncated = true;
142 break;
143 }
144 accumulated_bytes += chunk_bytes;
145 sections.push(chunk);
146 files_read += 1;
147 }
148
149 let body = sections.join("\n---\n");
150 let summary = if truncated {
151 let skipped = n - files_read;
152 format!(
153 "Read {files_read}/{n} files | {total_saved} tokens saved\n\
154 ⚠ Output capped at {max_bytes} bytes (LCTX_MAX_MULTI_READ_BYTES). \
155 {skipped} file(s) skipped. Use individual ctx_read calls for remaining files."
156 )
157 } else if total_saved > 0 {
158 format!("Read {n} files | {total_saved} tokens saved")
159 } else {
160 format!("Read {n} files")
161 };
162 MultiReadResult {
163 text: format!("{body}\n---\n{summary}"),
164 original_tokens: total_original,
165 }
166}
167
168fn file_read_cache_key(
169 path: &str,
170 mode: &str,
171 crp_mode: CrpMode,
172 task: Option<&str>,
173) -> FileReadKey {
174 let canonical = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path));
175 let mtime_ns = std::fs::metadata(&canonical)
176 .ok()
177 .and_then(|metadata| metadata.modified().ok())
178 .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
179 .map_or(0, |duration| duration.as_nanos());
180 FileReadKey {
181 path: canonical.to_string_lossy().into_owned(),
182 mtime_ns,
183 mode: mode.into(),
184 crp_mode: format!("{crp_mode:?}").to_ascii_lowercase(),
185 task_digest: blake3::hash(task.unwrap_or_default().as_bytes())
186 .to_hex()
187 .to_string(),
188 policy_rev: env!("CARGO_PKG_VERSION").into(),
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn multi_read_deduplicates_each_file_with_cross_agent_references() {
198 let directory = tempfile::tempdir().unwrap();
199 let first = directory.path().join("first.rs");
200 let second = directory.path().join("second.rs");
201 std::fs::write(&first, "fn first_probe() {}\n").unwrap();
202 std::fs::write(&second, "fn second_probe() {}\n").unwrap();
203 let paths = vec![
204 first.to_string_lossy().into_owned(),
205 second.to_string_lossy().into_owned(),
206 ];
207
208 let mut local = SessionCache::new();
209 let initial =
210 handle_with_task_fresh_result(&mut local, &paths, "full", false, CrpMode::Off, None);
211 assert!(initial.text.contains("first_probe"));
212
213 let mut another_agent = SessionCache::new();
214 let repeated = handle_with_task_fresh_result(
215 &mut another_agent,
216 &paths,
217 "full",
218 false,
219 CrpMode::Off,
220 None,
221 );
222 assert_eq!(repeated.text.matches("[cross-agent cache").count(), 2);
223 assert!(repeated.original_tokens > 0);
224 }
225}