Core features: - OpenAI-compatible streaming provider (vLLM, Ollama, OpenAI, etc.) - Agent loop with tool use (bash, read, write, edit, glob, grep) - Permission system: ask/yolo/sandbox/allowEdits + glob patterns - SLUG.md hierarchy loaded every turn (CLAUDE.md equivalent) - Session persistence with --continue/--resume/--fork-session - Hook system: 5 lifecycle events, command + prompt types - Compaction: ToolResultTrim/Truncate strategies, /compact command - Config via TOML, CLI args, env vars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
4.6 KiB
Rust
141 lines
4.6 KiB
Rust
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
const MAX_CHARS: usize = 40_000;
|
|
|
|
/// Read a file and return its trimmed contents, or None if the file doesn't exist.
|
|
fn read_file_optional(path: &PathBuf) -> Option<String> {
|
|
match fs::read_to_string(path) {
|
|
Ok(contents) => {
|
|
let trimmed = contents.trim().to_string();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed)
|
|
}
|
|
}
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Load all SLUG.md config files in priority order and return a concatenated string.
|
|
///
|
|
/// Priority (later overrides earlier):
|
|
/// 1. ~/.slug/SLUG.md — global user preferences
|
|
/// 2. ./SLUG.md — project-level (in working directory)
|
|
/// 3. .slug/rules/*.md — modular rule files in the project
|
|
/// 4. SLUG.local.md — private notes (gitignored)
|
|
///
|
|
/// Returns an empty string if no config files are found.
|
|
/// Truncates with a warning if the combined content exceeds 40,000 characters.
|
|
pub fn load_slug_context() -> String {
|
|
let mut sections: Vec<(String, String)> = Vec::new();
|
|
|
|
// 1. Global user preferences: ~/.slug/SLUG.md
|
|
if let Some(home_dir) = dirs::home_dir() {
|
|
let global_path = home_dir.join(".slug").join("SLUG.md");
|
|
if let Some(content) = read_file_optional(&global_path) {
|
|
sections.push(("# Global Rules (~/.slug/SLUG.md)".to_string(), content));
|
|
}
|
|
}
|
|
|
|
// 2. Project-level: ./SLUG.md
|
|
let project_path = PathBuf::from("SLUG.md");
|
|
if let Some(content) = read_file_optional(&project_path) {
|
|
sections.push(("# Project Rules (SLUG.md)".to_string(), content));
|
|
}
|
|
|
|
// 3. Modular rule files: .slug/rules/*.md
|
|
let rules_dir = PathBuf::from(".slug/rules");
|
|
if rules_dir.is_dir() {
|
|
let mut rule_files: Vec<PathBuf> = match fs::read_dir(&rules_dir) {
|
|
Ok(entries) => entries
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.path())
|
|
.filter(|p| p.extension().map(|ext| ext == "md").unwrap_or(false))
|
|
.collect(),
|
|
Err(_) => Vec::new(),
|
|
};
|
|
// Sort for deterministic ordering
|
|
rule_files.sort();
|
|
|
|
for rule_file in rule_files {
|
|
if let Some(content) = read_file_optional(&rule_file) {
|
|
let filename = rule_file
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("unknown");
|
|
let header = format!("# Project Rule (.slug/rules/{filename})");
|
|
sections.push((header, content));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Private local notes: ./SLUG.local.md
|
|
let local_path = PathBuf::from("SLUG.local.md");
|
|
if let Some(content) = read_file_optional(&local_path) {
|
|
sections.push((
|
|
"# Local Notes (SLUG.local.md)".to_string(),
|
|
content,
|
|
));
|
|
}
|
|
|
|
if sections.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
// Concatenate sections with headers
|
|
let mut result = String::new();
|
|
for (header, content) in §ions {
|
|
if !result.is_empty() {
|
|
result.push('\n');
|
|
}
|
|
result.push_str(header);
|
|
result.push('\n');
|
|
result.push_str(content);
|
|
result.push('\n');
|
|
}
|
|
|
|
// Enforce character budget
|
|
if result.len() > MAX_CHARS {
|
|
result.truncate(MAX_CHARS);
|
|
// Try to truncate at a clean line boundary
|
|
if let Some(last_newline) = result.rfind('\n') {
|
|
result.truncate(last_newline + 1);
|
|
}
|
|
result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n");
|
|
eprintln!(
|
|
"Warning: SLUG.md context exceeded {MAX_CHARS} characters and was truncated."
|
|
);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_load_slug_context_no_files() {
|
|
// In a directory without any SLUG files, should return empty string
|
|
// (This test is environment-dependent — it passes when run from a clean dir)
|
|
let result = load_slug_context();
|
|
// Just verify it doesn't panic and returns a String
|
|
let _ = result.len();
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncation_logic() {
|
|
let long_content = "x".repeat(MAX_CHARS + 100);
|
|
let mut result = long_content;
|
|
if result.len() > MAX_CHARS {
|
|
result.truncate(MAX_CHARS);
|
|
if let Some(last_newline) = result.rfind('\n') {
|
|
result.truncate(last_newline + 1);
|
|
}
|
|
result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n");
|
|
}
|
|
assert!(result.contains("truncated"));
|
|
}
|
|
}
|