diff --git a/.gitignore b/.gitignore index 25dfea2..069d8e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ opencode/ +context.md diff --git a/package.json b/package.json index 8282b45..95b43ca 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^2.0.0" + "@ai-sdk/provider": "^3.0.8", + "@ai-sdk/provider-utils": "^4.0.23" }, "devDependencies": { "@types/node": "^25.5.0", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..aa3d731 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,183 @@ +# scripts/ + +Strumenti operativi per il plugin `opencode-claude-code-plugin`. + +## Indice + +- [Import sessioni Claude Code in OpenCode](#import-sessioni-claude-code-in-opencode) + - [Avvio](#avvio) + - [REPL interattivo (comandi)](#repl-interattivo-comandi) + - [Esempi di uso](#esempi-di-uso) + - [Campi mostrati nella lista](#campi-mostrati-nella-lista) + - [Flag CLI](#flag-cli) + - [Collocamento automatico del progetto](#collocamento-automatico-del-progetto) + - [Cosa viene preservato / cosa viene perso](#cosa-viene-preservato--cosa-viene-perso) + - [Requisiti](#requisiti) + - [Troubleshooting](#troubleshooting) +- [Note di progetto - fix vision](#note-di-progetto---fix-vision) + +--- + +## Import sessioni Claude Code in OpenCode + +Script: `import_claude_to_opencode.py` + +Importa nel database di OpenCode le sessioni `.jsonl` generate dalla CLI di +Claude Code (cartella `~/.claude/projects/`), cosi' che la cronologia sia +navigabile dalla UI di OpenCode. + +### Avvio + +```bash +python scripts/import_claude_to_opencode.py # REPL interattivo +python scripts/import_claude_to_opencode.py openbao # REPL con filtro iniziale +python scripts/import_claude_to_opencode.py --dir dmm # pre-filtra per cwd path +python scripts/import_claude_to_opencode.py --limit 50 # cambia page size (default 20) +``` + +L'avvio indicizza tutte le sessioni disponibili (ordinate per ultima attivita' +utente, piu' recenti in cima), stampa le prime `--limit` righe ed entra nel +REPL. + +### REPL interattivo (comandi) + +Al prompt `>` si possono digitare: + +| Input | Effetto | +|-------|---------| +| `openbao` (keyword libera) | Filtra la lista per keyword (cerca in titolo, primo prompt, `cwd`) | +| `openbao \| 50` | Filtra e imposta il page size a 50 | +| `1` | Importa la sessione all'indice 1 della lista mostrata | +| `1 3 5` | Importa piu' sessioni | +| `1-5` | Range | +| `all` | Importa tutte le sessioni attualmente mostrate | +| `last 30` | Mostra le prime 30 della lista corrente | +| `clear` | Rimuove il filtro e torna alla lista completa | +| `help` | Stampa l'elenco dei comandi | +| `q` / `exit` | Esce | + +Dopo un import la lista filtrata rimane attiva: si puo' fare subito un'altra +ricerca senza ripartire da zero. + +### Esempi di uso + +**1. Trovare e importare per titolo custom.** +Le chat rinominate dalla UI di Claude Code salvano un `customTitle` nel +`.jsonl`. Lo script lo riconosce e lo marca con un `*`. + +``` +> test saluto + +1 match(es) for 'test saluto'. + [ 1]* 2026-04-17 11:35 T= 1 ...New folder test saluto + +> 1 +Importing [1] 8b9e371d-*.jsonl (test saluto) + OK: session ses_... -> C:\...\New folder +``` + +**2. Import in batch delle ultime 10 sessioni di un progetto specifico.** + +```bash +python scripts/import_claude_to_opencode.py --dir dmm-installations --limit 10 +``` + +Poi al prompt: `all`. + +**3. Batch mode non interattivo (legacy):** + +```bash +python scripts/import_claude_to_opencode.py openbao --limit 5 --batch +``` + +Stampa, chiede una volta la selezione con il vecchio prompt (`1 3`, `all`, `q`) +e esce. + +### Campi mostrati nella lista + +``` + [ N] YYYY-MM-DD HH:MM T= +``` + +| Campo | Significato | +|-------|-------------| +| `N` | Indice usato per selezionare nei comandi | +| `marker` | `*` se la chat ha un `customTitle` impostato dalla UI di Claude Code; spazio altrimenti | +| `YYYY-MM-DD HH:MM` | Timestamp dell'ultimo messaggio utente (last activity) | +| `T=<turns>` | Numero di turni user (escludendo i tool_result) | +| `cwd` | Working directory originale (troncato se lungo) | +| `title` | `customTitle` se presente, altrimenti primo prompt (troncato) | + +Ordine default: per ultimo messaggio utente, decrescente. + +### Flag CLI + +| Flag | Default | Descrizione | +|------|---------|-------------| +| `query` (pos.) | - | Keyword iniziale, entra nel REPL gia' filtrato | +| `--dir <substr>` | - | Pre-filtra per substring in `cwd` prima di indicizzare | +| `--limit <N>` | `20` | Page size nel REPL | +| `--batch` | `false` | Modalita' non interattiva (un solo ciclo) | +| `--model opus\|sonnet\|haiku` | auto-detect | Forza il `modelID` | +| `--project-id <id>` | auto-match | Forza project_id target | +| `--db <path>` | `~/.local/share/opencode/opencode.db` | Path del DB OpenCode | + +### Collocamento automatico del progetto + +1. Legge il primo `cwd` dal `.jsonl`. +2. Cerca `project.worktree == cwd` in OpenCode. Se esiste, usa quel progetto. +3. Altrimenti fallback al progetto `global` (worktree `/`). +4. `session.directory` resta comunque il `cwd` originale, quindi la session + compare sotto la cartella corretta nella UI di OpenCode. + +Override: `--project-id <id>`. + +### Cosa viene preservato / cosa viene perso + +Preservato: + +- Titolo custom (`customTitle`) quando presente +- Prompt utente e testi/reasoning assistant +- Tool call con `input` + `output` (match via `tool_use_id`) +- Token per turno (massimo tra gli eventi assistant del turno, che sono + cumulativi come nel runtime del plugin — evita doppi conteggi) +- Ordine cronologico + +Perso: + +- Immagini e allegati binari +- Sub-agent chain (`isSidechain: true`) +- Signature crypt dei thinking block +- Snapshot git reale (placeholder `000...000`) + +### Requisiti + +- Python 3 (stdlib `sqlite3`, nessuna dipendenza esterna) +- OpenCode chiuso durante l'import (WAL SQLite) + +### Troubleshooting + +| Sintomo | Causa | Azione | +|---------|-------|--------| +| `OpenCode DB not found` | Path DB errato | Passare `--db <path>` | +| `database is locked` | OpenCode in esecuzione | Chiudere OpenCode | +| Nessun match dopo una keyword | Filtro troppo stretto | `clear`, o keyword piu' generica | +| Il titolo importato non e' quello che vedo in Claude UI | Solo i rename fatti dalla UI di Claude Code creano `customTitle` nel `.jsonl`. I titoli generati solo nel cloud di Claude app non sono disponibili localmente | Rinominare la chat da Claude Code, poi reimportare | + +--- + +## Note di progetto - fix vision + +La gestione immagini richiede due modifiche concomitanti: + +1. `src/message-builder.ts`: conversione `part.type === "file" | "image"` in + blocco Anthropic `{type: "image", source: {type: "base64", media_type, data}}`. +2. `opencode.json`: sui modelli abilitati alla vision aggiungere + `"attachment": true` e + `"modalities": { "input": ["text", "image"], "output": ["text"] }`. + +Senza il secondo punto, OpenCode filtra i file part prima della chiamata al +plugin, sostituendoli con il placeholder `[Image #N]` nel testo. + +Modelli compatibili con vision (API Anthropic): Haiku 4.5, Sonnet 4.6, Opus 4.7. +Formati immagine supportati: `image/png`, `image/jpeg`, `image/gif`, `image/webp`. diff --git a/scripts/import_claude_to_opencode.py b/scripts/import_claude_to_opencode.py new file mode 100644 index 0000000..6ae042c --- /dev/null +++ b/scripts/import_claude_to_opencode.py @@ -0,0 +1,729 @@ +#!/usr/bin/env python3 +"""Interactive importer: Claude Code JSONL sessions -> OpenCode DB. + +Usage: + python import_claude_to_opencode.py # interactive browser + python import_claude_to_opencode.py openbao # filter by keyword in prompt or path + python import_claude_to_opencode.py --dir dmm # filter by cwd path substring + python import_claude_to_opencode.py --last 20 # show only last 20 by mtime + +After filtering, a numbered list is shown. Type the numbers to import +(space-separated, e.g. "1 3 5"), or "all", or "q" to quit. + +Ownership: + Sessions are imported into the OpenCode project whose worktree equals the + JSONL's cwd. If no matching project exists, the import is skipped with a + warning — open that folder once in OpenCode so the project row gets created, + then re-run. + +Flags: + --model <opus|sonnet|haiku> Override modelID in the imported message + --project-id <id> Force a specific project_id for all selections + --db <path> OpenCode DB path (default: ~/.local/share/opencode/opencode.db) +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import secrets +import sqlite3 +import sys +import time +from datetime import datetime +from pathlib import Path + +CLAUDE_PROJECTS = Path.home() / ".claude" / "projects" +OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db" +OPENCODE_VERSION = "1.4.3" +PLACEHOLDER_SNAPSHOT = "0" * 40 + +OPENCODE_HANDLED_TOOLS = { + "Edit", "Write", "Bash", "NotebookEdit", "TodoWrite", "Read", "Glob", "Grep", +} +CLAUDE_INTERNAL_TOOLS = {"ToolSearch", "Agent", "AskFollowupQuestion"} + + +# ---------- tool mapping (mirrors tool-mapping.ts) ---------- + +def map_tool_input(name, inp): + if not inp: + return inp + if name == "Write": + return {"filePath": inp.get("file_path") or inp.get("filePath"), + "content": inp.get("content")} + if name == "Edit": + return {"filePath": inp.get("file_path") or inp.get("filePath"), + "oldString": inp.get("old_string") or inp.get("oldString"), + "newString": inp.get("new_string") or inp.get("newString"), + "replaceAll": inp.get("replace_all") or inp.get("replaceAll")} + if name == "Read": + return {"filePath": inp.get("file_path") or inp.get("filePath"), + "offset": inp.get("offset"), "limit": inp.get("limit")} + if name == "Bash": + cmd = inp.get("command", "") or "" + desc = inp.get("description") or ( + f"Execute: {cmd[:50]}{'...' if len(cmd) > 50 else ''}") + return {"command": cmd, "description": desc, "timeout": inp.get("timeout")} + if name == "Grep": + return {"pattern": inp.get("pattern"), "path": inp.get("path"), + "include": inp.get("include")} + if name == "Glob": + return {"pattern": inp.get("pattern"), "path": inp.get("path")} + if name == "TodoWrite" and isinstance(inp.get("todos"), list): + return {"todos": [ + {"content": t.get("content"), + "status": t.get("status", "pending"), + "priority": t.get("priority", "medium"), + "id": t.get("id", f"todo_{int(time.time() * 1000)}_{i}")} + for i, t in enumerate(inp["todos"]) + ]} + return inp + + +def map_tool(name, inp): + if name in CLAUDE_INTERNAL_TOOLS: + return {"name": name, "input": inp, "executed": True, "skip": True} + if name == "EnterPlanMode": + return {"name": "plan_enter", "input": {}, "executed": False, "skip": False} + if name == "ExitPlanMode": + return {"name": "plan_exit", "input": inp, "executed": False, "skip": False} + if name in ("WebSearch", "web_search"): + q = inp.get("query") if isinstance(inp, dict) else None + mi = {"query": q} if q else inp + return {"name": "websearch_web_search_exa", "input": mi, + "executed": False, "skip": False} + if name == "TaskOutput": + if not inp: + return {"name": "bash", "input": None, "executed": False, "skip": False} + out = inp.get("content") or inp.get("output") or json.dumps(inp) + return {"name": "bash", + "input": {"command": f'echo "TASK OUTPUT: {str(out).replace(chr(34), chr(92)+chr(34))}"', + "description": "Displaying task output"}, + "executed": False, "skip": False} + if name.startswith("mcp__"): + parts = name[5:].split("__") + if len(parts) >= 2: + return {"name": f"{parts[0]}_{'_'.join(parts[1:])}", "input": inp, + "executed": False, "skip": False} + if name in OPENCODE_HANDLED_TOOLS: + return {"name": name.lower(), "input": map_tool_input(name, inp), + "executed": True, "skip": False} + return {"name": name, "input": inp, "executed": True, "skip": False} + + +# ---------- util ---------- + +def make_id(prefix): + return f"{prefix}_{format(int(time.time() * 1_000_000), 'x')}{secrets.token_hex(8)}" + + +def parse_ts(iso_str): + if not iso_str: + return int(time.time() * 1000) + try: + return int(datetime.fromisoformat(iso_str.replace("Z", "+00:00")).timestamp() * 1000) + except Exception: + return int(time.time() * 1000) + + +def normalize_model_id(raw): + if not raw: + return "opus" + if "opus" in raw: + return "opus" + if "sonnet" in raw: + return "sonnet" + if "haiku" in raw: + return "haiku" + return raw + + +# ---------- indexing ---------- + +def index_jsonl(path: Path): + """Return metadata dict or None. + + Extracts: cwd, first user prompt, custom-title (if present), turn count, + timestamp of the last user interaction (for ordering). + """ + cwd = None + first_text = None + custom_title = None + turns = 0 + last_user_ts = None + try: + with path.open(encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + except Exception: + continue + t = d.get("type") + if t == "custom-title": + ct = d.get("customTitle") + if ct: + custom_title = ct.strip() + continue + if cwd is None and d.get("cwd"): + cwd = d["cwd"] + if t != "user" or d.get("isSidechain"): + continue + content = d.get("message", {}).get("content") + if isinstance(content, list) and content and all( + isinstance(x, dict) and x.get("type") == "tool_result" + for x in content + ): + continue + turns += 1 + last_user_ts = parse_ts(d.get("timestamp")) + if first_text is None: + if isinstance(content, str): + first_text = content.strip() + elif isinstance(content, list): + chunks = [] + for c in content: + if isinstance(c, dict) and c.get("type") == "text": + tx = c.get("text", "") + if tx.startswith("<") or tx.startswith("[Image "): + continue + chunks.append(tx) + first_text = "\n".join(chunks).strip() or None + except Exception: + return None + if not cwd or not first_text: + return None + display_title = custom_title or first_text + return {"path": path, "cwd": cwd, + "title": display_title, "custom_title": custom_title, + "first_prompt": first_text, + "turns": turns, "mtime": path.stat().st_mtime, + "last_user_ts": last_user_ts or 0} + + +def collect_sessions(dir_filter=None): + sessions = [] + if not CLAUDE_PROJECTS.exists(): + return sessions + for proj in CLAUDE_PROJECTS.iterdir(): + if not proj.is_dir(): + continue + for fp in proj.glob("*.jsonl"): + info = index_jsonl(fp) + if not info: + continue + if dir_filter and dir_filter.lower() not in info["cwd"].lower(): + continue + sessions.append(info) + return sessions + + +def filter_sessions(sessions, query): + if not query: + return sessions + q = query.lower() + out = [] + for s in sessions: + hay = " ".join([ + s.get("title") or "", + s.get("first_prompt") or "", + s.get("custom_title") or "", + s.get("cwd") or "", + ]).lower() + if q in hay: + out.append(s) + return out + + +def _format_ts(ts_ms_or_s): + if not ts_ms_or_s: + return "-" * 16 + ts = ts_ms_or_s / 1000 if ts_ms_or_s > 1e12 else ts_ms_or_s + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + + +def print_sessions(sessions, limit=None): + if limit: + sessions = sessions[:limit] + for i, s in enumerate(sessions, 1): + # order key is last_user_ts (ms since epoch); fallback to mtime + ts = s.get("last_user_ts") or int(s.get("mtime", 0) * 1000) + dt = _format_ts(ts) + cwd = s["cwd"] + if len(cwd) > 45: + cwd = "..." + cwd[-42:] + title = s["title"].replace("\n", " ").strip() + marker = "*" if s.get("custom_title") else " " + if len(title) > 70: + title = title[:67] + "..." + print(f" [{i:>3}]{marker} {dt} T={s['turns']:>3} {cwd:<45} {title}") + + +def prompt_selection(total): + print() + raw = input("Select (e.g. '1 3', 'all', or 'q'): ").strip().lower() + if not raw or raw == "q": + return [] + if raw == "all": + return list(range(1, total + 1)) + picks = [] + for tok in re.split(r"[\s,]+", raw): + if not tok: + continue + if "-" in tok: + a, b = tok.split("-", 1) + picks.extend(range(int(a), int(b) + 1)) + else: + picks.append(int(tok)) + return [p for p in picks if 1 <= p <= total] + + +# ---------- import logic (unchanged core, refactored) ---------- + +def load_events(path): + out = [] + with path.open(encoding="utf-8", errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except Exception: + pass + return out + + +def is_tool_result_only(event): + c = event.get("message", {}).get("content") + if not isinstance(c, list) or not c: + return False + return all(isinstance(x, dict) and x.get("type") == "tool_result" for x in c) + + +def extract_user_text(event): + content = event.get("message", {}).get("content") + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + chunks = [] + for c in content: + if isinstance(c, dict) and c.get("type") == "text": + t = c.get("text", "") + if t.startswith("<system-reminder>") or t.startswith("<command-"): + continue + chunks.append(t) + return "\n".join(chunks).strip() + return "" + + +def group_turns(events): + turns = [] + cur = None + for e in events: + if e.get("type") not in ("user", "assistant"): + continue + if e.get("isSidechain"): + continue + role = e.get("message", {}).get("role") + if role == "user" and not is_tool_result_only(e): + if cur is not None: + turns.append(cur) + cur = {"user": e, "events": []} + else: + if cur is None: + continue + cur["events"].append(e) + if cur: + turns.append(cur) + return turns + + +def build_tool_results_map(turn_events): + m = {} + for e in turn_events: + if e.get("message", {}).get("role") != "user": + continue + content = e.get("message", {}).get("content") + if not isinstance(content, list): + continue + end_ts = parse_ts(e.get("timestamp")) + for c in content: + if not isinstance(c, dict) or c.get("type") != "tool_result": + continue + tid = c.get("tool_use_id") + raw = c.get("content") + if isinstance(raw, list): + raw = "\n".join(x.get("text", "") for x in raw + if isinstance(x, dict) and x.get("type") == "text") + m[tid] = {"content": raw or "", + "is_error": bool(c.get("is_error")), + "end_ts": end_ts} + return m + + +def import_one(db, jsonl_path, project_id_override=None, model_override=None): + events = load_events(jsonl_path) + if not events: + return None, "empty jsonl" + + first_cwd = None + for e in events: + if e.get("cwd"): + first_cwd = e["cwd"] + break + if not first_cwd: + return None, "no cwd in jsonl" + + if project_id_override: + row = db.execute("SELECT worktree FROM project WHERE id=?", + (project_id_override,)).fetchone() + if not row: + return None, f"project_id {project_id_override} not found" + project_id = project_id_override + else: + row = db.execute("SELECT id FROM project WHERE worktree=?", + (first_cwd,)).fetchone() + if row: + project_id = row[0] + else: + fallback = db.execute( + "SELECT id FROM project WHERE id='global' OR worktree='/' LIMIT 1" + ).fetchone() + if not fallback: + return None, f"no project match for cwd={first_cwd!r} and no global fallback" + project_id = fallback[0] + + turns = group_turns(events) + if not turns: + return None, "no user turns" + + custom_title = None + for e in events: + if e.get("type") == "custom-title": + ct = e.get("customTitle") + if ct: + custom_title = ct.strip() + + session_id = make_id("ses") + first_prompt = extract_user_text(turns[0]["user"]) + title_source = custom_title or first_prompt or "Imported Claude session" + title = title_source[:80].replace("\n", " ").strip() + first_ts = parse_ts(turns[0]["user"].get("timestamp")) + last_ts = first_ts + + msg_rows, part_rows = [], [] + + for turn in turns: + user_ev = turn["user"] + user_ts = parse_ts(user_ev.get("timestamp")) + user_text = extract_user_text(user_ev) + + user_msg_id = make_id("msg") + msg_rows.append((user_msg_id, session_id, user_ts, user_ts, + json.dumps({ + "role": "user", + "time": {"created": user_ts}, + "agent": "build", + "model": {"providerID": "claude-code", + "modelID": model_override or "opus"}, + "summary": {"diffs": []}, + }, ensure_ascii=False))) + part_rows.append((make_id("prt"), user_msg_id, session_id, user_ts, user_ts, + json.dumps({"type": "text", "text": user_text}, ensure_ascii=False))) + + assist_msg_id = make_id("msg") + assist_start_ts = user_ts + 1 + end_ts = assist_start_ts + assist_parts = [{"type": "step-start", "snapshot": PLACEHOLDER_SNAPSHOT}] + tool_results = build_tool_results_map(turn["events"]) + tok = {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0} + model_seen = None + + for e in turn["events"]: + if e.get("message", {}).get("role") != "assistant": + continue + e_ts = parse_ts(e.get("timestamp")) + end_ts = max(end_ts, e_ts) + if model_seen is None: + model_seen = e.get("message", {}).get("model") + # Usage on each assistant event is cumulative for the turn + # (same input/cache fields repeat, output grows). Take the max of + # each field to get the final turn totals, mirroring what the + # plugin does with lastIterationUsage(). + usage = e.get("message", {}).get("usage") or {} + tok["input"] = max(tok["input"], int(usage.get("input_tokens") or 0)) + tok["output"] = max(tok["output"], int(usage.get("output_tokens") or 0)) + tok["cache_read"] = max(tok["cache_read"], int(usage.get("cache_read_input_tokens") or 0)) + tok["cache_write"] = max(tok["cache_write"], int(usage.get("cache_creation_input_tokens") or 0)) + content = e.get("message", {}).get("content", []) + if not isinstance(content, list): + continue + for c in content: + if not isinstance(c, dict): + continue + ct = c.get("type") + if ct == "thinking" and c.get("thinking"): + assist_parts.append({"type": "reasoning", "text": c["thinking"]}) + elif ct == "text" and c.get("text"): + assist_parts.append({"type": "text", "text": c["text"]}) + elif ct == "tool_use": + mapped = map_tool(c.get("name", ""), c.get("input") or {}) + if mapped["skip"]: + continue + call_id = c.get("id") or make_id("toolu") + res = tool_results.get(call_id, {}) + status = "error" if res.get("is_error") else "completed" + title_str = (mapped["name"][:1].upper() + mapped["name"][1:]) if mapped["name"] else "" + assist_parts.append({ + "type": "tool", + "tool": mapped["name"], + "callID": call_id, + "state": { + "status": status, + "input": mapped["input"] or {}, + "output": res.get("content", ""), + "metadata": {}, + "title": title_str, + "time": {"start": e_ts, "end": res.get("end_ts", e_ts)}, + }, + "metadata": {"providerExecuted": mapped["executed"]}, + }) + + total_tok = tok["input"] + tok["output"] + tok["cache_read"] + tok["cache_write"] + assist_parts.append({ + "type": "step-finish", "reason": "stop", "snapshot": PLACEHOLDER_SNAPSHOT, + "tokens": {"total": total_tok, "input": tok["input"], + "output": tok["output"], "reasoning": tok["reasoning"], + "cache": {"write": tok["cache_write"], "read": tok["cache_read"]}}, + "cost": 0, + }) + + model_id = model_override or normalize_model_id(model_seen) + msg_rows.append((assist_msg_id, session_id, assist_start_ts, end_ts, + json.dumps({ + "parentID": user_msg_id, "role": "assistant", + "mode": "build", "agent": "build", + "path": {"cwd": first_cwd, "root": first_cwd}, + "cost": 0, + "tokens": {"total": total_tok, "input": tok["input"], + "output": tok["output"], "reasoning": tok["reasoning"], + "cache": {"write": tok["cache_write"], "read": tok["cache_read"]}}, + "modelID": model_id, "providerID": "claude-code", + "time": {"created": assist_start_ts, "completed": end_ts}, + "finish": "stop", + }, ensure_ascii=False))) + + pt = assist_start_ts + for p in assist_parts: + pt += 1 + part_rows.append((make_id("prt"), assist_msg_id, session_id, pt, pt, + json.dumps(p, ensure_ascii=False))) + last_ts = max(last_ts, end_ts) + + slug = f"imported-{hashlib.md5(jsonl_path.name.encode()).hexdigest()[:8]}" + session_row = (session_id, project_id, None, slug, first_cwd, title, + OPENCODE_VERSION, None, 0, 0, 0, None, None, None, + first_ts, last_ts, None, None, None) + + db.execute("BEGIN") + try: + db.execute( + "INSERT INTO session (id, project_id, parent_id, slug, directory, " + "title, version, share_url, summary_additions, summary_deletions, " + "summary_files, summary_diffs, revert, permission, time_created, " + "time_updated, time_compacting, time_archived, workspace_id) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", session_row) + db.executemany( + "INSERT INTO message (id, session_id, time_created, time_updated, data) " + "VALUES (?,?,?,?,?)", msg_rows) + db.executemany( + "INSERT INTO part (id, message_id, session_id, time_created, " + "time_updated, data) VALUES (?,?,?,?,?,?)", part_rows) + db.execute("COMMIT") + except Exception as ex: + db.execute("ROLLBACK") + return None, f"db error: {ex}" + return session_id, None + + +# ---------- main ---------- + +HELP_TEXT = """ +Commands: + <keyword> search sessions (keyword in title/prompt/cwd) + <keyword> | 20 show only top 20 results + <N> import session at index N + <N> <M> <K> import multiple + N-M import range + all import everything currently shown + last <N> show top N latest without a keyword filter + clear show back the full list (no keyword) + help this help + q / exit quit +""" + + +def _perform_import(db, targets, project_id_override, model_override): + ok = fail = 0 + for idx, s in targets: + print(f"\nImporting [{idx}] {s['path'].name} ({s['title'][:60]})") + sid, err = import_one(db, s["path"], + project_id_override=project_id_override, + model_override=model_override) + if err: + print(f" SKIP: {err}") + fail += 1 + else: + print(f" OK: session {sid} -> {s['cwd']}") + ok += 1 + print(f"\nDone. imported={ok} skipped={fail}") + + +def _parse_indices(tokens, total): + picks = [] + for tok in tokens: + if not tok: + continue + if "-" in tok: + a, b = tok.split("-", 1) + picks.extend(range(int(a), int(b) + 1)) + else: + picks.append(int(tok)) + return [p for p in picks if 1 <= p <= total] + + +def interactive_loop(all_sessions, db, project_id_override, model_override, + initial_query=None, initial_limit=20): + """Read-eval loop: keyword to filter, number(s) to import.""" + current = all_sessions + limit = initial_limit + if initial_query: + current = filter_sessions(all_sessions, initial_query) + print(f"\nShowing top {limit} of {len(current)} session(s). " + f"Type 'help' for commands.\n") + print_sessions(current, limit=limit) + + while True: + try: + raw = input("\n> ").strip() + except (EOFError, KeyboardInterrupt): + print() + return + if not raw: + continue + low = raw.lower() + if low in ("q", "exit", "quit"): + return + if low == "help": + print(HELP_TEXT) + continue + if low == "clear": + current = all_sessions + limit = initial_limit + print(f"Cleared filter. {len(current)} sessions total.\n") + print_sessions(current, limit=limit) + continue + if low.startswith("last "): + try: + limit = int(low.split(None, 1)[1]) + except Exception: + print("usage: last <N>") + continue + print_sessions(current, limit=limit) + continue + if low == "all": + visible = current[:limit] + if not visible: + print("nothing to import") + continue + _perform_import(db, list(enumerate(visible, 1)), + project_id_override, model_override) + continue + # maybe indices: tokens that are all numeric or ranges + tokens = re.split(r"[\s,]+", raw) + if all(re.fullmatch(r"\d+(-\d+)?", t) for t in tokens if t): + visible = current[:limit] + picks = _parse_indices(tokens, len(visible)) + if picks: + _perform_import(db, + [(i, visible[i - 1]) for i in picks], + project_id_override, model_override) + continue + # otherwise treat as keyword filter (allow "keyword | N" to set limit) + if "|" in raw: + kw, lim = raw.rsplit("|", 1) + raw = kw.strip() + try: + limit = int(lim.strip()) + except Exception: + pass + current = filter_sessions(all_sessions, raw) if raw else all_sessions + print(f"\n{len(current)} match(es) for {raw!r}. Showing top {limit}.\n") + print_sessions(current, limit=limit) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("query", nargs="?", default=None, + help="Initial filter keyword (searched in title, first prompt, cwd)") + ap.add_argument("--dir", dest="dir_filter", default=None, + help="Pre-filter by cwd path substring") + ap.add_argument("--limit", type=int, default=20, + help="How many results to show per page (default: 20)") + ap.add_argument("--batch", action="store_true", + help="Non-interactive: print the matches, prompt once, exit") + ap.add_argument("--model", default=None, + help="Override modelID (opus|sonnet|haiku)") + ap.add_argument("--project-id", default=None, + help="Force a target project_id") + ap.add_argument("--db", type=Path, default=OPENCODE_DB) + args = ap.parse_args() + + if not args.db.exists(): + raise SystemExit(f"OpenCode DB not found: {args.db}") + + print(f"Indexing {CLAUDE_PROJECTS}...") + sessions = collect_sessions(dir_filter=args.dir_filter) + # order by last user activity (newest first); fallback to mtime + sessions.sort(key=lambda s: s.get("last_user_ts") or int(s["mtime"] * 1000), + reverse=True) + if not sessions: + print("No sessions found with given filters.") + return + print(f"Indexed {len(sessions)} session(s).") + + db = sqlite3.connect(str(args.db)) + db.execute("PRAGMA foreign_keys = ON") + try: + if args.batch: + current = filter_sessions(sessions, args.query) + current = current[:args.limit] + if not current: + print("No matches.") + return + print_sessions(current) + picks = prompt_selection(len(current)) + if not picks: + print("No selection, exiting.") + return + _perform_import(db, + [(i, current[i - 1]) for i in picks], + args.project_id, args.model) + else: + interactive_loop(sessions, db, args.project_id, args.model, + initial_query=args.query, + initial_limit=args.limit) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/src/claude-code-language-model.ts b/src/claude-code-language-model.ts index cc65276..45a02a0 100644 --- a/src/claude-code-language-model.ts +++ b/src/claude-code-language-model.ts @@ -1,11 +1,41 @@ -import type { - LanguageModelV2, - LanguageModelV2CallWarning, - LanguageModelV2Content, - LanguageModelV2FinishReason, - LanguageModelV2StreamPart, - LanguageModelV2Usage, -} from "@ai-sdk/provider" +import type { LanguageModelV2 } from "@ai-sdk/provider" + +// V3 usage helper +function v3Usage(input?: number, output?: number, cacheRead?: number, cacheWrite?: number, reasoning?: number) { + // CLI returns input_tokens as non-cached only. Total must include all: input + cacheRead + cacheWrite + const totalInput = (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0) + return { + inputTokens: { + total: totalInput, + noCache: input ?? 0, + cacheRead: cacheRead ?? undefined as number | undefined, + cacheWrite: cacheWrite ?? undefined as number | undefined, + }, + outputTokens: { + total: output ?? 0, + text: undefined as number | undefined, + reasoning: reasoning ?? undefined as number | undefined, + }, + } +} + +function v3FinishReason(reason: string) { + return { unified: reason, raw: undefined as string | undefined } +} + +// CLI result.usage is cumulative across all internal iterations. +// OpenCode needs the last iteration's tokens to represent the current context size. +function lastIterationUsage(usage?: ClaudeStreamMessage["usage"]) { + if (!usage) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + const iters = usage.iterations + const last = iters && iters.length > 0 ? iters[iters.length - 1] : usage + return { + input: last.input_tokens ?? 0, + output: last.output_tokens ?? 0, + cacheRead: last.cache_read_input_tokens ?? 0, + cacheWrite: last.cache_creation_input_tokens ?? 0, + } +} import { generateId } from "@ai-sdk/provider-utils" import type { ClaudeCodeConfig, ClaudeStreamMessage } from "./types.js" import { mapTool } from "./tool-mapping.js" @@ -22,8 +52,8 @@ import { } from "./session-manager.js" import { log } from "./logger.js" -export class ClaudeCodeLanguageModel implements LanguageModelV2 { - readonly specificationVersion = "v2" +export class ClaudeCodeLanguageModel { + readonly specificationVersion = "v3" readonly modelId: string private readonly config: ClaudeCodeConfig @@ -42,6 +72,21 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { return Array.isArray(options?.tools) ? "tools" : "no-tools" } + private extractEffort(options: any): string | undefined { + // Read effort from providerOptions (keyed by provider name) or top-level options + const po = options?.providerOptions + if (po) { + // Try provider-specific key (e.g., "claude-code") and common keys + for (const key of [this.config.provider, "claude-code", "anthropic"]) { + const val = po[key] + if (val?.effort) return String(val.effort) + } + } + // Also check top-level effort (from merged variant options) + if (options?.effort) return String(options.effort) + return undefined + } + private latestUserText( prompt: Parameters<LanguageModelV2["doGenerate"]>[0]["prompt"], ): string { @@ -132,8 +177,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { async doGenerate( options: Parameters<LanguageModelV2["doGenerate"]>[0], - ): Promise<Awaited<ReturnType<LanguageModelV2["doGenerate"]>>> { - const warnings: LanguageModelV2CallWarning[] = [] + ): Promise<any> { + const warnings: any[] = [] const cwd = this.config.cwd ?? process.cwd() const scope = this.requestScope(options as any) const sk = sessionKey(cwd, `${this.modelId}::${scope}`) @@ -142,12 +187,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const text = this.synthesizeTitle(options.prompt) return { content: [{ type: "text", text }] as any, - finishReason: "stop", - usage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }, + finishReason: v3FinishReason("stop"), + usage: v3Usage(0, 0), request: { body: { text: "" } }, response: { id: generateId(), @@ -179,12 +220,30 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const userMsg = getClaudeUserMessage(options.prompt, includeHistoryContext) + // Empty message means no user content to send — return empty result + if (!userMsg) { + return { + content: [] as any, + finishReason: v3FinishReason("stop"), + usage: v3Usage(0, 0), + request: { body: { text: "" } }, + response: { + id: generateId(), + timestamp: new Date(), + modelId: this.modelId, + }, + warnings, + } + } + // doGenerate always spawns a fresh process, never reuse session ID + const effort = this.extractEffort(options) const cliArgs = buildCliArgs({ sessionKey: sk, skipPermissions: this.config.skipPermissions !== false, includeSessionId: false, model: this.modelId, + effort, }) log.info("doGenerate starting", { @@ -356,7 +415,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { proc.stdin?.write(userMsg + "\n") }) - const content: LanguageModelV2Content[] = [] + const content: any[] = [] if (result.thinking) { content.push({ @@ -396,20 +455,17 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } as any) } - const usage: LanguageModelV2Usage = { - inputTokens: result.usage?.input_tokens, - outputTokens: result.usage?.output_tokens, - totalTokens: - result.usage?.input_tokens && result.usage?.output_tokens - ? result.usage.input_tokens + result.usage.output_tokens - : undefined, - } + const lastIter = lastIterationUsage(result.usage) + const usage = v3Usage( + lastIter.input, + lastIter.output, + lastIter.cacheRead, + lastIter.cacheWrite, + ) return { content, - finishReason: (result.toolCalls.length > 0 - ? "tool-calls" - : "stop") as LanguageModelV2FinishReason, + finishReason: v3FinishReason("stop"), usage, request: { body: { text: userMsg } }, response: { @@ -430,8 +486,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { async doStream( options: Parameters<LanguageModelV2["doStream"]>[0], - ): Promise<Awaited<ReturnType<LanguageModelV2["doStream"]>>> { - const warnings: LanguageModelV2CallWarning[] = [] + ): Promise<any> { + const warnings: any[] = [] const cwd = this.config.cwd ?? process.cwd() const cliPath = this.config.cliPath const skipPermissions = this.config.skipPermissions !== false @@ -441,7 +497,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { if (scope === "no-tools") { const text = this.synthesizeTitle(options.prompt) const textId = generateId() - const stream = new ReadableStream<LanguageModelV2StreamPart>({ + const stream = new ReadableStream<any>({ start(controller) { controller.enqueue({ type: "stream-start", warnings }) controller.enqueue({ type: "text-start", id: textId } as any) @@ -453,19 +509,15 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { controller.enqueue({ type: "text-end", id: textId }) controller.enqueue({ type: "finish", - finishReason: "stop", - usage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - }, + finishReason: v3FinishReason("stop"), + usage: v3Usage(0, 0), providerMetadata: { "claude-code": { synthetic: true, path: "no-tools", }, }, - }) + } as any) controller.close() }, }) @@ -493,9 +545,31 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const userMsg = getClaudeUserMessage(options.prompt, includeHistoryContext) + // Empty message means no user content to send — return empty stream + if (!userMsg) { + const emptyTextId = generateId() + const emptyStream = new ReadableStream<any>({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings }) + controller.enqueue({ type: "text-start", id: emptyTextId } as any) + controller.enqueue({ type: "text-end", id: emptyTextId } as any) + controller.enqueue({ + type: "finish", + finishReason: v3FinishReason("stop"), + usage: v3Usage(0, 0), + providerMetadata: { "claude-code": { empty: true } }, + } as any) + controller.close() + }, + }) + return { stream: emptyStream, request: { body: { text: "" } } } + } + + const effort = this.extractEffort(options) log.info("doStream starting", { cwd, model: this.modelId, + effort, textLength: userMsg.length, includeHistoryContext, hasActiveProcess, @@ -505,9 +579,10 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { sessionKey: sk, skipPermissions, model: this.modelId, + effort, }) - const stream = new ReadableStream<LanguageModelV2StreamPart>({ + const stream = new ReadableStream<any>({ start(controller) { let activeProcess = getActiveProcess(sk) let proc: import("child_process").ChildProcess @@ -533,6 +608,37 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { let turnCompleted = false let controllerClosed = false + let resultFallbackTimer: ReturnType<typeof setTimeout> | undefined + + // If the CLI emits a final assistant text but never sends `result` + // (e.g. process stays alive for reuse), close the stream after 5s. + function resetResultFallback() { + if (resultFallbackTimer) clearTimeout(resultFallbackTimer) + resultFallbackTimer = undefined + } + function startResultFallback() { + resetResultFallback() + resultFallbackTimer = setTimeout(() => { + if (controllerClosed || turnCompleted) return + log.info("result fallback timer fired, closing stream with accumulated content") + controllerClosed = true + turnCompleted = true + lineEmitter.off("line", lineHandler) + lineEmitter.off("close", closeHandler) + for (const [idx, reasoningId] of reasoningIds) { + if (reasoningStarted.get(idx)) { + controller.enqueue({ type: "reasoning-end", id: reasoningId } as any) + } + } + controller.enqueue({ + type: "finish", + finishReason: v3FinishReason("stop"), + usage: v3Usage(0, 0), + providerMetadata: { "claude-code": resultMeta }, + } as any) + try { controller.close() } catch {} + }, 5000) + } const toolCallMap = new Map< number, @@ -613,12 +719,13 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { block.name !== "ask_user_question" && block.name !== "ExitPlanMode" ) { - const { name: mappedName, skip } = mapTool(block.name) + const { name: mappedName, skip, executed } = mapTool(block.name) if (!skip) { controller.enqueue({ type: "tool-input-start", id: block.id, toolName: mappedName, + providerExecuted: executed, } as any) log.info("tool started", { name: block.name, @@ -786,20 +893,34 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { // assistant message (complete, not streaming) if (msg.type === "assistant" && msg.message?.content) { + // Check what block types this message contains + const hasText = msg.message.content.some((b: any) => b.type === "text" && b.text) + const hasToolUse = msg.message.content.some((b: any) => b.type === "tool_use") + const blockTypes = msg.message.content.map((b: any) => b.type).join(",") + log.info("assistant message", { blockTypes, hasText, hasToolUse, controllerClosed }) + + // Text without tool_use = likely final response, start fallback timer + if (hasText && !hasToolUse) { + startResultFallback() + } + // Tool use = CLI still working, cancel any pending timer + if (hasToolUse) { + resetResultFallback() + } + for (const block of msg.message.content) { if (block.type === "text" && block.text) { - if (!textStarted) { - controller.enqueue({ - type: "text-start", - id: textId, - } as any) - textStarted = true - } + // Emit a complete text-start/delta/end cycle for each text block + // so OpenCode saves each block immediately as a separate part. + const blockTextId = generateId() + controller.enqueue({ type: "text-start", id: blockTextId } as any) controller.enqueue({ type: "text-delta", - id: textId, + id: blockTextId, delta: block.text, }) + controller.enqueue({ type: "text-end", id: blockTextId } as any) + textStarted = true } if (block.type === "thinking" && block.thinking) { @@ -890,6 +1011,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { type: "tool-input-start", id: block.id, toolName: mappedName, + providerExecuted: executed, } as any) controller.enqueue({ type: "tool-call", @@ -961,6 +1083,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { // result - end of conversation turn if (msg.type === "result") { + resetResultFallback() if (msg.session_id) { setClaudeSessionId(sk, msg.session_id) } @@ -980,9 +1103,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { turnCompleted = true - if (textStarted) { - controller.enqueue({ type: "text-end", id: textId }) - } + // text-end is now emitted per-block, no need for a final one here for (const [idx, reasoningId] of reasoningIds) { if (reasoningStarted.get(idx)) { @@ -993,24 +1114,20 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } } + const lastIter = lastIterationUsage(msg.usage) controller.enqueue({ type: "finish", - finishReason: - toolCallMap.size > 0 ? "tool-calls" : "stop", - usage: { - inputTokens: msg.usage?.input_tokens, - outputTokens: msg.usage?.output_tokens, - totalTokens: - msg.usage?.input_tokens && - msg.usage?.output_tokens - ? msg.usage.input_tokens + - msg.usage.output_tokens - : undefined, - }, + finishReason: v3FinishReason("stop"), + usage: v3Usage( + lastIter.input, + lastIter.output, + lastIter.cacheRead, + lastIter.cacheWrite, + ), providerMetadata: { "claude-code": resultMeta, }, - }) + } as any) controllerClosed = true lineEmitter.off("line", lineHandler) @@ -1030,25 +1147,19 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const closeHandler = () => { log.debug("readline closed") + resetResultFallback() if (controllerClosed) return controllerClosed = true lineEmitter.off("line", lineHandler) lineEmitter.off("close", closeHandler) - if (textStarted) { - controller.enqueue({ type: "text-end", id: textId }) - } controller.enqueue({ type: "finish", - finishReason: "stop", - usage: { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - }, + finishReason: v3FinishReason("stop"), + usage: v3Usage(0, 0), providerMetadata: { "claude-code": resultMeta, }, - }) + } as any) try { controller.close() } catch {} @@ -1067,23 +1178,13 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } catch {} }) - // On abort, keep process alive for next message + // On abort, don't close immediately — give the CLI a few seconds + // to emit the final text and result before we give up. if (options.abortSignal) { options.abortSignal.addEventListener("abort", () => { - if (!turnCompleted) { - log.info( - "abort signal received mid-turn, keeping process alive", - { cwd }, - ) - } - if (!controllerClosed) { - controllerClosed = true - lineEmitter.off("line", lineHandler) - lineEmitter.off("close", closeHandler) - try { - controller.close() - } catch {} - } + if (turnCompleted || controllerClosed) return + log.info("abort signal received mid-turn, starting grace period", { cwd }) + startResultFallback() }) } @@ -1092,7 +1193,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { log.debug("sent user message", { textLength: userMsg.length }) }, cancel() { - // Consumer cancelled the stream + log.info("stream cancelled by consumer") }, }) diff --git a/src/index.ts b/src/index.ts index 8e74f47..09102d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,13 +15,15 @@ export function createClaudeCode( const cwd = settings.cwd ?? process.cwd() const providerName = settings.name ?? "claude-code" + // Class uses specificationVersion "v3" at runtime but implements V2 interface for type compat. + // AI SDK detects V3 at runtime and skips the V2->V3 conversion layer. const createModel = (modelId: string): LanguageModelV2 => { return new ClaudeCodeLanguageModel(modelId, { provider: providerName, cliPath, cwd, skipPermissions: settings.skipPermissions ?? true, - }) + }) as unknown as LanguageModelV2 } const provider = function (modelId: string) { diff --git a/src/message-builder.ts b/src/message-builder.ts index aaae2f0..f1901be 100644 --- a/src/message-builder.ts +++ b/src/message-builder.ts @@ -60,6 +60,73 @@ export function compactConversationHistory(prompt: Prompt): string | null { return historyParts.join("\n\n") } +const SUPPORTED_IMAGE_MEDIA_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]) + +function buildImageBlock(part: any): any | null { + const mediaType: string | undefined = + part.mediaType ?? part.mimeType ?? part.mime + + const raw: unknown = part.data ?? part.url ?? part.source?.data + + if (!raw) { + log.warn("file part without data/url, skipping", { + type: part.type, + filename: part.filename, + }) + return null + } + + let resolvedMediaType = mediaType + let base64: string | null = null + + if (typeof raw === "string") { + if (raw.startsWith("data:")) { + const match = /^data:([^;,]+)(?:;[^,]*)?(?:;base64)?,(.*)$/s.exec(raw) + if (!match) { + log.warn("malformed data URI, skipping file part") + return null + } + resolvedMediaType = resolvedMediaType || match[1] + base64 = match[2] + } else if (/^https?:\/\//i.test(raw)) { + log.warn("remote URL file parts are not supported by Claude CLI, skipping", { + url: raw.slice(0, 80), + }) + return null + } else { + base64 = raw + } + } else if (raw instanceof Uint8Array) { + base64 = Buffer.from(raw).toString("base64") + } else { + log.warn("unsupported file part data type", { + dataType: typeof raw, + }) + return null + } + + if (!resolvedMediaType || !SUPPORTED_IMAGE_MEDIA_TYPES.has(resolvedMediaType)) { + log.warn("unsupported media type for Claude image block, skipping", { + mediaType: resolvedMediaType, + }) + return null + } + + return { + type: "image", + source: { + type: "base64", + media_type: resolvedMediaType, + data: base64, + }, + } +} + /** * Convert AI SDK prompt into a Claude CLI stream-json user message. */ @@ -104,8 +171,11 @@ Now continuing with the current message: content.push({ type: "text", text: msg.content }) } else if (Array.isArray(msg.content)) { for (const part of msg.content as any[]) { - if (part.type === "text") { + if (part.type === "text" && part.text) { content.push({ type: "text", text: part.text }) + } else if (part.type === "file" || part.type === "image") { + const block = buildImageBlock(part) + if (block) content.push(block) } else if (part.type === "tool-result") { const p = part as any let resultText = "" @@ -132,13 +202,7 @@ Now continuing with the current message: } if (content.length === 0) { - return JSON.stringify({ - type: "user", - message: { - role: "user", - content: [{ type: "text", text: "" }], - }, - }) + return "" } return JSON.stringify({ diff --git a/src/session-manager.ts b/src/session-manager.ts index cbf0be0..23decea 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -107,8 +107,9 @@ export function buildCliArgs(opts: { skipPermissions: boolean includeSessionId?: boolean model?: string + effort?: string }): string[] { - const { sessionKey, skipPermissions, includeSessionId = true, model } = opts + const { sessionKey, skipPermissions, includeSessionId = true, model, effort } = opts const args = [ "--output-format", "stream-json", @@ -121,6 +122,10 @@ export function buildCliArgs(opts: { args.push("--model", model) } + if (effort) { + args.push("--effort", effort) + } + if (includeSessionId) { const sessionId = claudeSessions.get(sessionKey) if (sessionId && !activeProcesses.has(sessionKey)) { diff --git a/src/types.ts b/src/types.ts index 89ab498..0756ef9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,12 @@ export interface ClaudeStreamMessage { output_tokens?: number cache_read_input_tokens?: number cache_creation_input_tokens?: number + iterations?: Array<{ + input_tokens?: number + output_tokens?: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + }> } content_block?: {