Axe 11 (modèles) : - am models : inventaire des runtimes locaux (ollama list --format json, llama.cpp .gguf sur disque, LM Studio API locale) avec cache façon probe.rs (TTL 30s) et absence de runtime signalée sans erreur fatale - am run <agent> --model <m> : résolution dans l'inventaire, transmission via model_env/model_arg de l'agent (défaut AM_MODEL), tracé en session - am models --prune : candidats inutilisés depuis settings.models_prune_days (défaut 30), :latest toujours protégé, dry-run + confirmation + événement EventKind::Model journalisé Axes 7/9 (catalogue) : - am catalog update : fetch du catalogue officiel, diff (ajoutés/retirés/ modifiés), confirmation, enregistrement en include - am catalog add <url> : validation du format (parse + validate), cache catalog-cache.json (TTL 3600s), événement EventKind::Catalog journalisé - include par URL (http/https/file) dans config.yaml, résolu au chargement avec cache + TTL ; override local toujours prioritaire (issue #67) - am suggest <requête> : réutilise le scoring fuzzy du catalogue, boost par usage réel (lancements + durée depuis le journal), top 5 justifié, --json REPL : commandes models/catalog/suggest (parse, complétion, descriptions), help specs, tip cheat sheet, man pages régénérées (49 pages, dont 3 nouvelles). Version bump 0.4.5, ROADMAP cases cochées, 266 tests verts.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
//! catalog: manage remote catalogs (issues #60 #67, axes 7 & 9).
|
||||
//! 'am catalog update' refreshes the official catalog and applies it as an
|
||||
//! include; 'am catalog add <url>' registers any external catalog (same
|
||||
//! YAML format) the same way. Everything goes through the probe-style
|
||||
//! cache in catalog_remote so offline runs keep working.
|
||||
|
||||
use super::*;
|
||||
use crate::catalog_remote::{self, CatalogDiff};
|
||||
use crate::cli::CatalogCmd;
|
||||
use crate::commands::config_cmd::register_include;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::output::print_json;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
pub fn run(app: &App, cmd: &CatalogCmd) -> Result<i32> {
|
||||
match cmd {
|
||||
CatalogCmd::Update => update(app),
|
||||
CatalogCmd::Add { url } => add(app, url),
|
||||
CatalogCmd::List => list(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// am catalog update: fetch the official catalog, show the diff against the
|
||||
/// local catalog, ask for confirmation, then register the URL as an include.
|
||||
fn update(app: &App) -> Result<i32> {
|
||||
let url = catalog_remote::official_url(app);
|
||||
app.log.info(&format!("fetching the official catalog: {url}"));
|
||||
let text = catalog_remote::fetch(app, &url, true)?;
|
||||
let remote = catalog_remote::parse_catalog(&text)?;
|
||||
let diff = catalog_remote::diff_catalogs(&app.config, &remote);
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"url": url,
|
||||
"agents": remote.agents.len(),
|
||||
"added": diff.added,
|
||||
"removed": diff.removed,
|
||||
"changed": diff.changed.iter().map(|(n, from, to)| serde_json::json!({"name": n, "run_from": from, "run_to": to})).collect::<Vec<_>>(),
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
print_diff(&diff);
|
||||
if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() {
|
||||
app.log
|
||||
.success(&format!("catalog is up to date ({} agents)", remote.agents.len()));
|
||||
return Ok(0);
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry("would register the official catalog as an include");
|
||||
return Ok(0);
|
||||
}
|
||||
if !app
|
||||
.confirm(&format!(
|
||||
"apply this catalog ({} agents, {} changes)?",
|
||||
remote.agents.len(),
|
||||
diff.added.len() + diff.removed.len() + diff.changed.len()
|
||||
))?
|
||||
{
|
||||
app.log.info("catalog update cancelled");
|
||||
return Ok(0);
|
||||
}
|
||||
register_include(app, &url)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Catalog)
|
||||
.with_args(vec![url.clone()])
|
||||
.with_reason("update"),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"official catalog registered as an include ({url}) — it is effective from the next run"
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am catalog add <url>: validate and register an external catalog.
|
||||
fn add(app: &App, url: &str) -> Result<i32> {
|
||||
crate::download::validate_url(url)?;
|
||||
if config_cmd::user_includes(app).iter().any(|i| i == url) {
|
||||
app.log
|
||||
.info(&format!("catalog {url} is already registered as an include"));
|
||||
return Ok(0);
|
||||
}
|
||||
app.log.info(&format!("fetching catalog: {url}"));
|
||||
// force: 'catalog add' must validate the current content of the URL,
|
||||
// not a possibly stale cached copy.
|
||||
let text = catalog_remote::fetch(app, url, true)?;
|
||||
let remote = catalog_remote::parse_catalog(&text)?;
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"url": url,
|
||||
"agents": remote.agents.len(),
|
||||
"registered": false,
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
if remote.agents.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"the catalog at {url} defines no agents — refusing to register it"
|
||||
));
|
||||
}
|
||||
if app.dry_run() {
|
||||
app.log.dry(&format!(
|
||||
"would register {url} ({:?} agents)",
|
||||
remote.agents.len()
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
register_include(app, url)?;
|
||||
app.emit(
|
||||
&Event::now(EventKind::Catalog)
|
||||
.with_args(vec![url.to_string()])
|
||||
.with_reason("add"),
|
||||
);
|
||||
app.log.success(&format!(
|
||||
"catalog {url} registered ({} agents) — effective from the next run",
|
||||
remote.agents.len()
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// am catalog list: show the registered remote catalogs (includes).
|
||||
/// The resolved config consumes its includes at load time, so this reads
|
||||
/// the user config file directly (like 'config add').
|
||||
fn list(app: &App) -> Result<i32> {
|
||||
let urls: Vec<String> = config_cmd::user_includes(app)
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
i.starts_with("http://")
|
||||
|| i.starts_with("https://")
|
||||
|| i.starts_with("file://")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if app.json() {
|
||||
print_json(&urls);
|
||||
return Ok(0);
|
||||
}
|
||||
if urls.is_empty() {
|
||||
app.log.info(
|
||||
"no remote catalog registered — 'am catalog add <url>' or 'am catalog update'",
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
println!("remote catalogs ({}):", urls.len());
|
||||
for u in &urls {
|
||||
println!(" - {u}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn print_diff(diff: &CatalogDiff) {
|
||||
if !diff.added.is_empty() {
|
||||
println!(" + {} new agent(s): {}", diff.added.len(), diff.added.join(", "));
|
||||
}
|
||||
if !diff.removed.is_empty() {
|
||||
println!(
|
||||
" - {} removed: {}",
|
||||
diff.removed.len(),
|
||||
diff.removed.join(", ")
|
||||
);
|
||||
}
|
||||
if !diff.changed.is_empty() {
|
||||
println!(" ~ {} changed run command(s):", diff.changed.len());
|
||||
for (name, from, to) in &diff.changed {
|
||||
println!(" {name}: '{from}' -> '{to}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,42 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
let dest = dir.join(&base);
|
||||
std::fs::copy(file, &dest)
|
||||
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
|
||||
register_include(app, &base)?;
|
||||
app.log.success(&format!(
|
||||
"added {} and registered it in {}",
|
||||
dest.display(),
|
||||
user_config_path(app)?.display()
|
||||
));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Read the `include:` entries of the user config file (they are consumed
|
||||
/// at load time, so the resolved config no longer lists them).
|
||||
pub fn user_includes(app: &App) -> Vec<String> {
|
||||
let Some(cfg_path) = user_config_path_if_any(app) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&cfg_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&text) else {
|
||||
return Vec::new();
|
||||
};
|
||||
value
|
||||
.get("include")
|
||||
.and_then(|i| i.as_sequence())
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add one entry to the `include:` list of the user config file (creating
|
||||
/// the file with a template when it does not exist). Used by 'config add'
|
||||
/// (local files) and 'catalog add/update' (remote URLs, issues #60 #67).
|
||||
pub fn register_include(app: &App, entry: &str) -> Result<()> {
|
||||
let cfg_path = user_config_path(app)?;
|
||||
let text = std::fs::read_to_string(&cfg_path)?;
|
||||
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
|
||||
@@ -227,7 +262,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
let key = serde_yaml::Value::String("include".to_string());
|
||||
match map.get_mut(&key) {
|
||||
Some(serde_yaml::Value::Sequence(seq)) => {
|
||||
let entry = serde_yaml::Value::String(base.clone());
|
||||
let entry = serde_yaml::Value::String(entry.to_string());
|
||||
if !seq.contains(&entry) {
|
||||
seq.push(entry);
|
||||
}
|
||||
@@ -236,7 +271,7 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
map.insert(
|
||||
key,
|
||||
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
|
||||
base.clone(),
|
||||
entry.to_string(),
|
||||
)]),
|
||||
);
|
||||
}
|
||||
@@ -247,16 +282,11 @@ fn add_file(app: &App, file: &Path) -> Result<i32> {
|
||||
_ => {
|
||||
std::fs::write(
|
||||
&cfg_path,
|
||||
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
|
||||
format!("version: \"1.0\"\ninclude:\n - {entry}\n"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
app.log.success(&format!(
|
||||
"added {} and registered it in {}",
|
||||
dest.display(),
|
||||
cfg_path.display()
|
||||
));
|
||||
Ok(0)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+9
-3
@@ -3,6 +3,7 @@
|
||||
pub mod agents_cmd;
|
||||
pub mod alias_cmd;
|
||||
pub mod annotations_cmd;
|
||||
pub mod catalog_cmd;
|
||||
pub mod completion_cmd;
|
||||
pub mod config_cmd;
|
||||
pub mod dashboard_cmd;
|
||||
@@ -17,6 +18,7 @@ pub mod list_cmd;
|
||||
pub mod log_cmd;
|
||||
pub mod logs_cmd;
|
||||
pub mod man_cmd;
|
||||
pub mod models_cmd;
|
||||
pub mod open_cmd;
|
||||
pub mod profile_cmd;
|
||||
pub mod projects_cmd;
|
||||
@@ -24,17 +26,18 @@ pub mod run_cmd;
|
||||
pub mod search_cmd;
|
||||
pub mod secret_cmd;
|
||||
pub mod self_uninstall;
|
||||
pub mod self_update;
|
||||
pub mod sessions_cmd;
|
||||
pub mod stats_cmd;
|
||||
pub mod self_update;
|
||||
pub mod status_cmd;
|
||||
pub mod suggest_cmd;
|
||||
pub mod theme_cmd;
|
||||
pub mod timeline_cmd;
|
||||
pub mod tip_cmd;
|
||||
pub mod uninstall_cmd;
|
||||
pub mod update_cmd;
|
||||
pub mod watch_cmd;
|
||||
pub mod version_cmd;
|
||||
pub mod watch_cmd;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::cli::Command;
|
||||
@@ -84,6 +87,9 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Agents { installed } => agents_cmd::run(app, *installed),
|
||||
Command::Alias(sub) => alias_cmd::run(app, sub),
|
||||
Command::Secret(sub) => secret_cmd::run(app, sub),
|
||||
Command::Models(args) => models_cmd::run(app, args),
|
||||
Command::Catalog(sub) => catalog_cmd::run(app, sub),
|
||||
Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")),
|
||||
Command::Start(a) => run_cmd::start(app, a),
|
||||
Command::Stop {
|
||||
agent,
|
||||
@@ -167,7 +173,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
||||
Command::Config(sub) => config_cmd::run(app, sub),
|
||||
Command::Theme { name } => theme_cmd::run(app, name.as_deref()),
|
||||
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
||||
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
||||
Command::Run { agent, model, args } => run_cmd::run(app, agent, model.as_deref(), args),
|
||||
Command::Completion { shell, installed } => completion_cmd::run(*shell, *installed),
|
||||
Command::SelfUpdate { check, to } => self_update::run(app, *check, to.as_deref()),
|
||||
Command::SelfUninstall => self_uninstall::run(app),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! models: inventory of local model runtimes (ollama, llama.cpp, LM Studio)
|
||||
//! and pruning of unused models (issues #70 #73, axe 11).
|
||||
|
||||
use super::*;
|
||||
use crate::cli::ModelsArgs;
|
||||
use crate::events::{Event, EventKind};
|
||||
use crate::models::{self, RuntimeModel};
|
||||
use crate::output::print_json;
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ModelRow {
|
||||
runtime: String,
|
||||
name: String,
|
||||
size_bytes: Option<u64>,
|
||||
quantization: Option<String>,
|
||||
last_used: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RuntimeRow {
|
||||
runtime: String,
|
||||
detected: bool,
|
||||
note: Option<String>,
|
||||
models: usize,
|
||||
}
|
||||
|
||||
pub fn run(app: &App, args: &ModelsArgs) -> Result<i32> {
|
||||
if args.prune {
|
||||
return prune(app, args);
|
||||
}
|
||||
let inventory = models::detect(app);
|
||||
if app.json() {
|
||||
print_json(&serde_json::json!({
|
||||
"runtimes": inventory.runtimes,
|
||||
"models": inventory.models,
|
||||
}));
|
||||
return Ok(0);
|
||||
}
|
||||
let theme = app.theme();
|
||||
let color = app.color();
|
||||
// Runtime status lines.
|
||||
for r in &inventory.runtimes {
|
||||
let icon = if r.detected { "✓" } else { "✗" };
|
||||
let note = r.note.as_deref().unwrap_or("");
|
||||
println!(" {icon} {} — {note}", r.runtime);
|
||||
}
|
||||
if !inventory.any_runtime() {
|
||||
app.log.info(
|
||||
"no local model runtime detected — install ollama, llama.cpp or LM Studio, or start their local API",
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
if inventory.models.is_empty() {
|
||||
app.log.info("runtimes detected but no models found");
|
||||
return Ok(0);
|
||||
}
|
||||
// Table: runtime | model | size | quant | last used.
|
||||
let rows: Vec<Vec<String>> = inventory
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| {
|
||||
vec![
|
||||
m.runtime.clone(),
|
||||
m.name.clone(),
|
||||
human_size(m.size_bytes),
|
||||
m.quantization.clone().unwrap_or_else(|| "—".to_string()),
|
||||
m.last_used.clone().unwrap_or_else(|| "—".to_string()),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let _ = (theme, color);
|
||||
print!(
|
||||
"{}",
|
||||
crate::output::boxed_table(
|
||||
vec!["RUNTIME", "MODEL", "SIZE", "QUANT", "LAST USED"],
|
||||
rows,
|
||||
app.theme(),
|
||||
app.color(),
|
||||
)
|
||||
);
|
||||
println!(
|
||||
"\n {} model(s) — 'am models --prune --dry-run' lists unused ones",
|
||||
inventory.models.len()
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// prune: list unused models, then delete them after confirmation.
|
||||
fn prune(app: &App, args: &ModelsArgs) -> Result<i32> {
|
||||
let days = args.days.unwrap_or_else(|| {
|
||||
app.config
|
||||
.settings
|
||||
.models_prune_days
|
||||
.unwrap_or(models::DEFAULT_PRUNE_DAYS)
|
||||
});
|
||||
let inventory = models::detect(app);
|
||||
let candidates = models::prune_candidates(&inventory.models, days);
|
||||
let _ = &inventory;
|
||||
if app.json() {
|
||||
let rows: Vec<ModelRow> = candidates
|
||||
.iter()
|
||||
.map(|m| ModelRow {
|
||||
runtime: m.runtime.clone(),
|
||||
name: m.name.clone(),
|
||||
size_bytes: m.size_bytes,
|
||||
quantization: m.quantization.clone(),
|
||||
last_used: m.last_used.clone(),
|
||||
})
|
||||
.collect();
|
||||
print_json(&rows);
|
||||
return Ok(if rows.is_empty() { 0 } else { 1 });
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
app.log.success(&format!(
|
||||
"no unused model found (threshold: {days} days; ':latest' models are always kept)"
|
||||
));
|
||||
return Ok(0);
|
||||
}
|
||||
println!(
|
||||
"{} model(s) unused for more than {days} days (':latest' models are protected):",
|
||||
candidates.len()
|
||||
);
|
||||
for m in &candidates {
|
||||
let size = human_size(m.size_bytes);
|
||||
let last = m.last_used.as_deref().unwrap_or("unknown");
|
||||
println!(" - [{}] {} ({size}, last used {last})", m.runtime, m.name);
|
||||
}
|
||||
if args.dry_run {
|
||||
app.log.info("dry-run: nothing was deleted — remove --dry-run to prune");
|
||||
return Ok(0);
|
||||
}
|
||||
let asked = app
|
||||
.confirm(&format!("delete these {} model(s)?", candidates.len()))?;
|
||||
if !asked {
|
||||
app.log.info("prune cancelled");
|
||||
return Ok(0);
|
||||
}
|
||||
let mut removed = 0;
|
||||
let mut errors = 0;
|
||||
for m in &candidates {
|
||||
match models::remove_model(m) {
|
||||
Ok(()) => {
|
||||
app.emit(
|
||||
&Event::now(EventKind::Model)
|
||||
.with_agent(m.runtime.clone())
|
||||
.with_args(vec![m.name.clone()])
|
||||
.with_reason("prune"),
|
||||
);
|
||||
app.log.success(&format!("removed {} ({})", m.name, m.runtime));
|
||||
removed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
app.log.error(&format!("{}: {e:#}", m.name));
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The inventory cache is stale after a prune: drop it so the next run
|
||||
// rescans the runtimes.
|
||||
let cache_path = app.events_dir().join("models-cache.json");
|
||||
let _ = std::fs::remove_file(&cache_path);
|
||||
if errors > 0 {
|
||||
app.log
|
||||
.error(&format!("{errors} model(s) could not be removed"));
|
||||
return Ok(1);
|
||||
}
|
||||
app.log
|
||||
.success(&format!("{removed} model(s) removed from the local runtimes"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Human-readable size ("1.2 GB", "450 MB", "—" when unknown).
|
||||
fn human_size(bytes: Option<u64>) -> String {
|
||||
match bytes {
|
||||
None => "—".to_string(),
|
||||
Some(b) => {
|
||||
let kb = b as f64 / 1024.0;
|
||||
let mb = kb / 1024.0;
|
||||
let gb = mb / 1024.0;
|
||||
if gb >= 1.0 {
|
||||
format!("{gb:.1} GB")
|
||||
} else if mb >= 1.0 {
|
||||
format!("{mb:.0} MB")
|
||||
} else {
|
||||
format!("{kb:.0} KB")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage of each model name across sessions (for --json consumers).
|
||||
#[allow(dead_code)]
|
||||
fn usage_index(_app: &App, _models: &[RuntimeModel]) -> BTreeMap<String, u64> {
|
||||
BTreeMap::new()
|
||||
}
|
||||
+68
-3
@@ -69,6 +69,16 @@ pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>
|
||||
&extra_args,
|
||||
&extra_env,
|
||||
)?;
|
||||
// --model (issue #71): resolve against the local runtimes, then
|
||||
// transmit through the agent's declared channel (env var or flag).
|
||||
let mut extra_args = extra_args;
|
||||
let mut extra_env = extra_env;
|
||||
if let Some(m) = opts.model.as_deref() {
|
||||
let agent = require_agent(app, &target)?;
|
||||
let (a, e) = apply_model(app, agent, m)?;
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
||||
let members = app.catalog.group_members(group);
|
||||
if members.is_empty() {
|
||||
@@ -299,10 +309,19 @@ pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -
|
||||
}
|
||||
|
||||
/// run: execute the agent command directly, no process management.
|
||||
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
|
||||
pub fn run(app: &App, target: &str, model: Option<&str>, extra: &[OsString]) -> Result<i32> {
|
||||
let agent = require_agent(app, target)?;
|
||||
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
|
||||
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
|
||||
let mut extra_args: Vec<String> = extra
|
||||
.iter()
|
||||
.map(|o| o.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
|
||||
if let Some(m) = model {
|
||||
let (a, e) = apply_model(app, agent, m)?;
|
||||
extra_args.extend(a);
|
||||
extra_env.extend(e);
|
||||
}
|
||||
let exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
|
||||
if app.dry_run() {
|
||||
app.log.dry(format!(
|
||||
"would run {} {}",
|
||||
@@ -339,6 +358,52 @@ fn cwd_string() -> Option<String> {
|
||||
std::env::current_dir().ok().map(|p| p.display().to_string())
|
||||
}
|
||||
|
||||
/// Resolve --model against the local runtimes and build the transmission
|
||||
/// channel for the agent (issue #71): the agent's `model_env` variable wins,
|
||||
/// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown
|
||||
/// model is an explicit error listing the available runtimes.
|
||||
pub fn apply_model(
|
||||
app: &App,
|
||||
agent: &AgentDef,
|
||||
model: &str,
|
||||
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
|
||||
let inventory = crate::models::detect(app);
|
||||
if inventory.find(model).is_none() {
|
||||
let known: Vec<String> = inventory.models.iter().map(|m| m.name.clone()).collect();
|
||||
let hint = if known.is_empty() {
|
||||
"no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string()
|
||||
} else {
|
||||
format!("available models: {} — run 'am models' for details", known.join(", "))
|
||||
};
|
||||
bail!("model '{model}' not found on any local runtime ({hint})");
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
let mut env = BTreeMap::new();
|
||||
if let Some(var) = &agent.model_env {
|
||||
env.insert(var.clone(), model.to_string());
|
||||
app.log.verbose(&format!(
|
||||
"model '{model}' transmitted to {} via {var}",
|
||||
agent.name
|
||||
));
|
||||
} else if let Some(flag) = &agent.model_arg {
|
||||
args.push(flag.clone());
|
||||
args.push(model.to_string());
|
||||
app.log.verbose(&format!(
|
||||
"model '{model}' transmitted to {} via {flag}",
|
||||
agent.name
|
||||
));
|
||||
} else {
|
||||
// Generic fallback: many local agents read AM_MODEL; the config can
|
||||
// pin a specific channel (model_env / model_arg) instead.
|
||||
env.insert("AM_MODEL".to_string(), model.to_string());
|
||||
app.log.info(&format!(
|
||||
"agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel",
|
||||
agent.name
|
||||
));
|
||||
}
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
|
||||
fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
||||
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! suggest: recommend agents for a natural-language request (issue #61,
|
||||
//! axe 7). Reuses the catalog fuzzy scoring (name/title/description/
|
||||
//! category/tags) and boosts the ranking with real usage from the event
|
||||
//! journal (launch counts and durations), with a short justification.
|
||||
|
||||
use super::*;
|
||||
use crate::output::print_json;
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Top N suggestions shown.
|
||||
const TOP_N: usize = 5;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SuggestRow {
|
||||
name: String,
|
||||
display_name: String,
|
||||
category: Option<String>,
|
||||
/// Combined score (fuzzy * usage boost).
|
||||
score: f64,
|
||||
/// Why this agent was suggested.
|
||||
reason: String,
|
||||
/// Real usage in the journal (when available).
|
||||
launches: u64,
|
||||
total_duration_s: u64,
|
||||
}
|
||||
|
||||
pub fn run(app: &App, query: &str) -> Result<i32> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
anyhow::bail!("empty query — describe what you need, e.g. 'am suggest un agent pour du Python'");
|
||||
}
|
||||
// 1. Fuzzy scoring over the catalog (name x3, title x2, rest x1).
|
||||
let scored: Vec<(u32, &AgentDef)> = app
|
||||
.catalog
|
||||
.agents()
|
||||
.iter()
|
||||
.filter(|a| !a.hidden)
|
||||
.filter_map(|a| {
|
||||
let kw = query.to_lowercase();
|
||||
let name = a.name.to_lowercase();
|
||||
let title = a.title().to_lowercase();
|
||||
let rest = format!(
|
||||
"{} {} {}",
|
||||
a.description.as_deref().unwrap_or("").to_lowercase(),
|
||||
a.category.as_deref().unwrap_or("").to_lowercase(),
|
||||
a.tags.join(" ").to_lowercase()
|
||||
);
|
||||
let score = score_token(&kw, &name)
|
||||
.saturating_mul(3)
|
||||
.saturating_add(score_token(&kw, &title).saturating_mul(2))
|
||||
.saturating_add(score_token(&kw, &rest));
|
||||
(score > 0).then_some((score, a))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 2. Real usage boost from the journal (stats_cmd aggregates launches
|
||||
// and durations per agent).
|
||||
let usage = crate::commands::stats_cmd::aggregate(app, None, None).unwrap_or_default();
|
||||
let launches: BTreeMap<&str, u64> = usage
|
||||
.iter()
|
||||
.map(|s| (s.agent.as_str(), s.launches))
|
||||
.collect();
|
||||
let duration: BTreeMap<&str, u64> = usage
|
||||
.iter()
|
||||
.map(|s| (s.agent.as_str(), s.total_duration_s))
|
||||
.collect();
|
||||
|
||||
let mut rows: Vec<SuggestRow> = scored
|
||||
.iter()
|
||||
.map(|(score, a)| {
|
||||
let l = launches.get(a.name.as_str()).copied().unwrap_or(0);
|
||||
let d = duration.get(a.name.as_str()).copied().unwrap_or(0);
|
||||
// Boost: up to +50 % for the most used agents (log scale).
|
||||
let boost = if l > 0 {
|
||||
1.0 + 0.5 * (1.0 + (l as f64).ln()).min(2.0) / 2.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let final_score = (*score as f64) * boost;
|
||||
let reason = justify(a, l, d);
|
||||
SuggestRow {
|
||||
name: a.name.clone(),
|
||||
display_name: a.title().to_string(),
|
||||
category: a.category.clone(),
|
||||
score: final_score,
|
||||
reason,
|
||||
launches: l,
|
||||
total_duration_s: d,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.name.cmp(&b.name))
|
||||
});
|
||||
rows.truncate(TOP_N);
|
||||
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
let suggestions = app.catalog.suggest(query);
|
||||
if suggestions.is_empty() {
|
||||
app.log.info(&format!(
|
||||
"no agent matches '{query}' — try 'am list --all' to browse the catalog"
|
||||
));
|
||||
} else {
|
||||
app.log.info(&format!(
|
||||
"no agent matches '{query}' — vouliez-vous dire : {} ?",
|
||||
suggestions.join(", ")
|
||||
));
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
println!("suggestions for \"{query}\":");
|
||||
let mut table = crate::output::Table::new(vec!["AGENT", "CATEGORY", "WHY"]);
|
||||
for r in &rows {
|
||||
table.row(vec![
|
||||
r.display_name.clone(),
|
||||
r.category.clone().unwrap_or_else(|| "—".to_string()),
|
||||
r.reason.clone(),
|
||||
]);
|
||||
}
|
||||
print!("{}", table.render());
|
||||
if rows.len() < scored.len() {
|
||||
println!(
|
||||
"\n ({} other match(es) not shown — 'am search {}' lists them)",
|
||||
scored.len() - rows.len(),
|
||||
query
|
||||
);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Short justification: matched field(s) + real usage when available.
|
||||
fn justify(a: &AgentDef, launches: u64, duration_s: u64) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(c) = &a.category {
|
||||
parts.push(format!("catégorie {c}"));
|
||||
}
|
||||
if !a.tags.is_empty() {
|
||||
parts.push(format!("tags: {}", a.tags.join(", ")));
|
||||
}
|
||||
if launches > 0 {
|
||||
parts.push(format!("{launches} lancement(s)"));
|
||||
if duration_s >= 3600 {
|
||||
parts.push(format!("{}h", duration_s / 3600));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
a.description
|
||||
.as_deref()
|
||||
.map(|d| {
|
||||
let mut d = d.to_string();
|
||||
d.truncate(60);
|
||||
d
|
||||
})
|
||||
.unwrap_or_else(|| "correspond à la requête".to_string())
|
||||
} else {
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reuse the catalog scoring helper (exported from catalog.rs).
|
||||
fn score_token(keyword: &str, hay: &str) -> u32 {
|
||||
crate::catalog::score_keyword(keyword, hay)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent(name: &str, desc: &str, tags: &[&str]) -> AgentDef {
|
||||
AgentDef {
|
||||
name: name.to_string(),
|
||||
display_name: None,
|
||||
description: Some(desc.to_string()),
|
||||
category: Some("coding-agent".to_string()),
|
||||
website: None,
|
||||
install: None,
|
||||
dependencies: vec![],
|
||||
run: Some(name.to_string()),
|
||||
args: vec![],
|
||||
env: BTreeMap::new(),
|
||||
version: None,
|
||||
pin_version: None,
|
||||
model_env: None,
|
||||
model_arg: None,
|
||||
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||
installable: false,
|
||||
note: None,
|
||||
hidden: false,
|
||||
platforms: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoring_prefers_relevant_agents() {
|
||||
let a = agent("python-mate", "assistant python", &["python"]);
|
||||
let b = agent("rust-mate", "assistant rust", &["rust"]);
|
||||
let catalog = crate::catalog::Catalog::new(
|
||||
vec![a, b],
|
||||
BTreeMap::new(),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
let kw = "python";
|
||||
let mut scored: Vec<(&str, u32)> = catalog
|
||||
.agents()
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
let s = score_token(kw, &x.name.to_lowercase())
|
||||
.saturating_mul(3)
|
||||
.saturating_add(score_token(kw, &x.title().to_lowercase()).saturating_mul(2))
|
||||
.saturating_add(score_token(
|
||||
kw,
|
||||
&format!("{} {}", x.description.clone().unwrap_or_default(), x.tags.join(" ")),
|
||||
));
|
||||
(s > 0).then_some((x.name.as_str(), s))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
assert_eq!(scored[0].0, "python-mate");
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,28 @@ pub static SECTIONS: &[TipSection] = &[
|
||||
options: &[],
|
||||
example: "search pi",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "suggest <requête>",
|
||||
about: "recommande un agent pour une demande en langage naturel (tags + usage réel)",
|
||||
options: &[("--json", "sortie machine-readable")],
|
||||
example: "suggest un agent pour du Python",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "models",
|
||||
about: "inventaire des modèles locaux (ollama, llama.cpp, LM Studio)",
|
||||
options: &[
|
||||
("--prune", "purge les modèles inutilisés"),
|
||||
("--dry-run", "simulation sans rien supprimer"),
|
||||
("--days <n>", "seuil d'inutilisation (défaut : 30)"),
|
||||
],
|
||||
example: "models --prune --dry-run",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "catalog update",
|
||||
about: "rafraîchit le catalogue officiel (diff + confirmation)",
|
||||
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
|
||||
example: "catalog add https://git.dracodev.net/team/agents.yaml",
|
||||
},
|
||||
TipEntry {
|
||||
usage: "install <agent>",
|
||||
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
|
||||
|
||||
Reference in New Issue
Block a user