Portable AI agent for Cloverleaf integration work. Pure bash + curl + jq. Zero dependency on v1 wrapper scripts or v2 cloverleaf-tools.pyz. 27 native Anthropic tools: NetConfig parsing (read) nc_list_protocols, nc_list_processes, nc_protocol_block, nc_protocol_field, nc_protocol_nested, nc_protocol_summary, nc_destinations, nc_sources, nc_xlate_refs, nc_tclproc_refs NetConfig modification (journal-backed writes with rollback) nc_insert_protocol, nc_add_route, larry_rollback_list Workflows nc_find_inbound, nc_make_jump (3-thread jump pattern), nc_find (tbn/tbp/tbh/tbpr/where replacements), nc_document, nc_diff_interface, nc_regression Messages hl7_field, nc_msgs (smat is SQLite!), hl7_diff (with --ignore MSH.7) File system read_file, list_dir, grep_files, glob_files, write_file, bash_exec Validated against a 22-site real Cloverleaf test install. Five worked examples end-to-end: jump-thread generation, smat MRN search, system documentation, interface+connected diff, HL7-aware regression diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
275 lines
9.2 KiB
Bash
Executable File
275 lines
9.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# nc-msgs.sh — native v3 smat query. No v1/v2 dependency, no hcidbdump.
|
|
#
|
|
# Cloverleaf smat databases are SQLite 3. v3 reads them directly via `sqlite3`
|
|
# in -ascii mode to preserve raw `\r` segment separators.
|
|
#
|
|
# Schema (smat_msgs columns we care about):
|
|
# Time INTEGER — milliseconds since epoch
|
|
# MessageContent BLOB — raw HL7 (segments separated by \r)
|
|
# SourceConn VARCHAR — source thread name
|
|
# DestConn VARCHAR — destination thread name
|
|
# Type VARCHAR — DATA, ACK, etc.
|
|
# MidDomain/Hub/Num INTEGER — message ID triple
|
|
#
|
|
# Usage:
|
|
# nc-msgs.sh <thread_name> [--after EXPR] [--before EXPR]
|
|
# [--field PATH=VALUE] # repeatable filter, AND semantics
|
|
# [--type DATA|ACK]
|
|
# [--limit N] # default 100
|
|
# [--format text|json|count|raw]
|
|
# [--sitedir DIR] # default $HCISITEDIR
|
|
# [--db PATH] # explicit smatdb path (overrides locate)
|
|
#
|
|
# Time expressions (--after, --before):
|
|
# "3 days ago", "12 hours ago", "30 minutes ago"
|
|
# "2026-05-20", "2026-05-20 14:30:00"
|
|
# unix epoch in seconds (e.g. 1772100000)
|
|
#
|
|
# Examples:
|
|
# nc-msgs.sh to_3m --after "3 days ago" --field PID.18=623000286
|
|
# nc-msgs.sh ADTto_3m --field MSH.9.2=A08 --limit 5
|
|
# nc-msgs.sh ADTto_3m --format count
|
|
set -u
|
|
set -o pipefail
|
|
|
|
NC_SELF="$0"
|
|
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
|
|
HL7F="$LIB_DIR/hl7-field.sh"
|
|
|
|
die() { printf 'nc-msgs: %s\n' "$*" >&2; exit 1; }
|
|
|
|
THREAD=""
|
|
AFTER=""
|
|
BEFORE=""
|
|
FILTERS=()
|
|
TYPE=""
|
|
LIMIT=100
|
|
FORMAT="text"
|
|
SITEDIR="${HCISITEDIR:-}"
|
|
DB_OVERRIDE=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--after) shift; AFTER="$1" ;;
|
|
--before) shift; BEFORE="$1" ;;
|
|
--field) shift; FILTERS+=("$1") ;;
|
|
--type) shift; TYPE="$1" ;;
|
|
--limit) shift; LIMIT="$1" ;;
|
|
--format) shift; FORMAT="$1" ;;
|
|
--sitedir) shift; SITEDIR="$1" ;;
|
|
--db) shift; DB_OVERRIDE="$1" ;;
|
|
-h|--help) sed -n '2,30p' "$NC_SELF"; exit 0 ;;
|
|
-*) die "unknown flag: $1" ;;
|
|
*) [ -z "$THREAD" ] && THREAD="$1" || die "extra arg: $1" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
[ -n "$THREAD" ] || die "usage: nc-msgs.sh <thread> [...flags]"
|
|
case "$FORMAT" in text|json|count|raw) ;; *) die "bad --format: $FORMAT" ;; esac
|
|
command -v sqlite3 >/dev/null 2>&1 || die "sqlite3 not on PATH (universally available on Cloverleaf hosts; install via your distro otherwise)"
|
|
|
|
# Locate smatdb
|
|
locate_smatdb() {
|
|
if [ -n "$DB_OVERRIDE" ]; then
|
|
[ -f "$DB_OVERRIDE" ] || die "no such db: $DB_OVERRIDE"
|
|
printf '%s\n' "$DB_OVERRIDE"
|
|
return
|
|
fi
|
|
[ -n "$SITEDIR" ] || die "no \$HCISITEDIR and no --sitedir; pass one or set the env var"
|
|
[ -d "$SITEDIR" ] || die "sitedir not a directory: $SITEDIR"
|
|
# Standard layout: $SITEDIR/exec/processes/<proc>/<thread>.smatdb
|
|
local found
|
|
found=$(find "$SITEDIR/exec/processes" -maxdepth 2 -type f -name "${THREAD}.smatdb" 2>/dev/null | head -1)
|
|
if [ -z "$found" ]; then
|
|
# Sometimes lives one level deeper or under a different layout
|
|
found=$(find "$SITEDIR" -type f -name "${THREAD}.smatdb" 2>/dev/null | head -1)
|
|
fi
|
|
[ -n "$found" ] || die "no smatdb found for thread $THREAD under $SITEDIR (looked for ${THREAD}.smatdb)"
|
|
printf '%s\n' "$found"
|
|
}
|
|
|
|
# Parse time expression -> unix ms
|
|
parse_time_ms() {
|
|
local expr="$1"
|
|
[ -z "$expr" ] && return 0
|
|
# If it's purely numeric and >= 10 digits, treat as already-ms
|
|
if [[ "$expr" =~ ^[0-9]+$ ]]; then
|
|
if [ "${#expr}" -ge 12 ]; then printf '%s' "$expr"; return; fi
|
|
if [ "${#expr}" -le 10 ]; then printf '%s' "$((expr * 1000))"; return; fi
|
|
fi
|
|
# GNU date and BSD date differ. Try GNU first (-d EXPR), fall back to BSD (-jf or -v).
|
|
local ts=""
|
|
if ts=$(date -d "$expr" +%s 2>/dev/null); then
|
|
printf '%s' "$((ts * 1000))"; return
|
|
fi
|
|
# BSD date — try `-v` shorthand for relative times
|
|
if echo "$expr" | grep -qE '^[0-9]+ (second|minute|hour|day|week|month|year)s? ago$'; then
|
|
local n unit
|
|
n=$(echo "$expr" | awk '{print $1}')
|
|
unit=$(echo "$expr" | awk '{print $2}' | sed 's/s$//')
|
|
local flag
|
|
case "$unit" in
|
|
second) flag="S" ;;
|
|
minute) flag="M" ;;
|
|
hour) flag="H" ;;
|
|
day) flag="d" ;;
|
|
week) flag="d"; n=$((n * 7)) ;;
|
|
month) flag="m" ;;
|
|
year) flag="y" ;;
|
|
esac
|
|
ts=$(date -v "-${n}${flag}" +%s 2>/dev/null) && { printf '%s' "$((ts * 1000))"; return; }
|
|
fi
|
|
# BSD date with -jf
|
|
if ts=$(date -jf "%Y-%m-%d %H:%M:%S" "$expr" +%s 2>/dev/null); then
|
|
printf '%s' "$((ts * 1000))"; return
|
|
fi
|
|
if ts=$(date -jf "%Y-%m-%d" "$expr" +%s 2>/dev/null); then
|
|
printf '%s' "$((ts * 1000))"; return
|
|
fi
|
|
die "could not parse time expression: $expr"
|
|
}
|
|
|
|
AFTER_MS=$(parse_time_ms "$AFTER")
|
|
BEFORE_MS=$(parse_time_ms "$BEFORE")
|
|
|
|
# Build WHERE clause
|
|
WHERE="1=1"
|
|
[ -n "$AFTER_MS" ] && WHERE="$WHERE AND Time >= $AFTER_MS"
|
|
[ -n "$BEFORE_MS" ] && WHERE="$WHERE AND Time <= $BEFORE_MS"
|
|
if [ -n "$TYPE" ]; then
|
|
# Escape single quotes
|
|
ESC_TYPE=$(printf '%s' "$TYPE" | sed "s/'/''/g")
|
|
WHERE="$WHERE AND Type = '$ESC_TYPE'"
|
|
fi
|
|
|
|
# Coarse LIKE pre-filter for any --field VALUEs (substring presence)
|
|
# This is just an SQL fast-path; the precise field match happens via hl7-field.sh below.
|
|
for filt in "${FILTERS[@]}"; do
|
|
val="${filt#*=}"
|
|
if [ -n "$val" ] && [ "$val" != "$filt" ]; then
|
|
ESC_VAL=$(printf '%s' "$val" | sed "s/'/''/g")
|
|
WHERE="$WHERE AND MessageContent LIKE '%${ESC_VAL}%'"
|
|
fi
|
|
done
|
|
|
|
SMATDB=$(locate_smatdb)
|
|
[ "$FORMAT" = "count" ] || printf 'nc-msgs: querying %s\n' "$SMATDB" >&2
|
|
|
|
# Pull the data
|
|
TMP_OUT=$(mktemp -d)
|
|
trap 'rm -rf "$TMP_OUT"' EXIT
|
|
|
|
SQL="SELECT Time, Type, SourceConn, DestConn, MessageContent FROM smat_msgs WHERE $WHERE ORDER BY Time DESC LIMIT $LIMIT"
|
|
sqlite3 -ascii "$SMATDB" "$SQL" > "$TMP_OUT/raw.bin" 2>"$TMP_OUT/err"
|
|
if [ -s "$TMP_OUT/err" ]; then
|
|
cat "$TMP_OUT/err" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Split rows (0x1e) into individual files, parse fields per row (0x1f)
|
|
awk -v RS=$'\x1e' -v FS=$'\x1f' -v outdir="$TMP_OUT" '
|
|
NF >= 5 {
|
|
n++
|
|
fpath = outdir "/msg_" sprintf("%05d", n) ".bin"
|
|
print $5 > fpath
|
|
close(fpath)
|
|
metafpath = outdir "/meta_" sprintf("%05d", n) ".tsv"
|
|
printf "%s\t%s\t%s\t%s\n", $1, $2, $3, $4 > metafpath
|
|
close(metafpath)
|
|
}
|
|
' "$TMP_OUT/raw.bin"
|
|
|
|
MSG_COUNT=$(ls "$TMP_OUT"/msg_*.bin 2>/dev/null | wc -l | tr -d ' ')
|
|
KEPT=0
|
|
|
|
# Apply --field filters precisely via hl7-field.sh
|
|
match_filters() {
|
|
local msg_file="$1"
|
|
for filt in "${FILTERS[@]}"; do
|
|
path="${filt%%=*}"
|
|
expected="${filt#*=}"
|
|
[ "$path" = "$expected" ] && continue # skip if "=" missing
|
|
# exact match: any repetition equal to expected
|
|
actual=$("$HL7F" "$path" "$msg_file" 2>/dev/null)
|
|
matched=0
|
|
if [ -n "$actual" ]; then
|
|
while IFS= read -r rep; do
|
|
[ "$rep" = "$expected" ] && { matched=1; break; }
|
|
done <<< "$actual"
|
|
fi
|
|
[ "$matched" = "1" ] || return 1
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Emit
|
|
case "$FORMAT" in
|
|
count)
|
|
# Count after filter
|
|
if [ ${#FILTERS[@]} -eq 0 ]; then
|
|
echo "$MSG_COUNT"
|
|
else
|
|
for f in "$TMP_OUT"/msg_*.bin; do
|
|
match_filters "$f" && KEPT=$((KEPT+1))
|
|
done
|
|
echo "$KEPT"
|
|
fi
|
|
;;
|
|
raw)
|
|
for f in "$TMP_OUT"/msg_*.bin; do
|
|
if [ ${#FILTERS[@]} -eq 0 ] || match_filters "$f"; then
|
|
cat "$f"; printf '\x1c' # File separator between messages (rare in HL7)
|
|
KEPT=$((KEPT+1))
|
|
fi
|
|
done
|
|
;;
|
|
text)
|
|
i=0
|
|
for f in "$TMP_OUT"/msg_*.bin; do
|
|
i=$((i+1))
|
|
if [ ${#FILTERS[@]} -eq 0 ] || match_filters "$f"; then
|
|
KEPT=$((KEPT+1))
|
|
meta=$(cat "${TMP_OUT}/meta_$(printf '%05d' "$i").tsv")
|
|
tm=$(printf '%s' "$meta" | awk -F'\t' '{print $1}')
|
|
typ=$(printf '%s' "$meta" | awk -F'\t' '{print $2}')
|
|
src=$(printf '%s' "$meta" | awk -F'\t' '{print $3}')
|
|
dst=$(printf '%s' "$meta" | awk -F'\t' '{print $4}')
|
|
# Render time
|
|
if [ "$tm" -gt 100000000000 ] 2>/dev/null; then
|
|
tm_h=$(date -r $((tm/1000)) 2>/dev/null || date -d "@$((tm/1000))" 2>/dev/null || echo "$tm")
|
|
else
|
|
tm_h="$tm"
|
|
fi
|
|
printf '===== msg %d time=%s type=%s src=%s dst=%s =====\n' "$KEPT" "$tm_h" "$typ" "$src" "$dst"
|
|
tr '\r' '\n' < "$f"
|
|
printf '\n'
|
|
fi
|
|
done
|
|
printf 'nc-msgs: %d msgs scanned, %d match filters\n' "$MSG_COUNT" "$KEPT" >&2
|
|
;;
|
|
json)
|
|
printf '['
|
|
first=1
|
|
i=0
|
|
for f in "$TMP_OUT"/msg_*.bin; do
|
|
i=$((i+1))
|
|
if [ ${#FILTERS[@]} -eq 0 ] || match_filters "$f"; then
|
|
KEPT=$((KEPT+1))
|
|
[ "$first" = "1" ] && first=0 || printf ','
|
|
meta=$(cat "${TMP_OUT}/meta_$(printf '%05d' "$i").tsv")
|
|
tm=$(printf '%s' "$meta" | awk -F'\t' '{print $1}')
|
|
typ=$(printf '%s' "$meta" | awk -F'\t' '{print $2}')
|
|
src=$(printf '%s' "$meta" | awk -F'\t' '{print $3}')
|
|
dst=$(printf '%s' "$meta" | awk -F'\t' '{print $4}')
|
|
# Replace \r with \n in message content for JSON-safety, then JSON-escape
|
|
msg_text=$(tr '\r' '\n' < "$f" | jq -Rs .)
|
|
printf '{"time_ms":%s,"type":"%s","source":"%s","dest":"%s","content":%s}' \
|
|
"$tm" "$typ" "$src" "$dst" "$msg_text"
|
|
fi
|
|
done
|
|
printf ']\n'
|
|
;;
|
|
esac
|