#!/usr/bin/env bash # Send THIS coding session's transcript (Claude Code or Codex) to anteater for # an AI post-mortem that improves the whole quickstart system (runbook + # bootstrap + skill). Finds the live transcript, compacts + lightly redacts it, # POSTs to /review, then polls /review/ for findings. # # Usage: send-transcript.sh [--idea ""] [--source claude|codex] # [--base https://.convex.site] # Env overrides: QB_REVIEW_BASE (anteater base url), QB_IDEA, QB_SOURCE. set -uo pipefail BASE="${QB_REVIEW_BASE:-https://graceful-tiger-715.convex.site}" IDEA="${QB_IDEA:-}" SOURCE="${QB_SOURCE:-}" CONSENT_ARG="" while [[ $# -gt 0 ]]; do case "$1" in --idea) IDEA="${2:-}"; shift 2 2>/dev/null || shift;; --source) SOURCE="${2:-}"; shift 2 2>/dev/null || shift;; --base) BASE="${2:-}"; shift 2 2>/dev/null || shift;; --consent) CONSENT_ARG="${2:-}"; shift 2 2>/dev/null || shift;; *) shift;; esac done # ── One-time consent gate ──────────────────────────────────────────────────── # Sharing a session is opt-in and remembered per-user, so we ask ONCE (never # nag). CONVEX_IMPROVE_CONSENT=never is an absolute kill switch, checked first — # nothing, not even --consent, can re-enable sending this run. Otherwise the # stored file holds the choice, and an explicit --consent this run sets it: # `always`/`never` persist to the file, `once` shares this session only. When # nothing is recorded we print CONSENT_REQUIRED and exit 4, and the agent asks # the user — nothing is uploaded until they choose. Redaction runs regardless. CONSENT_FILE="${HOME}/.convex/improve-consent" # Absolute kill switch: env=never wins over the file and over --consent. if [[ "${CONVEX_IMPROVE_CONSENT:-}" == "never" ]]; then echo "CONSENT=never — session sharing is off (CONVEX_IMPROVE_CONSENT=never); nothing sent." exit 0 fi # Stored choice lives in the file. The env var is disable-only (handled above), # so it can never silently enable an upload. consent_state="" if [[ -f "$CONSENT_FILE" ]]; then consent_state="$(tr -d '[:space:]' < "$CONSENT_FILE" 2>/dev/null)" fi # Persist always/never; warn (fail-safe: re-ask next time) if the write fails. persist_consent() { # $1 = always|never if ! { mkdir -p "$(dirname "$CONSENT_FILE")" && printf '%s\n' "$1" > "$CONSENT_FILE"; }; then echo "warning: could not save consent to $CONSENT_FILE — will ask again next session" >&2 fi } case "$CONSENT_ARG" in always) persist_consent always; consent_state="always";; once) consent_state="once";; never) persist_consent never; consent_state="never";; "") ;; *) echo "unknown --consent '$CONSENT_ARG' (use always|once|never)" >&2; exit 2;; esac case "$consent_state" in always|once) : ;; # proceed never) echo "CONSENT=never — session sharing is off; nothing sent. Re-enable: rm '$CONSENT_FILE'"; exit 0;; *) cat < newest *.jsonl path (mtime), empty if none [[ -d "$1" ]] || return 0 find "$1" -type f -name '*.jsonl' -print0 2>/dev/null \ | xargs -0 ls -t 2>/dev/null | head -1 } # Locate the live transcript (the newest .jsonl is the session being recorded). # This is the CLAUDE plugin, so the harness is BAKED IN as claude — the Codex # plugin ships the same script with QB_HARNESS=codex. `--source` still overrides. # A safety fallback covers running the wrong bundle in the other harness, or a # freshly-created empty rollout: if the baked harness has no real transcript but # the other one does, switch (unless --source forced it). QB_HARNESS="${QB_HARNESS:-claude}" CLAUDE_T="$(newest_jsonl "$HOME/.claude/projects")" CODEX_T="$(newest_jsonl "$HOME/.codex/sessions")" [[ -z "$CODEX_T" ]] && CODEX_T="$(newest_jsonl "$HOME/.codex")" fsize() { [[ -f "$1" ]] && wc -c < "$1" | tr -d ' ' || echo 0; } pick_by_source() { case "$1" in claude) TRANSCRIPT="$CLAUDE_T";; codex) TRANSCRIPT="$CODEX_T";; esac } EXPLICIT_SOURCE="$SOURCE" SOURCE="${SOURCE:-$QB_HARNESS}" pick_by_source "$SOURCE" # Fallback (only when not forced via --source): the baked harness has no real # transcript but the other one does → switch. if [[ -z "$EXPLICIT_SOURCE" ]]; then CUR="$(fsize "${TRANSCRIPT:-/nonexistent}")" if [[ "$CUR" -lt 1000 ]]; then if [[ "$SOURCE" == claude && "$(fsize "$CODEX_T")" -gt "$CUR" ]]; then SOURCE=codex; pick_by_source codex elif [[ "$SOURCE" == codex && "$(fsize "$CLAUDE_T")" -gt "$CUR" ]]; then SOURCE=claude; pick_by_source claude fi fi fi if [[ -z "${TRANSCRIPT:-}" || ! -f "$TRANSCRIPT" ]]; then echo "REVIEW_NO_TRANSCRIPT — no Claude (~/.claude/projects) or Codex (~/.codex) .jsonl found" exit 2 fi SESSION_ID="$(basename "$TRANSCRIPT" .jsonl)" echo "REVIEW_SOURCE=$SOURCE session=$SESSION_ID file=$TRANSCRIPT" # Which beta build produced this run — so feedback is attributable to a version. PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}" PLUGIN_VERSION="$(node -e "try{process.stdout.write(String(require('$PLUGIN_ROOT/.claude-plugin/plugin.json').version||''))}catch{}" 2>/dev/null)" MARKET_VERSION="$(node -e "try{const m=require('$PLUGIN_ROOT/.claude-plugin/marketplace.json');process.stdout.write(String((m.metadata&&m.metadata.version)||''))}catch{}" 2>/dev/null)" echo "REVIEW_VERSION plugin=${PLUGIN_VERSION:-?} marketplace=${MARKET_VERSION:-?}" # Compact + lightly redact into JSON {source,idea,sessionId,transcript,versions}. PAYLOAD="$(mktemp -t qb-review-XXXX.json)" node - "$TRANSCRIPT" "$SOURCE" "$IDEA" "$SESSION_ID" "$PLUGIN_VERSION" "$MARKET_VERSION" > "$PAYLOAD" <<'NODE' const fs = require("fs"); const [file, source, idea, sessionId, pluginVersion, marketplaceVersion] = process.argv.slice(2); const CAP = 170_000; // total chars sent to the model const PER = 1_200; // cap per tool result / long block function redact(s) { return String(s) .replace(/sk-(ant-)?[A-Za-z0-9_-]{16,}/g, "sk-REDACTED") .replace(/eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, "JWT-REDACTED") .replace(/([A-Z0-9_]*(KEY|SECRET|TOKEN|PASSWORD)[A-Z0-9_]*)\s*[=:]\s*\S+/gi, "$1=REDACTED") // strip long base64/data blobs so one screenshot doesn't eat the budget .replace(/[A-Za-z0-9+/]{300,}={0,2}/g, "[blob]"); } function clip(s, n) { s = String(s); return s.length > n ? s.slice(0, n) + `…[+${s.length - n}c]` : s; } function blockToText(b) { if (typeof b === "string") return b; if (!b || typeof b !== "object") return ""; if (b.type === "text" || typeof b.text === "string") return b.text || ""; if (b.type === "tool_use") return `⏵ tool ${b.name || ""} ${clip(JSON.stringify(b.input ?? {}), PER)}`; if (b.type === "tool_result") { const c = Array.isArray(b.content) ? b.content.map(blockToText).join("\n") : (b.content ?? ""); return `⏴ result ${clip(c, PER)}`; } if (b.content) return Array.isArray(b.content) ? b.content.map(blockToText).join("\n") : String(b.content); return ""; } const lines = []; for (const raw of fs.readFileSync(file, "utf8").split("\n")) { if (!raw.trim()) continue; let o; try { o = JSON.parse(raw); } catch { lines.push(clip(raw, PER)); continue; } // Codex `rollout` jsonl nests the message under o.payload // ({type:"message",role,content:[{text}]}; event_msg → {message}); Claude uses // o.message / o directly. Try payload first so both formats extract content. const msg = o.payload || o.message || o; const role = msg.role || o.role || o.type || "event"; if (role === "summary" && o.summary) { lines.push(`SUMMARY: ${o.summary}`); continue; } let content = msg.content ?? msg.message ?? o.content ?? o.text ?? ""; let text = Array.isArray(content) ? content.map(blockToText).filter(Boolean).join("\n") : blockToText(content); text = (text || "").trim(); if (!text) continue; lines.push(`${String(role).toUpperCase()}: ${text}`); } let transcript = redact(lines.join("\n")); if (transcript.length > CAP) { const head = Math.floor(CAP * 0.6), tail = CAP - head; transcript = transcript.slice(0, head) + `\n\n…[${transcript.length - CAP} chars elided]…\n\n` + transcript.slice(-tail); } process.stdout.write(JSON.stringify({ source, idea: idea || undefined, sessionId, pluginVersion: pluginVersion || undefined, marketplaceVersion: marketplaceVersion || undefined, harness: source === "claude" ? "claude-code" : source === "codex" ? "codex" : "other", transcript, })); NODE BYTES=$(wc -c < "$PAYLOAD" | tr -d ' ') echo "REVIEW_PAYLOAD_BYTES=$BYTES" # Guard against an empty/near-empty transcript (e.g. a freshly-created session # file that auto-detect grabbed by mtime). Don't bother the endpoint with it. if [[ "$BYTES" -lt 400 ]]; then echo "REVIEW_TRANSCRIPT_TOO_SMALL — '$TRANSCRIPT' has almost no content." echo " Re-run with --source $([ "$SOURCE" = claude ] && echo codex || echo claude), or --source to force the right harness." rm -f "$PAYLOAD"; exit 2 fi RESP="$(curl -fsS --max-time 30 -X POST "$BASE/review" -H 'content-type: application/json' --data @"$PAYLOAD")" || { echo "REVIEW_UPLOAD_FAILED"; perm_hint; rm -f "$PAYLOAD"; exit 3; } rm -f "$PAYLOAD" ID="$(printf '%s' "$RESP" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).id||"")}catch{}})')" if [[ -z "$ID" ]]; then echo "REVIEW_NO_ID resp=$RESP"; exit 3; fi echo "REVIEW_SUBMITTED id=$ID" # Poll for the AI review (usually ~10–25s). for i in $(seq 1 40); do S="$(curl -fsS --max-time 15 "$BASE/review/$ID" 2>/dev/null)" || { sleep 3; continue; } ST="$(printf '%s' "$S" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).status||"")}catch{}})')" if [[ "$ST" == "done" || "$ST" == "error" ]]; then echo "REVIEW_DONE status=$ST" printf '%s' "$S" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.stringify(JSON.parse(s),null,2))}catch{console.log(s)}})' exit 0 fi sleep 3 done echo "REVIEW_PENDING id=$ID — still reviewing; check $BASE/review/$ID later" exit 0