#!/usr/bin/env bash # larry-anywhere — portable Larry for remote shells (Linux + MobaXterm) # Single file. No installs. curl + jq + bash. # # Usage: # larry.sh # interactive in $PWD # larry.sh /path/to/cloverleaf/root # interactive, cd into that path first # larry.sh --no-update # skip self-update # larry.sh --version # print version and exit # larry.sh --help # print help and exit # # Env vars: # LARRY_HOME where to cache config/sessions (default: ~/.larry) # LARRY_BASE_URL root URL of the bundle on the server (default: # https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main) # Self-update pulls VERSION + MANIFEST from here and # refreshes every file listed in MANIFEST. # LARRY_UPDATE_URL (legacy override) full URL of latest larry.sh # LARRY_AGENTS_URL (legacy override) base URL for agents/ # LARRY_MODEL Claude model (default: claude-sonnet-4-6) # LARRY_MAX_TOKENS max output tokens per turn (default: 8192) # LARRY_NO_UPDATE set to 1 to disable self-update # ANTHROPIC_API_KEY overrides $LARRY_HOME/.env if set # # Slash commands during chat: # /quit /exit /q exit # /model switch model for this session # /cd change working directory # /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 # /status force-render the persistent status line (ctx + rate-limit) # /show-last-tool print last tool call + result (debug) # /help this help # # Env knobs (v0.6.9): # LARRY_NO_STATUS=1 disable the status line above the prompt # # 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.7.1" 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}" LARRY_AGENTS_URL="${LARRY_AGENTS_URL:-${LARRY_BASE_URL}/agents}" LARRY_MODEL="${LARRY_MODEL:-claude-sonnet-4-6}" LARRY_MAX_TOKENS="${LARRY_MAX_TOKENS:-8192}" LARRY_API_URL="${LARRY_API_URL:-https://api.anthropic.com/v1/messages}" LARRY_NO_UPDATE="${LARRY_NO_UPDATE:-0}" # ───────────────────────────────────────────────────────────────────────────── # Colors (only if stdout is a tty) # ───────────────────────────────────────────────────────────────────────────── if [ -t 1 ]; then C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m' C_BLUE=$'\033[34m'; C_MAGENTA=$'\033[35m'; C_CYAN=$'\033[36m' else C_RESET=''; C_BOLD=''; C_DIM=''; C_RED=''; C_GREEN='' C_YELLOW=''; C_BLUE=''; C_MAGENTA=''; C_CYAN='' fi log() { printf '%s[%s]%s %s\n' "$C_DIM" "$(date +%H:%M:%S)" "$C_RESET" "$*" >&2; } err() { printf '%serror:%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; } warn() { printf '%swarn:%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } larry_say() { printf '%s%slarry>%s %s\n' "$C_MAGENTA" "$C_BOLD" "$C_RESET" "$*"; } # ───────────────────────────────────────────────────────────────────────────── # CLI args # ───────────────────────────────────────────────────────────────────────────── ARG_DIR="" for arg in "$@"; do case "$arg" in --version|-V) echo "larry-anywhere $LARRY_VERSION"; 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" ;; esac done # ───────────────────────────────────────────────────────────────────────────── # Dependency check # ───────────────────────────────────────────────────────────────────────────── need_cmd() { command -v "$1" >/dev/null 2>&1 || { err "missing required command: $1"; exit 1; } } need_cmd curl # jq: allow a local copy in $LARRY_HOME/bin/jq as fallback if ! command -v jq >/dev/null 2>&1; then if [ -x "$LARRY_HOME/bin/jq" ]; then PATH="$LARRY_HOME/bin:$PATH" else err "missing jq. Install via your shell's package mechanism, or place a static jq binary at $LARRY_HOME/bin/jq" err "Download: https://github.com/jqlang/jq/releases (pick the static binary for your OS)" exit 1 fi fi # jqpath PATH — translate a path for jq's argv consumption. # On MobaXterm/Cygwin/MSYS the bundled jq is a Windows-native jq.exe that # rejects Cygwin paths like /tmp/tmp.X or /home/mobaxterm/.larry/... when # they come in as argv arguments (it tries to open them as Windows paths # and fails). cygpath -w translates Cygwin → Windows; jq.exe can then open # the file. On Linux/macOS cygpath does not exist and we echo the path # unchanged. Wrap EVERY --rawfile / --slurpfile path with $(jqpath "$p"). jqpath() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1" else printf '%s' "$1" fi } # ───────────────────────────────────────────────────────────────────────────── # Bootstrap LARRY_HOME and API key # ───────────────────────────────────────────────────────────────────────────── mkdir -p "$LARRY_HOME/agents" "$LARRY_HOME/sessions" "$LARRY_HOME/bin" 2>/dev/null || { err "cannot create $LARRY_HOME — set LARRY_HOME to a writable path and retry"; exit 1; } chmod 700 "$LARRY_HOME" 2>/dev/null || true # ───────────────────────────────────────────────────────────────────────────── # Authentication — two modes, OAuth preferred when available: # 1. OAuth subscription auth (bills against your Claude Max/Pro subscription). # Token file at $LARRY_HOME/.oauth.json — managed by larry-auth.sh. # 2. API key (separate pay-as-you-go API billing). Stored in $LARRY_HOME/.env. # ───────────────────────────────────────────────────────────────────────────── LARRY_AUTH_MODE="" # set later: "oauth" or "apikey" if [ -f "$LARRY_HOME/.oauth.json" ]; then LARRY_AUTH_MODE="oauth" elif [ -z "${ANTHROPIC_API_KEY:-}" ]; then if [ -f "$LARRY_HOME/.env" ]; then # shellcheck disable=SC1091 set -a; . "$LARRY_HOME/.env"; set +a fi [ -n "${ANTHROPIC_API_KEY:-}" ] && LARRY_AUTH_MODE="apikey" else LARRY_AUTH_MODE="apikey" fi prompt_first_run_auth() { printf '%sFirst-run authentication setup%s\n\n' "$C_BOLD" "$C_RESET" cat </dev/null read -r key stty echo 2>/dev/null echo "" if [ -z "$key" ]; then err "no key entered"; exit 1; fi umask 077 printf 'ANTHROPIC_API_KEY=%s\n' "$key" > "$LARRY_HOME/.env" chmod 600 "$LARRY_HOME/.env" ANTHROPIC_API_KEY="$key" log "API key saved." } # NOTE: the auth-prompt CALL (prompt_first_run_auth) is deliberately deferred # until AFTER self_update has run — otherwise a broken lib/oauth.sh traps the # user before the auto-update mechanism gets a chance to fix it. See call site # below the self_update block. # ───────────────────────────────────────────────────────────────────────────── # Fetch agents if missing # ───────────────────────────────────────────────────────────────────────────── LARRY_AGENT_FILES="larry.md clover.md cloverleaf-cheatsheet.md regress.md" fetch_agents_or_warn() { local need=0 for f in larry.md clover.md; do [ -f "$LARRY_HOME/agents/$f" ] || need=1 done [ "$need" = "0" ] && return 0 if [ -n "$LARRY_AGENTS_URL" ]; then log "fetching agent definitions from $LARRY_AGENTS_URL" for f in $LARRY_AGENT_FILES; do curl -fsSL --max-time 10 "$LARRY_AGENTS_URL/$f" -o "$LARRY_HOME/agents/$f" \ || { warn "could not fetch $f — using built-in fallback"; write_fallback_agent "$f"; } done else warn "agent files missing and LARRY_AGENTS_URL not set — writing built-in fallback (larry+clover only)" write_fallback_agent larry.md write_fallback_agent clover.md fi } write_fallback_agent() { case "$1" in larry.md) cat > "$LARRY_HOME/agents/larry.md" <<'AGENT_EOF' You are Larry, Bryan's team orchestrator at myPKA, running in portable mode on a remote shell. First sentence when asked who you are: "I'm Larry, your team orchestrator at myPKA (running portable mode)." Focus: Cloverleaf interface build and Netconfig analysis. No PHI involved. No production push. Tools available: read_file, list_dir, grep_files, glob_files, write_file (Y/N confirm), bash_exec (Y/N confirm). Style: concise, direct, cite path:line for code references. Ask one tight clarifying question only if a critical detail is missing. AGENT_EOF ;; clover.md) cat > "$LARRY_HOME/agents/clover.md" <<'AGENT_EOF' When the task is Cloverleaf-specific, channel Clover, Cloverleaf Integration Expert. Focus: UPOC TCL coding, interface specs, clean documentation. Idempotent, auditable, source-cited. Output: one-line status, artifact list, anomalies/open questions. AGENT_EOF ;; esac } fetch_agents_or_warn # ───────────────────────────────────────────────────────────────────────────── # Self-update — two-phase MANIFEST-driven sync. # # Phase A (local sync, no network if up-to-date): # If $LARRY_HOME/.last-sync-version != $LARRY_VERSION, the running larry.sh # is newer than the on-disk lib/agents/etc. files. Fetch MANIFEST from # $LARRY_BASE_URL and refresh every file listed. Stamp .last-sync-version. # # Phase B (remote version check): # Fetch $LARRY_BASE_URL/VERSION. If remote > local, pull new larry.sh, # replace self, relaunch with LARRY_JUST_UPDATED=1 so phase B is skipped # on the relaunch (avoids infinite loop). Phase A on the relaunch then # pulls every other file matching the new version. # # Skip all of it via --no-update or LARRY_NO_UPDATE=1. # ───────────────────────────────────────────────────────────────────────────── sync_from_manifest() { local base="$1" local manifest="$LARRY_HOME/.manifest.new" curl -fsSL --max-time 10 "$base/MANIFEST" -o "$manifest" 2>/dev/null || { rm -f "$manifest" return 1 } [ -s "$manifest" ] || { rm -f "$manifest"; return 1; } local self="$0" case "$self" in /*) ;; *) self="$PWD/$self" ;; esac local count=0 updated=0 failed=0 path tmp dest while IFS= read -r path; do case "$path" in ''|'#'*) continue ;; esac path="${path%%[[:space:]]*}" # strip trailing whitespace/comments [ -z "$path" ] && continue count=$((count + 1)) # larry.sh is updated by phase B, not here — skip to avoid clobbering # the running script mid-execution. [ "$path" = "larry.sh" ] && continue dest="$LARRY_HOME/$path" tmp="$dest.new" mkdir -p "$(dirname "$dest")" 2>/dev/null if curl -fsSL --max-time 15 "$base/$path" -o "$tmp" 2>/dev/null && [ -s "$tmp" ]; then if [ ! -f "$dest" ] || ! cmp -s "$dest" "$tmp"; then mv "$tmp" "$dest" case "$path" in *.sh) chmod +x "$dest" 2>/dev/null || true ;; esac updated=$((updated + 1)) else rm -f "$tmp" fi else rm -f "$tmp" failed=$((failed + 1)) fi done < "$manifest" rm -f "$manifest" if [ "$updated" -gt 0 ] || [ "$failed" -gt 0 ]; then log "manifest sync: $updated updated, $failed failed, $count total (from $base)" fi LARRY_SYNC_UPDATED_COUNT="$updated" LARRY_SYNC_FAILED_COUNT="$failed" return 0 } self_update() { [ "$LARRY_NO_UPDATE" = "1" ] && return 0 [ -z "$LARRY_BASE_URL" ] && return 0 local self="$0" case "$self" in /*) ;; *) self="$PWD/$self" ;; esac # Phase A: local file sync. Triggered when on-disk files are out of sync # with the running larry.sh version (e.g. just after a self-replace, or # on first launch after install). local last_sync="" [ -f "$LARRY_HOME/.last-sync-version" ] \ && last_sync=$(tr -d '[:space:]' < "$LARRY_HOME/.last-sync-version" 2>/dev/null) if [ "$last_sync" != "$LARRY_VERSION" ]; then LARRY_SYNC_UPDATED_COUNT=0 LARRY_SYNC_FAILED_COUNT=0 if sync_from_manifest "$LARRY_BASE_URL"; then printf '%s\n' "$LARRY_VERSION" > "$LARRY_HOME/.last-sync-version" 2>/dev/null || true if [ "${LARRY_JUST_UPDATED:-0}" = "1" ] && [ -n "${LARRY_PREV_VERSION:-}" ]; then # We came in via a phase-B self-replace; phase A then synced the rest. LARRY_UPDATE_NOTICE="updated v${LARRY_PREV_VERSION} → v${LARRY_VERSION} (${LARRY_SYNC_UPDATED_COUNT} files synced from manifest)" elif [ "$LARRY_SYNC_UPDATED_COUNT" -gt 0 ]; then if [ -n "$last_sync" ]; then LARRY_UPDATE_NOTICE="manifest sync v${last_sync} → v${LARRY_VERSION} (${LARRY_SYNC_UPDATED_COUNT} files updated)" else LARRY_UPDATE_NOTICE="first-run sync at v${LARRY_VERSION} (${LARRY_SYNC_UPDATED_COUNT} files synced from manifest)" fi fi fi fi # Phase B: skip the network version check on the relaunch right after a # self-replace (we just pulled it; checking again is pointless and risks # loops if curl returns stale/partial content). [ "${LARRY_JUST_UPDATED:-0}" = "1" ] && return 0 [ -w "$self" ] || return 0 local remote_ver remote_ver=$(curl -fsSL --max-time 5 "$LARRY_BASE_URL/VERSION" 2>/dev/null | tr -d '[:space:]') [ -z "$remote_ver" ] && return 0 [ "$remote_ver" = "$LARRY_VERSION" ] && return 0 local tmp="$LARRY_HOME/larry.sh.new" curl -fsSL --max-time 15 "$LARRY_BASE_URL/larry.sh" -o "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; } [ -s "$tmp" ] || { rm -f "$tmp"; return 0; } if cmp -s "$self" "$tmp"; then rm -f "$tmp" return 0 fi local new_ver new_ver=$(grep -m1 '^LARRY_VERSION=' "$tmp" | sed 's/.*"\(.*\)".*/\1/') [ -z "$new_ver" ] && { rm -f "$tmp"; return 0; } log "update found: $LARRY_VERSION -> $new_ver — relaunching" cp "$tmp" "$self" && chmod +x "$self" rm -f "$tmp" # Force phase A on the next launch by invalidating the sync stamp. rm -f "$LARRY_HOME/.last-sync-version" 2>/dev/null || true exec env LARRY_JUST_UPDATED=1 LARRY_PREV_VERSION="$LARRY_VERSION" "$self" ${ARG_DIR:+"$ARG_DIR"} } self_update # ── Deferred auth prompt ──────────────────────────────────────────────────── # Now that self_update has had a chance to refresh lib/oauth.sh, gate on # credentials. On a fresh box (no .oauth.json, no API key) this is the first # interactive prompt the user sees. if [ -z "$LARRY_AUTH_MODE" ]; then prompt_first_run_auth fi # ───────────────────────────────────────────────────────────────────────────── # Cloverleaf environment detection # Surfaces HCIROOT / HCISITE / HCISITEDIR and which tool layer is present # (modern cloverleaf-tools.pyz, classic Eric scripts, or neither). # Result is appended to the system prompt so the model knows where it is. # ───────────────────────────────────────────────────────────────────────────── detect_cloverleaf_env() { CLOVERLEAF_CTX="" local lines=() if [ -n "${HCIROOT:-}" ]; then lines+=("HCIROOT=$HCIROOT (exists=$([ -d "$HCIROOT" ] && echo yes || echo no))") else lines+=("HCIROOT=") fi if [ -n "${HCISITE:-}" ]; then local sitedir="${HCISITEDIR:-${HCIROOT:-}/$HCISITE}" lines+=("HCISITE=$HCISITE") lines+=("HCISITEDIR=$sitedir (exists=$([ -d "$sitedir" ] && echo yes || echo no))") if [ -d "$sitedir" ]; then [ -f "$sitedir/NetConfig" ] && lines+=("NetConfig present: $(wc -l < "$sitedir/NetConfig" | tr -d ' ') lines, $(wc -c < "$sitedir/NetConfig" | tr -d ' ') bytes") [ -d "$sitedir/Xlate" ] && lines+=("Xlate/: $(find "$sitedir/Xlate" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") [ -d "$sitedir/tables" ] && lines+=("tables/: $(find "$sitedir/tables" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") [ -d "$sitedir/tclprocs" ] && lines+=("tclprocs/: $(find "$sitedir/tclprocs" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") [ -d "$sitedir/formats" ] && lines+=("formats/: $(find "$sitedir/formats" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") fi else lines+=("HCISITE=") fi if [ -n "${HCIROOT:-}" ] && [ -d "$HCIROOT" ]; then local site_count site_count=$(find "$HCIROOT" -mindepth 1 -maxdepth 1 -type d \ ! -name 'archiving' ! -name 'master' ! -name 'lib' ! -name 'tcl' ! -name 'server' \ ! -name 'client' ! -name 'clgui' ! -name 'cchgs' ! -name 'epic*' ! -name 'beaker' \ ! -name 'Alerts' ! -name 'AppDefaults' ! -name 'Tables' ! -name 'backup*' \ 2>/dev/null | wc -l | tr -d ' ') lines+=("HCIROOT site-like subdirs: $site_count") fi # Tool layer detection local pyz_path="" if command -v cloverleaf-tools.pyz >/dev/null 2>&1; then pyz_path=$(command -v cloverleaf-tools.pyz) elif [ -x "./cloverleaf-tools.pyz" ]; then pyz_path="$PWD/cloverleaf-tools.pyz" elif [ -n "${HCIROOT:-}" ] && [ -x "$HCIROOT/cloverleaf-tools.pyz" ]; then pyz_path="$HCIROOT/cloverleaf-tools.pyz" fi if [ -n "$pyz_path" ]; then lines+=("Modern tools: cloverleaf-tools.pyz at $pyz_path") fi # Classic Eric scripts — detect a representative few local classic_found="" for c in tbn tbp tbh tbpr hlq mr mp mg hl awkcut sites each_site list_full_routes dbExtract; do command -v "$c" >/dev/null 2>&1 && classic_found+="$c " done if [ -n "$classic_found" ]; then lines+=("Classic tools on PATH: $classic_found") fi if [ -z "$pyz_path" ] && [ -z "$classic_found" ]; then lines+=("No Cloverleaf-tooling on PATH — Larry will fall back to bash one-liners only.") fi # Compose for system prompt CLOVERLEAF_CTX=$'\n\n## Detected runtime context (read-only)\n' for ln in "${lines[@]}"; do CLOVERLEAF_CTX+="- $ln"$'\n' done } detect_cloverleaf_env # ───────────────────────────────────────────────────────────────────────────── # Session state # ───────────────────────────────────────────────────────────────────────────── SESSION_ID="$(date +%Y-%m-%d-%H%M%S)-$$" MESSAGES_FILE="$LARRY_HOME/sessions/$SESSION_ID.messages.json" LOG_FILE="$LARRY_HOME/sessions/$SESSION_ID.log.md" printf '[]' > "$MESSAGES_FILE" { echo "# Larry-Anywhere session $SESSION_ID" echo "- start: $(date -Iseconds 2>/dev/null || date)" echo "- model: $LARRY_MODEL" echo "- host: $(hostname 2>/dev/null || echo unknown)" echo "- pwd: $(pwd)" echo "" } > "$LOG_FILE" log_section() { printf '\n## %s\n' "$1" >> "$LOG_FILE"; } log_append() { printf '%s\n' "$1" >> "$LOG_FILE"; } # ───────────────────────────────────────────────────────────────────────────── # Message store helpers # ───────────────────────────────────────────────────────────────────────────── # NOTE on jq file IO: pass files to jq via stdin redirection, not as argv. # On MobaXterm/Cygwin the bundled jq is a Windows-native binary that can't # resolve Cygwin paths like /home/mobaxterm/... when they come in as argv. # Stdin redirection always works because bash does the path open() itself. # Each of these passes the value through a tempfile (--rawfile / --slurpfile) # rather than argv (--arg / --argjson). Argv overflow ("Argument list too # long") on Cygwin's ~32KB total cap was the v0.6.1 bug for TOOLS_JSON; the # same pattern applies to any value that could grow with user input or # assistant output (multi-paragraph prompts, large tool results, etc.). add_user_text() { local content="$1" local cfile tmp cfile=$(mktemp); tmp=$(mktemp) printf '%s' "$content" > "$cfile" jq --rawfile c "$(jqpath "$cfile")" '. + [{"role":"user","content":[{"type":"text","text":$c}]}]' < "$MESSAGES_FILE" > "$tmp" \ && mv "$tmp" "$MESSAGES_FILE" rm -f "$cfile" } add_assistant_blocks() { local blocks="$1" local bfile tmp bfile=$(mktemp); tmp=$(mktemp) printf '%s' "$blocks" > "$bfile" jq --slurpfile b "$(jqpath "$bfile")" '. + [{"role":"assistant","content":$b[0]}]' < "$MESSAGES_FILE" > "$tmp" \ && mv "$tmp" "$MESSAGES_FILE" rm -f "$bfile" } add_user_tool_results() { local blocks="$1" local bfile tmp bfile=$(mktemp); tmp=$(mktemp) printf '%s' "$blocks" > "$bfile" jq --slurpfile b "$(jqpath "$bfile")" '. + [{"role":"user","content":$b[0]}]' < "$MESSAGES_FILE" > "$tmp" \ && mv "$tmp" "$MESSAGES_FILE" rm -f "$bfile" } # ───────────────────────────────────────────────────────────────────────────── # Tool implementations # ───────────────────────────────────────────────────────────────────────────── tool_read_file() { local path="$1" if [ ! -e "$path" ]; then echo "ERROR: file not found: $path"; return; fi if [ ! -f "$path" ]; then echo "ERROR: not a regular file: $path"; return; fi local size; size=$(wc -c < "$path" 2>/dev/null || echo 0) if [ "$size" -gt 250000 ]; then echo "ERROR: file too large ($size bytes, limit 250KB). Use grep_files to target sections." return fi awk '{printf "%6d\t%s\n", NR, $0}' "$path" } tool_list_dir() { local path="${1:-.}" if [ ! -d "$path" ]; then echo "ERROR: not a directory: $path"; return; fi ls -la --color=never "$path" 2>/dev/null || ls -la "$path" } tool_grep_files() { local pattern="$1"; local path="${2:-.}" if [ ! -e "$path" ]; then echo "ERROR: path not found: $path"; return; fi local total total=$(grep -rnI --color=never -c "$pattern" "$path" 2>/dev/null \ | awk -F: '{s+=$NF} END {print s+0}') grep -rnI --color=never "$pattern" "$path" 2>/dev/null | head -300 if [ "$total" -gt 300 ]; then echo "── shown 300 / $total total matches — narrow your pattern, or use bash_exec for counts ──" fi } tool_glob_files() { local pattern="$1"; local path="${2:-.}" if [ ! -d "$path" ]; then echo "ERROR: not a directory: $path"; return; fi local all; all=$(find "$path" -type f -name "$pattern" 2>/dev/null) local total; total=$(printf '%s\n' "$all" | grep -c .) printf '%s\n' "$all" | head -300 if [ "$total" -gt 300 ]; then echo "── shown 300 / $total total entries — narrow your pattern ──" fi } tool_write_file() { local path="$1"; local content="$2" local exists="no"; [ -f "$path" ] && exists="yes" printf '\n%s══ write_file ══%s\n' "$C_YELLOW" "$C_RESET" >&2 printf ' path: %s\n' "$path" >&2 printf ' exists: %s\n' "$exists" >&2 printf ' bytes: %d\n' "${#content}" >&2 if [ "$exists" = "yes" ]; then local tmp; tmp=$(mktemp) printf '%s' "$content" > "$tmp" printf '%s── diff ──%s\n' "$C_DIM" "$C_RESET" >&2 diff -u "$path" "$tmp" >&2 || true rm -f "$tmp" else printf '%s── new file preview (first 40 lines) ──%s\n' "$C_DIM" "$C_RESET" >&2 printf '%s' "$content" | head -40 >&2 printf '\n' >&2 fi printf '%sApprove write? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 read -r answer /dev/null printf '%s' "$content" > "$path" echo "OK: wrote $(printf '%s' "$content" | wc -l | tr -d ' ') lines to $path" log_section "write_file $path (approved)"; log_append '```'; log_append "$content"; log_append '```' else echo "DENIED by user. No write performed." log_section "write_file $path (DENIED)" fi } # ───────────────────────────────────────────────────────────────────────────── # v3 NetConfig tools — first-class native capabilities for Cloverleaf work. # Implemented as small bash+awk scripts in lib/ (alongside this file or in # $LARRY_HOME/lib). They invoke nothing from v1 scripts or v2 .pyz. # ───────────────────────────────────────────────────────────────────────────── _resolve_lib_dir() { local self_dir; self_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd) for candidate in "$self_dir/lib" "$LARRY_HOME/lib"; do [ -d "$candidate" ] && [ -x "$candidate/nc-parse.sh" ] && { echo "$candidate"; return 0; } done return 1 } LARRY_LIB_DIR="$(_resolve_lib_dir || echo '')" # v0.7.0: HL7 v2.x schema for inline tab completion + /hl7 / /hl7-fields slash # commands. Sourced (not executed) so the bash assoc arrays live in our shell. # Silently no-ops on bash <4 (assoc arrays unavailable); the REPL still works, # just without HL7 tab completion. if [ -n "$LARRY_LIB_DIR" ] && [ -r "$LARRY_LIB_DIR/hl7-schema.sh" ]; then # shellcheck disable=SC1090,SC1091 . "$LARRY_LIB_DIR/hl7-schema.sh" 2>/dev/null || true fi _lib_err_if_missing() { [ -n "$LARRY_LIB_DIR" ] && return 0 echo "ERROR: lib/ tools not found. Looked in \$(dirname \$0)/lib and \$LARRY_HOME/lib." echo " Run install-larry.sh or scp the larry-anywhere/lib/ directory next to larry.sh." return 1 } tool_nc_list_protocols() { local nc="$1" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" list-protocols "$nc" 2>&1 } tool_nc_list_processes() { local nc="$1" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" list-processes "$nc" 2>&1 } tool_nc_protocol_block() { local nc="$1" name="$2" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" protocol-block "$nc" "$name" 2>&1 } tool_nc_protocol_field() { local nc="$1" name="$2" field="$3" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" protocol-field "$nc" "$name" "$field" 2>&1 } tool_nc_protocol_nested() { local nc="$1" name="$2" path="$3" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" protocol-nested "$nc" "$name" "$path" 2>&1 } tool_nc_protocol_summary() { local nc="$1" filter="${2:-}" _lib_err_if_missing || return if [ -n "$filter" ]; then "$LARRY_LIB_DIR/nc-parse.sh" protocol-summary "$nc" --filter "$filter" 2>&1 else "$LARRY_LIB_DIR/nc-parse.sh" protocol-summary "$nc" 2>&1 fi } tool_nc_destinations() { local nc="$1" name="$2" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" destinations "$nc" "$name" 2>&1 } tool_nc_xlate_refs() { local nc="$1" name="${2:-}" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" xlate-refs "$nc" "$name" 2>&1 } tool_nc_find_inbound() { local nc="$1" mode="${2:-all}" fmt="${3:-tsv}" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-inbound.sh" "$nc" --mode "$mode" --format "$fmt" 2>&1 } tool_nc_make_jump() { local nc="$1" inbound="$2" new_host="$3" jump_port="$4" local inbound_host="${5:-127.0.0.1}" proc_jump="${6:-server_jump}" encoding="${7:-}" _lib_err_if_missing || return local args=(--inbound "$inbound" --new-host "$new_host" --jump-port "$jump_port" \ --inbound-host "$inbound_host" --process-jump "$proc_jump") [ -n "$encoding" ] && args+=(--encoding "$encoding") "$LARRY_LIB_DIR/nc-make-jump.sh" "$nc" "${args[@]}" 2>&1 } tool_nc_sources() { local nc="$1" name="$2" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" sources "$nc" "$name" 2>&1 } tool_nc_tclproc_refs() { local nc="$1" name="${2:-}" _lib_err_if_missing || return "$LARRY_LIB_DIR/nc-parse.sh" tclproc-refs "$nc" "$name" 2>&1 } tool_hl7_field() { local message="$1" field_path="$2" _lib_err_if_missing || return local tmp; tmp=$(mktemp) printf '%s' "$message" > "$tmp" "$LARRY_LIB_DIR/hl7-field.sh" "$field_path" "$tmp" 2>&1 rm -f "$tmp" } tool_nc_msgs() { local thread="$1" after="${2:-}" before="${3:-}" mrn_field="${4:-}" mrn_value="${5:-}" local limit="${6:-10}" format="${7:-text}" sitedir="${8:-${HCISITEDIR:-}}" db_path="${9:-}" _lib_err_if_missing || return local args=("$thread" --limit "$limit" --format "$format") [ -n "$after" ] && args+=(--after "$after") [ -n "$before" ] && args+=(--before "$before") [ -n "$sitedir" ] && args+=(--sitedir "$sitedir") [ -n "$db_path" ] && args+=(--db "$db_path") if [ -n "$mrn_field" ] && [ -n "$mrn_value" ]; then args+=(--field "${mrn_field}=${mrn_value}") fi "$LARRY_LIB_DIR/nc-msgs.sh" "${args[@]}" 2>&1 } tool_nc_find() { local mode="$1" query="$2" format="${3:-table}" hciroot="${4:-${HCIROOT:-}}" _lib_err_if_missing || return local args=(--format "$format") [ -n "$hciroot" ] && args+=(--hciroot "$hciroot") case "$mode" in name|port|host|process|where|xlate|tclproc) args+=(--"$mode" "$query") ;; *) echo "ERROR: unknown nc_find mode: $mode"; return 1 ;; esac "$LARRY_LIB_DIR/nc-find.sh" "${args[@]}" 2>&1 } tool_nc_insert_protocol() { local nc="$1" block_text="$2" mode="${3:-end}" anchor="${4:-}" _lib_err_if_missing || return local tmp; tmp=$(mktemp) printf '%s' "$block_text" > "$tmp" local args=(insert "$nc" "$tmp" --mode "$mode") [ -n "$anchor" ] && args+=(--anchor "$anchor") # Inherit LARRY_SESSION_ID from the running session so journal entries group together LARRY_SESSION_ID="${LARRY_SESSION_ID:-$SESSION_ID}" \ "$LARRY_LIB_DIR/nc-insert-protocol.sh" "${args[@]}" 2>&1 local rc=$? rm -f "$tmp" return $rc } tool_nc_add_route() { local nc="$1" protocol_name="$2" route_text="$3" _lib_err_if_missing || return local tmp; tmp=$(mktemp) printf '%s' "$route_text" > "$tmp" LARRY_SESSION_ID="${LARRY_SESSION_ID:-$SESSION_ID}" \ "$LARRY_LIB_DIR/nc-insert-protocol.sh" add-route "$nc" "$protocol_name" "$tmp" 2>&1 local rc=$? rm -f "$tmp" return $rc } tool_nc_regression() { local scope="$1" count="$2" env_a="$3" site_a="$4" env_b="$5" site_b="$6" out_dir="$7" local route_cmd="${8:-}" ignore="${9:-MSH.7}" phase="${10:-all}" dry_run="${11:-0}" local source_ssh_alias="${12:-}" target_ssh_alias="${13:-}" _lib_err_if_missing || return local args=(--scope "$scope" --count "$count" --env-a "$env_a" --env-b "$env_b" --out "$out_dir" \ --ignore "$ignore" --phase "$phase") [ -n "$site_a" ] && args+=(--site-a "$site_a") [ -n "$site_b" ] && args+=(--site-b "$site_b") [ -n "$route_cmd" ] && args+=(--route-test-cmd "$route_cmd") [ "$dry_run" = "1" ] && args+=(--dry-run) [ -n "$source_ssh_alias" ] && args+=(--source-ssh-alias "$source_ssh_alias") [ -n "$target_ssh_alias" ] && args+=(--target-ssh-alias "$target_ssh_alias") # Pass our resolved lib dir so the regression script can reach ssh-helper.sh # without re-resolving from its own $0. LARRY_LIB_DIR="$LARRY_LIB_DIR" "$LARRY_LIB_DIR/nc-regression.sh" "${args[@]}" 2>&1 } tool_hl7_diff() { local left_path="$1" right_path="$2" ignore="${3:-MSH.7}" include="${4:-}" format="${5:-text}" _lib_err_if_missing || return local args=() [ -n "$ignore" ] && args+=(--ignore "$ignore") [ -n "$include" ] && args+=(--include-fields "$include") args+=(--format "$format" "$left_path" "$right_path") "$LARRY_LIB_DIR/hl7-diff.sh" "${args[@]}" 2>&1 } # ───────────────────────────────────────────────────────────────────────────── # PHI preprocessing — replace {{phi:VALUE}} or {{phi:CATEGORY:VALUE}} in user # input with a local deterministic token BEFORE sending to the API. Tokens # come from the same lookup table hl7-sanitize.sh maintains, so they correlate # with PHI sanitized out of file/smat content. # ───────────────────────────────────────────────────────────────────────────── preprocess_phi_markers() { local input="$1" local sanitize_script="$LARRY_LIB_DIR/hl7-sanitize.sh" [ -x "$sanitize_script" ] || { printf '%s' "$input"; return; } # Three forms supported (processed in this order to avoid ambiguity): # 1. @@VALUE@@ bracketed; VALUE has no '@' and uses single-space word # separation. Use for values WITH spaces. Auto-detect category. # 2. @@VALUE unbracketed; VALUE has no whitespace or '@'. Auto-detect. # 3. {{phi:V}} / {{phi:CAT:V}} legacy, still supported. # Helper: tokenize one VALUE (optional category) and substitute MARKER → token. _phi_sub() { local marker="$1" value="$2" category="${3:-}" local args=(tokenize-value) [ -n "$category" ] && args+=(--category "$category") args+=("$value") local token; token=$("$sanitize_script" "${args[@]}" 2>/dev/null) [ -z "$token" ] && token="[[PHI_ERROR]]" input="${input//"$marker"/"$token"}" printf '%sphi>%s %s → %s\n' "$C_YELLOW" "$C_RESET" "$marker" "$token" >&2 } # Pass 1: bracketed @@VALUE@@ — value has no '@', allows internal single spaces # but no leading/trailing whitespace inside the brackets. local bracketed bracketed=$(printf '%s' "$input" | grep -oE '@@[^@[:space:]]+([ \t]+[^@[:space:]]+)*@@' 2>/dev/null | sort -u) while IFS= read -r marker; do [ -z "$marker" ] && continue local val="${marker#@@}"; val="${val%@@}" _phi_sub "$marker" "$val" done <<< "$bracketed" # Pass 2: unbracketed @@VALUE — value has no whitespace or '@'. Anything that # was inside a bracketed marker has already been replaced with a [[TOK]], so # it won't be re-matched here. local unbracketed unbracketed=$(printf '%s' "$input" | grep -oE '@@[^@[:space:]]+' 2>/dev/null | sort -u) while IFS= read -r marker; do [ -z "$marker" ] && continue local val="${marker#@@}" _phi_sub "$marker" "$val" done <<< "$unbracketed" # Pass 3: legacy {{phi:VALUE}} / {{phi:CATEGORY:VALUE}}. local legacy legacy=$(printf '%s' "$input" | grep -oE '\{\{phi:[^{}]+\}\}' 2>/dev/null | sort -u) while IFS= read -r marker; do [ -z "$marker" ] && continue local body="${marker#\{\{phi:}"; body="${body%\}\}}" local cat="" val="" if [[ "$body" == *:* ]] && [[ "${body%%:*}" =~ ^[A-Z][A-Z0-9_]+$ ]]; then cat="${body%%:*}"; val="${body#*:}" else val="$body" fi _phi_sub "$marker" "$val" "$cat" done <<< "$legacy" unset -f _phi_sub printf '%s' "$input" } # ───────────────────────────────────────────────────────────────────────────── # v0.7.1 — Automatic PHI detection # # Runs BEFORE preprocess_phi_markers (so explicit markers still take precedence) # and BEFORE @file inline expansion has already been done (so file contents # don't get token-walked here — they're tokenized by hl7_sanitize when needed). # # Strategy: walk every whitespace-delimited token and decide one of: # * leave alone (path / URL / already-token / already-marker / timestamp) # * tokenize via hl7-sanitize.sh tokenize-value (same pipeline as manual) # # Bryan's directive: err on the side of caution. We tokenize anything that # *looks* like PHI as long as it doesn't interfere with required canonical # matching. The same tokenize-value pipeline handles normalization, so # different surface forms of the same value share one token across the session # and across sanitized files. # # Modes (env LARRY_AUTO_PHI or /auto-phi slash): # confirm (default) — prompt Y/n on first sighting of a name-like value # aggressive — tokenize every match silently # off — disable auto-detection entirely # # Per-turn override: prepend "!nophi " to skip auto-detection for that turn. # ───────────────────────────────────────────────────────────────────────────── # Mode (default confirm). Promoted to AUTO_PHI_MODE so /auto-phi can mutate it. AUTO_PHI_MODE="${LARRY_AUTO_PHI:-confirm}" # Per-session memory: declined values (user said "n" to confirm prompt) and # accepted values (cached so we don't re-prompt). Keys are normalized canonical # strings. For bash<4, fall back to two pipe-delimited strings. if (( BASH_VERSINFO[0] >= 4 )); then declare -A AUTO_PHI_ACCEPTED 2>/dev/null declare -A AUTO_PHI_DECLINED 2>/dev/null else AUTO_PHI_ACCEPTED_LIST="" AUTO_PHI_DECLINED_LIST="" fi AUTO_PHI_SESSION_COUNT=0 # Built-in allowlist of common non-PHI two-word phrases that match the loose # "Title Case Title Case" name pattern. Lowercased + sorted on lookup. # This is intentionally small — confirm-mode catches the rest interactively. _AUTO_PHI_NAME_ALLOWLIST=$(cat <<'EOF' home assistant mac studio mac mini mac pro mac book apple watch apple tv new york los angeles san francisco san diego las vegas united states united kingdom north america south america microsoft office google cloud amazon web visual studio sublime text android studio docker desktop node red linux mint windows server ubuntu server debian linux red hat oracle linux EOF ) _auto_phi_in_allowlist() { local v_lower v_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') grep -Fxq -- "$v_lower" <<< "$_AUTO_PHI_NAME_ALLOWLIST" } _auto_phi_seen_accepted() { local key="$1" if (( BASH_VERSINFO[0] >= 4 )); then [ -n "${AUTO_PHI_ACCEPTED[$key]:-}" ] else [[ "|$AUTO_PHI_ACCEPTED_LIST|" == *"|$key|"* ]] fi } _auto_phi_seen_declined() { local key="$1" if (( BASH_VERSINFO[0] >= 4 )); then [ -n "${AUTO_PHI_DECLINED[$key]:-}" ] else [[ "|$AUTO_PHI_DECLINED_LIST|" == *"|$key|"* ]] fi } _auto_phi_mark_accepted() { local key="$1" if (( BASH_VERSINFO[0] >= 4 )); then AUTO_PHI_ACCEPTED[$key]=1 else AUTO_PHI_ACCEPTED_LIST="${AUTO_PHI_ACCEPTED_LIST}|$key" fi } _auto_phi_mark_declined() { local key="$1" if (( BASH_VERSINFO[0] >= 4 )); then AUTO_PHI_DECLINED[$key]=1 else AUTO_PHI_DECLINED_LIST="${AUTO_PHI_DECLINED_LIST}|$key" fi } # _auto_phi_classify VALUE → echoes a category (EMAIL/SSN/PHONE/DOB/MRN/NAME/NAME_LOOSE) # or empty string if the value is not a tokenization candidate. _auto_phi_classify() { local v="$1" [ -z "$v" ] && return 0 # Already-token format: [[CATEGORY_NNNN]] — leave alone. [[ "$v" =~ ^\[\[[A-Z][A-Z0-9_]*_[0-9]+\]\]$ ]] && return 0 # Already-marker formats: @@VALUE@@, @@VALUE, {{phi:...}} — manual handles. [[ "$v" == @@* ]] && return 0 [[ "$v" == *@@ ]] && return 0 [[ "$v" == \{\{phi:* ]] && return 0 # Path-like — leave alone. case "$v" in /*|./*|../*|~/*) return 0 ;; [A-Z]:\\*) return 0 ;; esac # URL-like — leave alone. case "$v" in http://*|https://*|ssh://*|ftp://*|sftp://*|file://*|ws://*|wss://*) return 0 ;; esac # Strip a single trailing punctuation that is sentence-grammar, not part # of the value. Re-evaluate the cleaned form. local trimmed="$v" case "$trimmed" in *[.,\;:\!\?\)]) trimmed="${trimmed%?}" ;; esac # Email-like. Must have exactly one @, dotted domain. if [[ "$trimmed" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]; then printf 'EMAIL'; return fi # Long-digit timestamp guard FIRST (before phone/SSN/MRN checks). Pure # digits 13+ chars OR 10 chars starting with '1' (epoch seconds / millis). # These would otherwise match the bare-phone or MRN patterns. Leave alone. if [[ "$trimmed" =~ ^[0-9]+$ ]]; then local n="${#trimmed}" if [ "$n" -ge 13 ]; then return 0; fi if [ "$n" -eq 10 ] && [[ "$trimmed" == 1* ]]; then return 0; fi fi # SSN-like. 9 digits with optional dashes (must total exactly 9 digits). if [[ "$trimmed" =~ ^[0-9]{3}-?[0-9]{2}-?[0-9]{4}$ ]]; then local d="${trimmed//-/}" [ "${#d}" -eq 9 ] && { printf 'SSN'; return; } fi # Phone-like. The regex needs to match the FULL token, including a "(212)" # prefix when the next token is "555-1234". We can't see across token # boundaries here, so we accept the most-common single-token forms: # 555-123-4567 5551234567 555.123.4567 # 555 123 4567 (212)555-1234 (212)5551234 # Multi-token "(212) 555-1234" is reconstructed by the two-token-PHONE # pass below in auto_detect_phi (caller side). if [[ "$trimmed" =~ ^\(?[0-9]{3}\)?[-\.\ ]?[0-9]{3}[-\.\ ]?[0-9]{4}$ ]]; then # Distinguish from pure-digit MRN: 10-digit all-numeric reaches here # too. If trimmed is 10 pure digits starting with '1' we already # returned above (timestamp). Otherwise treat as PHONE. printf 'PHONE'; return fi # DOB / date-like. if [[ "$trimmed" =~ ^[0-9]{1,4}[/-][0-9]{1,2}[/-][0-9]{1,4}$ ]]; then printf 'DOB'; return fi # MRN-like: pure digits, 6-12 chars (conservative — see spec rule #9). if [[ "$trimmed" =~ ^[0-9]+$ ]]; then local n2="${#trimmed}" if [ "$n2" -ge 6 ] && [ "$n2" -le 12 ]; then printf 'MRN'; return fi return 0 fi # Name-like (HL7 carat). if [[ "$trimmed" =~ ^[A-Za-z]+\^[A-Za-z]+ ]]; then printf 'NAME'; return fi # The loose "Title Case Title Case" pattern is handled across two whitespace # tokens at the caller level — not classified per-token here. return 0 } # auto_detect_phi INPUT — main entrypoint. Echoes the rewritten input. # Per-turn override: input starting with "!nophi " causes the function to # strip the prefix and return without scanning. auto_detect_phi() { local input="$1" local sanitize_script="$LARRY_LIB_DIR/hl7-sanitize.sh" [ -x "$sanitize_script" ] || { printf '%s' "$input"; return; } # Per-turn override. if [[ "$input" == '!nophi '* ]]; then printf '%s' "${input#!nophi }" return 0 fi if [ "$AUTO_PHI_MODE" = "off" ]; then printf '%s' "$input" return 0 fi # Build a list of replacements (orig\tcategory\token) so we don't mutate # the string mid-scan (which would invalidate offsets). local -a hits=() # Pass A: per-whitespace-token classification. local IFS=$' \t\n' tok local -a tokens read -r -a tokens <<< "$input" local t cat key strip_trailing for t in "${tokens[@]}"; do [ -z "$t" ] && continue # Also split comma-delimited sub-tokens (e.g. "a@b.com,c@d.com"). local sub for sub in ${t//,/ }; do [ -z "$sub" ] && continue cat=$(_auto_phi_classify "$sub") [ -z "$cat" ] && continue # Strip trailing sentence-grammar punct for the actual replace string, # but only one char to match classify's behaviour. strip_trailing="$sub" case "$strip_trailing" in *[.,\;:\!\?\)]) strip_trailing="${strip_trailing%?}" ;; esac hits+=("$strip_trailing|$cat") done done # Pass B: loose "Title Case Title Case" two-word names. Detect using a # tolerant regex over the prose; per Bryan's confirm-first default, every # hit goes through confirm unless mode=aggressive. local i name_pair for ((i=0; i<${#tokens[@]}-1; i++)); do local left="${tokens[$i]}" right="${tokens[$i+1]}" # Strip one trailing punct from right for the test. local right_clean="$right" case "$right_clean" in *[.,\;:\!\?\)]) right_clean="${right_clean%?}" ;; esac if [[ "$left" =~ ^[A-Z][a-z]+$ ]] && [[ "$right_clean" =~ ^[A-Z][a-z]+$ ]]; then name_pair="$left $right_clean" # Allowlist check (case-insensitive). if _auto_phi_in_allowlist "$name_pair"; then continue fi hits+=("$name_pair|NAME_LOOSE") fi done # Pass C: two-token phone "(212) 555-1234" or "(212) 5551234" etc. The # single-token classifier can't see across whitespace. local phone_pair for ((i=0; i<${#tokens[@]}-1; i++)); do local p_left="${tokens[$i]}" p_right="${tokens[$i+1]}" # Strip one trailing punct from p_right. case "$p_right" in *[.,\;:\!\?\)]) p_right="${p_right%?}" ;; esac if [[ "$p_left" =~ ^\(?[0-9]{3}\)?$ ]] \ && [[ "$p_right" =~ ^[0-9]{3}[-\.]?[0-9]{4}$ ]]; then phone_pair="$p_left $p_right" hits+=("$phone_pair|PHONE") fi done # No hits — fast path. [ ${#hits[@]} -eq 0 ] && { printf '%s' "$input"; return 0; } # Dedupe hits while preserving order. local -A seen_hits=() local -a uhits=() local h for h in "${hits[@]}"; do if [ -z "${seen_hits[$h]:-}" ]; then seen_hits[$h]=1 uhits+=("$h") fi done # Apply each hit: confirm where needed, then tokenize + substitute. local summary="" local mode="$AUTO_PHI_MODE" for h in "${uhits[@]}"; do local orig="${h%|*}" local cat="${h##*|}" local actual_cat="$cat" [ "$cat" = "NAME_LOOSE" ] && actual_cat="NAME" # Use canonical normalize for memory key (so "John Smith" / "JOHN SMITH" # share one decision). local mem_key mem_key=$("$sanitize_script" normalize-value "$orig" "$actual_cat" 2>/dev/null) || mem_key="$orig" [ -z "$mem_key" ] && mem_key="$orig" # User previously declined this value this session. if _auto_phi_seen_declined "$mem_key"; then continue; fi # Confirm-first prompting only for NAME_LOOSE (the high-FP-rate detector). # Strict-format hits (EMAIL/SSN/PHONE/DOB/MRN/NAME-with-caret) are always # tokenized. This matches Bryan's "err on the side of caution" while # keeping confirms rare and high-signal. if [ "$cat" = "NAME_LOOSE" ] && [ "$mode" = "confirm" ] \ && ! _auto_phi_seen_accepted "$mem_key"; then local ans printf '%sphi auto>%s possible PHI detected: "%s". Tokenize? [Y/n] ' \ "$C_YELLOW" "$C_RESET" "$orig" >&2 IFS= read -r ans /dev/null || ans="" case "$ans" in n|N|no|NO|No) _auto_phi_mark_declined "$mem_key"; continue ;; *) _auto_phi_mark_accepted "$mem_key" ;; esac fi # Tokenize. local token token=$("$sanitize_script" tokenize-value --category "$actual_cat" "$orig" 2>/dev/null) [ -z "$token" ] && continue # Substitute. Use literal string replacement (all occurrences). input="${input//"$orig"/"$token"}" # Build summary line. if [ -z "$summary" ]; then summary="${orig}→${token}" else summary="${summary}, ${orig}→${token}" fi AUTO_PHI_SESSION_COUNT=$((AUTO_PHI_SESSION_COUNT + 1)) done if [ -n "$summary" ]; then local count count=$(awk -F', ' '{print NF}' <<< "$summary") printf '%sphi auto>%s tokenized %d value(s): %s\n' \ "$C_YELLOW" "$C_RESET" "$count" "$summary" >&2 fi printf '%s' "$input" } tool_hl7_sanitize() { local input_path="$1" strict="${2:-0}" _lib_err_if_missing || return local args=() [ "$strict" = "1" ] && args+=(--strict) args+=("$input_path") "$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 # ───────────────────────────────────────────────────────────────────────────── # v0.6.9: Persistent status line — ctx + rate-limit visibility # ───────────────────────────────────────────────────────────────────────────── # Per Pax's research (Deliverables/2026-05-27-anthropic-rate-limit-headers- # research.md) the API exposes two distinct families of rate-limit headers: # # API-key mode: anthropic-ratelimit-{requests,tokens,input-tokens, # output-tokens}-{limit,remaining,reset} # Reset is an RFC 3339 datetime string. # # OAuth mode: anthropic-ratelimit-unified-{5h,7d}-{status,utilization, # reset} + -representative-claim + a top-level -reset. # Reset is a Unix epoch integer-as-string. # # Two DIFFERENT parsers needed (easy footgun called out by Pax). # # STATUS_* globals are updated by _parse_response_headers after every API # call, then read by render_status_line which is invoked before each prompt. # Empty string = "unknown" — render as "—", never as "0%". STATUS_ctx_used_tokens="" # input + cache_creation + cache_read for LAST turn STATUS_ctx_window="" # from MODEL_CONTEXT_WINDOWS lookup STATUS_oauth_5h_utilization="" # 0.0–1.0 (decimal string) STATUS_oauth_5h_reset_epoch="" # unix seconds STATUS_oauth_7d_utilization="" STATUS_oauth_7d_reset_epoch="" STATUS_oauth_representative="" # five_hour | seven_day | seven_day_opus | seven_day_sonnet STATUS_oauth_status="" # allowed | warning | rate_limited STATUS_api_reset_epoch="" # earliest of the *-reset RFC3339 timestamps, as epoch # session_cost is reused from _LARRY_INPUT/OUTPUT/CACHE_*_TOKENS via # _render_session_cost_dollars (no new state needed). # Session turns counter == _LARRY_TURNS (no new state needed). # Header-capture safety net: log the first 50 OAuth response header blocks # to $LARRY_HOME/log/headers.log so we can verify Pax's spec against Bryan's # actual account. Auto-disables after 50 calls. STATUS_oauth_headers_logged=0 STATUS_OAUTH_HEADER_LOG_LIMIT=50 # Model context-window lookup table (tokens). Source: Pax §4. # Default for unknown models: 200000 (safe lower bound for legacy releases). _model_context_window() { local m="$1" case "$m" in *opus-4-7*|*opus-4-6*) echo 1000000 ;; *sonnet-4-6*) echo 1000000 ;; *haiku-4-5*) echo 200000 ;; *sonnet-4-5*) echo 200000 ;; *opus-4-5*|*opus-4-1*) echo 200000 ;; *) echo 200000 ;; esac } # _header_value HEADER_FILE NAME — case-insensitive header lookup. # curl -D writes "Header-Name: value\r\n" lines. We strip the trailing CR # and any leading/trailing whitespace from the value. _header_value() { local f="$1" name="$2" # grep -i for case-insensitive name match; cut at first ':'; trim. local line val line=$(grep -i -m1 "^${name}:" "$f" 2>/dev/null) || return 0 val="${line#*:}" # Strip CR (curl on Windows / SSE responses). val="${val%$'\r'}" # Trim leading whitespace. val="${val# }" val="${val##[[:space:]]*}" # tolerate multiple leading spaces # Re-strip with parameter expansion (the bracket form is fussy). val="${val#"${val%%[![:space:]]*}"}" val="${val%"${val##*[![:space:]]}"}" printf '%s' "$val" } # _rfc3339_to_epoch STR — convert RFC 3339 datetime → Unix epoch seconds. # Returns empty string on parse failure. macOS `date -j -f` and GNU `date -d` # behave differently; we try GNU first, fall back to BSD. _rfc3339_to_epoch() { local s="$1" [ -z "$s" ] && return 0 local out # GNU date (Linux, Cygwin). out=$(date -d "$s" +%s 2>/dev/null) && [ -n "$out" ] && { printf '%s' "$out"; return 0; } # BSD date (macOS). Try ISO 8601 with timezone, then without. out=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$s" +%s 2>/dev/null) \ && [ -n "$out" ] && { printf '%s' "$out"; return 0; } out=$(date -j -f "%Y-%m-%dT%H:%M:%S%z" "${s/Z/+0000}" +%s 2>/dev/null) \ && [ -n "$out" ] && { printf '%s' "$out"; return 0; } # Give up silently — caller renders "—". return 0 } # _epoch_to_hhmm EPOCH — format epoch as HH:MM in local time. _epoch_to_hhmm() { local e="$1" [ -z "$e" ] && return 0 date -d "@$e" +%H:%M 2>/dev/null || date -r "$e" +%H:%M 2>/dev/null || true } # _epoch_to_ddd_mmm_d EPOCH — format epoch as "Mon Jun 2". _epoch_to_ddd_mmm_d() { local e="$1" [ -z "$e" ] && return 0 date -d "@$e" "+%a %b %-d" 2>/dev/null || date -r "$e" "+%a %b %-d" 2>/dev/null || true } # _humanize_tokens N — render an integer as 24K / 1.2M. _humanize_tokens() { local n="$1" [ -z "$n" ] && { printf '—'; return; } if [ "$n" -ge 1000000 ]; then awk -v n="$n" 'BEGIN{printf "%.1fM", n/1000000}' elif [ "$n" -ge 1000 ]; then awk -v n="$n" 'BEGIN{printf "%dK", n/1000}' else printf '%s' "$n" fi } # _parse_response_headers HEADER_FILE — extract rate-limit fields from a # curl -D dump and update STATUS_* globals. Idempotent; safe to call on # empty / partial files. # # Per Pax §2 / §3: # API-key resets: RFC 3339 datetime strings → convert to epoch. # OAuth resets: Unix epoch integer-as-string → use as-is. _parse_response_headers() { local f="$1" [ -s "$f" ] || return 0 # ── OAuth unified-* family ─────────────────────────────────────────────── local v v=$(_header_value "$f" "anthropic-ratelimit-unified-status") [ -n "$v" ] && STATUS_oauth_status="$v" v=$(_header_value "$f" "anthropic-ratelimit-unified-5h-utilization") [ -n "$v" ] && STATUS_oauth_5h_utilization="$v" v=$(_header_value "$f" "anthropic-ratelimit-unified-5h-reset") [ -n "$v" ] && STATUS_oauth_5h_reset_epoch="$v" v=$(_header_value "$f" "anthropic-ratelimit-unified-7d-utilization") [ -n "$v" ] && STATUS_oauth_7d_utilization="$v" v=$(_header_value "$f" "anthropic-ratelimit-unified-7d-reset") [ -n "$v" ] && STATUS_oauth_7d_reset_epoch="$v" v=$(_header_value "$f" "anthropic-ratelimit-unified-representative-claim") [ -n "$v" ] && STATUS_oauth_representative="$v" # ── API-key family (find earliest reset) ───────────────────────────────── # The four buckets (requests/tokens/input-tokens/output-tokens) each have # their own reset. We display the most-imminent one. local earliest="" local hname epoch rfc for hname in \ anthropic-ratelimit-requests-reset \ anthropic-ratelimit-tokens-reset \ anthropic-ratelimit-input-tokens-reset \ anthropic-ratelimit-output-tokens-reset; do rfc=$(_header_value "$f" "$hname") [ -z "$rfc" ] && continue epoch=$(_rfc3339_to_epoch "$rfc") [ -z "$epoch" ] && continue if [ -z "$earliest" ] || [ "$epoch" -lt "$earliest" ]; then earliest="$epoch" fi done [ -n "$earliest" ] && STATUS_api_reset_epoch="$earliest" # ── Safety net: log raw OAuth headers for first 50 calls ───────────────── # Only relevant in OAuth mode and only if we saw at least one unified-* # header (no point logging API-key responses). if [ "$LARRY_AUTH_MODE" = "oauth" ] \ && [ -n "$STATUS_oauth_status$STATUS_oauth_5h_utilization$STATUS_oauth_7d_utilization" ] \ && [ "$STATUS_oauth_headers_logged" -lt "$STATUS_OAUTH_HEADER_LOG_LIMIT" ]; then local log_dir="$LARRY_HOME/log" mkdir -p "$log_dir" 2>/dev/null || true if [ -d "$log_dir" ]; then { printf '── %s call #%d model=%s ──\n' \ "$(date -Iseconds 2>/dev/null || date)" \ "$((STATUS_oauth_headers_logged + 1))" \ "$LARRY_MODEL" grep -i '^anthropic-' "$f" 2>/dev/null || true grep -i '^retry-after:' "$f" 2>/dev/null || true printf '\n' } >> "$log_dir/headers.log" 2>/dev/null || true STATUS_oauth_headers_logged=$((STATUS_oauth_headers_logged + 1)) if [ "$STATUS_oauth_headers_logged" -eq "$STATUS_OAUTH_HEADER_LOG_LIMIT" ]; then printf '%s[v0.6.9 header-log] reached %d OAuth calls; raw header capture disabled. See %s%s\n' \ "$C_DIM" "$STATUS_OAUTH_HEADER_LOG_LIMIT" "$log_dir/headers.log" "$C_RESET" >&2 fi fi fi } # render_status_line — print the dim status line above the prompt. # Honors LARRY_NO_STATUS=1. Prints nothing if we have no data yet (first # turn of a session). Always ends with a trailing newline so the prompt # lands cleanly below. render_status_line() { [ "${LARRY_NO_STATUS:-0}" = "1" ] && return 0 # Pick template by auth mode. case "$LARRY_AUTH_MODE" in oauth) # Suppress if we have NO context data AND no OAuth data — first turn. if [ -z "$STATUS_ctx_used_tokens" ] \ && [ -z "$STATUS_oauth_5h_utilization" ] \ && [ -z "$STATUS_oauth_7d_utilization" ]; then return 0 fi _render_status_line_oauth ;; apikey) # Suppress only when context AND cost both absent (first turn). if [ -z "$STATUS_ctx_used_tokens" ] && [ "$_LARRY_TURNS" -eq 0 ]; then return 0 fi _render_status_line_apikey ;; *) return 0 ;; esac } # _ctx_segment — render "ctx 12% (24K/200K)" or "ctx — (—/—)". _ctx_segment() { local used="$STATUS_ctx_used_tokens" local win="$STATUS_ctx_window" # Lazy-init the window from the current model if not set. if [ -z "$win" ]; then win=$(_model_context_window "$LARRY_MODEL") STATUS_ctx_window="$win" fi if [ -z "$used" ]; then printf 'ctx — (—/%s)' "$(_humanize_tokens "$win")" return fi local pct pct=$(awk -v u="$used" -v w="$win" 'BEGIN{ if(w==0){print "—"} else {printf "%d", (u*100/w)} }') local color="$C_DIM" if [ "$pct" != "—" ]; then if [ "$pct" -ge 90 ]; then color="$C_RED" elif [ "$pct" -ge 75 ]; then color="$C_YELLOW" fi fi printf '%sctx %s%% (%s/%s)%s%s' "$color" "$pct" \ "$(_humanize_tokens "$used")" "$(_humanize_tokens "$win")" \ "$C_RESET" "$C_DIM" } # _utilization_pct DECIMAL — turn "0.7370692..." into "73" (integer percent). _utilization_pct() { local d="$1" [ -z "$d" ] && { printf '—'; return; } awk -v d="$d" 'BEGIN{printf "%d", d*100}' } # _utilization_pct_one DECIMAL — same but with one decimal place ("73.7"). _utilization_pct_one() { local d="$1" [ -z "$d" ] && { printf '—'; return; } awk -v d="$d" 'BEGIN{printf "%.1f", d*100}' } _render_status_line_oauth() { local ctx; ctx=$(_ctx_segment) local now; now=$(date +%s) # 5h segment local five_pct five_reset five_color="$C_DIM" if [ -n "$STATUS_oauth_5h_utilization" ]; then five_pct=$(_utilization_pct_one "$STATUS_oauth_5h_utilization") # Color by utilization or status. local raw_pct; raw_pct=$(_utilization_pct "$STATUS_oauth_5h_utilization") if [ "$raw_pct" -ge 90 ]; then five_color="$C_RED" elif [ "$raw_pct" -ge 75 ]; then five_color="$C_YELLOW" fi else five_pct="—" fi if [ -n "$STATUS_oauth_5h_reset_epoch" ]; then if [ "$STATUS_oauth_5h_reset_epoch" -le "$now" ]; then five_reset="— reset" else five_reset="reset $(_epoch_to_hhmm "$STATUS_oauth_5h_reset_epoch")" fi else five_reset="reset —" fi # 7d segment local seven_pct seven_reset seven_color="$C_DIM" if [ -n "$STATUS_oauth_7d_utilization" ]; then seven_pct=$(_utilization_pct_one "$STATUS_oauth_7d_utilization") local raw_pct7; raw_pct7=$(_utilization_pct "$STATUS_oauth_7d_utilization") if [ "$raw_pct7" -ge 90 ]; then seven_color="$C_RED" elif [ "$raw_pct7" -ge 75 ]; then seven_color="$C_YELLOW" fi else seven_pct="—" fi if [ -n "$STATUS_oauth_7d_reset_epoch" ]; then if [ "$STATUS_oauth_7d_reset_epoch" -le "$now" ]; then seven_reset="— reset" else seven_reset="reset $(_epoch_to_ddd_mmm_d "$STATUS_oauth_7d_reset_epoch")" fi else seven_reset="reset —" fi # Status-level color override (warning → yellow, rate_limited → red wins). local overall_pre="" case "$STATUS_oauth_status" in rate_limited) overall_pre="$C_RED" ;; warning) overall_pre="$C_YELLOW" ;; esac # Build the line. Width-aware: if cols < 100, drop the reset times. local cols cols=$(tput cols 2>/dev/null || echo 100) local line if [ "$cols" -ge 100 ]; then line=$(printf '%s─ %s ─ %s5h %s%% %s%s ─ %s7d %s%% %s%s ─%s' \ "$C_DIM" "$ctx" \ "$five_color" "$five_pct" "$five_reset" "$C_DIM" \ "$seven_color" "$seven_pct" "$seven_reset" "$C_DIM" \ "$C_RESET") else line=$(printf '%s─ %s ─ %s5h %s%%%s ─ %s7d %s%%%s ─%s' \ "$C_DIM" "$ctx" \ "$five_color" "$five_pct" "$C_DIM" \ "$seven_color" "$seven_pct" "$C_DIM" \ "$C_RESET") fi if [ -n "$overall_pre" ]; then printf '%s%s\n' "$overall_pre" "$line" else printf '%s\n' "$line" fi } _render_status_line_apikey() { local ctx; ctx=$(_ctx_segment) # Session $ from current cost trackers. local dollars; dollars=$(_render_session_cost_dollars) printf '%s─ %s ─ $%s session ─ %d turns ─%s\n' \ "$C_DIM" "$ctx" "$dollars" "$_LARRY_TURNS" "$C_RESET" } # _render_session_cost_dollars — reuse the existing pricing logic. # Returns the running session $ amount to 3 decimals. _render_session_cost_dollars() { local prices; prices=$(_price_for_model "$LARRY_MODEL") local in_price out_price in_price="${prices% *}" out_price="${prices#* }" awk -v ti="$_LARRY_INPUT_TOKENS" -v to="$_LARRY_OUTPUT_TOKENS" \ -v tcr="$_LARRY_CACHE_READ_TOKENS" -v tcw="$_LARRY_CACHE_WRITE_TOKENS" \ -v pi="$in_price" -v po="$out_price" \ 'BEGIN{ c = ti*pi/1000000 + to*po/1000000 \ + tcr*pi*0.1/1000000 + tcw*pi*1.25/1000000; printf "%.3f", c }' } # _record_ctx_used IN_TOK CACHE_READ CACHE_WRITE — update STATUS_ctx_used_tokens # with the LATEST turn's total context size. Per Pax §5: ctx_used = # input_tokens + cache_creation_input_tokens + cache_read_input_tokens. # (NOT the running cumulative sum — context resets per turn from Anthropic's # perspective.) _record_ctx_used() { local in_t="${1:-0}" cr="${2:-0}" cw="${3:-0}" STATUS_ctx_used_tokens=$(( in_t + cr + cw )) # Lazy-init the window so /status renders correctly even without an API call. [ -z "$STATUS_ctx_window" ] && STATUS_ctx_window=$(_model_context_window "$LARRY_MODEL") } 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. tool_ssh_exec() { local alias="$1" command="$2" max_lines="${3:-500}" local helper="$LARRY_LIB_DIR/ssh-helper.sh" [ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; } [ -n "$alias" ] && [ -n "$command" ] || { echo "ERROR: ssh_exec needs alias and command"; return 1; } local out out=$("$helper" exec "$alias" "$command" 2>&1) local rc=$? local total_lines total_lines=$(printf '%s' "$out" | wc -l | tr -d ' ') if [ "$total_lines" -gt "$max_lines" ]; then printf '%s\n[ssh_exec: output truncated — showed %s of %s lines. Exit rc=%d]\n' \ "$(printf '%s' "$out" | head -n "$max_lines")" "$max_lines" "$total_lines" "$rc" else printf '%s\n[ssh_exec: exit rc=%d]\n' "$out" "$rc" fi } tool_ssh_status() { local helper="$LARRY_LIB_DIR/ssh-helper.sh" [ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; } "$helper" status 2>&1 } # ── v0.6.8: cross-env file transfer over the open ControlMaster ──────────── # ssh_pull pulls a remote file → local; ssh_push pushes local → remote. Both # multiplex via the existing master socket (set up by /ssh-setup ALIAS) — no # second auth, no second TCP handshake. tool_ssh_pull() { local alias="$1" remote="$2" local_path="${3:-}" local helper="$LARRY_LIB_DIR/ssh-helper.sh" [ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; } [ -n "$alias" ] && [ -n "$remote" ] || { echo "ERROR: ssh_pull needs alias and remote_path"; return 1; } local out rc if [ -n "$local_path" ]; then out=$("$helper" pull "$alias" "$remote" "$local_path" 2>&1); rc=$? else out=$("$helper" pull "$alias" "$remote" 2>&1); rc=$? fi printf '%s\n[ssh_pull: exit rc=%d]\n' "$out" "$rc" } tool_ssh_push() { local alias="$1" local_path="$2" remote="$3" local helper="$LARRY_LIB_DIR/ssh-helper.sh" [ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; } [ -n "$alias" ] && [ -n "$local_path" ] && [ -n "$remote" ] \ || { echo "ERROR: ssh_push needs alias, local_path, and remote_path"; return 1; } local out rc out=$("$helper" push "$alias" "$local_path" "$remote" 2>&1); rc=$? printf '%s\n[ssh_push: exit rc=%d]\n' "$out" "$rc" } tool_ssh_pull_smat() { local alias="$1" site="$2" thread="$3" days_back="${4:-}" local helper="$LARRY_LIB_DIR/ssh-helper.sh" [ -x "$helper" ] || { echo "ERROR: ssh-helper.sh not installed"; return 1; } [ -n "$alias" ] && [ -n "$site" ] && [ -n "$thread" ] \ || { echo "ERROR: ssh_pull_smat needs alias, site, thread"; return 1; } local out rc if [ -n "$days_back" ]; then out=$("$helper" pull-smat "$alias" "$site" "$thread" "$days_back" 2>&1); rc=$? else out=$("$helper" pull-smat "$alias" "$site" "$thread" 2>&1); rc=$? fi # Cap returned bytes — sampled-mode b64 blobs can be sizable. Hard ceiling # at ~400 KB so tool result stays in a reasonable bound; truncation is # explicit so Larry-the-LLM can react and re-pull with smaller days_back. local bytes; bytes=$(printf '%s' "$out" | wc -c | tr -d ' ') if [ "$bytes" -gt 409600 ]; then out=$(printf '%s' "$out" | head -c 409600) printf '%s\n[ssh_pull_smat: output truncated at 400 KB; re-run with smaller days_back. exit rc=%d]\n' "$out" "$rc" else printf '%s\n[ssh_pull_smat: exit rc=%d]\n' "$out" "$rc" fi } tool_lesson_record() { local text="$1" topic="${2:-}" site="${3:-${HCISITE:-}}" severity="${4:-info}" _lib_err_if_missing || return local lessons_script="$LARRY_LIB_DIR/lessons.sh" [ -x "$lessons_script" ] || { echo "ERROR: lessons.sh not installed"; return 1; } local args=(add "$text" --severity "$severity") [ -n "$topic" ] && args+=(--topic "$topic") [ -n "$site" ] && args+=(--site "$site") "$lessons_script" "${args[@]}" 2>&1 } tool_larry_rollback_list() { local session_filter="${1:-}" if [ -n "$session_filter" ]; then "$LARRY_HOME/../larry-rollback.sh" --list --session "$session_filter" 2>&1 \ || "$LARRY_LIB_DIR/../larry-rollback.sh" --list --session "$session_filter" 2>&1 else "$LARRY_HOME/../larry-rollback.sh" --list 2>&1 \ || "$LARRY_LIB_DIR/../larry-rollback.sh" --list 2>&1 fi } tool_nc_document() { local pattern="$1" out_path="${2:-}" hciroot="${3:-${HCIROOT:-}}" local title="${4:-}" status="${5:-}" poc_internal="${6:-}" poc_vendor="${7:-}" escalation="${8:-}" open_items="${9:-}" notes="${10:-}" _lib_err_if_missing || return local args=(--name "$pattern") [ -n "$hciroot" ] && args+=(--hciroot "$hciroot") [ -n "$out_path" ] && args+=(--out "$out_path") [ -n "$title" ] && args+=(--title "$title") [ -n "$status" ] && args+=(--status "$status") [ -n "$poc_internal" ] && args+=(--poc-internal "$poc_internal") [ -n "$poc_vendor" ] && args+=(--poc-vendor "$poc_vendor") [ -n "$escalation" ] && args+=(--escalation "$escalation") [ -n "$open_items" ] && args+=(--open-items "$open_items") [ -n "$notes" ] && args+=(--notes "$notes") "$LARRY_LIB_DIR/nc-document.sh" "${args[@]}" 2>&1 } tool_nc_diff_interface() { local interface="$1" left="$2" right="$3" out_path="${4:-}" include_tables="${5:-0}" local left_label="${6:-}" right_label="${7:-}" depth="${8:-1}" _lib_err_if_missing || return [ -n "$interface" ] && [ -n "$left" ] && [ -n "$right" ] \ || { echo "ERROR: nc_diff_interface needs interface, left, right"; return 1; } local args=(--interface "$interface" --left "$left" --right "$right" --depth "$depth") [ -n "$out_path" ] && args+=(--out "$out_path") [ "$include_tables" = "1" ] && args+=(--include-tables) [ -n "$left_label" ] && args+=(--left-label "$left_label") [ -n "$right_label" ] && args+=(--right-label "$right_label") "$LARRY_LIB_DIR/nc-diff-interface.sh" "${args[@]}" 2>&1 } tool_bash_exec() { local cmd="$1" printf '\n%s══ bash_exec ══%s\n' "$C_YELLOW" "$C_RESET" >&2 printf '%s$ %s%s\n' "$C_BOLD" "$cmd" "$C_RESET" >&2 printf '%sRun this command? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 read -r answer &1 | head -500) echo "$out" log_section "bash_exec (approved)"; log_append '```'; log_append "$ $cmd"; log_append "$out"; log_append '```' else echo "DENIED by user. Command not executed." log_section "bash_exec DENIED: $cmd" fi } execute_tool() { local name="$1"; local input_json="$2" local J; J() { printf '%s' "$input_json" | jq -r "$1"; } case "$name" in read_file) tool_read_file "$(J '.path')" ;; list_dir) tool_list_dir "$(J '.path // "."')" ;; grep_files) tool_grep_files "$(J '.pattern')" "$(J '.path // "."')" ;; glob_files) tool_glob_files "$(J '.pattern')" "$(J '.path // "."')" ;; write_file) tool_write_file "$(J '.path')" "$(J '.content')" ;; bash_exec) tool_bash_exec "$(J '.command')" ;; nc_list_protocols) tool_nc_list_protocols "$(J '.netconfig')" ;; nc_list_processes) tool_nc_list_processes "$(J '.netconfig')" ;; nc_protocol_block) tool_nc_protocol_block "$(J '.netconfig')" "$(J '.name')" ;; nc_protocol_field) tool_nc_protocol_field "$(J '.netconfig')" "$(J '.name')" "$(J '.field')" ;; nc_protocol_nested) tool_nc_protocol_nested "$(J '.netconfig')" "$(J '.name')" "$(J '.path')" ;; nc_protocol_summary) tool_nc_protocol_summary "$(J '.netconfig')" "$(J '.filter // ""')" ;; nc_destinations) tool_nc_destinations "$(J '.netconfig')" "$(J '.name')" ;; nc_xlate_refs) tool_nc_xlate_refs "$(J '.netconfig')" "$(J '.name // ""')" ;; nc_find_inbound) tool_nc_find_inbound "$(J '.netconfig')" "$(J '.mode // "all"')" "$(J '.format // "tsv"')" ;; nc_make_jump) tool_nc_make_jump "$(J '.netconfig')" "$(J '.inbound')" "$(J '.new_host')" "$(J '.jump_port')" \ "$(J '.inbound_host // "127.0.0.1"')" "$(J '.process_jump // "server_jump"')" "$(J '.encoding // ""')" ;; nc_sources) tool_nc_sources "$(J '.netconfig')" "$(J '.name')" ;; nc_tclproc_refs) tool_nc_tclproc_refs "$(J '.netconfig')" "$(J '.name // ""')" ;; hl7_field) tool_hl7_field "$(J '.message')" "$(J '.field_path')" ;; nc_msgs) tool_nc_msgs "$(J '.thread')" "$(J '.after // ""')" "$(J '.before // ""')" \ "$(J '.field // ""')" "$(J '.value // ""')" \ "$(J '.limit // 10')" "$(J '.format // "text"')" \ "$(J '.sitedir // ""')" "$(J '.db // ""')" ;; nc_document) tool_nc_document "$(J '.name')" "$(J '.out // ""')" "$(J '.hciroot // ""')" \ "$(J '.title // ""')" "$(J '.status // ""')" \ "$(J '.poc_internal // ""')" "$(J '.poc_vendor // ""')" \ "$(J '.escalation // ""')" "$(J '.open_items // ""')" \ "$(J '.notes // ""')" ;; nc_find) tool_nc_find "$(J '.mode')" "$(J '.query')" "$(J '.format // "table"')" "$(J '.hciroot // ""')" ;; nc_insert_protocol) tool_nc_insert_protocol "$(J '.netconfig')" "$(J '.block')" "$(J '.mode // "end"')" "$(J '.anchor // ""')" ;; nc_add_route) tool_nc_add_route "$(J '.netconfig')" "$(J '.protocol_name')" "$(J '.route')" ;; hl7_diff) tool_hl7_diff "$(J '.left')" "$(J '.right')" "$(J '.ignore // "MSH.7"')" "$(J '.include // ""')" "$(J '.format // "text"')" ;; nc_diff_interface) tool_nc_diff_interface "$(J '.interface')" "$(J '.left')" "$(J '.right')" "$(J '.out // ""')" \ "$(J '.include_tables // 0' | sed "s/false/0/;s/true/1/")" \ "$(J '.left_label // ""')" "$(J '.right_label // ""')" \ "$(J '.depth // 1')" ;; nc_regression) tool_nc_regression "$(J '.scope')" "$(J '.count // 10')" "$(J '.env_a')" "$(J '.site_a // ""')" \ "$(J '.env_b')" "$(J '.site_b // ""')" "$(J '.out')" \ "$(J '.route_test_cmd // ""')" "$(J '.ignore // "MSH.7"')" \ "$(J '.phase // "all"')" "$(J '.dry_run // 0' | sed "s/false/0/;s/true/1/")" \ "$(J '.source_ssh_alias // ""')" "$(J '.target_ssh_alias // ""')" ;; lesson_record) tool_lesson_record "$(J '.text')" "$(J '.topic // ""')" "$(J '.site // ""')" "$(J '.severity // "info"')" ;; hl7_sanitize) tool_hl7_sanitize "$(J '.input_path')" "$(J '.strict // 0' | sed "s/false/0/;s/true/1/")" ;; ssh_exec) tool_ssh_exec "$(J '.alias')" "$(J '.command')" "$(J '.max_lines // 500')" ;; ssh_status) tool_ssh_status ;; ssh_pull) tool_ssh_pull "$(J '.alias')" "$(J '.remote_path')" "$(J '.local_path // ""')" ;; ssh_push) tool_ssh_push "$(J '.alias')" "$(J '.local_path')" "$(J '.remote_path')" ;; ssh_pull_smat) tool_ssh_pull_smat "$(J '.alias')" "$(J '.site')" "$(J '.thread')" "$(J '.days_back // ""')" ;; larry_rollback_list) tool_larry_rollback_list "$(J '.session // ""')" ;; *) echo "ERROR: unknown tool: $name" ;; esac } # ───────────────────────────────────────────────────────────────────────────── # Tool schema for the API # ───────────────────────────────────────────────────────────────────────────── TOOLS_JSON=$(cat <<'TOOLS_END' [ {"name":"read_file","description":"Read a single LOCAL regular file. Returns content with line numbers. Max 250KB; use grep_files for larger. For files on a remote SSH-aliased host, use ssh_pull first to fetch the file locally, then read the returned local path.","input_schema":{"type":"object","properties":{"path":{"type":"string","description":"Path to file (absolute or relative to cwd)."}},"required":["path"]}}, {"name":"list_dir","description":"List a directory (ls -la). Use to map a Cloverleaf site_root.","input_schema":{"type":"object","properties":{"path":{"type":"string","description":"Directory path. Defaults to current dir."}},"required":["path"]}}, {"name":"grep_files","description":"Recursive grep across LOCAL files only. Use for finding TCL procs, UPOC declarations, segment references, etc. Returns up to 300 matching lines with file:line:content. To grep remote files, use ssh_exec with grep, or ssh_pull the file first.","input_schema":{"type":"object","properties":{"pattern":{"type":"string","description":"Regex pattern (grep -E style)."},"path":{"type":"string","description":"Starting directory."}},"required":["pattern","path"]}}, {"name":"glob_files","description":"Find files by name pattern. Up to 300 paths.","input_schema":{"type":"object","properties":{"pattern":{"type":"string","description":"Shell glob like *.tcl or *Inbound*"},"path":{"type":"string","description":"Starting directory."}},"required":["pattern","path"]}}, {"name":"write_file","description":"Write content to a path. ALWAYS prompts Bryan for Y/N before writing. Shows a unified diff if file exists, or a preview if new.","input_schema":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}, {"name":"bash_exec","description":"Run a shell command. ALWAYS prompts Bryan for Y/N before running. Output capped at 500 lines.","input_schema":{"type":"object","properties":{"command":{"type":"string","description":"Single command line, passed to bash -c."}},"required":["command"]}}, {"name":"nc_list_protocols","description":"List every protocol (thread) declared in a Cloverleaf NetConfig file. Native v3 parser — does not invoke v1/v2 wrappers. One name per line.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"Absolute path to a NetConfig file, e.g. $HCISITEDIR/NetConfig."}},"required":["netconfig"]}}, {"name":"nc_list_processes","description":"List every process declared in a NetConfig. One name per line.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"}},"required":["netconfig"]}}, {"name":"nc_protocol_block","description":"Return the full TCL block for one protocol (everything between `protocol NAME {` and the matching `}`). Use to inspect every field of a thread.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Protocol name, e.g. IB_ADT_muxS."}},"required":["netconfig","name"]}}, {"name":"nc_protocol_field","description":"Get a top-level field value from a protocol block (e.g. PROCESSNAME, OBWORKASIB, OUTBOUNDONLY, GROUPS, ENCODING, ICLSERVERPORT, AUTOSTART, HOSTDOWN).","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string"},"field":{"type":"string","description":"Field name, e.g. PROCESSNAME"}},"required":["netconfig","name","field"]}}, {"name":"nc_protocol_nested","description":"Drill into a nested block via dotted path. Use PROTOCOL.TYPE / PROTOCOL.HOST / PROTOCOL.PORT / PROTOCOL.ISSERVER for connection details — those live inside the inner PROTOCOL{} block, NOT at top level.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string"},"path":{"type":"string","description":"Dotted path, e.g. PROTOCOL.PORT"}},"required":["netconfig","name","path"]}}, {"name":"nc_protocol_summary","description":"Compact TSV summary of all protocols with direction-relevant fields (name, process, direction, port, host, type, isserver, outonly, obworkasib, iclserverport). Optional --filter regex to narrow.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"filter":{"type":"string","description":"Optional regex to filter protocol names."}},"required":["netconfig"]}}, {"name":"nc_destinations","description":"List every DEST routed to from one protocol’s DATAXLATE block. Unique, sorted.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string"}},"required":["netconfig","name"]}}, {"name":"nc_xlate_refs","description":"List every .xlt file referenced in the NetConfig (all of them, or scoped to one protocol if `name` is provided).","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Optional. Limits to one protocol."}},"required":["netconfig"]}}, {"name":"nc_find_inbound","description":"Find inbound threads in a NetConfig. mode=tcp-listen (ISSERVER=1, directly fed by upstream client systems), mode=icl-or-file (OBWORKASIB=1, fed by internal Cloverleaf link or file drop), mode=all (default). Output formats: tsv, jsonl, table.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"mode":{"type":"string","enum":["tcp-listen","icl-or-file","all"],"description":"Which class of inbound to return."},"format":{"type":"string","enum":["tsv","jsonl","table"]}},"required":["netconfig"]}}, {"name":"nc_make_jump","description":"Generate the 3-thread jump set for the cross-environment data replay pattern Bryan uses. Emits FOUR artifacts: (1) linux__out for OLD env (outbound tcpip-client to new linux:jump_port), (2) windows__in for NEW env server_jump site (inbound tcpip-server listening on jump_port, routes internally to #3), (3) windows__out for NEW env server_jump site (outbound tcpip-client to 127.0.0.1:, where orig_port is the existing inbound listening port read from the NetConfig), (4) route-add snippet to splice into the OLD inbound DATAXLATE block. Tag = inbound thread name (auto). The NEW env existing inbound is left COMPLETELY UNCHANGED. Pure generation; caller uses write_file (Y/N) to persist.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"NetConfig path containing the inbound thread (OLD env)."},"inbound":{"type":"string","description":"Existing inbound protocol name to mirror. Must be a TCP-listener (ISSERVER=1); read its PROTOCOL.PORT first to confirm."},"new_host":{"type":"string","description":"Hostname/IP of the NEW linux env that OLD will TCP to."},"jump_port":{"type":"string","description":"TCP port for the OLD to NEW hop. linux__out targets it, windows__in listens on it."},"inbound_host":{"type":"string","description":"Host that windows__out connects to on NEW (the existing inbound on NEW). Default 127.0.0.1 (same box, loopback)."},"process_jump":{"type":"string","description":"Process for NEW-side threads on server_jump. Default server_jump."},"encoding":{"type":"string","description":"ENCODING override. Default = same as the existing inbound."}},"required":["netconfig","inbound","new_host","jump_port"]}}, {"name":"nc_sources","description":"List every protocol that has a DATAXLATE DEST routing to the named thread. The inverse of nc_destinations. Use this to find what feeds a given thread.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Target thread name."}},"required":["netconfig","name"]}}, {"name":"nc_tclproc_refs","description":"List every TCL proc name referenced from a protocol block (or from the whole NetConfig if name is omitted). Pulls from DATAFORMAT.PROC, PREPROCS.PROCS, POSTPROCS.PROCS, etc. Unique sorted.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Optional. Scope to one protocol."}},"required":["netconfig"]}}, {"name":"hl7_field","description":"Extract a specific HL7 v2 field from a message. field_path = SEG[.FIELD[.COMPONENT[.SUBCOMPONENT]]]. Examples: PID.3 (MRN), PID.18 (account number), MSH.7 (timestamp), MSH.9.2 (event code, like A08), PID.5 (patient name with components). Multiple repetitions are returned one per line. Native v3, no v1/v2 dependency.","input_schema":{"type":"object","properties":{"message":{"type":"string","description":"Raw HL7 message text. Segments separated by \\r."},"field_path":{"type":"string","description":"Field path like PID.3 or MSH.9.2"}},"required":["message","field_path"]}}, {"name":"nc_msgs","description":"Query Cloverleaf smat (SQLite!) databases for messages from a thread. Filters: time range, exact HL7 field match. Native v3 — reads smatdb directly with sqlite3 -ascii, no hcidbdump/dbExtract needed. Format text shows messages line-by-line with metadata; count returns just the count; json returns structured data. Operates on LOCAL smatdbs; for a remote env's smatdb, use ssh_pull_smat first (sampled mode is cheaper than pulling the whole DB).","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"Thread name. The .smatdb file under $HCISITEDIR/exec/processes/*/.smatdb is auto-located unless db is given."},"after":{"type":"string","description":"Time-after filter. Accepts \"3 days ago\", \"2026-05-20 14:30:00\", \"2026-05-20\", or a unix timestamp."},"before":{"type":"string","description":"Time-before filter, same formats as after."},"field":{"type":"string","description":"HL7 field path for exact-match filter, e.g. PID.18 or MSH.10."},"value":{"type":"string","description":"Value the field must equal. Use with field. Repeatable filters not supported via this single tool call — chain calls if you need multi-field AND."},"limit":{"type":"integer","description":"Max messages to return. Default 10."},"format":{"type":"string","enum":["text","json","count","raw"],"description":"text = human-readable with metadata; count = just the number; json = structured; raw = raw bytes separated by 0x1c."},"sitedir":{"type":"string","description":"Override $HCISITEDIR for thread-to-db location."},"db":{"type":"string","description":"Explicit .smatdb path; overrides auto-locate."}},"required":["thread"]}}, {"name":"nc_document","description":"Generate a complete markdown knowledge entry for a Cloverleaf subsystem identified by a name pattern. Walks every NetConfig under $HCIROOT, gathers config + sources + destinations + xlates + tclprocs for every matching thread, composes a markdown doc with placeholder context sections (Vendor POC, Internal Owner, Status, Escalation, Open items, Notes). Returns the doc text and (if out is given) writes it to that path.","input_schema":{"type":"object","properties":{"name":{"type":"string","description":"Case-insensitive substring/regex to match protocol names. e.g. 'codametrix', 'epic_adt', '3M'."},"out":{"type":"string","description":"Optional output file path. Convention: $LARRY_HOME/knowledge/.md."},"hciroot":{"type":"string","description":"Override $HCIROOT for the NetConfig scan."},"title":{"type":"string","description":"Doc title. Default derived from name."},"status":{"type":"string","description":"System status fill-in (production/test/decommissioning/...)."},"poc_internal":{"type":"string","description":"Internal owner fill-in."},"poc_vendor":{"type":"string","description":"Vendor POC fill-in."},"escalation":{"type":"string","description":"Escalation path fill-in."},"open_items":{"type":"string","description":"Open items / known issues fill-in. Can be multi-line, will be inserted as-is."},"notes":{"type":"string","description":"Freeform notes fill-in."}},"required":["name"]}}, {"name":"nc_find","description":"Cross-site thread search. Native v3 replacement for v1 tbn/tbp/tbh/tbpr/where. Walks every NetConfig under $HCIROOT and returns matching threads with site, port, host, process, direction, file, line. Modes: name=partial name match (like tbn); port=exact port (like tbp); host=substring on host (like tbh); process=substring on PROCESSNAME (like tbpr); where=exact name match across all sites (like the v1 ` where`); xlate=threads referencing a specific .xlt; tclproc=threads referencing a specific TCL proc.","input_schema":{"type":"object","properties":{"mode":{"type":"string","enum":["name","port","host","process","where","xlate","tclproc"],"description":"Search mode."},"query":{"type":"string","description":"Query value: partial name, port number, host substring, process name, exact thread name, xlate filename, or tclproc name."},"format":{"type":"string","enum":["table","tsv","jsonl"],"description":"Output format. Default table."},"hciroot":{"type":"string","description":"Override $HCIROOT."}},"required":["mode","query"]}}, {"name":"nc_insert_protocol","description":"Insert a new protocol block into a NetConfig file. ALL WRITES GO THROUGH THE JOURNAL — original is snapshotted, diff is saved, the file is atomically replaced. Use larry_rollback_list to view, larry-rollback.sh CLI to undo. mode=end appends; mode=after needs anchor=existing-protocol-name; mode=before needs anchor.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"Target NetConfig file path."},"block":{"type":"string","description":"The full protocol block text (starting with 'protocol NAME {' and ending with '}'). Get this from nc_make_jump output."},"mode":{"type":"string","enum":["end","after","before"],"description":"Insertion position. Default end."},"anchor":{"type":"string","description":"For mode=after|before: existing protocol name to position relative to."}},"required":["netconfig","block"]}}, {"name":"nc_add_route","description":"Splice a route entry into an existing protocol's DATAXLATE block. Used to add a new DEST to an inbound's routing (e.g. wiring the OLD inbound to also route to the new linux__out jump thread). ALL WRITES GO THROUGH THE JOURNAL.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"protocol_name":{"type":"string","description":"The existing protocol to modify."},"route":{"type":"string","description":"The route entry text (an inner `{ ... }` object with CACHEMSG, ROUTE_DETAILS, TRXID, etc.). Get from nc_make_jump's route_add output."}},"required":["netconfig","protocol_name","route"]}}, {"name":"larry_rollback_list","description":"List journal entries — every write that's gone through nc_insert_protocol, nc_add_route, or write_file (once journaled write_file is enabled). Shows session-id, sequence, target, timestamp. Use larry-rollback.sh from the shell to actually roll back.","input_schema":{"type":"object","properties":{"session":{"type":"string","description":"Optional. Limit to one session id."}},"required":[]}}, {"name":"lesson_record","description":"Append a lesson to local capture at $LARRY_HOME/lessons/.md. Use when Bryan teaches you something new (a correction, a pattern, a quirk, a gotcha) so the home-Larry can be updated later. Lessons stay LOCAL; Bryan exports them with `lessons.sh export` and pastes back to home-Larry when he can. CALL THIS WHEN: Bryan corrects a misunderstanding, reveals a site-specific convention, points out a bug, requests a behavior change, or shares a workflow detail you should remember next time.","input_schema":{"type":"object","properties":{"text":{"type":"string","description":"The lesson content. Markdown. Include enough context that home-Larry can act on it without re-deriving."},"topic":{"type":"string","description":"Short topic tag, e.g. \"NetConfig parsing\", \"jump-thread naming\", \"site conventions\"."},"site":{"type":"string","description":"Site this lesson is scoped to, if any. Default: current $HCISITE."},"severity":{"type":"string","enum":["info","warn","fix"],"description":"info=general learning, warn=behavior I should change, fix=Bryan called out a bug."}},"required":["text"]}}, {"name":"hl7_sanitize","description":"Tokenize PHI fields in an HL7 message file. Replaces values in patient identifiers, names, DOB, addresses, phones, SSN, account numbers, providers, visit numbers, NK1/GT1/IN1 fields, etc. with deterministic local tokens like [[MRN_0001]]. Same value gets same token across the entire local lookup table, so correlation analysis still works. The token-to-original mapping NEVER leaves the client (stored at $LARRY_HOME/sanitize/lookup.tsv, mode 0600). Use this when Bryan needs you to analyze a file that has real PHI. Returns the sanitized HL7 content with tokens substituted. Bryan can desanitize the final output locally with hl7-desanitize.sh.","input_schema":{"type":"object","properties":{"input_path":{"type":"string","description":"Path to the HL7 message file to sanitize."},"strict":{"type":"integer","description":"1=also tokenize any unknown Z* segments wholesale. Default 0 (safer for legibility but might miss custom PHI in Z segments)."}},"required":["input_path"]}}, {"name":"ssh_exec","description":"Run a shell command on a remote test/dev host via an authenticated SSH ControlMaster session. Bryan must have already configured the alias (via /ssh-add) and opened the master (via /ssh-setup). The password is stored locally and you CANNOT see it — do not ask Bryan for it; if the master is closed, tell him to run the /ssh-setup ALIAS slash command. Use ssh_status first to confirm which aliases are open. Output capped at max_lines (default 500). Tool result includes the remote exit code as a [ssh_exec: exit rc=N] footer.","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"Host alias Bryan configured. Run ssh_status to see the list."},"command":{"type":"string","description":"Shell command to execute on the remote. Quote as needed; will be passed through ssh as a single string."},"max_lines":{"type":"integer","description":"Cap output lines (default 500). Increase for known-large output, but prefer targeted commands."}},"required":["alias","command"]}}, {"name":"ssh_status","description":"List the SSH hosts Bryan has configured and which ones have an open ControlMaster session. Call this BEFORE ssh_exec to confirm an alias exists and the master is open. Each line shows: alias, user@host, port, cred (present/absent), master (open or dash). If the master is not open for an alias you need, ask Bryan to run the /ssh-setup ALIAS slash command. Do NOT attempt to authenticate yourself — you have no access to the password.","input_schema":{"type":"object","properties":{},"required":[]}}, {"name":"hl7_diff","description":"HL7-aware diff between two message files (or multi-message dumps). Compares segment-by-segment, field-by-field, with component and subcomponent precision. Ignores configured fields (default MSH.7 timestamp) so timestamp-only diffs do not show up as noise. Use for regression testing between environments (e.g. test vs prod route-test outputs).","input_schema":{"type":"object","properties":{"left":{"type":"string","description":"Path to left HL7 file."},"right":{"type":"string","description":"Path to right HL7 file."},"ignore":{"type":"string","description":"Comma-separated list of fields to ignore (e.g. MSH.7,MSH.10,EVN.6). Default MSH.7."},"include":{"type":"string","description":"If set, ONLY these fields are compared (overrides ignore for that set)."},"format":{"type":"string","enum":["text","tsv","count"],"description":"text=human-readable diff, tsv=machine-parseable, count=just the difference count."}},"required":["left","right"]}}, {"name":"nc_regression","description":"End-to-end regression testing between two Cloverleaf environments. 6 phases: discover inbounds in scope, sample N messages per inbound from env-A smatdbs, run route_test on env-A, run route_test on env-B with same inputs, hl7_diff every paired output file, compile summary report. Phases 3/4 require the Cloverleaf route_test command; pass it via route_test_cmd with placeholders {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE}. If route_test_cmd is empty, phases 3/4 are skipped and you can run them manually using the generated input files. For cross-env regression testing across SSH-aliased hosts, set source_ssh_alias and target_ssh_alias to existing SSH aliases (run ssh_status to list them first). When set, phases 1–4 run remotely via ssh_exec + ssh_pull/ssh_push; phases 5–6 stay local. env_a / env_b remain the HCIROOT paths AS SEEN ON THE REMOTE for that alias.","input_schema":{"type":"object","properties":{"scope":{"type":"string","description":"thread:NAME | threads:N1,N2 | site (needs site_a) | server (all sites)"},"count":{"type":"integer","description":"Messages to sample per inbound. Default 10."},"env_a":{"type":"string","description":"HCIROOT of env-A (the test/source env). If source_ssh_alias is set, this is the remote-side path."},"site_a":{"type":"string","description":"Site name on env-A. Required if scope=site."},"env_b":{"type":"string","description":"HCIROOT of env-B (the prod/target env). If target_ssh_alias is set, this is the remote-side path."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"LOCAL output root directory for inputs, outputs, diffs, and summary."},"route_test_cmd":{"type":"string","description":"Command template for invoking route_test. Use {THREAD} {INPUT} {OUTPUT_DIR} {HCIROOT} {HCISITE} as placeholders."},"ignore":{"type":"string","description":"hl7_diff ignore list. Default MSH.7."},"phase":{"type":"string","enum":["1","2","3","4","5","6","all"],"description":"Run a specific phase or all. Default all."},"dry_run":{"type":"integer","description":"1 = print what would happen, do not execute. Default 0."},"source_ssh_alias":{"type":"string","description":"SSH alias for the env-A (source) host. When set, phases 1–3 run remotely. Master must be open (ssh_status). Default empty = local."},"target_ssh_alias":{"type":"string","description":"SSH alias for the env-B (target) host. When set, phase 4 runs remotely. Master must be open. Default empty = local."}},"required":["scope","env_a","env_b","out"]}}, {"name":"ssh_pull","description":"Pull a file from a remote SSH-aliased host to a local path via the existing ControlMaster (no second auth, no second TCP handshake). Use this BEFORE calling any local tool (read_file, nc_diff_interface, grep_files, hl7_diff, etc.) when the source file lives on a remote host. The local path returned by this tool is stable for re-use within and across turns — pulling the same remote_path again returns the same local_path. If local_path is omitted, a deterministic temp path /tmp/larry-pulls/.. is used. Verifies the master is open first; if not, fails with a clear message ('open the master with /ssh-setup first'). Validates the transferred size matches the remote stat.","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"SSH alias (see ssh_status). Master must be open."},"remote_path":{"type":"string","description":"Absolute path on the remote host."},"local_path":{"type":"string","description":"Optional explicit local destination. If omitted, a deterministic /tmp/larry-pulls/.. path is used and printed in the tool result."}},"required":["alias","remote_path"]}}, {"name":"ssh_push","description":"Push a local file to a remote SSH-aliased host via the existing ControlMaster. Use for sending small input bundles to a remote env (e.g. regression-test input messages, a sanitized HL7 file to feed into route_test). Same multiplexing + error handling as ssh_pull. Validates remote-side size matches local size post-transfer.","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"SSH alias (see ssh_status). Master must be open."},"local_path":{"type":"string","description":"Absolute local path to the file to send."},"remote_path":{"type":"string","description":"Absolute remote destination path."}},"required":["alias","local_path","remote_path"]}}, {"name":"ssh_pull_smat","description":"Pull a Cloverleaf thread's smat archive (or recent messages from it) from a remote env. Two modes: (1) Full pull — omit days_back; the entire .smatdb file is scp'd locally; returns the local path. Fine for small archives. (2) Sampled — pass days_back=N; runs sqlite3 server-side to pull just messages from the last N days as TSV with base64-encoded blobs (unix_tsdirectiontypesourcedestmessage_blob_b64). Capped at 1000 rows; the trailer line reports truncated=yes/no. Avoids transferring multi-GB smatdbs when only N samples are needed. Uses ssh_exec under the hood to find the .smatdb path (the file lives at $HCISITEDIR/exec/processes/*/.smatdb on the remote, where * is a process name that varies by site).","input_schema":{"type":"object","properties":{"alias":{"type":"string","description":"SSH alias (see ssh_status). Master must be open."},"site":{"type":"string","description":"Cloverleaf HCISITE name on the remote — used to resolve $HCISITEDIR=$HCIROOT/."},"thread":{"type":"string","description":"Thread name (e.g. IB_ADT_muxS). The .smatdb is auto-located via find on the remote."},"days_back":{"type":"integer","description":"Optional. If set, sampled mode: only messages from the last N days are returned, base64-encoded, capped at 1000 rows. Omit for full-file pull."}},"required":["alias","site","thread"]}}, {"name":"nc_diff_interface","description":"Diff one Cloverleaf interface across two NetConfigs. Compares the protocol block plus referenced xlates, tclprocs, and (optionally) tables. Operates on LOCAL NetConfig paths. If a NetConfig file is on a remote host, first use ssh_pull to fetch it locally (and the related Xlate/, tclprocs/, tables/ dirs alongside), then pass the local paths here. The site root is dirname(NetConfig); related artifacts (Xlate/, tclprocs/, tables/) must be alongside that file.","input_schema":{"type":"object","properties":{"interface":{"type":"string","description":"Protocol/thread name to diff. e.g. ADTto_3m."},"left":{"type":"string","description":"Local path to the LEFT NetConfig file (e.g. dev)."},"right":{"type":"string","description":"Local path to the RIGHT NetConfig file (e.g. qa)."},"out":{"type":"string","description":"Optional output path for the markdown report. Default stdout."},"include_tables":{"type":"integer","description":"1 = also diff referenced tables. Default 0."},"left_label":{"type":"string","description":"Display label for left side (default A)."},"right_label":{"type":"string","description":"Display label for right side (default B)."},"depth":{"type":"integer","description":"Hops out from the named interface to also diff. Default 1."}},"required":["interface","left","right"]}} ] TOOLS_END ) # ───────────────────────────────────────────────────────────────────────────── # API call # ───────────────────────────────────────────────────────────────────────────── call_api() { local payload_file="$1" local auth_args=() if [ "$LARRY_AUTH_MODE" = "oauth" ]; then local oauth_script="$LARRY_LIB_DIR/oauth.sh" local token="" oauth_stderr_file="" if [ -x "$oauth_script" ]; then # Capture stderr so we can surface WHY ensure failed instead of silently # swallowing it. v0.6.4 and earlier piped 2>/dev/null here — that hid # the entire diagnostic chain when the file was corrupt, the refresh # 401'd, or jq couldn't read the path on MobaXterm. Never again. oauth_stderr_file=$(mktemp 2>/dev/null || echo "") if [ -n "$oauth_stderr_file" ]; then token=$("$oauth_script" ensure 2>"$oauth_stderr_file") else # Fallback if mktemp failed: still capture stderr inline. token=$("$oauth_script" ensure 2>&1 >/dev/null) && token=$("$oauth_script" ensure 2>/dev/null) || true fi else err "oauth.sh not found at $oauth_script — cannot ensure OAuth token" fi if [ -z "$token" ]; then err "OAuth token unavailable; run 'larry-auth.sh login' to re-authenticate" if [ -n "$oauth_stderr_file" ] && [ -s "$oauth_stderr_file" ]; then err "oauth.sh ensure said:" sed 's/^/ /' "$oauth_stderr_file" >&2 err "(for full diagnostic, run '/oauth-debug' in this REPL)" else err "oauth.sh ensure returned no stderr — try '/oauth-debug' for full state dump" fi [ -n "$oauth_stderr_file" ] && rm -f "$oauth_stderr_file" return 1 fi [ -n "$oauth_stderr_file" ] && rm -f "$oauth_stderr_file" auth_args=(-H "Authorization: Bearer $token" -H "anthropic-beta: oauth-2025-04-20") else auth_args=(-H "x-api-key: $ANTHROPIC_API_KEY") fi # v0.6.9: dump response headers to a tempfile via -D so the status-line # tracker can parse anthropic-ratelimit-* fields after the call returns. # The body still goes to stdout. We deliberately don't use -i (which would # interleave headers into stdout) because that would break the existing # callers that pipe the body straight into jq. local _hdrs_file; _hdrs_file=$(mktemp 2>/dev/null || echo "") local _curl_args=( -sS --max-time 180 ) [ -n "$_hdrs_file" ] && _curl_args+=( -D "$_hdrs_file" ) curl "${_curl_args[@]}" \ "${auth_args[@]}" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ --data-binary "@$payload_file" \ "$LARRY_API_URL" local _curl_rc=$? # Parse headers regardless of whether the body parse will succeed; headers # carry rate-limit info even on 429s. if [ -n "$_hdrs_file" ] && [ -s "$_hdrs_file" ]; then _parse_response_headers "$_hdrs_file" 2>/dev/null || true rm -f "$_hdrs_file" fi return $_curl_rc } # 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 # v0.6.9: dump response headers via -D for status-line tracking. -D writes # the header block immediately when the server emits it, BEFORE the SSE body # starts flowing — so the body stream on stdout is unaffected. We parse the # headers file at the START of the next agent_turn (see _maybe_drain_pending_ # headers). Why not after curl returns? Because this function is the LEFT # side of a pipeline and a `return` here happens in a subshell; the parent # process can't see updates to status vars unless we drain the file later. # # We stash the file path on disk so the next call_api/call_api_stream (or # the REPL renderer) can pick it up. Path is deterministic so the picker # doesn't need to share a variable across the subshell boundary. local _hdrs_file="$LARRY_HOME/.last-stream-headers" : > "$_hdrs_file" 2>/dev/null || _hdrs_file="" local _curl_args=( -sN --max-time 300 ) [ -n "$_hdrs_file" ] && _curl_args+=( -D "$_hdrs_file" ) curl "${_curl_args[@]}" \ "${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" } # _drain_pending_stream_headers — called by the parent shell after a streaming # turn completes. The streaming curl runs in a subshell (LHS of a pipe), so # its in-memory updates to STATUS_* vars don't survive. We persist the header # block on disk instead and parse it here, in the parent. _drain_pending_stream_headers() { local f="$LARRY_HOME/.last-stream-headers" if [ -s "$f" ]; then _parse_response_headers "$f" 2>/dev/null || true rm -f "$f" fi } build_system_prompt() { local sys="" # Load larry.md first (sets identity), then everything else alphabetically. if [ -f "$LARRY_HOME/agents/larry.md" ]; then sys+="$(cat "$LARRY_HOME/agents/larry.md")"$'\n\n' fi local f for f in "$LARRY_HOME/agents/"*.md; do [ -f "$f" ] || continue case "$f" in */larry.md) ;; # already added *) sys+="$(cat "$f")"$'\n\n' ;; esac done sys+="$CLOVERLEAF_CTX" printf '%s' "$sys" } # ───────────────────────────────────────────────────────────────────────────── # 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 )) # v0.6.9: record per-turn context size for the status line. # NB: this function runs in the parse_stream_to_response subshell, so its # update to STATUS_ctx_used_tokens won't propagate. The parent shell # re-derives this from the synthetic response file in agent_turn below. # 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. v0.6.9: include cache_* so the parent shell # (which doesn't see this subshell's STATUS_* updates) can recompute the # per-turn ctx total = input + cache_creation + cache_read. jq -n \ --argjson content "$content_json" \ --arg stop "$stop_reason" \ --argjson in_t "$in_tokens" --argjson out_t "$out_tokens" \ --argjson cr "$cache_read" --argjson cw "$cache_write" \ '{content:$content, stop_reason:$stop, usage:{input_tokens:$in_t, output_tokens:$out_t, cache_read_input_tokens:$cr, cache_creation_input_tokens:$cw}}' \ > "$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 # them via --arg / --argjson. Combined budget (TOOLS_JSON ~21KB + system # prompt ~25KB) easily exceeds Cygwin's ~32KB argv cap → E2BIG. local tools_file system_file 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, stream:$stream, system:$system, messages:$messages[0], tools:$tools[0]}' \ > "$payload_file" local resp="" local resp_file; resp_file=$(mktemp) local used_stream=0 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 # v0.6.9: drain rate-limit headers from the streaming curl (subshell # could not update STATUS_* vars directly). _drain_pending_stream_headers 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: $(_humanize_api_error "$resp")" rm -f "$tools_file" "$system_file" return 1 fi local blocks; blocks=$(printf '%s' "$resp" | jq -c '.content') add_assistant_blocks "$blocks" # 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 # v0.6.9: update the per-turn context-window tracker from THIS turn's # usage block. Runs in both streaming and non-streaming paths (the # synthetic stream JSON includes cache_* per v0.6.9 patch). The status # line reads this on the next prompt render. local _ctx_in _ctx_cr _ctx_cw _ctx_in=$(printf '%s' "$resp" | jq -r '.usage.input_tokens // 0' 2>/dev/null) _ctx_cr=$(printf '%s' "$resp" | jq -r '.usage.cache_read_input_tokens // 0' 2>/dev/null) _ctx_cw=$(printf '%s' "$resp" | jq -r '.usage.cache_creation_input_tokens // 0' 2>/dev/null) _record_ctx_used "$_ctx_in" "$_ctx_cr" "$_ctx_cw" # Log assistant text to session log { log_section "assistant" printf '%s' "$resp" | jq -r '.content[] | select(.type=="text") | .text' >> "$LOG_FILE" } local stop; stop=$(printf '%s' "$resp" | jq -r '.stop_reason // empty') if [ "$stop" != "tool_use" ]; then break; fi # Process tool uses local results='[]' while IFS= read -r tool_use; do [ -z "$tool_use" ] && continue local tu_id name input_json tu_id=$(printf '%s' "$tool_use" | jq -r '.id') name=$(printf '%s' "$tool_use" | jq -r '.name') input_json=$(printf '%s' "$tool_use" | jq -c '.input') # 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") # 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 # 500 lines, etc.) — pass via tempfile, not --arg, to avoid Cygwin # argv overflow. local result_file; result_file=$(mktemp) printf '%s' "$result" > "$result_file" results=$(printf '%s' "$results" | jq \ --arg id "$tu_id" --rawfile c "$(jqpath "$result_file")" \ '. + [{"type":"tool_result","tool_use_id":$id,"content":$c}]') rm -f "$result_file" done < <(printf '%s' "$resp" | jq -c '.content[] | select(.type=="tool_use")') add_user_tool_results "$results" done rm -f "$tools_file" "$system_file" } # ───────────────────────────────────────────────────────────────────────────── # Slash commands and REPL # ───────────────────────────────────────────────────────────────────────────── print_help() { cat < switch model (e.g. /model claude-opus-4-7) /cd change working directory /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) /auth show OAuth status (or "not authenticated") /login run OAuth login flow (switch from API-key to subscription auth) /logout delete OAuth tokens (revert to API-key auth) /oauth-debug dump full OAuth diagnostic (file state, parsed expiry, jq path/flavor, cygpath translation, truncated tokens, live ensure trace). Safe to copy-paste; secrets truncated. /lesson capture a lesson to local file (paste back to home-Larry later) /lessons list all captured lessons (newest first) /export dump the lesson bundle for paste-back to home-Larry /phi tokenize a PHI value locally; prints token to paste in prompts /unmask show the original PHI for a token (local only; never sent) /tokens show the full local PHI ↔ token lookup table Secure SSH (password stays local; never visible to Larry-the-LLM): /ssh-hosts list configured remote hosts /ssh-add register a new host /ssh-pass set/update password (hidden input; daily rotation OK) /ssh-setup open a long-lived ControlMaster connection /ssh-close close the ControlMaster /ssh-status [alias] show open masters + cred presence /ssh run command on the remote (you-driven, ad-hoc) Larry can also run things there via the ssh_exec tool. Cross-environment Cloverleaf shortcuts (v0.6.8): /nc-diff-env [pattern] diff NetConfigs across two SSH-aliased envs (e.g. /nc-diff-env qa dev ADT) /nc-regression-env [scope] 6-phase regression across SSH-aliased envs (e.g. /nc-regression-env dev qa server) HL7 schema lookup (v0.7.0): /hl7 print the field list for an HL7 segment (e.g. /hl7 PID → all 30 PID fields) /hl7 (no arg) list all known HL7 segments /hl7-fields print component breakdown for a field (e.g. /hl7-fields PID.5 → Family, Given, ...) Mouse mode (v0.7.0): /mouse on|off toggle xterm mouse + bracketed-paste for the session. Status with /mouse (no arg). Env: LARRY_NO_MOUSE=1 disables at startup. Caveat: click-to-position-cursor in the input line is terminal-dependent; iTerm2 and modern macOS Terminal forward clicks; MobaXterm/Cygwin behaviour varies. PHI inline syntax in any prompt: @@VALUE EASY: wrap PHI in @@. Spaceless = no end delim. e.g. @@12345 @@SMITH^JOHN @@V789 @@VALUE@@ Use when VALUE has spaces. e.g. @@John Smith@@ @@Smith, John@@ Name canonicalization: SMITH^JOHN, Smith, John, John Smith, JOHN SMITH all collapse to the same token. Category is auto-detected from value shape (MRN/SSN/DOB/NAME/MANUAL). {{phi:VALUE}} / {{phi:CAT:VALUE}} legacy syntax (still works) Automatic PHI detection (v0.7.1): Larry now scans every prompt for PHI-shaped values and tokenizes them BEFORE sending to Anthropic. Detects emails, SSNs, phones, dates, MRNs (6-12 pure digits), HL7 caret-names, "Last, First" names, and title-case "John Smith" patterns. Paths, URLs, timestamps, and a small allowlist (Home Assistant, Mac Studio, etc.) are skipped. Modes (env LARRY_AUTO_PHI or /auto-phi): confirm default — prompts Y/n on loose name-like matches once per session; explicit-format hits (email/SSN/phone/etc.) are always tokenized aggressive tokenize every match silently off disable auto-detection entirely (manual markers still work) Per-turn override: prefix any prompt with "!nophi " to skip the scan for that turn only. Manual @@VALUE / {{phi:VALUE}} markers always win. /redetect re-scan for HCIROOT/HCISITE/tools /sites list site dirs under HCIROOT /site switch HCISITE for this session /pwd show current working directory /help this help 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. @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). Status line (v0.6.9, repositioned v0.7.1): A dim 1-line summary now prints BELOW each just-completed turn (after the Larry response, before the next you[...]> prompt) so it stays adjacent to the conversation flow: OAuth: ─ ctx 12% (24K/200K) ─ 5h 1.8% reset 19:45 ─ 7d 73.7% reset Mon Jun 2 ─ API key: ─ ctx 12% (24K/200K) ─ $0.213 session ─ 14 turns ─ Disable entirely with LARRY_NO_STATUS=1. Force re-display with /status. Suppressed automatically on the first turn (no data yet). Memory upload at session close (v0.7.1): When LARRY_MEMORY_UPLOAD_URL is set, on clean exit Larry POSTs three artifacts to the configured endpoint: $LARRY_HOME/log/headers.log (header-log), $LARRY_HOME/sessions/.log.md (session-log), and .messages.json (session-messages). Each request carries X-Larry-Source, X-Larry-Version, and X-Session-Id headers. Unset = silent skip with a one-line warn at exit. TAB completion (v0.6.6/v0.6.7/v0.7.0): Type '/' followed by any prefix and press TAB. /h → /help /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). After @, file-path completion kicks in instead. HL7 inline completion (v0.7.0): tab-complete segments, fields, and components while you type a prompt. M → MSH (single match) PI → PID (single match) PID. → lists all 30 PID fields with descriptions PID.3 → completes to "PID.3 " (trailing space) PID.5. → lists PID.5 components (Family Name, Given Name, ...) PID.5.1 → completes to "PID.5.1 " Z-segments (site-specific) are not in the built-in schema; tab on Z prints a one-line hint. Non-slash input not matching any of the above falls back to a literal tab. EOF } # _slash_args CMD INPUT # Strip a leading "/cmd " (or just "/cmd") from INPUT and echo whatever follows. # If INPUT is just "/cmd" alone, echoes empty. Robust across bash versions — # doesn't rely on case-pattern escaped-space matching. _slash_args() { local cmd="$1" input="$2" case "$input" in "$cmd") printf '' ;; "$cmd "*) printf '%s' "${input#"$cmd "}" ;; "$cmd"*) printf '%s' "${input#"$cmd"}" ;; # no-space variants (rare) *) printf '' ;; esac } # _run_ssh_helper SUBCMD [ARGS...] # Invoke lib/ssh-helper.sh with arguments. Centralises the installed/missing # check and shields the main REPL from sub-helper exit codes (so a failing # ssh command doesn't propagate out and trip set -u elsewhere). _run_ssh_helper() { local helper="$LARRY_LIB_DIR/ssh-helper.sh" if [ ! -x "$helper" ]; then err "ssh-helper.sh not installed (expected at $helper)" return 0 fi "$helper" "$@" || true } # ───────────────────────────────────────────────────────────────────────────── # Slash-command TAB completion (v0.6.6) # ───────────────────────────────────────────────────────────────────────────── # # _LARRY_SLASH_CMDS — canonical list of slash commands. This is the single # source of truth for the TAB-completion function. The case statement in # main_loop is the dispatcher; this array is what the user sees when they # fuzzy-match. Keep them in sync when adding new commands. # # Excluded on purpose: command aliases (e.g. /exit and /q both map to # /quit) — completing to the canonical form is friendlier than offering # every spelling. _LARRY_SLASH_CMDS=( /help /quit /sys /pwd /env /auth /login /logout /oauth-debug /lesson /lessons /export /phi /unmask /tokens /ssh /ssh-hosts /ssh-add /ssh-remove /ssh-pass /ssh-setup /ssh-close /ssh-status /redetect /sites /site /reset /model /cd /load /clear /copy /cost /status /show-last-tool /nc-diff-env /nc-regression-env /hl7 /hl7-fields /mouse ) # _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" [/status]="force-render the persistent status line (ctx + rate-limit)" [/show-last-tool]="print full last tool call + result for debugging" [/nc-diff-env]=" [pattern] diff NetConfigs across two SSH-aliased envs" [/nc-regression-env]=" [scope] 6-phase regression across SSH-aliased envs" [/hl7]=" print full field list for an HL7 segment (e.g. /hl7 PID)" [/hl7-fields]=" print component breakdown (e.g. /hl7-fields PID.5)" [/mouse]="on|off toggle xterm mouse mode for this session" [/auto-phi]="on|off|aggressive|confirm — runtime control for v0.7.1 auto PHI detection" [/auto-phi-status]="show current auto-PHI mode + session tokenization count" ) # __larry_complete_slash — bound to TAB via `bind -x` (see _install_readline_tab). # # Reads READLINE_LINE (the current line buffer) and READLINE_POINT (cursor # position, 0-indexed). If the line starts with "/" and the cursor is on the # first word, we attempt prefix completion against _LARRY_SLASH_CMDS: # # * exactly one match → replace the line with the match (+ a trailing space # for commands that take an arg, e.g. "/site ") # * many matches → print them under the prompt and re-display the line # (readline will redraw automatically when we return) # * zero matches → silent no-op (readline does NOT insert a literal # tab, matching slash-aware completion in modern # shells) # # If the line does NOT start with "/" we insert a literal tab so the user's # muscle memory for whitespace alignment / indented heredocs still works. # # Refs: # bash(1) — READLINE Variables: $READLINE_LINE, $READLINE_POINT. # bash(1) — `bind -x '"\C-x": shell-function'` binds a key to a shell # function that may read/modify $READLINE_LINE in place. Available since # bash 4.0. __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 # v0.7.0: HL7-aware tab completion. # Extract the trailing whitespace-delimited token of $pre and test for HL7 # shapes. If none match, fall through to slash-command / literal-tab logic. local hl7_token="" case "$pre" in ''|*[[:space:]]) hl7_token="" ;; *) hl7_token="${pre##*[[:space:]]}" ;; esac # Recognised HL7 shapes: # 1) ^[A-Z]{1,3}$ partial segment ID (e.g. M, MS, MSH, PI) # 2) ^[A-Z]{3}\.\d*$ field within segment (e.g. PID., PID.3, MSH.10) # 3) ^[A-Z]{3}\.\d+\.\d*$ component within field (e.g. PID.5., PID.5.1) if [ -n "$hl7_token" ] && [ -n "${_HL7_SCHEMA_LOADED:-}" ]; then case "$hl7_token" in [A-Z]|[A-Z][A-Z]|[A-Z][A-Z][A-Z]) __larry_complete_hl7_segment "$hl7_token" return 0 ;; [A-Z][A-Z][A-Z].*) # Split on dots to discriminate field vs. component. local _hl7_rest="${hl7_token#???.}" # drop "SEG." case "$_hl7_rest" in *.*) # Two dots in the token — component completion (SEG.N.M*). __larry_complete_hl7_component "$hl7_token" return 0 ;; ''|[0-9]*) # Field completion (SEG.N*). Allow empty (just "PID.") and digit-only. __larry_complete_hl7_field "$hl7_token" return 0 ;; esac ;; esac 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". case "$line" in /*) # Has it already been word-split (a space anywhere in the line)? If yes, # fall through to literal-tab. Completion is for the command name only. case "$line" in *' '*) READLINE_LINE="${line:0:point}"$'\t'"${line:point}" READLINE_POINT=$((point + 1)) return 0 ;; esac ;; *) # Non-slash line — insert a literal tab at the cursor. READLINE_LINE="${line:0:point}"$'\t'"${line:point}" READLINE_POINT=$((point + 1)) return 0 ;; esac # Build the match list. Primary: prefix match. If exactly one prefix match, # complete it. If many, print them. If zero prefix matches AND the input is # at least 2 chars, try a subsequence fuzzy match as a polish; if that # yields exactly one, complete to it. local prefix="$line" local matches=() cmd for cmd in "${_LARRY_SLASH_CMDS[@]}"; do case "$cmd" in "$prefix"*) matches+=("$cmd") ;; esac done if [ "${#matches[@]}" -eq 0 ] && [ "${#prefix}" -ge 2 ]; then # Subsequence fuzzy: every char of $prefix (after the leading '/') must # appear in $cmd in order. Cheap, predictable, no scoring. local needle="${prefix#/}" for cmd in "${_LARRY_SLASH_CMDS[@]}"; do local hay="${cmd#/}" local i=0 nlen="${#needle}" ok=1 while [ "$i" -lt "$nlen" ]; do local ch="${needle:i:1}" case "$hay" in *"$ch"*) hay="${hay#*"$ch"}" ;; *) ok=0; break ;; esac i=$((i + 1)) done [ "$ok" -eq 1 ] && matches+=("$cmd") done fi if [ "${#matches[@]}" -eq 1 ]; then # Replace the buffer with the matched command. Append a space so the user # can immediately type the argument. (No-arg commands waste one keystroke # — acceptable.) READLINE_LINE="${matches[0]} " READLINE_POINT=${#READLINE_LINE} elif [ "${#matches[@]}" -gt 1 ]; then # 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_hl7_segment PARTIAL # Complete an HL7 segment ID at the cursor. PARTIAL is 1..3 uppercase letters. # - Exactly one match → replace with the full segment ID (no trailing space # so the user can keep typing ".") # - Multiple matches → list them with descriptions # - Zero matches → if PARTIAL starts with Z, print a Z-segment hint; # else silent no-op __larry_complete_hl7_segment() { local partial="$1" local line="$READLINE_LINE" local point="${READLINE_POINT:-0}" local pre="${line:0:point}" local post="${line:point}" # Locate the start of the partial inside pre so we can splice the replacement. local pre_head="${pre%"$partial"}" local matches=() s while IFS= read -r s; do [ -n "$s" ] && case "$s" in "$partial"*) matches+=("$s") ;; esac done < <(hl7_segments) if [ "${#matches[@]}" -eq 1 ]; then READLINE_LINE="${pre_head}${matches[0]}${post}" READLINE_POINT=$((${#pre_head} + ${#matches[0]})) return 0 fi # If the partial is itself an exact segment ID AND has more prefix-matches, # treat the exact match as the chosen completion (add a dot so the user can # continue typing the field). Common case: "MSH" with MSA also in the schema. if [ "${#matches[@]}" -gt 1 ] && [ -n "$(hl7_seg_desc "$partial")" ]; then READLINE_LINE="${pre_head}${partial}.${post}" READLINE_POINT=$((${#pre_head} + ${#partial} + 1)) return 0 fi if [ "${#matches[@]}" -gt 1 ]; then printf '\n' local m desc for m in "${matches[@]}"; do desc=$(hl7_seg_desc "$m") printf ' %s%-6s%s %s%s%s\n' "$C_CYAN" "$m" "$C_RESET" "$C_DIM" "$desc" "$C_RESET" done return 0 fi # No matches. Hint for Z-segments (site-specific, not baked in). case "$partial" in Z*) printf '\n %s(Z-segments are site-specific; not in the built-in schema)%s\n' "$C_DIM" "$C_RESET" ;; esac return 0 } # __larry_complete_hl7_field TOKEN # Complete an HL7 field within a segment. TOKEN looks like: # PID. → list all 30 PID fields # PID.1 → if unique completes to "PID.1 "; if many (1, 10..19) lists them # PID.3 → unique, completes to "PID.3 " __larry_complete_hl7_field() { local token="$1" local line="$READLINE_LINE" local point="${READLINE_POINT:-0}" local pre="${line:0:point}" local post="${line:point}" local pre_head="${pre%"$token"}" local seg="${token%%.*}" local partial="${token#*.}" # may be empty # Unknown segment — nothing to do. [ -z "$(hl7_seg_desc "$seg")" ] && return 0 # Gather candidate field indices that match the partial prefix. local matches=() idx name line2 while IFS=$'\t' read -r idx name; do case "$idx" in "$partial"*) matches+=("$idx"$'\t'"$name") ;; esac done < <(hl7_fields_for "$seg") if [ "${#matches[@]}" -eq 1 ]; then # Single match: complete to "SEG.N " (trailing space). local pair="${matches[0]}" local i="${pair%%$'\t'*}" local replacement="${seg}.${i} " READLINE_LINE="${pre_head}${replacement}${post}" READLINE_POINT=$((${#pre_head} + ${#replacement})) return 0 fi # If the partial is itself a valid exact field index AND there are other # prefix-matches (e.g. PID.3 also prefix-matches PID.30), prefer the exact # match — the user typed the complete number deliberately. if [ "${#matches[@]}" -gt 1 ] && [ -n "$partial" ] && [ -n "$(hl7_field_name "${seg}.${partial}")" ]; then local replacement="${seg}.${partial} " READLINE_LINE="${pre_head}${replacement}${post}" READLINE_POINT=$((${#pre_head} + ${#replacement})) return 0 fi if [ "${#matches[@]}" -gt 1 ]; then printf '\n' local pair i n key label for pair in "${matches[@]}"; do i="${pair%%$'\t'*}" n="${pair#*$'\t'}" label="${seg}.${i}" printf ' %s%-12s%s %s%s%s\n' "$C_CYAN" "$label" "$C_RESET" "$C_DIM" "$n" "$C_RESET" done return 0 fi return 0 } # __larry_complete_hl7_component TOKEN # Complete an HL7 component within a field. TOKEN looks like: # PID.5. → list all PID.5 components (Family, Given, ...) # PID.5.1 → unique, completes to "PID.5.1 " __larry_complete_hl7_component() { local token="$1" local line="$READLINE_LINE" local point="${READLINE_POINT:-0}" local pre="${line:0:point}" local post="${line:point}" local pre_head="${pre%"$token"}" # Split SEG.N.M-partial. We accept SEG = 3 uppercase letters. local seg="${token%%.*}" local rest="${token#*.}" # N.M-partial local field="${rest%%.*}" local partial="${rest#*.}" # may be empty local key="${seg}.${field}" # Validate the field actually exists in the schema. [ -z "$(hl7_field_name "$key")" ] && return 0 local matches=() idx name while IFS=$'\t' read -r idx name; do case "$idx" in "$partial"*) matches+=("$idx"$'\t'"$name") ;; esac done < <(hl7_components_for "$key") if [ "${#matches[@]}" -eq 0 ]; then # Field has no component breakdown defined. Print a one-line note so the # user knows tab-complete didn't fail — the data just isn't there. printf '\n %s(no component breakdown for %s in built-in schema)%s\n' "$C_DIM" "$key" "$C_RESET" return 0 fi if [ "${#matches[@]}" -eq 1 ]; then local pair="${matches[0]}" local m="${pair%%$'\t'*}" local replacement="${key}.${m} " READLINE_LINE="${pre_head}${replacement}${post}" READLINE_POINT=$((${#pre_head} + ${#replacement})) return 0 fi printf '\n' local pair m n label for pair in "${matches[@]}"; do m="${pair%%$'\t'*}" n="${pair#*$'\t'}" label="${key}.${m}" printf ' %s%-14s%s %s%s%s\n' "$C_CYAN" "$label" "$C_RESET" "$C_DIM" "$n" "$C_RESET" done return 0 } # __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 # subshells, sh-mode invocations). _install_readline_tab() { # `bind -x` is bash 4.0+. The `2>/dev/null` swallows the warning bash # emits on non-tty stdin ("bind: warning: line editing not enabled"). bind -x '"\t": __larry_complete_slash' 2>/dev/null || true } # v0.7.0: mouse support in the REPL. # # What this *does* enable: # - Bracketed-paste mode: terminal wraps pastes in \e[200~ ... \e[201~ so # multi-line pastes don't accidentally trigger early Enter. Most modern # terminals + readline (bind 'set enable-bracketed-paste on') do this # already; we set it explicitly to be safe. # - SGR mouse reporting (mode 1006): the terminal emits CSI ;x;yM / m # for clicks. Cooperating terminals (iTerm2, modern macOS Terminal, # xterm, kitty, alacritty) will forward these to the foreground process. # # What this *does not* attempt (yet): # - Click-to-position cursor in the readline input line. Reliable across # terminals would require: # (a) parsing the CSI escape sequence in real time, # (b) mapping (col,row) → buffer offset (which depends on the # prompt-line wrap, terminal width, and any preceding output), # (c) updating $READLINE_POINT from inside a `bind -x` handler bound # to ESC. # Bash readline lets you `bind -x '"\e[<": _handler'` but the handler # fires *per byte* (no buffering of the rest of the sequence) on most # bashes; the implementations that work require term-specific shims. # We document the limitation and ship the safer subset. # # Kill switch: LARRY_NO_MOUSE=1 in the environment skips both enable and # disable. /mouse on|off toggles at runtime. # # Refs: # - xterm Control Sequences (Ctlseqs.txt) — modes 1000/1003/1006/2004. # https://invisible-island.net/xterm/ctlseqs/ctlseqs.html # - readline 'set enable-bracketed-paste on' (~/.inputrc). _LARRY_MOUSE_ACTIVE=0 _install_mouse_mode() { # Honour the env kill switch. if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then _LARRY_MOUSE_ACTIVE=0 return 0 fi # Only attempt if we have a TTY. [ -t 1 ] || return 0 # Bracketed paste (terminal side). Idempotent in any decent terminal. printf '\033[?2004h' 2>/dev/null || true # Readline-side bracketed paste (so readline strips the wrapper bytes and # treats the paste as one chunk rather than typed input). bind 'set enable-bracketed-paste on' 2>/dev/null || true # SGR-encoded mouse reporting (mode 1006). Use 1000 (X10 button events) as # the base; 1003 (any-event including motion) is intentionally NOT enabled # — it floods the input stream and can interfere with readline. printf '\033[?1000h\033[?1006h' 2>/dev/null || true _LARRY_MOUSE_ACTIVE=1 } _uninstall_mouse_mode() { # Always emit the disable sequences even if we don't think it was on — # cheap and prevents a borked terminal if our state tracking drifts. [ -t 1 ] || return 0 printf '\033[?1006l\033[?1000l' 2>/dev/null || true _LARRY_MOUSE_ACTIVE=0 } # Ensure mouse mode is disabled on REPL exit (Ctrl-C, /quit, EOF). The trap # itself is registered AFTER the v0.7.1 upload function below, so we can # chain mouse-mode teardown after the memory upload in a single trap. # ───────────────────────────────────────────────────────────────────────────── # v0.7.1 — session-artifact upload at session close. # # When LARRY_MEMORY_UPLOAD_URL is set, on clean exit we POST the headers.log, # the session log.md, and the messages.json file to the configured endpoint. # Each artifact goes as its own request with distinguishing headers so the # ingest side can route appropriately. # # Bryan's memory pipeline (fswatch + ingest daemon) only sees files on his # Mac; the WORK BOX (MobaXterm/Cygwin) where larry.sh runs is isolated, so # we upload over the existing tailscale/network path. # # Safety: # - headers.log filters to ^anthropic-* / ^retry-after: response headers # only — request auth headers (Authorization / x-api-key) are NEVER # captured into the log at write time (see _parse_response_headers). # - session log.md contains conversation content. By design Bryan uses # PHI markers / auto-PHI, so PHI is already tokenized before reaching # the log. Auth tokens never enter the conversation stream. # - messages.json contains the same token-substituted conversation # content as the log. # # Set LARRY_MEMORY_UPLOAD_URL= (e.g. on proxy.bjnoela.com) to # enable. Unset = silent skip with a one-line warn at session close. # ───────────────────────────────────────────────────────────────────────────── _LARRY_UPLOAD_FIRED=0 upload_session_artifacts() { # Run once per session (in case both clean exit and EXIT trap fire). [ "$_LARRY_UPLOAD_FIRED" = "1" ] && return 0 _LARRY_UPLOAD_FIRED=1 local url="${LARRY_MEMORY_UPLOAD_URL:-}" if [ -z "$url" ]; then warn "(memory upload skipped: LARRY_MEMORY_UPLOAD_URL not configured)" return 0 fi command -v curl >/dev/null 2>&1 || { warn "(memory upload skipped: curl missing)"; return 0; } local artifacts=( "$LARRY_HOME/log/headers.log|headers-log|text/plain" "$LOG_FILE|session-log|text/markdown" "$MESSAGES_FILE|session-messages|application/json" ) local entry path kind ctype http_code uploaded=0 for entry in "${artifacts[@]}"; do path="${entry%%|*}" kind="${entry#*|}"; kind="${kind%%|*}" ctype="${entry##*|}" [ -f "$path" ] || continue [ -s "$path" ] || continue http_code=$(curl -fsS --max-time 15 \ -o /dev/null -w '%{http_code}' \ -X POST "$url" \ -H "Content-Type: $ctype" \ -H "X-Larry-Source: $kind" \ -H "X-Larry-Version: $LARRY_VERSION" \ -H "X-Session-Id: $SESSION_ID" \ --data-binary "@$path" 2>/dev/null) || http_code="000" if [ "$http_code" = "200" ] || [ "$http_code" = "201" ] || [ "$http_code" = "202" ] || [ "$http_code" = "204" ]; then uploaded=$((uploaded + 1)) else warn "(memory upload: $kind → HTTP $http_code)" fi done if [ "$uploaded" -gt 0 ]; then larry_say "memory upload: posted $uploaded artifact(s) to $url" fi } # Fire upload on EXIT trap too (covers Ctrl-C / EOF / kill). The function # is idempotent (_LARRY_UPLOAD_FIRED guard) so the clean-exit call from # main_loop won't double-post. trap 'upload_session_artifacts || true; _uninstall_mouse_mode' EXIT INT TERM read_user_input() { # Returns user input via global LARRY_INPUT. # If first line is "<<", read until line "EOF" (heredoc-style). # # 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). # # 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]>%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 [ "$line" = "EOF" ] && break LARRY_INPUT+="$line"$'\n' done else LARRY_INPUT="$first" fi } # _readline_ok — true if `read -e` is supported by this bash and stdin is a tty. # Cygwin/MobaXterm bash usually supports it; some stripped-down environments # (busybox, dash) don't. _readline_ok() { local _x ( IFS= read -e -r -t 0 _x /dev/null } 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" larry_say "Working dir: $(pwd)" else warn "arg is not a directory, ignoring: $ARG_DIR" fi fi # ── Startup banner ───────────────────────────────────────────────────────── # Always print the version; print a prominent "JUST UPDATED" badge when the # current launch came from a self-update so Bryan can verify the chain fired. if [ -n "${LARRY_UPDATE_NOTICE:-}" ]; then echo "" printf '%s%s═══════════════════════════════════════════════════════════════%s\n' "$C_GREEN" "$C_BOLD" "$C_RESET" printf '%s%s ✓ LARRY UPDATED%s\n' "$C_GREEN" "$C_BOLD" "$C_RESET" printf '%s%s %s%s\n' "$C_GREEN" "$C_BOLD" "$LARRY_UPDATE_NOTICE" "$C_RESET" printf '%s%s═══════════════════════════════════════════════════════════════%s\n' "$C_GREEN" "$C_BOLD" "$C_RESET" echo "" fi # ── Terminal fixups ──────────────────────────────────────────────────────── # Some terminals (notably MobaXterm/Cygwin and certain SSH setups) ship with # stty erase set to ^H while the keyboard actually sends ^? (DEL) for # backspace, so backspace gets passed through to read() as a literal char. # Force erase=^? if we have a tty; harmless if already correct. if [ -t 0 ] && command -v stty >/dev/null 2>&1; then stty erase '^?' 2>/dev/null || true fi # v0.7.0: enable mouse mode (bracketed-paste + SGR mouse reporting). The # trap installed in _install_mouse_mode tears this down on exit. _install_mouse_mode larry_say "${C_BOLD}Larry-Anywhere v$LARRY_VERSION${C_RESET} ready. Model: $LARRY_MODEL." larry_say "Type your message and press Enter. Use '<<' alone on a line to start multi-line (end with 'EOF'). /help for commands." echo "" while true; do local _short; _short=$(model_short_name) # v0.7.1: status line is rendered AFTER the previous agent_turn (see end # of loop), so it sits BELOW the just-completed prompt cycle / agent # response and ABOVE the next prompt. Net visual effect: status reads as # a footer to the most-recent turn. This is "Option B" from the v0.7.1 # spec — chosen over cursor-manipulation Option A because `read -e` # (readline) takes exclusive control of the cursor and inserting a # repositioned footer below an active prompt is fragile on MobaXterm / # Cygwin (readline redisplay clobbers manual cursor moves). printf '%syou[%s]>%s ' "$C_GREEN" "$_short" "$C_RESET" if ! read_user_input; then echo ""; break fi local input="$LARRY_INPUT" [ -z "$input" ] && continue 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 ;; /status) # v0.6.9: force-render the persistent status line on demand, # e.g. when it has scrolled off-screen mid-conversation. if [ "${LARRY_NO_STATUS:-0}" = "1" ]; then larry_say "status line disabled (LARRY_NO_STATUS=1)" else # Temporarily override the "first turn suppression" by # making sure ctx_used has a value even if unknown. [ -z "$STATUS_ctx_window" ] && STATUS_ctx_window=$(_model_context_window "$LARRY_MODEL") if [ -z "$STATUS_ctx_used_tokens" ] \ && [ -z "$STATUS_oauth_5h_utilization" ] \ && [ "$_LARRY_TURNS" -eq 0 ]; then larry_say "no data yet — make a turn first" else render_status_line fi fi continue ;; # v0.7.0: HL7 schema lookup commands. /hl7|/hl7\ *) local _arg; _arg=$(_slash_args "/hl7" "$input") if [ -z "${_HL7_SCHEMA_LOADED:-}" ]; then err "HL7 schema not loaded (lib/hl7-schema.sh missing or bash <4)" continue fi if [ -z "$_arg" ]; then printf '%susage:%s /hl7 e.g. /hl7 PID\n' "$C_YELLOW" "$C_RESET" printf '\n%sknown segments:%s\n' "$C_BOLD" "$C_RESET" local _s _d while IFS= read -r _s; do _d=$(hl7_seg_desc "$_s") printf ' %s%-6s%s %s%s%s\n' "$C_CYAN" "$_s" "$C_RESET" "$C_DIM" "$_d" "$C_RESET" done < <(hl7_segments) continue fi # Normalise to upper, drop a trailing dot if user typed "PID." _arg=$(printf '%s' "$_arg" | tr '[:lower:]' '[:upper:]') _arg="${_arg%.}" if [ -z "$(hl7_seg_desc "$_arg")" ]; then case "$_arg" in Z*) err "$_arg looks like a site-specific Z-segment; not in the built-in schema" ;; *) err "unknown segment: $_arg (try /hl7 with no args to list)" ;; esac continue fi printf '%s%s%s %s%s%s\n' "$C_BOLD$C_CYAN" "$_arg" "$C_RESET" "$C_DIM" "$(hl7_seg_desc "$_arg")" "$C_RESET" local _i _n _label while IFS=$'\t' read -r _i _n; do _label="${_arg}.${_i}" printf ' %s%-12s%s %s%s%s\n' "$C_CYAN" "$_label" "$C_RESET" "$C_DIM" "$_n" "$C_RESET" done < <(hl7_fields_for "$_arg") continue ;; /hl7-fields|/hl7-fields\ *) local _arg; _arg=$(_slash_args "/hl7-fields" "$input") if [ -z "${_HL7_SCHEMA_LOADED:-}" ]; then err "HL7 schema not loaded (lib/hl7-schema.sh missing or bash <4)" continue fi if [ -z "$_arg" ]; then err "usage: /hl7-fields e.g. /hl7-fields PID.5" continue fi _arg=$(printf '%s' "$_arg" | tr '[:lower:]' '[:upper:]') _arg="${_arg%.}" case "$_arg" in [A-Z][A-Z][A-Z].[0-9]*) : ;; *) err "expected form SEG.N (3 uppercase letters, dot, number)"; continue ;; esac local _fname; _fname=$(hl7_field_name "$_arg") if [ -z "$_fname" ]; then err "unknown field: $_arg" continue fi printf '%s%s%s %s%s%s\n' "$C_BOLD$C_CYAN" "$_arg" "$C_RESET" "$C_DIM" "$_fname" "$C_RESET" local _has=0 _m _n _label while IFS=$'\t' read -r _m _n; do _has=1 _label="${_arg}.${_m}" printf ' %s%-14s%s %s%s%s\n' "$C_CYAN" "$_label" "$C_RESET" "$C_DIM" "$_n" "$C_RESET" done < <(hl7_components_for "$_arg") if [ "$_has" -eq 0 ]; then printf ' %s(no component breakdown for %s in built-in schema)%s\n' "$C_DIM" "$_arg" "$C_RESET" fi continue ;; # v0.7.0: mouse mode toggle (xterm SGR mouse + bracketed paste). /mouse|/mouse\ *) local _arg; _arg=$(_slash_args "/mouse" "$input") case "${_arg:-status}" in on) LARRY_NO_MOUSE=0 _install_mouse_mode if [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then larry_say "mouse mode ON (bracketed-paste + SGR mouse reporting; click-to-position is terminal-dependent)" else warn "mouse mode requested but no TTY detected" fi ;; off) _uninstall_mouse_mode LARRY_NO_MOUSE=1 larry_say "mouse mode OFF" ;; status) if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then larry_say "mouse mode: disabled (LARRY_NO_MOUSE=1). /mouse on to enable." elif [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then larry_say "mouse mode: active (bracketed-paste + SGR reporting)" else larry_say "mouse mode: inactive" fi ;; *) err "usage: /mouse on|off (no arg → status)" ;; esac continue ;; # v0.7.1: auto-PHI runtime control. /auto-phi|/auto-phi\ *) local _arg; _arg=$(_slash_args "/auto-phi" "$input") case "${_arg:-status}" in on|confirm) AUTO_PHI_MODE="confirm" larry_say "auto-phi: confirm (default — prompts on loose name-like matches)" ;; aggressive) AUTO_PHI_MODE="aggressive" larry_say "auto-phi: aggressive (tokenizes all candidates silently)" ;; off) AUTO_PHI_MODE="off" larry_say "auto-phi: off (explicit markers @@VALUE / {{phi:VALUE}} still work)" ;; status) larry_say "auto-phi mode: $AUTO_PHI_MODE (tokenized this session: $AUTO_PHI_SESSION_COUNT)" ;; *) err "usage: /auto-phi on|off|aggressive|confirm (no arg → status)" ;; esac continue ;; /auto-phi-status) larry_say "auto-phi mode: $AUTO_PHI_MODE (tokenized this session: $AUTO_PHI_SESSION_COUNT)" 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 ;; /auth) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" status; else echo "(oauth.sh not installed)"; fi; continue ;; /login) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" login && LARRY_AUTH_MODE="oauth" && larry_say "switched to OAuth subscription auth"; else err "oauth.sh not installed"; fi; continue ;; /logout) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" logout; LARRY_AUTH_MODE="apikey"; fi; continue ;; /oauth-debug) if [ -x "$LARRY_LIB_DIR/oauth.sh" ]; then "$LARRY_LIB_DIR/oauth.sh" debug else err "oauth.sh not installed at $LARRY_LIB_DIR/oauth.sh" fi continue ;; /lesson\ *) local text="${input#/lesson }" [ -n "$text" ] && tool_lesson_record "$text" "" "${HCISITE:-}" "info" || err "usage: /lesson " continue ;; /lessons) [ -x "$LARRY_LIB_DIR/lessons.sh" ] && "$LARRY_LIB_DIR/lessons.sh" list || err "lessons.sh not installed" continue ;; /export) [ -x "$LARRY_LIB_DIR/lessons.sh" ] && "$LARRY_LIB_DIR/lessons.sh" export || err "lessons.sh not installed" continue ;; /phi\ *) local val="${input#/phi }" if [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ]; then local token; token=$("$LARRY_LIB_DIR/hl7-sanitize.sh" tokenize-value "$val" 2>/dev/null) [ -n "$token" ] && printf '%sphi>%s %s → %s (use this in your next prompt)\n' "$C_YELLOW" "$C_RESET" "$val" "$token" || err "phi tokenization failed" else err "hl7-sanitize.sh not installed"; fi continue ;; /unmask\ *) local tok="${input#/unmask }" if [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ]; then local val; val=$("$LARRY_LIB_DIR/hl7-sanitize.sh" detokenize-value "$tok" 2>/dev/null) [ -n "$val" ] && printf '%sunmask>%s %s → %s (local only; never sent to API)\n' "$C_YELLOW" "$C_RESET" "$tok" "$val" || err "no such token: $tok" else err "hl7-sanitize.sh not installed"; fi continue ;; /tokens) [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ] && "$LARRY_LIB_DIR/hl7-sanitize.sh" show-table \ || err "hl7-sanitize.sh not installed" continue ;; # ── SSH ControlMaster commands (password never visible to Larry-the-LLM) ── # Patterns use /foo* (matches both "/foo" alone and "/foo args") for # robustness across bash versions. Body strips the prefix and validates. /ssh-hosts*|/ssh-list*) _run_ssh_helper hosts continue ;; /ssh-add*) local rest; rest=$(_slash_args "/ssh-add" "$input") if [ -z "$rest" ]; then err "usage: /ssh-add "; continue fi # shellcheck disable=SC2086 _run_ssh_helper add $rest continue ;; /ssh-remove*|/ssh-rm*) local rest; rest=$(_slash_args "/ssh-remove" "$input") [ -z "$rest" ] && rest=$(_slash_args "/ssh-rm" "$input") if [ -z "$rest" ]; then err "usage: /ssh-remove "; continue; fi _run_ssh_helper remove "$rest" continue ;; /ssh-pass*) local rest; rest=$(_slash_args "/ssh-pass" "$input") if [ -z "$rest" ]; then err "usage: /ssh-pass "; continue; fi _run_ssh_helper pass "$rest" continue ;; /ssh-setup*) local rest; rest=$(_slash_args "/ssh-setup" "$input") if [ -z "$rest" ]; then err "usage: /ssh-setup "; continue; fi _run_ssh_helper setup "$rest" continue ;; /ssh-close*) local rest; rest=$(_slash_args "/ssh-close" "$input") if [ -z "$rest" ]; then err "usage: /ssh-close "; continue; fi _run_ssh_helper close "$rest" continue ;; /ssh-status*) local rest; rest=$(_slash_args "/ssh-status" "$input") if [ -n "$rest" ]; then _run_ssh_helper status "$rest"; else _run_ssh_helper status; fi continue ;; /ssh*) local rest; rest=$(_slash_args "/ssh" "$input") if [ -z "$rest" ]; then err "usage: /ssh "; continue; fi local alias="${rest%% *}" rcmd="${rest#"$alias"}" rcmd="${rcmd# }" if [ -z "$alias" ] || [ -z "$rcmd" ]; then err "usage: /ssh "; continue fi _run_ssh_helper exec "$alias" "$rcmd" continue ;; /redetect) detect_cloverleaf_env system_prompt=$(build_system_prompt) larry_say "re-detected. /env to view." continue ;; /sites) if [ -n "${HCIROOT:-}" ] && [ -d "$HCIROOT" ]; then if command -v sites >/dev/null 2>&1; then sites; else find "$HCIROOT" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; \ | grep -Ev '^(archiving|master|lib|tcl|server|client|clgui|cchgs|Alerts|AppDefaults|Tables|backup.*)$' | sort fi else err "HCIROOT not set"; fi continue ;; /site\ *) HCISITE="${input#/site }"; HCISITEDIR="$HCIROOT/$HCISITE" export HCISITE HCISITEDIR detect_cloverleaf_env system_prompt=$(build_system_prompt) larry_say "HCISITE -> $HCISITE ($HCISITEDIR)"; continue ;; /reset) printf '[]' > "$MESSAGES_FILE"; larry_say "history cleared."; continue ;; /model\ *) LARRY_MODEL="${input#/model }"; larry_say "model -> $LARRY_MODEL"; continue ;; /cd\ *) local target="${input#/cd }" if cd "$target" 2>/dev/null; then larry_say "cd -> $(pwd)"; else err "no such directory: $target"; fi continue ;; /load\ *) local f="${input#/load }" if [ ! -f "$f" ]; then err "no such file: $f"; continue; fi input="$(cat "$f")" larry_say "loaded $(wc -l < "$f" | tr -d ' ') lines from $f as your next message" ;; # v0.6.8: cross-env convenience commands. These templatize a prompt and # hand it to Larry-the-LLM to execute via the existing tools (no new # control flow). The prompt cites the motivating workflow so the model # picks the right tool chain unambiguously. /nc-diff-env*) local rest; rest=$(_slash_args "/nc-diff-env" "$input") if [ -z "$rest" ]; then err "usage: /nc-diff-env [pattern]"; continue fi # Tokenize positional args: env_a, env_b, optional pattern. local _ea _eb _pat _ea="${rest%% *}"; rest="${rest#"$_ea"}"; rest="${rest# }" _eb="${rest%% *}"; rest="${rest#"$_eb"}"; rest="${rest# }" _pat="$rest" if [ -z "$_ea" ] || [ -z "$_eb" ]; then err "usage: /nc-diff-env [pattern]"; continue fi input=$(cat <}. Plan and execute: 1. Run ssh_status to confirm both aliases have an open ControlMaster. If either is closed, stop and tell me to run /ssh-setup . 2. Use ssh_exec to locate the NetConfig paths on each env (e.g. find \$HCIROOT -maxdepth 3 -name NetConfig -type f), or ask me for the site name if HCIROOT isn't exported on the remote. 3. ssh_pull each NetConfig locally. Also pull the matching Xlate/, tclprocs/, tables/ directories alongside if you intend to diff referenced artifacts. 4. Use nc_diff_interface with --interface set per protocol, --left and --right pointing at the two local NetConfigs. If a pattern was given, restrict the set of protocols to those matching $_pat (use nc_list_protocols + a filter). 5. Report each difference with file-path references back to the source envs (alias:remote_path so I can copy-paste back into ssh). Be terse. One section per protocol. Aggregate identical diffs. EOF ) larry_say "/nc-diff-env: templated prompt prepared for $_ea vs $_eb${_pat:+ pattern=$_pat}" ;; /nc-regression-env*) local rest; rest=$(_slash_args "/nc-regression-env" "$input") if [ -z "$rest" ]; then err "usage: /nc-regression-env [scope]"; continue fi local _src _tgt _scope _src="${rest%% *}"; rest="${rest#"$_src"}"; rest="${rest# }" _tgt="${rest%% *}"; rest="${rest#"$_tgt"}"; rest="${rest# }" _scope="${rest:-server}" if [ -z "$_src" ] || [ -z "$_tgt" ]; then err "usage: /nc-regression-env [scope]"; continue fi local _ts; _ts=$(date +%Y%m%d-%H%M%S) local _out="$LARRY_HOME/regression/$_ts" input=$(cat <. 2. Discover the remote HCIROOT for each alias (ssh_exec 'echo \$HCIROOT'). If not exported, ask me. Same for HCISITE if scope=site. 3. Call nc_regression with: - scope = "$_scope" - source_ssh_alias = "$_src" - target_ssh_alias = "$_tgt" - env_a = - env_b = - out = "$_out" - count = 10 (messages sampled per inbound) - route_test_cmd = use the existing default if I haven't given you one; otherwise prompt me with a one-liner template I should approve. - phase = "all" 4. After the run, read the compiled report at $_out/regression-summary.md and read $_out/diff/_index.md, then summarize: - threads tested, - pairs compared, - total field differences post-ignore, - any threads where one env had outputs the other didn't. 5. Reference the SSH alias names ($_src and $_tgt) in your summary, not raw user@host strings. EOF ) larry_say "/nc-regression-env: templated prompt prepared for $_src → $_tgt (scope=$_scope, out=$_out)" ;; /*) 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 # v0.7.1: auto-PHI detection runs BEFORE explicit markers, but the function # itself defers to existing markers (it leaves anything inside @@...@@ or # {{phi:...}} alone). Manual markers still win. input=$(auto_detect_phi "$input") # 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 input=$(preprocess_phi_markers "$input") fi log_section "user"; log_append "$input" add_user_text "$input" agent_turn "$system_prompt" || warn "turn ended with error" echo "" # v0.7.1: status line below the just-completed prompt cycle. Lives between # turns, immediately above the next prompt. /status forces a re-render. render_status_line done log_section "session-end" log_append "- end: $(date -Iseconds 2>/dev/null || date)" upload_session_artifacts || true larry_say "session log: $LOG_FILE" } main_loop