cloverleaf-larry/larry.sh
Bryan Johnson 8661948cf6 v0.7.0: HL7-aware tab completion + REPL mouse mode
Two REPL enhancements:

1. HL7 v2.x inline tab completion. Type a segment ID or SEG.field or
   SEG.field.component in any prompt and TAB completes against a built-in
   schema (18 segments fully fielded: MSH, PID, PV1, PV2, EVN, MSA, ERR,
   NK1, GT1, IN1, IN2, OBR, OBX, ORC, AL1, DG1, PR1, ROL; component
   breakdowns for MSH.9, PID.3, PID.5, PID.11, PV1.3, NK1.4, OBX.3, IN1.4).
   New slash commands /hl7 <SEG> and /hl7-fields <SEG.N> print schema
   without typing. Z-segments get a "site-specific" hint instead of a
   guess. Exact-match wins over prefix siblings (PID.3 completes over
   PID.30; MSH completes over MSH+MSA).

2. Mouse mode. /mouse on|off and LARRY_NO_MOUSE env kill switch enable
   bracketed-paste + SGR mouse reporting (mode 1006). Click-to-position
   cursor in the input line is intentionally NOT implemented in this
   pass — it requires per-terminal escape parsing inside bind -x which
   is not reliable across iTerm2 / macOS Terminal / MobaXterm / Cygwin
   in a single pass. Documented as terminal-dependent.

New file: lib/hl7-schema.sh (sourced; bash assoc arrays for the segment
+field+component tables, plus helpers hl7_segments / hl7_fields_for /
hl7_components_for / hl7_field_name).

MANIFEST + install-larry.sh updated to fetch the new lib file on
install/self-update.

Regression-safe: v0.6.9 status line, slash completion, @file completion,
streaming SSE, header capture, and all 37 prior slash commands are
unchanged. Added 3 new slash commands (/hl7, /hl7-fields, /mouse).

Verification: 15/15 automated checks on the three completion paths
(segment, field, component) — including mid-buffer completion and
exact-match preference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:15:11 -07:00

