use crate::config::manifest::{OverflowPolicy, RoleRules};
use crate::prompt::Error;
use std::path::Path;
const BYTES_PER_TOKEN: u64 = 4;
const TRANSCRIPT_DIR: &str = "messages";
const SYSTEM_SLOT: &[&str] = &["goal.md", "name", "soul.md"];
const TOOLS_DESC_DIR: &str = "descriptions/tools";
const SKILLS_DESC_PREFIX: &str = "descriptions/skills/";
const SUMMARY_PREFIX: &str = "summary/";
struct Entry {
path: String,
content: String,
}
impl Entry {
fn tokens(&self) -> u64 {
(self.content.len() as u64).div_ceil(BYTES_PER_TOKEN)
}
}
pub(super) fn compose(worktree: &Path, rules: Option<&RoleRules>) -> Result<Vec<String>, Error> {
let Some(rules) = rules else {
return Ok(Vec::new());
};
let files = walk(worktree)?;
let mut taken = vec![false; files.len()];
let head = select(worktree, &rules.pinned, &files, &mut taken)?;
let body = select(worktree, &rules.order, &files, &mut taken)?;
let spent: u64 = head.iter().map(Entry::tokens).sum();
let allowance = u64::from(rules.budget_tokens).saturating_sub(spent);
let body = fit(body, allowance, rules.overflow);
Ok(head.into_iter().chain(body).map(render).collect())
}
fn render(e: Entry) -> String {
format!("<file path=\"{}\">\n{}\n</file>", e.path, e.content)
}
fn select(
worktree: &Path,
patterns: &[String],
files: &[String],
taken: &mut [bool],
) -> Result<Vec<Entry>, Error> {
let mut out = Vec::new();
for pattern in patterns {
for (i, file) in files.iter().enumerate() {
if taken[i] || !glob_match(pattern, file) {
continue;
}
taken[i] = true;
let bytes = std::fs::read(worktree.join(file)).map_err(Error::Io)?;
let content = String::from_utf8_lossy(&bytes).into_owned();
if marked_summary(file, &content) {
return Err(Error::SummaryConflictMarkers { path: file.clone() });
}
out.push(Entry {
path: file.clone(),
content,
});
}
}
Ok(out)
}
fn marked_summary(path: &str, content: &str) -> bool {
path.starts_with(SUMMARY_PREFIX)
&& content
.lines()
.any(|l| l.starts_with("<<<<<<< ") || l.starts_with(">>>>>>> "))
}
fn fit(body: Vec<Entry>, allowance: u64, policy: OverflowPolicy) -> Vec<Entry> {
let total: u64 = body.iter().map(Entry::tokens).sum();
if total <= allowance {
return body;
}
match policy {
OverflowPolicy::DropOldestSummaries => drop_oldest_summaries(body, allowance),
OverflowPolicy::Truncate => cut(body, allowance, true),
OverflowPolicy::Drop => cut(body, allowance, false),
}
}
fn drop_oldest_summaries(mut body: Vec<Entry>, allowance: u64) -> Vec<Entry> {
let mut total: u64 = body.iter().map(Entry::tokens).sum();
while total > allowance {
let oldest = body
.iter()
.enumerate()
.filter(|(_, e)| e.path.starts_with(SUMMARY_PREFIX))
.min_by(|a, b| a.1.path.cmp(&b.1.path))
.map(|(i, _)| i);
let Some(i) = oldest else {
break;
};
total -= body[i].tokens();
body.remove(i);
}
body
}
fn cut(body: Vec<Entry>, allowance: u64, truncate: bool) -> Vec<Entry> {
let mut kept = Vec::new();
let mut remaining = allowance;
for mut e in body {
let t = e.tokens();
if t <= remaining {
remaining -= t;
kept.push(e);
continue;
}
if truncate && remaining > 0 {
let mut n = usize::try_from(remaining * BYTES_PER_TOKEN).expect("fits: n < len");
while !e.content.is_char_boundary(n) {
n -= 1;
}
e.content.truncate(n);
kept.push(e);
}
break;
}
kept
}
fn walk(worktree: &Path) -> Result<Vec<String>, Error> {
let mut out = Vec::new();
descend(worktree, worktree, "", &mut out)?;
out.sort();
Ok(out)
}
fn descend(worktree: &Path, dir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<(), Error> {
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(Error::Io(e)),
};
for entry in rd {
let entry = entry.map_err(Error::Io)?;
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
if skip(worktree, &rel) {
continue;
}
if entry.file_type().map_err(Error::Io)?.is_dir() {
descend(worktree, &entry.path(), &rel, out)?;
} else {
out.push(rel);
}
}
Ok(())
}
fn skip(worktree: &Path, rel: &str) -> bool {
rel == ".git"
|| rel == TRANSCRIPT_DIR
|| rel == TOOLS_DESC_DIR
|| SYSTEM_SLOT.contains(&rel)
|| tool_backed(worktree, rel)
}
fn tool_backed(worktree: &Path, rel: &str) -> bool {
let stem = rel
.strip_prefix(SKILLS_DESC_PREFIX)
.and_then(|f| f.strip_suffix(".md"));
let Some(name) = stem else { return false };
let schema = format!("{name}.json");
worktree.join(TOOLS_DESC_DIR).join(schema).exists()
}
mod glob;
use glob::glob_match;
#[cfg(test)]
mod tests;