Each Larry is independent. Bryan's question "how will Larry on Windows
talk to Larry on Linux for regression file transfer" answered: they don't.
File transfer is YOUR responsibility (scp / gh release / shared mount /
USB), but nc-regression now produces and consumes portable bundles that
make the split a one-command-on-each-side workflow.
Changes:
lib/nc-regression.sh
+ --phase env-a convenience for phases 1+2+3 (env-A side)
+ --phase env-b convenience for phases 4+5+6 (env-B side + diff)
+ --bundle-out PATH after env-A phases, tar inputs+outputs/env-a +
manifest.json + README.md + inbounds.txt
+ --bundle-in PATH at start, untar a bundle into $OUT; pulls scope
from the manifest so the env-B side just needs
--env-b and --route-test-cmd
MANUAL.md
+ New "Cross-environment Larry — how the boxes communicate" section
+ Bundle transport table (scp, gh release, NFS, USB, etc.)
+ Notes that the lesson loop uses the same local-capture / manual-
transport / central-merge model
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1092 lines
68 KiB
Bash
Executable File
1092 lines
68 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_UPDATE_URL URL of latest larry.sh for self-update (optional)
|
||
# LARRY_AGENTS_URL base URL for agents/ refresh (optional)
|
||
# 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.4.3"
|
||
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
|
||
LARRY_UPDATE_URL="${LARRY_UPDATE_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/larry.sh}"
|
||
LARRY_AGENTS_URL="${LARRY_AGENTS_URL:-https://raw.githubusercontent.com/bojj27/cloverleaf-larry/main/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."
|
||
}
|
||
|
||
if [ -z "$LARRY_AUTH_MODE" ]; then
|
||
prompt_first_run_auth
|
||
fi
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 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
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
self_update() {
|
||
[ "$LARRY_NO_UPDATE" = "1" ] && return 0
|
||
[ -z "$LARRY_UPDATE_URL" ] && return 0
|
||
local self="$0"
|
||
case "$self" in /*) ;; *) self="$PWD/$self" ;; esac
|
||
[ -w "$self" ] || return 0
|
||
|
||
local tmp="$LARRY_HOME/larry.sh.new"
|
||
if curl -fsSL --max-time 5 "$LARRY_UPDATE_URL" -o "$tmp" 2>/dev/null; then
|
||
if [ -s "$tmp" ] && ! cmp -s "$self" "$tmp"; then
|
||
local new_ver
|
||
new_ver=$(grep -m1 '^LARRY_VERSION=' "$tmp" | sed 's/.*"\(.*\)".*/\1/')
|
||
log "update found: $LARRY_VERSION -> ${new_ver:-?}"
|
||
cp "$tmp" "$self" && chmod +x "$self"
|
||
rm -f "$tmp"
|
||
log "relaunching..."
|
||
exec "$self" --no-update ${ARG_DIR:+"$ARG_DIR"}
|
||
fi
|
||
rm -f "$tmp"
|
||
fi
|
||
|
||
# Also refresh agents
|
||
if [ -n "$LARRY_AGENTS_URL" ]; then
|
||
for f in larry.md clover.md; do
|
||
curl -fsSL --max-time 5 "$LARRY_AGENTS_URL/$f" -o "$LARRY_HOME/agents/$f.new" 2>/dev/null \
|
||
&& [ -s "$LARRY_HOME/agents/$f.new" ] \
|
||
&& mv "$LARRY_HOME/agents/$f.new" "$LARRY_HOME/agents/$f" \
|
||
|| rm -f "$LARRY_HOME/agents/$f.new"
|
||
done
|
||
fi
|
||
}
|
||
self_update
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 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
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
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/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; }
|
||
|
||
# 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 </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/")" ;;
|
||
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_<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":"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 <<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
|
||
|
||
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 <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
|
||
}
|
||
|
||
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
|
||
|
||
larry_say "Larry-Anywhere v$LARRY_VERSION 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 ;;
|
||
/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
|