1use std::path::{Path, PathBuf};
16
17use anyhow::Result;
18use serde::{Deserialize, Serialize};
19
20use crate::core::bm25_index::BM25Index;
21use crate::core::compressor::aggressive_compress;
22use crate::core::tokens::count_tokens;
23
24use super::sha256_hex;
25
26pub const DEFAULT_BUDGET_TOKENS: usize = 4000;
28
29const MAX_FILE_BYTES: u64 = 256 * 1024;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Condition {
36 Baseline,
38 LeanCtx,
40 JsonCrush,
46}
47
48impl Condition {
49 pub fn label(self) -> &'static str {
51 match self {
52 Condition::Baseline => "baseline",
53 Condition::LeanCtx => "lean_ctx",
54 Condition::JsonCrush => "json_crush",
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct AssembledContext {
62 pub text: String,
64 pub tokens: usize,
66 pub files: usize,
68 pub digest: String,
70}
71
72pub fn assemble(
74 condition: Condition,
75 workspace: &Path,
76 query: &str,
77 budget: usize,
78) -> Result<AssembledContext> {
79 let entries = match condition {
80 Condition::Baseline => baseline_entries(workspace),
81 Condition::LeanCtx => lean_ctx_entries(workspace, query),
82 Condition::JsonCrush => json_crush_entries(workspace, query),
83 };
84 Ok(pack(&entries, budget))
85}
86
87fn baseline_entries(workspace: &Path) -> Vec<(String, String)> {
89 let mut files = gather_text_files(workspace);
90 files.sort_by(|a, b| a.0.cmp(&b.0));
91 files
92}
93
94fn lean_ctx_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
96 ranked_entries(workspace, query, |content, ext| {
97 aggressive_compress(content, ext)
98 })
99}
100
101fn json_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
105 ranked_entries(workspace, query, |content, ext| match ext {
106 Some("json" | "jsonl") => crate::core::json_crush::crush_text_if_beneficial(content)
107 .unwrap_or_else(|| aggressive_compress(content, ext)),
108 _ => aggressive_compress(content, ext),
109 })
110}
111
112fn ranked_entries(
116 workspace: &Path,
117 query: &str,
118 render: impl Fn(&str, Option<&str>) -> String,
119) -> Vec<(String, String)> {
120 let index = BM25Index::build_from_directory(workspace);
121 let ranked = index.search(query, 256);
122 let mut out = Vec::new();
123 let mut seen = std::collections::HashSet::new();
124 for result in ranked {
125 if !seen.insert(result.file_path.clone()) {
126 continue;
127 }
128 let path = resolve(workspace, &result.file_path);
129 let Ok(content) = std::fs::read_to_string(&path) else {
130 continue;
131 };
132 let ext = path.extension().and_then(|e| e.to_str());
133 out.push((rel_label(workspace, &path), render(&content, ext)));
134 }
135 if out.is_empty() {
136 return baseline_entries(workspace);
137 }
138 out
139}
140
141fn resolve(root: &Path, file_path: &str) -> PathBuf {
143 let p = Path::new(file_path);
144 if p.is_absolute() {
145 p.to_path_buf()
146 } else {
147 root.join(p)
148 }
149}
150
151fn rel_label(root: &Path, path: &Path) -> String {
153 path.strip_prefix(root)
154 .unwrap_or(path)
155 .to_string_lossy()
156 .into_owned()
157}
158
159fn gather_text_files(root: &Path) -> Vec<(String, String)> {
162 let mut out = Vec::new();
163 let walker = ignore::WalkBuilder::new(root)
164 .hidden(true)
165 .git_ignore(true)
166 .require_git(false)
167 .filter_entry(crate::core::walk_filter::keep_entry)
168 .build();
169 for entry in walker.flatten() {
170 if !entry.file_type().is_some_and(|t| t.is_file()) {
171 continue;
172 }
173 let path = entry.path();
174 if entry.metadata().map_or(u64::MAX, |m| m.len()) > MAX_FILE_BYTES {
175 continue;
176 }
177 if let Ok(content) = std::fs::read_to_string(path) {
178 out.push((rel_label(root, path), content));
179 }
180 }
181 out
182}
183
184fn pack(entries: &[(String, String)], budget: usize) -> AssembledContext {
187 let mut text = String::new();
188 let mut running = 0usize;
189 let mut files = 0usize;
190 for (label, content) in entries {
191 let block = format!("// file: {label}\n{content}\n\n");
192 let cost = count_tokens(&block);
193 if running > 0 && running + cost > budget {
194 continue;
195 }
196 text.push_str(&block);
197 running += cost;
198 files += 1;
199 if running >= budget {
200 break;
201 }
202 }
203 let capped = truncate_to_tokens(&text, budget);
204 let tokens = count_tokens(&capped);
205 let digest = sha256_hex(capped.as_bytes());
206 AssembledContext {
207 text: capped,
208 tokens,
209 files,
210 digest,
211 }
212}
213
214fn truncate_to_tokens(text: &str, budget: usize) -> String {
216 if count_tokens(text) <= budget {
217 return text.to_string();
218 }
219 let chars: Vec<char> = text.chars().collect();
220 let (mut lo, mut hi) = (0usize, chars.len());
221 while lo < hi {
222 let mid = lo + (hi - lo).div_ceil(2);
223 let candidate: String = chars[..mid].iter().collect();
224 if count_tokens(&candidate) <= budget {
225 lo = mid;
226 } else {
227 hi = mid - 1;
228 }
229 }
230 chars[..lo].iter().collect()
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::fs;
237
238 fn workspace() -> tempfile::TempDir {
239 let dir = tempfile::tempdir().unwrap();
240 fs::write(
241 dir.path().join("relevant.md"),
242 "The consolidation pipeline persists to bm25, graph, knowledge and session stores.",
243 )
244 .unwrap();
245 fs::write(
246 dir.path().join("noise.md"),
247 "Lorem ipsum dolor sit amet, totally unrelated filler content about cats and weather.",
248 )
249 .unwrap();
250 dir
251 }
252
253 #[test]
254 fn conditions_produce_distinct_digests() {
255 let ws = workspace();
256 let a = assemble(Condition::Baseline, ws.path(), "consolidation stores", 4000).unwrap();
257 let b = assemble(Condition::LeanCtx, ws.path(), "consolidation stores", 4000).unwrap();
258 assert!(a.tokens > 0 && b.tokens > 0);
259 assert_ne!(a.digest, b.digest, "raw vs compressed must differ");
260 }
261
262 #[test]
263 fn assembly_is_deterministic() {
264 let ws = workspace();
265 let first = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
266 let second = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
267 assert_eq!(first.digest, second.digest);
268 }
269
270 #[test]
271 fn json_crush_condition_beats_baseline_and_is_deterministic() {
272 let dir = tempfile::tempdir().unwrap();
273 let rows: Vec<String> = (0..30)
274 .map(|i| {
275 format!(
276 "{{\"id\":{i},\"role\":\"operator\",\"status\":\"active\",\"region\":\"emea\"}}"
277 )
278 })
279 .collect();
280 fs::write(
281 dir.path().join("roster.json"),
282 format!("[{}]", rows.join(",")),
283 )
284 .unwrap();
285
286 let crushed = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
287 let baseline = assemble(Condition::Baseline, dir.path(), "roster operator", 4000).unwrap();
288 assert!(
289 crushed.tokens < baseline.tokens,
290 "crush {} must beat baseline {}",
291 crushed.tokens,
292 baseline.tokens
293 );
294
295 let again = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
296 assert_eq!(
297 crushed.digest, again.digest,
298 "json_crush assembly is deterministic"
299 );
300 }
301
302 #[test]
303 fn budget_is_respected() {
304 let ws = workspace();
305 let ctx = assemble(Condition::Baseline, ws.path(), "x", 12).unwrap();
306 assert!(ctx.tokens <= 12, "got {} tokens", ctx.tokens);
307 }
308
309 #[test]
310 fn truncate_caps_tokens() {
311 let long = "word ".repeat(5000);
312 let capped = truncate_to_tokens(&long, 50);
313 assert!(count_tokens(&capped) <= 50);
314 }
315}