3629 lines
182 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <name> switch model for this session
# /cd <path> change working directory
# /reset clear conversation history (keeps log file)
# /load <file> 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: @<path> 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.0"
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 <<EOF
Two options:
1) OAuth login (bills your Claude Max / Pro subscription quota)
- Open a URL in any browser (even on a different device)
- Paste back the code
- Subscription billing — same as Claude Code
2) Anthropic API key (separate API billing, pay-as-you-go)
- Paste your sk-ant-... key, saved to $LARRY_HOME/.env
EOF
printf ' Choose [1=oauth, 2=apikey, q=quit]: '
read -r choice
case "${choice:-1}" in
1|o|oauth)
local auth_script=""
for c in "$(dirname "$0")/larry-auth.sh" "$LARRY_HOME/../larry-auth.sh" "$LARRY_HOME/lib/oauth.sh"; do
[ -x "$c" ] && { auth_script="$c"; break; }
done
[ -n "$auth_script" ] || { err "larry-auth.sh not found — reinstall or use API key"; prompt_api_key; return; }
"$auth_script" login || { err "OAuth failed — falling back to API key"; prompt_api_key; return; }
LARRY_AUTH_MODE="oauth"
;;
2|k|key|apikey)
prompt_api_key
LARRY_AUTH_MODE="apikey"
;;
q|quit) err "no auth selected"; exit 1 ;;
*) err "unrecognized choice; defaulting to OAuth"; prompt_first_run_auth ;;
esac
}
prompt_api_key() {
printf '%sAPI key setup%s\n' "$C_BOLD" "$C_RESET"
echo " Paste your Anthropic API key (starts with sk-ant-...) and press Enter."
echo " It will be saved to $LARRY_HOME/.env with permissions 0600."
echo ""
printf ' ANTHROPIC_API_KEY: '
stty -echo 2>/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=<unset>")
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=<unset>")
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/tty || answer=""
if [[ "$answer" =~ ^[Yy]$ ]]; then
mkdir -p "$(dirname "$path")" 2>/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"
}
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 @<path> 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:
# - @<token> : @ 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: @<filename> 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.01.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/<alias> 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 </dev/tty || answer=""
if [[ "$answer" =~ ^[Yy]$ ]]; then
local out
out=$(bash -c "$cmd" 2>&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 protocols 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_<tag>_out for OLD env (outbound tcpip-client to new linux:jump_port), (2) windows_<tag>_in for NEW env server_jump site (inbound tcpip-server listening on jump_port, routes internally to #3), (3) windows_<tag>_out for NEW env server_jump site (outbound tcpip-client to 127.0.0.1:<orig_port>, 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_<tag>_out targets it, windows_<tag>_in listens on it."},"inbound_host":{"type":"string","description":"Host that windows_<tag>_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/*/<thread>.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/<system>.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 `<thread> 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_<tag>_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/<date>.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 14 run remotely via ssh_exec + ssh_pull/ssh_push; phases 56 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 13 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/<alias>.<basename>.<hash> is used. Verifies the master is open first; if not, fails with a clear message ('open the master with /ssh-setup <alias> 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/<alias>.<basename>.<hash> 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 <thread>.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_ts<TAB>direction<TAB>type<TAB>source<TAB>dest<TAB>message_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/*/<thread>.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/<site>."},"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 <<EOF
${C_BOLD}Larry-Anywhere v$LARRY_VERSION${C_RESET}
Model: $LARRY_MODEL
Home: $LARRY_HOME
Session: $SESSION_ID
Log: $LOG_FILE
Slash commands:
/quit /exit /q exit
/clear clear the terminal screen (distinct from /reset)
/copy copy last assistant response to clipboard
/cost show running token + dollar cost for the session
/status force-render the persistent status line (ctx + rate-limit)
/show-last-tool print full last tool call + result (debug aid)
/model <name> switch model (e.g. /model claude-opus-4-7)
/cd <path> change working directory
/reset clear conversation history (keeps the log file)
/load <file> 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 <text> 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 <value> tokenize a PHI value locally; prints token to paste in prompts
/unmask <token> 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 <alias> <user@host[:port]> register a new host
/ssh-pass <alias> set/update password (hidden input; daily rotation OK)
/ssh-setup <alias> open a long-lived ControlMaster connection
/ssh-close <alias> close the ControlMaster
/ssh-status [alias] show open masters + cred presence
/ssh <alias> <command> 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 <a> <b> [pattern] diff NetConfigs across two SSH-aliased envs
(e.g. /nc-diff-env qa dev ADT)
/nc-regression-env <src> <tgt> [scope]
6-phase regression across SSH-aliased envs
(e.g. /nc-regression-env dev qa server)
HL7 schema lookup (v0.7.0):
/hl7 <SEGMENT> 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 <SEG.FIELD> 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)
/redetect re-scan for HCIROOT/HCISITE/tools
/sites list site dirs under HCIROOT
/site <name> 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 @<path>; 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):
A dim 1-line summary prints above each you[...] > prompt:
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).
TAB completion (v0.6.6/v0.6.7/v0.7.0):
Type '/' followed by any prefix and press TAB.
/h<TAB> → /help
/ss<TAB> → lists every /ssh-* command with one-line descriptions
/ssh-h<TAB> → /ssh-hosts
/q<TAB> → /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<TAB> → MSH (single match)
PI<TAB> → PID (single match)
PID.<TAB> → lists all 30 PID fields with descriptions
PID.3<TAB> → completes to "PID.3 " (trailing space)
PID.5.<TAB> → lists PID.5 components (Family Name, Given Name, ...)
PID.5.1<TAB> → completes to "PID.5.1 "
Z-segments (site-specific) are not in the built-in schema; tab on Z<TAB>
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]="<text> 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]="<value> tokenize a PHI value locally"
[/unmask]="<token> show original PHI for a token"
[/tokens]="show full local PHI <-> token lookup table"
[/ssh]="<alias> <cmd> run command on the remote"
[/ssh-hosts]="list configured remote hosts"
[/ssh-add]="<alias> <user@host[:port]> register a new host"
[/ssh-remove]="<alias> remove a host"
[/ssh-pass]="<alias> set/update password (hidden input)"
[/ssh-setup]="<alias> open a long-lived ControlMaster"
[/ssh-close]="<alias> 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]="<name> switch HCISITE for this session"
[/reset]="clear conversation history (keeps log)"
[/model]="<name> switch model (e.g. /model claude-opus-4-7)"
[/cd]="<path> change working directory"
[/load]="<file> 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]="<env_a> <env_b> [pattern] diff NetConfigs across two SSH-aliased envs"
[/nc-regression-env]="<source> <target> [scope] 6-phase regression across SSH-aliased envs"
[/hl7]="<SEGMENT> print full field list for an HL7 segment (e.g. /hl7 PID)"
[/hl7-fields]="<SEG.FIELD> print component breakdown (e.g. /hl7-fields PID.5)"
[/mouse]="on|off toggle xterm mouse mode for this session"
)
# __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
# @<partial> 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 @<non-whitespace> 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 ".<field>")
# - 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 @<partial> 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 @<partial> with @<chosen> + 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 <btn>;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). Idempotent.
trap '_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 ) 2>/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.6.9: persistent status line above the prompt.
# Only on the FIRST line of input — heredoc continuation reads in
# read_user_input do not invoke this loop iteration.
render_status_line
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 <SEGMENT> 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 <SEGMENT.FIELD> 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 ;;
/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 <text>"
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 <alias> <user@host[:port]>"; 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 <alias>"; 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 <alias>"; 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 <alias>"; 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 <alias>"; 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 <alias> <command>"; continue; fi
local alias="${rest%% *}" rcmd="${rest#"$alias"}"
rcmd="${rcmd# }"
if [ -z "$alias" ] || [ -z "$rcmd" ]; then
err "usage: /ssh <alias> <command>"; 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 <env_a> <env_b> [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 <env_a> <env_b> [pattern]"; continue
fi
input=$(cat <<EOF
Cross-environment NetConfig diff request — Bryan's motivating workflow #1
("Compare the ADT site NetConfig on qa to dev").
Source SSH aliases: $_ea (env_a), $_eb (env_b).
Pattern filter: ${_pat:-<none — diff all protocols>}.
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 <alias>.
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 <source> <target> [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 <source> <target> [scope]"; continue
fi
local _ts; _ts=$(date +%Y%m%d-%H%M%S)
local _out="$LARRY_HOME/regression/$_ts"
input=$(cat <<EOF
Cross-environment regression test — Bryan's motivating workflow #2
("Grab smat files from dev and bring to qa for regression testing").
Source SSH alias: $_src
Target SSH alias: $_tgt
Scope: $_scope
Output root: $_out
Plan and execute:
1. ssh_status to confirm BOTH aliases have an open ControlMaster. If either
is closed, stop and tell me to run /ssh-setup <alias>.
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 = <remote HCIROOT for $_src>
- env_b = <remote HCIROOT for $_tgt>
- 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
# 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 ""
done
log_section "session-end"
log_append "- end: $(date -Iseconds 2>/dev/null || date)"
larry_say "session log: $LOG_FILE"
}
main_loop