v0.6.2 fixed the TOOLS_JSON argv overflow but four other call sites had
the same risk pattern — any of them would have crashed under Cygwin's
~32KB argv cap with large user input, large agent responses, or large
tool results:
add_user_text --arg c "$content" ← multi-paragraph prompts
add_assistant_blocks --argjson b "$blocks" ← long assistant turns
add_user_tool_results --argjson b "$blocks" ← chained tool results
agent_turn loop --arg c "$result" ← tool output (up to 250KB
for read_file, 500 lines
for ssh_exec, etc.)
agent_turn loop --arg system "$system_prompt" ← agents/*.md
total ~25KB
All five are now passed via tempfile + --rawfile (for raw strings) or
--slurpfile (for pre-parsed JSON). Same proven pattern as the v0.6.2
TOOLS_JSON fix. Tempfiles are cleaned at every return path.
Verified by pushing a 60KB user prompt through the pipeline on macOS
(also has the larger 256KB argv cap that masked these bugs locally
before, but the codepath now uses files for the large values regardless
of platform). Messages file stored the full 60025-char prompt with no
warnings.
After this commit, the only --arg / --argjson calls remaining all carry
known-small values (UUIDs, version strings, port numbers, etc.).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1423 lines
86 KiB
Bash
Executable File
1423 lines
86 KiB
Bash
Executable File
#!/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
|
||
# /help this help
|
||
set -u
|
||
set -o pipefail
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Config
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
LARRY_VERSION="0.6.3"
|
||
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 <<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 "$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 "$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 "$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 '')"
|
||
|
||
_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; }
|
||
|
||
# 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
|
||
}
|
||
|
||
# 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
|
||
}
|
||
|
||
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 </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_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/")" ;;
|
||
ssh_exec) tool_ssh_exec "$(J '.alias')" "$(J '.command')" "$(J '.max_lines // 500')" ;;
|
||
ssh_status) tool_ssh_status ;;
|
||
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 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_<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.","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.","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"]}}
|
||
]
|
||
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
|
||
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"
|
||
# 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"
|
||
while true; do
|
||
local payload_file; payload_file=$(mktemp)
|
||
jq -n \
|
||
--arg model "$LARRY_MODEL" \
|
||
--argjson max_tokens "$LARRY_MAX_TOKENS" \
|
||
--rawfile system "$system_file" \
|
||
--slurpfile messages "$MESSAGES_FILE" \
|
||
--slurpfile tools "$tools_file" \
|
||
'{model:$model, max_tokens:$max_tokens, system:$system, messages:$messages[0], tools:$tools[0]}' \
|
||
> "$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?)"; rm -f "$tools_file" "$system_file"; return 1; fi
|
||
|
||
local err_type; err_type=$(printf '%s' "$resp" | jq -r '.error.type // empty' 2>/dev/null)
|
||
if [ -n "$err_type" ]; then
|
||
err "API error: $err_type — $(printf '%s' "$resp" | jq -r '.error.message // "no message"')"
|
||
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
|
||
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 '```'
|
||
|
||
# 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 "$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
|
||
/model <name> switch model (e.g. /model claude-opus-4-7)
|
||
/cd <path> change working directory
|
||
/reset clear conversation history
|
||
/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)
|
||
/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.
|
||
|
||
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: start with '<<' on its own line, end with 'EOF' on its own line.
|
||
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
|
||
}
|
||
|
||
read_user_input() {
|
||
# Returns user input via global LARRY_INPUT.
|
||
# If first line is "<<", read until line "EOF" (heredoc-style).
|
||
#
|
||
# Uses readline editing (-e) so backspace, arrow keys, and history work
|
||
# correctly across terminals (MobaXterm/Cygwin in particular often has
|
||
# stty erase mismatches that swallow plain `read`'s backspace). We pass
|
||
# the prompt via -p so readline knows the visible width.
|
||
LARRY_INPUT=""
|
||
local first
|
||
if [ -t 0 ] && _readline_ok; then
|
||
local prompt; prompt=$(printf '%syou>%s ' "$C_GREEN" "$C_RESET")
|
||
# Clear the prompt the caller already printed, then re-emit via readline.
|
||
printf '\r\033[K'
|
||
IFS= read -e -r -p "$prompt" first || return 1
|
||
[ -n "$first" ] && history -s "$first"
|
||
else
|
||
IFS= read -r first || return 1
|
||
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)
|
||
|
||
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
|
||
|
||
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 <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" ;;
|
||
/*) 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:"* ]] || [[ "$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
|