Initial Slug Code Rust implementation
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>
This commit is contained in:
parent
f2e1d53e37
commit
b8bf9029fe
21 changed files with 6280 additions and 0 deletions
141
src/slugmd/mod.rs
Normal file
141
src/slugmd/mod.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue