diff --git a/crates/aish-config/src/lib.rs b/crates/aish-config/src/lib.rs index 82d6d30f..a18818b1 100644 --- a/crates/aish-config/src/lib.rs +++ b/crates/aish-config/src/lib.rs @@ -18,6 +18,6 @@ pub mod model; pub use loader::ConfigLoader; pub use model::{ - compile_remote_danger_patterns, ConfigModel, InlineCompletionConfig, MemoryConfig, - OutputOffloadConfig, RegistrySource, SkillsConfig, ToolArgPreviewConfig, + compile_remote_danger_patterns, ApiAccountConfig, ConfigModel, InlineCompletionConfig, + MemoryConfig, OutputOffloadConfig, RegistrySource, SkillsConfig, ToolArgPreviewConfig, }; diff --git a/crates/aish-config/src/model.rs b/crates/aish-config/src/model.rs index c59488db..f0bcecb9 100644 --- a/crates/aish-config/src/model.rs +++ b/crates/aish-config/src/model.rs @@ -501,6 +501,23 @@ pub struct ConfigModel { /// Skill marketplace: auto-search + registry sources. #[serde(default)] pub skills: SkillsConfig, + /// Additional API accounts for multi-key quota rotation under the same + /// provider. The top-level `api_key` is always the primary (account #0); + /// entries here are tried in order when the primary hits a rate/usage limit. + #[serde(default)] + pub api_accounts: Vec, + /// Ordered fallback model names tried when the primary model fails + /// (rate-limit / usage-limit / exhausted 5xx). Each runs with the active account. + #[serde(default)] + pub fallback_models: Vec, + /// Models the user has switched to via /model, most-recent-first. Surfaced + /// at the top of the picker so switching back is one keystroke. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub recent_models: Vec, + /// Restore the primary model after its cooldown window expires following + /// a fallback. Default: true. + #[serde(default = "default_true")] + pub fallback_revert_on_cooldown: bool, } impl Default for ConfigModel { @@ -553,10 +570,39 @@ impl Default for ConfigModel { inline_completion: InlineCompletionConfig::default(), pty_daemon_enabled: default_true(), skills: SkillsConfig::default(), + api_accounts: vec![], + fallback_models: vec![], + recent_models: vec![], + fallback_revert_on_cooldown: default_true(), } } } +/// One additional API credential for multi-key quota rotation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ApiAccountConfig { + /// Human-readable label shown in the rotation UI. + pub name: String, + pub api_key: String, + /// Optional override of the provider base URL for this account. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_base: Option, + /// Optional per-account model override. When set, this account uses its + /// own model regardless of the global `model` / fallback chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Relative selection weight (advisory; higher = preferred). + #[serde(default = "default_one")] + pub weight: u32, + /// Skip this account entirely when true. + #[serde(default)] + pub disabled: bool, +} + +fn default_one() -> u32 { + 1 +} + /// Compile the `remote_danger_patterns` into `Regex` objects, logging each /// invalid pattern at warn level and skipping it. Patterns come from config /// and don't change during a session, so callers should compile once and diff --git a/crates/aish-i18n/locales/de-DE.yaml b/crates/aish-i18n/locales/de-DE.yaml index dd4695c9..59084e03 100644 --- a/crates/aish-i18n/locales/de-DE.yaml +++ b/crates/aish-i18n/locales/de-DE.yaml @@ -183,6 +183,195 @@ shell: audit: "Audit-Protokoll abfragen (wer/wann/was/KI-Vorschlag/Bestaetigung)" forget-approvals: "Gemerkte Befehlsgenehmigungen löschen (diese Sitzung)" skill: "Skills suchen, installieren, prüfen; Registries verwalten" + accounts: "Multi-Key-Rotationskonten verwalten" + fallback: "Die Modell-Fallback-Kette verwalten" + fork: "Aktuelle Sitzung abzweigen" + sessions: "Sitzungsbaum anzeigen" + export: "Aktuelle Sitzung als Markdown exportieren" + + menu: + accounts: + title: "Rotationskonten" + list: "Konten auflisten" + list_desc: "alle Rotationskonten + Status anzeigen" + add: "Konto hinzufügen" + add_desc: "einen neuen API-Schlüssel registrieren" + remove: "Konto entfernen" + remove_desc: "ein Konto löschen" + toggle: "Aktivieren / Deaktivieren" + toggle_desc: "ein Konto pausieren oder fortsetzen" + use: "Konto wechseln" + use_desc: "Aktives Konto wechseln" + remove_title: "Konto entfernen" + toggle_title: "Konto aktivieren / deaktivieren" + fallback: + title: "Modell-Fallback-Kette" + list: "Kette auflisten" + list_desc: "primäres + Fallback-Modelle und Richtlinie anzeigen" + add: "Modell hinzufügen" + add_desc: "ein Fallback-Modell anhängen" + remove: "Modell entfernen" + remove_desc: "ein Modell aus der Kette entfernen" + clear: "Kette leeren" + clear_desc: "die Fallback-Kette leeren" + revert: "Revert umschalten" + revert_desc: "Auto-Revert auf primär ein/aus" + remove_title: "Fallback-Modell entfernen" + revert_title: "Revert-Richtlinie" + on: "Auto-Revert" + on_desc: "nach Abklingzeit zum primären Modell zurückkehren (empfohlen)" + off: "Bei Fallback bleiben" + off_desc: "Fallback bis manuellem Wechsel beibehalten" + audit: + title: "Audit-Protokoll" + recent: "Letzte Ereignisse" + recent_desc: "die letzten 20 über alle hinweg" + user: "Nach Benutzer filtern" + user_desc: "Ereignisse für einen Benutzer anzeigen" + host: "Nach Host filtern" + host_desc: "Ereignisse auf einem Host anzeigen" + type: "Nach Ereignistyp filtern" + type_desc: "einen Ereignistyp anzeigen" + user_label: "Benutzername" + host_label: "Host" + type_label: "Ereignistyp" + doctor: + title: "Systemdiagnose" + run: "Diagnose ausführen" + run_desc: "Systemzustand prüfen" + fix: "Mit Auto-Fix ausführen" + fix_desc: "automatische Reparaturen versuchen" + record: + title: "Terminalaufzeichnung" + start: "Aufzeichnung starten" + start_desc: "eine neue Terminalaufzeichnung beginnen" + stop: "Aufzeichnung stoppen" + stop_desc: "die Terminalaufzeichnung beenden und speichern" + plan: + title: "Planmodus" + start: "Planung starten" + start_desc: "in den schreibgeschützten Planmodus wechseln" + status: "Status anzeigen" + status_desc: "aktueller Plan-Status" + exit: "Planmodus verlassen" + exit_desc: "Planung verlassen" + not_in_plan: "Nicht im Planmodus. Verwende `/plan start` (oder `; `), um mit der Planung zu beginnen." + kill: + title: "Aktive Sitzungen beenden" + all: "Alle anderen beenden" + all_desc: "jede Sitzung außer der aktuellen" + + accounts: + list_title: "Rotationskonten" + primary: " [0] primär key={key}... {state}" + key_preview: "key={key}..." + unset: "(nicht gesetzt)" + enabled: "aktiviert" + disabled: "deaktiviert" + added: "Konto '{name}' hinzugefügt ({count} zusätzliche Konten)" + removed: "Konto '{name}' entfernt" + already_exists: "Konto '{name}' existiert bereits" + toggled: "{action} Konto '{name}'" + no_extra_remove: "keine zusätzlichen Konten zu entfernen" + no_extra_toggle: "keine zusätzlichen Konten umzuschalten" + no_account_named: "kein Konto namens '{name}'" + no_extra_hint: " (keine zusätzlichen Konten; füge eins hinzu mit /accounts add )" + usage_add: "Verwendung: /accounts add [api_base]" + usage_remove: "Verwendung: /accounts remove " + usage_enable: "Verwendung: /accounts enable|disable " + unknown_sub: "unbekannter Unterbefehl '{sub}'. Versuche /accounts help" + help_1: "/accounts Rotationskonten auflisten" + help_2: "/accounts add [base]" + help_3: "/accounts remove " + help_4: "/accounts enable|disable " + help_5: "(ohne Argumente wird ein interaktives Menü geöffnet)" + name_label: "Kontoname" + name_desc: "kurze Bezeichnung, z.B. 'team-b'" + key_label: "API-Schlüssel" + key_desc: "der Schlüssel für dieses Konto" + base_label: "API-Base (optional)" + base_desc: "Endpunkt überschreiben; leer = wie primär" + custom_model: "Benutzerdefiniert (manuell eingeben)" + model_label: "Modell" + model_desc: "Von diesem Endpunkt verwendeter Modellname" + select_model: "Modell zur Überprüfung wählen" + no_models: "Keine Modelle aufgelistet; verwende primäres Modell '{model}'" + fetch_failed: "Modelle konnten nicht abgerufen werden: {error}" + verifying: "Verbindung wird überprüft..." + connected: "Verbunden ({latency}ms) - Modell: {model}" + tool_yes: "Tool-Aufrufe: unterstützt" + tool_no: "Tool-Aufrufe: nicht unterstützt" + verify_failed: "Verbindungsprüfung fehlgeschlagen: {error}" + verify_failed_title: "Überprüfung fehlgeschlagen" + save_anyway: "Trotzdem speichern" + cancel_add: "Abbrechen" + usage_use: "Verwendung: /accounts use " + switched: "Zu '{name}' gewechselt (Modell: {model})" + no_extra_use: "Keine zusätzlichen Konten zum Wechseln. Mit /accounts add hinzufügen." + primary_choice: "Primär ({model})" + select_use: "Konto zur Verwendung auswählen" + + fallback: + chain_title: "Fallback-Modell-Kette" + primary: " primär : {model}" + none: " fallback: (nicht konfiguriert)" + policy_auto: " Richtlinie : nach Abkühlung zum primären Modell zurückkehren" + policy_stay: " Richtlinie : bei Fallback bleiben bis manueller Wechsel" + added: "Fallback '{model}' hinzugefügt" + removed: "Fallback '{model}' entfernt" + cleared: "Fallback-Kette geleert" + already_in_chain: "'{model}' ist bereits in der Fallback-Kette" + not_in_chain: "'{model}' nicht in der Fallback-Kette" + empty: "Fallback-Kette ist leer" + usage_add: "Verwendung: /fallback add " + usage_remove: "Verwendung: /fallback remove " + usage_revert: "Verwendung: /fallback revert on|off" + unknown_sub: "unbekannter Unterbefehl '{sub}'. Versuche /fallback help" + help_1: "/fallback Fallback-Modell-Kette auflisten" + help_2: "/fallback add ein Fallback-Modell anhängen" + help_3: "/fallback remove ein Fallback-Modell entfernen" + help_4: "/fallback clear Kette leeren" + help_5: "/fallback revert on|off nach Abkühlung zum primären Modell zurückkehren" + help_6: "(ohne Argumente wird ein interaktives Menü geöffnet)" + model_label: "Fallback-Modell" + model_desc: "Modellname, der probiert wird, wenn das primäre scheitert (z.B. gpt-4o-mini)" + revert_already: "Rückkehr-Richtlinie bereits '{policy}'" + revert_set_on: "Rückkehr-Richtlinie: on (automatisch)" + revert_set_off: "Rückkehr-Richtlinie: off (bleiben)" + + fork: + forked: "Sitzung verzweigt {short} (von {parent})" + switch_failed: "Verzweigung erstellt, aber Wechsel fehlgeschlagen: {error}" + switch_manual: "verwende `/resume {short}` zum manuellen Wechseln" + failed: "Verzweigung fehlgeschlagen: {error}" + store_unavailable: "Sitzungsspeicher nicht verfügbar; Verzweigung nicht möglich" + + sessions: + none: "(keine gespeicherten Sitzungen)" + switch_failed: "Sitzungswechsel fehlgeschlagen: {error}" + list_failed: "Sitzungen auflisten fehlgeschlagen: {error}" + + export: + md_header: "# aish Sitzungsexport" + session_label: "- **Sitzung**: `{uuid}`" + model_label: "- **Modell**: {model}" + api_base_label: "- **api_base**: {base}" + forked_label: "- **verzweigt von**: `{parent}`" + created_label: "- **erstellt**: {created}" + working_dir: "**Arbeitsverzeichnis**: `{cwd}`" + conversation_header: "## Unterhaltung" + history_header: "## Befehlsverlauf" + exported: "exportiert {file} ({msgs} Nachrichten, {cmds} Befehle)" + write_failed: "Schreiben von {file} fehlgeschlagen: {error}" + usage: "Verwendung: /export [md] (nur markdown wird unterstützt)" + store_unavailable: "Sitzungsspeicher nicht verfügbar; Export nicht möglich" + not_found: "aktuelle Sitzung im Speicher nicht gefunden" + load_failed: "Laden der Sitzung fehlgeschlagen: {error}" + + common: + cancelled: "abgebrochen" + session_store_unavailable: "Sitzungsspeicher nicht verfügbar" + config_save_warning: "Warnung: Konfiguration konnte nicht gespeichert werden: {error}" feedback: select_type: "Feedback-Typ auswählen" @@ -312,12 +501,53 @@ shell: switch_same: "Modell unverändert: {model}" switch_success: "Zu Modell gewechselt: {model}" verify_failed: "Modellvalidierung fehlgeschlagen: {reason}" + picker_title: "Modell / Konto wechseln" + picker_subtitle: "↑↓ Auswaehlen, Enter wechseln" + fetch_failed_subtitle: "Modellliste konnte nicht abgerufen werden (Endpoint unerreichbar) — nur aktuell + Verlauf" + picker_search: "Modelle oder URLs suchen..." + picker_footer: "a Konto hinzufuegen m verwalten Esc Abbrechen" + action_add: "neu" + action_del: "loeschen" + action_toggle: "an/aus" + action_edit: "bearbeiten" + edit_label: "Modellname" + edit_desc: "Modell dieses Kontos" + edited: "'{name}' Modell auf {model} aktualisiert" + cannot_remove_primary: "Das primaere Modell kann nicht entfernt werden" + switched_account: "Zu '{name}' gewechselt (Modell: {model})" + fallback_badge: "Fallback" + primary_badge: "Primär" + recent_badge: "kuerzlich" + action_fallback: "Fallback" + fallback_not_switchable: "Fallback-Modell '{model}' ist nicht direkt umschaltbar; wird nur versucht, wenn das primaere versagt" + fallback_add_label: "Fallback-Modellname" + fallback_add_desc: "Modell, das versucht wird, wenn das primaere versagt" + fallback_added: "Fallback '{model}' hinzugefuegt" + fallback_removed: "Fallback '{model}' entfernt" + fallback_exists: "Fallback '{model}' existiert bereits" + action_manage: "verwalten" + manage_title: "Konten & Fallback" + manage_search: "Suchen..." + manage_subtitle: "d markierten Eintrag loeschen" + manage_footer: "d loeschen f Fallback hinzufuegen Esc zurueck" + manage_empty: "Keine Konten oder Fallbacks zu verwalten" token: title: "Token-Nutzung (letzte 7 Tage)" input_tokens: "Eingabe-Tokens" output_tokens: "Ausgabe-Tokens" total: "Gesamt" api_calls: "API-Aufrufe" + today: "Heute" + session: "Diese Sitzung" + rotation: "Kontorotation" + rotation_avail: "{available}/{total} verfügbar" + rotation_count: "{count} Rotationen in dieser Sitzung" + state_active: "aktiv" + state_cooling: "Abkühlphase" + state_ready: "bereit" + active_model: "Aktives Modell: {model}" + fallback_active: "Fallback aktiv" + fallback_chain: "Fallback-Kette: {chain}" setup: no_config_manager: "Neukonfiguration nicht möglich: Konfigurationsmanager nicht verfügbar" cancelled: "Einrichtung abgebrochen; aktuelle Einstellungen werden beibehalten" diff --git a/crates/aish-i18n/locales/en-US.yaml b/crates/aish-i18n/locales/en-US.yaml index 38e637a3..a555127b 100644 --- a/crates/aish-i18n/locales/en-US.yaml +++ b/crates/aish-i18n/locales/en-US.yaml @@ -368,6 +368,195 @@ shell: audit: "Query audit log (who/when/what/AI suggestion/confirm)" forget-approvals: "Clear remembered command approvals (this session)" skill: "Search, install, verify skills; manage registries" + accounts: "Manage multi-key rotation accounts" + fallback: "Manage the model fallback chain" + fork: "Branch the current session" + sessions: "Show the session tree" + export: "Export the current session to Markdown" + + menu: + accounts: + title: "Rotation accounts" + list: "List accounts" + list_desc: "show all rotation accounts + status" + add: "Add account" + add_desc: "register a new API key" + remove: "Remove account" + remove_desc: "delete an account" + toggle: "Enable / disable" + toggle_desc: "pause or resume an account" + use: "Switch account" + use_desc: "Switch the active account" + remove_title: "Remove account" + toggle_title: "Enable / disable account" + fallback: + title: "Fallback model chain" + list: "List chain" + list_desc: "show primary + fallback models and policy" + add: "Add model" + add_desc: "append a fallback model" + remove: "Remove model" + remove_desc: "drop a model from the chain" + clear: "Clear chain" + clear_desc: "empty the fallback chain" + revert: "Toggle revert" + revert_desc: "auto-revert to primary on/off" + remove_title: "Remove fallback model" + revert_title: "Revert policy" + on: "Auto-revert" + on_desc: "return to primary after cooldown (recommended)" + off: "Stay on fallback" + off_desc: "keep fallback until manual switch" + audit: + title: "Audit log" + recent: "Recent events" + recent_desc: "last 20 across all" + user: "Filter by user" + user_desc: "show events for one user" + host: "Filter by host" + host_desc: "show events on one host" + type: "Filter by event type" + type_desc: "show one event type" + user_label: "Username" + host_label: "Host" + type_label: "Event type" + doctor: + title: "System doctor" + run: "Run diagnostics" + run_desc: "check system health" + fix: "Run with auto-fix" + fix_desc: "attempt automatic fixes" + record: + title: "Terminal recording" + start: "Start recording" + start_desc: "begin a new terminal cast" + stop: "Stop recording" + stop_desc: "finish and save the terminal cast" + plan: + title: "Plan mode" + start: "Start planning" + start_desc: "enter read-only plan mode" + status: "Show status" + status_desc: "current plan state" + exit: "Exit plan mode" + exit_desc: "leave planning" + not_in_plan: "Not in plan mode. Use `/plan start` (or `; `) to begin planning." + kill: + title: "Kill live sessions" + all: "Kill all others" + all_desc: "every session except current" + + accounts: + list_title: "Rotation accounts" + primary: " [0] primary key={key}... {state}" + key_preview: "key={key}..." + unset: "(unset)" + enabled: "enabled" + disabled: "disabled" + added: "added account '{name}' ({count} extra accounts)" + removed: "removed account '{name}'" + already_exists: "account '{name}' already exists" + toggled: "{action} account '{name}'" + no_extra_remove: "no extra accounts to remove" + no_extra_toggle: "no extra accounts to toggle" + no_account_named: "no account named '{name}'" + no_extra_hint: " (no extra accounts; add one with /accounts add )" + usage_add: "usage: /accounts add [api_base]" + usage_remove: "usage: /accounts remove " + usage_enable: "usage: /accounts enable|disable " + unknown_sub: "unknown subcommand '{sub}'. try /accounts help" + help_1: "/accounts list rotation accounts" + help_2: "/accounts add [base]" + help_3: "/accounts remove " + help_4: "/accounts enable|disable " + help_5: "(no args opens an interactive menu)" + name_label: "Account name" + name_desc: "short label, e.g. 'team-b'" + key_label: "API key" + key_desc: "the key for this account" + base_label: "API base (optional)" + base_desc: "override endpoint; blank = same as primary" + custom_model: "Custom (enter manually)" + model_label: "Model" + model_desc: "Model name used by this endpoint" + select_model: "Select a model to verify" + no_models: "No models listed; using primary model '{model}'" + fetch_failed: "Failed to fetch models: {error}" + verifying: "Verifying connectivity..." + connected: "Connected ({latency}ms) - model: {model}" + tool_yes: "Tool calling: supported" + tool_no: "Tool calling: not supported" + verify_failed: "Connection check failed: {error}" + verify_failed_title: "Verification failed" + save_anyway: "Save anyway" + cancel_add: "Cancel" + usage_use: "usage: /accounts use " + switched: "Switched to '{name}' (model: {model})" + no_extra_use: "No extra accounts to switch to. Add one with /accounts add." + primary_choice: "primary ({model})" + select_use: "Select account to use" + + fallback: + chain_title: "Fallback model chain" + primary: " primary : {model}" + none: " fallback: (none configured)" + policy_auto: " policy : auto-revert to primary on cooldown" + policy_stay: " policy : stay on fallback until manual switch" + added: "added fallback '{model}'" + removed: "removed fallback '{model}'" + cleared: "cleared fallback chain" + already_in_chain: "'{model}' is already in the fallback chain" + not_in_chain: "'{model}' not in the fallback chain" + empty: "fallback chain is empty" + usage_add: "usage: /fallback add " + usage_remove: "usage: /fallback remove " + usage_revert: "usage: /fallback revert on|off" + unknown_sub: "unknown subcommand '{sub}'. try /fallback help" + help_1: "/fallback list the fallback model chain" + help_2: "/fallback add append a fallback model" + help_3: "/fallback remove remove a fallback model" + help_4: "/fallback clear empty the chain" + help_5: "/fallback revert on|off auto-revert to primary on cooldown" + help_6: "(no args opens an interactive menu)" + model_label: "Fallback model" + model_desc: "model name tried when the primary fails (e.g. gpt-4o-mini)" + revert_already: "revert policy already '{policy}'" + revert_set_on: "revert policy: on (auto-revert)" + revert_set_off: "revert policy: off (stay)" + + fork: + forked: "forked session {short} (from {parent})" + switch_failed: "fork created but switch failed: {error}" + switch_manual: "use `/resume {short}` to switch manually" + failed: "fork failed: {error}" + store_unavailable: "session store unavailable; cannot fork" + + sessions: + none: "(no saved sessions)" + switch_failed: "failed to switch session: {error}" + list_failed: "failed to list sessions: {error}" + + export: + md_header: "# aish session export" + session_label: "- **session**: `{uuid}`" + model_label: "- **model**: {model}" + api_base_label: "- **api_base**: {base}" + forked_label: "- **forked from**: `{parent}`" + created_label: "- **created**: {created}" + working_dir: "**working dir**: `{cwd}`" + conversation_header: "## Conversation" + history_header: "## Command history" + exported: "exported {file} ({msgs} messages, {cmds} commands)" + write_failed: "failed to write {file}: {error}" + usage: "usage: /export [md] (only markdown is supported)" + store_unavailable: "session store unavailable; cannot export" + not_found: "current session not found in store" + load_failed: "failed to load session: {error}" + + common: + cancelled: "cancelled" + session_store_unavailable: "session store unavailable" + config_save_warning: "warning: failed to save config: {error}" record: started: "⏺ Recording started: {path}" @@ -667,6 +856,36 @@ shell: switch_same: "Model unchanged: {model}" switch_success: "Switched to model: {model}" verify_failed: "Model validation failed: {reason}" + picker_title: "Switch model / account" + picker_subtitle: "Up/Dn to select, Enter to switch" + fetch_failed_subtitle: "Could not fetch model list (endpoint unreachable) — showing current + history only" + picker_search: "Search models or URLs..." + picker_footer: "a add account m manage Esc cancel" + action_add: "add" + action_del: "delete" + action_toggle: "toggle" + action_edit: "edit" + edit_label: "Model name" + edit_desc: "Model this account uses" + edited: "Updated '{name}' model to {model}" + cannot_remove_primary: "The primary model cannot be removed" + switched_account: "Switched to '{name}' (model: {model})" + fallback_badge: "fallback" + primary_badge: "primary" + recent_badge: "recent" + action_fallback: "fallback" + fallback_not_switchable: "Fallback model '{model}' is not directly switchable; it is tried only when the primary fails" + fallback_add_label: "Fallback model name" + fallback_add_desc: "Model tried when the primary fails" + fallback_added: "Added fallback '{model}'" + fallback_removed: "Removed fallback '{model}'" + fallback_exists: "Fallback '{model}' already exists" + action_manage: "manage" + manage_title: "Accounts & fallback" + manage_search: "Search..." + manage_subtitle: "d to delete the highlighted entry" + manage_footer: "d delete f add fallback Esc back" + manage_empty: "No accounts or fallbacks to manage" token: title: "Token Usage (last 7 days)" @@ -674,6 +893,17 @@ shell: output_tokens: "Output tokens" total: "Total" api_calls: "API calls" + today: "Today" + session: "This session" + rotation: "Account rotation" + rotation_avail: "{available}/{total} available" + rotation_count: "{count} rotations this session" + state_active: "active" + state_cooling: "cooling down" + state_ready: "ready" + active_model: "Active model: {model}" + fallback_active: "fallback active" + fallback_chain: "Fallback chain: {chain}" setup: no_config_manager: "Cannot reconfigure: configuration manager unavailable" diff --git a/crates/aish-i18n/locales/es-ES.yaml b/crates/aish-i18n/locales/es-ES.yaml index 0c1d1dfc..58a31d85 100644 --- a/crates/aish-i18n/locales/es-ES.yaml +++ b/crates/aish-i18n/locales/es-ES.yaml @@ -183,6 +183,195 @@ shell: audit: "Consultar el registro de auditoría (quién/cuándo/qué/sugerencia de IA/confirmación)" forget-approvals: "Borrar aprobaciones de comandos recordadas (esta sesión)" skill: "Buscar, instalar, verificar skills; gestionar registros" + accounts: "Gestionar cuentas de rotación multi-clave" + fallback: "Gestionar la cadena de modelos de respaldo" + fork: "Bifurcar la sesión actual" + sessions: "Mostrar el árbol de sesiones" + export: "Exportar la sesión actual a Markdown" + + menu: + accounts: + title: "Cuentas de rotación" + list: "Listar cuentas" + list_desc: "mostrar todas las cuentas de rotación + estado" + add: "Añadir cuenta" + add_desc: "registrar una nueva clave API" + remove: "Eliminar cuenta" + remove_desc: "eliminar una cuenta" + toggle: "Activar / desactivar" + toggle_desc: "pausar o reanudar una cuenta" + use: "Cambiar cuenta" + use_desc: "Cambiar la cuenta activa" + remove_title: "Eliminar cuenta" + toggle_title: "Activar / desactivar cuenta" + fallback: + title: "Cadena de modelos de respaldo" + list: "Listar cadena" + list_desc: "mostrar modelos primario + de respaldo y la política" + add: "Añadir modelo" + add_desc: "añadir un modelo de respaldo" + remove: "Eliminar modelo" + remove_desc: "quitar un modelo de la cadena" + clear: "Vaciar cadena" + clear_desc: "vaciar la cadena de respaldo" + revert: "Alternar revertir" + revert_desc: "auto-revertir al primario on/off" + remove_title: "Eliminar modelo de respaldo" + revert_title: "Política de reversión" + on: "Auto-revertir" + on_desc: "volver al primario tras el enfriamiento (recomendado)" + off: "Mantener respaldo" + off_desc: "mantener respaldo hasta cambio manual" + audit: + title: "Registro de auditoría" + recent: "Eventos recientes" + recent_desc: "los últimos 20 en total" + user: "Filtrar por usuario" + user_desc: "mostrar eventos de un usuario" + host: "Filtrar por host" + host_desc: "mostrar eventos en un host" + type: "Filtrar por tipo de evento" + type_desc: "mostrar un tipo de evento" + user_label: "Nombre de usuario" + host_label: "Host" + type_label: "Tipo de evento" + doctor: + title: "Diagnóstico del sistema" + run: "Ejecutar diagnósticos" + run_desc: "comprobar la salud del sistema" + fix: "Ejecutar con auto-fix" + fix_desc: "intentar correcciones automáticas" + record: + title: "Grabación de terminal" + start: "Iniciar grabación" + start_desc: "comenzar una nueva grabación de terminal" + stop: "Detener grabación" + stop_desc: "finalizar y guardar la grabación de terminal" + plan: + title: "Modo plan" + start: "Iniciar planificación" + start_desc: "entrar en modo plan de solo lectura" + status: "Mostrar estado" + status_desc: "estado actual del plan" + exit: "Salir del modo plan" + exit_desc: "abandonar la planificación" + not_in_plan: "No estás en modo plan. Usa `/plan start` (o `; `) para comenzar a planificar." + kill: + title: "Terminar sesiones activas" + all: "Terminar todas las demás" + all_desc: "todas las sesiones excepto la actual" + + accounts: + list_title: "Cuentas de rotación" + primary: " [0] primaria key={key}... {state}" + key_preview: "key={key}..." + unset: "(sin definir)" + enabled: "habilitada" + disabled: "deshabilitada" + added: "cuenta '{name}' añadida ({count} cuentas adicionales)" + removed: "cuenta '{name}' eliminada" + already_exists: "la cuenta '{name}' ya existe" + toggled: "{action} cuenta '{name}'" + no_extra_remove: "no hay cuentas adicionales que eliminar" + no_extra_toggle: "no hay cuentas adicionales que alternar" + no_account_named: "no hay ninguna cuenta llamada '{name}'" + no_extra_hint: " (sin cuentas adicionales; añade una con /accounts add )" + usage_add: "uso: /accounts add [api_base]" + usage_remove: "uso: /accounts remove " + usage_enable: "uso: /accounts enable|disable " + unknown_sub: "subcomando desconocido '{sub}'. prueba /accounts help" + help_1: "/accounts listar cuentas de rotación" + help_2: "/accounts add [base]" + help_3: "/accounts remove " + help_4: "/accounts enable|disable " + help_5: "(sin argumentos abre un menú interactivo)" + name_label: "Nombre de cuenta" + name_desc: "etiqueta corta, p. ej. 'team-b'" + key_label: "Clave API" + key_desc: "la clave para esta cuenta" + base_label: "API base (opcional)" + base_desc: "sobrescribir endpoint; vacío = igual que primaria" + custom_model: "Personalizado (introducir manualmente)" + model_label: "Modelo" + model_desc: "Nombre del modelo utilizado por este endpoint" + select_model: "Selecciona un modelo para verificar" + no_models: "No hay modelos listados; usando el modelo principal '{model}'" + fetch_failed: "Error al obtener modelos: {error}" + verifying: "Verificando conectividad..." + connected: "Conectado ({latency}ms) - modelo: {model}" + tool_yes: "Llamada a herramientas: soportada" + tool_no: "Llamada a herramientas: no soportada" + verify_failed: "Comprobación de conexión fallida: {error}" + verify_failed_title: "Verificación fallida" + save_anyway: "Guardar de todos modos" + cancel_add: "Cancelar" + usage_use: "uso: /accounts use " + switched: "Cambiado a '{name}' (modelo: {model})" + no_extra_use: "No hay cuentas adicionales para cambiar. Añade una con /accounts add." + primary_choice: "Principal ({model})" + select_use: "Selecciona la cuenta a usar" + + fallback: + chain_title: "Cadena de modelos de respaldo" + primary: " primario : {model}" + none: " respaldo : (sin configurar)" + policy_auto: " política : revertir al primario tras enfriamiento" + policy_stay: " política : mantener respaldo hasta cambio manual" + added: "respaldo '{model}' añadido" + removed: "respaldo '{model}' eliminado" + cleared: "cadena de respaldo vaciada" + already_in_chain: "'{model}' ya está en la cadena de respaldo" + not_in_chain: "'{model}' no está en la cadena de respaldo" + empty: "la cadena de respaldo está vacía" + usage_add: "uso: /fallback add " + usage_remove: "uso: /fallback remove " + usage_revert: "uso: /fallback revert on|off" + unknown_sub: "subcomando desconocido '{sub}'. prueba /fallback help" + help_1: "/fallback listar la cadena de modelos de respaldo" + help_2: "/fallback add añadir un modelo de respaldo" + help_3: "/fallback remove eliminar un modelo de respaldo" + help_4: "/fallback clear vaciar la cadena" + help_5: "/fallback revert on|off revertir al primario tras enfriamiento" + help_6: "(sin argumentos abre un menú interactivo)" + model_label: "Modelo de respaldo" + model_desc: "nombre del modelo probado cuando el primario falla (p. ej. gpt-4o-mini)" + revert_already: "la política de reversión ya es '{policy}'" + revert_set_on: "política de reversión: on (auto-revertir)" + revert_set_off: "política de reversión: off (mantener)" + + fork: + forked: "sesión bifurcada {short} (de {parent})" + switch_failed: "bifurcación creada pero el cambio falló: {error}" + switch_manual: "usa `/resume {short}` para cambiar manualmente" + failed: "bifurcación fallida: {error}" + store_unavailable: "almacén de sesiones no disponible; no se puede bifurcar" + + sessions: + none: "(sin sesiones guardadas)" + switch_failed: "fallo al cambiar de sesión: {error}" + list_failed: "fallo al listar sesiones: {error}" + + export: + md_header: "# exportación de sesión aish" + session_label: "- **sesión**: `{uuid}`" + model_label: "- **modelo**: {model}" + api_base_label: "- **api_base**: {base}" + forked_label: "- **bifurcada de**: `{parent}`" + created_label: "- **creada**: {created}" + working_dir: "**directorio de trabajo**: `{cwd}`" + conversation_header: "## Conversación" + history_header: "## Historial de comandos" + exported: "exportado {file} ({msgs} mensajes, {cmds} comandos)" + write_failed: "fallo al escribir {file}: {error}" + usage: "uso: /export [md] (solo se admite markdown)" + store_unavailable: "almacén de sesiones no disponible; no se puede exportar" + not_found: "sesión actual no encontrada en el almacén" + load_failed: "fallo al cargar la sesión: {error}" + + common: + cancelled: "cancelado" + session_store_unavailable: "almacén de sesiones no disponible" + config_save_warning: "aviso: no se pudo guardar la configuración: {error}" feedback: select_type: "Seleccionar tipo de comentario" @@ -312,12 +501,53 @@ shell: switch_same: "El modelo no ha cambiado: {model}" switch_success: "Se cambió al modelo: {model}" verify_failed: "Falló la validación del modelo: {reason}" + picker_title: "Cambiar modelo / cuenta" + picker_subtitle: "↑↓ seleccionar, Enter cambiar" + fetch_failed_subtitle: "No se pudo obtener la lista de modelos (endpoint inalcanzable) — solo actual e historial" + picker_search: "Buscar modelos o URLs..." + picker_footer: "a anadir cuenta m gestionar Esc cancelar" + action_add: "anadir" + action_del: "borrar" + action_toggle: "act/des" + action_edit: "editar" + edit_label: "Nombre del modelo" + edit_desc: "Modelo de esta cuenta" + edited: "Actualizado modelo de '{name}' a {model}" + cannot_remove_primary: "El modelo principal no se puede eliminar" + switched_account: "Cambiado a '{name}' (modelo: {model})" + fallback_badge: "respaldo" + primary_badge: "Principal" + recent_badge: "reciente" + action_fallback: "respaldo" + fallback_not_switchable: "El modelo de respaldo '{model}' no se puede cambiar directamente; se prueba solo cuando el principal falla" + fallback_add_label: "Nombre del modelo de respaldo" + fallback_add_desc: "Modelo probado cuando el principal falla" + fallback_added: "Anadido respaldo '{model}'" + fallback_removed: "Eliminado respaldo '{model}'" + fallback_exists: "El respaldo '{model}' ya existe" + action_manage: "gestionar" + manage_title: "Cuentas y respaldo" + manage_search: "Buscar..." + manage_subtitle: "d para borrar la entrada seleccionada" + manage_footer: "d borrar f anadir respaldo Esc volver" + manage_empty: "No hay cuentas ni respaldos que gestionar" token: title: "Uso de tokens (últimos 7 días)" input_tokens: "Tokens de entrada" output_tokens: "Tokens de salida" total: "Total" api_calls: "Llamadas API" + today: "Hoy" + session: "Esta sesión" + rotation: "Rotación de cuentas" + rotation_avail: "{available}/{total} disponibles" + rotation_count: "{count} rotaciones en esta sesión" + state_active: "activo" + state_cooling: "en enfriamiento" + state_ready: "listo" + active_model: "Modelo activo: {model}" + fallback_active: "respaldo activo" + fallback_chain: "Cadena de respaldo: {chain}" setup: no_config_manager: "No se puede reconfigurar: el gestor de configuración no está disponible" cancelled: "Configuración cancelada; se conservan los ajustes actuales" diff --git a/crates/aish-i18n/locales/fr-FR.yaml b/crates/aish-i18n/locales/fr-FR.yaml index 5d99e477..5dd831c1 100644 --- a/crates/aish-i18n/locales/fr-FR.yaml +++ b/crates/aish-i18n/locales/fr-FR.yaml @@ -183,6 +183,195 @@ shell: audit: "Consulter le journal d'audit (qui/quand/quoi/suggestion IA/confirmation)" forget-approvals: "Effacer les approbations mémorisées (cette session)" skill: "Rechercher, installer, vérifier des skills; gérer les registres" + accounts: "Gérer les comptes de rotation multi-clés" + fallback: "Gérer la chaîne des modèles de secours" + fork: "Créer une branche de la session actuelle" + sessions: "Afficher l'arborescence des sessions" + export: "Exporter la session actuelle en Markdown" + + menu: + accounts: + title: "Comptes de rotation" + list: "Lister les comptes" + list_desc: "afficher tous les comptes de rotation + état" + add: "Ajouter un compte" + add_desc: "enregistrer une nouvelle clé API" + remove: "Supprimer le compte" + remove_desc: "supprimer un compte" + toggle: "Activer / désactiver" + toggle_desc: "mettre en pause ou reprendre un compte" + use: "Changer de compte" + use_desc: "Changer le compte actif" + remove_title: "Supprimer le compte" + toggle_title: "Activer / désactiver le compte" + fallback: + title: "Chaîne de modèles de secours" + list: "Lister la chaîne" + list_desc: "afficher les modèles primaire + de secours et la politique" + add: "Ajouter un modèle" + add_desc: "ajouter un modèle de secours" + remove: "Supprimer le modèle" + remove_desc: "retirer un modèle de la chaîne" + clear: "Vider la chaîne" + clear_desc: "vider la chaîne de secours" + revert: "Basculer la réversion" + revert_desc: "réversion auto vers le primaire on/off" + remove_title: "Supprimer le modèle de secours" + revert_title: "Politique de réversion" + on: "Réversion auto" + on_desc: "revenir au primaire après le délai (recommandé)" + off: "Rester sur le secours" + off_desc: "garder le secours jusqu'au basculement manuel" + audit: + title: "Journal d'audit" + recent: "Événements récents" + recent_desc: "les 20 derniers tous confondus" + user: "Filtrer par utilisateur" + user_desc: "afficher les événements d'un utilisateur" + host: "Filtrer par hôte" + host_desc: "afficher les événements sur un hôte" + type: "Filtrer par type d'événement" + type_desc: "afficher un type d'événement" + user_label: "Nom d'utilisateur" + host_label: "Hôte" + type_label: "Type d'événement" + doctor: + title: "Diagnostic système" + run: "Lancer les diagnostics" + run_desc: "vérifier l'état du système" + fix: "Lancer avec auto-correction" + fix_desc: "tenter des corrections automatiques" + record: + title: "Enregistrement du terminal" + start: "Démarrer l'enregistrement" + start_desc: "commencer un nouvel enregistrement de terminal" + stop: "Arrêter l'enregistrement" + stop_desc: "terminer et sauvegarder l'enregistrement du terminal" + plan: + title: "Mode plan" + start: "Commencer la planification" + start_desc: "entrer en mode plan en lecture seule" + status: "Afficher le statut" + status_desc: "état actuel du plan" + exit: "Quitter le mode plan" + exit_desc: "quitter la planification" + not_in_plan: "Pas en mode plan. Utilisez `/plan start` (ou `; `) pour commencer à planifier." + kill: + title: "Terminer les sessions actives" + all: "Terminer toutes les autres" + all_desc: "toutes les sessions sauf l'actuelle" + + accounts: + list_title: "Comptes de rotation" + primary: " [0] primaire key={key}... {state}" + key_preview: "key={key}..." + unset: "(non défini)" + enabled: "activé" + disabled: "désactivé" + added: "compte '{name}' ajouté ({count} comptes supplémentaires)" + removed: "compte '{name}' supprimé" + already_exists: "le compte '{name}' existe déjà" + toggled: "{action} compte '{name}'" + no_extra_remove: "aucun compte supplémentaire à supprimer" + no_extra_toggle: "aucun compte supplémentaire à basculer" + no_account_named: "aucun compte nommé '{name}'" + no_extra_hint: " (aucun compte supplémentaire ; ajoutez-en un avec /accounts add )" + usage_add: "usage : /accounts add [api_base]" + usage_remove: "usage : /accounts remove " + usage_enable: "usage : /accounts enable|disable " + unknown_sub: "sous-commande inconnue '{sub}'. essayez /accounts help" + help_1: "/accounts lister les comptes de rotation" + help_2: "/accounts add [base]" + help_3: "/accounts remove " + help_4: "/accounts enable|disable " + help_5: "(sans argument, un menu interactif s'ouvre)" + name_label: "Nom du compte" + name_desc: "étiquette courte, par ex. 'team-b'" + key_label: "Clé API" + key_desc: "la clé pour ce compte" + base_label: "API base (optionnel)" + base_desc: "remplacer le point de terminaison ; vide = comme le primaire" + custom_model: "Personnalisé (saisir manuellement)" + model_label: "Modèle" + model_desc: "Nom du modèle utilisé par ce point de terminaison" + select_model: "Sélectionnez un modèle à vérifier" + no_models: "Aucun modèle listé ; utilisation du modèle principal '{model}'" + fetch_failed: "Échec de la récupération des modèles : {error}" + verifying: "Vérification de la connectivité..." + connected: "Connecté ({latency}ms) - modèle : {model}" + tool_yes: "Appel d'outils : pris en charge" + tool_no: "Appel d'outils : non pris en charge" + verify_failed: "Échec de la vérification de la connexion : {error}" + verify_failed_title: "Échec de la vérification" + save_anyway: "Enregistrer quand même" + cancel_add: "Annuler" + usage_use: "usage : /accounts use " + switched: "Basculé vers '{name}' (modèle : {model})" + no_extra_use: "Aucun compte supplémentaire à activer. Ajoutez-en un avec /accounts add." + primary_choice: "Principal ({model})" + select_use: "Sélectionner le compte à utiliser" + + fallback: + chain_title: "Chaîne de modèles de repli" + primary: " primaire : {model}" + none: " repli : (non configuré)" + policy_auto: " politique : revenir au primaire après refroidissement" + policy_stay: " politique : rester sur le repli jusqu'à changement manuel" + added: "repli '{model}' ajouté" + removed: "repli '{model}' supprimé" + cleared: "chaîne de repli vidée" + already_in_chain: "'{model}' est déjà dans la chaîne de repli" + not_in_chain: "'{model}' n'est pas dans la chaîne de repli" + empty: "la chaîne de repli est vide" + usage_add: "usage : /fallback add " + usage_remove: "usage : /fallback remove " + usage_revert: "usage : /fallback revert on|off" + unknown_sub: "sous-commande inconnue '{sub}'. essayez /fallback help" + help_1: "/fallback lister la chaîne de modèles de repli" + help_2: "/fallback add ajouter un modèle de repli" + help_3: "/fallback remove retirer un modèle de repli" + help_4: "/fallback clear vider la chaîne" + help_5: "/fallback revert on|off revenir au primaire après refroidissement" + help_6: "(sans argument, un menu interactif s'ouvre)" + model_label: "Modèle de repli" + model_desc: "nom du modèle essayé quand le primaire échoue (par ex. gpt-4o-mini)" + revert_already: "la politique de repli est déjà '{policy}'" + revert_set_on: "politique de repli : on (auto-retour)" + revert_set_off: "politique de repli : off (rester)" + + fork: + forked: "session bifurquée {short} (depuis {parent})" + switch_failed: "bifurcation créée mais le changement a échoué : {error}" + switch_manual: "utilisez `/resume {short}` pour changer manuellement" + failed: "échec de la bifurcation : {error}" + store_unavailable: "stockage de session indisponible ; bifurcation impossible" + + sessions: + none: "(aucune session enregistrée)" + switch_failed: "échec du changement de session : {error}" + list_failed: "échec de la liste des sessions : {error}" + + export: + md_header: "# exportation de session aish" + session_label: "- **session** : `{uuid}`" + model_label: "- **modèle** : {model}" + api_base_label: "- **api_base** : {base}" + forked_label: "- **bifurquée de** : `{parent}`" + created_label: "- **créée** : {created}" + working_dir: "**répertoire de travail** : `{cwd}`" + conversation_header: "## Conversation" + history_header: "## Historique des commandes" + exported: "exporté {file} ({msgs} messages, {cmds} commandes)" + write_failed: "échec d'écriture de {file} : {error}" + usage: "usage : /export [md] (seul markdown est pris en charge)" + store_unavailable: "stockage de session indisponible ; export impossible" + not_found: "session actuelle introuvable dans le stockage" + load_failed: "échec du chargement de la session : {error}" + + common: + cancelled: "annulé" + session_store_unavailable: "stockage de session indisponible" + config_save_warning: "avertissement : échec de l'enregistrement de la configuration : {error}" feedback: select_type: "Sélectionner le type de commentaire" @@ -312,12 +501,53 @@ shell: switch_same: "Modele inchange : {model}" switch_success: "Modele active : {model}" verify_failed: "La validation du modele a echoue : {reason}" + picker_title: "Changer modele / compte" + picker_subtitle: "↑↓ selectionner, Enter basculer" + fetch_failed_subtitle: "Impossible de recuperer la liste des modeles (endpoint injoignable) — actuel + historique seulement" + picker_search: "Rechercher modeles ou URLs..." + picker_footer: "a ajouter compte m gerer Esc annuler" + action_add: "ajouter" + action_del: "supprimer" + action_toggle: "act/des" + action_edit: "editer" + edit_label: "Nom du modele" + edit_desc: "Modele de ce compte" + edited: "Modele de '{name}' mis a jour vers {model}" + cannot_remove_primary: "Le modele principal ne peut pas etre supprime" + switched_account: "Bascule vers '{name}' (modele : {model})" + fallback_badge: "secours" + primary_badge: "Principal" + recent_badge: "recent" + action_fallback: "secours" + fallback_not_switchable: "Le modele de secours '{model}' n'est pas directement commutable; essaye seulement quand le principal echoue" + fallback_add_label: "Nom du modele de secours" + fallback_add_desc: "Modele essaye quand le principal echoue" + fallback_added: "Ajoute secours '{model}'" + fallback_removed: "Supprime secours '{model}'" + fallback_exists: "Le secours '{model}' existe deja" + action_manage: "gerer" + manage_title: "Comptes et secours" + manage_search: "Rechercher..." + manage_subtitle: "d pour supprimer l'entree selectionnee" + manage_footer: "d supprimer f ajouter secours Esc retour" + manage_empty: "Aucun compte ni secours a gerer" token: title: "Utilisation de tokens (7 derniers jours)" input_tokens: "Tokens d'entrée" output_tokens: "Tokens de sortie" total: "Total" api_calls: "Appels API" + today: "Aujourd'hui" + session: "Cette session" + rotation: "Rotation de comptes" + rotation_avail: "{available}/{total} disponibles" + rotation_count: "{count} rotations cette session" + state_active: "actif" + state_cooling: "en refroidissement" + state_ready: "prêt" + active_model: "Modèle actif : {model}" + fallback_active: "repli actif" + fallback_chain: "Chaîne de repli : {chain}" setup: no_config_manager: "Reconfiguration impossible : gestionnaire de configuration indisponible" cancelled: "Configuration annulee, les parametres actuels sont conserves" diff --git a/crates/aish-i18n/locales/ja-JP.yaml b/crates/aish-i18n/locales/ja-JP.yaml index 5ba5fa62..690d8156 100644 --- a/crates/aish-i18n/locales/ja-JP.yaml +++ b/crates/aish-i18n/locales/ja-JP.yaml @@ -183,6 +183,195 @@ shell: audit: "監査ログを照会(誰/いつ/何/AI提案/確認)" forget-approvals: "記憶されたコマンド承認を削除(このセッション)" skill: "スキルの検索・インストール・検証;レジストリ管理" + accounts: "複数キーのローテーションアカウントを管理" + fallback: "モデルのフォールバックチェーンを管理" + fork: "現在のセッションを分岐" + sessions: "セッションツリーを表示" + export: "現在のセッションを Markdown にエクスポート" + + menu: + accounts: + title: "ローテーションアカウント" + list: "アカウント一覧" + list_desc: "すべてのローテーションアカウントと状態を表示" + add: "アカウント追加" + add_desc: "新しい API キーを登録" + remove: "アカウント削除" + remove_desc: "アカウントを 1 件削除" + toggle: "有効 / 無効" + toggle_desc: "アカウントを一時停止または再開" + use: "アカウント切替" + use_desc: "使用するアカウントを切り替え" + remove_title: "アカウント削除" + toggle_title: "アカウントの有効 / 無効" + fallback: + title: "フォールバックモデルチェーン" + list: "チェーン一覧" + list_desc: "プライマリとフォールバックモデル、ポリシーを表示" + add: "モデル追加" + add_desc: "フォールバックモデルを追加" + remove: "モデル削除" + remove_desc: "チェーンからモデルを削除" + clear: "チェーンをクリア" + clear_desc: "フォールバックチェーンを空にする" + revert: "リバート切替" + revert_desc: "プライマリへの自動リバート オン/オフ" + remove_title: "フォールバックモデル削除" + revert_title: "リバートポリシー" + on: "自動リバート" + on_desc: "クールダウン後にプライマリへ戻る(推奨)" + off: "フォールバックを維持" + off_desc: "手動切り替えまでフォールバックを維持" + audit: + title: "監査ログ" + recent: "最近のイベント" + recent_desc: "直近 20 件(すべて)" + user: "ユーザーで絞り込み" + user_desc: "特定ユーザーのイベントを表示" + host: "ホストで絞り込み" + host_desc: "特定ホスト上のイベントを表示" + type: "イベント種別で絞り込み" + type_desc: "特定のイベント種別を表示" + user_label: "ユーザー名" + host_label: "ホスト" + type_label: "イベントタイプ" + doctor: + title: "システムドクター" + run: "診断を実行" + run_desc: "システムの健全性を確認" + fix: "自動修復付きで実行" + fix_desc: "自動修復を試みる" + record: + title: "ターミナル録画" + start: "録画を開始" + start_desc: "新しいターミナル録画を開始" + stop: "録画を停止" + stop_desc: "ターミナル録画を終了して保存" + plan: + title: "プランモード" + start: "プラン開始" + start_desc: "読み取り専用のプランモードに入る" + status: "状態を表示" + status_desc: "現在のプラン状態" + exit: "プランモード終了" + exit_desc: "プランを終了" + not_in_plan: "プランモードではありません。`/plan start`(または `; <リクエスト>`)で計画を開始してください。" + kill: + title: "アクティブセッションを終了" + all: "他をすべて終了" + all_desc: "現在以外のすべてのセッション" + + accounts: + list_title: "ローテーションアカウント" + primary: " [0] プライマリ key={key}... {state}" + key_preview: "key={key}..." + unset: "(未設定)" + enabled: "有効" + disabled: "無効" + added: "アカウント '{name}' を追加しました(追加アカウント {count} 件)" + removed: "アカウント '{name}' を削除しました" + already_exists: "アカウント '{name}' は既に存在します" + toggled: "{action} アカウント '{name}'" + no_extra_remove: "削除できる追加アカウントがありません" + no_extra_toggle: "切り替えられる追加アカウントがありません" + no_account_named: "'{name}' という名前のアカウントはありません" + no_extra_hint: " (追加アカウントはありません;/accounts add <名前> で追加)" + usage_add: "使い方: /accounts add <名前> [api_base]" + usage_remove: "使い方: /accounts remove <名前>" + usage_enable: "使い方: /accounts enable|disable <名前>" + unknown_sub: "不明なサブコマンド '{sub}'。/accounts help を試してください" + help_1: "/accounts ローテーションアカウント一覧" + help_2: "/accounts add <名前> [base]" + help_3: "/accounts remove <名前>" + help_4: "/accounts enable|disable <名前>" + help_5: "(引数なしで対話メニューを開きます)" + name_label: "アカウント名" + name_desc: "短いラベル、例: 'team-b'" + key_label: "API キー" + key_desc: "このアカウントのキー" + base_label: "API ベース(任意)" + base_desc: "エンドポイントを上書き;空欄 = プライマリと同じ" + custom_model: "カスタム(手動入力)" + model_label: "モデル" + model_desc: "このエンドポイントが使用するモデル名" + select_model: "検証するモデルを選択してください" + no_models: "モデルがリストされていません;プライマリモデル '{model}' を使用します" + fetch_failed: "モデルの取得に失敗しました:{error}" + verifying: "接続を検証しています..." + connected: "接続済み({latency}ms)— モデル:{model}" + tool_yes: "ツール呼び出し:対応" + tool_no: "ツール呼び出し:非対応" + verify_failed: "接続確認に失敗しました:{error}" + verify_failed_title: "検証に失敗しました" + save_anyway: "それでも保存" + cancel_add: "キャンセル" + usage_use: "使い方: /accounts use <名前>" + switched: "'{name}' に切り替えました(モデル: {model})" + no_extra_use: "切り替え可能なアカウントがありません。/accounts add で追加してください。" + primary_choice: "プライマリ ({model})" + select_use: "使用するアカウントを選択" + + fallback: + chain_title: "フォールバックモデルチェーン" + primary: " プライマリ : {model}" + none: " フォールバック:(未設定)" + policy_auto: " ポリシー : クールダウン後にプライマリへ自動復帰" + policy_stay: " ポリシー : 手動切り替えまでフォールバックを維持" + added: "フォールバック '{model}' を追加しました" + removed: "フォールバック '{model}' を削除しました" + cleared: "フォールバックチェーンを空にしました" + already_in_chain: "'{model}' は既にフォールバックチェーンにあります" + not_in_chain: "'{model}' はフォールバックチェーンにありません" + empty: "フォールバックチェーンが空です" + usage_add: "使い方: /fallback add <モデル>" + usage_remove: "使い方: /fallback remove <モデル>" + usage_revert: "使い方: /fallback revert on|off" + unknown_sub: "不明なサブコマンド '{sub}'。/fallback help を試してください" + help_1: "/fallback フォールバックモデルチェーン一覧" + help_2: "/fallback add <モデル> フォールバックモデルを追加" + help_3: "/fallback remove <モデル> フォールバックモデルを削除" + help_4: "/fallback clear チェーンを空にする" + help_5: "/fallback revert on|off クールダウン後にプライマリへ自動復帰" + help_6: "(引数なしで対話メニューを開きます)" + model_label: "フォールバックモデル" + model_desc: "プライマリ失敗時に試行されるモデル名(例: gpt-4o-mini)" + revert_already: "復帰ポリシーは既に '{policy}' です" + revert_set_on: "復帰ポリシー: on(自動復帰)" + revert_set_off: "復帰ポリシー: off(維持)" + + fork: + forked: "セッションをフォークしました {short}(親 {parent})" + switch_failed: "フォークを作成しましたが切り替えに失敗しました: {error}" + switch_manual: "`/resume {short}` で手動切り替えしてください" + failed: "フォーク失敗: {error}" + store_unavailable: "セッションストアが利用できません;フォークできません" + + sessions: + none: "(保存済みセッションはありません)" + switch_failed: "セッションの切り替えに失敗しました: {error}" + list_failed: "セッション一覧の取得に失敗しました: {error}" + + export: + md_header: "# aish セッションエクスポート" + session_label: "- **セッション**: `{uuid}`" + model_label: "- **モデル**: {model}" + api_base_label: "- **api_base**: {base}" + forked_label: "- **フォーク元**: `{parent}`" + created_label: "- **作成**: {created}" + working_dir: "**作業ディレクトリ**: `{cwd}`" + conversation_header: "## 会話" + history_header: "## コマンド履歴" + exported: "エクスポートしました {file}({msgs} メッセージ、{cmds} コマンド)" + write_failed: "{file} の書き込みに失敗しました: {error}" + usage: "使い方: /export [md] (markdown のみ対応)" + store_unavailable: "セッションストアが利用できません;エクスポートできません" + not_found: "ストアに現在のセッションが見つかりません" + load_failed: "セッションの読み込みに失敗しました: {error}" + + common: + cancelled: "キャンセルしました" + session_store_unavailable: "セッションストアが利用できません" + config_save_warning: "警告: 設定の保存に失敗しました: {error}" feedback: select_type: "フィードバックの種類を選択" @@ -312,12 +501,53 @@ shell: switch_same: "モデルは変更されていません: {model}" switch_success: "モデルを切り替えました: {model}" verify_failed: "モデル検証に失敗しました: {reason}" + picker_title: "モデル / アカウント切替" + picker_subtitle: "↑↓ 選択、Enter で切替" + fetch_failed_subtitle: "モデルリストを取得できません(endpointに到達不能)— 現在と履歴のみ表示" + picker_search: "モデルや URL を検索..." + picker_footer: "a アカウント追加 m 管理 Esc キャンセル" + action_add: "追加" + action_del: "削除" + action_toggle: "切替" + action_edit: "編集" + edit_label: "モデル名" + edit_desc: "このアカウントが使用するモデル" + edited: "'{name}' のモデルを {model} に更新しました" + cannot_remove_primary: "プライマリモデルは削除できません" + switched_account: "'{name}' に切り替えました(モデル: {model})" + fallback_badge: "フォールバック" + primary_badge: "プライマリ" + recent_badge: "最近" + action_fallback: "フォールバック" + fallback_not_switchable: "フォールバックモデル '{model}' は直接切替不可。主モデル失敗時のみ試行されます" + fallback_add_label: "フォールバックモデル名" + fallback_add_desc: "主モデル失敗時に試行されるモデル" + fallback_added: "フォールバック '{model}' を追加しました" + fallback_removed: "フォールバック '{model}' を削除しました" + fallback_exists: "フォールバック '{model}' は既に存在します" + action_manage: "管理" + manage_title: "アカウントとフォールバック管理" + manage_search: "検索..." + manage_subtitle: "d 選択項目を削除" + manage_footer: "d 削除 f フォールバック追加 Esc 戻る" + manage_empty: "管理するアカウントやフォールバックがありません" token: title: "Token 使用量 (過去7日間)" input_tokens: "入力 tokens" output_tokens: "出力 tokens" total: "合計" api_calls: "API 呼び出し回数" + today: "今日" + session: "このセッション" + rotation: "アカウントローテーション" + rotation_avail: "利用可能 {available}/{total}" + rotation_count: "このセッションのローテーション {count} 回" + state_active: "使用中" + state_cooling: "クールダウン中" + state_ready: "準備完了" + active_model: "現在のモデル: {model}" + fallback_active: "フォールバック動作中" + fallback_chain: "フォールバックチェーン: {chain}" setup: no_config_manager: "再設定できません: 設定マネージャーを利用できません" cancelled: "セットアップをキャンセルしました。現在の設定を維持します" diff --git a/crates/aish-i18n/locales/zh-CN.yaml b/crates/aish-i18n/locales/zh-CN.yaml index 187d599a..333b1638 100644 --- a/crates/aish-i18n/locales/zh-CN.yaml +++ b/crates/aish-i18n/locales/zh-CN.yaml @@ -368,6 +368,195 @@ shell: audit: "查询审计日志(谁/何时/什么/AI建议/确认)" forget-approvals: "清除本会话记住的命令审批" skill: "搜索、安装、校验技能;管理 registry 源" + accounts: "管理多密钥轮换账户" + fallback: "管理模型备用链" + fork: "分支当前会话" + sessions: "显示会话树" + export: "导出当前会话为 Markdown" + + menu: + accounts: + title: "轮换账户" + list: "列出账户" + list_desc: "显示所有轮换账户及状态" + add: "添加账户" + add_desc: "注册新的 API Key" + remove: "删除账户" + remove_desc: "删除一个账户" + toggle: "启用 / 停用" + toggle_desc: "暂停或恢复一个账户" + use: "切换账户" + use_desc: "切换当前使用的账户" + remove_title: "删除账户" + toggle_title: "启用 / 停用账户" + fallback: + title: "模型回退链" + list: "查看回退链" + list_desc: "显示主模型与回退模型及策略" + add: "添加模型" + add_desc: "追加一个回退模型" + remove: "移除模型" + remove_desc: "从回退链中移除一个模型" + clear: "清空回退链" + clear_desc: "清空所有回退模型" + revert: "切换回退策略" + revert_desc: "是否自动回退到主模型" + remove_title: "移除回退模型" + revert_title: "回退策略" + on: "自动回退" + on_desc: "冷却后返回主模型(推荐)" + off: "保持回退" + off_desc: "保持回退直到手动切换" + audit: + title: "审计日志" + recent: "最近事件" + recent_desc: "最近 20 条(全部)" + user: "按用户筛选" + user_desc: "查看某个用户的事件" + host: "按主机筛选" + host_desc: "查看某台主机上的事件" + type: "按事件类型筛选" + type_desc: "查看某类事件" + user_label: "用户名" + host_label: "主机" + type_label: "事件类型" + doctor: + title: "系统诊断" + run: "运行诊断" + run_desc: "检查系统健康状态" + fix: "运行并自动修复" + fix_desc: "尝试自动修复问题" + record: + title: "终端录制" + start: "开始录制" + start_desc: "开始一段新的终端录制" + stop: "停止录制" + stop_desc: "结束并保存终端录制" + plan: + title: "计划模式" + start: "开始规划" + start_desc: "进入只读计划模式" + status: "查看状态" + status_desc: "当前计划状态" + exit: "退出计划模式" + exit_desc: "退出规划" + not_in_plan: "当前不在计划模式。使用 `/plan start`(或 `; <请求>`)开始规划。" + kill: + title: "终止活跃会话" + all: "终止其他全部" + all_desc: "除当前会话外的所有会话" + + accounts: + list_title: "轮换账号" + primary: " [0] 主账号 key={key}... {state}" + key_preview: "key={key}..." + unset: "(未设置)" + enabled: "已启用" + disabled: "已禁用" + added: "已添加账号 '{name}'(共 {count} 个额外账号)" + removed: "已移除账号 '{name}'" + already_exists: "账号 '{name}' 已存在" + toggled: "{action} 账号 '{name}'" + no_extra_remove: "没有可移除的额外账号" + no_extra_toggle: "没有可切换的额外账号" + no_account_named: "没有名为 '{name}' 的账号" + no_extra_hint: " (暂无额外账号;用 /accounts add <名称> 添加)" + usage_add: "用法:/accounts add <名称> [api_base]" + usage_remove: "用法:/accounts remove <名称>" + usage_enable: "用法:/accounts enable|disable <名称>" + unknown_sub: "未知子命令 '{sub}'。试试 /accounts help" + help_1: "/accounts 列出轮换账号" + help_2: "/accounts add <名称> [base]" + help_3: "/accounts remove <名称>" + help_4: "/accounts enable|disable <名称>" + help_5: "(不带参数将打开交互菜单)" + name_label: "账号名称" + name_desc: "简短标签,例如 'team-b'" + key_label: "API key" + key_desc: "该账号的密钥" + base_label: "API base(可选)" + base_desc: "覆盖端点;留空 = 与主账号相同" + custom_model: "自定义(手动输入)" + model_label: "模型" + model_desc: "该端点使用的模型名" + select_model: "选择要验证的模型" + no_models: "未列出模型;使用主模型 '{model}'" + fetch_failed: "获取模型失败:{error}" + verifying: "正在验证连通性..." + connected: "已连接({latency}ms)— 模型:{model}" + tool_yes: "工具调用:支持" + tool_no: "工具调用:不支持" + verify_failed: "连通性检测失败:{error}" + verify_failed_title: "验证失败" + save_anyway: "仍然保存" + cancel_add: "取消" + usage_use: "用法:/accounts use <名称>" + switched: "已切换到账户 '{name}'(模型:{model})" + no_extra_use: "没有可切换的额外账户。用 /accounts add 添加。" + primary_choice: "主账号 ({model})" + select_use: "选择要使用的账户" + + fallback: + chain_title: "回退模型链" + primary: " 主模型 : {model}" + none: " 回退 :(未配置)" + policy_auto: " 策略 : 冷却后自动回退到主模型" + policy_stay: " 策略 : 保持回退直到手动切换" + added: "已添加回退 '{model}'" + removed: "已移除回退 '{model}'" + cleared: "已清空回退链" + already_in_chain: "'{model}' 已在回退链中" + not_in_chain: "'{model}' 不在回退链中" + empty: "回退链为空" + usage_add: "用法:/fallback add <模型>" + usage_remove: "用法:/fallback remove <模型>" + usage_revert: "用法:/fallback revert on|off" + unknown_sub: "未知子命令 '{sub}'。试试 /fallback help" + help_1: "/fallback 列出回退模型链" + help_2: "/fallback add <模型> 追加一个回退模型" + help_3: "/fallback remove <模型> 移除一个回退模型" + help_4: "/fallback clear 清空回退链" + help_5: "/fallback revert on|off 冷却后自动回退到主模型" + help_6: "(不带参数将打开交互菜单)" + model_label: "回退模型" + model_desc: "主模型失败时尝试的模型名(例如 gpt-4o-mini)" + revert_already: "回退策略已是 '{policy}'" + revert_set_on: "回退策略:on(自动回退)" + revert_set_off: "回退策略:off(保持)" + + fork: + forked: "已分叉会话 {short}(来自 {parent})" + switch_failed: "分叉已创建但切换失败:{error}" + switch_manual: "使用 `/resume {short}` 手动切换" + failed: "分叉失败:{error}" + store_unavailable: "会话存储不可用;无法分叉" + + sessions: + none: "(无已保存会话)" + switch_failed: "切换会话失败:{error}" + list_failed: "列出会话失败:{error}" + + export: + md_header: "# aish 会话导出" + session_label: "- **会话**:`{uuid}`" + model_label: "- **模型**:{model}" + api_base_label: "- **api_base**:{base}" + forked_label: "- **分叉自**:`{parent}`" + created_label: "- **创建时间**:{created}" + working_dir: "**工作目录**:`{cwd}`" + conversation_header: "## 对话" + history_header: "## 命令历史" + exported: "已导出 {file}({msgs} 条消息,{cmds} 条命令)" + write_failed: "写入 {file} 失败:{error}" + usage: "用法:/export [md] (仅支持 markdown)" + store_unavailable: "会话存储不可用;无法导出" + not_found: "在存储中找不到当前会话" + load_failed: "加载会话失败:{error}" + + common: + cancelled: "已取消" + session_store_unavailable: "会话存储不可用" + config_save_warning: "警告:保存配置失败:{error}" record: started: "⏺ 录制已开始: {path}" @@ -449,7 +638,7 @@ shell: confirm_choice_remember: "本会话记住" confirm_choice_reply: "回复 AI 换方案" confirm_choice_deny: "拒绝" - forget_approvals_cleared: "已清除本会话的命令审批记忆({count} 项)" + forget_approvals_cleared: "已清除本会话的审批记忆({count} 项)" # 提示符 prompt_prefix: "执行" @@ -667,6 +856,36 @@ shell: switch_same: "模型未变更,仍为: {model}" switch_success: "已切换模型: {model}" verify_failed: "模型验证失败: {reason}" + picker_title: "切换模型 / 账户" + picker_subtitle: "↑↓ 选择,Enter 切换到该模型/账户" + fetch_failed_subtitle: "无法获取模型列表(endpoint 不可达)—— 仅显示当前和历史" + picker_search: "搜索模型或 URL..." + picker_footer: "a 添加账户 m 管理 Esc 取消" + action_add: "添加" + action_del: "删除" + action_toggle: "启停" + action_edit: "编辑" + edit_label: "模型名" + edit_desc: "该账户使用的模型" + edited: "已更新 '{name}' 模型为 {model}" + cannot_remove_primary: "主模型不可删除" + switched_account: "已切换到 '{name}'(模型:{model})" + fallback_badge: "回退" + primary_badge: "主" + recent_badge: "最近" + action_fallback: "回退" + fallback_not_switchable: "回退模型 '{model}' 不可直接切换,仅作主模型失败时的备选" + fallback_add_label: "回退模型名" + fallback_add_desc: "主模型失败时尝试的模型" + fallback_added: "已添加回退 '{model}'" + fallback_removed: "已移除回退 '{model}'" + fallback_exists: "回退 '{model}' 已存在" + action_manage: "管理" + manage_title: "账户与回退管理" + manage_search: "搜索..." + manage_subtitle: "d 删除选中项" + manage_footer: "d 删除 f 加回退 Esc 返回" + manage_empty: "暂无账户或回退可管理" token: title: "Token 统计 (近 7 天)" @@ -674,6 +893,17 @@ shell: output_tokens: "输出 tokens" total: "总计" api_calls: "API 调用次数" + today: "今日" + session: "本会话" + rotation: "账号轮换" + rotation_avail: "可用 {available}/{total}" + rotation_count: "本次轮换 {count} 次" + state_active: "使用中" + state_cooling: "冷却中" + state_ready: "就绪" + active_model: "当前模型: {model}" + fallback_active: "回退生效中" + fallback_chain: "回退链: {chain}" setup: no_config_manager: "无法重新配置:配置管理器不可用" diff --git a/crates/aish-llm/src/approval_memory.rs b/crates/aish-llm/src/approval_memory.rs index 3c8dbfb5..b36191a1 100644 --- a/crates/aish-llm/src/approval_memory.rs +++ b/crates/aish-llm/src/approval_memory.rs @@ -30,14 +30,16 @@ pub enum ApprovalChoice { Deny, } -/// Session-scoped memory of approved commands. +/// Session-scoped memory of approved operations. /// -/// The key is `(host_key, normalized_command)`. Looking up or recording a -/// command always re-derives its key from the raw text, so callers never need -/// to normalize themselves. +/// The key is `(tool_name, host_key, normalized_target)`. Scoping by tool +/// name keeps a remembered web-fetch host from ever auto-approving a shell +/// command of the same text. Looking up or recording an entry always +/// re-derives its key from the raw text, so callers never normalize +/// themselves. #[derive(Debug, Default)] pub struct ApprovalMemory { - allowed: HashSet<(String, String)>, + allowed: HashSet<(String, String, String)>, } impl ApprovalMemory { @@ -49,27 +51,31 @@ impl ApprovalMemory { /// compute the host from the *normalized* text so a leading wrapper such /// as `nohup` does not fragment remote commands between localhost and /// their real host. - fn key(command: &str) -> Option<(String, String)> { + fn key(tool_name: &str, command: &str) -> Option<(String, String, String)> { let normalized = normalize_command(command); if normalized.is_empty() { return None; } - Some((command_host_key(&normalized), normalized)) + Some(( + tool_name.to_string(), + command_host_key(&normalized), + normalized, + )) } - /// Returns true if this command was previously approved for the session - /// on its target host. - pub fn is_allowed(&self, command: &str) -> bool { - match Self::key(command) { + /// Returns true if this `(tool, command)` was previously approved for the + /// session on its target host. + pub fn is_allowed(&self, tool_name: &str, command: &str) -> bool { + match Self::key(tool_name, command) { Some(key) => self.allowed.contains(&key), None => false, } } - /// Record an approval so subsequent equivalent commands skip the prompt. + /// Record an approval so subsequent equivalent operations skip the prompt. /// Empty commands are ignored. - pub fn remember(&mut self, command: &str) { - if let Some(key) = Self::key(command) { + pub fn remember(&mut self, tool_name: &str, command: &str) { + if let Some(key) = Self::key(tool_name, command) { self.allowed.insert(key); } } @@ -166,44 +172,44 @@ mod tests { fn empty_memory_allows_nothing() { let mem = ApprovalMemory::new(); assert!(mem.is_empty()); - assert!(!mem.is_allowed("systemctl restart nginx")); + assert!(!mem.is_allowed("bash", "systemctl restart nginx")); } #[test] fn remembers_equivalent_commands() { let mut mem = ApprovalMemory::new(); - mem.remember("systemctl restart nginx"); + mem.remember("bash", "systemctl restart nginx"); // Identical text matches. - assert!(mem.is_allowed("systemctl restart nginx")); + assert!(mem.is_allowed("bash", "systemctl restart nginx")); // nohup wrapper is stripped, so it matches the bare command. - assert!(mem.is_allowed("nohup systemctl restart nginx")); + assert!(mem.is_allowed("bash", "nohup systemctl restart nginx")); // Decorative whitespace is NOT collapsed (preserves quoted paths), // so a multi-space variant stays distinct. - assert!(!mem.is_allowed("systemctl restart nginx")); + assert!(!mem.is_allowed("bash", "systemctl restart nginx")); assert_eq!(mem.len(), 1); } #[test] fn sudo_variant_is_not_authorized_by_non_root_approval() { let mut mem = ApprovalMemory::new(); - mem.remember("systemctl restart nginx"); + mem.remember("bash", "systemctl restart nginx"); // A non-root approval must NOT silently authorize the sudo (root) // variant — that would escalate privileges and skip the sandbox. - assert!(!mem.is_allowed("sudo systemctl restart nginx")); + assert!(!mem.is_allowed("bash", "sudo systemctl restart nginx")); // Reverse direction must also hold. let mut mem2 = ApprovalMemory::new(); - mem2.remember("sudo systemctl restart nginx"); - assert!(!mem2.is_allowed("systemctl restart nginx")); - assert!(mem2.is_allowed("sudo systemctl restart nginx")); + mem2.remember("bash", "sudo systemctl restart nginx"); + assert!(!mem2.is_allowed("bash", "systemctl restart nginx")); + assert!(mem2.is_allowed("bash", "sudo systemctl restart nginx")); } #[test] fn distinguishes_hosts() { let mut mem = ApprovalMemory::new(); - mem.remember("ssh root@host-a systemctl restart nginx"); - assert!(mem.is_allowed("ssh root@host-a systemctl restart nginx")); + mem.remember("bash", "ssh root@host-a systemctl restart nginx"); + assert!(mem.is_allowed("bash", "ssh root@host-a systemctl restart nginx")); // Same command text but a different host must not be auto-approved. - assert!(!mem.is_allowed("ssh root@host-b systemctl restart nginx")); + assert!(!mem.is_allowed("bash", "ssh root@host-b systemctl restart nginx")); } #[test] @@ -212,33 +218,33 @@ mod tests { // command. Otherwise `nohup ssh root@host ...` would store localhost // and never match the bare `ssh root@host ...` (real host). let mut mem = ApprovalMemory::new(); - mem.remember("nohup ssh root@host uptime"); - assert!(mem.is_allowed("ssh root@host uptime")); - assert!(mem.is_allowed("nohup ssh root@host uptime")); - assert!(!mem.is_allowed("ssh root@other uptime")); + mem.remember("bash", "nohup ssh root@host uptime"); + assert!(mem.is_allowed("bash", "ssh root@host uptime")); + assert!(mem.is_allowed("bash", "nohup ssh root@host uptime")); + assert!(!mem.is_allowed("bash", "ssh root@other uptime")); // Normalizes-to-empty inputs (nohup-only / whitespace) record nothing. let mut mem2 = ApprovalMemory::new(); - mem2.remember("nohup"); + mem2.remember("bash", "nohup"); assert!(mem2.is_empty()); - mem2.remember(" "); + mem2.remember("bash", " "); assert!(mem2.is_empty()); } #[test] fn local_commands_share_localhost_key() { let mut mem = ApprovalMemory::new(); - mem.remember("systemctl restart nginx"); + mem.remember("bash", "systemctl restart nginx"); // Same command text (no sudo) matches on localhost. - assert!(mem.is_allowed("systemctl restart nginx")); - assert!(mem.is_allowed("nohup systemctl restart nginx")); + assert!(mem.is_allowed("bash", "systemctl restart nginx")); + assert!(mem.is_allowed("bash", "nohup systemctl restart nginx")); } #[test] fn distinct_commands_are_not_implicitly_approved() { let mut mem = ApprovalMemory::new(); - mem.remember("systemctl restart nginx"); - assert!(!mem.is_allowed("systemctl restart mysql")); - assert!(!mem.is_allowed("systemctl stop nginx")); + mem.remember("bash", "systemctl restart nginx"); + assert!(!mem.is_allowed("bash", "systemctl restart mysql")); + assert!(!mem.is_allowed("bash", "systemctl stop nginx")); } #[test] fn path_distinct_commands_are_not_cross_authorized() { @@ -247,13 +253,13 @@ mod tests { // command text. Different paths must stay distinct so a remembered // approval never replays against a different path. let mut mem = ApprovalMemory::new(); - mem.remember("rm /tmp/a"); - assert!(mem.is_allowed("rm /tmp/a")); - assert!(!mem.is_allowed("rm /tmp/b")); - assert!(!mem.is_allowed("rm /tmp/a/x")); + mem.remember("bash", "rm /tmp/a"); + assert!(mem.is_allowed("bash", "rm /tmp/a")); + assert!(!mem.is_allowed("bash", "rm /tmp/b")); + assert!(!mem.is_allowed("bash", "rm /tmp/a/x")); // A path prefix must not authorize a longer path either. - mem.remember("rm /var/log"); - assert!(!mem.is_allowed("rm /var/log/app.log")); + mem.remember("bash", "rm /var/log"); + assert!(!mem.is_allowed("bash", "rm /var/log/app.log")); } #[test] @@ -262,31 +268,46 @@ mod tests { // path). normalize_command preserves it verbatim, so two paths that // differ only in internal spacing must NOT share an approval key. let mut mem = ApprovalMemory::new(); - mem.remember("rm '/tmp/a b'"); - assert!(mem.is_allowed("rm '/tmp/a b'")); - assert!(!mem.is_allowed("rm '/tmp/a b'")); + mem.remember("bash", "rm '/tmp/a b'"); + assert!(mem.is_allowed("bash", "rm '/tmp/a b'")); + assert!(!mem.is_allowed("bash", "rm '/tmp/a b'")); // Reverse direction. let mut mem2 = ApprovalMemory::new(); - mem2.remember("rm '/tmp/a b'"); - assert!(!mem2.is_allowed("rm '/tmp/a b'")); + mem2.remember("bash", "rm '/tmp/a b'"); + assert!(!mem2.is_allowed("bash", "rm '/tmp/a b'")); } #[test] fn clear_wipes_memory() { let mut mem = ApprovalMemory::new(); - mem.remember("systemctl restart nginx"); - assert!(mem.is_allowed("systemctl restart nginx")); + mem.remember("bash", "systemctl restart nginx"); + assert!(mem.is_allowed("bash", "systemctl restart nginx")); mem.clear(); assert!(mem.is_empty()); - assert!(!mem.is_allowed("systemctl restart nginx")); + assert!(!mem.is_allowed("bash", "systemctl restart nginx")); } #[test] fn empty_command_is_ignored() { let mut mem = ApprovalMemory::new(); - mem.remember(""); + mem.remember("bash", ""); assert!(mem.is_empty()); - assert!(!mem.is_allowed("")); + assert!(!mem.is_allowed("bash", "")); + } + + #[test] + fn tool_name_scopes_approvals() { + // The tool_name dimension keeps a remembered operation from leaking + // across tools: approving a web_fetch host must not also auto-approve + // a shell command of the same text, and vice versa. + let mut mem = ApprovalMemory::new(); + mem.remember("web_fetch", "example.com"); + assert!(mem.is_allowed("web_fetch", "example.com")); + assert!(!mem.is_allowed("bash", "example.com")); + assert_eq!(mem.len(), 1); + // Distinct tool names each take a slot. + mem.remember("bash", "example.com"); + assert_eq!(mem.len(), 2); } #[test] diff --git a/crates/aish-llm/src/lib.rs b/crates/aish-llm/src/lib.rs index 66ca0948..354fe797 100644 --- a/crates/aish-llm/src/lib.rs +++ b/crates/aish-llm/src/lib.rs @@ -27,6 +27,7 @@ pub mod probe; pub mod prompt; pub mod provider; pub mod providers; +pub mod rotation; pub mod session; pub mod streaming; pub mod subsession; @@ -64,6 +65,9 @@ pub use provider::{ pub use providers::{ OpenAiCompatProvider, ProviderCapabilities, ProviderMetadata, ProviderRegistry, }; +pub use rotation::{ + ApiAccount, FailureKind, ResolvedCredential, RetryPolicy, RotationSnapshot, RotationState, +}; pub use session::LlmSession; pub use streaming::{SseEvent, StreamParser}; pub use subsession::{SubSession, SubSessionConfig}; diff --git a/crates/aish-llm/src/rotation.rs b/crates/aish-llm/src/rotation.rs new file mode 100644 index 00000000..38614f70 --- /dev/null +++ b/crates/aish-llm/src/rotation.rs @@ -0,0 +1,707 @@ +//! Multi-account credential rotation and model fallback chains. +//! +//! Inspired by oh-my-pi's `retry.fallbackChains` + round-robin credentials. +//! Provides two-layer recovery on top of the existing per-request HTTP retry +//! (which lives in `client.rs` / `api::mod.rs`): +//! +//! 1. **Account rotation** — one provider, multiple API keys. On a rate-limit / +//! usage-limit the current account is cooled down and the next available +//! one is tried for the rest of the turn. +//! 2. **Model fallback** — when every account is exhausted (or a model-specific +//! hard error fires), switch to a configured fallback model. The primary +//! model is suppressed for a cooldown window and may be restored on success +//! (`revert_on_cooldown`). +//! +//! The state is kept in a [`RotationState`] behind a `Mutex` on `LlmSession`. +//! Each request reads the current credential via [`RotationState::current`] and, +//! on failure, advances via [`RotationState::advance_on_error`]. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use aish_core::AishError; + +/// Default cooldown applied to an account after a rate-limit / usage-limit hit. +pub const DEFAULT_ACCOUNT_COOLDOWN: Duration = Duration::from_secs(60); +/// Default cooldown applied to the primary model after falling back. +pub const DEFAULT_MODEL_COOLDOWN: Duration = Duration::from_secs(120); + +/// A single API credential used for quota rotation. Multiple accounts under the +/// same provider let aish keep working when one key burns its quota. +#[derive(Debug, Clone)] +pub struct ApiAccount { + /// Human-readable label, e.g. `"work-key"` / `"team-b"`. + pub name: String, + pub api_key: String, + /// Optional override of the provider base URL for this account. + pub api_base: Option, + /// Optional per-account model override. Falls back to the active model + /// (primary or fallback chain) when `None`. + pub model: Option, + /// Relative selection weight (higher = preferred). Currently advisory. + pub weight: u32, + /// When true the account is skipped entirely. + pub disabled: bool, +} + +impl ApiAccount { + pub fn new(name: impl Into, api_key: impl Into) -> Self { + Self { + name: name.into(), + api_key: api_key.into(), + api_base: None, + model: None, + weight: 1, + disabled: false, + } + } +} + +/// Retry / fallback policy driving [`RotationState`]. +#[derive(Debug, Clone)] +pub struct RetryPolicy { + pub enabled: bool, + pub account_cooldown: Duration, + pub model_cooldown: Duration, + /// When true, restore the primary model after its cooldown expires. + pub revert_on_cooldown: bool, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + enabled: true, + account_cooldown: DEFAULT_ACCOUNT_COOLDOWN, + model_cooldown: DEFAULT_MODEL_COOLDOWN, + revert_on_cooldown: true, + } + } +} + +/// Classification of a recoverable failure, used to decide the rotation step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureKind { + /// HTTP 429 / explicit rate-limit wording. + RateLimit, + /// Quota / usage / billing exhausted (parsed from the response body). + UsageLimit, + /// 5xx server error after the inner HTTP retry budget is spent. + ServerError, + /// Connection / timeout / network. + Network, + /// Model-specific hard error (not found, unsupported) — skip the model. + ModelError, +} + +impl FailureKind { + /// Heuristic classification from an [`AishError`], mirroring oh-my-pi's + /// regex text classification: provider errors are not typed here, they are + /// string-classified against the user-facing message. Returns `None` for + /// errors rotation cannot fix (auth, parse, cancellation). + pub fn from_error(err: &AishError) -> Option { + let msg = match err { + AishError::Llm(s) => s.as_str(), + AishError::Timeout => return Some(Self::Network), + _ => return None, + }; + Self::from_message(msg) + } + + /// Pure classifier over a message string — extracted for unit testing. + pub fn from_message(msg: &str) -> Option { + let lower = msg.to_lowercase(); + // Auth / permission: never recoverable by rotation. + if lower.contains("401") + || lower.contains("403") + || lower.contains("authentication failed") + || lower.contains("unauthorized") + || lower.contains("forbidden") + || lower.contains("invalid api key") + { + return None; + } + // Context-length / prompt-too-long: not recoverable by rotation — the + // identical over-long prompt fails on every key and model. Must run + // before the usage-limit check, whose "exceeded" keyword would otherwise + // misclassify e.g. "context_length_exceeded" as a UsageLimit and burn + // account cooldowns on an unrecoverable prompt-size error. + if lower.contains("context length") + || lower.contains("context_length") + || lower.contains("maximum context") + || lower.contains("context window") + { + return None; + } + // Model-not-found / unsupported → switch model. + if lower.contains("404") + || lower.contains("model not found") + || lower.contains("does not exist") + || lower.contains("does not support") + || lower.contains("not supported") + || lower.contains("unsupported") + { + return Some(Self::ModelError); + } + if lower.contains("429") + || lower.contains("rate limit") + || lower.contains("rate-limit") + || lower.contains("too many requests") + { + return Some(Self::RateLimit); + } + if lower.contains("quota") + || lower.contains("usage limit") + || lower.contains("billing") + || lower.contains("exceeded") + || lower.contains("insufficient") + || lower.contains("credit") + { + return Some(Self::UsageLimit); + } + if lower.contains("timeout") + || lower.contains("timed out") + || lower.contains("connection") + || lower.contains("connect") + || lower.contains("reset") + || lower.contains("unreachable") + || lower.contains("broken pipe") + { + return Some(Self::Network); + } + if lower.contains("500") + || lower.contains("502") + || lower.contains("503") + || lower.contains("504") + || lower.contains("server error") + || lower.contains("overloaded") + || lower.contains("service unavailable") + || lower.contains("bad gateway") + || lower.contains("internal error") + { + return Some(Self::ServerError); + } + None + } + + /// Whether this failure should trigger account rotation (vs model fallback). + pub fn is_account_recoverable(self) -> bool { + // Network failures are environment-wide (not account-specific), so + // rotating keys only burns otherwise-good credentials. They fall + // through to model fallback instead of exhausting every account. + matches!(self, Self::RateLimit | Self::UsageLimit | Self::ServerError) + } +} + +/// A resolved credential + model to use for the next request. +#[derive(Debug, Clone)] +pub struct ResolvedCredential { + pub api_key: String, + pub api_base: String, + pub model: String, + /// Human-readable label for logging / UI, e.g. `"team-b @ gpt-4o"`. + pub label: String, +} + +/// Immutable snapshot of rotation state for UI display (`/token`). +#[derive(Debug, Clone, Default)] +pub struct RotationSnapshot { + pub current_account: Option, + pub available_accounts: usize, + pub cooled_accounts: Vec, + pub account_names: Vec, + pub primary_model: String, + pub fallback_models: Vec, + pub active_model: String, + pub on_fallback: bool, + pub total_rotations: u64, +} + +/// State machine for credential rotation + model fallback. +/// +/// Owned by `LlmSession` behind a `Mutex`. Not `Clone`: rotation history is +/// session-scoped and must not be silently duplicated. +pub struct RotationState { + accounts: Vec, + current_account: usize, + fallback_models: Vec, + primary_model: String, + /// `0` = primary, `n` = `fallback_models[n-1]`. + current_model_index: usize, + account_cooldowns: HashMap, + model_cooldown_until: Option, + policy: RetryPolicy, + total_rotations: u64, +} + +impl RotationState { + pub fn new( + primary_model: String, + accounts: Vec, + fallback_models: Vec, + policy: RetryPolicy, + ) -> Self { + Self { + accounts, + current_account: 0, + fallback_models, + primary_model, + current_model_index: 0, + account_cooldowns: HashMap::new(), + model_cooldown_until: None, + policy, + total_rotations: 0, + } + } + + /// True when rotation can actually do something useful (more than one + /// enabled account, or at least one fallback model). + pub fn is_active(&self) -> bool { + self.enabled_accounts().count() > 1 || !self.fallback_models.is_empty() + } + + fn enabled_accounts(&self) -> impl Iterator { + self.accounts + .iter() + .enumerate() + .filter(|(_, a)| !a.disabled) + } + + /// The model currently in use (primary, or a fallback). + pub fn active_model_name(&self) -> String { + if self.current_model_index == 0 { + self.primary_model.clone() + } else { + self.fallback_models + .get(self.current_model_index - 1) + .cloned() + .unwrap_or_else(|| self.primary_model.clone()) + } + } + + /// The credential + model to use right now. `default_base` is used when an + /// account has no explicit `api_base` override. + pub fn current(&self, default_base: &str) -> ResolvedCredential { + let acct = self + .accounts + .get(self.current_account) + .filter(|a| !a.disabled) + .or_else(|| self.enabled_accounts().next().map(|(_, a)| a)) + .cloned() + .unwrap_or_else(|| ApiAccount { + name: "default".into(), + api_key: String::new(), + api_base: None, + model: None, + weight: 1, + disabled: false, + }); + let model = acct + .model + .clone() + .unwrap_or_else(|| self.active_model_name()); + ResolvedCredential { + api_base: acct + .api_base + .clone() + .unwrap_or_else(|| default_base.to_string()), + api_key: acct.api_key.clone(), + label: format!("{} @ {}", acct.name, model), + model, + } + } + + /// Advance rotation after a failure. Returns `true` if a recovery was + /// applied (retry with a different credential / model), `false` if every + /// recovery path is exhausted and the error should surface to the user. + pub fn advance_on_error(&mut self, kind: FailureKind) -> bool { + if !self.policy.enabled { + return false; + } + // Network failures are environment-wide: rotating accounts only burns + // otherwise-good keys against the same dead endpoint, and model fallback + // retries that same endpoint under a different model name — also futile, + // and it would demote the primary model for the whole cooldown window. + // The inner HTTP retry already absorbs transient blips, so surface it. + if matches!(kind, FailureKind::Network) { + return false; + } + let now = Instant::now(); + + // Layer 1: account rotation for recoverable kinds. + if kind.is_account_recoverable() { + self.account_cooldowns + .insert(self.current_account, now + self.policy.account_cooldown); + if let Some((idx, _)) = self.pick_available_account(now) { + self.current_account = idx; + self.total_rotations += 1; + return true; + } + // No other account available right now → fall through to model fallback. + } + + // Layer 2: model fallback (also reached when every account is cooled down). + self.advance_model(now) + } + + /// Pick the next enabled, non-cooled account that is not the current one. + fn pick_available_account(&self, now: Instant) -> Option<(usize, &ApiAccount)> { + let enabled: Vec<_> = self.enabled_accounts().collect(); + if enabled.is_empty() { + return None; + } + // Round-robin starting just after the current index, skipping cooled. + let n = self.accounts.len(); + for offset in 1..=n { + let i = (self.current_account + offset) % n; + if let Some(a) = self.accounts.get(i) { + if a.disabled || self.is_cooled(i, now) { + continue; + } + return Some((i, a)); + } + } + None + } + + fn is_cooled(&self, idx: usize, now: Instant) -> bool { + self.account_cooldowns + .get(&idx) + .is_some_and(|until| *until > now) + } + + fn advance_model(&mut self, now: Instant) -> bool { + // Suppress the primary model with a cooldown on the first fall. + if self.current_model_index == 0 { + self.model_cooldown_until = Some(now + self.policy.model_cooldown); + } + let next = self.current_model_index + 1; + if (next - 1) < self.fallback_models.len() { + self.current_model_index = next; + self.total_rotations += 1; + true + } else { + false + } + } + + /// Called after a successful request: if we were on a fallback and the + /// primary model's cooldown expired (and `revert_on_cooldown` is set), + /// restore the primary model. Also clears any stale account cooldowns. + pub fn on_success(&mut self) { + let now = Instant::now(); + // Drop expired account cooldowns so they become eligible again. + self.account_cooldowns.retain(|_, until| *until > now); + + if !self.policy.revert_on_cooldown || self.current_model_index == 0 { + return; + } + if self.model_cooldown_until.is_some_and(|t| t <= now) { + self.current_model_index = 0; + self.model_cooldown_until = None; + } + } + + /// Reset back to the primary model + first account (e.g. after a manual + /// model switch via `/model`). + pub fn reset(&mut self) { + self.current_model_index = 0; + self.current_account = 0; + self.model_cooldown_until = None; + self.account_cooldowns.clear(); + } + + /// Manually switch to the named account, resetting any model fallback so + /// the account's own model (or the global model) is used. Returns `false` + /// when no enabled account matches `name`. This is the `/accounts use` + /// path; it does not touch the rotation counter (manual switch != failover). + pub fn use_account(&mut self, name: &str) -> bool { + if let Some(idx) = self + .accounts + .iter() + .position(|a| a.name == name && !a.disabled) + { + self.current_account = idx; + self.current_model_index = 0; + self.model_cooldown_until = None; + self.account_cooldowns.remove(&idx); + true + } else { + false + } + } + + /// Name of the account currently in use, for restoring the selection + /// across a rotation rebuild (e.g. after `/accounts add`). + pub fn current_account_name(&self) -> Option<&str> { + self.accounts + .get(self.current_account) + .map(|a| a.name.as_str()) + } + + pub fn is_on_fallback(&self) -> bool { + self.current_model_index > 0 + } + + pub fn policy(&self) -> &RetryPolicy { + &self.policy + } + + /// Immutable snapshot for UI display. + pub fn snapshot(&self) -> RotationSnapshot { + let now = Instant::now(); + let account_names: Vec = self + .enabled_accounts() + .map(|(_, a)| a.name.clone()) + .collect(); + let cooled: Vec = self + .enabled_accounts() + .filter(|(i, _)| self.is_cooled(*i, now)) + .map(|(_, a)| a.name.clone()) + .collect(); + let current = self + .accounts + .get(self.current_account) + .filter(|a| !a.disabled) + .map(|a| a.name.clone()); + RotationSnapshot { + current_account: current, + available_accounts: self + .enabled_accounts() + .filter(|(i, _)| !self.is_cooled(*i, now)) + .count(), + cooled_accounts: cooled, + account_names, + primary_model: self.primary_model.clone(), + fallback_models: self.fallback_models.clone(), + active_model: self.active_model_name(), + on_fallback: self.is_on_fallback(), + total_rotations: self.total_rotations, + } + } + + /// Human-readable error shown when every model was rejected by the provider + /// as invalid. Aggregates the primary + fallback names so the user sees the + /// full configured set, not only the last fallback attempted. + pub fn model_exhaustion_error(&self) -> String { + let tried = std::iter::once(self.primary_model.as_str()) + .chain(self.fallback_models.iter().map(String::as_str)) + .collect::>() + .join(", "); + format!( + "all configured models were rejected by the provider as invalid \ + (tried: {tried}). Verify the `model` / `fallback_models` names in \ + the aish config — they may be misspelled or not offered by this \ + endpoint." + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn accounts(n: usize) -> Vec { + (0..n) + .map(|i| { + let mut a = ApiAccount::new(format!("acct-{i}"), format!("key-{i}")); + a.weight = 1; + a + }) + .collect() + } + + #[test] + fn classifies_auth_as_non_recoverable() { + assert!(FailureKind::from_message("API error 401: bad key").is_none()); + assert!(FailureKind::from_message("403 Forbidden").is_none()); + assert!(FailureKind::from_message("Invalid API key").is_none()); + } + + #[test] + fn classifies_rate_limit_and_usage() { + assert_eq!( + FailureKind::from_message("API error 429: Too many requests"), + Some(FailureKind::RateLimit) + ); + assert_eq!( + FailureKind::from_message("quota exceeded for this period"), + Some(FailureKind::UsageLimit) + ); + } + + #[test] + fn classifies_model_and_server_errors() { + assert_eq!( + FailureKind::from_message("Model not found: gpt-x"), + Some(FailureKind::ModelError) + ); + assert_eq!( + FailureKind::from_message("API error 503: Service unavailable"), + Some(FailureKind::ServerError) + ); + assert_eq!( + FailureKind::from_message("connection reset by peer"), + Some(FailureKind::Network) + ); + } + + #[test] + fn classifies_context_length_as_non_recoverable() { + // Prompt-too-long errors are not account-specific and must not trigger + // rotation (would waste cooldowns on an unrecoverable error). + assert!(FailureKind::from_message("context_length_exceeded").is_none()); + assert!( + FailureKind::from_message("This model's maximum context length is 8192 tokens") + .is_none() + ); + assert!(FailureKind::from_message("maximum context length exceeded").is_none()); + // A plain quota error is still classified as a usage limit. + assert_eq!( + FailureKind::from_message("quota exceeded for this period"), + Some(FailureKind::UsageLimit) + ); + } + + #[test] + fn network_failure_surfaces_without_demoting_primary_model() { + let mut s = RotationState::new( + "gpt-4".into(), + accounts(2), + vec!["fallback-x".into()], + RetryPolicy::default(), + ); + // A network failure must surface immediately: no rotation recovery, and + // the primary model must stay selected (not put on fallback cooldown). + assert!(!s.advance_on_error(FailureKind::Network)); + assert_eq!(s.current("https://x").model, "gpt-4"); + } + + #[test] + fn exhaustion_error_lists_primary_and_fallbacks() { + let s = RotationState::new( + "gl".into(), + Vec::new(), + vec!["glm-4.7".into()], + RetryPolicy::default(), + ); + let msg = s.model_exhaustion_error(); + assert!(msg.contains("gl"), "primary model missing: {msg}"); + assert!(msg.contains("glm-4.7"), "fallback model missing: {msg}"); + } + + #[test] + fn use_account_switches_and_uses_its_model() { + let mut deepseek = ApiAccount::new("deepseek", "key-d"); + deepseek.model = Some("deepseek-chat".into()); + let primary = ApiAccount::new("primary", "key-p"); + let mut s = RotationState::new( + "gpt-4".into(), + vec![primary, deepseek], + Vec::new(), + RetryPolicy::default(), + ); + // primary uses the global model + assert_eq!(s.current("https://x").model, "gpt-4"); + assert!(s.use_account("deepseek")); + // switched account uses its own model + assert_eq!(s.current("https://x").model, "deepseek-chat"); + assert_eq!(s.current_account_name(), Some("deepseek")); + } + + #[test] + fn use_account_unknown_returns_false() { + let mut s = RotationState::new( + "gpt-4".into(), + accounts(2), + Vec::new(), + RetryPolicy::default(), + ); + assert!(!s.use_account("nonexistent")); + assert_eq!(s.current_account_name(), Some("acct-0")); + } + + #[test] + fn rotates_accounts_on_rate_limit() { + let mut s = RotationState::new("gpt-4".into(), accounts(3), vec![], RetryPolicy::default()); + assert_eq!(s.current("https://x").api_key, "key-0"); + assert!(s.advance_on_error(FailureKind::RateLimit)); + assert_eq!(s.current("https://x").api_key, "key-1"); + assert!(s.advance_on_error(FailureKind::RateLimit)); + assert_eq!(s.current("https://x").api_key, "key-2"); + } + + #[test] + fn falls_back_to_model_when_accounts_exhausted() { + let mut s = RotationState::new( + "gpt-4".into(), + accounts(1), + vec!["gpt-4o-mini".into()], + RetryPolicy::default(), + ); + // Single account: rate-limit cools it, no other account → model fallback. + assert!(s.advance_on_error(FailureKind::RateLimit)); + assert!(s.is_on_fallback()); + assert_eq!(s.current("https://x").model, "gpt-4o-mini"); + } + + #[test] + fn returns_false_when_everything_exhausted() { + let mut s = RotationState::new( + "gpt-4".into(), + accounts(1), + vec!["gpt-4o-mini".into()], + RetryPolicy::default(), + ); + assert!(s.advance_on_error(FailureKind::RateLimit)); // → fallback model + // Fallback model also fails with a model error → no more fallbacks. + assert!(!s.advance_on_error(FailureKind::ModelError)); + } + + #[test] + fn model_error_skips_account_rotation() { + let mut s = RotationState::new( + "gpt-4".into(), + accounts(3), + vec!["gpt-4o-mini".into()], + RetryPolicy::default(), + ); + // Model errors go straight to model fallback, ignoring accounts. + assert!(s.advance_on_error(FailureKind::ModelError)); + assert!(s.is_on_fallback()); + assert_eq!(s.current("https://x").api_key, "key-0"); + } + + #[test] + fn disabled_policy_never_rotates() { + let mut policy = RetryPolicy::default(); + policy.enabled = false; + let mut s = RotationState::new("gpt-4".into(), accounts(3), vec![], policy); + assert!(!s.advance_on_error(FailureKind::RateLimit)); + } + + #[test] + fn snapshot_reports_cooled_accounts() { + let mut s = RotationState::new("gpt-4".into(), accounts(2), vec![], RetryPolicy::default()); + s.advance_on_error(FailureKind::RateLimit); // acct-0 cooled, switch to acct-1 + let snap = s.snapshot(); + assert_eq!(snap.current_account.as_deref(), Some("acct-1")); + assert!(snap.cooled_accounts.contains(&"acct-0".to_string())); + assert_eq!(snap.available_accounts, 1); + assert_eq!(snap.total_rotations, 1); + } + + #[test] + fn is_active_requires_multiple_accounts_or_fallbacks() { + let single = RotationState::new("m".into(), accounts(1), vec![], RetryPolicy::default()); + assert!(!single.is_active()); + let multi = RotationState::new("m".into(), accounts(2), vec![], RetryPolicy::default()); + assert!(multi.is_active()); + let with_fb = RotationState::new( + "m".into(), + accounts(1), + vec!["x".into()], + RetryPolicy::default(), + ); + assert!(with_fb.is_active()); + } +} diff --git a/crates/aish-llm/src/session.rs b/crates/aish-llm/src/session.rs index f49179e1..5b8c48fd 100644 --- a/crates/aish-llm/src/session.rs +++ b/crates/aish-llm/src/session.rs @@ -10,6 +10,7 @@ use crate::api::{resolve_api_dialect, stream_simple, ApiDialect, StreamContext}; use crate::approval_memory::{ApprovalChoice, ApprovalMemory}; use crate::client::LlmResponse; use crate::langfuse::LangfuseClient; +use crate::rotation::{FailureKind, RotationState}; use crate::streaming::{extract_message_text, SseEvent, StreamParser}; use crate::types::*; @@ -98,6 +99,8 @@ pub struct LlmSession { tool_execution_policy: crate::tool_context::ToolExecutionPolicy, /// True for sessions created via [`Self::create_subsession`]. is_sub_agent: bool, + /// Multi-account rotation + model fallback state. `None` when disabled. + rotation: Option>, /// Scripted chat completion responses for unit/integration tests (pop in order). #[cfg(test)] test_chat_responses: Option>>>>, @@ -157,6 +160,7 @@ impl LlmSession { last_turn_compaction: std::sync::Mutex::new(None), tool_execution_policy: crate::tool_context::ToolExecutionPolicy::default(), is_sub_agent: false, + rotation: None, #[cfg(test)] test_chat_responses: None, } @@ -410,6 +414,40 @@ impl LlmSession { ); } + /// Install a multi-account rotation + fallback state. When set, every + /// chat completion routes through the rotation loop, advancing to the next + /// API account or fallback model on recoverable failures. + pub fn set_rotation(&mut self, state: RotationState) { + self.rotation = Some(std::sync::Mutex::new(state)); + } + + /// Snapshot of the rotation state for UI display (`/token`), if active. + pub fn rotation_snapshot(&self) -> Option { + self.rotation.as_ref().map(|m| m.lock().unwrap().snapshot()) + } + + /// Disable rotation (e.g. after the user removes all extra accounts). + pub fn clear_rotation(&mut self) { + self.rotation = None; + } + + /// Manually switch the active rotation account by name (the `/accounts use` + /// path). Returns `false` when rotation is disabled or no account matches. + pub fn use_rotation_account(&self, name: &str) -> bool { + self.rotation + .as_ref() + .map(|m| m.lock().unwrap().use_account(name)) + .unwrap_or(false) + } + + /// Name of the account currently in use, for restoring the selection + /// across a rotation rebuild. + pub fn current_rotation_account(&self) -> Option { + self.rotation + .as_ref() + .and_then(|m| m.lock().unwrap().current_account_name().map(String::from)) + } + /// Return the current model name. pub fn model_name(&self) -> &str { &self.stream_ctx.model @@ -440,16 +478,70 @@ impl LlmSession { } } - stream_simple( - self.api_dialect, - &self.stream_ctx, - messages, - tools, - stream, - temperature, - max_tokens, - ) - .await + // Fast path: no rotation configured — issue the request directly. + let Some(rotation) = &self.rotation else { + return stream_simple( + self.api_dialect, + &self.stream_ctx, + messages, + tools, + stream, + temperature, + max_tokens, + ) + .await; + }; + + // Rotation path: try the current credential/model, and on a recoverable + // failure (429 / usage-limit / 5xx / network / model error) advance to + // the next account or fallback model until recovery is exhausted. + loop { + let cred = { + let guard = rotation.lock().unwrap(); + guard.current(&self.stream_ctx.api_base) + }; + let dialect = resolve_api_dialect(&cred.model, &cred.api_base, &cred.api_key); + let mut ctx = self.stream_ctx.clone(); + ctx.refresh_dialect(&cred.model, Some(&cred.api_base), Some(&cred.api_key)); + + match stream_simple( + dialect, + &ctx, + messages, + tools, + stream, + temperature, + max_tokens, + ) + .await + { + Ok(resp) => { + rotation.lock().unwrap().on_success(); + return Ok(resp); + } + Err(err) => { + let Some(kind) = FailureKind::from_error(&err) else { + return Err(err); + }; + tracing::warn!( + error = %err, + kind = ?kind, + credential = %cred.label, + "LLM request failed; attempting credential/model rotation" + ); + let advanced = rotation.lock().unwrap().advance_on_error(kind); + if !advanced { + if kind == FailureKind::ModelError { + let msg = rotation.lock().unwrap().model_exhaustion_error(); + return Err(AishError::Llm(msg)); + } + return Err(err); + } + let next = rotation.lock().unwrap().current(&self.stream_ctx.api_base); + tracing::info!(credential = %next.label, "rotated to next credential/model"); + } + } + } } /// Low-level chat completion returning the raw API response. @@ -1463,6 +1555,7 @@ impl LlmSession { max_context_tokens: self.max_context_tokens, context_budget_policy: self.context_budget_policy.clone(), compact_consecutive_failures: std::sync::Mutex::new(0), + rotation: None, plan_state: Arc::new(Mutex::new(PlanModeState::default())), token_stats: std::sync::Mutex::new(crate::usage::TokenStats::default()), last_prompt_estimate: std::sync::atomic::AtomicU64::new(0), @@ -1486,20 +1579,22 @@ impl LlmSession { ))); } - // Approval memory: if this command was previously approved for the + // Approval memory: if this operation was previously approved for the // session, skip the whole preflight (including the sandbox pre-run). - let memory_command = args.get("command").and_then(|value| value.as_str()); - if memory_command.is_some_and(|command| { + // The key is tool-defined (`Tool::approval_key`) so non-command tools + // such as web_fetch (keyed by host) are remembered too. + let memory_key = tool.approval_key(args); + if memory_key.as_ref().is_some_and(|key| { self.approval_memory .as_ref() - .is_some_and(|memory| memory.lock().unwrap().is_allowed(command)) + .is_some_and(|memory| memory.lock().unwrap().is_allowed(tool_name, key)) }) { self.emit_audit(AuditEvent::security_decision( chrono::Utc::now(), self.audit_session_uuid.clone(), None, None, - memory_command.map(|c| self.redact(c)), + memory_key.as_deref().map(|c| self.redact(c)), "allow".to_string(), Some("remembered".to_string()), None, @@ -1540,8 +1635,8 @@ impl LlmSession { }; if matches!(choice, ApprovalChoice::RememberSession) { if let Some(memory) = &self.approval_memory { - if let Some(command) = memory_command { - memory.lock().unwrap().remember(command); + if let Some(key) = memory_key.as_deref() { + memory.lock().unwrap().remember(tool_name, key); } } } @@ -3176,6 +3271,95 @@ mod tests { ); } + // Tool that simulates web_fetch: it has NO `command` arg and derives its + // approval key from `url`, exercising the non-command remember path. + struct HostConfirmTool { + preflight_calls: std::sync::Arc, + } + impl HostConfirmTool { + fn new(counter: std::sync::Arc) -> Self { + Self { + preflight_calls: counter, + } + } + } + impl Tool for HostConfirmTool { + fn name(&self) -> &str { + "web_fetch" + } + fn description(&self) -> &str { + "host-keyed confirm-counting test tool" + } + fn parameters(&self) -> serde_json::Value { + serde_json::json!({"type": "object", "properties": {}}) + } + fn approval_key(&self, args: &serde_json::Value) -> Option { + // Mirror WebFetchTool: key on the URL/host, not a command. + args.get("url").and_then(|v| v.as_str()).map(String::from) + } + fn preflight(&self, _args: &serde_json::Value) -> PreflightResult { + self.preflight_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + PreflightResult::Confirm { + message: "test confirmation".to_string(), + security: None, + } + } + fn execute(&self, _args: serde_json::Value) -> ToolResult { + ToolResult::success("fetched") + } + } + + #[tokio::test] + async fn approval_memory_remembers_non_command_tool() { + // Regression for the bug where pressing [a] on a non-command tool + // (e.g. web_fetch) reported "remembered" but stored nothing: the old + // code keyed memory on args["command"], which such tools lack. + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + session.register_tool(Box::new(HostConfirmTool::new(counter.clone()))); + session.set_confirmation_callback(std::sync::Arc::new( + |_ctx: &PreflightSecurityContext| ApprovalChoice::RememberSession, + )); + session.set_approval_memory(std::sync::Arc::new(std::sync::Mutex::new( + ApprovalMemory::new(), + ))); + + let tool_call = ToolCall { + id: "call_1".into(), + name: "web_fetch".into(), + arguments: serde_json::json!({ "url": "https://example.com/a" }).to_string(), + }; + + // First call: preflight runs, user remembers (keyed by the URL). + let result = session.execute_tool_external(&tool_call).await; + assert!(result.ok); + assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1); + + // Second call to the SAME url: memory hit, preflight skipped. + let result2 = session.execute_tool_external(&tool_call).await; + assert!(result2.ok); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 1, + "remembered non-command tool must skip preflight" + ); + + // A different target is NOT covered by the remembered approval. + let other = ToolCall { + id: "call_2".into(), + name: "web_fetch".into(), + arguments: serde_json::json!({ "url": "https://example.org/b" }).to_string(), + }; + let result3 = session.execute_tool_external(&other).await; + assert!(result3.ok); + assert_eq!( + counter.load(std::sync::atomic::Ordering::SeqCst), + 2, + "a different target must re-run preflight" + ); + } + #[tokio::test] async fn approval_reply_to_ai_denies_execution() { let mut session = LlmSession::new("http://localhost", "key", "model", None, None); diff --git a/crates/aish-llm/src/types.rs b/crates/aish-llm/src/types.rs index 23fbaf07..00d5c3bf 100644 --- a/crates/aish-llm/src/types.rs +++ b/crates/aish-llm/src/types.rs @@ -551,6 +551,17 @@ pub trait Tool: Send + Sync { self.preflight(args) } + /// The string that identifies this invocation for session-scoped approval + /// memory (the `[a] Remember this session` choice). Defaults to the + /// `command` argument; tools without one (e.g. `web_fetch`, keyed by + /// host) override this. Returning `None` means the tool is not + /// rememberable — `[a]` then has no persistent effect. + fn approval_key(&self, args: &serde_json::Value) -> Option { + args.get("command") + .and_then(|value| value.as_str()) + .map(|s| s.to_string()) + } + fn execute(&self, args: serde_json::Value) -> ToolResult; /// Async variant of `execute`. The default implementation delegates to the diff --git a/crates/aish-session/src/models.rs b/crates/aish-session/src/models.rs index 7041f292..b1414c73 100644 --- a/crates/aish-session/src/models.rs +++ b/crates/aish-session/src/models.rs @@ -11,6 +11,12 @@ pub struct SessionRecord { pub api_base: Option, pub run_user: Option, pub state: serde_json::Value, + /// UUID of the session this one was forked from (`None` for a root session). + #[serde(default)] + pub parent_session_uuid: Option, + /// History row id within the parent at which this branch diverges. + #[serde(default)] + pub branch_point_message_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/aish-session/src/store.rs b/crates/aish-session/src/store.rs index a866da44..6f433d13 100644 --- a/crates/aish-session/src/store.rs +++ b/crates/aish-session/src/store.rs @@ -13,12 +13,14 @@ use crate::models::{ const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS sessions ( - session_uuid TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - model TEXT NOT NULL, - api_base TEXT, - run_user TEXT, - state TEXT DEFAULT '{}' + session_uuid TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + model TEXT NOT NULL, + api_base TEXT, + run_user TEXT, + state TEXT DEFAULT '{}', + parent_session_uuid TEXT, + branch_point_message_id INTEGER ); CREATE TABLE IF NOT EXISTS history ( @@ -105,6 +107,8 @@ impl SessionStore { // Create tables. conn.execute_batch(SCHEMA) .map_err(|e| AishError::Session(format!("failed to create schema: {e}")))?; + // Apply additive migrations for databases created by older builds. + migrate_schema(&conn)?; debug!(path = ?db_path, "opened session store"); @@ -138,6 +142,8 @@ impl SessionStore { api_base: api_base.map(|s| s.to_string()), run_user: user, state, + parent_session_uuid: None, + branch_point_message_id: None, }) } @@ -170,6 +176,17 @@ impl SessionStore { tx.execute("DELETE FROM history WHERE session_uuid = ?1", params![uuid]) .map_err(|e| AishError::Session(format!("failed to delete session history: {e}")))?; + // Reparent any children so deleting a forked parent does not orphan + // them out of session_roots()/list_children(); they resurface as roots. + // Clear branch_point_message_id too — it references the parent's now- + // deleted history, so a promoted root must not carry a dangling branch. + tx.execute( + "UPDATE sessions + SET parent_session_uuid = NULL, branch_point_message_id = NULL + WHERE parent_session_uuid = ?1", + params![uuid], + ) + .map_err(|e| AishError::Session(format!("failed to reparent child sessions: {e}")))?; tx.execute( "DELETE FROM sessions WHERE session_uuid = ?1", params![uuid], @@ -185,22 +202,13 @@ impl SessionStore { let mut stmt = self .conn .prepare( - "SELECT session_uuid, created_at, model, api_base, run_user, state + "SELECT session_uuid, created_at, model, api_base, run_user, state, + parent_session_uuid, branch_point_message_id FROM sessions WHERE session_uuid = ?1", ) .map_err(|e| AishError::Session(format!("failed to prepare get_session: {e}")))?; - let result = stmt.query_row(params![uuid], |row| { - Ok(SessionRecord { - session_uuid: row.get(0)?, - created_at: parse_datetime(&row.get::<_, String>(1)?), - model: row.get(2)?, - api_base: row.get(3)?, - run_user: row.get(4)?, - state: serde_json::from_str(&row.get::<_, String>(5)?) - .unwrap_or(serde_json::Value::Object(Default::default())), - }) - }); + let result = stmt.query_row(params![uuid], row_to_session_record); match result { Ok(record) => Ok(Some(record)), @@ -214,23 +222,14 @@ impl SessionStore { let mut stmt = self .conn .prepare( - "SELECT session_uuid, created_at, model, api_base, run_user, state + "SELECT session_uuid, created_at, model, api_base, run_user, state, + parent_session_uuid, branch_point_message_id FROM sessions", ) .map_err(|e| AishError::Session(format!("failed to prepare list_sessions: {e}")))?; let rows = stmt - .query_map([], |row| { - Ok(SessionRecord { - session_uuid: row.get(0)?, - created_at: parse_datetime(&row.get::<_, String>(1)?), - model: row.get(2)?, - api_base: row.get(3)?, - run_user: row.get(4)?, - state: serde_json::from_str(&row.get::<_, String>(5)?) - .unwrap_or(serde_json::Value::Object(Default::default())), - }) - }) + .query_map([], row_to_session_record) .map_err(|e| AishError::Session(format!("failed to query sessions: {e}")))?; let mut sessions = Vec::new(); @@ -249,6 +248,106 @@ impl SessionStore { Ok(sessions) } + /// Fork a new session from an existing one. + /// + /// The new session copies the parent's persisted `state` snapshot plus its + /// `model`/`api_base`/`run_user` metadata, and records `parent_uuid` plus + /// the optional `branch_point_message_id` (the history row at which the + /// branch diverges). The fork's `created_at` is the current time. + pub fn fork_session( + &self, + parent_uuid: &str, + branch_point_message_id: Option, + new_uuid: &str, + ) -> Result { + let parent = self.get_session(parent_uuid)?.ok_or_else(|| { + AishError::Session(format!("parent session not found: {parent_uuid}")) + })?; + + let now = Utc::now(); + let now_str = now.to_rfc3339(); + let state_str = serde_json::to_string(&parent.state)?; + + self.conn + .execute( + "INSERT INTO sessions + (session_uuid, created_at, model, api_base, run_user, state, + parent_session_uuid, branch_point_message_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + new_uuid, + now_str, + parent.model, + parent.api_base, + parent.run_user, + state_str, + parent_uuid, + branch_point_message_id, + ], + ) + .map_err(|e| AishError::Session(format!("failed to fork session: {e}")))?; + + Ok(SessionRecord { + session_uuid: new_uuid.to_string(), + created_at: now, + model: parent.model, + api_base: parent.api_base, + run_user: parent.run_user, + state: parent.state, + parent_session_uuid: Some(parent_uuid.to_string()), + branch_point_message_id, + }) + } + + /// List the direct child sessions forked from `parent_uuid`, oldest first. + pub fn list_children(&self, parent_uuid: &str) -> Result> { + let mut stmt = self + .conn + .prepare( + "SELECT session_uuid, created_at, model, api_base, run_user, state, + parent_session_uuid, branch_point_message_id + FROM sessions WHERE parent_session_uuid = ?1 + ORDER BY created_at ASC", + ) + .map_err(|e| AishError::Session(format!("failed to prepare list_children: {e}")))?; + + let rows = stmt + .query_map(params![parent_uuid], row_to_session_record) + .map_err(|e| AishError::Session(format!("failed to query children: {e}")))?; + + let mut children = Vec::new(); + for row in rows { + children.push( + row.map_err(|e| AishError::Session(format!("failed to read child row: {e}")))?, + ); + } + Ok(children) + } + + /// List all root sessions (those without a parent), newest first. + pub fn session_roots(&self) -> Result> { + let mut stmt = self + .conn + .prepare( + "SELECT session_uuid, created_at, model, api_base, run_user, state, + parent_session_uuid, branch_point_message_id + FROM sessions WHERE parent_session_uuid IS NULL + ORDER BY created_at DESC", + ) + .map_err(|e| AishError::Session(format!("failed to prepare session_roots: {e}")))?; + + let rows = stmt + .query_map([], row_to_session_record) + .map_err(|e| AishError::Session(format!("failed to query session roots: {e}")))?; + + let mut roots = Vec::new(); + for row in rows { + roots.push( + row.map_err(|e| AishError::Session(format!("failed to read root row: {e}")))?, + ); + } + Ok(roots) + } /// Add a command history entry and return its row id. pub fn add_history_entry(&self, entry: &HistoryEntry) -> Result { @@ -479,6 +578,52 @@ impl AuditSink for AuditStore { } } +/// Build a [`SessionRecord`] from a query row in the canonical column order: +/// `session_uuid, created_at, model, api_base, run_user, state, +/// parent_session_uuid, branch_point_message_id`. +fn row_to_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SessionRecord { + session_uuid: row.get(0)?, + created_at: parse_datetime(&row.get::<_, String>(1)?), + model: row.get(2)?, + api_base: row.get(3)?, + run_user: row.get(4)?, + state: serde_json::from_str(&row.get::<_, String>(5)?) + .unwrap_or(serde_json::Value::Object(Default::default())), + parent_session_uuid: row.get(6)?, + branch_point_message_id: row.get(7)?, + }) +} + +/// Add columns introduced after the initial schema (`parent_session_uuid`, +/// `branch_point_message_id`) to databases created by older builds. Idempotent: +/// each column is added only when missing, detected via `PRAGMA table_info`. +fn migrate_schema(conn: &rusqlite::Connection) -> Result<()> { + add_column_if_missing( + conn, + "ALTER TABLE sessions ADD COLUMN parent_session_uuid TEXT DEFAULT NULL;", + )?; + add_column_if_missing( + conn, + "ALTER TABLE sessions ADD COLUMN branch_point_message_id INTEGER DEFAULT NULL;", + )?; + Ok(()) +} + +/// Apply an `ALTER TABLE ... ADD COLUMN` idempotently. SQLite errors with +/// "duplicate column name" if the column already exists (e.g. a concurrent +/// `SessionStore::open` ran the migration first); treat that error as success +/// so a concurrent migration cannot break startup. +fn add_column_if_missing(conn: &rusqlite::Connection, sql: &str) -> Result<()> { + match conn.execute_batch(sql) { + Ok(()) => Ok(()), + Err(e) if e.to_string().contains("duplicate column") => Ok(()), + Err(e) => Err(AishError::Session(format!( + "failed to apply session schema migration: {e}" + ))), + } +} + /// Parse an RFC 3339 datetime string, falling back to UTC now on failure. fn parse_datetime(s: &str) -> chrono::DateTime { let normalized = if let Some(prefix) = s.strip_suffix('Z') { @@ -647,4 +792,190 @@ mod tests { .unwrap(); assert!(other_user.is_empty()); } + fn temp_store() -> (tempfile::TempDir, SessionStore) { + let temp = tempfile::tempdir().unwrap(); + let db_path = temp.path().join("sessions.db"); + let store = SessionStore::open(Some(&db_path)).unwrap(); + (temp, store) + } + + #[test] + fn fork_session_copies_state_and_links_parent() { + let (_temp, store) = temp_store(); + let parent = store + .create_session("gpt-4o", Some("http://localhost:11434")) + .unwrap(); + let snapshot = SessionStateSnapshot { + cwd: Some("/srv".to_string()), + summary_preview: Some("parent summary".to_string()), + context_messages_snapshot: vec![SessionContextMessage { + role: "user".to_string(), + content: "hello".to_string(), + memory_type: MemoryType::Llm, + name: None, + tool_call_id: None, + }], + updated_at: Some(Utc::now()), + }; + store + .update_session_state(&parent.session_uuid, &snapshot) + .unwrap(); + + let child_uuid = "forked-uuid-1234"; + let forked = store + .fork_session(&parent.session_uuid, Some(42), child_uuid) + .unwrap(); + + // Metadata is copied from the parent. + assert_eq!(forked.model, "gpt-4o"); + assert_eq!(forked.api_base.as_deref(), Some("http://localhost:11434")); + assert_eq!(forked.run_user, parent.run_user); + assert_eq!(forked.session_uuid, child_uuid); + + // Branch linkage is recorded on the returned record. + assert_eq!( + forked.parent_session_uuid.as_deref(), + Some(parent.session_uuid.as_str()) + ); + assert_eq!(forked.branch_point_message_id, Some(42)); + + // The persisted child must carry the parent's state and linkage. + let reloaded = store.get_session(child_uuid).unwrap().unwrap(); + let reloaded_parent = store.get_session(&parent.session_uuid).unwrap().unwrap(); + assert_eq!(reloaded.state, reloaded_parent.state); + let child_snapshot = reloaded.state_snapshot(); + assert_eq!(child_snapshot.cwd.as_deref(), Some("/srv")); + assert_eq!( + child_snapshot.summary_preview.as_deref(), + Some("parent summary") + ); + assert_eq!(child_snapshot.context_messages_snapshot.len(), 1); + assert_eq!( + reloaded.parent_session_uuid.as_deref(), + Some(parent.session_uuid.as_str()) + ); + assert_eq!(reloaded.branch_point_message_id, Some(42)); + + // The parent is the only root; the child is not a root. + let roots = store.session_roots().unwrap(); + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].session_uuid, parent.session_uuid); + } + + #[test] + fn list_children_returns_only_direct_descendants() { + let (_temp, store) = temp_store(); + let parent = store.create_session("m", None).unwrap(); + + let first = store + .fork_session(&parent.session_uuid, None, "child-1") + .unwrap(); + let second = store + .fork_session(&parent.session_uuid, Some(7), "child-2") + .unwrap(); + // A grandchild belongs to a child, not the parent. + let _grand = store.fork_session("child-1", None, "grand-1").unwrap(); + + let children = store.list_children(&parent.session_uuid).unwrap(); + assert_eq!(children.len(), 2); + // Ordered oldest-first by created_at. + assert_eq!(children[0].session_uuid, first.session_uuid); + assert_eq!(children[1].session_uuid, second.session_uuid); + for child in &children { + assert_eq!( + child.parent_session_uuid.as_deref(), + Some(parent.session_uuid.as_str()) + ); + } + + // The grandchild shows up only under its own parent. + let grandchildren = store.list_children("child-1").unwrap(); + assert_eq!(grandchildren.len(), 1); + assert_eq!(grandchildren[0].session_uuid, "grand-1"); + } + + #[test] + fn delete_session_reparents_children_to_root() { + let (_temp, store) = temp_store(); + let parent = store.create_session("m", None).unwrap(); + let _child = store + .fork_session(&parent.session_uuid, Some(42), "child-1") + .unwrap(); + let _grand = store.fork_session("child-1", None, "grand-1").unwrap(); + + // Sanity: parent is the only root before deletion. + assert_eq!(store.session_roots().unwrap().len(), 1); + + store.delete_session(&parent.session_uuid).unwrap(); + + // The deleted parent is gone... + assert!(store.get_session(&parent.session_uuid).unwrap().is_none()); + // ...and its child was reparented to root instead of being orphaned. + let roots = store.session_roots().unwrap(); + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].session_uuid, "child-1"); + assert!(roots[0].parent_session_uuid.is_none()); + // The dangling branch point (into the deleted parent's history) is cleared. + assert!(roots[0].branch_point_message_id.is_none()); + // The grandchild is still nested under the reparented child. + assert_eq!(store.list_children("child-1").unwrap().len(), 1); + } + + #[test] + fn fork_session_missing_parent_errors() { + let (_temp, store) = temp_store(); + let err = store.fork_session("does-not-exist", None, "x").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("parent session not found"), + "unexpected error: {msg}" + ); + } + + #[test] + fn migrate_schema_is_backward_compatible_with_legacy_db() { + // Create a database that predates the branch columns, then re-open it + // through SessionStore which must transparently add them. + let temp = tempfile::tempdir().unwrap(); + let db_path = temp.path().join("legacy.db"); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + session_uuid TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + model TEXT NOT NULL, + api_base TEXT, + run_user TEXT, + state TEXT DEFAULT '{}' + ); + INSERT INTO sessions (session_uuid, created_at, model, api_base, run_user, state) + VALUES ('legacy-1', + '2026-01-01T00:00:00+00:00', + 'old-model', '', '', '{}');", + ) + .unwrap(); + } + + // Re-opening triggers migrate_schema; the legacy row round-trips and + // the new columns default to NULL. + let store = SessionStore::open(Some(&db_path)).unwrap(); + let record = store.get_session("legacy-1").unwrap().unwrap(); + assert_eq!(record.model, "old-model"); + assert!(record.parent_session_uuid.is_none()); + assert!(record.branch_point_message_id.is_none()); + + // Forking off the migrated legacy row must now work. + let forked = store + .fork_session("legacy-1", Some(3), "legacy-fork") + .unwrap(); + assert_eq!(forked.parent_session_uuid.as_deref(), Some("legacy-1")); + assert_eq!(forked.branch_point_message_id, Some(3)); + assert_eq!(store.list_children("legacy-1").unwrap().len(), 1); + + // The legacy row is a root; the fork is not. + let roots = store.session_roots().unwrap(); + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].session_uuid, "legacy-1"); + } } diff --git a/crates/aish-shell/src/ai_handler.rs b/crates/aish-shell/src/ai_handler.rs index 19b8c1b5..6ef3bdbd 100644 --- a/crates/aish-shell/src/ai_handler.rs +++ b/crates/aish-shell/src/ai_handler.rs @@ -329,6 +329,29 @@ impl AiHandler { self.llm_session.update_model(model, api_base, api_key); } + /// Snapshot of the multi-account rotation state for UI display (`/token`). + pub fn rotation_snapshot(&self) -> Option { + self.llm_session.rotation_snapshot() + } + + /// Install, replace, or clear the rotation state. `None` disables rotation. + pub fn apply_rotation_state(&mut self, state: Option) { + match state { + Some(s) => self.llm_session.set_rotation(s), + None => self.llm_session.clear_rotation(), + } + } + + /// Manually switch the active rotation account by name (`/accounts use`). + pub fn use_rotation_account(&self, name: &str) -> bool { + self.llm_session.use_rotation_account(name) + } + + /// Name of the account currently in use, for restoring across rebuilds. + pub fn current_rotation_account(&self) -> Option { + self.llm_session.current_rotation_account() + } + pub fn register_tool(&mut self, tool: Box) { self.llm_session.register_tool(tool); } @@ -337,6 +360,10 @@ impl AiHandler { pub fn token_stats(&self) -> aish_llm::TokenStats { self.token_store.stats() } + /// Return today's persisted usage bucket (input, output, requests). + pub fn token_today(&self) -> (u64, u64, u64) { + self.token_store.today() + } /// Return cumulative token usage for the current session (not persisted store). pub fn session_token_stats(&self) -> aish_llm::TokenStats { diff --git a/crates/aish-shell/src/app.rs b/crates/aish-shell/src/app.rs index 9da48f0c..f917ddf5 100644 --- a/crates/aish-shell/src/app.rs +++ b/crates/aish-shell/src/app.rs @@ -322,12 +322,144 @@ fn prompt_edit_value(label: &str, desc: &str, current: &str, secret: bool) -> Op } } +/// Interactive menu picker built on the inline selection panel. Each option is +/// `(value, label, description)`. Returns the chosen value, or `None` on cancel. +/// REPL-safe (the underlying panel acquires the interactive input guard). +fn pick_menu(title: &str, options: &[(String, String, String)]) -> Option { + let opts: Vec = options + .iter() + .map(|(v, l, d)| { + let mut o = crate::tui::DialogOption::new(v.as_str(), l.as_str()); + if !d.is_empty() { + o = o.with_description(d.as_str()); + } + o + }) + .collect(); + match crate::tui::show_selection_dialog(title, "", &opts, false, true) { + crate::tui::DialogResult::Selected(v) => Some(v), + _ => None, + } +} + fn llm_session_from_config(config: &ConfigModel) -> LlmSession { - LlmSession::with_context( + let mut session = LlmSession::with_context( stream_context_from_config(config), Some(config.temperature), config.max_tokens, - ) + ); + + // Multi-account quota rotation + model fallback. The top-level api_key is + // account #0 (the primary); configured api_accounts extend the pool. + let accounts = rotation_accounts_from_config(config); + if !config.fallback_models.is_empty() || accounts.len() > 1 { + let mut policy = aish_llm::RetryPolicy::default(); + policy.revert_on_cooldown = config.fallback_revert_on_cooldown; + let state = aish_llm::RotationState::new( + config.model.clone(), + accounts, + config.fallback_models.clone(), + policy, + ); + if state.is_active() { + session.set_rotation(state); + } + } + session +} + +/// Build the rotation account pool from config. The primary `api_key` is always +/// index 0; each entry in `config.api_accounts` follows. Disabled when the key +/// is empty so rotation skips it instead of wasting an attempt. +fn rotation_accounts_from_config(config: &ConfigModel) -> Vec { + let mut accounts = vec![aish_llm::ApiAccount { + name: "primary".to_string(), + api_key: config.api_key.clone(), + api_base: None, + model: None, + weight: 1, + disabled: config.api_key.trim().is_empty(), + }]; + for acct in &config.api_accounts { + accounts.push(aish_llm::ApiAccount { + name: acct.name.clone(), + api_key: acct.api_key.clone(), + api_base: acct.api_base.clone(), + model: acct.model.clone(), + weight: acct.weight.max(1), + disabled: acct.disabled, + }); + } + accounts +} + +/// Derive a readable account label from an endpoint URL (its host) or, failing +/// a parseable host, the model name. Used when an outgoing primary account is +/// auto-preserved on an endpoint switch. +fn account_label(base: &str, model: &str) -> String { + let host = base + .split("://") + .nth(1) + .and_then(|rest| rest.split(['/', ':']).next()) + .filter(|h| !h.is_empty() && h.contains('.')); + host.map(str::to_string) + .unwrap_or_else(|| model.to_string()) +} + +/// Append a numeric suffix so `label` does not collide with an existing +/// account name — the manage panel and delete-by-name path key on `name`. +fn unique_account_name(accounts: &[aish_config::ApiAccountConfig], label: &str) -> String { + if !accounts.iter().any(|a| a.name == label) { + return label.to_string(); + } + let mut n = 2; + loop { + let candidate = format!("{label}-{n}"); + if !accounts.iter().any(|a| a.name == candidate) { + return candidate; + } + n += 1; + } +} + +#[cfg(test)] +mod account_helpers_tests { + use super::*; + + #[test] + fn account_label_extracts_dotted_host() { + assert_eq!( + account_label("https://api.openai.com/v1", "gpt-4"), + "api.openai.com" + ); + } + + #[test] + fn account_label_falls_back_to_model_for_non_fqdn_or_garbage() { + // No dot in host → not a usable label. + assert_eq!(account_label("http://localhost:8080", "llama"), "llama"); + // No scheme/host → model fallback. + assert_eq!(account_label("not-a-url", "my-model"), "my-model"); + assert_eq!(account_label("", "my-model"), "my-model"); + } + + #[test] + fn unique_account_name_appends_suffix_only_on_collision() { + let existing = aish_config::ApiAccountConfig { + name: "api.openai.com".into(), + api_key: "k".into(), + api_base: None, + model: None, + weight: 1, + disabled: false, + }; + let accts = vec![existing]; + assert_eq!(unique_account_name(&accts, "free"), "free"); + assert_eq!( + unique_account_name(&accts, "api.openai.com"), + "api.openai.com-2" + ); + } } fn stream_context_from_parts( @@ -567,6 +699,10 @@ pub struct AishShell { /// Session-scoped approval memory shared with the LLM session. Kept on the /// shell so slash commands (e.g. `/forget-approvals`) can reset it. approval_memory: Arc>, + /// Session-scoped "auto-vet" flag shared with `SkillInstallTool`. Kept on + /// the shell so `/forget-approvals` can reset the remembered decision + /// alongside the command approval memory. + skill_auto_vet: Arc, } impl AishShell { @@ -1027,6 +1163,9 @@ impl AishShell { tool_registry.register(Box::new(skill_tool)); tool_registry.register(Box::new(aish_tools::AgentTool::new())); // Register skill registry tools (search + install) from config. + // Session-scoped "auto-vet" flag shared with SkillInstallTool so + // `/forget-approvals` can reset the remembered review decision. + let skill_auto_vet: Arc; { let registry_configs: Vec = config .skills @@ -1042,9 +1181,9 @@ impl AishShell { tool_registry.register(Box::new(aish_tools::SkillSearchTool::new( registry_configs.clone(), ))); - tool_registry.register(Box::new(aish_tools::SkillInstallTool::new( - registry_configs, - ))); + let skill_install_tool = aish_tools::SkillInstallTool::new(registry_configs); + skill_auto_vet = skill_install_tool.auto_vet_handle(); + tool_registry.register(Box::new(skill_install_tool)); tool_registry.register(Box::new(aish_tools::SkillTrustTool)); } @@ -2022,6 +2161,7 @@ impl AishShell { shared_recorder, inline_ai: None, approval_memory, + skill_auto_vet, }) } @@ -3353,6 +3493,9 @@ impl AishShell { Some("/audit") => self.handle_audit_command(&parts), Some("/forget-approvals") => self.handle_forget_approvals(), Some("/skill") => self.handle_skill_command(&parts), + Some("/fork") => self.handle_fork_command(), + Some("/sessions") => self.handle_sessions_command(), + Some("/export") => self.handle_export_command(&parts), _ => { eprintln!("{}", { let mut args = std::collections::HashMap::new(); @@ -3364,15 +3507,25 @@ impl AishShell { true } - /// Handle `/forget-approvals` — clear all session-scoped command approvals - /// so previously "remembered" commands prompt for confirmation again. + /// Handle `/forget-approvals` — clear all session-scoped approvals so + /// previously "remembered" decisions prompt again. The `[a] Remember this + /// session` choice reaches two stores, both of which must clear together: + /// the command approval memory (bash/secure_bash) and + /// the skill-install "auto-vet this session" toggle. fn handle_forget_approvals(&mut self) { - let count = { + let command_count = { let mut memory = self.approval_memory.lock().unwrap(); let n = memory.len(); memory.clear(); n }; + // Reset the skill-install auto-vet toggle (set when the user picked + // "remember this session" on the post-install review dialog). `swap` + // returns the previous value so it counts toward the total cleared. + let skill_vet_cleared = self + .skill_auto_vet + .swap(false, std::sync::atomic::Ordering::SeqCst); + let count = command_count + usize::from(skill_vet_cleared); let mut args = std::collections::HashMap::new(); args.insert("count".to_string(), count.to_string()); println!( @@ -3923,183 +4076,840 @@ impl AishShell { None => None, } } - - fn handle_audit_command(&self, parts: &[&str]) { - let Some(ref audit) = self.audit_store else { - eprintln!("audit is not enabled. Set 'audit.enabled: true' in security_policy.yaml."); - return; + /// `/fork` — branch the current session into a new one (copied context), + /// then switch into the fork so the original is preserved at its branch point. + fn handle_fork_command(&mut self) { + // Persist the current context first so the fork copies the latest state. + self.persist_session_snapshot(); + let parent_uuid = self.session_uuid.clone(); + let new_uuid = uuid::Uuid::new_v4().to_string(); + + // fork_session borrows the store immutably; keep that borrow in a block so + // the later `&mut self` resume call is not blocked by the live reference. + let fork_result = { + let Some(store) = self.session_store.as_ref() else { + eprintln!("{}", t("shell.fork.store_unavailable")); + return; + }; + store.fork_session(&parent_uuid, None, &new_uuid) }; - let mut query = aish_session::AuditQuery::new(); - query.limit = 20; - - let mut i = 1; - while i < parts.len() { - match parts[i] { - "--user" if i + 1 < parts.len() => { - query.user = Some(parts[i + 1].to_string()); - i += 2; - continue; - } - "--host" if i + 1 < parts.len() => { - query.host = Some(parts[i + 1].to_string()); - i += 2; - continue; - } - "--event-type" if i + 1 < parts.len() => { - match parts[i + 1].parse::() { - Ok(t) => query.event_type = Some(t), - Err(e) => { - eprintln!("invalid event type: {e}"); - return; - } - } - i += 2; - continue; - } - "--since" if i + 1 < parts.len() => { - match chrono::DateTime::parse_from_rfc3339(parts[i + 1]) { - Ok(dt) => query.since = Some(dt.with_timezone(&chrono::Utc)), - Err(_) => { - eprintln!("invalid --since datetime (use RFC 3339, e.g. 2026-01-01T00:00:00Z)"); - return; - } - } - i += 2; - continue; - } - "--limit" if i + 1 < parts.len() => { - if let Ok(n) = parts[i + 1].parse::() { - query.limit = n; - } - i += 2; - continue; - } - _ => {} - } - i += 1; - } - - // Access control: non-root users may not query another user's events - // by name. Without --user, all events are shown (the user field in - // audit records reflects the REMOTE SSH user, not the local OS user, - // so filtering by local user would hide SSH session events). - // SAFETY: getuid() never fails. - let current_uid = unsafe { libc::getuid() }; - if current_uid != 0 { - let me = self - .audit_user - .clone() - .unwrap_or_else(|| current_uid.to_string()); - if let Some(ref requested) = query.user { - if requested != &me { + let new_short = &new_uuid[..8.min(new_uuid.len())]; + let parent_short = &parent_uuid[..8.min(parent_uuid.len())]; + match fork_result { + Ok(_) => { + println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.fork.forked", &{ + let mut args = std::collections::HashMap::new(); + args.insert("short".to_string(), new_short.to_string()); + args.insert("parent".to_string(), parent_short.to_string()); + args + }) + ); + if let Err(e) = self.resume_session_with_options(&new_uuid, false, true) { eprintln!( - "permission denied: non-root users can only query their own audit events" + "{}", + t_with_args("shell.fork.switch_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + args + }) + ); + eprintln!( + "{}", + t_with_args("shell.fork.switch_manual", &{ + let mut args = std::collections::HashMap::new(); + args.insert("short".to_string(), new_short.to_string()); + args + }) ); - return; } } + Err(e) => eprintln!( + "{}", + t_with_args("shell.fork.failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + args + }) + ), } + } - let events = match audit.query(&query) { - Ok(events) => events, + /// `/sessions` — browse the session tree in an interactive panel (instead of + /// flooding the screen) and switch to the chosen session on submit. + fn handle_sessions_command(&mut self) { + let Some(store) = self.session_store.as_ref() else { + eprintln!("{}", t("shell.common.session_store_unavailable")); + return; + }; + let roots = match store.session_roots() { + Ok(r) => r, Err(e) => { - eprintln!("failed to query audit log: {e}"); + eprintln!( + "{}", + t_with_args("shell.sessions.list_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + args + }) + ); return; } }; - - if events.is_empty() { - println!("No audit events found."); + if roots.is_empty() { + println!("{}", t("shell.sessions.none")); return; } - println!( - "{:<26} {:<18} {:<10} {:<12} DETAILS", - "TIMESTAMP", "EVENT", "USER", "HOST" + // Walk the full session forest depth-first so nested forks stay visible + // without dumping every node to stdout. + let mut items: Vec = Vec::new(); + for root in &roots { + self.collect_session_tree(store, root, 0, &mut items); + } + + let panel = aish_ui::SearchSelectPanel::new( + aish_i18n::t("shell.resume.selector_title"), + aish_i18n::t("shell.resume.search_placeholder"), + items, ); - println!("{}", "─".repeat(100)); - for ev in &events { - let detail = match ev.event_type { - AuditEventType::Command => { - let cmd = ev.command.as_deref().unwrap_or(""); - let source = ev.source.as_deref().unwrap_or(""); - match ev.return_code { - Some(rc) if rc != 0 => format!("[{}] rc={} {}", source, rc, cmd), - _ => format!("[{}] {}", source, cmd), - } - } - AuditEventType::AiTool => { - let tool = ev.ai_tool.as_deref().unwrap_or("?"); - let args_raw = ev.ai_args.as_deref().unwrap_or(""); - let args_display = serde_json::from_str::(args_raw) - .map(|v| format_tool_args_for_display(tool, &v)) - .unwrap_or_else(|_| truncate_str(args_raw, 80).to_string()); - format!("{} › {}", tool, args_display) - } - AuditEventType::SecurityDecision => { - let dec = ev.decision.as_deref().unwrap_or("?").to_uppercase(); - let mut parts = vec![dec]; - if let Some(choice) = ev.user_choice.as_deref().filter(|c| !c.is_empty()) { - parts.push(format!("user={}", choice)); - } - if let Some(rule) = ev.matched_rule.as_deref().filter(|r| !r.is_empty()) { - parts.push(format!("rule={}", rule)); - } - if let Some(cmd) = ev.command.as_deref().filter(|c| !c.is_empty()) { - parts.push(truncate_str(cmd, 60).to_string()); - } - parts.join(" ") + if let Ok(aish_ui::PanelOutcome::Submitted(aish_ui::SearchSelectOutcome::Selected(id))) = + aish_ui::PanelRuntime::new().run(panel) + { + if id != self.session_uuid { + if let Err(e) = self.resume_session_with_options(&id, true, true) { + eprintln!( + "{}", + t_with_args("shell.sessions.switch_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + args + }) + ); } - }; - println!( - "{:<26} {:<18} {:<10} {:<12} {}", - ev.ts.format("%Y-%m-%d %H:%M:%S"), - ev.event_type, - ev.user.as_deref().unwrap_or("-"), - ev.host.as_deref().unwrap_or("-"), - detail - ); + } } - println!("\n{} event(s) shown.", events.len()); } - fn handle_resume_command(&mut self, parts: &[&str]) { - match parts.len() { - 1 => self.select_recent_session(), - 2 => self.resume_session(parts[1]), - _ => eprintln!("{}", t("shell.resume.usage")), + /// Build one panel row for a session node, indenting forks beneath roots. + fn session_tree_item( + &self, + session: &aish_session::SessionRecord, + depth: usize, + ) -> aish_ui::SearchSelectItem { + let indent = if depth > 0 { + format!("{}└ ", " ".repeat(depth)) + } else { + String::new() + }; + let short = &session.session_uuid[..8.min(session.session_uuid.len())]; + let when = session.created_at.format("%m-%d %H:%M"); + let cur = if session.session_uuid == self.session_uuid { + " *" + } else { + "" + }; + let label = format!("{}{} {} {}{}", indent, short, session.model, when, cur); + let snap = session.state_snapshot(); + let preview: String = snap + .summary_preview + .as_deref() + .unwrap_or("") + .chars() + .take(60) + .collect(); + let search = format!("{} {} {}", short, session.model, preview); + let mut item = aish_ui::SearchSelectItem::new(session.session_uuid.clone(), label) + .with_search_text(search); + if !preview.is_empty() { + item = item.with_detail(preview); } + item } - /// List all live PTY daemon sessions with interactive selection. - /// Enter switches/attaches, Ctrl+E renames the highlighted session (then - /// re-shows the refreshed list), Esc cancels. - fn handle_live_sessions_command(&self) { - use aish_i18n::{t, t_with_args}; + /// Depth-first walk of the session tree: push `node` then recurse into its + /// children. The fork model guarantees acyclic parent links (a parent always + /// exists before its fork), so recursion terminates without a visited set. + fn collect_session_tree( + &self, + store: &aish_session::SessionStore, + node: &aish_session::SessionRecord, + depth: usize, + items: &mut Vec, + ) { + items.push(self.session_tree_item(node, depth)); + if let Ok(children) = store.list_children(&node.session_uuid) { + for child in &children { + self.collect_session_tree(store, child, depth + 1, items); + } + } + } - if !self.config.pty_daemon_enabled { - eprintln!( - "{}", - theme::warning(&t("shell.live_sessions.daemon_disabled")) - ); - eprintln!("{}", t("shell.live_sessions.daemon_disabled_hint")); + /// `/export [md]` — export the current session (AI conversation + command + /// history) to a Markdown file for postmortem or sharing. + fn handle_export_command(&self, parts: &[&str]) { + let format = parts.get(1).copied().unwrap_or("md"); + if format != "md" && format != "markdown" { + eprintln!("{}", t("shell.export.usage")); return; } - - let current_id = std::env::var("AISH_SESSION_ID").ok(); - let home = dirs::home_dir() - .map(|h| h.to_string_lossy().to_string()) - .unwrap_or_default(); - - loop { - let sessions = aish_pty::discover_sessions(); - if sessions.is_empty() { - println!("{}", t("shell.live_sessions.none_active")); + let Some(store) = self.session_store.as_ref() else { + eprintln!("{}", t("shell.export.store_unavailable")); + return; + }; + let uuid = &self.session_uuid; + let record = match store.get_session(uuid) { + Ok(Some(r)) => r, + Ok(None) => { + eprintln!("{}", t("shell.export.not_found")); return; } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + Err(e) => { + eprintln!( + "{}", + t_with_args("shell.export.load_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + args + }) + ); + return; + } + }; + let history = match store.get_history(uuid, 10000) { + Ok(h) => h, + Err(e) => { + eprintln!( + "{}", + t_with_args("shell.export.load_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + args + }) + ); + return; + } + }; + let snap = record.state_snapshot(); + + let mut md = String::new(); + md.push_str(&format!("{}\n\n", t("shell.export.md_header"))); + md.push_str(&format!( + "{}\n", + t_with_args("shell.export.session_label", &{ + let mut args = std::collections::HashMap::new(); + args.insert("uuid".to_string(), uuid.to_string()); + args + }) + )); + md.push_str(&format!( + "{}\n", + t_with_args("shell.export.model_label", &{ + let mut args = std::collections::HashMap::new(); + args.insert("model".to_string(), record.model.clone()); + args + }) + )); + if let Some(base) = &record.api_base { + md.push_str(&format!( + "{}\n", + t_with_args("shell.export.api_base_label", &{ + let mut args = std::collections::HashMap::new(); + args.insert("base".to_string(), base.clone()); + args + }) + )); + } + if let Some(parent) = &record.parent_session_uuid { + md.push_str(&format!( + "{}\n", + t_with_args("shell.export.forked_label", &{ + let mut args = std::collections::HashMap::new(); + args.insert("parent".to_string(), parent.clone()); + args + }) + )); + } + md.push_str(&format!( + "{}\n", + t_with_args("shell.export.created_label", &{ + let mut args = std::collections::HashMap::new(); + args.insert( + "created".to_string(), + record + .created_at + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), + ); + args + }) + )); + if let Some(cwd) = &snap.cwd { + md.push_str(&format!( + "\n{}\n", + t_with_args("shell.export.working_dir", &{ + let mut args = std::collections::HashMap::new(); + args.insert("cwd".to_string(), cwd.clone()); + args + }) + )); + } + + md.push_str(&format!("\n{}\n\n", t("shell.export.conversation_header"))); + for msg in &snap.context_messages_snapshot { + let role = match msg.role.as_str() { + "user" => "User", + "assistant" => "Assistant", + "system" => "System", + other => other, + }; + md.push_str(&format!("**{}**\n\n{}\n\n", role, msg.content)); + } + + md.push_str(&format!("\n{}\n\n", t("shell.export.history_header"))); + md.push_str("| # | source | rc | command |\n|---|---|---|---|\n"); + for (i, entry) in history.iter().enumerate() { + let cmd = entry + .command + .replace('|', "\\|") + .replace('\n', " ") + .replace('`', "\\`"); + let rc = entry + .returncode + .map(|c| c.to_string()) + .unwrap_or_else(|| "-".into()); + md.push_str(&format!( + "| {} | {} | {} | {} |\n", + i + 1, + entry.source, + rc, + cmd + )); + } + + let fname = format!("aish-session-{}.md", &uuid[..8.min(uuid.len())]); + match std::fs::write(&fname, &md) { + Ok(_) => println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.export.exported", &{ + let mut args = std::collections::HashMap::new(); + args.insert("file".to_string(), fname); + args.insert( + "msgs".to_string(), + snap.context_messages_snapshot.len().to_string(), + ); + args.insert("cmds".to_string(), history.len().to_string()); + args + }) + ), + Err(e) => eprintln!( + "{}", + t_with_args("shell.export.write_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("file".to_string(), fname); + args.insert("error".to_string(), e.to_string()); + args + }) + ), + } + } + + fn accounts_add_interactive(&mut self) { + use crate::wizard::model_fetch::fetch_models_from_api; + use crate::wizard::verification::{check_connectivity, check_tool_support}; + + let name = match prompt_edit_value( + &t("shell.accounts.name_label"), + &t("shell.accounts.name_desc"), + "", + false, + ) { + Some(n) if !n.trim().is_empty() => n.trim().to_string(), + _ => { + println!("{}", t("shell.common.cancelled")); + return; + } + }; + let key = match prompt_edit_value( + &t("shell.accounts.key_label"), + &t("shell.accounts.key_desc"), + "", + true, + ) { + Some(k) if !k.trim().is_empty() => k.trim().to_string(), + _ => { + println!("{}", t("shell.common.cancelled")); + return; + } + }; + // api_base defaults to the primary endpoint; the user may override it. + let base = match prompt_edit_value( + &t("shell.accounts.base_label"), + &t("shell.accounts.base_desc"), + &self.config.api_base, + false, + ) { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => self.config.api_base.clone(), + }; + + if self.config.api_accounts.iter().any(|a| a.name == name) { + eprintln!( + "{}", + t_with_args("shell.accounts.already_exists", &{ + let mut args = std::collections::HashMap::new(); + args.insert("name".to_string(), name.clone()); + args + }) + ); + return; + } + + // Pick a model to verify against (same strategy as /setup): + // - if the endpoint lists models, show them with a "custom" option; + // - otherwise (or when the user picks custom), prompt for a name. + let chosen: Option = match fetch_models_from_api(&base, &key, 10) { + Ok(models) if !models.is_empty() => { + let mut opts: Vec<(String, String, String)> = models + .iter() + .map(|m| (m.clone(), m.clone(), String::new())) + .collect(); + opts.push(( + "__custom__".to_string(), + t("shell.accounts.custom_model"), + String::new(), + )); + match pick_menu(&t("shell.accounts.select_model"), &opts) { + Some(m) if m == "__custom__" => None, + Some(m) => Some(m), + None => { + println!("{}", t("shell.common.cancelled")); + return; + } + } + } + Ok(_) => { + println!( + "{}", + t_with_args("shell.accounts.no_models", &{ + let mut args = std::collections::HashMap::new(); + args.insert("model".to_string(), self.config.model.clone()); + args + }) + ); + None + } + Err(e) => { + println!( + "{}", + t_with_args("shell.accounts.fetch_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e); + args + }) + ); + None + } + }; + // Fetch failed/empty or the user picked "custom" -> enter a model name. + let model = match chosen { + Some(m) => m, + None => match prompt_edit_value( + &t("shell.accounts.model_label"), + &t("shell.accounts.model_desc"), + &self.config.model, + false, + ) { + Some(m) if !m.trim().is_empty() => m.trim().to_string(), + _ => { + println!("{}", t("shell.common.cancelled")); + return; + } + }, + }; + + // Verify connectivity + tool support (same probes as /setup). + println!("{}", t("shell.accounts.verifying")); + let conn = check_connectivity(&base, &key, &model, 15); + if conn.ok { + println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.accounts.connected", &{ + let mut args = std::collections::HashMap::new(); + args.insert( + "latency".to_string(), + conn.latency_ms.unwrap_or(0).to_string(), + ); + args.insert("model".to_string(), model.clone()); + args + }) + ); + let tools = check_tool_support(&base, &key, &model, 30); + let state = t(if tools.supports { + "shell.accounts.tool_yes" + } else { + "shell.accounts.tool_no" + }); + println!(" {}", state); + } else { + let err = conn.error.unwrap_or_default(); + eprintln!( + "{}", + t_with_args("shell.accounts.verify_failed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), err); + args + }) + ); + let save = pick_menu( + &t("shell.accounts.verify_failed_title"), + &[ + ( + "save".to_string(), + t("shell.accounts.save_anyway"), + String::new(), + ), + ( + "cancel".to_string(), + t("shell.accounts.cancel_add"), + String::new(), + ), + ], + ); + match save.as_deref() { + Some("save") => {} + _ => { + println!("{}", t("shell.common.cancelled")); + return; + } + } + } + + // Persist. api_base is stored only when it differs from the primary. + let api_base_opt = if base == self.config.api_base { + None + } else { + Some(base) + }; + self.config + .api_accounts + .push(aish_config::ApiAccountConfig { + name: name.clone(), + api_key: key, + api_base: api_base_opt, + model: Some(model.clone()), + weight: 1, + disabled: false, + }); + self.persist_config_and_rebuild_rotation(); + println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.accounts.added", &{ + let mut args = std::collections::HashMap::new(); + args.insert("name".to_string(), name); + args.insert( + "count".to_string(), + self.config.api_accounts.len().to_string(), + ); + args + }) + ); + } + + /// Persist the current config to disk, then rebuild the live rotation state. + fn persist_config_and_rebuild_rotation(&mut self) { + let config_path = aish_config::ConfigLoader::default_config_path(); + if let Err(e) = aish_config::ConfigLoader::save(&self.config, &config_path) { + eprintln!( + "{}", + t_with_args("shell.common.config_save_warning", &{ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + args + }) + ); + } + self.rebuild_rotation(); + } + + /// Rebuild the LLM session rotation state from the current config. + fn rebuild_rotation(&mut self) { + let prev = self.ai_handler.current_rotation_account(); + let accounts = rotation_accounts_from_config(&self.config); + let state = if !self.config.fallback_models.is_empty() || accounts.len() > 1 { + let mut policy = aish_llm::RetryPolicy::default(); + policy.revert_on_cooldown = self.config.fallback_revert_on_cooldown; + let mut s = aish_llm::RotationState::new( + self.config.model.clone(), + accounts, + self.config.fallback_models.clone(), + policy, + ); + if s.is_active() { + if let Some(ref name) = prev { + s.use_account(name); + } + Some(s) + } else { + None + } + } else { + None + }; + self.ai_handler.apply_rotation_state(state); + } + + fn handle_audit_command(&self, parts: &[&str]) { + let Some(ref audit) = self.audit_store else { + eprintln!("audit is not enabled. Set 'audit.enabled: true' in security_policy.yaml."); + return; + }; + + // No args → interactive menu (power-user keeps `/audit --user x ...`). + if parts.len() == 1 { + let opts: Vec<(String, String, String)> = vec![ + ( + "recent".to_string(), + t("shell.menu.audit.recent"), + t("shell.menu.audit.recent_desc"), + ), + ( + "user".to_string(), + t("shell.menu.audit.user"), + t("shell.menu.audit.user_desc"), + ), + ( + "host".to_string(), + t("shell.menu.audit.host"), + t("shell.menu.audit.host_desc"), + ), + ( + "type".to_string(), + t("shell.menu.audit.type"), + t("shell.menu.audit.type_desc"), + ), + ]; + let action = match pick_menu(&t("shell.menu.audit.title"), &opts) { + Some(a) => a, + None => return, + }; + // Build owned args so the borrowed `&[&str]` outlives the match + // arms; recurse into the same handler for the filter cases. For + // "recent" (single element) fall through to the default query + // below instead of recursing — otherwise the len()==1 menu would + // re-trigger on itself. + let owned: Vec = match action.as_str() { + "recent" => vec!["/audit".to_string()], + "user" => match prompt_edit_value( + &t("shell.menu.audit.user_label"), + &t("shell.menu.audit.user_desc"), + "", + false, + ) { + Some(u) if !u.is_empty() => vec!["/audit".into(), "--user".into(), u], + _ => return, + }, + "host" => match prompt_edit_value( + &t("shell.menu.audit.host_label"), + &t("shell.menu.audit.host_desc"), + "", + false, + ) { + Some(h) if !h.is_empty() => vec!["/audit".into(), "--host".into(), h], + _ => return, + }, + "type" => match prompt_edit_value( + &t("shell.menu.audit.type_label"), + &t("shell.menu.audit.type_desc"), + "", + false, + ) { + Some(ty) if !ty.is_empty() => { + vec!["/audit".into(), "--event-type".into(), ty] + } + _ => return, + }, + _ => return, + }; + if owned.len() > 1 { + let refs: Vec<&str> = owned.iter().map(|s| s.as_str()).collect(); + return self.handle_audit_command(&refs); + } + // action == "recent": fall through to the default query. + } + + let mut query = aish_session::AuditQuery::new(); + query.limit = 20; + + let mut i = 1; + while i < parts.len() { + match parts[i] { + "--user" if i + 1 < parts.len() => { + query.user = Some(parts[i + 1].to_string()); + i += 2; + continue; + } + "--host" if i + 1 < parts.len() => { + query.host = Some(parts[i + 1].to_string()); + i += 2; + continue; + } + "--event-type" if i + 1 < parts.len() => { + match parts[i + 1].parse::() { + Ok(t) => query.event_type = Some(t), + Err(e) => { + eprintln!("invalid event type: {e}"); + return; + } + } + i += 2; + continue; + } + "--since" if i + 1 < parts.len() => { + match chrono::DateTime::parse_from_rfc3339(parts[i + 1]) { + Ok(dt) => query.since = Some(dt.with_timezone(&chrono::Utc)), + Err(_) => { + eprintln!("invalid --since datetime (use RFC 3339, e.g. 2026-01-01T00:00:00Z)"); + return; + } + } + i += 2; + continue; + } + "--limit" if i + 1 < parts.len() => { + if let Ok(n) = parts[i + 1].parse::() { + query.limit = n; + } + i += 2; + continue; + } + _ => {} + } + i += 1; + } + + // Access control: non-root users may not query another user's events + // by name. Without --user, all events are shown (the user field in + // audit records reflects the REMOTE SSH user, not the local OS user, + // so filtering by local user would hide SSH session events). + // SAFETY: getuid() never fails. + let current_uid = unsafe { libc::getuid() }; + if current_uid != 0 { + let me = self + .audit_user + .clone() + .unwrap_or_else(|| current_uid.to_string()); + if let Some(ref requested) = query.user { + if requested != &me { + eprintln!( + "permission denied: non-root users can only query their own audit events" + ); + return; + } + } + } + + let events = match audit.query(&query) { + Ok(events) => events, + Err(e) => { + eprintln!("failed to query audit log: {e}"); + return; + } + }; + + if events.is_empty() { + println!("No audit events found."); + return; + } + + println!( + "{:<26} {:<18} {:<10} {:<12} DETAILS", + "TIMESTAMP", "EVENT", "USER", "HOST" + ); + println!("{}", "─".repeat(100)); + for ev in &events { + let detail = match ev.event_type { + AuditEventType::Command => { + let cmd = ev.command.as_deref().unwrap_or(""); + let source = ev.source.as_deref().unwrap_or(""); + match ev.return_code { + Some(rc) if rc != 0 => format!("[{}] rc={} {}", source, rc, cmd), + _ => format!("[{}] {}", source, cmd), + } + } + AuditEventType::AiTool => { + let tool = ev.ai_tool.as_deref().unwrap_or("?"); + let args_raw = ev.ai_args.as_deref().unwrap_or(""); + let args_display = serde_json::from_str::(args_raw) + .map(|v| format_tool_args_for_display(tool, &v)) + .unwrap_or_else(|_| truncate_str(args_raw, 80).to_string()); + format!("{} › {}", tool, args_display) + } + AuditEventType::SecurityDecision => { + let dec = ev.decision.as_deref().unwrap_or("?").to_uppercase(); + let mut parts = vec![dec]; + if let Some(choice) = ev.user_choice.as_deref().filter(|c| !c.is_empty()) { + parts.push(format!("user={}", choice)); + } + if let Some(rule) = ev.matched_rule.as_deref().filter(|r| !r.is_empty()) { + parts.push(format!("rule={}", rule)); + } + if let Some(cmd) = ev.command.as_deref().filter(|c| !c.is_empty()) { + parts.push(truncate_str(cmd, 60).to_string()); + } + parts.join(" ") + } + }; + println!( + "{:<26} {:<18} {:<10} {:<12} {}", + ev.ts.format("%Y-%m-%d %H:%M:%S"), + ev.event_type, + ev.user.as_deref().unwrap_or("-"), + ev.host.as_deref().unwrap_or("-"), + detail + ); + } + println!("\n{} event(s) shown.", events.len()); + } + + fn handle_resume_command(&mut self, parts: &[&str]) { + match parts.len() { + 1 => self.select_recent_session(), + 2 => self.resume_session(parts[1]), + _ => eprintln!("{}", t("shell.resume.usage")), + } + } + + /// List all live PTY daemon sessions with interactive selection. + /// Enter switches/attaches, Ctrl+E renames the highlighted session (then + /// re-shows the refreshed list), Esc cancels. + fn handle_live_sessions_command(&self) { + use aish_i18n::{t, t_with_args}; + + if !self.config.pty_daemon_enabled { + eprintln!( + "{}", + theme::warning(&t("shell.live_sessions.daemon_disabled")) + ); + eprintln!("{}", t("shell.live_sessions.daemon_disabled_hint")); + return; + } + + let current_id = std::env::var("AISH_SESSION_ID").ok(); + let home = dirs::home_dir() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_default(); + + loop { + let sessions = aish_pty::discover_sessions(); + if sessions.is_empty() { + println!("{}", t("shell.live_sessions.none_active")); + return; + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); @@ -4340,20 +5150,34 @@ impl AishShell { } }; - // No args: list sessions so the user can pick an ID. + // No args: interactive menu of killable sessions. if parts.len() < 2 { - println!( - "{}", - theme::bold(&t("shell.kill_live_sessions.list_header")) - ); + let mut rows: Vec<(String, String)> = Vec::new(); for s in &sessions { - println!("{}", format_session_line(s)); + let current = current_id + .as_ref() + .map(|c| s.session_id == *c || s.session_id.starts_with(c)) + .unwrap_or(false); + if current { + continue; + } + rows.push((s.session_id.clone(), format_session_line(s))); } - println!( - "\n{}", - theme::faint(&t("shell.kill_live_sessions.usage_hint")) - ); - return; + let mut menu: Vec<(String, String, String)> = rows + .iter() + .map(|(v, l)| (v.clone(), l.clone(), String::new())) + .collect(); + menu.push(( + "all".to_string(), + t("shell.menu.kill.all"), + t("shell.menu.kill.all_desc"), + )); + let choice = match pick_menu(&t("shell.menu.kill.title"), &menu) { + Some(c) => c, + None => return, + }; + return self + .handle_kill_live_sessions_command(&["/kill_live_sessions", choice.as_str()]); } // `/kill_live_sessions all` @@ -4492,8 +5316,28 @@ impl AishShell { } fn handle_doctor_command(&mut self, parts: &[&str]) { + // No args → interactive menu (power-user keeps `/doctor [--fix]`). + let fix = if parts.len() == 1 { + let opts: Vec<(String, String, String)> = vec![ + ( + "run".to_string(), + t("shell.menu.doctor.run"), + t("shell.menu.doctor.run_desc"), + ), + ( + "fix".to_string(), + t("shell.menu.doctor.fix"), + t("shell.menu.doctor.fix_desc"), + ), + ]; + match pick_menu(&t("shell.menu.doctor.title"), &opts) { + Some(action) => action == "fix", + None => return, + } + } else { + parts.iter().skip(1).any(|arg| *arg == "--fix") + }; let doctor = crate::doctor::Doctor::new(); - let fix = parts.iter().skip(1).any(|arg| *arg == "--fix"); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { doctor.run(fix).await; @@ -4897,6 +5741,32 @@ impl AishShell { } fn handle_record_command(&mut self, parts: &[&str]) { + // No args → interactive menu (power-user keeps `/record start|stop`). + if parts.len() == 1 { + let recording = self + .shared_recorder + .lock() + .map(|g| g.is_some()) + .unwrap_or(false); + let opts: Vec<(String, String, String)> = if recording { + vec![( + "stop".to_string(), + t("shell.menu.record.stop"), + t("shell.menu.record.stop_desc"), + )] + } else { + vec![( + "start".to_string(), + t("shell.menu.record.start"), + t("shell.menu.record.start_desc"), + )] + }; + let action = match pick_menu(&t("shell.menu.record.title"), &opts) { + Some(a) => a, + None => return, + }; + return self.handle_record_command(&["/record", action.as_str()]); + } let subcmd = parts.get(1).copied().unwrap_or(""); let mut guard = self .shared_recorder @@ -4984,18 +5854,16 @@ impl AishShell { } fn handle_model_command(&mut self, parts: &[&str]) { + // `/model ` switches directly (preserving the pre-panel + // shortcut); `/model` with no argument opens the picker panel. if parts.len() == 1 { - let mut args = std::collections::HashMap::new(); - args.insert("model".to_string(), self.config.model.clone()); - println!("{}", t_with_args("shell.model.current", &args)); + self.model_picker_panel(); return; } - - if parts.len() > 1 && (parts[1] == "--help" || parts[1] == "-h") { + if parts[1] == "--help" || parts[1] == "-h" { println!("{}", theme::accent(&t("shell.model_usage"))); return; } - let new_model = parts[1..].join(" "); if new_model == self.config.model { let mut args = std::collections::HashMap::new(); @@ -5003,26 +5871,19 @@ impl AishShell { println!("{}", t_with_args("shell.model.switch_same", &args)); return; } - - // Detect provider for the new model - let _provider = aish_llm::detect_provider(&new_model, &self.config.api_base); - // Update LLM session self.ai_handler.update_model( &new_model, Some(&self.config.api_base), Some(&self.config.api_key), ); - // Update inline completion model so it follows `/model` switches if let Some(ai) = &self.inline_ai { ai.update_model(&new_model); } - // Update config self.config.model = new_model.clone(); self.refresh_config_dependent_tools(); - // Persist to config file let config_path = aish_config::ConfigLoader::default_config_path(); if let Err(e) = aish_config::ConfigLoader::save(&self.config, &config_path) { @@ -5035,12 +5896,342 @@ impl AishShell { }) ); } - let mut args = std::collections::HashMap::new(); args.insert("model".to_string(), new_model); println!("{}", t_with_args("shell.model.switch_success", &args)); } + /// `/model` opens a model switcher spanning every configured endpoint: + /// it fetches each endpoint's model list, shows the URL next to each + /// model, and lets you search by model name or URL. Enter switches the + /// primary endpoint+model in place; recently-used entries float to the + /// top. `a` adds a multi-key account. + fn model_picker_panel(&mut self) { + use crate::wizard::model_fetch::fetch_models_from_api; + use aish_ui::{ + PanelOutcome, PanelRuntime, SearchSelectItem, SearchSelectOutcome, SearchSelectPanel, + }; + + // Collect endpoints + fetch model lists once on entry. Re-fetched only + // after an account is added; switching models reuses the cache. + let mut endpoints: Vec<(String, String)> = + vec![(self.config.api_base.clone(), self.config.api_key.clone())]; + for a in &self.config.api_accounts { + if a.disabled { + continue; + } + let base = a + .api_base + .clone() + .unwrap_or_else(|| self.config.api_base.clone()); + if !endpoints.iter().any(|(b, _)| b == &base) { + endpoints.push((base, a.api_key.clone())); + } + } + let mut fetched: Vec<(String, String)> = Vec::new(); + for (base, key) in &endpoints { + if let Ok(models) = fetch_models_from_api(base, key, 4) { + for m in models { + fetched.push((m, base.clone())); + } + } + } + + loop { + let cur_model = self.config.model.clone(); + let cur_base = self.config.api_base.clone(); + let current_val = format!("{}\u{0}{}", cur_model, cur_base); + + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + seen.insert(current_val.clone()); + let mut items: Vec = + vec![ + SearchSelectItem::new(current_val.clone(), cur_model.clone()) + .with_detail(&cur_base) + .with_search_text(format!("{} {}", cur_model, cur_base)), + ]; + for r in &self.config.recent_models { + if let Some((m, b)) = r.split_once('\u{0}') { + if seen.insert(r.clone()) { + items.push( + SearchSelectItem::new(r.clone(), m.to_string()) + .with_detail(b) + .with_search_text(format!("{} {}", m, b)) + .with_badge(t("shell.model.recent_badge")), + ); + } + } + } + for (m, b) in &fetched { + let val = format!("{}\u{0}{}", m, b); + if seen.insert(val.clone()) { + items.push( + SearchSelectItem::new(val, m.clone()) + .with_detail(b) + .with_search_text(format!("{} {}", m, b)), + ); + } + } + // Also surface each account's configured model, in case its endpoint + // didn't respond to the /models fetch — the user explicitly set it, + // so it must always be reachable in the list. + for a in &self.config.api_accounts { + if let Some(m) = &a.model { + let base = a.api_base.clone().unwrap_or_else(|| cur_base.clone()); + let val = format!("{}\u{0}{}", m, base); + if seen.insert(val.clone()) { + items.push( + SearchSelectItem::new(val, m.clone()) + .with_detail(&base) + .with_search_text(format!("{} {}", m, base)), + ); + } + } + } + + let panel = SearchSelectPanel::new( + t("shell.model.picker_title"), + t("shell.model.picker_search"), + items, + ) + .with_shimmer(Some(¤t_val)) + .with_subtitle(if fetched.is_empty() { + t("shell.model.fetch_failed_subtitle") + } else { + t("shell.model.picker_subtitle") + }) + .with_footer(t("shell.model.picker_footer")) + .with_action('a', t("shell.model.action_add")) + .with_action('m', t("shell.model.action_manage")); + + let outcome = { + let _guard = aish_tools::bash::acquire_interactive_input_guard(); + PanelRuntime::new().run(panel) + }; + + match outcome { + Ok(PanelOutcome::Submitted(SearchSelectOutcome::Selected(val))) => { + if let Some((model, base)) = val.split_once('\u{0}') { + if model != self.config.model || base != self.config.api_base { + let key = endpoints + .iter() + .find(|(b, _)| b == base) + .map(|(_, k)| k.clone()) + .unwrap_or_else(|| self.config.api_key.clone()); + self.switch_endpoint_model(base, &key, model); + } + } + } + Ok(PanelOutcome::Submitted(SearchSelectOutcome::Action('a', _))) => { + self.accounts_add_interactive(); + // A new account may have added an endpoint: re-collect + re-fetch. + endpoints = vec![(self.config.api_base.clone(), self.config.api_key.clone())]; + for a in &self.config.api_accounts { + if a.disabled { + continue; + } + let base = a + .api_base + .clone() + .unwrap_or_else(|| self.config.api_base.clone()); + if !endpoints.iter().any(|(b, _)| b == &base) { + endpoints.push((base, a.api_key.clone())); + } + } + fetched.clear(); + for (base, key) in &endpoints { + if let Ok(models) = fetch_models_from_api(base, key, 4) { + for m in models { + fetched.push((m, base.clone())); + } + } + } + } + Ok(PanelOutcome::Submitted(SearchSelectOutcome::Action('m', _))) => { + self.manage_accounts_fallback(); + } + _ => break, + } + } + } + + /// Switch the active endpoint + model. Switching to a *different* endpoint + /// demotes the outgoing primary into `api_accounts` (so it stays listed + /// and switchable instead of being silently overwritten and lost) and, if + /// the target is itself an existing account, promotes it out of the pool to + /// avoid a duplicate. Switching only the model on the same endpoint leaves + /// the accounts untouched. The choice is recorded in recent history. + fn switch_endpoint_model(&mut self, base: &str, key: &str, model: &str) { + let prev_base = self.config.api_base.clone(); + let prev_key = self.config.api_key.clone(); + let prev_model = self.config.model.clone(); + let endpoint_changed = base != prev_base || key != prev_key; + + if endpoint_changed { + // Preserve the outgoing primary as a named account so a switch to a + // different endpoint never loses it. + if !prev_key.is_empty() + && !self + .config + .api_accounts + .iter() + .any(|a| a.api_key == prev_key) + { + let label = account_label(&prev_base, &prev_model); + self.config + .api_accounts + .push(aish_config::ApiAccountConfig { + name: unique_account_name(&self.config.api_accounts, &label), + api_key: prev_key, + api_base: Some(prev_base.clone()), + model: Some(prev_model.clone()), + weight: 1, + disabled: false, + }); + } + // Promote the target if it is an existing account: drop it from the + // pool so the same credential isn't both primary and a pool entry + // (which would double it in the rotation pool). + self.config + .api_accounts + .retain(|a| a.api_key.is_empty() || a.api_key != key); + } + + self.config.api_base = base.to_string(); + self.config.api_key = key.to_string(); + self.config.model = model.to_string(); + self.ai_handler.update_model(model, Some(base), Some(key)); + if let Some(ai) = &self.inline_ai { + ai.update_model(model); + } + self.refresh_config_dependent_tools(); + let rec = format!("{}\u{0}{}", model, base); + self.config.recent_models.retain(|r| r != &rec); + self.config.recent_models.insert(0, rec); + self.config.recent_models.truncate(20); + let path = aish_config::ConfigLoader::default_config_path(); + let _ = aish_config::ConfigLoader::save(&self.config, &path); + self.rebuild_rotation(); + println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.model.switch_success", &{ + let mut args = std::collections::HashMap::new(); + args.insert("model".to_string(), model.to_string()); + args + }) + ); + } + + /// Manage accounts + fallback chain in a sub-panel: list each, 'd' deletes + /// the highlighted entry, 'f' adds a fallback model. Esc returns to /model. + fn manage_accounts_fallback(&mut self) { + use aish_ui::{ + PanelOutcome, PanelRuntime, SearchSelectItem, SearchSelectOutcome, SearchSelectPanel, + }; + loop { + let mut items: Vec = Vec::new(); + for a in &self.config.api_accounts { + let mut item = SearchSelectItem::new(format!("acct:{}", a.name), a.name.clone()) + .with_detail(a.api_key.chars().take(6).collect::()); + if a.disabled { + item = item.with_badge(t("shell.accounts.disabled")); + } + items.push(item); + } + for m in &self.config.fallback_models { + items.push( + SearchSelectItem::new(format!("fb:{}", m), m.clone()) + .with_badge(t("shell.model.fallback_badge")), + ); + } + if items.is_empty() { + println!("{}", t("shell.model.manage_empty")); + return; + } + let panel = SearchSelectPanel::new( + t("shell.model.manage_title"), + t("shell.model.manage_search"), + items, + ) + .with_subtitle(t("shell.model.manage_subtitle")) + .with_footer(t("shell.model.manage_footer")) + .with_action('d', t("shell.model.action_del")) + .with_action('f', t("shell.model.action_fallback")); + let outcome = { + let _guard = aish_tools::bash::acquire_interactive_input_guard(); + PanelRuntime::new().run(panel) + }; + match outcome { + Ok(PanelOutcome::Submitted(SearchSelectOutcome::Action('d', val))) => { + if let Some(name) = val.strip_prefix("acct:") { + self.config.api_accounts.retain(|a| a.name != name); + self.persist_config_and_rebuild_rotation(); + println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.accounts.removed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("name".to_string(), name.to_string()); + args + }) + ); + } else if let Some(m) = val.strip_prefix("fb:") { + self.config.fallback_models.retain(|x| x != m); + self.persist_config_and_rebuild_rotation(); + println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.model.fallback_removed", &{ + let mut args = std::collections::HashMap::new(); + args.insert("model".to_string(), m.to_string()); + args + }) + ); + } + } + Ok(PanelOutcome::Submitted(SearchSelectOutcome::Action('f', _))) => { + self.add_fallback_interactive(); + } + _ => break, + } + } + } + + /// Prompt for a fallback model name and add it to the chain. + fn add_fallback_interactive(&mut self) { + let model = match prompt_edit_value( + &t("shell.model.fallback_add_label"), + &t("shell.model.fallback_add_desc"), + "", + false, + ) { + Some(m) if !m.trim().is_empty() => m.trim().to_string(), + _ => { + println!("{}", t("shell.common.cancelled")); + return; + } + }; + if self.config.fallback_models.iter().any(|m| m == &model) { + eprintln!( + "{}", + t_with_args("shell.model.fallback_exists", &{ + let mut args = std::collections::HashMap::new(); + args.insert("model".to_string(), model); + args + }) + ); + return; + } + self.config.fallback_models.push(model.clone()); + self.persist_config_and_rebuild_rotation(); + println!( + "\x1b[32m{}\x1b[0m", + t_with_args("shell.model.fallback_added", &{ + let mut args = std::collections::HashMap::new(); + args.insert("model".to_string(), model); + args + }) + ); + } + /// Handle `/setting` — single-screen settings panel. /// /// Flat layout: category chips on top, type-to-filter list, inline edit @@ -5848,6 +7039,41 @@ impl AishShell { fn handle_plan_command(&mut self, parts: &[&str]) { use aish_core::PlanPhase; + // No args → interactive menu (power-user keeps `/plan start|status|exit`). + if parts.len() == 1 { + let opts: Vec<(String, String, String)> = match self.ai_handler.plan_phase() { + PlanPhase::Normal => vec![ + ( + "start".to_string(), + t("shell.menu.plan.start"), + t("shell.menu.plan.start_desc"), + ), + ( + "status".to_string(), + t("shell.menu.plan.status"), + t("shell.menu.plan.status_desc"), + ), + ], + PlanPhase::Planning => vec![ + ( + "status".to_string(), + t("shell.menu.plan.status"), + t("shell.menu.plan.status_desc"), + ), + ( + "exit".to_string(), + t("shell.menu.plan.exit"), + t("shell.menu.plan.exit_desc"), + ), + ], + }; + let action = match pick_menu(&t("shell.menu.plan.title"), &opts) { + Some(a) => a, + None => return, + }; + return self.handle_plan_command(&["/plan", action.as_str()]); + } + if parts.len() > 1 && (parts[1] == "--help" || parts[1] == "-h") { println!("{}", theme::accent("Usage: /plan [start|status|exit]")); return; @@ -5891,6 +7117,12 @@ impl AishShell { "exit" => { println!("mode=shell, approval_status=draft, artifact=-"); } + "status" => { + // Not currently planning — report that instead of + // silently entering plan mode (the catch-all below + // would otherwise treat `status` as `start`). + println!("{}", theme::faint(&t("shell.menu.plan.not_in_plan"))); + } _ => { // `/plan` or `/plan start` from shell mode → enter planning self.ai_handler.enter_plan_mode(&self.session_uuid); @@ -5909,32 +7141,96 @@ impl AishShell { } } - /// Handle `/token` — show cumulative token usage statistics (last 7 days). + /// Handle `/token` — show token usage (7-day totals, today, this session), + /// and (when active) the multi-account rotation status that previously + /// lived under `/usage`. fn handle_token_command(&self) { let stats = self.ai_handler.token_stats(); let total = stats.total_input + stats.total_output; println!(); - println!("{}", aish_i18n::t("shell.token.title")); + println!("\x1b[1m{}\x1b[0m", t("shell.token.title")); println!( " {} {}", - aish_i18n::t("shell.token.input_tokens"), + t("shell.token.input_tokens"), format_number(stats.total_input) ); println!( " {} {}", - aish_i18n::t("shell.token.output_tokens"), + t("shell.token.output_tokens"), format_number(stats.total_output) ); - println!( - " {} {}", - aish_i18n::t("shell.token.total"), - format_number(total) - ); + println!(" {} {}", t("shell.token.total"), format_number(total)); println!( " {} {}", - aish_i18n::t("shell.token.api_calls"), + t("shell.token.api_calls"), format_number(stats.request_count) ); + let (today_in, today_out, _) = self.ai_handler.token_today(); + let sess = self.ai_handler.session_token_stats(); + println!( + " {} {} {} {}", + t("shell.token.today"), + format_number(today_in + today_out), + t("shell.token.session"), + format_number(sess.total_input + sess.total_output), + ); + + // Rotation fold-in — only surfaced when multi-account/model rotation + // is actually active (no nag for single-account users). + if let Some(snap) = self.ai_handler.rotation_snapshot() { + println!(); + println!( + " {} {} {}", + t("shell.token.rotation"), + t_with_args("shell.token.rotation_avail", &{ + let mut a = std::collections::HashMap::new(); + a.insert("total".to_string(), snap.account_names.len().to_string()); + a.insert("available".to_string(), snap.available_accounts.to_string()); + a + }), + t_with_args("shell.token.rotation_count", &{ + let mut a = std::collections::HashMap::new(); + a.insert("count".to_string(), snap.total_rotations.to_string()); + a + }), + ); + for name in &snap.account_names { + let is_cur = snap.current_account.as_deref() == Some(name.as_str()); + let is_cool = snap.cooled_accounts.iter().any(|c| c == name); + let (mark, skey) = if is_cur { + ("\x1b[32m*\x1b[0m", "shell.token.state_active") + } else if is_cool { + ("\x1b[31mx\x1b[0m", "shell.token.state_cooling") + } else { + ("o", "shell.token.state_ready") + }; + println!(" {} {:<16} {}", mark, name, t(skey)); + } + let fb = if snap.on_fallback { + format!(" \x1b[33m({})\x1b[0m", t("shell.token.fallback_active")) + } else { + String::new() + }; + println!( + " {}{}", + t_with_args("shell.token.active_model", &{ + let mut a = std::collections::HashMap::new(); + a.insert("model".to_string(), snap.active_model.clone()); + a + }), + fb + ); + if !snap.fallback_models.is_empty() { + println!( + " {}", + t_with_args("shell.token.fallback_chain", &{ + let mut a = std::collections::HashMap::new(); + a.insert("chain".to_string(), snap.fallback_models.join(" -> ")); + a + }) + ); + } + } println!(); } diff --git a/crates/aish-shell/src/readline.rs b/crates/aish-shell/src/readline.rs index 53a660ac..2c3772da 100644 --- a/crates/aish-shell/src/readline.rs +++ b/crates/aish-shell/src/readline.rs @@ -49,6 +49,12 @@ pub const SLASH_COMMANDS: &[(&str, &str)] = &[ "/skill", "Search, install, verify skills; manage registries", ), + ( + "/fork", + "Branch the current session into a new one (copied context)", + ), + ("/sessions", "Show the session tree (roots + forks)"), + ("/export", "Export current session to Markdown"), ]; // --------------------------------------------------------------------------- @@ -827,6 +833,6 @@ mod tests { cmd ); } - assert_eq!(SLASH_COMMANDS.len(), 18); + assert_eq!(SLASH_COMMANDS.len(), 21); } } diff --git a/crates/aish-shell/src/resume_selector.rs b/crates/aish-shell/src/resume_selector.rs index 92fc9a60..a9b83487 100644 --- a/crates/aish-shell/src/resume_selector.rs +++ b/crates/aish-shell/src/resume_selector.rs @@ -76,6 +76,8 @@ pub fn select_resume_session(items: &[ResumeSessionItem]) -> io::Result Ok(None), // Quit (Ctrl+Q) is not meaningful for the resume picker; cancel. PanelOutcome::Submitted(SearchSelectOutcome::Quit) => Ok(None), + // Actions are not used by the resume picker; treat as a no-op. + PanelOutcome::Submitted(SearchSelectOutcome::Action(_, _)) => Ok(None), PanelOutcome::Cancelled => Ok(None), } } diff --git a/crates/aish-shell/src/token_store.rs b/crates/aish-shell/src/token_store.rs index ba134c9e..cad93899 100644 --- a/crates/aish-shell/src/token_store.rs +++ b/crates/aish-shell/src/token_store.rs @@ -93,6 +93,13 @@ impl TokenUsageStore { } stats } + /// Return today's persisted usage bucket (input, output, requests). + pub fn today(&self) -> (u64, u64, u64) { + self.records + .get(&Self::today_string()) + .map(|r| (r.input, r.output, r.requests)) + .unwrap_or((0, 0, 0)) + } fn today_string() -> String { chrono::Local::now().format("%Y-%m-%d").to_string() diff --git a/crates/aish-shell/tests/slash_popup_commands.rs b/crates/aish-shell/tests/slash_popup_commands.rs index fbfe10a0..aef53afd 100644 --- a/crates/aish-shell/tests/slash_popup_commands.rs +++ b/crates/aish-shell/tests/slash_popup_commands.rs @@ -30,7 +30,7 @@ fn each_builtin_command_enter_executes() { #[test] fn slash_commands_table_has_expected_count() { - assert_eq!(SLASH_COMMANDS.len(), 18); + assert_eq!(SLASH_COMMANDS.len(), 21); } #[test] diff --git a/crates/aish-skills/src/manager.rs b/crates/aish-skills/src/manager.rs index a595816b..e038ae14 100644 --- a/crates/aish-skills/src/manager.rs +++ b/crates/aish-skills/src/manager.rs @@ -161,30 +161,8 @@ impl SkillManager { /// The file must start with a YAML frontmatter block delimited by `---`. fn parse_skill_file(&self, source: SkillSource, skill_path: &Path) -> aish_core::Result { let content = std::fs::read_to_string(skill_path)?; - let re = regex::Regex::new(FRONTMATTER_REGEX).map_err(|e| { - aish_core::AishError::Skill(format!("Invalid frontmatter regex: {}", e)) - })?; - - let caps = re.captures(&content).ok_or_else(|| { - aish_core::AishError::Skill( - "Invalid skill file format: must start with YAML frontmatter".into(), - ) - })?; - - let frontmatter_yaml = caps.get(1).unwrap().as_str(); - let skill_content = &content[caps.get(0).unwrap().end()..]; - let skill_content = skill_content.trim(); - - let metadata: SkillMetadata = serde_yaml::from_str(frontmatter_yaml) - .map_err(|e| aish_core::AishError::Skill(format!("Invalid YAML frontmatter: {}", e)))?; - if metadata.context == crate::SkillExecutionContext::SubAgent - && metadata.agent.as_deref().is_none_or(str::is_empty) - { - return Err(aish_core::AishError::Skill(format!( - "Skill '{}' uses context=subagent but does not declare an agent", - metadata.name - ))); - } + let (metadata, body) = parse_skill_metadata(&content)?; + let skill_content = body.trim(); let base_dir = skill_path .parent() @@ -321,6 +299,40 @@ impl SkillManager { } } +/// Parse a SKILL.md document: extract the YAML frontmatter, deserialize it +/// into [`SkillMetadata`], and enforce the loader's invariants. Returns the +/// parsed metadata and the document body (the content after the frontmatter +/// block, not yet trimmed). +/// +/// This is the single source of truth for "would the loader accept this file": +/// [`SkillManager::parse_skill_file`], the registry installer, and the verifier +/// all route through it, so install/verify-time validation can never drift from +/// load-time validation. A skill that fails this (e.g. one that declares +/// `context: subagent`/`fork` without an `agent`) is rejected here rather than +/// landing on disk only to be rejected on every hot-reload. +pub fn parse_skill_metadata(content: &str) -> aish_core::Result<(SkillMetadata, &str)> { + let re = regex::Regex::new(FRONTMATTER_REGEX) + .map_err(|e| aish_core::AishError::Skill(format!("Invalid frontmatter regex: {}", e)))?; + let caps = re.captures(content).ok_or_else(|| { + aish_core::AishError::Skill( + "Invalid skill file format: must start with YAML frontmatter".into(), + ) + })?; + let frontmatter_yaml = caps.get(1).unwrap().as_str(); + let body = &content[caps.get(0).unwrap().end()..]; + let metadata: SkillMetadata = serde_yaml::from_str(frontmatter_yaml) + .map_err(|e| aish_core::AishError::Skill(format!("Invalid YAML frontmatter: {}", e)))?; + if metadata.context == crate::SkillExecutionContext::SubAgent + && metadata.agent.as_deref().is_none_or(str::is_empty) + { + return Err(aish_core::AishError::Skill(format!( + "Skill '{}' uses context=subagent but does not declare an agent", + metadata.name + ))); + } + Ok((metadata, body)) +} + /// Walk a directory recursively, following symlinks while detecting cycles. fn walk_dir(dir: &Path) -> Vec { let mut files = Vec::new(); @@ -629,4 +641,30 @@ mod tests { "nested SKILL.md under an untrusted skill must be quarantined" ); } + #[test] + fn parse_skill_metadata_rejects_fork_without_agent() { + // The docker-best-practices bug: `context: fork` aliases to SubAgent, + // which requires a named agent. The shared parse must reject it so the + // installer and verifier reject it too. + let content = "---\nname: docker\ndescription: d\ncontext: fork\n---\nbody\n"; + let err = parse_skill_metadata(content).expect_err("fork without agent must fail"); + assert!(err.to_string().contains("agent"), "got: {err}"); + } + + #[test] + fn parse_skill_metadata_accepts_subagent_with_agent_and_returns_body() { + let content = "---\nname: diag\ndescription: d\ncontext: subagent\nagent: troubleshoot\n---\n## Body\n"; + let (metadata, body) = + parse_skill_metadata(content).expect("valid subagent skill must parse"); + assert_eq!(metadata.name, "diag"); + assert_eq!(metadata.agent.as_deref(), Some("troubleshoot")); + assert!(body.trim_start().starts_with("## Body")); + } + + #[test] + fn parse_skill_metadata_rejects_missing_frontmatter() { + let err = parse_skill_metadata("no frontmatter here at all") + .expect_err("must require frontmatter"); + assert!(err.to_string().contains("frontmatter"), "got: {err}"); + } } diff --git a/crates/aish-skills/src/registry/mod.rs b/crates/aish-skills/src/registry/mod.rs index 9264784a..9c36cb42 100644 --- a/crates/aish-skills/src/registry/mod.rs +++ b/crates/aish-skills/src/registry/mod.rs @@ -254,22 +254,7 @@ impl RegistryManager { let adapter = self.find_adapter(&skill.registry).ok_or_else(|| { aish_core::AishError::Skill(format!("No adapter for registry '{}'", skill.registry)) })?; - // Remember whether this is a fresh install or a reinstall over an - // existing skill, so a failed reinstall does not delete the previously - // installed (possibly trusted / locally modified) skill. - let pre_existed = target_dir.join(&skill.slug).exists(); - pre_quarantine(target_dir, &skill.slug)?; - match adapter.install(skill, target_dir) { - Ok(result) => Ok(result), - Err(e) => { - // Only remove on a failed FRESH install; a failed reinstall - // must leave the previous skill intact rather than destroy it. - if !pre_existed { - let _ = std::fs::remove_dir_all(target_dir.join(&skill.slug)); - } - Err(e) - } - } + Self::install_transactional(adapter, skill, target_dir, None) } /// Install a skill, respecting a cooperative cancel flag checked between @@ -285,15 +270,61 @@ impl RegistryManager { let adapter = self.find_adapter(&skill.registry).ok_or_else(|| { aish_core::AishError::Skill(format!("No adapter for registry '{}'", skill.registry)) })?; - let pre_existed = target_dir.join(&skill.slug).exists(); - pre_quarantine(target_dir, &skill.slug)?; - match adapter.install_with_cancel(skill, target_dir, cancel) { - Ok(result) => Ok(result), + Self::install_transactional(adapter, skill, target_dir, Some(cancel)) + } + + /// Transactional install core shared by [`install`](Self::install) and + /// [`install_with_cancel`](Self::install_with_cancel). + /// + /// For a reinstall the existing skill dir is moved aside first (same + /// filesystem, so the rename is atomic), then the replacement is downloaded + /// into a fresh dir and validated. On failure the failed replacement is + /// removed and the original restored in place — a bad replacement can never + /// overwrite/quarantine a previously trusted or locally modified skill. + fn install_transactional( + adapter: &dyn RegistryAdapter, + skill: &RegistrySkill, + target_dir: &Path, + cancel: Option<&AtomicBool>, + ) -> Result { + // Reject path-escaping slugs BEFORE constructing the stash path — + // reinstall_stash_path joins the slug into target_dir, so an unsafe + // slug must not reach it. (pre_quarantine re-validates downstream.) + installer::validate_install_slug(&skill.slug)?; + let live = target_dir.join(&skill.slug); + let stash = if live.exists() { + let stash = reinstall_stash_path(target_dir, &skill.slug); + if stash.exists() { + let _ = std::fs::remove_dir_all(&stash); + } + std::fs::rename(&live, &stash).map_err(|e| { + aish_core::AishError::Skill(format!( + "failed to stash existing skill for reinstall: {e}" + )) + })?; + Some(stash) + } else { + None + }; + let result = pre_quarantine(target_dir, &skill.slug) + .and_then(|_| match cancel { + None => adapter.install(skill, target_dir), + Some(flag) => adapter.install_with_cancel(skill, target_dir, flag), + }) + .and_then(|r| validate_installed_skill(&r.dir).map(|_| r)); + match result { + Ok(result) => { + // Success: the validated replacement supersedes the original. + if let Some(s) = stash { + let _ = std::fs::remove_dir_all(s); + } + Ok(result) + } Err(e) => { - // Only remove on a failed FRESH install; a failed reinstall - // must leave the previous skill intact rather than destroy it. - if !pre_existed { - let _ = std::fs::remove_dir_all(target_dir.join(&skill.slug)); + // Failure: drop the failed replacement and restore the original. + let _ = std::fs::remove_dir_all(&live); + if let Some(s) = stash { + let _ = std::fs::rename(s, &live); } Err(e) } @@ -367,6 +398,29 @@ pub fn parse_skill_id_rest(rest: &str) -> Option<(String, String)> { } } +/// Build a unique sibling path (same `target_dir` filesystem) for stashing an +/// existing skill dir during a transactional reinstall. A same-filesystem +/// rename is atomic; stashing under `/tmp` could cross mounts and fail with +/// EXDEV. The nanos+pid suffix avoids collisions between concurrent installs. +fn reinstall_stash_path(target_dir: &Path, slug: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + target_dir.join(format!(".{slug}.reinstall-{nanos}-{}", std::process::id())) +} + +/// Confirm an installed skill's SKILL.md is loadable by running the exact +/// parse the loader uses. A skill the loader rejects is unusable — it lands on +/// disk only to be rejected on every hot-reload — so this turns a silent +/// post-install failure (warning spam) into an actionable install-time error. +fn validate_installed_skill(dir: &Path) -> Result<()> { + let content = std::fs::read_to_string(dir.join("SKILL.md")).map_err(|e| { + aish_core::AishError::Skill(format!("Installed skill has no readable SKILL.md: {}", e)) + })?; + crate::manager::parse_skill_metadata(&content).map(|_| ()) +} + /// Pre-create the skill directory with its `.untrusted` quarantine marker /// BEFORE the adapter downloads any files. Writing the marker first (a) closes /// the race where a hot-reload observes files without the marker and (b) makes @@ -447,4 +501,122 @@ mod tests { "parent contents must be intact" ); } + /// Test adapter that writes a fixed SKILL.md into the install dir, so we + /// can drive `RegistryManager::install` end-to-end without any HTTP. + struct FakeAdapter { + name: &'static str, + skill_md: &'static str, + } + + impl RegistryAdapter for FakeAdapter { + fn name(&self) -> &str { + self.name + } + fn search(&self, _query: &str, _limit: usize) -> Result> { + Ok(vec![]) + } + fn install(&self, skill: &RegistrySkill, target_dir: &Path) -> Result { + let dir = target_dir.join(&skill.slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("SKILL.md"), self.skill_md).unwrap(); + Ok(InstallResult { + dir, + skill_name: skill.slug.clone(), + }) + } + } + + fn fake_registry_skill(slug: &str, registry: &str) -> RegistrySkill { + RegistrySkill { + id: format!("{registry}/{slug}"), + name: slug.to_string(), + description: "test".into(), + registry: registry.to_string(), + source: "owner/repo".into(), + slug: slug.to_string(), + installs: 0, + homepage: None, + } + } + + #[test] + fn install_rejects_unloadable_skill_and_rolls_back_fresh_install() { + // Regression for "installed skill spams hot-reload warnings forever": + // a skill the loader rejects (context=fork, no agent) must be refused + // at install time and the fresh install dir removed. + let tmp = tempfile::tempdir().expect("temp dir"); + let mut manager = RegistryManager { adapters: vec![] }; + manager.register(Box::new(FakeAdapter { + name: "fake", + skill_md: "---\nname: bad\ndescription: d\ncontext: fork\n---\nbody\n", + })); + let skill = fake_registry_skill("bad-skill", "fake"); + + let err = manager + .install(&skill, tmp.path()) + .expect_err("unloadable skill must be rejected at install time"); + assert!(err.to_string().contains("agent"), "got: {err}"); + assert!( + !tmp.path().join("bad-skill").exists(), + "fresh install must be rolled back" + ); + } + + #[test] + fn install_accepts_loadable_skill() { + let tmp = tempfile::tempdir().expect("temp dir"); + let mut manager = RegistryManager { adapters: vec![] }; + manager.register(Box::new(FakeAdapter { + name: "fake", + skill_md: "---\nname: good\ndescription: d\n---\nbody\n", + })); + let skill = fake_registry_skill("good-skill", "fake"); + + let result = manager + .install(&skill, tmp.path()) + .expect("loadable skill installs"); + assert!(result.dir.join("SKILL.md").exists()); + } + + #[test] + fn install_failed_reinstall_keeps_previous_skill_dir() { + // A failed RE-install must not delete the previously installed skill + // dir, even when the newly written SKILL.md fails validation. + let tmp = tempfile::tempdir().expect("temp dir"); + let dir = tmp.path().join("exists"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("SKILL.md"), + "---\nname: old\ndescription: d\n---\nold body\n", + ) + .unwrap(); + + let mut manager = RegistryManager { adapters: vec![] }; + manager.register(Box::new(FakeAdapter { + name: "fake", + skill_md: "---\nname: bad\ndescription: d\ncontext: fork\n---\nbody\n", + })); + let skill = fake_registry_skill("exists", "fake"); + + let err = manager + .install(&skill, tmp.path()) + .expect_err("unloadable reinstall must still fail"); + assert!(err.to_string().contains("agent")); + assert!( + dir.exists(), + "previous skill dir must survive a failed reinstall" + ); + // The original content must be byte-for-byte intact (not overwritten by + // the failed replacement) and the original trusted state preserved. + let preserved = std::fs::read_to_string(dir.join("SKILL.md")) + .expect("original SKILL.md must survive a failed reinstall"); + assert_eq!( + preserved, "---\nname: old\ndescription: d\n---\nold body\n", + "failed reinstall must not overwrite the previous skill content" + ); + assert!( + !dir.join(crate::models::UNTRUSTED_MARKER).exists(), + "original trust state must be restored (no quarantine marker)" + ); + } } diff --git a/crates/aish-skills/src/registry/verifier.rs b/crates/aish-skills/src/registry/verifier.rs index 84014905..2ac40c8c 100644 --- a/crates/aish-skills/src/registry/verifier.rs +++ b/crates/aish-skills/src/registry/verifier.rs @@ -57,37 +57,25 @@ pub fn verify_skill_dir(dir: &Path) -> VerifyReport { } }; - // 2. Frontmatter parses. - let frontmatter_ok = match parse_frontmatter(&content) { - Ok(fm) => { - if let Some(name) = fm.get("name").and_then(|v| v.as_str()) { - skill_name = name.to_string(); - } - let has_name = fm.get("name").and_then(|v| v.as_str()).is_some(); - let has_desc = fm.get("description").and_then(|v| v.as_str()).is_some(); + // 2. Frontmatter parses and satisfies the loader's invariants. Reuses the + // exact parse the loader + installer use, so `verify` cannot report a + // skill as valid that the loader would reject (e.g. context=fork with + // no agent). + let frontmatter_ok = match crate::manager::parse_skill_metadata(&content) { + Ok((metadata, _)) => { + skill_name = metadata.name.clone(); checks.push(VerifyCheck { label: "Frontmatter valid".into(), - passed: has_name && has_desc, - detail: if has_name && has_desc { - format!("name=\"{}\", has description", skill_name) - } else { - let mut missing = Vec::new(); - if !has_name { - missing.push("name"); - } - if !has_desc { - missing.push("description"); - } - format!("Missing fields: {}", missing.join(", ")) - }, + passed: true, + detail: format!("name=\"{}\"", skill_name), }); - has_name && has_desc + true } Err(e) => { checks.push(VerifyCheck { label: "Frontmatter valid".into(), passed: false, - detail: e, + detail: e.to_string(), }); false } @@ -109,23 +97,6 @@ pub fn verify_skill_dir(dir: &Path) -> VerifyReport { } } -/// Parse YAML frontmatter from SKILL.md content. -/// Returns a map of key → JSON value. -fn parse_frontmatter(content: &str) -> Result { - let re = regex::Regex::new(r"(?s)^---\s*\n(.*?)\n---\s*\n") - .map_err(|e| format!("Regex error: {}", e))?; - - let caps = re - .captures(content) - .ok_or_else(|| "Missing YAML frontmatter (--- delimiters)".to_string())?; - - let yaml_str = caps.get(1).unwrap().as_str(); - let yaml: serde_yaml::Value = - serde_yaml::from_str(yaml_str).map_err(|e| format!("YAML parse error: {}", e))?; - - serde_json::to_value(&yaml).map_err(|e| format!("Conversion error: {}", e)) -} - /// Check that scripts in a directory exist and have executable permissions. fn check_scripts_dir(scripts_dir: &Path, checks: &mut Vec) { let entries = match std::fs::read_dir(scripts_dir) { diff --git a/crates/aish-tools/Cargo.toml b/crates/aish-tools/Cargo.toml index 38d7d833..fc96e8d4 100644 --- a/crates/aish-tools/Cargo.toml +++ b/crates/aish-tools/Cargo.toml @@ -15,13 +15,13 @@ serde.workspace = true serde_json.workspace = true tracing.workspace = true regex.workspace = true +dirs.workspace = true uuid.workspace = true reqwest.workspace = true tokio.workspace = true futures.workspace = true glob.workspace = true shellexpand.workspace = true -dirs.workspace = true inquire.workspace = true [dev-dependencies] diff --git a/crates/aish-tools/src/skill_registry/skill_registry.rs b/crates/aish-tools/src/skill_registry/skill_registry.rs index 9a3412ee..968a70fb 100644 --- a/crates/aish-tools/src/skill_registry/skill_registry.rs +++ b/crates/aish-tools/src/skill_registry/skill_registry.rs @@ -202,6 +202,17 @@ impl SkillInstallTool { } } + /// Shared handle to the session-scoped "auto-vet" flag. + /// + /// Set when the user picks "remember this session" on the post-install + /// review dialog, so later installs skip the dialog and auto-run the + /// vetter. `/forget-approvals` swaps it back to `false` so the review + /// dialog reappears — the same `[a]` choice reaches this flag and the + /// command approval memory, so both must be clearable together. + pub fn auto_vet_handle(&self) -> std::sync::Arc { + self.auto_vet.clone() + } + fn user_skills_dir() -> PathBuf { // Shared with the loader (SkillManager::scan_skill_roots) so installs // always land where skills are read from. @@ -661,3 +672,28 @@ determining its risk is Low or Medium. Never trust a High/Extreme skill." } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The handle returned by `auto_vet_handle` must alias the tool's internal + /// flag. `/forget-approvals` relies on this to reset a "remember this + /// session" decision the skill-install tool recorded privately. + #[test] + fn auto_vet_handle_resets_the_remembered_decision() { + let tool = SkillInstallTool::new(vec![]); + let handle = tool.auto_vet_handle(); + assert!(!handle.load(std::sync::atomic::Ordering::SeqCst)); + + // Simulate the user picking "remember this session" on the review + // dialog (execute_async_in_session stores into this same flag). + handle.store(true, std::sync::atomic::Ordering::SeqCst); + assert!(tool.auto_vet.load(std::sync::atomic::Ordering::SeqCst)); + + // `/forget-approvals` swaps the handle back to false; the tool must + // observe the reset so later installs re-prompt for review. + assert!(handle.swap(false, std::sync::atomic::Ordering::SeqCst)); + assert!(!tool.auto_vet.load(std::sync::atomic::Ordering::SeqCst)); + } +} diff --git a/crates/aish-tools/src/web_fetch/web_fetch.rs b/crates/aish-tools/src/web_fetch/web_fetch.rs index af47936b..fcc190a5 100644 --- a/crates/aish-tools/src/web_fetch/web_fetch.rs +++ b/crates/aish-tools/src/web_fetch/web_fetch.rs @@ -148,6 +148,16 @@ impl Tool for WebFetchTool { } } + fn approval_key(&self, args: &serde_json::Value) -> Option { + // web_fetch confirms per host (see `preflight`), so remember per host + // too — approving one URL auto-approves the rest of that host for the + // session. Invalid/missing URLs are not rememberable. + let url = args.get("url")?.as_str()?; + validate_and_normalize_url(url) + .ok() + .and_then(|normalized| normalized.host_str().map(|h| h.to_string())) + } + fn execute(&self, _args: serde_json::Value) -> ToolResult { ToolResult::error("WebFetch requires async execution; use execute_async") } diff --git a/crates/aish-ui/src/select.rs b/crates/aish-ui/src/select.rs index 5c5580ba..49458a0e 100644 --- a/crates/aish-ui/src/select.rs +++ b/crates/aish-ui/src/select.rs @@ -91,17 +91,34 @@ pub enum SearchSelectOutcome { Rename(String), /// Exit the panel (triggered by Ctrl+Q). Callers decide exit semantics. Quit, + /// A configured single-key action was pressed (e.g. 'a' add, 'd' delete). + /// Action keys are intercepted before the search buffer. + Action(char, String), } -/// Match when every whitespace-separated token in `query` is a substring of `search_text`. -fn matches_tokenized_query(search_text: &str, query: &str) -> bool { +/// Relevance score for `query` against `search_text`: every whitespace-separated +/// token must be a substring (AND), but exact matches beat prefix matches beat +/// substring matches, so the most relevant entry floats to the top. Returns +/// `None` when any token is absent. +fn match_score(search_text: &str, query: &str) -> Option { let query = query.trim().to_lowercase(); if query.is_empty() { - return true; + return Some(0); + } + let st = search_text.to_lowercase(); + let mut total = 0; + for token in query.split_whitespace() { + if st == token { + total += 100; + } else if st.starts_with(token) { + total += 50; + } else if st.contains(token) { + total += 10; + } else { + return None; + } } - query - .split_whitespace() - .all(|token| search_text.contains(token)) + Some(total) } #[derive(Debug, Clone)] @@ -121,6 +138,9 @@ pub struct SearchSelectPanel { shimmer_value: Option, /// Animation clock in milliseconds, advanced one tick per redraw. anim_ms: u64, + /// Configured single-key actions (key, label). These keys are intercepted + /// before the search buffer so they trigger `Action(char)`, not typing. + actions: Vec<(char, String)>, } impl SearchSelectPanel { @@ -141,10 +161,18 @@ impl SearchSelectPanel { selected: 0, max_visible_items: DEFAULT_VISIBLE_ITEMS, shimmer_value: None, + actions: Vec::new(), anim_ms: 0, } } + /// Register a single-key action. The key is intercepted before the search + /// buffer and emitted as `SearchSelectOutcome::Action(char)`. + pub fn with_action(mut self, key: char, label: impl Into) -> Self { + self.actions.push((key, label.into())); + self + } + pub fn with_footer(mut self, footer: impl Into) -> Self { self.footer = Some(footer.into()); self @@ -196,13 +224,19 @@ impl SearchSelectPanel { if query.is_empty() { (0..self.items.len()).map(SelectEntry::Item).collect() } else { - self.items + let mut scored: Vec<(usize, usize)> = self + .items .iter() .enumerate() .filter_map(|(index, item)| { - matches_tokenized_query(&item.search_text, query) - .then_some(SelectEntry::Item(index)) + match_score(&item.search_text, query).map(|s| (index, s)) }) + .collect(); + // Highest score first; ties keep original order for stability. + scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + scored + .into_iter() + .map(|(i, _)| SelectEntry::Item(i)) .collect() } } @@ -431,6 +465,25 @@ impl PanelComponent for SearchSelectPanel { if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { + let lower = ch.to_ascii_lowercase(); + // Action keys only fire on an empty query; once the user is + // typing a search, letters must filter instead of triggering a + // panel action (otherwise typing "model" would hit the `m` + // manage action). + if self.query.is_empty() { + if let Some((action_key, _)) = self.actions.iter().find(|(k, _)| *k == lower) { + let value = self + .filtered_entries() + .get(self.selected) + .and_then(|e| match e { + SelectEntry::Item(i) => { + self.items.get(*i).map(|it| it.value.clone()) + } + }) + .unwrap_or_default(); + return PanelEvent::Submit(SearchSelectOutcome::Action(*action_key, value)); + } + } self.query.push(ch); self.selected = 0; self.clamp_selection();