diff --git a/consult b/consult new file mode 100755 index 0000000..4ad80a6 --- /dev/null +++ b/consult @@ -0,0 +1,1268 @@ +#!/usr/bin/env python3 +"""consult — Query external AI models via OpenRouter. + +Usage: + consult "question" Ask default model + consult -m google/gemini-2.5-pro "q" Ask specific model + consult -f file.py "review this code" Include file as context + cat log.txt | consult "analyze errors" Pipe stdin as context + consult models List enabled models + consult debate "problem statement" Multi-model adversarial debate +""" + +import argparse +import json +import os +import re +import sys +import threading +import time +import urllib.error +import urllib.request + +CONFIG_DIR = os.path.expanduser("~/.config/bterminal") +CONFIG_FILE = os.path.join(CONFIG_DIR, "consult.json") + +OPENROUTER_API = "https://openrouter.ai/api/v1/chat/completions" +OPENROUTER_MODELS_API = "https://openrouter.ai/api/v1/models" + +CLAUDE_CODE_MODELS = { + "claude-code/opus": {"name": "Claude Opus 4.6", "alias": "opus", "source": "claude-code"}, + "claude-code/sonnet": {"name": "Claude Sonnet 4.6", "alias": "sonnet", "source": "claude-code"}, + "claude-code/haiku": {"name": "Claude Haiku 4.5", "alias": "haiku", "source": "claude-code"}, +} + +DEFAULT_CONFIG = { + "api_key": "", + "default_model": "google/gemini-2.5-pro", + "models": { + "google/gemini-2.5-pro": {"enabled": True, "name": "Gemini 2.5 Pro", "source": "openrouter"}, + "openai/gpt-4o": {"enabled": True, "name": "GPT-4o", "source": "openrouter"}, + "openai/o3-mini": {"enabled": True, "name": "o3-mini", "source": "openrouter"}, + "deepseek/deepseek-r1": {"enabled": True, "name": "DeepSeek R1", "source": "openrouter"}, + "anthropic/claude-sonnet-4": {"enabled": False, "name": "Claude Sonnet 4", "source": "openrouter"}, + "meta-llama/llama-4-maverick": {"enabled": False, "name": "Llama 4 Maverick", "source": "openrouter"}, + }, + "tribunal": { + "analyst_model": "", + "advocate_model": "", + "critic_model": "", + "arbiter_model": "", + "max_rounds": 3, + }, +} + + +def load_config(): + if os.path.isfile(CONFIG_FILE): + try: + with open(CONFIG_FILE) as f: + cfg = json.load(f) + # Ensure Claude Code models exist + models = cfg.setdefault("models", {}) + changed = False + for mid, info in CLAUDE_CODE_MODELS.items(): + if mid not in models: + models[mid] = dict(info, enabled=False) + changed = True + if changed: + with open(CONFIG_FILE, "w") as f: + json.dump(cfg, f, indent=2) + return cfg + except (json.JSONDecodeError, IOError): + pass + os.makedirs(CONFIG_DIR, exist_ok=True) + cfg = DEFAULT_CONFIG.copy() + with open(CONFIG_FILE, "w") as f: + json.dump(cfg, f, indent=2) + return cfg + + +def get_api_key(): + cfg = load_config() + key = cfg.get("api_key", "") + if not key: + print( + "Error: No API key. Set it in BTerminal Consult tab " + "or edit ~/.config/bterminal/consult.json", + file=sys.stderr, + ) + sys.exit(1) + return key + + +# ── OpenRouter API call (non-streaming, JSON mode) ────────────────────── + + +def call_openrouter(api_key, model, system_prompt, user_prompt): + """Single non-streaming call. Returns (text, usage_dict).""" + payload = json.dumps({ + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "max_tokens": 16384, + "temperature": 0.7, + }).encode() + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/DexterFromLab/BTerminal", + "X-Title": "BTerminal Consult Tribunal", + } + + req = urllib.request.Request(OPENROUTER_API, data=payload, headers=headers) + resp = urllib.request.urlopen(req, timeout=300) + data = json.loads(resp.read().decode()) + + if "error" in data: + raise RuntimeError(data["error"].get("message", str(data["error"]))) + + text = data.get("choices", [{}])[0].get("message", {}).get("content", "") + usage = data.get("usage", {}) + return text, usage + + +# ── Claude Code API call ──────────────────────────────────────────────── + + +def is_claude_code_model(model_id): + """Check if model ID refers to a Claude Code local model.""" + return model_id.startswith("claude-code/") + + +def get_claude_alias(model_id): + """Get Claude CLI alias from model ID (e.g. 'claude-code/opus' -> 'opus').""" + info = CLAUDE_CODE_MODELS.get(model_id) + if info: + return info["alias"] + return model_id.split("/", 1)[-1] + + +def call_claude_code(model_id, system_prompt, user_prompt): + """Call Claude Code CLI. Returns (text, usage_dict).""" + import subprocess + + alias = get_claude_alias(model_id) + full_prompt = f"{system_prompt}\n\n{user_prompt}" if system_prompt else user_prompt + + env = os.environ.copy() + env.pop("CLAUDECODE", None) + + try: + result = subprocess.run( + ["claude", "-p", "--model", alias, full_prompt], + capture_output=True, text=True, timeout=600, env=env, + ) + except subprocess.TimeoutExpired: + raise RuntimeError(f"Claude Code timeout (10min) — prompt too large or model too slow") + + if result.returncode != 0: + raise RuntimeError(f"Claude Code error: {result.stderr.strip()}") + + text = result.stdout.strip() + # Claude Code CLI doesn't report token usage, estimate roughly + usage = { + "prompt_tokens": len(full_prompt) // 4, + "completion_tokens": len(text) // 4, + } + return text, usage + + +def call_model(api_key, model, system_prompt, user_prompt): + """Dispatch to the right API based on model source.""" + if is_claude_code_model(model): + return call_claude_code(model, system_prompt, user_prompt) + return call_openrouter(api_key, model, system_prompt, user_prompt) + + +# ── JSON extraction ───────────────────────────────────────────────────── + + +def extract_json(text): + """Extract JSON object from LLM response (handles fences, preamble).""" + # Direct parse + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Code fence + m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) + if m: + try: + return json.loads(m.group(1).strip()) + except json.JSONDecodeError: + pass + + # Balanced brace extraction + start = text.find("{") + if start == -1: + return None + depth = 0 + in_str = False + esc = False + for i in range(start, len(text)): + ch = text[i] + if esc: + esc = False + continue + if ch == "\\" and in_str: + esc = True + continue + if ch == '"': + in_str = not in_str + continue + if in_str: + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(text[start:i + 1]) + except json.JSONDecodeError: + return None + return None + + +def coerce(data): + """Coerce LLM output quirks (string numbers, lowercase enums, etc.).""" + if not isinstance(data, dict): + return data + + for k in ("confidence", "confidenceChange"): + if k in data and isinstance(data[k], str): + try: + data[k] = float(data[k]) + except ValueError: + data[k] = 0.5 + + for k in ("severity", "likelihood", "impact", "category", "verdict"): + if k in data and isinstance(data[k], str): + data[k] = data[k].upper().replace(" ", "_") + + for k in ("dimensions", "constraints", "criteria", "tradeoffs", + "assumptions", "unresolved", "actionItems"): + if k in data and isinstance(data[k], list): + data[k] = [ + (x if isinstance(x, str) + else x.get("name", x.get("label", x.get("title", + x.get("description", json.dumps(x)))))) + if isinstance(x, dict) else str(x) + for x in data[k] + ] + + for k in ("solutions", "objections", "defenses", "riskMatrix", "revisedSolutions"): + if k in data and isinstance(data[k], list): + data[k] = [coerce(x) if isinstance(x, dict) else x for x in data[k]] + + return data + + +# ── Pricing ───────────────────────────────────────────────────────────── + + +_pricing_cache = None + + +def fetch_pricing(api_key): + global _pricing_cache + if _pricing_cache is not None: + return _pricing_cache + + try: + req = urllib.request.Request( + OPENROUTER_MODELS_API, + headers={"Authorization": f"Bearer {api_key}"}, + ) + resp = urllib.request.urlopen(req, timeout=15) + data = json.loads(resp.read().decode()) + pricing = {} + for m in data.get("data", []): + p = m.get("pricing", {}) + if p.get("prompt") and p.get("completion"): + pricing[m["id"]] = { + "input": float(p["prompt"]) * 1_000_000, + "output": float(p["completion"]) * 1_000_000, + } + _pricing_cache = pricing + return pricing + except Exception: + _pricing_cache = {} + return {} + + +def calc_cost(pricing, model, usage): + p = pricing.get(model, {"input": 3, "output": 15}) + inp = usage.get("prompt_tokens", 0) + out = usage.get("completion_tokens", 0) + cost = (inp * p["input"] + out * p["output"]) / 1_000_000 + return inp, out, cost + + +# ── Agent prompts ─────────────────────────────────────────────────────── + +SYSTEM_PROMPTS = { + "analyst": """You are the Analyst — the first mind to touch the problem. Your job is to decompose the problem into a structured frame that downstream agents (Advocates, Critics, Arbiter) can work from. + +Your output is the shared foundation. If you miss a dimension, the entire debate will have a blind spot. + +1. Restate the problem in precise, unambiguous language. +2. Identify dimensions — the axes along which solutions will vary. +3. Surface constraints — hard limits stated or implied. +4. Define success criteria — what "solved" looks like. +5. Provide context — relevant background. +6. Suggest perspectives — 2-3 distinct lenses for solution generation. + +IMPORTANT: Respond with valid JSON only. No markdown code fences, no explanation outside the JSON.""", + + "advocate": """You are the Advocate — a solution generator and defender. + +Mode 1 (Generation): Produce 2-3 distinct approaches from different perspectives. Each solution must be genuinely different. Include: title, approach (specific enough to implement), tradeoffs (genuine downsides), assumptions (falsifiable), confidence (0-1), perspective. + +Mode 2 (Defense): For each objection, respond with: +- DEFENDED: objection is wrong, provide counter-argument +- CONCEDED: objection is valid, describe how solution changes +- PARTIALLY_CONCEDED: has merit but doesn't invalidate core approach + +Do NOT defend everything reflexively. Intellectual honesty makes the output more useful. + +IMPORTANT: Respond with valid JSON only. No markdown code fences, no explanation outside the JSON.""", + + "critic": """You are the Critic — an adversarial evaluator who stress-tests solutions. Find real flaws, not contrarian noise. + +1. Identify genuine weaknesses that would cause real problems +2. Challenge hidden assumptions +3. Find edge cases where the solution breaks +4. Assess feasibility given constraints +5. Flag missing considerations + +Categories: FEASIBILITY, SCALABILITY, COST, COMPLEXITY, SECURITY, MAINTAINABILITY, PERFORMANCE, UX, CORRECTNESS, MISSING_REQUIREMENT +Severity: CRITICAL, HIGH, MEDIUM, LOW + +Every objection must have a concrete argument and evidence. Suggest mitigations where possible. + +IMPORTANT: Respond with valid JSON only. No markdown code fences, no explanation outside the JSON.""", + + "arbiter": """You are the Arbiter — the final decision-maker who synthesizes an adversarial debate into a clear, actionable recommendation. + +1. Weigh all solutions, objections, and defenses +2. Assess what survived adversarial scrutiny +3. Evaluate whether concessions fatally weakened solutions or led to improvements +4. Identify the strongest path given success criteria and constraints +5. Name unresolved questions +6. Provide concrete, executable action items + +Confidence: 0.9+ clear winner, 0.7-0.9 good with minor concerns, 0.5-0.7 significant uncertainty, <0.5 no clear winner. + +IMPORTANT: Respond with valid JSON only. No markdown code fences, no explanation outside the JSON.""", +} + + +# ── User prompts ──────────────────────────────────────────────────────── + + +def analyst_prompt(problem): + return f"""Analyze the following problem and produce a structured problem frame. + +Problem: {problem} + +Produce a JSON object with: +- problem: {{ statement, dimensions (array of strings), constraints (array of strings), criteria (array of strings), context (string) }} +- suggestedPerspectives: string[] (2-3 distinct perspectives)""" + + +def advocate_generate_prompt(problem, problem_frame, perspectives): + ctx = json.dumps({"problemFrame": problem_frame, "perspectives": perspectives}, indent=2) + return f"""Generate solutions for the following problem. + +Problem: {problem} + +Context: +{ctx} + +Generate 2-3 distinct solutions from different perspectives. Each solution must have clear tradeoffs and explicit assumptions. + +Produce a JSON object with: +- solutions: [{{ id (e.g. "S-1"), title, approach (detailed), tradeoffs (array), assumptions (array), confidence (0-1), perspective }}]""" + + +def advocate_defend_prompt(problem, problem_frame, solutions, objections): + ctx = json.dumps({ + "problemFrame": problem_frame, "solutions": solutions, + "defenseMode": True, "objections": objections, + }, indent=2) + return f"""Defend solutions against objections. + +Problem: {problem} + +Context: +{ctx} + +For each objection respond with DEFENDED, CONCEDED, or PARTIALLY_CONCEDED. + +Produce a JSON object with: +- defenses: [{{ objectionId, verdict ("DEFENDED"|"CONCEDED"|"PARTIALLY_CONCEDED"), counterArgument, modification (string|null), confidenceChange (number -1 to +1) }}] +- revisedSolutions: (optional) updated solutions incorporating concessions""" + + +def critic_prompt(problem, problem_frame, solutions, targeted, prev_objections=None, prev_defenses=None): + ctx = {"problemFrame": problem_frame, "solutions": solutions, "targeted": targeted} + if prev_objections: + ctx["previousObjections"] = [ + {"id": o.get("id", "?"), "targetSolutionId": o.get("targetSolutionId", "?"), + "category": o.get("category", "?"), "argument": (o.get("argument") or "")[:200]} + for o in prev_objections + ] + if prev_defenses: + ctx["previousDefenses"] = [ + {"objectionId": d.get("objectionId", "?"), "verdict": d.get("verdict", "?"), + "counterArgument": (d.get("counterArgument") or "")[:200]} + for d in prev_defenses + ] + ctx_str = json.dumps(ctx, indent=2) + focus = ("Focus ONLY on aspects not already covered by previous objections." + if targeted else "This is a FULL critique. Evaluate every solution thoroughly.") + return f"""Critically evaluate the following solutions. + +Problem: {problem} + +Context: +{ctx_str} + +{focus} + +Produce a JSON object with: +- objections: [{{ id (e.g. "OBJ-1"), targetSolutionId, category, severity, argument, evidence (string|null), suggestedMitigation (string|null) }}] +- overallAssessment: string""" + + +def arbiter_prompt(problem, problem_frame, solutions, all_objections, all_defenses, rounds_summary, converged, convergence_round): + ctx = json.dumps({ + "problemFrame": problem_frame, "solutions": solutions, + "allObjections": all_objections, "allDefenses": all_defenses, + "rounds": rounds_summary, "converged": converged, + "convergenceRound": convergence_round, + }, indent=2) + return f"""Synthesize the full debate and produce a final ruling. + +Problem: {problem} + +Context: +{ctx} + +Produce a JSON object with: +- recommendation: string (specific and actionable) +- confidence: number (0-1) +- rationale: string (reference specific debate points) +- dissent: string|null +- unresolved: string[] +- actionItems: string[] +- riskMatrix: [{{ risk, likelihood, impact, mitigation }}]""" + + +# ── Convergence ───────────────────────────────────────────────────────── + + +def extract_delta(new_objections, all_objections): + existing_ids = {o["id"] for o in all_objections} + existing_args = {(o.get("argument") or "").lower()[:80] for o in all_objections} + return [ + o for o in new_objections + if o["id"] not in existing_ids + and (o.get("argument") or "").lower()[:80] not in existing_args + ] + + +# ── Report generation ─────────────────────────────────────────────────── + + +def severity_badge(s): + return {"CRITICAL": "[C]", "HIGH": "[H]", "MEDIUM": "[M]", "LOW": "[L]"}.get(s, f"[{s}]") + + +def confidence_bar(c): + filled = round(c * 10) + return "\u2588" * filled + "\u2591" * (10 - filled) + f" {c*100:.0f}%" + + +def generate_report(problem, problem_frame, solutions, rounds, all_objections, + all_defenses, ruling, converged, convergence_round, + total_rounds, duration, cost_data, agent_models): + lines = ["# Tribunal Report", "", f"**Date:** {time.strftime('%Y-%m-%d')}", ""] + + # Models used + lines.append("## Models") + lines.append("") + lines.append(f"| Role | Model |") + lines.append(f"|------|-------|") + for role in ("analyst", "advocate", "critic", "arbiter"): + lines.append(f"| {role.title()} | `{agent_models.get(role, '?')}` |") + lines.append("") + + # Problem + lines.append("## Problem") + lines.append("") + lines.append(problem_frame.get("statement", problem)) + dims = problem_frame.get("dimensions", []) + if dims: + lines.append(f"\n**Dimensions:** {', '.join(dims)}") + lines.append("") + + # Stats + lines.append("## Debate Statistics") + lines.append("") + lines.append(f"- **Solutions proposed:** {len(solutions)}") + lines.append(f"- **Total objections:** {len(all_objections)}") + lines.append(f"- **Total defenses:** {len(all_defenses)}") + lines.append(f"- **Rounds:** {total_rounds}") + conv_str = f"yes (round {convergence_round})" if converged else "no" + lines.append(f"- **Converged:** {conv_str}") + lines.append(f"- **Duration:** {duration:.1f}s") + if cost_data: + lines.append(f"- **Estimated cost:** ${cost_data['total_cost']:.2f}") + lines.append("") + + # Solutions + lines.append("## Solutions") + lines.append("") + for s in solutions: + lines.append(f"### {s.get('id', '?')}: {s.get('title', 'Untitled')}") + lines.append("") + lines.append(f"**Perspective:** {s.get('perspective', '?')} | " + f"**Confidence:** {confidence_bar(s.get('confidence', 0))}") + lines.append("") + lines.append(f"**Approach:** {s.get('approach', '?')}") + lines.append("") + tradeoffs = s.get("tradeoffs", []) + if tradeoffs: + lines.append("**Tradeoffs:**") + for t in tradeoffs: + lines.append(f"- {t}") + lines.append("") + lines.append("---") + lines.append("") + + # Objections table + if all_objections: + lines.append("## Objections") + lines.append("") + lines.append("| ID | Target | Category | Severity | Argument |") + lines.append("|----|--------|----------|----------|----------|") + for o in all_objections: + arg = o.get("argument", "")[:80] + ("..." if len(o.get("argument", "")) > 80 else "") + lines.append(f"| {o['id']} | {o['targetSolutionId']} | {o['category']} | " + f"{severity_badge(o['severity'])} {o['severity']} | {arg} |") + lines.append("") + + # Defenses + if all_defenses: + lines.append("## Defenses") + lines.append("") + for d in all_defenses: + v = d.get("verdict", "?") + icon = {"DEFENDED": "\u2713", "CONCEDED": "\u2717", "PARTIALLY_CONCEDED": "~"}.get(v, "?") + ca = (d.get("counterArgument") or "")[:120] + lines.append(f"- **{icon} {d['objectionId']}** \u2192 {v}: {ca}") + if d.get("modification"): + lines.append(f" - *Modification:* {d['modification'][:200]}") + lines.append("") + + # Ruling + if ruling: + lines.append("## Final Ruling") + lines.append("") + lines.append(f"**Confidence:** {confidence_bar(ruling.get('confidence', 0))}") + lines.append("") + lines.append("### Recommendation") + lines.append("") + lines.append(ruling.get("recommendation", "No recommendation")) + lines.append("") + lines.append("### Rationale") + lines.append("") + lines.append(ruling.get("rationale", "")) + lines.append("") + if ruling.get("unresolved"): + lines.append("### Unresolved Issues") + lines.append("") + for u in ruling["unresolved"]: + lines.append(f"- {u}") + lines.append("") + if ruling.get("actionItems"): + lines.append("### Action Items") + lines.append("") + for i, a in enumerate(ruling["actionItems"], 1): + lines.append(f"{i}. {a}") + lines.append("") + if ruling.get("riskMatrix"): + lines.append("### Risk Matrix") + lines.append("") + lines.append("| Risk | Likelihood | Impact | Mitigation |") + lines.append("|------|-----------|--------|------------|") + for r in ruling["riskMatrix"]: + lines.append(f"| {r.get('risk','')} | {r.get('likelihood','')} | " + f"{r.get('impact','')} | {r.get('mitigation','')} |") + lines.append("") + + # Cost breakdown + if cost_data and cost_data.get("by_agent"): + lines.append("## Cost Breakdown") + lines.append("") + lines.append("| Agent | Model | Calls | Tokens In | Tokens Out | Cost |") + lines.append("|-------|-------|-------|-----------|------------|------|") + for name, stats in cost_data["by_agent"].items(): + lines.append(f"| {name} | `{stats['model']}` | {stats['calls']} | " + f"~{stats['input']//1000}K | ~{stats['output']//1000}K | " + f"${stats['cost']:.3f} |") + lines.append(f"\n**Total: ${cost_data['total_cost']:.2f}**") + lines.append("") + + return "\n".join(lines) + + +# ── Main debate pipeline ──────────────────────────────────────────────── + + +def log(msg): + print(f"[tribunal] {msg}", file=sys.stderr) + + +class Spinner: + """Animated spinner showing elapsed time during long operations.""" + + FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + def __init__(self, message): + self._message = message + self._stop = threading.Event() + self._thread = None + + def start(self): + self._stop.clear() + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() + + def _spin(self): + start = time.time() + i = 0 + while not self._stop.is_set(): + elapsed = time.time() - start + frame = self.FRAMES[i % len(self.FRAMES)] + sys.stderr.write(f"\r[tribunal] {self._message} {frame} {elapsed:.0f}s") + sys.stderr.flush() + i += 1 + self._stop.wait(0.1) + + def stop(self, final_message=None): + self._stop.set() + if self._thread: + self._thread.join() + sys.stderr.write("\r\033[K") + if final_message: + sys.stderr.write(f"[tribunal] {final_message}\n") + sys.stderr.flush() + + +def run_agent(api_key, role, model, user_prompt, retries=1): + """Call agent and parse JSON response. Returns (parsed_dict, usage).""" + source = "Claude Code" if is_claude_code_model(model) else "OpenRouter" + label = f"{role.title()} ({model} [{source}])" + + for attempt in range(retries + 1): + spinner = Spinner(label) + spinner.start() + start = time.time() + try: + text, usage = call_model(api_key, model, SYSTEM_PROMPTS[role], user_prompt) + except RuntimeError as e: + elapsed = time.time() - start + if attempt < retries: + spinner.stop(f"{role.title()} FAILED ({elapsed:.1f}s) — retrying...") + continue + spinner.stop(f"{role.title()} FAILED ({elapsed:.1f}s) — {e}") + return None, {"prompt_tokens": 0, "completion_tokens": 0} + except Exception: + elapsed = time.time() - start + spinner.stop(f"{role.title()} FAILED ({elapsed:.1f}s)") + raise + elapsed = time.time() - start + spinner.stop(f"{role.title()} done ({elapsed:.1f}s)") + break + + parsed = extract_json(text) + if parsed is None: + log(f"WARNING: {role} returned no parseable JSON") + return None, usage + return coerce(parsed), usage + + +def debate(problem, analyst_model, advocate_model, critic_model, arbiter_model, + max_rounds=3, single_pass=False, output_dir=".tribunal"): + all_models = [analyst_model, advocate_model, critic_model, arbiter_model] + needs_openrouter = any(not is_claude_code_model(m) for m in all_models) + api_key = get_api_key() if needs_openrouter else "" + start_time = time.time() + + # Resolve models + agent_models = { + "analyst": analyst_model, + "advocate": advocate_model, + "critic": critic_model, + "arbiter": arbiter_model, + } + + print(file=sys.stderr) + log("Multi-model adversarial debate") + log(f"Analyst: {analyst_model}") + log(f"Advocate: {advocate_model}") + log(f"Critic: {critic_model}") + log(f"Arbiter: {arbiter_model}") + log(f"Max rounds: {max_rounds}") + print(file=sys.stderr) + + # Fetch pricing + pricing = fetch_pricing(api_key) + + cost_data = {"total_input": 0, "total_output": 0, "total_cost": 0, "by_agent": {}} + + def track(agent_name, model, usage): + inp, out, cost = calc_cost(pricing, model, usage) + cost_data["total_input"] += inp + cost_data["total_output"] += out + cost_data["total_cost"] += cost + if agent_name not in cost_data["by_agent"]: + cost_data["by_agent"][agent_name] = {"input": 0, "output": 0, "cost": 0, "calls": 0, "model": model} + a = cost_data["by_agent"][agent_name] + a["input"] += inp + a["output"] += out + a["cost"] += cost + a["calls"] += 1 + + # ── ANSI colors ── + C_BOLD = "\033[1m" + C_DIM = "\033[2m" + C_CYAN = "\033[36m" + C_GREEN = "\033[32m" + C_YELLOW = "\033[33m" + C_RED = "\033[31m" + C_MAGENTA = "\033[35m" + C_RESET = "\033[0m" + + def section(title): + width = 60 + print(f"\n{C_CYAN}{C_BOLD}{'─' * width}", file=sys.stderr) + print(f" {title}", file=sys.stderr) + print(f"{'─' * width}{C_RESET}\n", file=sys.stderr) + + def bullet(text, indent=2): + print(f"{' ' * indent}{C_DIM}•{C_RESET} {text}", file=sys.stderr) + + # ── Phase 0: Analyst ── + section("Phase 0: ANALYST — framing the problem") + result, usage = run_agent(api_key, "analyst", analyst_model, analyst_prompt(problem)) + track("analyst", analyst_model, usage) + + if result and "problem" in result: + problem_frame = result["problem"] + perspectives = result.get("suggestedPerspectives", ["pragmatist", "innovator"]) + else: + log("Analyst parse failed, using minimal frame") + problem_frame = {"statement": problem, "dimensions": ["feasibility", "complexity"], + "constraints": [], "criteria": ["correctness"], "context": ""} + perspectives = ["pragmatist", "innovator"] + + # Show analyst output + stmt = problem_frame.get("statement", problem) + print(f"\n {C_BOLD}Problem frame:{C_RESET}", file=sys.stderr) + # Wrap long statement + for i in range(0, len(stmt), 76): + print(f" {stmt[i:i+76]}", file=sys.stderr) + + dims = problem_frame.get("dimensions", []) + if dims: + print(f"\n {C_BOLD}Dimensions ({len(dims)}):{C_RESET}", file=sys.stderr) + for d in dims: + label = d[:90] + "..." if len(d) > 90 else d + bullet(label, indent=4) + + constraints = problem_frame.get("constraints", []) + if constraints: + print(f"\n {C_BOLD}Constraints ({len(constraints)}):{C_RESET}", file=sys.stderr) + for c in constraints: + label = c[:90] + "..." if len(c) > 90 else c + bullet(label, indent=4) + + criteria = problem_frame.get("criteria", []) + if criteria: + print(f"\n {C_BOLD}Success criteria ({len(criteria)}):{C_RESET}", file=sys.stderr) + for c in criteria: + label = c[:90] + "..." if len(c) > 90 else c + bullet(label, indent=4) + + print(f"\n {C_BOLD}Perspectives:{C_RESET} {', '.join(perspectives)}", file=sys.stderr) + print(file=sys.stderr) + + # ── Phase 1: Advocate generates solutions ── + section("Phase 1: ADVOCATE — generating solutions") + result, usage = run_agent(api_key, "advocate", advocate_model, + advocate_generate_prompt(problem, problem_frame, perspectives)) + track("advocate", advocate_model, usage) + + solutions = result.get("solutions", []) if result else [] + + if not solutions: + log("ERROR: No solutions generated, aborting") + return + + # Show solutions + for s in solutions: + sid = s.get("id", "?") + title = s.get("title", "Untitled") + conf = s.get("confidence", 0) + persp = s.get("perspective", "?") + print(f"\n {C_GREEN}{C_BOLD}{sid}: {title}{C_RESET}", file=sys.stderr) + print(f" {C_DIM}Perspective:{C_RESET} {persp} | {C_DIM}Confidence:{C_RESET} {confidence_bar(conf)}", file=sys.stderr) + approach = s.get("approach", "") + if approach: + short = approach[:200] + "..." if len(approach) > 200 else approach + print(f" {short}", file=sys.stderr) + tradeoffs = s.get("tradeoffs", []) + if tradeoffs: + print(f" {C_YELLOW}Tradeoffs:{C_RESET}", file=sys.stderr) + for t in tradeoffs[:4]: + bullet(t[:100], indent=6) + print(file=sys.stderr) + + # ── Phase 2+: Adversarial rounds ── + all_objections = [] + all_defenses = [] + rounds = [] + converged = False + convergence_round = 0 + round_num = 1 + + while not converged and round_num <= max_rounds: + is_full = round_num <= 1 + + # Critic + mode = "full" if is_full else "targeted" + section(f"Round {round_num}: CRITIC — {mode} critique") + result, usage = run_agent( + api_key, "critic", critic_model, + critic_prompt(problem, problem_frame, solutions, not is_full, + all_objections if all_objections else None, + all_defenses if all_defenses else None)) + track(f"critic-r{round_num}", critic_model, usage) + + new_objections = result.get("objections", []) if result else [] + + # Convergence check + delta = extract_delta(new_objections, all_objections) + if not delta: + converged = True + convergence_round = round_num + print(f"\n {C_GREEN}{C_BOLD}✓ No new objections — debate converged{C_RESET}", file=sys.stderr) + rounds.append({"round": round_num, "agent": "critic", "objections": 0, "defenses": 0}) + break + + all_objections.extend(delta) + rounds.append({"round": round_num, "agent": "critic", + "objections": len(delta), "defenses": 0}) + + # Show objections + for o in delta: + oid = o.get("id", "?") + target = o.get("targetSolutionId", "?") + cat = o.get("category", "?") + sev = o.get("severity", "?") + arg = (o.get("argument") or "")[:150] + sev_color = C_RED if sev in ("CRITICAL", "HIGH") else C_YELLOW + print(f"\n {sev_color}{severity_badge(sev)}{C_RESET} {C_BOLD}{oid}{C_RESET} → {target} ({cat})", file=sys.stderr) + print(f" {arg}", file=sys.stderr) + mit = o.get("suggestedMitigation") + if mit: + print(f" {C_DIM}Mitigation: {mit[:120]}{C_RESET}", file=sys.stderr) + + assessment = result.get("overallAssessment", "") if result else "" + if assessment: + print(f"\n {C_BOLD}Assessment:{C_RESET} {assessment[:300]}", file=sys.stderr) + + # Advocate defense + section(f"Round {round_num}: ADVOCATE — defense") + result, usage = run_agent( + api_key, "advocate", advocate_model, + advocate_defend_prompt(problem, problem_frame, solutions, delta)) + track(f"advocate-r{round_num}", advocate_model, usage) + + defenses = result.get("defenses", []) if result else [] + if result and result.get("revisedSolutions"): + solutions = result["revisedSolutions"] + log(f"Solutions revised based on concessions") + all_defenses.extend(defenses) + rounds.append({"round": round_num, "agent": "advocate", + "objections": 0, "defenses": len(defenses)}) + + # Show defenses + for d in defenses: + did = d.get("objectionId", "?") + verdict = d.get("verdict", "?") + ca = (d.get("counterArgument") or "")[:150] + if verdict == "DEFENDED": + icon, color = "✓", C_GREEN + elif verdict == "CONCEDED": + icon, color = "✗", C_RED + else: + icon, color = "~", C_YELLOW + print(f"\n {color}{icon} {did}{C_RESET} → {C_BOLD}{verdict}{C_RESET}", file=sys.stderr) + print(f" {ca}", file=sys.stderr) + mod = d.get("modification") + if mod: + print(f" {C_MAGENTA}Modification: {mod[:150]}{C_RESET}", file=sys.stderr) + + print(file=sys.stderr) + round_num += 1 + if single_pass: + break + + # ── Final: Arbiter ── + section("FINAL: ARBITER — synthesis & ruling") + result, usage = run_agent( + api_key, "arbiter", arbiter_model, + arbiter_prompt(problem, problem_frame, solutions, all_objections, + all_defenses, rounds, converged, convergence_round)) + track("arbiter", arbiter_model, usage) + + ruling = result + + # Show ruling + if ruling: + conf = ruling.get("confidence", 0) + print(f"\n {C_BOLD}Confidence:{C_RESET} {confidence_bar(conf)}", file=sys.stderr) + + rec = ruling.get("recommendation", "") + if rec: + print(f"\n {C_GREEN}{C_BOLD}Recommendation:{C_RESET}", file=sys.stderr) + for i in range(0, len(rec), 76): + print(f" {rec[i:i+76]}", file=sys.stderr) + + rationale = ruling.get("rationale", "") + if rationale: + print(f"\n {C_BOLD}Rationale:{C_RESET}", file=sys.stderr) + for i in range(0, len(rationale), 76): + print(f" {rationale[i:i+76]}", file=sys.stderr) + + dissent = ruling.get("dissent") + if dissent: + print(f"\n {C_YELLOW}{C_BOLD}Dissent:{C_RESET} {dissent}", file=sys.stderr) + + unresolved = ruling.get("unresolved", []) + if unresolved: + print(f"\n {C_BOLD}Unresolved:{C_RESET}", file=sys.stderr) + for u in unresolved: + bullet(u, indent=4) + + actions = ruling.get("actionItems", []) + if actions: + print(f"\n {C_BOLD}Action items:{C_RESET}", file=sys.stderr) + for i, a in enumerate(actions, 1): + print(f" {C_GREEN}{i}.{C_RESET} {a}", file=sys.stderr) + + risks = ruling.get("riskMatrix", []) + if risks: + print(f"\n {C_BOLD}Risk matrix:{C_RESET}", file=sys.stderr) + for r in risks: + risk = r.get("risk", "?") + lh = r.get("likelihood", "?") + imp = r.get("impact", "?") + sev_color = C_RED if imp in ("HIGH", "CRITICAL") else C_YELLOW + print(f" {sev_color}▸{C_RESET} {risk} ({C_DIM}likelihood:{C_RESET} {lh}, {C_DIM}impact:{C_RESET} {imp})", file=sys.stderr) + mit = r.get("mitigation", "") + if mit: + print(f" {C_DIM}→ {mit[:120]}{C_RESET}", file=sys.stderr) + + print(file=sys.stderr) + + duration = time.time() - start_time + total_rounds = round_num - (0 if converged else 1) + + # Generate and save report + report = generate_report( + problem, problem_frame, solutions, rounds, all_objections, + all_defenses, ruling, converged, convergence_round, + total_rounds, duration, cost_data, agent_models) + + os.makedirs(output_dir, exist_ok=True) + report_path = os.path.join(output_dir, "tribunal-report.md") + with open(report_path, "w") as f: + f.write(report) + + # Also save raw JSON + raw_path = os.path.join(output_dir, "tribunal-data.json") + with open(raw_path, "w") as f: + json.dump({ + "problem": problem_frame, "solutions": solutions, + "objections": all_objections, "defenses": all_defenses, + "ruling": ruling, "converged": converged, + "convergenceRound": convergence_round, + "totalRounds": total_rounds, "duration": duration, + "cost": cost_data, "models": agent_models, + }, f, indent=2) + + # Summary + log(f"Report: {report_path}") + log(f"Data: {raw_path}") + print(file=sys.stderr) + log("Summary:") + log(f" Solutions: {len(solutions)}") + log(f" Objections: {len(all_objections)}") + log(f" Defenses: {len(all_defenses)}") + log(f" Rounds: {total_rounds}") + conv = f"yes (round {convergence_round})" if converged else "no" + log(f" Converged: {conv}") + log(f" Duration: {duration:.1f}s") + log(f" Cost: ${cost_data['total_cost']:.2f}") + if ruling: + log(f" Confidence: {ruling.get('confidence', 0)*100:.0f}%") + + +# ── Original consult functions ────────────────────────────────────────── + + +def ask(question, model=None, files=None, system_prompt=None): + cfg = load_config() + model = model or cfg.get("default_model", "google/gemini-2.5-pro") + + if not is_claude_code_model(model): + api_key = cfg.get("api_key", "") + if not api_key: + print( + "Error: No API key. Set it in BTerminal Consult tab " + "or edit ~/.config/bterminal/consult.json", + file=sys.stderr, + ) + sys.exit(1) + + content = question or "" + + if files: + for fpath in files: + try: + with open(fpath) as f: + fc = f.read() + content += f"\n\n--- {fpath} ---\n```\n{fc}\n```" + except Exception as e: + print(f"Warning: Cannot read {fpath}: {e}", file=sys.stderr) + + if not sys.stdin.isatty(): + stdin_data = sys.stdin.read() + if stdin_data.strip(): + content += f"\n\n--- stdin ---\n```\n{stdin_data}\n```" + + if not content.strip(): + print("Error: No question provided.", file=sys.stderr) + sys.exit(1) + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": content}) + + model_name = cfg.get("models", {}).get(model, {}).get("name", model) + source = "Claude Code" if is_claude_code_model(model) else "OpenRouter" + print(f"[{model_name} — {model} ({source})]", file=sys.stderr) + + # Claude Code models use CLI directly + if is_claude_code_model(model): + combined = content + if system_prompt: + combined = f"{system_prompt}\n\n{content}" + try: + text, _ = call_claude_code(model, "", combined) + print(text) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + return + + payload = json.dumps({"model": model, "messages": messages, "stream": True}).encode() + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/DexterFromLab/BTerminal", + "X-Title": "BTerminal Consult", + } + + req = urllib.request.Request(OPENROUTER_API, data=payload, headers=headers) + + try: + resp = urllib.request.urlopen(req, timeout=120) + while True: + line = resp.readline() + if not line: + break + line = line.decode("utf-8").strip() + if not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + break + try: + chunk = json.loads(data) + delta = chunk["choices"][0].get("delta", {}) + text = delta.get("content", "") + if text: + sys.stdout.write(text) + sys.stdout.flush() + except (json.JSONDecodeError, KeyError, IndexError): + pass + print() + except urllib.error.HTTPError as e: + body = e.read().decode() + try: + err = json.loads(body) + msg = err.get("error", {}).get("message", body) + except json.JSONDecodeError: + msg = body + print(f"Error ({e.code}): {msg}", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print(f"Connection error: {e.reason}", file=sys.stderr) + sys.exit(1) + + +def list_models(): + cfg = load_config() + default = cfg.get("default_model", "") + models = cfg.get("models", {}) + enabled = {k: v for k, v in models.items() if v.get("enabled")} + + if not enabled: + print("No models enabled. Use BTerminal Consult tab to enable models.") + return + + print("Enabled models:") + for mid in sorted(enabled): + info = enabled[mid] + star = " [default]" if mid == default else "" + source = info.get("source", "openrouter") + src_tag = "[CC]" if source == "claude-code" else "[OR]" + print(f" {src_tag} {mid} — {info.get('name', mid)}{star}") + + disabled = {k: v for k, v in models.items() if not v.get("enabled")} + if disabled: + print(f"\nDisabled models: {len(disabled)} (enable in BTerminal Consult tab)") + + +def print_help(): + print("consult — Query external AI models via OpenRouter\n") + print("Usage:") + print(' consult "question" Ask default model') + print(' consult -m model_id "question" Ask specific model') + print(' consult -f file.py "review this code" Include file as context') + print(' cat log.txt | consult "analyze errors" Pipe stdin as context') + print(' consult -s "system prompt" "question" Custom system prompt') + print(" consult models List enabled models") + print(' consult debate "problem" Multi-model debate') + print() + print("Options:") + print(" -m, --model MODEL Model ID (default: from config)") + print(" -f, --file FILE Include file(s) as context (repeatable)") + print(" -s, --system PROMPT System prompt") + print(" -h, --help Show this help") + print() + print("Debate options:") + print(" --analyst MODEL Model for problem analysis") + print(" --advocate MODEL Model for solution generation") + print(" --critic MODEL Model for adversarial critique") + print(" --arbiter MODEL Model for final synthesis") + print(" --rounds N Max debate rounds (default: 3)") + print(" --single-pass One round only") + print(" --output DIR Output directory (default: .tribunal)") + print() + list_models() + print(f"\nConfig: {CONFIG_FILE}") + + +def main(): + if len(sys.argv) == 1 or sys.argv[1] in ("-h", "--help"): + print_help() + sys.exit(0) + + if sys.argv[1] == "models": + list_models() + return + + # ── debate subcommand ── + if sys.argv[1] == "debate": + cfg = load_config() + tribunal_cfg = cfg.get("tribunal", {}) + default_model = cfg.get("default_model", "google/gemini-2.5-pro") + + parser = argparse.ArgumentParser(prog="consult debate", add_help=False) + parser.add_argument("_debate", nargs=1) # consume "debate" + parser.add_argument("problem", nargs="*") + parser.add_argument("--analyst", default=tribunal_cfg.get("analyst_model") or default_model) + parser.add_argument("--advocate", default=tribunal_cfg.get("advocate_model") or default_model) + parser.add_argument("--critic", default=tribunal_cfg.get("critic_model") or default_model) + parser.add_argument("--arbiter", default=tribunal_cfg.get("arbiter_model") or default_model) + parser.add_argument("--rounds", type=int, default=tribunal_cfg.get("max_rounds", 3)) + parser.add_argument("--single-pass", action="store_true") + parser.add_argument("--output", default=".tribunal") + + args = parser.parse_args() + problem = " ".join(args.problem) if args.problem else "" + + if not problem: + print("Error: No problem statement provided.", file=sys.stderr) + print('Usage: consult debate "your problem statement"', file=sys.stderr) + sys.exit(1) + + try: + debate( + problem, + analyst_model=args.analyst, + advocate_model=args.advocate, + critic_model=args.critic, + arbiter_model=args.arbiter, + max_rounds=args.rounds, + single_pass=args.single_pass, + output_dir=args.output, + ) + except urllib.error.HTTPError as e: + body = e.read().decode() + try: + err = json.loads(body) + msg = err.get("error", {}).get("message", body) + except json.JSONDecodeError: + msg = body + print(f"Error ({e.code}): {msg}", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError as e: + print(f"Connection error: {e.reason}", file=sys.stderr) + sys.exit(1) + return + + # ── regular query ── + parser = argparse.ArgumentParser(prog="consult", add_help=False) + parser.add_argument("question", nargs="*") + parser.add_argument("-m", "--model") + parser.add_argument("-f", "--file", action="append", dest="files") + parser.add_argument("-s", "--system") + + args = parser.parse_args() + question = " ".join(args.question) if args.question else "" + + if not question and not args.files and sys.stdin.isatty(): + parser.print_help() + sys.exit(0) + + ask(question, args.model, args.files, args.system) + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh index f271f5c..3e76e65 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ set -euo pipefail # BTerminal installer -# Installs BTerminal + ctx (Claude Code context manager) +# Installs BTerminal + ctx (context manager) + consult (multi-model tribunal) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" INSTALL_DIR="$HOME/.local/share/bterminal" @@ -40,8 +40,9 @@ mkdir -p "$INSTALL_DIR" "$BIN_DIR" "$CONFIG_DIR" "$CTX_DIR" "$ICON_DIR" cp "$SCRIPT_DIR/bterminal.py" "$INSTALL_DIR/bterminal.py" cp "$SCRIPT_DIR/ctx" "$INSTALL_DIR/ctx" +cp "$SCRIPT_DIR/consult" "$INSTALL_DIR/consult" cp "$SCRIPT_DIR/bterminal.svg" "$ICON_DIR/bterminal.svg" -chmod +x "$INSTALL_DIR/bterminal.py" "$INSTALL_DIR/ctx" +chmod +x "$INSTALL_DIR/bterminal.py" "$INSTALL_DIR/ctx" "$INSTALL_DIR/consult" # ─── Symlinks ────────────────────────────────────────────────────────── @@ -49,9 +50,11 @@ echo "[3/5] Creating symlinks in $BIN_DIR..." ln -sf "$INSTALL_DIR/bterminal.py" "$BIN_DIR/bterminal" ln -sf "$INSTALL_DIR/ctx" "$BIN_DIR/ctx" +ln -sf "$INSTALL_DIR/consult" "$BIN_DIR/consult" echo " bterminal -> $INSTALL_DIR/bterminal.py" echo " ctx -> $INSTALL_DIR/ctx" +echo " consult -> $INSTALL_DIR/consult" # ─── Init ctx database ──────────────────────────────────────────────── @@ -90,6 +93,9 @@ echo "" echo "Context manager:" echo " ctx --help" echo "" +echo "Multi-model tribunal:" +echo " consult --help" +echo "" echo "Make sure $BIN_DIR is in your PATH." echo "If not, add to ~/.bashrc:" echo " export PATH=\"\$HOME/.local/bin:\$PATH\""