fix: am ai avec aichat >= 0.30 — rôles index.yaml + agents.txt, contexte filtré, generation --code (v1.1.5)
- roles.rs: aichat 0.30 a remplace les agents markdown agents/<name>.md
par functions/agents/<name>/index.yaml + registre functions/agents.txt;
generate_all ecrit les DEUX formats (compat <= 0.29 et >= 0.30)
- ai_cmd.rs: role_args detecte la definition index.yaml en premier
('Unknown agent am-copilot' corrige) ; contexte par defaut (dossier
courant) filtre par am (.git, target, node_modules, binaires non-UTF-8,
.env*, > 64 KiB, budget 128 KiB, 40 fichiers max) — aichat ne filtre
rien et echouait sur .git/index ; exec mode utilise le role am-do
par defaut (commande brute)
- shell_ai.rs: generation via --code au lieu de --execute --dry-run
(aichat 0.30: --dry-run echo la requete sans appeler l'API)
- version 1.1.5, man pages et ROADMAP maj
This commit is contained in:
+108
-13
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! Conversational mode (no `--exec`) spawns AIChat with the prompt and the
|
||||
//! context files (default: the current directory). Exec mode generates the
|
||||
//! shell command through aichat in dry-run, classifies it, applies the
|
||||
//! safety policy (settings.shell_ai, dry-run by default) and only executes
|
||||
//! after confirmation when required.
|
||||
//! shell command through aichat (`--code` — never executed by aichat),
|
||||
//! classifies it, applies the safety policy (settings.shell_ai, dry-run by
|
||||
//! default) and only executes after confirmation when required.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::AiArgs;
|
||||
@@ -45,9 +45,11 @@ pub fn run(app: &App, args: &AiArgs) -> Result<i32> {
|
||||
|
||||
let prompt = args.prompt.join(" ");
|
||||
// Context files: explicit --files wins, else the current directory
|
||||
// (issue #96: `aichat -f . "<prompt>"`).
|
||||
// (issue #96: `aichat -f . "<prompt>"`). The default directory is
|
||||
// walked by am to skip noise/vendored/binary files — aichat itself
|
||||
// filters nothing and fails on non-UTF-8 content (e.g. .git/index).
|
||||
let files: Vec<String> = if args.files.is_empty() {
|
||||
vec![".".to_string()]
|
||||
context_files(std::path::Path::new("."))
|
||||
} else {
|
||||
args.files
|
||||
.iter()
|
||||
@@ -75,23 +77,92 @@ fn aichat_installed(app: &App) -> bool {
|
||||
|| which::which("aichat").is_ok()
|
||||
}
|
||||
|
||||
/// Walk `dir` and collect text files usable as aichat context (the
|
||||
/// default `--files` when the flag is absent). aichat filters nothing
|
||||
/// and fails on non-UTF-8 content (e.g. `.git/index`), so am pre-filters:
|
||||
/// - skipped directories: `.git`, vendored/build noise (`target`,
|
||||
/// `node_modules`, `dist`, `build`, `__pycache__`, `vendor`…);
|
||||
/// - skipped files: `.env*` (secrets), > 64 KiB, invalid UTF-8.
|
||||
/// Then the survivors are sorted and taken while a total budget of
|
||||
/// ~128 KiB holds (≈32K tokens — fits common 64K-token models), capped
|
||||
/// at 40 files. Empty when nothing qualifies (aichat is called without
|
||||
/// `-f`).
|
||||
fn context_files(dir: &std::path::Path) -> Vec<String> {
|
||||
const MAX_FILES: usize = 40;
|
||||
const MAX_SIZE: u64 = 64 * 1024;
|
||||
const TOTAL_BUDGET: u64 = 128 * 1024;
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
".git", "target", "node_modules", "dist", "build", "__pycache__", ".venv", "venv",
|
||||
".idea", ".vscode", ".next", ".cache", "vendor", ".terraform", "coverage", ".gradle",
|
||||
".cargo", "obj", "out", ".svn", ".hg",
|
||||
];
|
||||
fn walk(dir: &std::path::Path, out: &mut Vec<(String, u64)>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if path.is_dir() {
|
||||
if SKIP_DIRS.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
walk(&path, out);
|
||||
} else {
|
||||
if name.starts_with(".env") {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.len() > MAX_SIZE {
|
||||
continue;
|
||||
}
|
||||
// UTF-8 sniff on the first bytes — aichat rejects binaries.
|
||||
let Ok(mut f) = std::fs::File::open(&path) else { continue };
|
||||
use std::io::Read;
|
||||
let mut head = Vec::new();
|
||||
if f.take(8192).read_to_end(&mut head).is_err()
|
||||
|| std::str::from_utf8(&head).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push((path.display().to_string(), meta.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
walk(dir, &mut found);
|
||||
found.sort();
|
||||
let mut files = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
for (p, len) in found {
|
||||
if files.len() >= MAX_FILES || total + len > TOTAL_BUDGET {
|
||||
break;
|
||||
}
|
||||
total += len;
|
||||
files.push(p);
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// Build the aichat `--agent` arguments for the requested role. Without a
|
||||
/// `--role` flag the am-copilot role is used by default (v1.1.2): when its
|
||||
/// file is missing the roles are regenerated on the fly, and a generation
|
||||
/// failure degrades gracefully (no role). An explicit unknown role is an
|
||||
/// error.
|
||||
/// definition is missing the roles are regenerated on the fly, and a
|
||||
/// generation failure degrades gracefully (no role). An explicit unknown
|
||||
/// role is an error.
|
||||
fn role_args(app: &App, role: Option<&str>) -> Result<Vec<String>> {
|
||||
let role = role.unwrap_or("am-copilot");
|
||||
let dir = crate::roles::aichat_agents_dir();
|
||||
let path = dir.join(format!("{role}.md"));
|
||||
if path.exists() {
|
||||
// aichat >= 0.30 reads functions/agents/<name>/index.yaml; the legacy
|
||||
// agents/<name>.md covers aichat <= 0.29 (roles.rs writes both).
|
||||
let def = crate::roles::aichat_agent_def_dir(role).join("index.yaml");
|
||||
let legacy = crate::roles::aichat_agents_dir().join(format!("{role}.md"));
|
||||
let present = || def.exists() || legacy.exists();
|
||||
if present() {
|
||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||
}
|
||||
if role == "am-copilot" {
|
||||
if let Err(e) = crate::roles::generate_all(app) {
|
||||
app.log.verbose(&format!("roles generation skipped: {e:#}"));
|
||||
}
|
||||
if path.exists() {
|
||||
if present() {
|
||||
return Ok(vec!["--agent".to_string(), role.to_string()]);
|
||||
}
|
||||
app.log.warn(crate::i18n::tr_in(
|
||||
@@ -171,7 +242,9 @@ fn exec_mode(
|
||||
"would generate & classify via aichat (exec) — commande non exécutée"
|
||||
));
|
||||
}
|
||||
let role = role_args(app, args.role.as_deref())?;
|
||||
// Exec mode asks for the raw command: default to the am-do role
|
||||
// ("Réponds UNIQUEMENT par la commande") unless --role is explicit.
|
||||
let role = role_args(app, args.role.as_deref().or(Some("am-do")))?;
|
||||
let cmd = crate::shell_ai::generate(
|
||||
app,
|
||||
prompt,
|
||||
@@ -256,4 +329,26 @@ mod tests {
|
||||
let s = crate::config::ShellAiSettings::default();
|
||||
assert_eq!(s.safety_mode(), SafetyMode::DryRun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_files_skips_binary_noise_and_secrets() {
|
||||
let base = std::env::temp_dir().join(format!("am-ai-ctx-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(base.join("target")).unwrap();
|
||||
std::fs::create_dir_all(base.join("src")).unwrap();
|
||||
std::fs::write(base.join("src/a.txt"), "hello").unwrap();
|
||||
std::fs::write(base.join("src/b.md"), "# titre").unwrap();
|
||||
std::fs::write(base.join(".env"), "TOKEN=secret").unwrap();
|
||||
std::fs::write(base.join("bin.dat"), [0u8, 159, 146, 150]).unwrap();
|
||||
std::fs::write(base.join(".git/index"), "binary-ish").unwrap();
|
||||
std::fs::write(base.join("target/x.rs"), "fn main(){}").unwrap();
|
||||
let files = context_files(&base);
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a.txt", "b.md"], "{files:?}");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user