#!/usr/bin/env python3 import sys import json import base64 import mimetypes import shutil import argparse from pathlib import Path from datetime import datetime, timezone import requests CONFIG_PATH = Path.home() / ".config" / "capture-ai" / "config.json" CACHE_BASE = Path.home() / ".cache" / "capture-ai" REFS_CACHE_DIR = CACHE_BASE / "refs" OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" GLOBAL_SYSTEM_PROMPT = ( "Bu konuşmada sana zaman zaman e-posta, kod, komut, yapılandırma dosyası " "veya birebir kopyalanması gereken metinler yazdırılabilir.\n\n" "FORMAT KURALI:\n" "Eğer kullanıcıdan gelen istekte doğrudan kopyalanabilir bir içerik " "(örneğin kod, script, terminal komutu, yapılandırma dosyası vb.) " "üretmen gerekiyorsa aşağıdaki formatı kullan:\n\n" "title\n" "\n" "title\n" "copy\n" "\n" "copy\n\n" "Kurallar:\n" "- 'title' ve 'copy' kelimeleri tek başına satır olmalıdır.\n" "- 'title' bloğu kod bloğundan önce gelmelidir.\n" "- Kod yoksa bu formatı kullanma.\n" "- Normal sohbet cevaplarında bu formatı kullanma.\n" "- Sadece gerçekten kopyalanabilir içerik varsa bu formatı kullan.\n" "\n\n[FILE] BLOKLARI HAKKINDA:\n" "- Eğer kullanıcı bir dosya eklediyse, dosya içeriği bu mesaj içinde " "[FILE] ... [CONTENT] ... [/CONTENT] formatında ZATEN verilmiştir.\n" "- Bu durumda kullanıcıdan 'kodu buraya yapıştır' veya 'dosyayı paylaş' deme.\n" "- Değişiklik gerekiyorsa [CONTENT] içeriğini okuyarak işlem yap.\n" "- Dosya içeriğini tekrar isteme.\n" "- path alanı olarak [FILE] bloğunda verilen path değerini aynen kullan.\n" "\n\nDOSYA DEĞİŞİKLİĞİ KURALI:\n" "- [FILE] bloklarında 'editable: true' ise dosyada değişiklik önerebilirsin.\n" "- 'editable: false' olan dosyalar için ASLA dosyaya uygulanacak değişiklik isteme.\n" "- editable:false dosyalar için apply bloğu üretme.\n" "- Eğer değişiklik gerekmiyorsa apply bloğu hiç koyma.\n" "\n\nAPPLY (OTOMATİK UYGULAMA) PROTOKOLÜ:\n" "Eğer ve sadece eğer kullanıcı 'editable: true' bir dosyada değişiklik istiyorsa,\n" "cevabının SONUNA aşağıdaki blok formatıyla ekle (başka yerde kullanma):\n\n" "apply\n" "[\n" " {\n" " \"path\": \"/absolute/path/to/file\",\n" " \"find\": \"DOSYADA BİREBİR GEÇEN ANCHOR METİN\",\n" " \"mode\": \"before|after|replace\",\n" " \"text\": \"EKLENECEK/YERİNE GEÇECEK METİN\"\n" " }\n" "]\n" "apply\n\n" "Kurallar:\n" "- 'apply' kelimesi tek başına satır olmalı (başlangıç ve bitiş).\n" "- apply bloğunun içi SADECE JSON olmalı (başka açıklama koyma).\n" "- JSON sadece object/array olmalı, yorum (//) veya trailing virgül olamaz.\n" "- mode sadece: before, after, replace.\n" "- path MUTLAKA absolute path olmalı.\n" "- find, dosyada birebir bulunan kısa/orta bir anchor olmalı.\n" "- text, eklenecek içerik ve sonunda '\\n' gerekiyorsa ekle.\n" "\n\nAPPLY ÖZET SATIRI KURALI:\n" "- Eğer apply bloğu üretiyorsan, 'Başarıyla yaptım' gibi kesin/bitmiş ifade ASLA kullanma.\n" "- apply bloğundan ÖNCE sadece 1 satır özet yaz.\n" "- Özet formatı TAM olarak şu olsun:\n" " 'Bu satırı ediyorum: -> '\n" " MODE: replace|before|after\n" "- ve çok uzunsa 120 karakterde kes ve '...' ekle.\n" "- Özet dışında ekstra açıklama yazma.\n" ) def _safe_read_text(path: Path, max_bytes: int = 250_000) -> str: try: data = path.read_bytes() if len(data) > max_bytes: data = data[:max_bytes] return data.decode("utf-8", errors="replace") except Exception as e: return f"[Dosya okunamadı: {e}]" def _is_text_file(path: Path) -> bool: ext = path.suffix.lower() return ext in { ".txt", ".md", ".py", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash", ".zsh", ".js", ".ts", ".tsx", ".jsx", ".css", ".html", ".xml", ".c", ".cpp", ".h", ".hpp", ".rs", ".go", ".java", ".kt", ".cs", ".sql" } def _img_path_to_data_url(p: str) -> str | None: try: fp = Path(p) if not fp.exists(): return None mime, _ = mimetypes.guess_type(str(fp)) if not mime: mime = "image/png" b64 = base64.b64encode(fp.read_bytes()).decode("utf-8") return f"data:{mime};base64,{b64}" except Exception: return None def _die(msg: str, code: int = 1): sys.stderr.write(msg + "\n") sys.exit(code) def _load_config() -> dict: if not CONFIG_PATH.exists(): _die("Config dosyası bulunamadı.", 1) try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) or {} except Exception as e: _die(f"Config okunamadı: {e}", 1) def _save_config(cfg: dict): try: CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: # config yazılamasa bile AI çalışsın pass def _parse_selected_indexes(arg: str) -> list[int]: out = [] if not arg: return out for x in arg.split(","): x = x.strip() if x.isdigit(): out.append(int(x)) # uniq + stable seen = set() uniq = [] for i in out: if i not in seen: seen.add(i) uniq.append(i) return uniq def _mime_for(path: Path) -> str: mime, _ = mimetypes.guess_type(str(path)) return mime or "application/octet-stream" def _data_url_for_image(path: Path) -> str: mime = _mime_for(path) b64 = base64.b64encode(path.read_bytes()).decode("ascii") return f"data:{mime};base64,{b64}" def _role_map(chat_role: str) -> str: return "assistant" if chat_role == "bot" else "user" def _safe_chat_key(chat_file: Path) -> str: return chat_file.stem.replace("/", "_").replace("\\", "_") def _utc_stamp() -> str: return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def _copy_to_cache(src: Path, dest_dir: Path) -> Path: dest_dir.mkdir(parents=True, exist_ok=True) base = src.stem.replace(" ", "_") ext = src.suffix or ".img" dest = dest_dir / f"{base}{ext}" if dest.exists(): dest = dest_dir / f"{base}_{_utc_stamp()}{ext}" shutil.copy2(src, dest) return dest def _build_blocks_and_cache_info(msg: dict, cache_images_dir: Path | None): blocks = [] cached_image = None # 1) Text txt = msg.get("content") or "" if txt: blocks.append({"type": "text", "text": txt}) # 2) Images (legacy "image" + new "images") imgs = [] one = msg.get("image") if one: imgs.append(one) many = msg.get("images") if isinstance(many, list): imgs.extend([str(x) for x in many if x]) # uniq seen = set() uniq_imgs = [] for p in imgs: if p not in seen: seen.add(p) uniq_imgs.append(p) for img_raw in uniq_imgs: img_path = Path(img_raw) if not img_path.exists(): continue if cache_images_dir is not None: try: cached_image = _copy_to_cache(img_path, cache_images_dir) except Exception: cached_image = cached_image # önceki varsa kalsın try: blocks.append({ "type": "image_url", "image_url": {"url": _data_url_for_image(img_path)} }) except Exception: pass # 3) Files (new: "files": [{"path","name","edit"}]) files = msg.get("files") or [] if isinstance(files, list): for f in files: if not isinstance(f, dict): continue p_raw = str(f.get("path") or "").strip() if not p_raw: continue p = Path(p_raw) if not p.exists() or not p.is_file(): continue editable = bool(f.get("edit")) name = (f.get("name") or p.name or "").strip() mime = _mime_for(p) if _is_text_file(p): file_text = _safe_read_text(p) blocks.append({ "type": "text", "text": ( "\n[FILE]\n" f"name: {name}\n" f"path: {str(p)}\n" f"mime: {mime}\n" f"editable: {'true' if editable else 'false'}\n" "type: text\n" "[CONTENT]\n" f"{file_text}\n" "[/CONTENT]\n" "[/FILE]\n" ) }) else: # Excel/PDF/DOCX vb. şu an içerik aktarmıyoruz (metadata gönderiyoruz) blocks.append({ "type": "text", "text": ( "\n[FILE]\n" f"name: {name}\n" f"path: {str(p)}\n" f"mime: {mime}\n" f"editable: {'true' if editable else 'false'}\n" "type: binary_or_unsupported_preview\n" "note: Bu dosya türü şu an içerik olarak gönderilmiyor.\n" "[/FILE]\n" ) }) if not blocks: blocks = [{"type": "text", "text": ""}] return blocks, (str(cached_image) if cached_image else None) def _pick_model(cfg: dict, chat_file: Path, cli_model: str | None) -> str: """ Öncelik: 1) CLI --model verilmişse onu kullan (alternatif) 2) chat_models[chat_file.name] 3) ai_models[0] (en üst = en son kullanılan) Eğer chat'in modeli config'te yoksa/silinmişse otomatik ai_models[0]'a düzeltir. """ models = cfg.get("ai_models", []) if not isinstance(models, list): models = [] models = [str(m).strip() for m in models if str(m).strip()] if not models: _die("Config içinde ai_models boş. En az 1 model olmalı.", 1) if cli_model: return str(cli_model).strip() chat_models = cfg.get("chat_models", {}) if not isinstance(chat_models, dict): chat_models = {} chat_key = chat_file.name chosen = str(chat_models.get(chat_key) or "").strip() if (not chosen) or (chosen not in models): chosen = models[0] chat_models[chat_key] = chosen cfg["chat_models"] = chat_models _save_config(cfg) return chosen def main(): # Args parser = argparse.ArgumentParser(add_help=False) parser.add_argument("chat_file", nargs="?") parser.add_argument("selected_indexes", nargs="?") parser.add_argument("--model", dest="model", default=None) if len(sys.argv) < 2: sys.exit(1) args, _unknown = parser.parse_known_args() if not args.chat_file: sys.exit(1) chat_file = Path(args.chat_file) if not chat_file.exists(): sys.exit(1) selected_indexes = [] if args.selected_indexes: try: selected_indexes = _parse_selected_indexes(args.selected_indexes) except Exception: selected_indexes = [] # Config + API key cfg = _load_config() API_KEY = (cfg.get("open_router_key") or "").strip() if not API_KEY: _die("OpenRouter API Key bulunamadı.", 1) # model seçimi (chat’e göre) model_name = _pick_model(cfg, chat_file, args.model) # Read chat try: messages = json.loads(chat_file.read_text(encoding="utf-8")) or [] except Exception as e: _die(f"Chat okunamadı: {e}", 1) if not messages: sys.exit(0) last = messages[-1] final_messages = [] editable_map = {} # path(str) -> bool final_messages.append({ "role": "system", "content": GLOBAL_SYSTEM_PROMPT }) # Cache setup REFS_CACHE_DIR.mkdir(parents=True, exist_ok=True) # Reference system (TEXT + IMAGE) + CACHE if selected_indexes: chat_key = _safe_chat_key(chat_file) bundle_id = _utc_stamp() bundle_dir = REFS_CACHE_DIR / chat_key / bundle_id bundle_images_dir = bundle_dir / "images" bundle_dir.mkdir(parents=True, exist_ok=True) final_messages.append({ "role": "system", "content": ( "Aşağıdaki mesajlar sadece son kullanıcı mesajını anlaman için referans olarak verilmiştir. " "Bu mesajlara cevap verme. Sadece en son kullanıcı mesajını yanıtla." ) }) cache_items = [] for i in selected_indexes: if i < len(messages) - 1: msg = messages[i] role = _role_map(msg.get("role")) has_any_attach = False # editable_map doldur (referans mesajın file edit izinleri) files_meta = msg.get("files") or [] if isinstance(files_meta, list): for ff in files_meta: if not isinstance(ff, dict): continue p_raw = str(ff.get("path") or "").strip() if not p_raw: continue editable_map[p_raw] = bool(ff.get("edit")) # images? if msg.get("image") and Path(str(msg["image"])).exists(): has_any_attach = True imgs = msg.get("images") if isinstance(imgs, list) and any(Path(str(p)).exists() for p in imgs): has_any_attach = True # files? files = msg.get("files") if isinstance(files, list) and any(Path(str(f.get("path") or "")).exists() for f in files if isinstance(f, dict)): has_any_attach = True if has_any_attach: blocks, cached_img = _build_blocks_and_cache_info(msg, bundle_images_dir) final_messages.append({"role": role, "content": blocks}) else: final_messages.append({"role": role, "content": msg.get("content", "")}) cached_img = None cache_items.append({ "index": i, "role": msg.get("role", ""), "content": msg.get("content", ""), "image_original": msg.get("image", None), "images_original": msg.get("images", None), "files_original": msg.get("files", None), "image_cached": cached_img }) bundle = { "bundle_id": bundle_id, "created_at_utc": datetime.now(timezone.utc).isoformat(), "chat_file": str(chat_file), "selected_indexes": selected_indexes, "items": cache_items } try: (bundle_dir / "bundle.json").write_text( json.dumps(bundle, ensure_ascii=False, indent=2), encoding="utf-8" ) except Exception: pass # Last message (answer) - TEXT + (image / images) # last message editable_map doldur files_last = last.get("files") or [] if isinstance(files_last, list): for ff in files_last: if not isinstance(ff, dict): continue p_raw = str(ff.get("path") or "").strip() if not p_raw: continue editable_map[p_raw] = bool(ff.get("edit")) # Last message (answer) - TEXT + (images/files) role = _role_map(last.get("role")) has_any_attach = False # images? if last.get("image") and Path(str(last["image"])).exists(): has_any_attach = True imgs = last.get("images") if isinstance(imgs, list) and any(Path(str(p)).exists() for p in imgs): has_any_attach = True # files? files = last.get("files") if isinstance(files, list) and any( Path(str(f.get("path") or "")).exists() for f in files if isinstance(f, dict) ): has_any_attach = True if has_any_attach: blocks, _ = _build_blocks_and_cache_info(last, cache_images_dir=None) final_messages.append({"role": role, "content": blocks}) else: final_messages.append({"role": role, "content": last.get("content", "")}) # Payload payload = { "model": model_name, "messages": final_messages, "max_tokens": 5120 } # Request try: response = requests.post( OPENROUTER_URL, headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", "HTTP-Referer": "http://localhost", "X-Title": "capture-ai" }, json=payload, timeout=120 ) if response.status_code != 200: sys.stderr.write(f"OpenRouter API ERROR ({response.status_code}): {response.text}\n") sys.exit(1) data = response.json() try: reply = data["choices"][0]["message"]["content"] except Exception: sys.stderr.write("OpenRouter beklenmeyen cevap formatı döndürdü.\n") sys.exit(1) except requests.exceptions.Timeout: sys.stderr.write("İstek zaman aşımına uğradı (timeout).\n") sys.exit(1) except requests.exceptions.ConnectionError: sys.stderr.write("Bağlantı hatası. İnternet bağlantınızı kontrol edin.\n") sys.exit(1) except Exception as e: sys.stderr.write(f"AI ERROR: {str(e)}\n") sys.exit(1) # Save bot reply messages.append({"role": "bot", "content": reply}) try: chat_file.write_text(json.dumps(messages, ensure_ascii=False, indent=2), encoding="utf-8") except Exception as e: sys.stderr.write(f"Chat yazılamadı: {e}\n") sys.exit(1) sys.exit(0) if __name__ == "__main__": main()