From 63bd09a3027c698b949b123ee14b93de8bde8b1a Mon Sep 17 00:00:00 2001 From: byram Date: Sat, 20 Jun 2026 21:00:00 +0300 Subject: [PATCH 1/2] feat(agy-acp): add image support, @ resource parsing, usage quota API, and command delays --- agy-acp/Cargo.lock | 7 + agy-acp/Cargo.toml | 1 + agy-acp/LICENSE.original | 21 ++ agy-acp/src/adapter.rs | 442 ++++++++++++++++++++++++++++++++++++- agy-acp/src/db.rs | 18 ++ agy-acp/src/main.rs | 127 ++++++++++- agy-acp/src/protobuf.rs | 81 ++++++- agy-acp/src/streaming.rs | 25 ++- agy-acp/src/types.rs | 179 +++++++++++++++ agy-acp/test/acp_surucu.py | 319 ++++++++++++++++++++++++++ 10 files changed, 1192 insertions(+), 28 deletions(-) create mode 100644 agy-acp/LICENSE.original create mode 100755 agy-acp/test/acp_surucu.py diff --git a/agy-acp/Cargo.lock b/agy-acp/Cargo.lock index b30d224c6..daf7b9112 100644 --- a/agy-acp/Cargo.lock +++ b/agy-acp/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "agy-acp" version = "0.1.0" dependencies = [ + "base64", "fs2", "rusqlite", "serde", @@ -33,6 +34,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.12.1" diff --git a/agy-acp/Cargo.toml b/agy-acp/Cargo.toml index 350f81bcb..bcbc33f90 100644 --- a/agy-acp/Cargo.toml +++ b/agy-acp/Cargo.toml @@ -13,3 +13,4 @@ uuid = { version = "1", features = ["v4"] } fs2 = "0.4.3" rusqlite = { version = "=0.31.0", features = ["bundled"] } shell-words = "1.1.0" +base64 = "0.22.1" diff --git a/agy-acp/LICENSE.original b/agy-acp/LICENSE.original new file mode 100644 index 000000000..86c6e638f --- /dev/null +++ b/agy-acp/LICENSE.original @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 openabdev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agy-acp/src/adapter.rs b/agy-acp/src/adapter.rs index b3c4ecda1..0b84b77e6 100644 --- a/agy-acp/src/adapter.rs +++ b/agy-acp/src/adapter.rs @@ -65,8 +65,30 @@ impl Adapter { } /// Resolve the `agy` binary path. - pub fn agy_bin() -> &'static str { - "/usr/local/bin/agy" + /// + /// Order: `AGY_BIN` env override → first existing common install path + /// (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`) → bare `agy` + /// (PATH lookup). The previous hardcoded `/usr/local/bin/agy` broke local + /// installs where the binary lives under `~/.local/bin` — the spawn failed + /// with exit 127 and surfaced in Zed as a broken/auth error. + pub fn agy_bin() -> String { + if let Ok(custom) = std::env::var("AGY_BIN") { + if !custom.is_empty() { + return custom; + } + } + let home = std::env::var("HOME").unwrap_or_default(); + let candidates = [ + format!("{home}/.local/bin/agy"), + "/usr/local/bin/agy".to_string(), + "/opt/homebrew/bin/agy".to_string(), + ]; + for c in candidates { + if std::path::Path::new(&c).is_file() { + return c; + } + } + "agy".to_string() } /// Build PATH with common agent binary locations prepended. @@ -208,6 +230,7 @@ impl Adapter { .collect() } + #[allow(dead_code)] pub fn new_conversation_id(&self, before: &HashSet) -> Option { let after = self.conversation_snapshot(); let mut created: Vec<_> = after.difference(before).collect(); @@ -244,6 +267,402 @@ impl Adapter { true } + // --- Slash commands (ACP availableCommands) --- + + /// Commands advertised to the client via `available_commands_update`. + /// + /// Only commands the bridge can actually fulfil in non-interactive (`-p`) + /// mode are listed. agy's TUI-only commands (`/compact`, `/btw`…) + /// require a real terminal (bubbletea TTY) and cannot be bridged here. + pub fn available_commands_json() -> Value { + json!([ + { "name": "help", "description": "Köprü komutları hakkında yardım" }, + { "name": "models", "description": "Kullanılabilir modelleri listele" }, + { "name": "changelog", "description": "agy sürüm notlarını göster" }, + { "name": "plugins", "description": "Yüklü agy eklentilerini listele" }, + { "name": "usage", "description": "Modellerin kullanım istatistiklerini göster" }, + { "name": "new", "description": "Yeni konuşma başlat (bağlamı temizle)" } + ]) + } + + /// Build an `available_commands_update` session notification for a session. + pub fn available_commands_notification(session_id: &str) -> JsonRpcNotification { + JsonRpcNotification { + jsonrpc: "2.0", + method: "session/update".to_string(), + params: json!({ + "sessionId": session_id, + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": Self::available_commands_json(), + }, + }), + } + } + + /// Run an `agy` subcommand non-interactively and capture its output. + fn run_agy_subcommand(args: &[&str]) -> String { + match std::process::Command::new(Self::agy_bin()) + .args(args) + .env("PATH", Self::augmented_path()) + .output() + { + Ok(o) => { + let mut s = String::from_utf8_lossy(&o.stdout).trim_end().to_string(); + if s.is_empty() { + let err = String::from_utf8_lossy(&o.stderr); + if !err.trim().is_empty() { + s = err.trim_end().to_string(); + } + } + if s.is_empty() { + s = format!("(çıktı yok — exit: {})", o.status); + } + s + } + Err(e) => format!("Komut çalıştırılamadı: {e}"), + } + } + + fn help_text() -> String { + "Kullanılabilir komutlar (agy-acp köprüsü):\n\ + • /help — bu yardım\n\ + • /models — kullanılabilir modeller\n\ + • /changelog — agy sürüm notları\n\ + • /plugins — yüklü eklentiler\n\ + • /usage — model kullanım istatistikleri\n\ + • /new — yeni konuşma başlat (bağlamı temizle)\n\n\ + Not: agy'nin /compact, /btw gibi komutları gerçek bir terminal \ + (TTY) gerektirir ve bu köprü üzerinden kullanılamaz." + .to_string() + } + + /// Build a simple usage report from persisted session state. + pub fn usage_report(&self, on_status: Option<&dyn Fn(&str)>) -> String { + use std::process::Command; + + if let Some(cb) = on_status { cb("Lokal agy portları taranıyor..."); } + + // Find active agy ports using lsof + let lsof_cmd = "lsof -i -P -n 2>/dev/null | grep \"agy.*LISTEN\" | awk '{print $9}' | grep -oE '[0-9]+$' | sort -u"; + let output = Command::new("sh") + .arg("-c") + .arg(lsof_cmd) + .output(); + + let mut ports = Vec::new(); + if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + for line in stdout.lines() { + if let Ok(port) = line.trim().parse::() { + ports.push(port); + } + } + } + + // Add some default fallback ports if lsof fails or returns empty + if ports.is_empty() { + ports = vec![54644, 54724, 54808, 54883, 54933]; + } + + if let Some(cb) = on_status { cb("Modellerin kota bilgileri canlı API üzerinden alınıyor..."); } + + let mut quota_json = None; + for &port in &ports { + let url = format!("http://127.0.0.1:{}/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", port); + let out = Command::new("curl") + .arg("-s") + .arg("--connect-timeout") + .arg("1") + .arg("--max-time") + .arg("2") + .arg("-X") + .arg("POST") + .arg(&url) + .arg("-H") + .arg("Content-Type: application/json") + .arg("-d") + .arg("{}") + .output(); + + if let Ok(res) = out { + if res.status.success() { + if let Ok(json) = serde_json::from_slice::(&res.stdout) { + if json.pointer("/response/groups").is_some() { + quota_json = Some(json); + break; + } + } + } + } + } + + if let Some(json) = quota_json { + if let Some(cb) = on_status { cb("Kota bilgileri çözümleniyor..."); } + + let mut report = String::from("Modellerin Kullanım Bilgileri (agy - Canlı API):\n"); + if let Some(groups) = json.pointer("/response/groups").and_then(|g| g.as_array()) { + for group in groups { + let group_name = group.get("displayName").and_then(|v| v.as_str()).unwrap_or("Bilinmeyen Grup"); + report.push_str(&format!("\n{}:\n", group_name.to_uppercase())); + + if let Some(buckets) = group.get("buckets").and_then(|b| b.as_array()) { + for bucket in buckets { + let bucket_name = bucket.get("displayName").and_then(|v| v.as_str()).unwrap_or("Limit"); + let desc = bucket.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let remaining = bucket.get("remainingFraction").and_then(|v| v.as_f64()).unwrap_or(0.0); + + // remainingFraction is the fraction remaining (e.g. 0.94 means 94.48% remaining) + let percentage = remaining * 100.0; + + report.push_str(&format!("• {}: %{:.2} kalan\n", bucket_name, percentage)); + if !desc.is_empty() { + report.push_str(&format!(" Açıklama: {}\n", desc)); + } + } + } + } + return report; + } + } + + // Fallback to the old screen-based scraper method if local API fails + if let Some(cb) = on_status { cb("Canlı API başarısız oldu, eski screen-based yöntemi deneniyor..."); } + self.fallback_screen_usage_report(on_status) + } + + /// Fallback method to build a usage report using screen-based scraping if the API fails. + pub fn fallback_screen_usage_report(&self, on_status: Option<&dyn Fn(&str)>) -> String { + use std::process::Command; + use std::fs; + + let sess_id = format!("agy_usage_{}", uuid::Uuid::new_v4().to_string().chars().take(8).collect::()); + let screenrc_file = format!("/tmp/screenrc_{}", sess_id); + let out_file_1 = format!("/tmp/page1_{}.txt", sess_id); + let out_file_2 = format!("/tmp/page2_{}.txt", sess_id); + + // Write temporary screenrc file to enforce a terminal size (necessary for TUI rendering) + let screenrc_content = "width 80\nheight 40\n"; + if fs::write(&screenrc_file, screenrc_content).is_err() { + return String::from("Hata: geçici screenrc dosyası oluşturulamadı."); + } + + let agy_bin = Self::agy_bin(); + let path = Self::augmented_path(); + + let run_sh = |cmd: &str| -> bool { + Command::new("sh") + .arg("-c") + .arg(cmd) + .env("PATH", &path) + .status() + .map(|s| s.success()) + .unwrap_or(false) + }; + + if let Some(cb) = on_status { cb("Kota oturumu arka planda başlatılıyor..."); } + let init_cmd = format!("screen -c {} -d -m -S {} {}", screenrc_file, sess_id, agy_bin); + if !run_sh(&init_cmd) { + let _ = fs::remove_file(&screenrc_file); + return String::from("Hata: screen oturumu başlatılamadı."); + } + + std::thread::sleep(std::time::Duration::from_secs(6)); + + if let Some(cb) = on_status { cb("Kota ekranına geçmek için /usage komutu gönderiliyor..."); } + let usage_cmd = format!("screen -S {} -p 0 -X stuff \"/usage\"$'\\r'", sess_id); + let _ = run_sh(&usage_cmd); + + std::thread::sleep(std::time::Duration::from_secs(4)); + + if let Some(cb) = on_status { cb("Gemini kota bilgileri alınıyor (Sayfa 1)..."); } + let hardcopy1_cmd = format!("screen -S {} -p 0 -X hardcopy {}", sess_id, out_file_1); + let _ = run_sh(&hardcopy1_cmd); + + let pgdown_cmd = format!("screen -S {} -p 0 -X stuff $'\\033[6~'", sess_id); + let _ = run_sh(&pgdown_cmd); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + if let Some(cb) = on_status { cb("Claude/GPT kota bilgileri alınıyor (Sayfa 2)..."); } + let hardcopy2_cmd = format!("screen -S {} -p 0 -X hardcopy {}", sess_id, out_file_2); + let _ = run_sh(&hardcopy2_cmd); + + let quit_cmd = format!("screen -S {} -p 0 -X quit", sess_id); + let _ = run_sh(&quit_cmd); + + if let Some(cb) = on_status { cb("Kota bilgileri çözümleniyor (parse ediliyor)..."); } + + // Helper function to clean screen bytes + let clean_screen_output = |bytes: &[u8]| -> String { + let mut result = String::new(); + for &b in bytes { + match b { + 0x0 => {} // skip null bytes + 0x88 => result.push_str("█"), // filled block + 0x91 => result.push_str("░"), // empty block + 0xb7 => result.push_str("·"), // bullet dot + _ => { + if b.is_ascii() { + result.push(b as char); + } else { + result.push_str(&String::from_utf8_lossy(&[b])); + } + } + } + } + result + }; + + // Read files + let page1_bytes = fs::read(&out_file_1).unwrap_or_default(); + let page2_bytes = fs::read(&out_file_2).unwrap_or_default(); + + let page1_str = clean_screen_output(&page1_bytes); + let page2_str = clean_screen_output(&page2_bytes); + + // Cleanup temp files + let _ = fs::remove_file(&screenrc_file); + let _ = fs::remove_file(&out_file_1); + let _ = fs::remove_file(&out_file_2); + + // Helper functions for parsing + let extract_percentage = |line: &str| -> Option { + let bytes = line.as_bytes(); + if let Some(pos) = line.find('%') { + let mut start = pos; + while start > 0 { + let prev = bytes[start - 1]; + if prev.is_ascii_digit() || prev == b'.' { + start -= 1; + } else { + break; + } + } + if start < pos { + return Some(String::from_utf8_lossy(&bytes[start..=pos]).to_string()); + } + } + None + }; + + let extract_refresh_time = |line: &str| -> Option { + if let Some(pos) = line.find("Refreshes in ") { + return Some(line[pos + "Refreshes in ".len()..].trim().to_string()); + } + if line.contains("Quota available") { + return Some("Mevcut".to_string()); + } + None + }; + + let parse_limits = |content: &str| -> Option<(String, String, String, String)> { + let mut weekly_pct = None; + let mut weekly_ref = None; + let mut five_hour_pct = None; + let mut five_hour_ref = None; + + let lines: Vec<&str> = content.lines().map(|l| l.trim()).collect(); + + for i in 0..lines.len() { + if lines[i] == "Weekly Limit" { + for j in (i + 1)..std::cmp::min(i + 4, lines.len()) { + if weekly_pct.is_none() { + if let Some(pct) = extract_percentage(lines[j]) { + weekly_pct = Some(pct); + } + } + if weekly_ref.is_none() { + if let Some(ref_time) = extract_refresh_time(lines[j]) { + weekly_ref = Some(ref_time); + } + } + } + } + if lines[i] == "Five Hour Limit" { + for j in (i + 1)..std::cmp::min(i + 4, lines.len()) { + if five_hour_pct.is_none() { + if let Some(pct) = extract_percentage(lines[j]) { + five_hour_pct = Some(pct); + } + } + if five_hour_ref.is_none() { + if let Some(ref_time) = extract_refresh_time(lines[j]) { + five_hour_ref = Some(ref_time); + } + } + } + } + } + + if let (Some(w_pct), Some(w_ref), Some(f_pct), Some(f_ref)) = (weekly_pct, weekly_ref, five_hour_pct, five_hour_ref) { + Some((w_pct, w_ref, f_pct, f_ref)) + } else { + None + } + }; + + // Try to parse both pages + let gemini_parsed = parse_limits(&page1_str); + let claude_parsed = parse_limits(&page2_str); + + match (gemini_parsed, claude_parsed) { + (Some(g), Some(c)) => { + format!( + "Modellerin Kullanım Bilgileri (agy):\n\n\ + GEMINI MODELLERİ:\n\ + • Haftalık Limit: %{} (Yenilenme süresi: {})\n\ + • 5 Saatlik Limit: %{} (Yenilenme süresi: {})\n\n\ + CLAUDE VE GPT MODELLERİ:\n\ + • Haftalık Limit: %{} (Yenilenme süresi: {})\n\ + • 5 Saatlik Limit: %{} (Yenilenme süresi: {})", + g.0.replace("%", ""), g.1, g.2.replace("%", ""), g.3, + c.0.replace("%", ""), c.1, c.2.replace("%", ""), c.3 + ) + } + _ => { + if page1_str.trim().is_empty() && page2_str.trim().is_empty() { + String::from("Hata: Kota bilgisi boş döndü. agy henüz giriş yapmamış olabilir veya API yanıt vermedi.") + } else { + format!( + "Kota bilgileri parse edilemedi.\n\n\ + --- SAYFA 1 HAM ÇIKTI ---\n{}\n\n\ + --- SAYFA 2 HAM ÇIKTI ---\n{}", + page1_str, page2_str + ) + } + } + } + } + + /// If `prompt_text` is a recognised slash command, execute it and return + /// its output. Returns `None` for normal prompts (forwarded to agy as usual). + pub fn try_handle_command(&mut self, session_id: &str, prompt_text: &str, on_status: Option<&dyn Fn(&str)>) -> Option { + let rest = prompt_text.trim().strip_prefix('/')?; + let mut parts = rest.splitn(2, char::is_whitespace); + let cmd = parts.next().unwrap_or("").to_lowercase(); + match cmd.as_str() { + "help" => Some(Self::help_text()), + "models" => Some(Self::run_agy_subcommand(&["models"])), + "changelog" => Some(Self::run_agy_subcommand(&["changelog"])), + "plugins" | "plugin" => Some(Self::run_agy_subcommand(&["plugin", "list"])), + "usage" => Some(self.usage_report(on_status)), + "new" | "clear" => { + if !self.sessions.contains_key(session_id) { + let _ = self.restore_session_state(session_id); + } + if let Some(s) = self.sessions.get_mut(session_id) { + s.conversation_id = None; + s.last_step_idx = -1; + } + let model_id = self.sessions.get(session_id).and_then(|s| s.model_id.clone()); + self.persist_session(session_id, None, -1, model_id.as_deref()); + Some("✓ Yeni konuşma başlatıldı. Önceki bağlam temizlendi.".to_string()) + } + _ => None, + } + } + // --- JSON-RPC handlers --- pub fn handle_initialize(&self, id: Value) -> JsonRpcResponse { @@ -253,7 +672,14 @@ impl Adapter { result: Some(json!({ "protocolVersion": 1, "agentInfo": { "name": "agy", "version": env!("CARGO_PKG_VERSION") }, - "agentCapabilities": { "streaming": true, "loadSession": true }, + "agentCapabilities": { + "streaming": true, + "loadSession": true, + "promptCapabilities": { + "image": true, + "embeddedContext": true + } + }, })), error: None, } @@ -318,18 +744,14 @@ impl Adapter { pub fn prepare_prompt_state( &mut self, params: &Value, - ) -> (String, String, Vec, Option>, Option, i64) { + ) -> (String, String, Vec, Option>, Option, i64, Vec) { let session_id = params.get("sessionId").and_then(|v| v.as_str()).unwrap_or("").to_string(); if !session_id.is_empty() && !self.sessions.contains_key(&session_id) { let _ = self.restore_session_state(&session_id); } - let prompt_text = params - .get("prompt") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|b| b.get("text").and_then(|t| t.as_str())).collect::>().join("\n")) - .unwrap_or_default(); + let (prompt_text, temp_files) = parse_prompt_and_extract_media(params.get("prompt")); let clean_prompt = prompt_text.trim().to_string(); let snapshot = if self.sessions.get(&session_id).map(|s| s.conversation_id.is_none()).unwrap_or(false) { @@ -364,6 +786,6 @@ impl Adapter { let initial_conv_id = self.sessions.get(&session_id).and_then(|s| s.conversation_id.clone()); let initial_step_idx = self.sessions.get(&session_id).map(|s| s.last_step_idx).unwrap_or(-1); - (session_id, clean_prompt, args, snapshot, initial_conv_id, initial_step_idx) + (session_id, clean_prompt, args, snapshot, initial_conv_id, initial_step_idx, temp_files) } } diff --git a/agy-acp/src/db.rs b/agy-acp/src/db.rs index 3711cdfcd..ad575b9bb 100644 --- a/agy-acp/src/db.rs +++ b/agy-acp/src/db.rs @@ -14,6 +14,22 @@ pub fn show_narration() -> bool { false } +/// Check if tool call output/result should be attached to tool_call updates. +/// +/// Enabled by `AGY_SHOW_TOOL_OUTPUT=1` (or `true`), or by the shared +/// `OPENAB_TOOL_DISPLAY=full` switch. When off, only the tool title + rawInput +/// are sent (current default behaviour). +pub fn show_tool_output() -> bool { + if let Ok(v) = std::env::var("AGY_SHOW_TOOL_OUTPUT") { + return v == "1" || v.eq_ignore_ascii_case("true"); + } + if let Ok(v) = std::env::var("OPENAB_TOOL_DISPLAY") { + return v.eq_ignore_ascii_case("full"); + } + false +} + + /// A part is considered narration if every non-empty line starts with "I will". pub fn is_narration(text: &str) -> bool { let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); @@ -24,6 +40,7 @@ pub fn is_narration(text: &str) -> bool { } /// Filter out leading narration from response parts. +#[allow(dead_code)] pub fn filter_narration(parts: &[String]) -> String { if show_narration() || parts.len() <= 1 { return parts.join("\n"); @@ -34,6 +51,7 @@ pub fn filter_narration(parts: &[String]) -> String { /// Read the latest response from the SQLite conversation DB. /// Returns (response_text, max_step_idx) or None if reading fails. +#[allow(dead_code)] pub fn read_response_from_db(conversations_dir: &PathBuf, conversation_id: &str, after_step_idx: i64) -> Option<(String, i64)> { let db_path = conversations_dir.join(format!("{}.db", conversation_id)); let conn = Connection::open_with_flags( diff --git a/agy-acp/src/main.rs b/agy-acp/src/main.rs index f06c5913b..e17432e54 100644 --- a/agy-acp/src/main.rs +++ b/agy-acp/src/main.rs @@ -28,9 +28,20 @@ impl Adapter { initial_step_idx: i64, working_dir: String, conversations_dir: PathBuf, + temp_files: Vec, cancelled: Arc, out_tx: mpsc::UnboundedSender>, ) -> PromptOutput { + struct TempFilesCleanupGuard(Vec); + impl Drop for TempFilesCleanupGuard { + fn drop(&mut self) { + for path in &self.0 { + let _ = std::fs::remove_file(path); + } + } + } + let _cleanup_guard = TempFilesCleanupGuard(temp_files); + let spawn_result = Command::new(Adapter::agy_bin()) .args(&args) .env("PATH", Adapter::augmented_path()) @@ -284,7 +295,14 @@ async fn main() { pending_prompts += 1; tokio::spawn(async move { let mut adapter = adapter.lock().await; - let _ = out_tx.send(Some(serde_json::to_string(&adapter.handle_session_new(id)).unwrap())); + let resp = adapter.handle_session_new(id); + let sid = resp.result.as_ref() + .and_then(|r| r.get("sessionId")).and_then(|v| v.as_str()).map(String::from); + let _ = out_tx.send(Some(serde_json::to_string(&resp).unwrap())); + if let Some(sid) = sid { + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + let _ = out_tx.send(Some(serde_json::to_string(&Adapter::available_commands_notification(&sid)).unwrap())); + } let _ = out_tx.send(None); }); Vec::new() @@ -295,7 +313,15 @@ async fn main() { pending_prompts += 1; tokio::spawn(async move { let mut adapter = adapter.lock().await; - let _ = out_tx.send(Some(serde_json::to_string(&adapter.handle_session_load(id, ¶ms)).unwrap())); + let resp = adapter.handle_session_load(id, ¶ms); + let sid = if resp.error.is_none() { + params.get("sessionId").and_then(|v| v.as_str()).map(String::from) + } else { None }; + let _ = out_tx.send(Some(serde_json::to_string(&resp).unwrap())); + if let Some(sid) = sid { + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + let _ = out_tx.send(Some(serde_json::to_string(&Adapter::available_commands_notification(&sid)).unwrap())); + } let _ = out_tx.send(None); }); Vec::new() @@ -312,15 +338,54 @@ async fn main() { let out_tx = out_tx.clone(); pending_prompts += 1; tokio::spawn(async move { - let (sid, args, snapshot, init_conv, init_idx, wd, cd) = { + // Slash command interception — fulfilled by the bridge, no agy spawn. + let prompt_text = parse_prompt(params.get("prompt")); + let out_tx_clone = out_tx.clone(); + let session_id_clone = session_id.clone(); + let on_status = move |status_msg: &str| { + let notif = JsonRpcNotification { + jsonrpc: "2.0", + method: "session/update".to_string(), + params: json!({ + "sessionId": session_id_clone, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": format!("⏳ *{}*\n\n", status_msg) }, + } + }), + }; + let _ = out_tx_clone.send(Some(serde_json::to_string(¬if).unwrap())); + }; + + let cmd_output = { let mut adapter = adapter.lock().await; - let (sid, _prompt, args, snapshot, init_conv, init_idx) = adapter.prepare_prompt_state(¶ms); + adapter.try_handle_command(&session_id, &prompt_text, Some(&on_status)) + }; + if let Some(text) = cmd_output { + let notif = JsonRpcNotification { + jsonrpc: "2.0", method: "session/update".to_string(), + params: json!({ "sessionId": session_id, "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": text }, + }}), + }; + let _ = out_tx.send(Some(serde_json::to_string(¬if).unwrap())); + let _ = out_tx.send(Some(serde_json::to_string(&JsonRpcResponse { + jsonrpc: "2.0", id, result: Some(json!({ "stopReason": "end_turn" })), error: None, + }).unwrap())); + if !session_id.is_empty() { active_cancellations.lock().unwrap().remove(&session_id); } + let _ = out_tx.send(None); + return; + } + let (sid, args, snapshot, init_conv, init_idx, wd, cd, temp_files) = { + let mut adapter = adapter.lock().await; + let (sid, _prompt, args, snapshot, init_conv, init_idx, temp_files) = adapter.prepare_prompt_state(¶ms); let wd = adapter.working_dir.clone(); let cd = adapter.conversations_dir.clone(); - (sid, args, snapshot, init_conv, init_idx, wd, cd) + (sid, args, snapshot, init_conv, init_idx, wd, cd, temp_files) }; let output = Adapter::execute_prompt( - id, &sid, args, snapshot, init_conv, init_idx, wd, cd, cancelled, out_tx.clone(), + id, &sid, args, snapshot, init_conv, init_idx, wd, cd, temp_files, cancelled, out_tx.clone(), ).await; if let Some((bound_conv_id, new_step_idx)) = output.session_update { let mut adapter = adapter.lock().await; @@ -415,8 +480,13 @@ mod tests { available_models: Some(vec![]), }; let response = adapter.handle_initialize(json!(1)); - assert_eq!(response.result.as_ref().and_then(|r| r.get("agentCapabilities")) - .and_then(|c| c.get("loadSession")).and_then(|v| v.as_bool()), Some(true)); + let capabilities = response.result.as_ref().and_then(|r| r.get("agentCapabilities")).unwrap(); + assert_eq!(capabilities.get("loadSession").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(capabilities.get("streaming").and_then(|v| v.as_bool()), Some(true)); + + let prompt_caps = capabilities.get("promptCapabilities").unwrap(); + assert_eq!(prompt_caps.get("image").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(prompt_caps.get("embeddedContext").and_then(|v| v.as_bool()), Some(true)); } #[test] @@ -633,4 +703,45 @@ mod tests { drop(stdin); let _ = child.wait(); assert!(got_notif); assert!(text.to_lowercase().contains("pong")); } + + #[test] + fn test_base64_decode_clean_header() { + let input = "data:image/png;base64,SGVsbG8gd29ybGQ="; // "Hello world" base64 + let decoded = base64_decode(input).expect("Should decode successfully"); + assert_eq!(decoded, b"Hello world"); + } + + #[test] + fn test_base64_decode_whitespace() { + let input = "SGVsbG8\ngd29y\r\nbGQ="; // "Hello world" base64 with newlines + let decoded = base64_decode(input).expect("Should decode successfully"); + assert_eq!(decoded, b"Hello world"); + } + + #[test] + fn test_parse_prompt_and_extract_media() { + let prompt_val = serde_json::json!([ + { + "type": "text", + "text": "Check this image: " + }, + { + "type": "image", + "data": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 png + "mimeType": "image/png" + } + ]); + + let (clean_prompt, temp_files) = parse_prompt_and_extract_media(Some(&prompt_val)); + + assert!(clean_prompt.contains("Check this image:")); + assert!(clean_prompt.contains("![temp_media_")); + assert_eq!(temp_files.len(), 1); + + let file_path = std::path::Path::new(&temp_files[0]); + assert!(file_path.exists()); + + // Cleanup + let _ = std::fs::remove_file(file_path); + } } diff --git a/agy-acp/src/protobuf.rs b/agy-acp/src/protobuf.rs index 8a9226855..7eb7d0abb 100644 --- a/agy-acp/src/protobuf.rs +++ b/agy-acp/src/protobuf.rs @@ -75,15 +75,92 @@ pub fn extract_tool_from_step_payload(blob: &[u8]) -> Option<(String, Option Option { + let mut parts: Vec = Vec::new(); + collect_output_strings(blob, true, &mut parts); + if parts.is_empty() { + return None; + } + let mut text = parts.join("\n"); + const MAX: usize = 6000; + if text.len() > MAX { + let mut end = MAX; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + text.truncate(end); + text.push_str("\n… (kesildi)"); + } + Some(text) +} + +/// Recursively collect readable UTF-8 string leaves from a protobuf blob. +/// At the top level, the tool-call wrapper (field 5) is skipped — that is the +/// input side, surfaced separately via `rawInput`. +fn collect_output_strings(blob: &[u8], top_level: bool, out: &mut Vec) { + let mut i = 0; + while i < blob.len() { + let Some((tag, c)) = read_varint(&blob[i..]) else { break; }; + i += c; + let field_number = tag >> 3; + let wire_type = tag & 0x7; + match wire_type { + 0 => { + let Some((_, c)) = read_varint(&blob[i..]) else { break; }; + i += c; + } + 2 => { + let Some((len, c)) = read_varint(&blob[i..]) else { break; }; + i += c; + let len = len as usize; + if i + len > blob.len() { + break; + } + let chunk = &blob[i..i + len]; + i += len; + if top_level && field_number == 5 { + continue; + } + if let Ok(s) = std::str::from_utf8(chunk) { + let total = s.chars().count(); + let printable = s + .chars() + .filter(|ch| ch.is_alphanumeric() || ch.is_ascii_graphic() || matches!(ch, ' ' | '\n' | '\t' | '\r')) + .count(); + if total > 0 && printable as f64 >= total as f64 * 0.85 { + let t = s.trim(); + if !t.is_empty() { + out.push(t.to_string()); + } + continue; + } + } + collect_output_strings(chunk, false, out); + } + 5 => i += 4, + 1 => i += 8, + _ => break, + } + } +} + /// Derive a short title for a tool call based on name and input. pub fn tool_call_title(name: &str, input: &Option) -> String { if let Some(input) = input { - for key in ["path", "file", "AbsolutePath", "FilePath"] { + for key in ["path", "file", "AbsolutePath", "FilePath", "TargetFile"] { if let Some(path) = input.get(key).and_then(|v| v.as_str()) { return format!("{}: {}", name, path); } } - for key in ["query", "command", "text"] { + for key in ["query", "command", "text", "CommandLine", "Target"] { if let Some(val) = input.get(key).and_then(|v| v.as_str()) { let truncated: String = val.chars().take(60).collect(); return format!("{}: {}", name, truncated); diff --git a/agy-acp/src/streaming.rs b/agy-acp/src/streaming.rs index daf65d6b0..97344dd07 100644 --- a/agy-acp/src/streaming.rs +++ b/agy-acp/src/streaming.rs @@ -5,8 +5,8 @@ use std::fs; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use crate::db::{is_narration, show_narration}; -use crate::protobuf::{extract_text_from_step_payload, extract_tool_from_step_payload, is_tool_step_type, tool_call_title}; +use crate::db::{is_narration, show_narration, show_tool_output}; +use crate::protobuf::{extract_text_from_step_payload, extract_tool_from_step_payload, extract_tool_output_text, is_tool_step_type, tool_call_title}; use crate::types::{JsonRpcNotification, StreamingState}; /// Poll the SQLite DB for new text since `base_step_idx` and emit streaming deltas. @@ -143,18 +143,27 @@ pub fn poll_streaming_delta( .unwrap(), ); + let mut completed = json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": tool_call_id, + "title": title, + "status": "completed", + }); + if show_tool_output() { + if let Some(output) = extract_tool_output_text(&payload) { + completed["content"] = json!([ + { "type": "content", "content": { "type": "text", "text": output } } + ]); + completed["rawOutput"] = json!({ "text": output }); + } + } notifications.push( serde_json::to_string(&JsonRpcNotification { jsonrpc: "2.0", method: "session/update".to_string(), params: json!({ "sessionId": session_id, - "update": { - "sessionUpdate": "tool_call_update", - "toolCallId": tool_call_id, - "title": title, - "status": "completed", - }, + "update": completed, }), }) .unwrap(), diff --git a/agy-acp/src/types.rs b/agy-acp/src/types.rs index c3e9ce651..e4dc10605 100644 --- a/agy-acp/src/types.rs +++ b/agy-acp/src/types.rs @@ -3,6 +3,7 @@ use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::atomic::AtomicBool; use std::sync::Arc; +use base64::{Engine as _, engine::general_purpose}; #[derive(Debug, Deserialize)] pub struct JsonRpcRequest { @@ -72,3 +73,181 @@ impl Drop for StopGuard { self.0.store(true, std::sync::atomic::Ordering::SeqCst); } } + +pub fn parse_prompt(prompt_val: Option<&Value>) -> String { + let Some(val) = prompt_val else { + return String::new(); + }; + if let Some(s) = val.as_str() { + return s.to_string(); + } + if let Some(arr) = val.as_array() { + return parse_prompt_blocks(arr); + } + String::new() +} + +pub fn parse_prompt_blocks(arr: &[Value]) -> String { + let mut prompt_text = String::new(); + for block in arr { + if let Some(block_type) = block.get("type").and_then(|t| t.as_str()) { + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + prompt_text.push_str(text); + } + } + "file" | "resource_link" => { + if let Some(uri) = block.get("uri").and_then(|u| u.as_str()) { + let mut prefix = "@"; + if prompt_text.ends_with('@') { + prefix = ""; + } + if let Some(path) = uri.strip_prefix("file://") { + prompt_text.push_str(&format!("{}{}", prefix, path)); + } else { + prompt_text.push_str(&format!("{}{}", prefix, uri)); + } + } + } + "resource" => { + if let Some(content) = block.get("content").and_then(|c| c.get("text")).and_then(|t| t.as_str()) { + prompt_text.push_str(content); + } else if let Some(uri) = block.get("uri").and_then(|u| u.as_str()) { + let mut prefix = "@"; + if prompt_text.ends_with('@') { + prefix = ""; + } + if let Some(path) = uri.strip_prefix("file://") { + prompt_text.push_str(&format!("{}{}", prefix, path)); + } else { + prompt_text.push_str(&format!("{}{}", prefix, uri)); + } + } + } + _ => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + prompt_text.push_str(text); + } + } + } + } else { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + prompt_text.push_str(text); + } + } + } + prompt_text +} + +pub fn parse_prompt_and_extract_media(prompt_val: Option<&Value>) -> (String, Vec) { + let mut temp_files = Vec::new(); + let Some(val) = prompt_val else { + return (String::new(), temp_files); + }; + if let Some(s) = val.as_str() { + return (s.to_string(), temp_files); + } + let Some(arr) = val.as_array() else { + return (String::new(), temp_files); + }; + + let mut prompt_text = String::new(); + let temp_dir_path = std::path::PathBuf::from("/tmp/agy-media"); + + for block in arr { + if let Some(block_type) = block.get("type").and_then(|t| t.as_str()) { + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + prompt_text.push_str(text); + } + } + "file" | "resource_link" => { + if let Some(uri) = block.get("uri").and_then(|u| u.as_str()) { + let mut prefix = "@"; + if prompt_text.ends_with('@') { + prefix = ""; + } + if let Some(path) = uri.strip_prefix("file://") { + prompt_text.push_str(&format!("{}{}", prefix, path)); + } else { + prompt_text.push_str(&format!("{}{}", prefix, uri)); + } + } + } + "resource" => { + if let Some(content) = block.get("content").and_then(|c| c.get("text")).and_then(|t| t.as_str()) { + prompt_text.push_str(content); + } else if let Some(uri) = block.get("uri").and_then(|u| u.as_str()) { + let mut prefix = "@"; + if prompt_text.ends_with('@') { + prefix = ""; + } + if let Some(path) = uri.strip_prefix("file://") { + prompt_text.push_str(&format!("{}{}", prefix, path)); + } else { + prompt_text.push_str(&format!("{}{}", prefix, uri)); + } + } + } + "image" => { + if let Some(base64_data) = block.get("data").and_then(|d| d.as_str()) { + let mime_type = block.get("mimeType").and_then(|m| m.as_str()).unwrap_or("image/png"); + let ext = match mime_type { + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "png", + }; + + if let Some(bytes) = base64_decode(base64_data) { + let _ = std::fs::create_dir_all(&temp_dir_path); + let rand_suffix: String = uuid::Uuid::new_v4().to_string().chars().take(8).collect(); + let temp_file_name = format!("temp_media_{}.{}", rand_suffix, ext); + let temp_file_path = temp_dir_path.join(&temp_file_name); + if std::fs::write(&temp_file_path, &bytes).is_ok() { + let path_str = temp_file_path.to_string_lossy().to_string(); + prompt_text.push_str(&format!(" ![{}]({}) ", temp_file_name, path_str)); + temp_files.push(path_str); + } + } + } + } + _ => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + prompt_text.push_str(text); + } + } + } + } else { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + prompt_text.push_str(text); + } + } + } + (prompt_text, temp_files) +} + +pub fn base64_decode(input: &str) -> Option> { + // data:image/png;base64,... gibi bir ön ek varsa temizle + let clean_input = if let Some(comma_idx) = input.find(',') { + &input[comma_idx + 1..] + } else { + input + }; + + // Boşlukları, yeni satırları ve '=' karakterlerini temizle (padding'i kendimiz ekleyeceğiz) + let mut clean_input_str: String = clean_input + .chars() + .filter(|c| !c.is_whitespace() && c != &'=') + .collect(); + + // 4'ün katı olana kadar '=' ekle + while clean_input_str.len() % 4 != 0 { + clean_input_str.push('='); + } + + general_purpose::STANDARD.decode(clean_input_str).ok() +} + diff --git a/agy-acp/test/acp_surucu.py b/agy-acp/test/acp_surucu.py new file mode 100755 index 000000000..037410aae --- /dev/null +++ b/agy-acp/test/acp_surucu.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +""" +acp_surucu.py — agy-acp için headless stdio sürücüsü (Zed'siz test). + +Taze derlenmiş `target/release/agy-acp`'i subprocess başlatır ve ACP 1.0 +JSON-RPC akışını (newline-delimited JSON) stdio üzerinden sürer: + initialize → session/new (cwd) → session/prompt (content blocks) +Agent'tan gelen session/request_permission isteklerini otomatik onaylar, +session/update bildirimlerini (mesaj/düşünce/araç) canlı yazar. + +Kullanım: + python3 test/acp_surucu.py "dizini listele ve README oku" + python3 test/acp_surucu.py --cancel-after 8 "sleep 120" + python3 test/acp_surucu.py --reddet "..." # izin isteklerini REDDET + python3 test/acp_surucu.py --timeout 180 "..." + +Çıkış kodu: prompt stopReason 'end_turn'/'completed' ise 0, hata/timeout ise 1. +""" +import argparse +import base64 +import json +import os +import subprocess +import sys +import threading +import time +from pathlib import Path + +PROJE_KOK = Path(__file__).resolve().parent.parent +BINARY = PROJE_KOK / "target" / "release" / "agy-acp" + +# ── Renkler (tty ise) ────────────────────────────────────────────────────── +_T = sys.stdout.isatty() +def _c(s, code): return f"\033[{code}m{s}\033[0m" if _T else s +def gri(s): return _c(s, "90") +def mavi(s): return _c(s, "34") +def yes(s): return _c(s, "32") +def sari(s): return _c(s, "33") +def kir(s): return _c(s, "31") + + +class AcpSurucu: + def __init__(self, proc, reddet=False, sessiz_dusunce=False): + self.proc = proc + self.reddet = reddet + self.sessiz_dusunce = sessiz_dusunce + self._yaz_kilit = threading.Lock() + self._sonraki_id = 0 + self._bekleyen = {} # id -> threading.Event + self._yanitlar = {} # id -> (result|error) + self._kapali = False + self.son_stop_reason = None + self._okuyucu = threading.Thread(target=self._oku_dongu, daemon=True) + self._okuyucu.start() + + # ── Düşük seviye G/Ç ──────────────────────────────────────────────── + def _gonder(self, obj): + satir = json.dumps(obj, ensure_ascii=False) + with self._yaz_kilit: + self.proc.stdin.write(satir + "\n") + self.proc.stdin.flush() + + def _istek(self, method, params, zaman_asimi=300): + self._sonraki_id += 1 + rid = self._sonraki_id + ev = threading.Event() + self._bekleyen[rid] = ev + self._gonder({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + if not ev.wait(zaman_asimi): + raise TimeoutError(f"{method} yanıtı {zaman_asimi}s içinde gelmedi") + sonuc = self._yanitlar.pop(rid, None) + if sonuc and "error" in sonuc: + raise RuntimeError(f"{method} hatası: {sonuc['error']}") + return sonuc.get("result") if sonuc else None + + def _bildir(self, method, params): + self._gonder({"jsonrpc": "2.0", "method": method, "params": params}) + + def _yanitla(self, rid, result=None, error=None): + msg = {"jsonrpc": "2.0", "id": rid} + if error is not None: + msg["error"] = error + else: + msg["result"] = result if result is not None else {} + self._gonder(msg) + + # ── Okuyucu döngüsü ──────────────────────────────────────────────── + def _oku_dongu(self): + for ham in self.proc.stdout: + ham = ham.strip() + if not ham: + continue + try: + msg = json.loads(ham) + except json.JSONDecodeError: + print(gri(f" [parse edilemeyen satır] {ham[:200]}")) + continue + if "method" in msg and "id" in msg: + self._gelen_istek(msg) # agent→client isteği + elif "method" in msg: + self._gelen_bildirim(msg) # agent→client notification + elif "id" in msg: + rid = msg["id"] + self._yanitlar[rid] = msg # bizim isteğimize yanıt + ev = self._bekleyen.pop(rid, None) + if ev: + ev.set() + self._kapali = True + for ev in self._bekleyen.values(): + ev.set() + + def _gelen_istek(self, msg): + method = msg["method"] + rid = msg["id"] + if method == "session/request_permission": + self._izin_yanitla(msg) + elif method in ("fs/read_text_file", "fs/write_text_file"): + self._yanitla(rid, result={}) + else: + self._yanitla(rid, error={"code": -32601, "message": f"Desteklenmeyen: {method}"}) + + def _izin_yanitla(self, msg): + params = msg.get("params", {}) + secenekler = params.get("options", []) + tc = params.get("toolCall", {}) + ad = tc.get("title") or tc.get("toolName") or "?" + if self.reddet: + tercih = next((o for o in secenekler if "reject" in o.get("kind", "")), None) + etiket = kir("REDDET") + else: + tercih = next((o for o in secenekler if "allow" in o.get("kind", "")), None) + etiket = yes("ONAY") + if tercih is None and secenekler: + tercih = secenekler[0] + if tercih is None: + self._yanitla(msg["id"], result={"outcome": {"outcome": "cancelled"}}) + return + print(gri(f" 🔐 izin [{etiket}{gri(']')} {ad} → {tercih.get('name')}")) + self._yanitla(msg["id"], result={ + "outcome": {"outcome": "selected", "optionId": tercih["optionId"]} + }) + + def _gelen_bildirim(self, msg): + if msg["method"] != "session/update": + return + up = msg.get("params", {}).get("update", {}) + tip = up.get("sessionUpdate") + if tip == "agent_message_chunk": + metin = (up.get("content") or {}).get("text", "") + if metin: + sys.stdout.write(metin) + sys.stdout.flush() + elif tip == "agent_thought_chunk": + if not self.sessiz_dusunce: + metin = (up.get("content") or {}).get("text", "") + if metin: + sys.stdout.write(gri(metin)) + sys.stdout.flush() + elif tip == "tool_call": + ad = up.get("title") or up.get("toolName") or up.get("toolCallId", "?") + print(mavi(f"\n 🔧 araç: {ad} [{up.get('status', '?')}]")) + self._arac_girdi(up) + elif tip == "tool_call_update": + durum = up.get("status") + if durum: + print(gri(f" ↳ {up.get('toolCallId', '')} [{durum}]")) + self._arac_girdi(up) + self._arac_gozlem(up) + elif tip == "plan": + print(mavi(f"\n 📋 plan: {len(up.get('entries', []))} adım")) + else: + print(gri(f"\n [update: {tip}]")) + + def _arac_girdi(self, up): + ham = up.get("rawInput") + if not ham: + return + try: + s = json.dumps(ham, ensure_ascii=False) + except Exception: + s = str(ham) + if len(s) > 2000: + s = s[:2000] + f"…(+{len(s) - 2000} kr)" + print(gri(f" ⮑ girdi: {s}")) + + def _arac_gozlem(self, up): + # hem single format hem list formatındaki content'leri desteklemek için + contents = up.get("content", []) + if isinstance(contents, dict): + contents = [contents] + for c in contents or []: + if not isinstance(c, dict): + continue + tc = c.get("type") + if tc == "content": + metin = (c.get("content") or {}).get("text", "") + if metin: + print(gri(f" ⮑ gözlem: {metin}")) + elif tc == "diff": + print(gri(f" ⮑ diff: {c.get('path', '')}")) + + # ── Yüksek seviye akış ───────────────────────────────────────────── + def initialize(self): + return self._istek("initialize", { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": {"name": "acp_surucu", "version": "0.1.0"}, + }, zaman_asimi=60) + + def yeni_oturum(self, cwd): + sonuc = self._istek("session/new", { + "cwd": str(cwd), + "mcpServers": [], + }, zaman_asimi=120) + return sonuc["sessionId"] + + def prompt(self, session_id, bloklar, zaman_asimi): + sonuc = self._istek("session/prompt", { + "sessionId": session_id, + "prompt": bloklar, + }, zaman_asimi=zaman_asimi) + self.son_stop_reason = (sonuc or {}).get("stopReason") + return self.son_stop_reason + + def iptal(self, session_id): + print(sari(f"\n ✖ session/cancel gönderiliyor (sessionId={session_id})")) + self._bildir("session/cancel", {"sessionId": session_id}) + + +def metin_blok(text): + return {"type": "text", "text": text} + + +def resim_blok(yol): + p = Path(yol) + veri = base64.b64encode(p.read_bytes()).decode("ascii") + ext = p.suffix.lower().lstrip(".") + mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", + "gif": "image/gif", "webp": "image/webp"}.get(ext, "image/png") + return {"type": "image", "data": veri, "mimeType": mime} + + +def main(): + ap = argparse.ArgumentParser(description="agy-acp headless stdio sürücüsü") + ap.add_argument("prompt", help="Ajana gönderilecek ilk istem metni") + ap.add_argument("--then", action="append", default=[], dest="then", + help="Aynı oturumda sıralı gönderilecek ek istem (tekrarlanabilir)") + ap.add_argument("--cwd", default=str(PROJE_KOK), help="Oturum çalışma dizini") + ap.add_argument("--image", action="append", default=[], help="Eklenecek resim yolu") + ap.add_argument("--cancel-after", type=float, default=None, + help="N saniye sonra session/cancel gönder") + ap.add_argument("--timeout", type=float, default=300, help="prompt yanıt zaman aşımı (s)") + ap.add_argument("--reddet", action="store_true", help="İzin isteklerini REDDET") + ap.add_argument("--sessiz-dusunce", action="store_true", help="thinking chunk'larını gösterme") + args = ap.parse_args() + + if not BINARY.exists(): + print(kir(f"Binary yok: {BINARY}\nÖnce: cargo build --release"), file=sys.stderr) + return 2 + + env = dict(os.environ) + + print(gri(f"→ başlatılıyor: {BINARY}")) + print(gri(f" cwd={args.cwd} iptal={args.cancel_after} reddet={args.reddet}")) + proc = subprocess.Popen( + [str(BINARY)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=sys.stderr, + env=env, text=True, bufsize=1, + ) + + surucu = AcpSurucu(proc, reddet=args.reddet, sessiz_dusunce=args.sessiz_dusunce) + cikis = 1 + try: + surucu.initialize() + print(gri("✓ initialize")) + sid = surucu.yeni_oturum(args.cwd) + print(gri(f"✓ session/new → {sid}")) + + bloklar = [metin_blok(args.prompt)] + for img in args.image: + bloklar.append(resim_blok(img)) + + if args.cancel_after is not None: + def _gec_iptal(): + time.sleep(args.cancel_after) + surucu.iptal(sid) + threading.Thread(target=_gec_iptal, daemon=True).start() + + istemler = [(bloklar, args.prompt)] + for t in args.then: + istemler.append(([metin_blok(t)], t)) + + cikis = 0 + for idx, (blk, metin) in enumerate(istemler): + print(mavi(f"\n── PROMPT {idx+1}/{len(istemler)} ────────────────────────────\n{metin}\n──")) + t0 = time.time() + stop = surucu.prompt(sid, blk, zaman_asimi=args.timeout) + sure = time.time() - t0 + print(f"\n\n{gri('──')} stopReason={yes(str(stop))} ({sure:.1f}s)") + if stop not in ("end_turn", "completed", "max_tokens", "max_turn_requests"): + cikis = 1 + break + except (TimeoutError, RuntimeError) as e: + print(kir(f"\n✗ {e}")) + cikis = 1 + except KeyboardInterrupt: + print(sari("\n✖ kullanıcı kesti")) + cikis = 130 + finally: + try: + proc.terminate() + proc.wait(timeout=5) + except Exception: + proc.kill() + return cikis + + +if __name__ == "__main__": + sys.exit(main()) From a15e7b76c8ff242abe5149a32d80d629ab89c519 Mon Sep 17 00:00:00 2001 From: byram Date: Sat, 20 Jun 2026 21:08:12 +0300 Subject: [PATCH 2/2] style(agy-acp): format usage report as markdown table with progress bars --- agy-acp/src/adapter.rs | 71 ++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/agy-acp/src/adapter.rs b/agy-acp/src/adapter.rs index 0b84b77e6..1c71abb37 100644 --- a/agy-acp/src/adapter.rs +++ b/agy-acp/src/adapter.rs @@ -341,6 +341,12 @@ impl Adapter { pub fn usage_report(&self, on_status: Option<&dyn Fn(&str)>) -> String { use std::process::Command; + let make_progress_bar = |percentage: f64| -> String { + let filled = (percentage / 10.0).round().clamp(0.0, 10.0) as usize; + let empty = 10 - filled; + format!("{}{}", "█".repeat(filled), "░".repeat(empty)) + }; + if let Some(cb) = on_status { cb("Lokal agy portları taranıyor..."); } // Find active agy ports using lsof @@ -400,11 +406,13 @@ impl Adapter { if let Some(json) = quota_json { if let Some(cb) = on_status { cb("Kota bilgileri çözümleniyor..."); } - let mut report = String::from("Modellerin Kullanım Bilgileri (agy - Canlı API):\n"); + let mut report = String::from("### Modellerin Kullanım Bilgileri (Canlı API)\n\n"); + report.push_str("| Grup / Model | Limit Tipi | Kalan Kota | Durum |\n"); + report.push_str("| :--- | :--- | :---: | :--- |\n"); + if let Some(groups) = json.pointer("/response/groups").and_then(|g| g.as_array()) { for group in groups { let group_name = group.get("displayName").and_then(|v| v.as_str()).unwrap_or("Bilinmeyen Grup"); - report.push_str(&format!("\n{}:\n", group_name.to_uppercase())); if let Some(buckets) = group.get("buckets").and_then(|b| b.as_array()) { for bucket in buckets { @@ -412,13 +420,22 @@ impl Adapter { let desc = bucket.get("description").and_then(|v| v.as_str()).unwrap_or(""); let remaining = bucket.get("remainingFraction").and_then(|v| v.as_f64()).unwrap_or(0.0); - // remainingFraction is the fraction remaining (e.g. 0.94 means 94.48% remaining) let percentage = remaining * 100.0; + let bar = make_progress_bar(percentage); - report.push_str(&format!("• {}: %{:.2} kalan\n", bucket_name, percentage)); - if !desc.is_empty() { - report.push_str(&format!(" Açıklama: {}\n", desc)); - } + let limit_name = if !desc.is_empty() { + format!("{} ({})", bucket_name, desc) + } else { + bucket_name.to_string() + }; + + report.push_str(&format!( + "| **{}** | {} | %{:.2} | `{}` |\n", + group_name.to_uppercase(), + limit_name, + percentage, + bar + )); } } } @@ -608,16 +625,38 @@ impl Adapter { match (gemini_parsed, claude_parsed) { (Some(g), Some(c)) => { + let make_progress_bar = |percentage: f64| -> String { + let filled = (percentage / 10.0).round().clamp(0.0, 10.0) as usize; + let empty = 10 - filled; + format!("{}{}", "█".repeat(filled), "░".repeat(empty)) + }; + + let to_pct = |s: &str| -> f64 { + s.replace("%", "").trim().parse::().unwrap_or(0.0) + }; + + let g_w_pct = to_pct(&g.0); + let g_f_pct = to_pct(&g.2); + let c_w_pct = to_pct(&c.0); + let c_f_pct = to_pct(&c.2); + + let g_w_bar = make_progress_bar(g_w_pct); + let g_f_bar = make_progress_bar(g_f_pct); + let c_w_bar = make_progress_bar(c_w_pct); + let c_f_bar = make_progress_bar(c_f_pct); + format!( - "Modellerin Kullanım Bilgileri (agy):\n\n\ - GEMINI MODELLERİ:\n\ - • Haftalık Limit: %{} (Yenilenme süresi: {})\n\ - • 5 Saatlik Limit: %{} (Yenilenme süresi: {})\n\n\ - CLAUDE VE GPT MODELLERİ:\n\ - • Haftalık Limit: %{} (Yenilenme süresi: {})\n\ - • 5 Saatlik Limit: %{} (Yenilenme süresi: {})", - g.0.replace("%", ""), g.1, g.2.replace("%", ""), g.3, - c.0.replace("%", ""), c.1, c.2.replace("%", ""), c.3 + "### Modellerin Kullanım Bilgileri (Canlı API fallback)\n\n\ + | Grup / Model | Limit Tipi | Kalan Kota | Durum | Yenilenme |\n\ + | :--- | :--- | :---: | :--- | :--- |\n\ + | **GEMINI** | Haftalık Limit | %{:.2} | `{}` | {} |\n\ + | **GEMINI** | 5 Saatlik Limit | %{:.2} | `{}` | {} |\n\ + | **CLAUDE/GPT** | Haftalık Limit | %{:.2} | `{}` | {} |\n\ + | **CLAUDE/GPT** | 5 Saatlik Limit | %{:.2} | `{}` | {} |", + g_w_pct, g_w_bar, g.1, + g_f_pct, g_f_bar, g.3, + c_w_pct, c_w_bar, c.1, + c_f_pct, c_f_bar, c.3 ) } _ => {