Skip to main content

lean_ctx/core/context_bundle/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Budgeted context bundles for chat products (#1885).
3//!
4//! `lean-ctx pack --limit 128k` produces one self-contained XML document that
5//! fits a chat input box: the files most relevant to the task in full, the
6//! next tier as signatures, and everything else only in the directory tree.
7//! The limit is a hard cap on the emitted document, measured in characters
8//! (what chat UIs count) or `o200k_base` tokens.
9
10pub(crate) mod collect;
11pub mod export;
12pub(crate) mod limit;
13pub(crate) mod rank;
14pub(crate) mod render;
15mod report;
16#[cfg(test)]
17mod tests;
18
19use std::collections::{BTreeMap, HashSet};
20use std::fmt::Write as _;
21use std::path::{Path, PathBuf};
22
23use crate::core::ib::intent::{TaskIntent, classify_text};
24
25pub use limit::{Unit, parse_limit};
26use render::Detail;
27
28pub const DEFAULT_LIMIT: usize = 128_000;
29pub const DEFAULT_KNOWLEDGE_CATEGORIES: &[&str] = &["decision", "architecture", "solution"];
30pub const DEFAULT_KNOWLEDGE_LIMIT: usize = 10;
31
32/// Share of the limit the directory tree may take before it is collapsed.
33const TREE_PERCENT: usize = 15;
34/// Share of the limit project knowledge may take before facts are dropped.
35const KNOWLEDGE_PERCENT: usize = 20;
36/// Largest share of the file budget one file may take in full (the most
37/// relevant file is exempt: it is the one the reader needs most).
38const MAX_FILE_PERCENT: usize = 40;
39/// Withheld paths listed by name in the summary; the rest are counted.
40const MAX_LISTED_WITHHELD: usize = 20;
41/// How the root element of every bundle document opens.
42const DOCUMENT_OPEN: &str = "<bundle generator=\"lean-ctx\"";
43
44/// What to bundle and how big it may get.
45#[derive(Debug, Clone)]
46pub struct BundleOptions {
47    pub root: PathBuf,
48    /// Directory or file to bundle, relative to `root` or absolute inside it.
49    pub scope: Option<PathBuf>,
50    pub limit: usize,
51    pub unit: Unit,
52    /// Task description; `None` or `auto` falls back to the session task.
53    pub intent: Option<String>,
54    pub include: Vec<String>,
55    pub exclude: Vec<String>,
56    /// Knowledge categories to include (`all` for every category).
57    pub knowledge: Option<Vec<String>>,
58    pub knowledge_limit: usize,
59    /// Also include machine-derived `auto:*` facts.
60    pub knowledge_auto: bool,
61    /// Withhold files in which secrets are detected.
62    pub security_check: bool,
63}
64
65impl BundleOptions {
66    pub fn new(root: PathBuf) -> Self {
67        Self {
68            root,
69            scope: None,
70            limit: DEFAULT_LIMIT,
71            unit: Unit::Chars,
72            intent: None,
73            include: Vec::new(),
74            exclude: Vec::new(),
75            knowledge: None,
76            knowledge_limit: DEFAULT_KNOWLEDGE_LIMIT,
77            knowledge_auto: false,
78            security_check: true,
79        }
80    }
81}
82
83/// Where a ranked file ended up.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Placement {
86    Full,
87    Signatures,
88    /// Not included; the reason is shown in the report.
89    Omitted(&'static str),
90}
91
92impl Placement {
93    pub(crate) fn label(self) -> &'static str {
94        match self {
95            Self::Full => "full",
96            Self::Signatures => "signatures",
97            Self::Omitted(_) => "omitted",
98        }
99    }
100}
101
102/// A ranked file and its placement.
103#[derive(Debug, Clone)]
104pub struct PlannedFile {
105    pub path: String,
106    pub score: f64,
107    pub placement: Placement,
108    /// Size of the emitted `<file>` element in the bundle's unit (0 when omitted).
109    pub size: usize,
110}
111
112/// A file that was never a candidate for inclusion.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Withheld {
115    pub path: String,
116    pub reason: String,
117}
118
119/// Where the task description came from.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum IntentSource {
122    Argument,
123    Session,
124    None,
125}
126
127/// A rendered bundle plus everything needed to explain it.
128#[derive(Debug, Clone)]
129pub struct Bundle {
130    pub xml: String,
131    /// Size of `xml` in `unit`.
132    pub size: usize,
133    pub limit: usize,
134    pub unit: Unit,
135    pub intent: TaskIntent,
136    pub intent_text: String,
137    pub intent_source: IntentSource,
138    /// Readable files in rank order.
139    pub files: Vec<PlannedFile>,
140    pub withheld: Vec<Withheld>,
141    pub knowledge_facts: usize,
142    pub walk_truncated: bool,
143}
144
145impl Bundle {
146    pub fn fits(&self) -> bool {
147        self.size <= self.limit
148    }
149
150    pub fn count(&self, label: &str) -> usize {
151        self.files
152            .iter()
153            .filter(|f| f.placement.label() == label)
154            .count()
155    }
156
157    /// Human-readable allocation report.
158    pub fn report(&self) -> String {
159        report::render(self)
160    }
161}
162
163/// Build a bundle. Errors are user-facing (bad path, bad glob).
164///
165/// Sources: every file is read through the rooted admitted reader
166/// (`collect`), so ranking, signatures and file bodies are derived from
167/// admitted text only. The document is NOT a delivery of exactly its sources:
168/// it also carries the task text, the directory tree, the summary and
169/// knowledge facts, it reads more files than it renders, and it can read more
170/// than one source set can name (`archive::authority::MAX_SOURCES`). The
171/// call's source capture is therefore marked incomplete — the candidate's
172/// mark for a mixed output — and no lineage is recorded for the result.
173pub fn build(opts: &BundleOptions) -> Result<Bundle, String> {
174    crate::core::archive::authority::mark_unbound();
175    let root = opts
176        .root
177        .canonicalize()
178        .map_err(|e| format!("project root '{}': {e}", opts.root.display()))?;
179    let scope = resolve_scope(&root, opts.scope.as_deref())?;
180    let root_str = root.to_string_lossy().to_string();
181
182    let mut collection = collect::collect(&root, &scope, &opts.include, &opts.exclude)?;
183    let withheld = screen(&mut collection.candidates, opts.security_check);
184
185    let (intent_text, intent, intent_source) = resolve_intent(opts.intent.as_deref(), &root_str);
186    let readable = collection
187        .candidates
188        .iter()
189        .filter(|c| c.content.is_some())
190        .count();
191    let edges: Vec<(String, String)> = if readable > 1 {
192        crate::core::graph_index::load_or_build(&root_str)
193            .edges
194            .into_iter()
195            .map(|e| (e.from, e.to))
196            .collect()
197    } else {
198        Vec::new()
199    };
200    let changed = changed_files(&root);
201    let ranked = rank::rank(&rank::RankInput {
202        candidates: &collection.candidates,
203        intent_text: &intent_text,
204        intent,
205        edges: &edges,
206        changed: &changed,
207    });
208
209    let measure = |text: &str| opts.unit.measure(text);
210    let facts = match &opts.knowledge {
211        Some(categories) => knowledge_facts(
212            &root_str,
213            categories,
214            opts.knowledge_limit,
215            opts.knowledge_auto,
216        ),
217        None => Vec::new(),
218    };
219    let knowledge = fit_knowledge(&facts, opts.limit * KNOWLEDGE_PERCENT / 100, &measure);
220    let paths: Vec<&str> = collection
221        .candidates
222        .iter()
223        .map(|c| c.path.as_str())
224        .collect();
225    let tree = render::directory_tree(&paths, opts.limit * TREE_PERCENT / 100, measure);
226
227    let frame = Frame {
228        limit: opts.limit,
229        unit: opts.unit,
230        intent,
231        intent_text: &intent_text,
232        tree: &tree,
233        knowledge: &knowledge.0,
234        withheld: &withheld,
235    };
236    let mut slots: Vec<Slot> = ranked
237        .iter()
238        .map(|r| {
239            let candidate = &collection.candidates[r.index];
240            Slot {
241                path: candidate.path.clone(),
242                content: candidate.content.as_deref().unwrap_or_default(),
243                score: r.score,
244                placement: Placement::Omitted("over budget"),
245                full: None,
246                signatures: std::cell::OnceCell::new(),
247            }
248        })
249        .collect();
250
251    let xml = allocate(&frame, &mut slots, &measure);
252    let size = measure(&xml);
253    Ok(Bundle {
254        xml,
255        size,
256        limit: opts.limit,
257        unit: opts.unit,
258        intent,
259        intent_text,
260        intent_source,
261        files: slots
262            .iter()
263            .map(|s| PlannedFile {
264                path: s.path.clone(),
265                score: s.score,
266                placement: s.placement,
267                size: s.block().map_or(0, |(_, size)| size),
268            })
269            .collect(),
270        withheld,
271        knowledge_facts: knowledge.1,
272        walk_truncated: collection.truncated,
273    })
274}
275
276fn resolve_scope(root: &Path, scope: Option<&Path>) -> Result<PathBuf, String> {
277    let Some(scope) = scope else {
278        return Ok(root.to_path_buf());
279    };
280    let joined = if scope.is_absolute() {
281        scope.to_path_buf()
282    } else {
283        root.join(scope)
284    };
285    let resolved = joined
286        .canonicalize()
287        .map_err(|e| format!("path '{}': {e}", scope.display()))?;
288    if !resolved.starts_with(root) {
289        return Err(format!(
290            "path '{}' is outside the project root {}",
291            scope.display(),
292            root.display()
293        ));
294    }
295    Ok(resolved)
296}
297
298/// Record the files that are not sources of the bundle and withhold files
299/// that look like they hold secrets.
300///
301/// This is an additional pass over text the admitted reader already decided
302/// on (`collect`), never the only content check: with `security_check` off
303/// the reader's decision still stands. A file the reader withheld is listed
304/// with the reader's own content-free sentence; its name is shown the way the
305/// read tools show the name of a withheld source, never as an absent file.
306fn screen(candidates: &mut [collect::Candidate], security_check: bool) -> Vec<Withheld> {
307    let custom = if security_check {
308        crate::core::config::Config::load()
309            .secret_detection
310            .custom_patterns
311    } else {
312        Vec::new()
313    };
314    let mut withheld = Vec::new();
315    for candidate in candidates.iter_mut() {
316        if let Some(reason) = candidate.skipped {
317            withheld.push(Withheld {
318                path: candidate.path.clone(),
319                reason: candidate
320                    .refusal
321                    .clone()
322                    .unwrap_or_else(|| reason.to_string()),
323            });
324            continue;
325        }
326        if !security_check {
327            continue;
328        }
329        let Some(content) = candidate.content.as_deref() else {
330            continue;
331        };
332        let matches = crate::core::secret_detection::detect_secrets_with_custom(content, &custom);
333        if matches.is_empty() {
334            continue;
335        }
336        let mut kinds: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
337        for m in &matches {
338            kinds.entry(m.pattern_name).or_default().push(m.line_number);
339        }
340        let detail = kinds
341            .iter()
342            .map(|(kind, lines)| {
343                let lines: Vec<String> = lines.iter().take(3).map(|l| format!("L{l}")).collect();
344                format!("{kind} {}", lines.join(","))
345            })
346            .collect::<Vec<_>>()
347            .join("; ");
348        candidate.content = None;
349        candidate.skipped = Some("possible secret");
350        withheld.push(Withheld {
351            path: candidate.path.clone(),
352            reason: format!("possible secret ({detail})"),
353        });
354    }
355    // Possible secrets first: they are the entries a reader must not miss.
356    withheld.sort_by(|a, b| {
357        let secret = |w: &Withheld| !w.reason.starts_with("possible secret");
358        secret(a).cmp(&secret(b)).then_with(|| a.path.cmp(&b.path))
359    });
360    withheld
361}
362
363fn resolve_intent(arg: Option<&str>, root: &str) -> (String, TaskIntent, IntentSource) {
364    if let Some(text) = arg.map(str::trim)
365        && !text.is_empty()
366        && !text.eq_ignore_ascii_case("auto")
367    {
368        return (
369            text.to_string(),
370            classify_text(text).unwrap_or_default(),
371            IntentSource::Argument,
372        );
373    }
374    // The caller's own task, never the project's latest session's (I07/S03).
375    let task = crate::core::session::caller::with_for_project(root, |session| session.task.clone())
376        .flatten()
377        .filter(|task| !task.description.trim().is_empty());
378    match task {
379        Some(task) => {
380            let intent = task
381                .intent
382                .as_deref()
383                .and_then(classify_text)
384                .or_else(|| classify_text(&task.description))
385                .unwrap_or_default();
386            (
387                task.description.trim().to_string(),
388                intent,
389                IntentSource::Session,
390            )
391        }
392        None => (String::new(), TaskIntent::Unknown, IntentSource::None),
393    }
394}
395
396/// Root-relative paths changed in the working tree (tracked and untracked).
397fn changed_files(root: &Path) -> HashSet<String> {
398    let mut changed = HashSet::new();
399    for args in [
400        &["diff", "--name-only", "--relative", "HEAD"][..],
401        &["ls-files", "--others", "--exclude-standard"][..],
402    ] {
403        if let Some(out) = crate::core::git_util::git_out(root, args) {
404            changed.extend(out.lines().map(str::to_string));
405        }
406    }
407    changed
408}
409
410/// Curated, current, public facts in the requested categories, newest first.
411/// Categories match singular or plural (`decisions` selects `decision`);
412/// machine-derived `auto:*` facts only join when `with_auto` is set.
413fn knowledge_facts(
414    root: &str,
415    categories: &[String],
416    limit: usize,
417    with_auto: bool,
418) -> Vec<String> {
419    let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root) else {
420        return Vec::new();
421    };
422    let wanted: Vec<String> = categories.iter().map(|c| c.trim().to_lowercase()).collect();
423    let all = wanted.iter().any(|c| c == "all");
424    let matches = |category: &str| {
425        let category = category.to_lowercase();
426        wanted
427            .iter()
428            .any(|w| *w == category || w.strip_suffix('s') == Some(category.as_str()))
429    };
430    // The bundle carries fact values into a tool reply or a bundle file: only
431    // facts that may be delivered now enter it
432    // (`ProjectKnowledge::delivery_filter`).
433    let deliverable = knowledge.delivery_filter();
434    let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> = knowledge
435        .facts
436        .iter()
437        .filter(|&f| f.is_current() && deliverable(f))
438        .filter(|f| f.sensitivity == crate::core::sensitivity::SensitivityLevel::Public)
439        .filter(|f| with_auto || !crate::core::knowledge::is_machine_derived(&f.category, &f.key))
440        .filter(|f| all || matches(&f.category))
441        .filter(|f| crate::core::secret_detection::detect_secrets(&f.value).is_empty())
442        .collect();
443    facts.sort_by(|a, b| {
444        b.last_confirmed
445            .cmp(&a.last_confirmed)
446            .then_with(|| a.category.cmp(&b.category))
447            .then_with(|| a.key.cmp(&b.key))
448    });
449    facts
450        .into_iter()
451        .take(limit)
452        .map(|f| render::fact_block(&f.category, &f.key, &f.value))
453        .collect()
454}
455
456/// The `<knowledge>` section and how many facts it holds, trimmed to `max`.
457fn fit_knowledge(
458    facts: &[String],
459    max: usize,
460    measure: &impl Fn(&str) -> usize,
461) -> (String, usize) {
462    let mut kept = facts.len();
463    while kept > 0 {
464        let section = format!("<knowledge>\n{}</knowledge>\n", facts[..kept].concat());
465        if measure(&section) <= max {
466            return (section, kept);
467        }
468        kept -= 1;
469    }
470    (String::new(), 0)
471}
472
473/// Everything in the bundle except the files.
474struct Frame<'a> {
475    limit: usize,
476    unit: Unit,
477    intent: TaskIntent,
478    intent_text: &'a str,
479    tree: &'a str,
480    knowledge: &'a str,
481    withheld: &'a [Withheld],
482}
483
484struct Slot<'a> {
485    path: String,
486    content: &'a str,
487    score: f64,
488    placement: Placement,
489    /// Rendered block and its size, computed on first use.
490    full: Option<(String, usize)>,
491    /// Signature block; the inner `None` means the file has no signatures.
492    signatures: std::cell::OnceCell<Option<(String, usize)>>,
493}
494
495impl Slot<'_> {
496    fn full(&mut self, measure: &impl Fn(&str) -> usize) -> usize {
497        if self.full.is_none() {
498            let block = render::file_block(&self.path, Detail::Full, self.content);
499            let size = measure(&block);
500            self.full = Some((block, size));
501        }
502        self.full.as_ref().map_or(0, |(_, size)| *size)
503    }
504
505    fn signatures(&self, measure: &impl Fn(&str) -> usize) -> Option<usize> {
506        self.signatures
507            .get_or_init(|| {
508                let ext = Path::new(&self.path)
509                    .extension()
510                    .and_then(|e| e.to_str())
511                    .unwrap_or_default();
512                let sigs = crate::core::signatures::extract_signatures(self.content, ext);
513                (!sigs.is_empty()).then(|| {
514                    let body: Vec<String> = sigs
515                        .iter()
516                        .map(crate::core::signatures::Signature::to_compact_located)
517                        .collect();
518                    let block =
519                        render::file_block(&self.path, Detail::Signatures, &body.join("\n"));
520                    let size = measure(&block);
521                    (block, size)
522                })
523            })
524            .as_ref()
525            .map(|(_, size)| *size)
526    }
527
528    fn block(&self) -> Option<(&str, usize)> {
529        let rendered = match self.placement {
530            Placement::Full => self.full.as_ref(),
531            Placement::Signatures => self.signatures.get().and_then(Option::as_ref),
532            Placement::Omitted(_) => None,
533        };
534        rendered.map(|(block, size)| (block.as_str(), *size))
535    }
536}
537
538/// Place files greedily by rank, then shrink until the whole document fits.
539fn allocate(frame: &Frame<'_>, slots: &mut [Slot<'_>], measure: &impl Fn(&str) -> usize) -> String {
540    // Upper bound for the summary: every count at its maximum width.
541    let overhead = measure(&assemble(frame, slots, slots.len()));
542    let budget = frame.limit.saturating_sub(overhead);
543    let per_file_cap = budget * MAX_FILE_PERCENT / 100;
544    let mut remaining = budget;
545    for (rank, slot) in slots.iter_mut().enumerate() {
546        if remaining == 0 {
547            break;
548        }
549        let full = slot.full(measure);
550        if full <= remaining && (rank == 0 || full <= per_file_cap) {
551            slot.placement = Placement::Full;
552            remaining -= full;
553            continue;
554        }
555        match slot.signatures(measure) {
556            Some(size) if size <= remaining => {
557                slot.placement = Placement::Signatures;
558                remaining -= size;
559            }
560            Some(_) => slot.placement = Placement::Omitted("over budget"),
561            None => slot.placement = Placement::Omitted("over budget; no signatures"),
562        }
563    }
564
565    // Token counts are not strictly additive and the summary estimate is a
566    // bound, so measure the real document and shed the lowest-ranked files.
567    loop {
568        let xml = assemble(frame, slots, 0);
569        let size = measure(&xml);
570        if size <= frame.limit {
571            return xml;
572        }
573        let mut excess = size - frame.limit;
574        let mut changed = false;
575        for slot in slots.iter_mut().rev() {
576            if excess == 0 {
577                break;
578            }
579            match slot.placement {
580                Placement::Full => {
581                    let full = slot.full(measure);
582                    match slot.signatures(measure) {
583                        Some(sig) if sig < full => {
584                            slot.placement = Placement::Signatures;
585                            excess = excess.saturating_sub(full - sig);
586                        }
587                        _ => {
588                            slot.placement = Placement::Omitted("over budget");
589                            excess = excess.saturating_sub(full);
590                        }
591                    }
592                    changed = true;
593                }
594                Placement::Signatures => {
595                    let sig = slot.signatures(measure).unwrap_or(0);
596                    slot.placement = Placement::Omitted("over budget");
597                    excess = excess.saturating_sub(sig);
598                    changed = true;
599                }
600                Placement::Omitted(_) => {}
601            }
602        }
603        if !changed {
604            return xml;
605        }
606    }
607}
608
609/// Render the document. `pad_counts` > 0 renders the summary as if every
610/// count were that large, giving a size bound before allocation.
611fn assemble(frame: &Frame<'_>, slots: &[Slot<'_>], pad_counts: usize) -> String {
612    let mut out = String::new();
613    let _ = writeln!(
614        out,
615        "{DOCUMENT_OPEN} limit=\"{}\" unit=\"{}\" intent=\"{}\">",
616        frame.limit, frame.unit, frame.intent
617    );
618    if !frame.intent_text.is_empty() {
619        let _ = writeln!(out, "<task>{}</task>", render::escape(frame.intent_text));
620    }
621    out.push_str(&summary(frame, slots, pad_counts));
622    out.push_str("<directory_structure>\n");
623    out.push_str(frame.tree);
624    out.push_str("</directory_structure>\n");
625    out.push_str(frame.knowledge);
626    out.push_str("<files>\n");
627    if pad_counts == 0 {
628        for slot in slots {
629            if let Some((block, _)) = slot.block() {
630                out.push_str(block);
631            }
632        }
633    }
634    out.push_str("</files>\n</bundle>\n");
635    out
636}
637
638fn summary(frame: &Frame<'_>, slots: &[Slot<'_>], pad_counts: usize) -> String {
639    let count = |label: &str| {
640        if pad_counts > 0 {
641            pad_counts
642        } else {
643            slots
644                .iter()
645                .filter(|s| s.placement.label() == label)
646                .count()
647        }
648    };
649    let mut out = String::from("<summary>\n");
650    let _ = writeln!(
651        out,
652        "Context bundle for a {} {} input limit. Files are ordered by relevance to the task.",
653        frame.limit, frame.unit
654    );
655    let _ = writeln!(
656        out,
657        "Files: {} full, {} signatures only (declarations with line numbers), {} omitted to fit the limit, {} withheld.",
658        count("full"),
659        count("signatures"),
660        count("omitted"),
661        frame.withheld.len()
662    );
663    out.push_str(
664        "Omitted and withheld files appear only in the directory structure; ask for them by path.\n",
665    );
666    if !frame.withheld.is_empty() {
667        out.push_str("Withheld:\n");
668        for w in frame.withheld.iter().take(MAX_LISTED_WITHHELD) {
669            let _ = writeln!(
670                out,
671                "- {}: {}",
672                render::escape(&w.path),
673                render::escape(&w.reason)
674            );
675        }
676        if frame.withheld.len() > MAX_LISTED_WITHHELD {
677            let _ = writeln!(
678                out,
679                "- and {} more",
680                frame.withheld.len() - MAX_LISTED_WITHHELD
681            );
682        }
683    }
684    out.push_str("</summary>\n");
685    out
686}