#!/usr/bin/env bash # larry-anywhere — portable Larry for remote shells (Linux + MobaXterm) # Single file. No installs. curl + jq + bash. # # Usage: # larry.sh # interactive in $PWD # larry.sh /path/to/cloverleaf/root # interactive, cd into that path first # larry.sh --no-update # skip self-update # larry.sh --version # print version and exit # larry.sh --help # print help and exit # # Env vars: # LARRY_HOME where to cache config/sessions (default: ~/.larry) # LARRY_BASE_URL root URL of the bundle on the server (default: # https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main) # Self-update pulls VERSION + MANIFEST from here and # refreshes every file listed in MANIFEST. # LARRY_UPDATE_URL (legacy override) full URL of latest larry.sh # LARRY_AGENTS_URL (legacy override) base URL for agents/ # LARRY_MODEL Claude model (default: claude-sonnet-4-6) # LARRY_MAX_TOKENS max output tokens per turn (default: 8192) # LARRY_NO_UPDATE set to 1 to disable self-update # ANTHROPIC_API_KEY overrides $LARRY_HOME/.env if set # # Slash commands during chat: # /quit /exit /q exit # /model switch model for this session # /cd change working directory # /reset clear conversation history (keeps log file) # /load paste a file's contents as your next user message # /sys print the active system prompt # /help this help set -u set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── LARRY_VERSION="0.5.1" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_BASE_URL="${LARRY_BASE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main}" LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-${LARRY_BASE_URL}/larry.sh}" LARRY_AGENTS_URL="${LARRY_AGENTS_URL:-${LARRY_BASE_URL}/agents}" LARRY_MODEL="${LARRY_MODEL:-claude-sonnet-4-6}" LARRY_MAX_TOKENS="${LARRY_MAX_TOKENS:-8192}" LARRY_API_URL="${LARRY_API_URL:-https://api.anthropic.com/v1/messages}" LARRY_NO_UPDATE="${LARRY_NO_UPDATE:-0}" # ───────────────────────────────────────────────────────────────────────────── # Colors (only if stdout is a tty) # ───────────────────────────────────────────────────────────────────────────── if [ -t 1 ]; then C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m' C_BLUE=$'\033[34m'; C_MAGENTA=$'\033[35m'; C_CYAN=$'\033[36m' else C_RESET=''; C_BOLD=''; C_DIM=''; C_RED=''; C_GREEN='' C_YELLOW=''; C_BLUE=''; C_MAGENTA=''; C_CYAN='' fi log() { printf '%s[%s]%s %s\n' "$C_DIM" "$(date +%H:%M:%S)" "$C_RESET" "$*" >&2; } err() { printf '%serror:%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; } warn() { printf '%swarn:%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } larry_say() { printf '%s%slarry>%s %s\n' "$C_MAGENTA" "$C_BOLD" "$C_RESET" "$*"; } # ───────────────────────────────────────────────────────────────────────────── # CLI args # ───────────────────────────────────────────────────────────────────────────── ARG_DIR="" for arg in "$@"; do case "$arg" in --version|-V) echo "larry-anywhere $LARRY_VERSION"; exit 0 ;; --help|-h) sed -n '2,30p' "$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 # ───────────────────────────────────────────────────────────────────────────── # Bootstrap LARRY_HOME and API key # ───────────────────────────────────────────────────────────────────────────── mkdir -p "$LARRY_HOME/agents" "$LARRY_HOME/sessions" "$LARRY_HOME/bin" 2>/dev/null || { err "cannot create $LARRY_HOME — set LARRY_HOME to a writable path and retry"; exit 1; } chmod 700 "$LARRY_HOME" 2>/dev/null || true # ───────────────────────────────────────────────────────────────────────────── # Authentication — two modes, OAuth preferred when available: # 1. OAuth subscription auth (bills against your Claude Max/Pro subscription). # Token file at $LARRY_HOME/.oauth.json — managed by larry-auth.sh. # 2. API key (separate pay-as-you-go API billing). Stored in $LARRY_HOME/.env. # ───────────────────────────────────────────────────────────────────────────── LARRY_AUTH_MODE="" # set later: "oauth" or "apikey" if [ -f "$LARRY_HOME/.oauth.json" ]; then LARRY_AUTH_MODE="oauth" elif [ -z "${ANTHROPIC_API_KEY:-}" ]; then if [ -f "$LARRY_HOME/.env" ]; then # shellcheck disable=SC1091 set -a; . "$LARRY_HOME/.env"; set +a fi [ -n "${ANTHROPIC_API_KEY:-}" ] && LARRY_AUTH_MODE="apikey" else LARRY_AUTH_MODE="apikey" fi prompt_first_run_auth() { printf '%sFirst-run authentication setup%s\n\n' "$C_BOLD" "$C_RESET" cat </dev/null read -r key stty echo 2>/dev/null echo "" if [ -z "$key" ]; then err "no key entered"; exit 1; fi umask 077 printf 'ANTHROPIC_API_KEY=%s\n' "$key" > "$LARRY_HOME/.env" chmod 600 "$LARRY_HOME/.env" ANTHROPIC_API_KEY="$key" log "API key saved." } # NOTE: the auth-prompt CALL (prompt_first_run_auth) is deliberately deferred # until AFTER self_update has run — otherwise a broken lib/oauth.sh traps the # user before the auto-update mechanism gets a chance to fix it. See call site # below the self_update block. # ───────────────────────────────────────────────────────────────────────────── # Fetch agents if missing # ───────────────────────────────────────────────────────────────────────────── LARRY_AGENT_FILES="larry.md clover.md cloverleaf-cheatsheet.md regress.md" fetch_agents_or_warn() { local need=0 for f in larry.md clover.md; do [ -f "$LARRY_HOME/agents/$f" ] || need=1 done [ "$need" = "0" ] && return 0 if [ -n "$LARRY_AGENTS_URL" ]; then log "fetching agent definitions from $LARRY_AGENTS_URL" for f in $LARRY_AGENT_FILES; do curl -fsSL --max-time 10 "$LARRY_AGENTS_URL/$f" -o "$LARRY_HOME/agents/$f" \ || { warn "could not fetch $f — using built-in fallback"; write_fallback_agent "$f"; } done else warn "agent files missing and LARRY_AGENTS_URL not set — writing built-in fallback (larry+clover only)" write_fallback_agent larry.md write_fallback_agent clover.md fi } write_fallback_agent() { case "$1" in larry.md) cat > "$LARRY_HOME/agents/larry.md" <<'AGENT_EOF' You are Larry, Bryan's team orchestrator at myPKA, running in portable mode on a remote shell. First sentence when asked who you are: "I'm Larry, your team orchestrator at myPKA (running portable mode)." Focus: Cloverleaf interface build and Netconfig analysis. No PHI involved. No production push. Tools available: read_file, list_dir, grep_files, glob_files, write_file (Y/N confirm), bash_exec (Y/N confirm). Style: concise, direct, cite path:line for code references. Ask one tight clarifying question only if a critical detail is missing. AGENT_EOF ;; clover.md) cat > "$LARRY_HOME/agents/clover.md" <<'AGENT_EOF' When the task is Cloverleaf-specific, channel Clover, Cloverleaf Integration Expert. Focus: UPOC TCL coding, interface specs, clean documentation. Idempotent, auditable, source-cited. Output: one-line status, artifact list, anomalies/open questions. AGENT_EOF ;; esac } fetch_agents_or_warn # ───────────────────────────────────────────────────────────────────────────── # Self-update — two-phase MANIFEST-driven sync. # # Phase A (local sync, no network if up-to-date): # If $LARRY_HOME/.last-sync-version != $LARRY_VERSION, the running larry.sh # is newer than the on-disk lib/agents/etc. files. Fetch MANIFEST from # $LARRY_BASE_URL and refresh every file listed. Stamp .last-sync-version. # # Phase B (remote version check): # Fetch $LARRY_BASE_URL/VERSION. If remote > local, pull new larry.sh, # replace self, relaunch with LARRY_JUST_UPDATED=1 so phase B is skipped # on the relaunch (avoids infinite loop). Phase A on the relaunch then # pulls every other file matching the new version. # # Skip all of it via --no-update or LARRY_NO_UPDATE=1. # ───────────────────────────────────────────────────────────────────────────── sync_from_manifest() { local base="$1" local manifest="$LARRY_HOME/.manifest.new" curl -fsSL --max-time 10 "$base/MANIFEST" -o "$manifest" 2>/dev/null || { rm -f "$manifest" return 1 } [ -s "$manifest" ] || { rm -f "$manifest"; return 1; } local self="$0" case "$self" in /*) ;; *) self="$PWD/$self" ;; esac local count=0 updated=0 failed=0 path tmp dest while IFS= read -r path; do case "$path" in ''|'#'*) continue ;; esac path="${path%%[[:space:]]*}" # strip trailing whitespace/comments [ -z "$path" ] && continue count=$((count + 1)) # larry.sh is updated by phase B, not here — skip to avoid clobbering # the running script mid-execution. [ "$path" = "larry.sh" ] && continue dest="$LARRY_HOME/$path" tmp="$dest.new" mkdir -p "$(dirname "$dest")" 2>/dev/null if curl -fsSL --max-time 15 "$base/$path" -o "$tmp" 2>/dev/null && [ -s "$tmp" ]; then if [ ! -f "$dest" ] || ! cmp -s "$dest" "$tmp"; then mv "$tmp" "$dest" case "$path" in *.sh) chmod +x "$dest" 2>/dev/null || true ;; esac updated=$((updated + 1)) else rm -f "$tmp" fi else rm -f "$tmp" failed=$((failed + 1)) fi done < "$manifest" rm -f "$manifest" if [ "$updated" -gt 0 ] || [ "$failed" -gt 0 ]; then log "manifest sync: $updated updated, $failed failed, $count total (from $base)" fi LARRY_SYNC_UPDATED_COUNT="$updated" LARRY_SYNC_FAILED_COUNT="$failed" return 0 } self_update() { [ "$LARRY_NO_UPDATE" = "1" ] && return 0 [ -z "$LARRY_BASE_URL" ] && return 0 local self="$0" case "$self" in /*) ;; *) self="$PWD/$self" ;; esac # Phase A: local file sync. Triggered when on-disk files are out of sync # with the running larry.sh version (e.g. just after a self-replace, or # on first launch after install). local last_sync="" [ -f "$LARRY_HOME/.last-sync-version" ] \ && last_sync=$(tr -d '[:space:]' < "$LARRY_HOME/.last-sync-version" 2>/dev/null) if [ "$last_sync" != "$LARRY_VERSION" ]; then LARRY_SYNC_UPDATED_COUNT=0 LARRY_SYNC_FAILED_COUNT=0 if sync_from_manifest "$LARRY_BASE_URL"; then printf '%s\n' "$LARRY_VERSION" > "$LARRY_HOME/.last-sync-version" 2>/dev/null || true if [ "${LARRY_JUST_UPDATED:-0}" = "1" ] && [ -n "${LARRY_PREV_VERSION:-}" ]; then # We came in via a phase-B self-replace; phase A then synced the rest. LARRY_UPDATE_NOTICE="updated v${LARRY_PREV_VERSION} → v${LARRY_VERSION} (${LARRY_SYNC_UPDATED_COUNT} files synced from manifest)" elif [ "$LARRY_SYNC_UPDATED_COUNT" -gt 0 ]; then if [ -n "$last_sync" ]; then LARRY_UPDATE_NOTICE="manifest sync v${last_sync} → v${LARRY_VERSION} (${LARRY_SYNC_UPDATED_COUNT} files updated)" else LARRY_UPDATE_NOTICE="first-run sync at v${LARRY_VERSION} (${LARRY_SYNC_UPDATED_COUNT} files synced from manifest)" fi fi fi fi # Phase B: skip the network version check on the relaunch right after a # self-replace (we just pulled it; checking again is pointless and risks # loops if curl returns stale/partial content). [ "${LARRY_JUST_UPDATED:-0}" = "1" ] && return 0 [ -w "$self" ] || return 0 local remote_ver remote_ver=$(curl -fsSL --max-time 5 "$LARRY_BASE_URL/VERSION" 2>/dev/null | tr -d '[:space:]') [ -z "$remote_ver" ] && return 0 [ "$remote_ver" = "$LARRY_VERSION" ] && return 0 local tmp="$LARRY_HOME/larry.sh.new" curl -fsSL --max-time 15 "$LARRY_BASE_URL/larry.sh" -o "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; } [ -s "$tmp" ] || { rm -f "$tmp"; return 0; } if cmp -s "$self" "$tmp"; then rm -f "$tmp" return 0 fi local new_ver new_ver=$(grep -m1 '^LARRY_VERSION=' "$tmp" | sed 's/.*"\(.*\)".*/\1/') [ -z "$new_ver" ] && { rm -f "$tmp"; return 0; } log "update found: $LARRY_VERSION -> $new_ver — relaunching" cp "$tmp" "$self" && chmod +x "$self" rm -f "$tmp" # Force phase A on the next launch by invalidating the sync stamp. rm -f "$LARRY_HOME/.last-sync-version" 2>/dev/null || true exec env LARRY_JUST_UPDATED=1 LARRY_PREV_VERSION="$LARRY_VERSION" "$self" ${ARG_DIR:+"$ARG_DIR"} } self_update # ── Deferred auth prompt ──────────────────────────────────────────────────── # Now that self_update has had a chance to refresh lib/oauth.sh, gate on # credentials. On a fresh box (no .oauth.json, no API key) this is the first # interactive prompt the user sees. if [ -z "$LARRY_AUTH_MODE" ]; then prompt_first_run_auth fi # ───────────────────────────────────────────────────────────────────────────── # Cloverleaf environment detection # Surfaces HCIROOT / HCISITE / HCISITEDIR and which tool layer is present # (modern cloverleaf-tools.pyz, classic Eric scripts, or neither). # Result is appended to the system prompt so the model knows where it is. # ───────────────────────────────────────────────────────────────────────────── detect_cloverleaf_env() { CLOVERLEAF_CTX="" local lines=() if [ -n "${HCIROOT:-}" ]; then lines+=("HCIROOT=$HCIROOT (exists=$([ -d "$HCIROOT" ] && echo yes || echo no))") else lines+=("HCIROOT=") fi if [ -n "${HCISITE:-}" ]; then local sitedir="${HCISITEDIR:-${HCIROOT:-}/$HCISITE}" lines+=("HCISITE=$HCISITE") lines+=("HCISITEDIR=$sitedir (exists=$([ -d "$sitedir" ] && echo yes || echo no))") if [ -d "$sitedir" ]; then [ -f "$sitedir/NetConfig" ] && lines+=("NetConfig present: $(wc -l < "$sitedir/NetConfig" | tr -d ' ') lines, $(wc -c < "$sitedir/NetConfig" | tr -d ' ') bytes") [ -d "$sitedir/Xlate" ] && lines+=("Xlate/: $(find "$sitedir/Xlate" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") [ -d "$sitedir/tables" ] && lines+=("tables/: $(find "$sitedir/tables" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") [ -d "$sitedir/tclprocs" ] && lines+=("tclprocs/: $(find "$sitedir/tclprocs" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") [ -d "$sitedir/formats" ] && lines+=("formats/: $(find "$sitedir/formats" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') files") fi else lines+=("HCISITE=") fi if [ -n "${HCIROOT:-}" ] && [ -d "$HCIROOT" ]; then local site_count site_count=$(find "$HCIROOT" -mindepth 1 -maxdepth 1 -type d \ ! -name 'archiving' ! -name 'master' ! -name 'lib' ! -name 'tcl' ! -name 'server' \ ! -name 'client' ! -name 'clgui' ! -name 'cchgs' ! -name 'epic*' ! -name 'beaker' \ ! -name 'Alerts' ! -name 'AppDefaults' ! -name 'Tables' ! -name 'backup*' \ 2>/dev/null | wc -l | tr -d ' ') lines+=("HCIROOT site-like subdirs: $site_count") fi # Tool layer detection local pyz_path="" if command -v cloverleaf-tools.pyz >/dev/null 2>&1; then pyz_path=$(command -v cloverleaf-tools.pyz) elif [ -x "./cloverleaf-tools.pyz" ]; then pyz_path="$PWD/cloverleaf-tools.pyz" elif [ -n "${HCIROOT:-}" ] && [ -x "$HCIROOT/cloverleaf-tools.pyz" ]; then pyz_path="$HCIROOT/cloverleaf-tools.pyz" fi if [ -n "$pyz_path" ]; then lines+=("Modern tools: cloverleaf-tools.pyz at $pyz_path") fi # Classic Eric scripts — detect a representative few local classic_found="" for c in tbn tbp tbh tbpr hlq mr mp mg hl awkcut sites each_site list_full_routes dbExtract; do command -v "$c" >/dev/null 2>&1 && classic_found+="$c " done if [ -n "$classic_found" ]; then lines+=("Classic tools on PATH: $classic_found") fi if [ -z "$pyz_path" ] && [ -z "$classic_found" ]; then lines+=("No Cloverleaf-tooling on PATH — Larry will fall back to bash one-liners only.") fi # Compose for system prompt CLOVERLEAF_CTX=$'\n\n## Detected runtime context (read-only)\n' for ln in "${lines[@]}"; do CLOVERLEAF_CTX+="- $ln"$'\n' done } detect_cloverleaf_env # ───────────────────────────────────────────────────────────────────────────── # Session state # ───────────────────────────────────────────────────────────────────────────── SESSION_ID="$(date +%Y-%m-%d-%H%M%S)-$$" MESSAGES_FILE="$LARRY_HOME/sessions/$SESSION_ID.messages.json" LOG_FILE="$LARRY_HOME/sessions/$SESSION_ID.log.md" printf '[]' > "$MESSAGES_FILE" { echo "# Larry-Anywhere session $SESSION_ID" echo "- start: $(date -Iseconds 2>/dev/null || date)" echo "- model: $LARRY_MODEL" echo "- host: $(hostname 2>/dev/null || echo unknown)" echo "- pwd: $(pwd)" echo "" } > "$LOG_FILE" log_section() { printf '\n## %s\n' "$1" >> "$LOG_FILE"; } log_append() { printf '%s\n' "$1" >> "$LOG_FILE"; } # ───────────────────────────────────────────────────────────────────────────── # Message store helpers # ───────────────────────────────────────────────────────────────────────────── add_user_text() { local content="$1" local tmp; tmp=$(mktemp) jq --arg c "$content" '. + [{"role":"user","content":[{"type":"text","text":$c}]}]' "$MESSAGES_FILE" > "$tmp" \ && mv "$tmp" "$MESSAGES_FILE" } add_assistant_blocks() { local blocks="$1" local tmp; tmp=$(mktemp) jq --argjson b "$blocks" '. + [{"role":"assistant","content":$b}]' "$MESSAGES_FILE" > "$tmp" \ && mv "$tmp" "$MESSAGES_FILE" } add_user_tool_results() { local blocks="$1" local tmp; tmp=$(mktemp) jq --argjson b "$blocks" '. + [{"role":"user","content":$b}]' "$MESSAGES_FILE" > "$tmp" \ && mv "$tmp" "$MESSAGES_FILE" } # ───────────────────────────────────────────────────────────────────────────── # Tool implementations # ───────────────────────────────────────────────────────────────────────────── tool_read_file() { local path="$1" if [ ! -e "$path" ]; then echo "ERROR: file not found: $path"; return; fi if [ ! -f "$path" ]; then echo "ERROR: not a regular file: $path"; return; fi local size; size=$(wc -c < "$path" 2>/dev/null || echo 0) if [ "$size" -gt 250000 ]; then echo "ERROR: file too large ($size bytes, limit 250KB). Use grep_files to target sections." return fi awk '{printf "%6d\t%s\n", NR, $0}' "$path" } tool_list_dir() { local path="${1:-.}" if [ ! -d "$path" ]; then echo "ERROR: not a directory: $path"; return; fi ls -la --color=never "$path" 2>/dev/null || ls -la "$path" } tool_grep_files() { local pattern="$1"; local path="${2:-.}" if [ ! -e "$path" ]; then echo "ERROR: path not found: $path"; return; fi local total total=$(grep -rnI --color=never -c "$pattern" "$path" 2>/dev/null \ | awk -F: '{s+=$NF} END {print s+0}') grep -rnI --color=never "$pattern" "$path" 2>/dev/null | head -300 if [ "$total" -gt 300 ]; then echo "── shown 300 / $total total matches — narrow your pattern, or use bash_exec for counts ──" fi } tool_glob_files() { local pattern="$1"; local path="${2:-.}" if [ ! -d "$path" ]; then echo "ERROR: not a directory: $path"; return; fi local all; all=$(find "$path" -type f -name "$pattern" 2>/dev/null) local total; total=$(printf '%s\n' "$all" | grep -c .) printf '%s\n' "$all" | head -300 if [ "$total" -gt 300 ]; then echo "── shown 300 / $total total entries — narrow your pattern ──" fi } tool_write_file() { local path="$1"; local content="$2" local exists="no"; [ -f "$path" ] && exists="yes" printf '\n%s══ write_file ══%s\n' "$C_YELLOW" "$C_RESET" >&2 printf ' path: %s\n' "$path" >&2 printf ' exists: %s\n' "$exists" >&2 printf ' bytes: %d\n' "${#content}" >&2 if [ "$exists" = "yes" ]; then local tmp; tmp=$(mktemp) printf '%s' "$content" > "$tmp" printf '%s── diff ──%s\n' "$C_DIM" "$C_RESET" >&2 diff -u "$path" "$tmp" >&2 || true rm -f "$tmp" else printf '%s── new file preview (first 40 lines) ──%s\n' "$C_DIM" "$C_RESET" >&2 printf '%s' "$content" | head -40 >&2 printf '\n' >&2 fi printf '%sApprove write? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 read -r answer /dev/null printf '%s' "$content" > "$path" echo "OK: wrote $(printf '%s' "$content" | wc -l | tr -d ' ') lines to $path" log_section "write_file $path (approved)"; log_append '```'; log_append "$content"; log_append '```' else echo "DENIED by user. No write performed." log_section "write_file $path (DENIED)" fi } # ───────────────────────────────────────────────────────────────────────────── # v3 NetConfig tools — first-class native capabilities for Cloverleaf work. # Implemented as small bash+awk scripts in lib/ (alongside this file or in # $LARRY_HOME/lib). They invoke nothing from v1 scripts or v2 .pyz. # ───────────────────────────────────────────────────────────────────────────── _resolve_lib_dir() { local self_dir; self_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd) for candidate in "$self_dir/lib" "$LARRY_HOME/lib"; do [ -d "$candidate" ] && [ -x "$candidate/nc-parse.sh" ] && { echo "$candidate"; return 0; } done return 1 } LARRY_LIB_DIR="$(_resolve_lib_dir || echo '')" _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}" _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) "$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; } # Use grep -oE to extract markers reliably across bash versions. local markers markers=$(printf '%s' "$input" | grep -oE '\{\{phi:[^{}]+\}\}' 2>/dev/null | sort -u) [ -z "$markers" ] && { printf '%s' "$input"; return; } while IFS= read -r marker; do [ -z "$marker" ] && continue # Strip {{phi: prefix and }} suffix local body="${marker#\{\{phi:}" body="${body%\}\}}" local category="" value="" if [[ "$body" == *:* ]] && [[ "${body%%:*}" =~ ^[A-Z][A-Z0-9_]+$ ]]; then category="${body%%:*}" value="${body#*:}" else value="$body" fi 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 done <<< "$markers" 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 } 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_bash_exec() { local cmd="$1" printf '\n%s══ bash_exec ══%s\n' "$C_YELLOW" "$C_RESET" >&2 printf '%s$ %s%s\n' "$C_BOLD" "$cmd" "$C_RESET" >&2 printf '%sRun this command? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 read -r answer &1 | head -500) echo "$out" log_section "bash_exec (approved)"; log_append '```'; log_append "$ $cmd"; log_append "$out"; log_append '```' else echo "DENIED by user. Command not executed." log_section "bash_exec DENIED: $cmd" fi } execute_tool() { local name="$1"; local input_json="$2" local J; J() { printf '%s' "$input_json" | jq -r "$1"; } case "$name" in read_file) tool_read_file "$(J '.path')" ;; list_dir) tool_list_dir "$(J '.path // "."')" ;; grep_files) tool_grep_files "$(J '.pattern')" "$(J '.path // "."')" ;; glob_files) tool_glob_files "$(J '.pattern')" "$(J '.path // "."')" ;; write_file) tool_write_file "$(J '.path')" "$(J '.content')" ;; bash_exec) tool_bash_exec "$(J '.command')" ;; nc_list_protocols) tool_nc_list_protocols "$(J '.netconfig')" ;; nc_list_processes) tool_nc_list_processes "$(J '.netconfig')" ;; nc_protocol_block) tool_nc_protocol_block "$(J '.netconfig')" "$(J '.name')" ;; nc_protocol_field) tool_nc_protocol_field "$(J '.netconfig')" "$(J '.name')" "$(J '.field')" ;; nc_protocol_nested) tool_nc_protocol_nested "$(J '.netconfig')" "$(J '.name')" "$(J '.path')" ;; nc_protocol_summary) tool_nc_protocol_summary "$(J '.netconfig')" "$(J '.filter // ""')" ;; nc_destinations) tool_nc_destinations "$(J '.netconfig')" "$(J '.name')" ;; nc_xlate_refs) tool_nc_xlate_refs "$(J '.netconfig')" "$(J '.name // ""')" ;; nc_find_inbound) tool_nc_find_inbound "$(J '.netconfig')" "$(J '.mode // "all"')" "$(J '.format // "tsv"')" ;; nc_make_jump) tool_nc_make_jump "$(J '.netconfig')" "$(J '.inbound')" "$(J '.new_host')" "$(J '.jump_port')" \ "$(J '.inbound_host // "127.0.0.1"')" "$(J '.process_jump // "server_jump"')" "$(J '.encoding // ""')" ;; nc_sources) tool_nc_sources "$(J '.netconfig')" "$(J '.name')" ;; nc_tclproc_refs) tool_nc_tclproc_refs "$(J '.netconfig')" "$(J '.name // ""')" ;; hl7_field) tool_hl7_field "$(J '.message')" "$(J '.field_path')" ;; nc_msgs) tool_nc_msgs "$(J '.thread')" "$(J '.after // ""')" "$(J '.before // ""')" \ "$(J '.field // ""')" "$(J '.value // ""')" \ "$(J '.limit // 10')" "$(J '.format // "text"')" \ "$(J '.sitedir // ""')" "$(J '.db // ""')" ;; nc_document) tool_nc_document "$(J '.name')" "$(J '.out // ""')" "$(J '.hciroot // ""')" \ "$(J '.title // ""')" "$(J '.status // ""')" \ "$(J '.poc_internal // ""')" "$(J '.poc_vendor // ""')" \ "$(J '.escalation // ""')" "$(J '.open_items // ""')" \ "$(J '.notes // ""')" ;; nc_find) tool_nc_find "$(J '.mode')" "$(J '.query')" "$(J '.format // "table"')" "$(J '.hciroot // ""')" ;; nc_insert_protocol) tool_nc_insert_protocol "$(J '.netconfig')" "$(J '.block')" "$(J '.mode // "end"')" "$(J '.anchor // ""')" ;; nc_add_route) tool_nc_add_route "$(J '.netconfig')" "$(J '.protocol_name')" "$(J '.route')" ;; hl7_diff) tool_hl7_diff "$(J '.left')" "$(J '.right')" "$(J '.ignore // "MSH.7"')" "$(J '.include // ""')" "$(J '.format // "text"')" ;; nc_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/")" ;; 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/")" ;; larry_rollback_list) tool_larry_rollback_list "$(J '.session // ""')" ;; *) echo "ERROR: unknown tool: $name" ;; esac } # ───────────────────────────────────────────────────────────────────────────── # Tool schema for the API # ───────────────────────────────────────────────────────────────────────────── TOOLS_JSON='[ {"name":"read_file","description":"Read a single regular file. Returns content with line numbers. Max 250KB; use grep_files for larger.","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 files. Use for finding TCL procs, UPOC declarations, segment references, etc. Returns up to 300 matching lines with file:line:content.","input_schema":{"type":"object","properties":{"pattern":{"type":"string","description":"Regex pattern (grep -E style)."},"path":{"type":"string","description":"Starting directory."}},"required":["pattern","path"]}}, {"name":"glob_files","description":"Find files by name pattern. Up to 300 paths.","input_schema":{"type":"object","properties":{"pattern":{"type":"string","description":"Shell glob like *.tcl or *Inbound*"},"path":{"type":"string","description":"Starting directory."}},"required":["pattern","path"]}}, {"name":"write_file","description":"Write content to a path. ALWAYS prompts Bryan for Y/N before writing. Shows a unified diff if file exists, or a preview if new.","input_schema":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}, {"name":"bash_exec","description":"Run a shell command. ALWAYS prompts Bryan for Y/N before running. Output capped at 500 lines.","input_schema":{"type":"object","properties":{"command":{"type":"string","description":"Single command line, passed to bash -c."}},"required":["command"]}}, {"name":"nc_list_protocols","description":"List every protocol (thread) declared in a Cloverleaf NetConfig file. Native v3 parser — does not invoke v1/v2 wrappers. One name per line.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"Absolute path to a NetConfig file, e.g. $HCISITEDIR/NetConfig."}},"required":["netconfig"]}}, {"name":"nc_list_processes","description":"List every process declared in a NetConfig. One name per line.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"}},"required":["netconfig"]}}, {"name":"nc_protocol_block","description":"Return the full TCL block for one protocol (everything between `protocol NAME {` and the matching `}`). Use to inspect every field of a thread.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Protocol name, e.g. IB_ADT_muxS."}},"required":["netconfig","name"]}}, {"name":"nc_protocol_field","description":"Get a top-level field value from a protocol block (e.g. PROCESSNAME, OBWORKASIB, OUTBOUNDONLY, GROUPS, ENCODING, ICLSERVERPORT, AUTOSTART, HOSTDOWN).","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string"},"field":{"type":"string","description":"Field name, e.g. PROCESSNAME"}},"required":["netconfig","name","field"]}}, {"name":"nc_protocol_nested","description":"Drill into a nested block via dotted path. Use PROTOCOL.TYPE / PROTOCOL.HOST / PROTOCOL.PORT / PROTOCOL.ISSERVER for connection details — those live inside the inner PROTOCOL{} block, NOT at top level.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string"},"path":{"type":"string","description":"Dotted path, e.g. PROTOCOL.PORT"}},"required":["netconfig","name","path"]}}, {"name":"nc_protocol_summary","description":"Compact TSV summary of all protocols with direction-relevant fields (name, process, direction, port, host, type, isserver, outonly, obworkasib, iclserverport). Optional --filter regex to narrow.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"filter":{"type":"string","description":"Optional regex to filter protocol names."}},"required":["netconfig"]}}, {"name":"nc_destinations","description":"List every DEST routed to from one protocol’s DATAXLATE block. Unique, sorted.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string"}},"required":["netconfig","name"]}}, {"name":"nc_xlate_refs","description":"List every .xlt file referenced in the NetConfig (all of them, or scoped to one protocol if `name` is provided).","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Optional. Limits to one protocol."}},"required":["netconfig"]}}, {"name":"nc_find_inbound","description":"Find inbound threads in a NetConfig. mode=tcp-listen (ISSERVER=1, directly fed by upstream client systems), mode=icl-or-file (OBWORKASIB=1, fed by internal Cloverleaf link or file drop), mode=all (default). Output formats: tsv, jsonl, table.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"mode":{"type":"string","enum":["tcp-listen","icl-or-file","all"],"description":"Which class of inbound to return."},"format":{"type":"string","enum":["tsv","jsonl","table"]}},"required":["netconfig"]}}, {"name":"nc_make_jump","description":"Generate the 3-thread jump set for the cross-environment data replay pattern Bryan uses. Emits FOUR artifacts: (1) linux__out for OLD env (outbound tcpip-client to new linux:jump_port), (2) windows__in for NEW env server_jump site (inbound tcpip-server listening on jump_port, routes internally to #3), (3) windows__out for NEW env server_jump site (outbound tcpip-client to 127.0.0.1:, where orig_port is the existing inbound listening port read from the NetConfig), (4) route-add snippet to splice into the OLD inbound DATAXLATE block. Tag = inbound thread name (auto). The NEW env existing inbound is left COMPLETELY UNCHANGED. Pure generation; caller uses write_file (Y/N) to persist.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"NetConfig path containing the inbound thread (OLD env)."},"inbound":{"type":"string","description":"Existing inbound protocol name to mirror. Must be a TCP-listener (ISSERVER=1); read its PROTOCOL.PORT first to confirm."},"new_host":{"type":"string","description":"Hostname/IP of the NEW linux env that OLD will TCP to."},"jump_port":{"type":"string","description":"TCP port for the OLD to NEW hop. linux__out targets it, windows__in listens on it."},"inbound_host":{"type":"string","description":"Host that windows__out connects to on NEW (the existing inbound on NEW). Default 127.0.0.1 (same box, loopback)."},"process_jump":{"type":"string","description":"Process for NEW-side threads on server_jump. Default server_jump."},"encoding":{"type":"string","description":"ENCODING override. Default = same as the existing inbound."}},"required":["netconfig","inbound","new_host","jump_port"]}}, {"name":"nc_sources","description":"List every protocol that has a DATAXLATE DEST routing to the named thread. The inverse of nc_destinations. Use this to find what feeds a given thread.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Target thread name."}},"required":["netconfig","name"]}}, {"name":"nc_tclproc_refs","description":"List every TCL proc name referenced from a protocol block (or from the whole NetConfig if name is omitted). Pulls from DATAFORMAT.PROC, PREPROCS.PROCS, POSTPROCS.PROCS, etc. Unique sorted.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"name":{"type":"string","description":"Optional. Scope to one protocol."}},"required":["netconfig"]}}, {"name":"hl7_field","description":"Extract a specific HL7 v2 field from a message. field_path = SEG[.FIELD[.COMPONENT[.SUBCOMPONENT]]]. Examples: PID.3 (MRN), PID.18 (account number), MSH.7 (timestamp), MSH.9.2 (event code, like A08), PID.5 (patient name with components). Multiple repetitions are returned one per line. Native v3, no v1/v2 dependency.","input_schema":{"type":"object","properties":{"message":{"type":"string","description":"Raw HL7 message text. Segments separated by \\r."},"field_path":{"type":"string","description":"Field path like PID.3 or MSH.9.2"}},"required":["message","field_path"]}}, {"name":"nc_msgs","description":"Query Cloverleaf smat (SQLite!) databases for messages from a thread. Filters: time range, exact HL7 field match. Native v3 — reads smatdb directly with sqlite3 -ascii, no hcidbdump/dbExtract needed. Format text shows messages line-by-line with metadata; count returns just the count; json returns structured data.","input_schema":{"type":"object","properties":{"thread":{"type":"string","description":"Thread name. The .smatdb file under $HCISITEDIR/exec/processes/*/.smatdb is auto-located unless db is given."},"after":{"type":"string","description":"Time-after filter. Accepts \\"3 days ago\\", \\"2026-05-20 14:30:00\\", \\"2026-05-20\\", or a unix timestamp."},"before":{"type":"string","description":"Time-before filter, same formats as after."},"field":{"type":"string","description":"HL7 field path for exact-match filter, e.g. PID.18 or MSH.10."},"value":{"type":"string","description":"Value the field must equal. Use with field. Repeatable filters not supported via this single tool call — chain calls if you need multi-field AND."},"limit":{"type":"integer","description":"Max messages to return. Default 10."},"format":{"type":"string","enum":["text","json","count","raw"],"description":"text = human-readable with metadata; count = just the number; json = structured; raw = raw bytes separated by 0x1c."},"sitedir":{"type":"string","description":"Override $HCISITEDIR for thread-to-db location."},"db":{"type":"string","description":"Explicit .smatdb path; overrides auto-locate."}},"required":["thread"]}}, {"name":"nc_document","description":"Generate a complete markdown knowledge entry for a Cloverleaf subsystem identified by a name pattern. Walks every NetConfig under $HCIROOT, gathers config + sources + destinations + xlates + tclprocs for every matching thread, composes a markdown doc with placeholder context sections (Vendor POC, Internal Owner, Status, Escalation, Open items, Notes). Returns the doc text and (if out is given) writes it to that path.","input_schema":{"type":"object","properties":{"name":{"type":"string","description":"Case-insensitive substring/regex to match protocol names. e.g. 'codametrix', 'epic_adt', '3M'."},"out":{"type":"string","description":"Optional output file path. Convention: $LARRY_HOME/knowledge/.md."},"hciroot":{"type":"string","description":"Override $HCIROOT for the NetConfig scan."},"title":{"type":"string","description":"Doc title. Default derived from name."},"status":{"type":"string","description":"System status fill-in (production/test/decommissioning/...)."},"poc_internal":{"type":"string","description":"Internal owner fill-in."},"poc_vendor":{"type":"string","description":"Vendor POC fill-in."},"escalation":{"type":"string","description":"Escalation path fill-in."},"open_items":{"type":"string","description":"Open items / known issues fill-in. Can be multi-line, will be inserted as-is."},"notes":{"type":"string","description":"Freeform notes fill-in."}},"required":["name"]}}, {"name":"nc_find","description":"Cross-site thread search. Native v3 replacement for v1 tbn/tbp/tbh/tbpr/where. Walks every NetConfig under $HCIROOT and returns matching threads with site, port, host, process, direction, file, line. Modes: name=partial name match (like tbn); port=exact port (like tbp); host=substring on host (like tbh); process=substring on PROCESSNAME (like tbpr); where=exact name match across all sites (like the v1 ` where`); xlate=threads referencing a specific .xlt; tclproc=threads referencing a specific TCL proc.","input_schema":{"type":"object","properties":{"mode":{"type":"string","enum":["name","port","host","process","where","xlate","tclproc"],"description":"Search mode."},"query":{"type":"string","description":"Query value: partial name, port number, host substring, process name, exact thread name, xlate filename, or tclproc name."},"format":{"type":"string","enum":["table","tsv","jsonl"],"description":"Output format. Default table."},"hciroot":{"type":"string","description":"Override $HCIROOT."}},"required":["mode","query"]}}, {"name":"nc_insert_protocol","description":"Insert a new protocol block into a NetConfig file. ALL WRITES GO THROUGH THE JOURNAL — original is snapshotted, diff is saved, the file is atomically replaced. Use larry_rollback_list to view, larry-rollback.sh CLI to undo. mode=end appends; mode=after needs anchor=existing-protocol-name; mode=before needs anchor.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string","description":"Target NetConfig file path."},"block":{"type":"string","description":"The full protocol block text (starting with 'protocol NAME {' and ending with '}'). Get this from nc_make_jump output."},"mode":{"type":"string","enum":["end","after","before"],"description":"Insertion position. Default end."},"anchor":{"type":"string","description":"For mode=after|before: existing protocol name to position relative to."}},"required":["netconfig","block"]}}, {"name":"nc_add_route","description":"Splice a route entry into an existing protocol's DATAXLATE block. Used to add a new DEST to an inbound's routing (e.g. wiring the OLD inbound to also route to the new linux__out jump thread). ALL WRITES GO THROUGH THE JOURNAL.","input_schema":{"type":"object","properties":{"netconfig":{"type":"string"},"protocol_name":{"type":"string","description":"The existing protocol to modify."},"route":{"type":"string","description":"The route entry text (an inner `{ ... }` object with CACHEMSG, ROUTE_DETAILS, TRXID, etc.). Get from nc_make_jump's route_add output."}},"required":["netconfig","protocol_name","route"]}}, {"name":"larry_rollback_list","description":"List journal entries — every write that's gone through nc_insert_protocol, nc_add_route, or write_file (once journaled write_file is enabled). Shows session-id, sequence, target, timestamp. Use larry-rollback.sh from the shell to actually roll back.","input_schema":{"type":"object","properties":{"session":{"type":"string","description":"Optional. Limit to one session id."}},"required":[]}}, {"name":"lesson_record","description":"Append a lesson to local capture at $LARRY_HOME/lessons/.md. Use when Bryan teaches you something new (a correction, a pattern, a quirk, a gotcha) so the home-Larry can be updated later. Lessons stay LOCAL; Bryan exports them with `lessons.sh export` and pastes back to home-Larry when he can. CALL THIS WHEN: Bryan corrects a misunderstanding, reveals a site-specific convention, points out a bug, requests a behavior change, or shares a workflow detail you should remember next time.","input_schema":{"type":"object","properties":{"text":{"type":"string","description":"The lesson content. Markdown. Include enough context that home-Larry can act on it without re-deriving."},"topic":{"type":"string","description":"Short topic tag, e.g. \"NetConfig parsing\", \"jump-thread naming\", \"site conventions\"."},"site":{"type":"string","description":"Site this lesson is scoped to, if any. Default: current $HCISITE."},"severity":{"type":"string","enum":["info","warn","fix"],"description":"info=general learning, warn=behavior I should change, fix=Bryan called out a bug."}},"required":["text"]}}, {"name":"hl7_sanitize","description":"Tokenize PHI fields in an HL7 message file. Replaces values in patient identifiers, names, DOB, addresses, phones, SSN, account numbers, providers, visit numbers, NK1/GT1/IN1 fields, etc. with deterministic local tokens like [[MRN_0001]]. Same value gets same token across the entire local lookup table, so correlation analysis still works. The token-to-original mapping NEVER leaves the client (stored at $LARRY_HOME/sanitize/lookup.tsv, mode 0600). Use this when Bryan needs you to analyze a file that has real PHI. Returns the sanitized HL7 content with tokens substituted. Bryan can desanitize the final output locally with hl7-desanitize.sh.","input_schema":{"type":"object","properties":{"input_path":{"type":"string","description":"Path to the HL7 message file to sanitize."},"strict":{"type":"integer","description":"1=also tokenize any unknown Z* segments wholesale. Default 0 (safer for legibility but might miss custom PHI in Z segments)."}},"required":["input_path"]}}, {"name":"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.","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)."},"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)."},"site_b":{"type":"string","description":"Site name on env-B."},"out":{"type":"string","description":"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."}},"required":["scope","env_a","env_b","out"]}} ]' # ───────────────────────────────────────────────────────────────────────────── # 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 if [ -x "$oauth_script" ]; then token=$("$oauth_script" ensure 2>/dev/null) fi if [ -z "$token" ]; then err "OAuth token unavailable; run 'larry-auth.sh login' to re-authenticate" return 1 fi auth_args=(-H "Authorization: Bearer $token" -H "anthropic-beta: oauth-2025-04-20") else auth_args=(-H "x-api-key: $ANTHROPIC_API_KEY") fi curl -sS --max-time 180 \ "${auth_args[@]}" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ --data-binary "@$payload_file" \ "$LARRY_API_URL" } 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 # ───────────────────────────────────────────────────────────────────────────── agent_turn() { local system_prompt="$1" while true; do local payload_file; payload_file=$(mktemp) jq -n \ --arg model "$LARRY_MODEL" \ --argjson max_tokens "$LARRY_MAX_TOKENS" \ --arg system "$system_prompt" \ --slurpfile messages "$MESSAGES_FILE" \ --argjson tools "$TOOLS_JSON" \ '{model:$model, max_tokens:$max_tokens, system:$system, messages:$messages[0], tools:$tools}' \ > "$payload_file" local resp; resp=$(call_api "$payload_file") rm -f "$payload_file" if [ -z "$resp" ]; then err "empty response from API (timeout or network?)"; return 1; fi local err_type; err_type=$(printf '%s' "$resp" | jq -r '.error.type // empty' 2>/dev/null) if [ -n "$err_type" ]; then err "API error: $err_type — $(printf '%s' "$resp" | jq -r '.error.message // "no message"')" return 1 fi local blocks; blocks=$(printf '%s' "$resp" | jq -c '.content') add_assistant_blocks "$blocks" # Print text blocks printf '%s' "$resp" | jq -r '.content[] | select(.type=="text") | .text' \ | sed "s/^/${C_MAGENTA}/; s/\$/${C_RESET}/" 2>/dev/null \ || printf '%s' "$resp" | jq -r '.content[] | select(.type=="text") | .text' # 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') printf '\n%s▶ %s%s %s\n' "$C_CYAN" "$name" "$C_RESET" "$input_json" >&2 log_section "tool: $name $(printf '%s' "$input_json" | jq -c .)" local result; result=$(execute_tool "$name" "$input_json") log_append '```'; log_append "$result"; log_append '```' results=$(printf '%s' "$results" | jq \ --arg id "$tu_id" --arg c "$result" \ '. + [{"type":"tool_result","tool_use_id":$id,"content":$c}]') done < <(printf '%s' "$resp" | jq -c '.content[] | select(.type=="tool_use")') add_user_tool_results "$results" done } # ───────────────────────────────────────────────────────────────────────────── # Slash commands and REPL # ───────────────────────────────────────────────────────────────────────────── print_help() { cat < switch model (e.g. /model claude-opus-4-7) /cd change working directory /reset clear conversation history /load load file contents as your next user message /sys print the active system prompt /env print detected Cloverleaf env (HCIROOT, HCISITE, tools) /auth show OAuth status (or "not authenticated") /login run OAuth login flow (switch from API-key to subscription auth) /logout delete OAuth tokens (revert to API-key auth) /lesson capture a lesson to local file (paste back to home-Larry later) /lessons list all captured lessons (newest first) /export dump the lesson bundle for paste-back to home-Larry /phi tokenize a PHI value locally; prints token to paste in prompts /unmask show the original PHI for a token (local only; never sent) /tokens show the full local PHI ↔ token lookup table PHI inline syntax in any prompt: {{phi:VALUE}} tokenize before send; auto-detects category {{phi:MRN:12345}} explicit category=MRN (matches sanitized data) {{phi:NAME:JOHN SMITH}} explicit category=NAME /redetect re-scan for HCIROOT/HCISITE/tools /sites list site dirs under HCIROOT /site switch HCISITE for this session /pwd show current working directory /help this help Multi-line input: start with '<<' on its own line, end with 'EOF' on its own line. EOF } read_user_input() { # Returns user input via global LARRY_INPUT. # If first line is "<<", read until line "EOF" (heredoc-style). LARRY_INPUT="" local first; IFS= read -r first || return 1 if [ "$first" = "<<" ]; then local line while IFS= read -r line; do [ "$line" = "EOF" ] && break LARRY_INPUT+="$line"$'\n' done else LARRY_INPUT="$first" fi } main_loop() { local system_prompt; system_prompt=$(build_system_prompt) 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 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 printf '%syou>%s ' "$C_GREEN" "$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 ;; /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 ;; /lesson\ *) local text="${input#/lesson }" [ -n "$text" ] && tool_lesson_record "$text" "" "${HCISITE:-}" "info" || err "usage: /lesson " continue ;; /lessons) [ -x "$LARRY_LIB_DIR/lessons.sh" ] && "$LARRY_LIB_DIR/lessons.sh" list || err "lessons.sh not installed" continue ;; /export) [ -x "$LARRY_LIB_DIR/lessons.sh" ] && "$LARRY_LIB_DIR/lessons.sh" export || err "lessons.sh not installed" continue ;; /phi\ *) local val="${input#/phi }" if [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ]; then local token; token=$("$LARRY_LIB_DIR/hl7-sanitize.sh" tokenize-value "$val" 2>/dev/null) [ -n "$token" ] && printf '%sphi>%s %s → %s (use this in your next prompt)\n' "$C_YELLOW" "$C_RESET" "$val" "$token" || err "phi tokenization failed" else err "hl7-sanitize.sh not installed"; fi continue ;; /unmask\ *) local tok="${input#/unmask }" if [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ]; then local val; val=$("$LARRY_LIB_DIR/hl7-sanitize.sh" detokenize-value "$tok" 2>/dev/null) [ -n "$val" ] && printf '%sunmask>%s %s → %s (local only; never sent to API)\n' "$C_YELLOW" "$C_RESET" "$tok" "$val" || err "no such token: $tok" else err "hl7-sanitize.sh not installed"; fi continue ;; /tokens) [ -x "$LARRY_LIB_DIR/hl7-sanitize.sh" ] && "$LARRY_LIB_DIR/hl7-sanitize.sh" show-table \ || err "hl7-sanitize.sh not installed" continue ;; /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" ;; /*) err "unknown command: $input (try /help)"; continue ;; 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:"* ]]; 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