diff --git a/VERSION b/VERSION index 05e8a45..2228cad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.6 +0.6.7 diff --git a/larry.sh b/larry.sh index d28c05f..f648dd3 100755 --- a/larry.sh +++ b/larry.sh @@ -29,14 +29,21 @@ # /reset clear conversation history (keeps log file) # /load paste a file's contents as your next user message # /sys print the active system prompt +# /clear clear terminal screen +# /copy copy last assistant response to clipboard +# /cost show running token + dollar cost for the session +# /show-last-tool print last tool call + result (debug) # /help this help +# +# Inline file syntax: @ in any prompt inlines the file's contents +# (TAB to autocomplete). See /help for details. set -u set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.6.6" +LARRY_VERSION="0.6.7" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_BASE_URL="${LARRY_BASE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main}" LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-${LARRY_BASE_URL}/larry.sh}" @@ -70,7 +77,7 @@ ARG_DIR="" for arg in "$@"; do case "$arg" in --version|-V) echo "larry-anywhere $LARRY_VERSION"; exit 0 ;; - --help|-h) sed -n '2,30p' "$0"; exit 0 ;; + --help|-h) sed -n '2,40p' "$0"; exit 0 ;; --no-update) LARRY_NO_UPDATE=1 ;; -*) err "unknown flag: $arg"; exit 2 ;; *) ARG_DIR="$arg" ;; @@ -836,6 +843,331 @@ tool_hl7_sanitize() { "$LARRY_LIB_DIR/hl7-sanitize.sh" "${args[@]}" 2>&1 } +# ───────────────────────────────────────────────────────────────────────────── +# v0.6.7 — @file inline-file preprocessing +# +# Replaces @ tokens in user input with inlined file contents as fenced +# code blocks appended after the prose. Runs BEFORE PHI tokenization so PHI +# markers inside inlined files still get caught. +# +# Token grammar: +# - @ : @ followed by non-whitespace chars; @ must be preceded by +# whitespace, start-of-line, or punctuation. Skipped if +# preceded by a non-whitespace word char (e.g. email). +# - @{path} : bracketed form for paths with spaces. Closing } required. +# +# Validation: +# missing → warn, leave literal +# directory → warn, skip +# binary → warn, skip (first 8KB scanned for null bytes) +# >250KB → truncate to 250KB with footer +# ───────────────────────────────────────────────────────────────────────────── +preprocess_atfile_refs() { + local input="$1" + # Quick reject: no @ → no work. + case "$input" in + *@*) ;; + *) printf '%s' "$input"; return ;; + esac + + # Collect all @-refs in order; dedupe by resolved path; build fenced footer. + # Two grammars: + # 1. @{path with spaces} + # 2. @bare-token (no whitespace, no '}') + # We scan with a single awk-style loop in pure bash. + local refs=() # ordered raw tokens (path strings, NOT including @) + local seen=() # parallel list of resolved paths (for dedupe) + local i=0 n=${#input} + local prev_char=$'\n' # treat start as whitespace + while [ "$i" -lt "$n" ]; do + local ch="${input:i:1}" + if [ "$ch" = "@" ]; then + # Decide if eligible: prev_char must be whitespace, start-of-line, or punctuation + case "$prev_char" in + ''|[[:space:]]|'('|'['|','|';'|':'|'"'|"'"|'<'|'>'|'='|'`'|'|') + # Eligible. Look at next char. + local nx="${input:i+1:1}" + local token="" + local end=$((i + 1)) + if [ "$nx" = "{" ]; then + # @{...} bracketed + local j=$((i + 2)) + while [ "$j" -lt "$n" ] && [ "${input:j:1}" != "}" ]; do + token+="${input:j:1}" + j=$((j + 1)) + done + if [ "$j" -lt "$n" ] && [ "${input:j:1}" = "}" ]; then + end=$((j + 1)) + else + # Unclosed brace — bail, treat @ as literal + token="" + fi + else + # @bare-token: read until whitespace or terminating punctuation + local j=$((i + 1)) + while [ "$j" -lt "$n" ]; do + local cj="${input:j:1}" + case "$cj" in + [[:space:]]) break ;; + # Allow most punctuation in paths, but stop at obvious terminators. + ',') break ;; + ';') break ;; + ')') break ;; + ']') break ;; + '}') break ;; + '"') break ;; + "'") break ;; + '`') break ;; + esac + token+="$cj" + j=$((j + 1)) + done + # Strip a single trailing period (common when path ends a sentence). + case "$token" in + *.) ;; # leave foo.md alone + *..) ;; + esac + # But if token ends with '.' and there's no extension dot earlier, strip. + # Heuristic: only strip trailing '.' if followed by EOL/space and no other dot in token. + if [ -n "$token" ] && [ "${token: -1}" = "." ]; then + local body="${token%.}" + case "$body" in + *.*) ;; # has another dot → trailing . might be valid (e.g. ../foo.) — leave + *) token="$body" ;; + esac + fi + end="$j" + fi + if [ -n "$token" ]; then + refs+=("$token") + i="$end" + prev_char="${input:end-1:1}" + continue + fi + ;; + esac + fi + prev_char="$ch" + i=$((i + 1)) + done + + if [ "${#refs[@]}" -eq 0 ]; then + printf '%s' "$input" + return + fi + + # Resolve, validate, dedupe, build the footer. + local footer="" + local r resolved canonical + for r in "${refs[@]}"; do + # Resolve relative paths against current pwd. + case "$r" in + /*) resolved="$r" ;; + *) resolved="$PWD/$r" ;; + esac + # Canonical-ish key for dedupe (no symlink resolution to keep it cheap). + canonical="$resolved" + local skip=0 dup s + for s in "${seen[@]}"; do + [ "$s" = "$canonical" ] && { skip=1; break; } + done + [ "$skip" = "1" ] && continue + seen+=("$canonical") + + if [ ! -e "$resolved" ]; then + printf '%satfile>%s @%s not found; leaving literal\n' "$C_YELLOW" "$C_RESET" "$r" >&2 + continue + fi + if [ -d "$resolved" ]; then + printf '%satfile>%s @%s is a directory; skipping\n' "$C_YELLOW" "$C_RESET" "$r" >&2 + continue + fi + if [ ! -f "$resolved" ]; then + printf '%satfile>%s @%s not a regular file; skipping\n' "$C_YELLOW" "$C_RESET" "$r" >&2 + continue + fi + # Binary detection: scan first 8KB for null bytes. Compare byte counts + # before/after `tr -d '\0'` — grep with a literal NUL doesn't work + # portably (NUL terminates the pattern string in many greps). + local _head_bytes _stripped_bytes + _head_bytes=$(head -c 8192 "$resolved" 2>/dev/null | wc -c | tr -d ' ') + _stripped_bytes=$(head -c 8192 "$resolved" 2>/dev/null | LC_ALL=C tr -d '\0' | wc -c | tr -d ' ') + if [ "$_head_bytes" != "$_stripped_bytes" ]; then + printf '%satfile>%s @%s appears to be binary; skipping\n' "$C_YELLOW" "$C_RESET" "$r" >&2 + continue + fi + + local size; size=$(wc -c < "$resolved" 2>/dev/null || echo 0) + local content footer_note="" + if [ "$size" -gt 256000 ]; then + content=$(head -c 256000 "$resolved" 2>/dev/null) + footer_note=$'\n[file truncated at 250 KB; total size: '"$(( size / 1024 ))"' KB]' + else + content=$(cat "$resolved" 2>/dev/null) + fi + + # Language hint from extension. + local ext="${r##*.}" + case "$ext" in + "$r"|"") ext="" ;; # no extension + esac + + footer+=$'\n\n—————\n'"$r"$':\n```'"$ext"$'\n'"$content""$footer_note"$'\n```' + printf '%satfile>%s @%s inlined (%d bytes)\n' "$C_YELLOW" "$C_RESET" "$r" "$size" >&2 + done + + if [ -z "$footer" ]; then + printf '%s' "$input" + return + fi + + printf '%s%s' "$input" "$footer" +} + +# Session-scope flag: print the @file tip once per session. +_LARRY_ATFILE_TIP_SHOWN=0 +maybe_show_atfile_tip() { + [ "$_LARRY_ATFILE_TIP_SHOWN" = "1" ] && return + case "$1" in + *@*) + printf '%s(tip: @ attaches the file contents; TAB to autocomplete)%s\n' "$C_DIM" "$C_RESET" >&2 + _LARRY_ATFILE_TIP_SHOWN=1 + ;; + esac +} + +# ───────────────────────────────────────────────────────────────────────────── +# v0.6.7 — clipboard + cost + model-name + tool-display helpers +# ───────────────────────────────────────────────────────────────────────────── + +# Detect clipboard command. Cached after first call. +_LARRY_CLIP_CMD="" +_LARRY_CLIP_DETECTED=0 +detect_clipboard() { + [ "$_LARRY_CLIP_DETECTED" = "1" ] && { printf '%s' "$_LARRY_CLIP_CMD"; return; } + _LARRY_CLIP_DETECTED=1 + if command -v pbcopy >/dev/null 2>&1; then + _LARRY_CLIP_CMD="pbcopy" + elif [ -n "${WAYLAND_DISPLAY:-}" ] && command -v wl-copy >/dev/null 2>&1; then + _LARRY_CLIP_CMD="wl-copy" + elif command -v xclip >/dev/null 2>&1; then + _LARRY_CLIP_CMD="xclip -selection clipboard" + elif command -v xsel >/dev/null 2>&1; then + _LARRY_CLIP_CMD="xsel --clipboard --input" + elif [ -e /dev/clipboard ]; then + _LARRY_CLIP_CMD="tee /dev/clipboard >/dev/null" + elif command -v clip.exe >/dev/null 2>&1; then + _LARRY_CLIP_CMD="clip.exe" + fi + printf '%s' "$_LARRY_CLIP_CMD" +} + +# Anthropic pricing per million tokens (USD), as of 2026-05. +# Source: https://platform.claude.com/docs/en/about-claude/pricing +# Refresh periodically — these are constants Bryan can hand-edit. +_price_for_model() { + # Returns: "input_price output_price" per MTok + case "$1" in + *opus*) echo "15 75" ;; + *haiku*) echo "1 5" ;; + *sonnet*|*) echo "3 15" ;; + esac +} + +# Session cost tracker. Updated on each non-streaming response or message_delta. +_LARRY_INPUT_TOKENS=0 +_LARRY_OUTPUT_TOKENS=0 +_LARRY_CACHE_READ_TOKENS=0 +_LARRY_CACHE_WRITE_TOKENS=0 +_LARRY_TURNS=0 + +print_cost_summary() { + local prices; prices=$(_price_for_model "$LARRY_MODEL") + local in_price out_price + in_price="${prices% *}" + out_price="${prices#* }" + # Compute via awk for floating point (bash has no fp). + local cost_in cost_out cost_read cost_write total + cost_in=$(awk -v t="$_LARRY_INPUT_TOKENS" -v p="$in_price" 'BEGIN{printf "%.4f", t*p/1000000}') + cost_out=$(awk -v t="$_LARRY_OUTPUT_TOKENS" -v p="$out_price" 'BEGIN{printf "%.4f", t*p/1000000}') + cost_read=$(awk -v t="$_LARRY_CACHE_READ_TOKENS" -v p="$in_price" 'BEGIN{printf "%.4f", t*p*0.1/1000000}') + cost_write=$(awk -v t="$_LARRY_CACHE_WRITE_TOKENS" -v p="$in_price" 'BEGIN{printf "%.4f", t*p*1.25/1000000}') + total=$(awk -v a="$cost_in" -v b="$cost_out" -v c="$cost_read" -v d="$cost_write" 'BEGIN{printf "%.4f", a+b+c+d}') + + printf '%sSession cost so far:%s\n' "$C_BOLD" "$C_RESET" + printf ' Model: %s (in $%s/MTok, out $%s/MTok)\n' "$LARRY_MODEL" "$in_price" "$out_price" + printf ' Input tokens: %s ($%s)\n' "$_LARRY_INPUT_TOKENS" "$cost_in" + printf ' Output tokens: %s ($%s)\n' "$_LARRY_OUTPUT_TOKENS" "$cost_out" + printf ' Cache reads: %s ($%s)\n' "$_LARRY_CACHE_READ_TOKENS" "$cost_read" + printf ' Cache writes: %s ($%s)\n' "$_LARRY_CACHE_WRITE_TOKENS" "$cost_write" + printf ' Total: $%s\n' "$total" + printf ' Turns: %s\n' "$_LARRY_TURNS" +} + +# Derive a short label from the full model ID for the prompt. +# claude-sonnet-4-6 → sonnet-4.6 +# claude-opus-4-7 → opus-4.7 +# claude-haiku-4-5 → haiku-4.5 +model_short_name() { + local m="${1:-$LARRY_MODEL}" + # Strip leading "claude-" if present. + m="${m#claude-}" + # Convert remaining "-N-M" tail to "-N.M": last two dashes. + # We do this by replacing the LAST '-' with '.'. + local last="${m##*-}" + local rest="${m%-*}" + # If rest still has digits separated by '-', collapse the last hyphen too. + case "$rest" in + *-*) + local rest_last="${rest##*-}" + local rest_rest="${rest%-*}" + # If both rest_last and last are numeric, collapse all to dots. + case "$rest_last$last" in + *[!0-9]*) printf '%s' "$m" ;; + *) printf '%s-%s.%s' "$rest_rest" "$rest_last" "$last" ;; + esac + ;; + *) + printf '%s' "$m" + ;; + esac +} + +# Session-scope: last assistant text (for /copy) and last tool call+result (for /show-last-tool). +_LARRY_LAST_ASSISTANT_TEXT="" +_LARRY_LAST_TOOL_NAME="" +_LARRY_LAST_TOOL_INPUT="" +_LARRY_LAST_TOOL_RESULT="" + +# Pretty-print a tool-use input JSON one key:value per line, truncating long +# values. Used by both streaming and non-streaming paths. +_pretty_tool_input() { + local input_json="$1" + printf '%s' "$input_json" | jq -r ' + to_entries + | map( + .key as $k + | (.value | if type=="string" then . else tojson end) as $v + | " " + $k + ": " + (if ($v|length) > 120 then ($v[0:117] + "...") else $v end) + ) + | join("\n") + ' 2>/dev/null +} + +# Display a tool call header (cyan + bold name, dim args, optional truncation hint). +display_tool_call() { + local name="$1" input_json="$2" + printf '\n%s%s▶ %s%s\n' "$C_CYAN" "$C_BOLD" "$name" "$C_RESET" + local pretty; pretty=$(_pretty_tool_input "$input_json") + if [ -n "$pretty" ]; then + printf '%s%s%s\n' "$C_DIM" "$pretty" "$C_RESET" >&2 + # Was anything truncated? Check raw lengths. + if printf '%s' "$input_json" | grep -q '.\{121,\}'; then + printf '%s (use /show-last-tool for full args)%s\n' "$C_DIM" "$C_RESET" >&2 + fi + fi +} + # Secure SSH tools — password is read from $LARRY_HOME/.ssh-creds/ by # ssh-helper.sh and never exposed in argv, env, or tool output. The Larry-LLM # only sees: alias name, command, command output. @@ -1067,6 +1399,40 @@ call_api() { "$LARRY_API_URL" } +# call_api_stream — same as call_api but for SSE responses. Writes the raw +# event stream to stdout (one line per SSE field, blank lines between events). +# Caller is responsible for parsing. Returns curl's exit status. +# +# Uses -N (no buffering) so each delta arrives as it ships from the server. +# We DO NOT use -sS here because we want stderr enabled on failure for the +# fallback path to inspect; but -s on stdout is fine because the response is +# pure SSE either way. +call_api_stream() { + local payload_file="$1" + local auth_args=() + if [ "$LARRY_AUTH_MODE" = "oauth" ]; then + local oauth_script="$LARRY_LIB_DIR/oauth.sh" + local token="" + if [ -x "$oauth_script" ]; then + token=$("$oauth_script" ensure 2>/dev/null) + fi + if [ -z "$token" ]; then + err "OAuth token unavailable (streaming); run /login to re-authenticate" + return 1 + fi + auth_args=(-H "Authorization: Bearer $token" -H "anthropic-beta: oauth-2025-04-20") + else + auth_args=(-H "x-api-key: $ANTHROPIC_API_KEY") + fi + curl -sN --max-time 300 \ + "${auth_args[@]}" \ + -H "anthropic-version: 2023-06-01" \ + -H "content-type: application/json" \ + -H "accept: text/event-stream" \ + --data-binary "@$payload_file" \ + "$LARRY_API_URL" +} + build_system_prompt() { local sys="" # Load larry.md first (sets identity), then everything else alphabetically. @@ -1088,6 +1454,227 @@ build_system_prompt() { # ───────────────────────────────────────────────────────────────────────────── # Agent turn — loop until stop_reason != tool_use # ───────────────────────────────────────────────────────────────────────────── +# _humanize_api_error CODE BODY — turn raw API errors into friendlier prose. +# Returns the rendered message on stdout; never fails. +_humanize_api_error() { + local body="$1" + local err_type err_msg + err_type=$(printf '%s' "$body" | jq -r '.error.type // empty' 2>/dev/null) + err_msg=$(printf '%s' "$body" | jq -r '.error.message // empty' 2>/dev/null) + case "$err_type" in + authentication_error|invalid_request_error) + case "$err_msg" in + *[Oo]auth*|*[Tt]oken*|*expired*|*revoked*) + printf 'Authentication failed — OAuth token may have expired or been revoked. Run /login to re-authenticate.' + return ;; + *[Aa]pi*[Kk]ey*|*x-api-key*) + printf 'Authentication failed — API key invalid or revoked. Set ANTHROPIC_API_KEY or run /login.' + return ;; + esac + printf '%s — %s' "$err_type" "$err_msg" + ;; + rate_limit_error|overloaded_error) + printf 'Rate limited by Anthropic (%s). Wait a few seconds and retry. (%s)' "$err_type" "$err_msg" + ;; + not_found_error) + printf 'API said not found — usually a bad model name. Current LARRY_MODEL=%s. (%s)' "$LARRY_MODEL" "$err_msg" + ;; + *) + [ -n "$err_type" ] && printf '%s — %s' "$err_type" "$err_msg" || printf '%s' "$body" + ;; + esac +} + +# parse_stream_to_response — read SSE from stdin, write events to stdout as +# they arrive (for text deltas) AND assemble the equivalent non-streaming +# response JSON to the file named in $1. Returns 0 on clean stream, 1 on +# parse failure (caller falls back to non-streaming). +# +# Side effects: +# - prints text deltas to stderr (the visible terminal output) as they arrive +# - writes a JSON file with {content:[...], stop_reason, usage} on success +# - updates _LARRY_LAST_ASSISTANT_TEXT +parse_stream_to_response() { + local out_file="$1" + # State: ordered content blocks. We use parallel arrays keyed by block index. + # block_type[i]: "text" | "tool_use" + # block_text[i]: accumulated text (for text blocks) + # block_id[i], block_name[i], block_input_buf[i]: for tool_use blocks + local -a block_type=() block_text=() block_id=() block_name=() block_input_buf=() + local stop_reason="" out_tokens=0 in_tokens=0 cache_read=0 cache_write=0 + local started_text=0 + local line data event_type + + while IFS= read -r line; do + # Strip CR (curl on Windows / SSE servers often emit CRLF). + line="${line%$'\r'}" + case "$line" in + 'event: '*) event_type="${line#event: }"; continue ;; + 'data: '*) + data="${line#data: }" + [ -z "$data" ] && continue + # Parse the event JSON. Each line is one JSON object. + local etype + etype=$(printf '%s' "$data" | jq -r '.type // empty' 2>/dev/null) + case "$etype" in + message_start) + # Pull initial input tokens from .message.usage + local u + u=$(printf '%s' "$data" | jq -r '.message.usage // empty' 2>/dev/null) + if [ -n "$u" ]; then + in_tokens=$(printf '%s' "$u" | jq -r '.input_tokens // 0' 2>/dev/null) + cache_read=$(printf '%s' "$u" | jq -r '.cache_read_input_tokens // 0' 2>/dev/null) + cache_write=$(printf '%s' "$u" | jq -r '.cache_creation_input_tokens // 0' 2>/dev/null) + fi + ;; + content_block_start) + local idx btype + idx=$(printf '%s' "$data" | jq -r '.index' 2>/dev/null) + btype=$(printf '%s' "$data" | jq -r '.content_block.type' 2>/dev/null) + block_type[$idx]="$btype" + block_text[$idx]="" + block_input_buf[$idx]="" + if [ "$btype" = "tool_use" ]; then + block_id[$idx]=$(printf '%s' "$data" | jq -r '.content_block.id' 2>/dev/null) + block_name[$idx]=$(printf '%s' "$data" | jq -r '.content_block.name' 2>/dev/null) + # Print the tool-call header EARLY (args still streaming). + # We re-print final args on content_block_stop. + printf '\n%s%s▶ %s%s %s(streaming args...)%s\n' \ + "$C_CYAN" "$C_BOLD" "${block_name[$idx]}" "$C_RESET" "$C_DIM" "$C_RESET" >&2 + fi + ;; + content_block_delta) + local idx dtype + idx=$(printf '%s' "$data" | jq -r '.index' 2>/dev/null) + dtype=$(printf '%s' "$data" | jq -r '.delta.type' 2>/dev/null) + case "$dtype" in + text_delta) + local t + t=$(printf '%s' "$data" | jq -r '.delta.text' 2>/dev/null) + # Stream to stderr so it can't get swallowed by stdout redirect. + # Color whole stream with magenta (Larry's voice). + if [ "$started_text" = "0" ]; then + printf '%s' "$C_MAGENTA" >&2 + started_text=1 + fi + printf '%s' "$t" >&2 + block_text[$idx]+="$t" + ;; + input_json_delta) + local pj + pj=$(printf '%s' "$data" | jq -r '.delta.partial_json' 2>/dev/null) + block_input_buf[$idx]+="$pj" + ;; + thinking_delta|signature_delta) + : ;; # ignore for now + esac + ;; + content_block_stop) + local idx + idx=$(printf '%s' "$data" | jq -r '.index' 2>/dev/null) + if [ "${block_type[$idx]:-}" = "tool_use" ]; then + # Validate accumulated JSON. If empty, treat as {}. + local buf="${block_input_buf[$idx]:-}" + [ -z "$buf" ] && buf="{}" + # Test it parses; if not, store as empty object. + if ! printf '%s' "$buf" | jq -e . >/dev/null 2>&1; then + buf="{}" + fi + block_input_buf[$idx]="$buf" + # Pretty-display the final args under the header we printed earlier. + local pretty; pretty=$(_pretty_tool_input "$buf") + if [ -n "$pretty" ]; then + printf '%s%s%s\n' "$C_DIM" "$pretty" "$C_RESET" >&2 + if printf '%s' "$buf" | grep -q '.\{121,\}'; then + printf '%s (use /show-last-tool for full args)%s\n' "$C_DIM" "$C_RESET" >&2 + fi + fi + fi + ;; + message_delta) + stop_reason=$(printf '%s' "$data" | jq -r '.delta.stop_reason // empty' 2>/dev/null) + local ot + ot=$(printf '%s' "$data" | jq -r '.usage.output_tokens // empty' 2>/dev/null) + [ -n "$ot" ] && out_tokens="$ot" + ;; + message_stop) + : ;; + ping|error) + if [ "$etype" = "error" ]; then + local em; em=$(printf '%s' "$data" | jq -r '.error.message // .error.type // empty' 2>/dev/null) + err "stream error event: $em" + return 1 + fi + ;; + esac + ;; + '') continue ;; + esac + done + + # Close color if we printed text. + [ "$started_text" = "1" ] && printf '%s\n' "$C_RESET" >&2 + + # If we never got any blocks, treat as failure. + if [ "${#block_type[@]}" -eq 0 ]; then + return 1 + fi + + # Track cost + _LARRY_INPUT_TOKENS=$(( _LARRY_INPUT_TOKENS + in_tokens )) + _LARRY_OUTPUT_TOKENS=$(( _LARRY_OUTPUT_TOKENS + out_tokens )) + _LARRY_CACHE_READ_TOKENS=$(( _LARRY_CACHE_READ_TOKENS + cache_read )) + _LARRY_CACHE_WRITE_TOKENS=$(( _LARRY_CACHE_WRITE_TOKENS + cache_write )) + + # Assemble the synthetic response file. We rebuild content[] in index order. + local content_json="[]" + local i max=0 + for i in "${!block_type[@]}"; do + [ "$i" -gt "$max" ] && max="$i" + done + local accumulated_text="" + for ((i=0; i<=max; i++)); do + local bt="${block_type[$i]:-}" + [ -z "$bt" ] && continue + if [ "$bt" = "text" ]; then + local txt="${block_text[$i]:-}" + accumulated_text+="$txt" + local tf; tf=$(mktemp) + printf '%s' "$txt" > "$tf" + content_json=$(printf '%s' "$content_json" | jq \ + --rawfile t "$(jqpath "$tf")" \ + '. + [{"type":"text","text":$t}]') + rm -f "$tf" + elif [ "$bt" = "tool_use" ]; then + # NB: don't use ${var:-{}} default — bash treats inner '}' as closing + # the expansion. Fall back manually instead. + local id="${block_id[$i]:-}" nm="${block_name[$i]:-}" inp="${block_input_buf[$i]:-}" + [ -z "$inp" ] && inp="{}" + local inf; inf=$(mktemp) + printf '%s' "$inp" > "$inf" + content_json=$(printf '%s' "$content_json" | jq \ + --arg id "$id" --arg name "$nm" --slurpfile i "$(jqpath "$inf")" \ + '. + [{"type":"tool_use","id":$id,"name":$name,"input":$i[0]}]') + rm -f "$inf" + fi + done + + [ -n "$accumulated_text" ] && _LARRY_LAST_ASSISTANT_TEXT="$accumulated_text" + + # Emit synthetic response JSON. + jq -n \ + --argjson content "$content_json" \ + --arg stop "$stop_reason" \ + --argjson in_t "$in_tokens" --argjson out_t "$out_tokens" \ + '{content:$content, stop_reason:$stop, usage:{input_tokens:$in_t,output_tokens:$out_t}}' \ + > "$out_file" + return 0 +} + +# Try streaming first; if anything goes wrong, fall back to non-streaming. +# LARRY_NO_STREAM=1 disables streaming entirely. +LARRY_NO_STREAM="${LARRY_NO_STREAM:-0}" + agent_turn() { local system_prompt="$1" # Write the large blobs to files ONCE per agent_turn rather than passing @@ -1097,25 +1684,52 @@ agent_turn() { tools_file=$(mktemp); system_file=$(mktemp) printf '%s' "$TOOLS_JSON" > "$tools_file" printf '%s' "$system_prompt" > "$system_file" + + _LARRY_TURNS=$(( _LARRY_TURNS + 1 )) + while true; do local payload_file; payload_file=$(mktemp) + local stream_flag="false" + [ "$LARRY_NO_STREAM" != "1" ] && stream_flag="true" jq -n \ --arg model "$LARRY_MODEL" \ --argjson max_tokens "$LARRY_MAX_TOKENS" \ + --argjson stream "$stream_flag" \ --rawfile system "$(jqpath "$system_file")" \ --slurpfile messages "$(jqpath "$MESSAGES_FILE")" \ --slurpfile tools "$(jqpath "$tools_file")" \ - '{model:$model, max_tokens:$max_tokens, system:$system, messages:$messages[0], tools:$tools[0]}' \ + '{model:$model, max_tokens:$max_tokens, stream:$stream, system:$system, messages:$messages[0], tools:$tools[0]}' \ > "$payload_file" - local resp; resp=$(call_api "$payload_file") - rm -f "$payload_file" + local resp="" + local resp_file; resp_file=$(mktemp) + local used_stream=0 - if [ -z "$resp" ]; then err "empty response from API (timeout or network?)"; rm -f "$tools_file" "$system_file"; return 1; fi + if [ "$stream_flag" = "true" ]; then + # Stream; parse_stream_to_response writes the synthetic response into $resp_file. + if call_api_stream "$payload_file" | parse_stream_to_response "$resp_file"; then + used_stream=1 + resp=$(cat "$resp_file") + else + warn "streaming parse failed — falling back to non-streaming for this turn" + # Re-build payload without stream:true and call non-streaming. + jq 'del(.stream)' < "$payload_file" > "$payload_file.ns" && mv "$payload_file.ns" "$payload_file" + resp=$(call_api "$payload_file") + fi + else + resp=$(call_api "$payload_file") + fi + rm -f "$payload_file" "$resp_file" + + if [ -z "$resp" ]; then + err "Network error: empty response from $LARRY_API_URL (timeout, DNS, or connection reset). Check connectivity." + rm -f "$tools_file" "$system_file" + return 1 + fi local err_type; err_type=$(printf '%s' "$resp" | jq -r '.error.type // empty' 2>/dev/null) if [ -n "$err_type" ]; then - err "API error: $err_type — $(printf '%s' "$resp" | jq -r '.error.message // "no message"')" + err "API error: $(_humanize_api_error "$resp")" rm -f "$tools_file" "$system_file" return 1 fi @@ -1123,10 +1737,25 @@ agent_turn() { local blocks; blocks=$(printf '%s' "$resp" | jq -c '.content') add_assistant_blocks "$blocks" - # Print text blocks - printf '%s' "$resp" | jq -r '.content[] | select(.type=="text") | .text' \ - | sed "s/^/${C_MAGENTA}/; s/\$/${C_RESET}/" 2>/dev/null \ - || printf '%s' "$resp" | jq -r '.content[] | select(.type=="text") | .text' + # Print text blocks (only if we did NOT already stream them above). + if [ "$used_stream" = "0" ]; then + local non_stream_text + non_stream_text=$(printf '%s' "$resp" | jq -r '.content[] | select(.type=="text") | .text') + if [ -n "$non_stream_text" ]; then + printf '%s%s%s\n' "$C_MAGENTA" "$non_stream_text" "$C_RESET" + _LARRY_LAST_ASSISTANT_TEXT="$non_stream_text" + fi + # Cost tracking for non-streaming path. + local nu_in nu_out nu_cr nu_cw + nu_in=$(printf '%s' "$resp" | jq -r '.usage.input_tokens // 0' 2>/dev/null) + nu_out=$(printf '%s' "$resp" | jq -r '.usage.output_tokens // 0' 2>/dev/null) + nu_cr=$(printf '%s' "$resp" | jq -r '.usage.cache_read_input_tokens // 0' 2>/dev/null) + nu_cw=$(printf '%s' "$resp" | jq -r '.usage.cache_creation_input_tokens // 0' 2>/dev/null) + _LARRY_INPUT_TOKENS=$(( _LARRY_INPUT_TOKENS + nu_in )) + _LARRY_OUTPUT_TOKENS=$(( _LARRY_OUTPUT_TOKENS + nu_out )) + _LARRY_CACHE_READ_TOKENS=$(( _LARRY_CACHE_READ_TOKENS + nu_cr )) + _LARRY_CACHE_WRITE_TOKENS=$(( _LARRY_CACHE_WRITE_TOKENS + nu_cw )) + fi # Log assistant text to session log { @@ -1146,10 +1775,24 @@ agent_turn() { name=$(printf '%s' "$tool_use" | jq -r '.name') input_json=$(printf '%s' "$tool_use" | jq -c '.input') - printf '\n%s▶ %s%s %s\n' "$C_CYAN" "$name" "$C_RESET" "$input_json" >&2 + # Only render the call header if we did NOT stream (streaming already + # rendered it). Either way, record for /show-last-tool. + if [ "$used_stream" = "0" ]; then + display_tool_call "$name" "$input_json" + fi + _LARRY_LAST_TOOL_NAME="$name" + _LARRY_LAST_TOOL_INPUT="$input_json" log_section "tool: $name $(printf '%s' "$input_json" | jq -c .)" - local result; result=$(execute_tool "$name" "$input_json") + local result + result=$(execute_tool "$name" "$input_json") + # Wrap common jq malformed-json errors in tool results. + case "$result" in + *"jq: error"*"parse error"*) + result="Tool returned malformed JSON; raw body: $(printf '%s' "$result" | head -c 200)" + ;; + esac + _LARRY_LAST_TOOL_RESULT="$result" log_append '```'; log_append "$result"; log_append '```' # Tool results can be large (read_file up to 250KB, ssh_exec up to @@ -1181,9 +1824,13 @@ ${C_BOLD}Larry-Anywhere v$LARRY_VERSION${C_RESET} Slash commands: /quit /exit /q exit + /clear clear the terminal screen (distinct from /reset) + /copy copy last assistant response to clipboard + /cost show running token + dollar cost for the session + /show-last-tool print full last tool call + result (debug aid) /model switch model (e.g. /model claude-opus-4-7) /cd change working directory - /reset clear conversation history + /reset clear conversation history (keeps the log file) /load load file contents as your next user message /sys print the active system prompt /env print detected Cloverleaf env (HCIROOT, HCISITE, tools) @@ -1225,17 +1872,31 @@ Slash commands: /pwd show current working directory /help this help -Multi-line input: start with '<<' on its own line, end with 'EOF' on its own line. +Multi-line input: + - Explicit: '<<' on its own line, end with 'EOF' on its own line. + - Auto: paste any multi-line text — Larry slurps the whole paste in one + read (50ms buffer detection). + - Backslash: end a line with '\' to continue on the next; blank line ends. -TAB completion (v0.6.6): +@file inline-file syntax (v0.6.7): + Reference a file in your prompt with @; Larry resolves and inlines the + contents as a fenced code block. Examples: + @./README.md relative path (against current cwd) + @/etc/hosts absolute path + @{path with spaces.txt} bracketed form for paths containing spaces + Multiple refs in one prompt all get inlined. Email addresses (bryan@x.com) + are not matched. Binary files and files >250 KB are skipped/truncated with + a warning. TAB after @ autocompletes against files in cwd (fzf if installed). + +TAB completion (v0.6.6/v0.6.7): Type '/' followed by any prefix and press TAB. /h → /help - /ss → lists every /ssh-* command + /ss → lists every /ssh-* command with one-line descriptions /ssh-h → /ssh-hosts /q → /quit Subsequence fuzzy is the fallback when no prefix matches (e.g. /sssp finds - /ssh-setup). Non-slash input falls back to inserting a literal tab so - normal typing isn't disturbed. + /ssh-setup). After @, file-path completion kicks in instead. Non-slash + input falls back to a literal tab. EOF } @@ -1309,6 +1970,54 @@ _LARRY_SLASH_CMDS=( /model /cd /load + /clear + /copy + /cost + /show-last-tool +) + +# _LARRY_SLASH_CMDS_DESC — one-line descriptions for each slash command. +# Used by TAB completion to render multi-match lists with context. Keep in +# sync with _LARRY_SLASH_CMDS above and with print_help below. +# Requires bash 4+ for associative arrays. We already require bash 4 elsewhere +# (bind -x, READLINE_LINE) so this adds no new constraint, but on systems +# where this parses but isn't supported the lookup just returns empty. +declare -A _LARRY_SLASH_CMDS_DESC 2>/dev/null || true +_LARRY_SLASH_CMDS_DESC=( + [/help]="show this help" + [/quit]="exit" + [/sys]="print the active system prompt" + [/pwd]="show current working directory" + [/env]="print detected Cloverleaf env (HCIROOT, HCISITE, tools)" + [/auth]="show OAuth status (or not authenticated)" + [/login]="run OAuth login flow (switch to subscription auth)" + [/logout]="delete OAuth tokens (revert to API-key auth)" + [/oauth-debug]="dump full OAuth diagnostic" + [/lesson]=" capture a lesson for paste-back to home-Larry" + [/lessons]="list all captured lessons (newest first)" + [/export]="dump the lesson bundle for paste-back" + [/phi]=" tokenize a PHI value locally" + [/unmask]=" show original PHI for a token" + [/tokens]="show full local PHI <-> token lookup table" + [/ssh]=" run command on the remote" + [/ssh-hosts]="list configured remote hosts" + [/ssh-add]=" register a new host" + [/ssh-remove]=" remove a host" + [/ssh-pass]=" set/update password (hidden input)" + [/ssh-setup]=" open a long-lived ControlMaster" + [/ssh-close]=" close the ControlMaster" + [/ssh-status]="show open ControlMaster sessions + cred presence" + [/redetect]="re-scan for HCIROOT/HCISITE/tools" + [/sites]="list site dirs under HCIROOT" + [/site]=" switch HCISITE for this session" + [/reset]="clear conversation history (keeps log)" + [/model]=" switch model (e.g. /model claude-opus-4-7)" + [/cd]=" change working directory" + [/load]=" load file contents as your next user message" + [/clear]="clear the terminal screen" + [/copy]="copy last assistant response to clipboard" + [/cost]="show running token + dollar cost for the session" + [/show-last-tool]="print full last tool call + result for debugging" ) # __larry_complete_slash — bound to TAB via `bind -x` (see _install_readline_tab). @@ -1336,6 +2045,39 @@ _LARRY_SLASH_CMDS=( __larry_complete_slash() { local line="$READLINE_LINE" local point="${READLINE_POINT:-0}" + + # @file completion (v0.6.7 item 12): if the cursor is on (or right after) an + # @ token, complete file paths instead of slash commands. + # Find the start of the @ token at the cursor. + local pre="${line:0:point}" + # Look for a trailing @ chunk in pre. + local at_token="" + case "$pre" in + *@*) + # Extract from the last @ in pre to the cursor. + local tail_at="${pre##*@}" + # The character BEFORE the @ matters: if it's a non-whitespace char + # (e.g., bryan@example.com) we skip — that's an email, not a file ref. + local before_at="${pre%@*}" + local last_char="${before_at: -1}" + if [ -z "$last_char" ] || [[ "$last_char" =~ [[:space:]] ]]; then + # Eligible @-ref. The token candidate is everything after the @ up to + # the cursor, with no embedded whitespace. + case "$tail_at" in + *[[:space:]]*) ;; # whitespace seen — cursor is past the token + *) at_token="$tail_at" ;; + esac + fi + ;; + esac + + if [ -n "$at_token" ] || [ "$pre" = "${pre%@}@" ]; then + # Note: empty at_token (just typed @) also enters this branch via the + # second clause; in that case at_token="" and we list everything from CWD. + __larry_complete_atfile "$at_token" + return 0 + fi + # Only complete when the buffer is a single token starting with '/'. # If there's whitespace before the cursor, we treat it as "user typing # arguments to a command", not "user wants to complete the command name". @@ -1397,16 +2139,92 @@ __larry_complete_slash() { READLINE_LINE="${matches[0]} " READLINE_POINT=${#READLINE_LINE} elif [ "${#matches[@]}" -gt 1 ]; then - # Multiple matches: print them on a new line, then let readline redisplay - # the prompt with the user's current buffer intact. - printf '\n' - printf ' %s' "${matches[@]}" + # Multiple matches (v0.6.7 polish): print each on its own line with the + # one-line description from _LARRY_SLASH_CMDS_DESC. Readline redisplays + # the prompt + current buffer on return. printf '\n' + local m + for m in "${matches[@]}"; do + local desc="${_LARRY_SLASH_CMDS_DESC[$m]:-}" + if [ -n "$desc" ]; then + printf ' %s%-20s%s %s%s%s\n' "$C_CYAN" "$m" "$C_RESET" "$C_DIM" "$desc" "$C_RESET" + else + printf ' %s%s%s\n' "$C_CYAN" "$m" "$C_RESET" + fi + done # READLINE_LINE / READLINE_POINT stay as-is so the user sees their input. fi # Zero matches → silent no-op (the user's typo stays on screen so they can fix it). } +# __larry_complete_atfile PARTIAL +# Complete a file path for an @ reference. Uses fzf if on PATH for +# an interactive picker; otherwise lists matches under the prompt and (if +# exactly one) completes inline. +__larry_complete_atfile() { + local partial="$1" + local line="$READLINE_LINE" + local point="${READLINE_POINT:-0}" + local pre="${line:0:point}" + local post="${line:point}" + + # Find the @-anchor in pre so we can replace from there. + local at_idx="${pre%@*}" + local at_pos="${#at_idx}" # position of the '@' itself + + # Build candidate list. find rooted at CWD, depth 4, exclude dotdirs and + # common heavy dirs. + local candidates=() + while IFS= read -r f; do + [ -n "$f" ] && candidates+=("$f") + done < <( + find . -maxdepth 4 -type f \ + \( -path '*/.git' -o -path '*/node_modules' -o -path '*/__pycache__' -o -path '*/.venv' \) -prune -o \ + -type f -print 2>/dev/null \ + | sed 's|^\./||' \ + | ( + if [ -n "$partial" ]; then + # Case-insensitive substring filter on the partial. + local lc; lc=$(printf '%s' "$partial" | tr '[:upper:]' '[:lower:]') + awk -v p="$lc" 'BEGIN{IGNORECASE=1} index(tolower($0), p) > 0' 2>/dev/null \ + || grep -i -F "$partial" + else + cat + fi + ) \ + | head -200 + ) + + if [ "${#candidates[@]}" -eq 0 ]; then + # Nothing matched — silent no-op (user can keep typing). + return 0 + fi + + local chosen="" + if [ "${#candidates[@]}" -eq 1 ]; then + chosen="${candidates[0]}" + elif command -v fzf >/dev/null 2>&1 && [ -t 0 ] && [ -t 1 ]; then + # Interactive picker via fzf. + chosen=$(printf '%s\n' "${candidates[@]}" | fzf --height=40% --reverse --query="$partial" 2>/dev/null || true) + # Readline got blown away by fzf — force a redraw. + printf '\n' + else + # Print the list, no inline completion. + printf '\n' + local c + for c in "${candidates[@]}"; do + printf ' %s@%s%s\n' "$C_CYAN" "$c" "$C_RESET" + done + return 0 + fi + + if [ -n "$chosen" ]; then + # Replace pre's @ with @ + space, then re-append post. + READLINE_LINE="${pre:0:at_pos}@${chosen} ${post}" + READLINE_POINT=$((at_pos + 1 + ${#chosen} + 1)) + fi +} + # _install_readline_tab — wire TAB to the slash-completer for the lifetime # of the REPL. Safe to call multiple times (bind is idempotent for the same # key). No-op if `bind` isn't a builtin in this bash (e.g. non-interactive @@ -1421,27 +2239,68 @@ read_user_input() { # Returns user input via global LARRY_INPUT. # If first line is "<<", read until line "EOF" (heredoc-style). # - # Uses readline editing (-e) so backspace, arrow keys, and history work - # correctly across terminals (MobaXterm/Cygwin in particular often has - # stty erase mismatches that swallow plain `read`'s backspace). We pass - # the prompt via -p so readline knows the visible width. + # v0.6.7 additions: + # - Prompt includes the model short name: you[sonnet-4.6]> + # - Multi-line paste auto-detection: if the first read returns data AND + # more is buffered within 50ms, slurp it as continuation. Also triggers + # auto-heredoc if first line ends with backslash. + # - History: persists across sessions via $HISTFILE (set in main_loop). # - # v0.6.6: TAB on the first line is bound to __larry_complete_slash for - # slash-command completion. Continuation lines of a '<<' heredoc DO NOT - # get completion (we only `bind -x` ahead of the first-line read, not - # inside the heredoc loop), keeping the heredoc's "raw text" semantics. + # Uses readline editing (-e) so backspace, arrow keys, and history work + # correctly across terminals. LARRY_INPUT="" local first + local short; short=$(model_short_name) if [ -t 0 ] && _readline_ok; then - local prompt; prompt=$(printf '%syou>%s ' "$C_GREEN" "$C_RESET") + local prompt; prompt=$(printf '%syou[%s]>%s ' "$C_GREEN" "$short" "$C_RESET") # Clear the prompt the caller already printed, then re-emit via readline. printf '\r\033[K' _install_readline_tab IFS= read -e -r -p "$prompt" first || return 1 [ -n "$first" ] && history -s "$first" + # Persist non-sensitive lines to HISTFILE. + if [ -n "$first" ] && [ -n "${HISTFILE:-}" ]; then + case "$first" in + /login*|/ssh-pass*|/ssh-add*) ;; # never persist credential-bearing lines + *) history -a 2>/dev/null || true ;; + esac + fi else IFS= read -r first || return 1 fi + + # Auto-heredoc: trailing backslash means "I have more to type, please slurp + # additional lines until I send a blank one". + if [ -n "$first" ] && [ "${first: -1}" = "\\" ]; then + LARRY_INPUT="${first%\\}"$'\n' + local cont + while IFS= read -r cont; do + [ -z "$cont" ] && break + [ "${cont: -1}" = "\\" ] && cont="${cont%\\}" + LARRY_INPUT+="$cont"$'\n' || true + done + return 0 + fi + + # Multi-line paste auto-detection: bash `read -e` returns ONE line at a time + # but if a paste contains newlines, the rest sits in the input buffer. We + # check non-blockingly for buffered chars within 50ms. + if [ -t 0 ] && [ -n "$first" ]; then + local extra="" + # Read one char at a time, up to 50ms per char. Bail when no more input. + while IFS= read -r -t 0.05 -N 1 ch 2>/dev/null; do + extra+="$ch" + # Cap at 64KB to avoid runaway buffer hangs. + [ "${#extra}" -ge 65536 ] && break + done + if [ -n "$extra" ]; then + LARRY_INPUT="$first"$'\n'"$extra" + # Strip trailing newline if any. + LARRY_INPUT="${LARRY_INPUT%$'\n'}" + return 0 + fi + fi + if [ "$first" = "<<" ]; then local line while IFS= read -r line; do @@ -1464,6 +2323,18 @@ _readline_ok() { main_loop() { local system_prompt; system_prompt=$(build_system_prompt) + # ── Persistent command history (v0.6.7) ──────────────────────────────────── + # HISTFILE persists across `larry` invocations; HISTSIZE caps in-memory size. + # /login and /ssh-pass entries are filtered out in read_user_input before + # `history -a` runs. + export HISTFILE="${HISTFILE:-$LARRY_HOME/.history}" + export HISTSIZE=1000 + export HISTFILESIZE=1000 + # Avoid duplicate consecutive entries. + export HISTCONTROL="ignoredups" + # Load existing history. -r reads HISTFILE into memory; safe if file missing. + history -r 2>/dev/null || true + if [ -n "$ARG_DIR" ]; then if [ -d "$ARG_DIR" ]; then cd "$ARG_DIR" @@ -1498,7 +2369,8 @@ main_loop() { echo "" while true; do - printf '%syou>%s ' "$C_GREEN" "$C_RESET" + local _short; _short=$(model_short_name) + printf '%syou[%s]>%s ' "$C_GREEN" "$_short" "$C_RESET" if ! read_user_input; then echo ""; break fi @@ -1508,6 +2380,33 @@ main_loop() { case "$input" in /quit|/exit|/q) larry_say "bye."; break ;; /help) print_help; continue ;; + /clear) printf '\033[2J\033[H'; continue ;; + /copy) + if [ -z "$_LARRY_LAST_ASSISTANT_TEXT" ]; then + err "no assistant response yet to copy" + continue + fi + local clip; clip=$(detect_clipboard) + if [ -z "$clip" ]; then + warn "no clipboard tool detected — printing instead" + printf '%s\n' "$_LARRY_LAST_ASSISTANT_TEXT" + else + printf '%s' "$_LARRY_LAST_ASSISTANT_TEXT" | eval "$clip" \ + && larry_say "copied last response ($(printf '%s' "$_LARRY_LAST_ASSISTANT_TEXT" | wc -c | tr -d ' ') bytes) via $clip" + fi + continue ;; + /cost) print_cost_summary; continue ;; + /show-last-tool) + if [ -z "$_LARRY_LAST_TOOL_NAME" ]; then + err "no tool calls yet this session" + else + printf '%s%s▶ %s%s\n' "$C_CYAN" "$C_BOLD" "$_LARRY_LAST_TOOL_NAME" "$C_RESET" + printf '%sinput:%s\n' "$C_BOLD" "$C_RESET" + printf '%s' "$_LARRY_LAST_TOOL_INPUT" | jq . 2>/dev/null || printf '%s\n' "$_LARRY_LAST_TOOL_INPUT" + printf '\n%sresult:%s\n' "$C_GREEN$C_BOLD" "$C_RESET" + printf '%s\n' "$_LARRY_LAST_TOOL_RESULT" + fi + continue ;; /sys) printf '%s\n' "$system_prompt"; continue ;; /pwd) echo "$(pwd)"; continue ;; /env) printf '%s\n' "$CLOVERLEAF_CTX"; continue ;; @@ -1615,6 +2514,15 @@ main_loop() { /*) err "unknown command: $input (try /help)"; continue ;; esac + # @file preprocessing (v0.6.7 item 12): inline file contents BEFORE PHI + # tokenization so PHI markers inside attached files get caught. + case "$input" in + *@*) + maybe_show_atfile_tip "$input" + input=$(preprocess_atfile_refs "$input") + ;; + esac + # PHI preprocessing: replace any {{phi:VALUE}} markers with local tokens # BEFORE the input enters conversation history and gets sent to Anthropic. if [[ "$input" == *"{{phi:"* ]] || [[ "$input" == *"@@"* ]]; then