Files
paseo/skills/paseo-chat/bin/chat.sh
Mohamed Boudra f13178a9ad Replace mapfile with portable while-read loop in chat script
mapfile is a bash 4+ builtin and fails on macOS default bash 3.2
with "command not found".
2026-03-26 17:51:56 +07:00

635 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
chat.sh room create --room ROOM [--title TITLE] [--purpose PURPOSE]
chat.sh room list
chat.sh room show --room ROOM
chat.sh post --room ROOM (--body TEXT | --body-file PATH) [--agent-id ID] [--agent-name NAME] [--reply-to MESSAGE_ID]
chat.sh read --room ROOM [--limit N] [--since MESSAGE_ID|ISO_TIMESTAMP] [--agent-id ID]
chat.sh wait --room ROOM [--since MESSAGE_ID] [--timeout SECONDS]
Options:
--root PATH Override chat root (default: ~/.paseo/chat)
EOF
exit 1
}
yaml_quote() {
local value="${1//\'/\'\'}"
printf "'%s'" "$value"
}
require_command() {
local name="$1"
command -v "$name" >/dev/null 2>&1 || {
echo "Error: required command not found: $name" >&2
exit 1
}
}
sanitize_room_path() {
local room="$1"
if [[ -z "$room" || "$room" == /* || "$room" == *".."* ]]; then
echo "Error: invalid room id: $room" >&2
exit 1
fi
}
load_body() {
local inline_value="$1"
local file_value="$2"
if [[ -n "$inline_value" && -n "$file_value" ]]; then
echo "Error: use either --body or --body-file, not both" >&2
exit 1
fi
if [[ -n "$file_value" ]]; then
[[ -f "$file_value" ]] || { echo "Error: --body-file not found: $file_value" >&2; exit 1; }
cat "$file_value"
return
fi
if [[ -n "$inline_value" ]]; then
printf '%s' "$inline_value"
return
fi
echo "Error: one of --body or --body-file is required" >&2
exit 1
}
frontmatter_value() {
local key="$1"
local file="$2"
awk -F': ' -v search_key="$key" '
BEGIN { in_frontmatter = 0 }
/^---$/ {
if (in_frontmatter == 0) {
in_frontmatter = 1
next
}
exit
}
in_frontmatter == 1 && $1 == search_key {
value = substr($0, length(search_key) + 3)
gsub(/^'\''|'\''$/, "", value)
print value
exit
}
' "$file"
}
message_body() {
local file="$1"
awk '
BEGIN { delim_count = 0 }
/^---$/ {
delim_count += 1
next
}
delim_count >= 2 {
print
}
' "$file"
}
message_preview() {
local file="$1"
local max_length="${2:-100}"
local preview
preview="$(
message_body "$file" |
awk 'NF { print; exit }' |
tr -s '[:space:]' ' ' |
sed 's/^ //; s/ $//'
)"
if [[ ${#preview} -gt "$max_length" ]]; then
preview="${preview:0:$((max_length - 3))}..."
fi
printf '%s' "$preview"
}
format_timestamp() {
local ts="$1"
local time_part="${ts#*T}"
time_part="${time_part%Z}"
local date_part="${ts%%T*}"
printf '%s %s' "$date_part" "$time_part"
}
print_message() {
local file="$1"
local created_at agent_id agent_name reply_to message_id
created_at="$(frontmatter_value "created_at" "$file")"
agent_id="$(frontmatter_value "agent_id" "$file")"
agent_name="$(frontmatter_value "agent_name" "$file")"
reply_to="$(frontmatter_value "reply_to" "$file")"
message_id="$(frontmatter_value "message_id" "$file")"
local display_time
display_time="$(format_timestamp "$created_at")"
local author
if [[ -n "$agent_name" ]]; then
author="$agent_name ($agent_id)"
else
author="$agent_id"
fi
printf '┌─ %s ── %s ── [%s]' "$author" "$display_time" "$message_id"
if [[ -n "$reply_to" ]]; then
printf ' ↩ %s' "$reply_to"
fi
printf '\n'
printf '│\n'
message_body "$file" | sed '/./,$!d' | while IFS= read -r line; do
printf '│ %s\n' "$line"
done
printf '│\n'
printf '└─\n'
}
require_room() {
local room="$1"
local room_dir="$rooms_root/$room"
[[ -d "$room_dir" ]] || { echo "Error: room not found: $room" >&2; exit 1; }
}
find_message_file_by_id() {
local room="$1"
local message_id="$2"
find "$rooms_root/$room/messages" -name '*.md' -type f -print0 |
while IFS= read -r -d '' file; do
if [[ "$(frontmatter_value "message_id" "$file")" == "$message_id" ]]; then
printf '%s\n' "$file"
return 0
fi
done
}
message_files_sorted() {
local room="$1"
find "$rooms_root/$room/messages" -name '*.md' -type f | sort
}
print_message_files() {
local files=("$@")
if [[ ${#files[@]} -eq 0 ]]; then
return
fi
local index
for ((index = 0; index < ${#files[@]}; index += 1)); do
print_message "${files[$index]}"
if [[ $index -lt $((${#files[@]} - 1)) ]]; then
printf '\n'
fi
done
}
extract_mentions() {
local body="$1"
printf '%s\n' "$body" | grep -oE '@[A-Za-z0-9._:-]+' | sed 's/^@//' | awk '!seen[$0]++'
}
room_participant_ids() {
local room="$1"
find "$rooms_root/$room/messages" -name '*.md' -type f | sort |
while IFS= read -r file; do
frontmatter_value "agent_id" "$file"
done |
awk 'NF && $0 != "manual" && !seen[$0]++'
}
agent_is_archived() {
local target_agent_id="$1"
local archived_at
archived_at="$("$paseo_bin" inspect "$target_agent_id" --json 2>/dev/null | jq -r '.ArchivedAt // empty' 2>/dev/null)" || archived_at=""
[[ -n "$archived_at" ]]
}
notify_agent() {
local target_agent_id="$1"
local room="$2"
local sender_agent_id="$3"
local sender_agent_name="$4"
local message_id="$5"
local reason="$6"
local message_file="$7"
if [[ -z "$target_agent_id" || "$target_agent_id" == "manual" || "$target_agent_id" == "$sender_agent_id" ]]; then
return
fi
if agent_is_archived "$target_agent_id"; then
return
fi
local sender_label
if [[ -n "$sender_agent_name" ]]; then
sender_label="$sender_agent_name ($sender_agent_id)"
else
sender_label="$sender_agent_id"
fi
local notification full_message
notification="You have a new chat message.
Room: $room
From: $sender_label
Message ID: $message_id
Reason: $reason"
full_message="$(message_body "$message_file")"
if [[ -n "$full_message" ]]; then
notification="${notification}
---
$full_message"
fi
notification="${notification}
-----"
"$paseo_bin" send --no-wait "$target_agent_id" "$notification" >/dev/null 2>&1 || true
}
require_command uuidgen
chat_root="${HOME}/.paseo/chat"
paseo_bin="${PASEO_CHAT_PASEO_BIN:-paseo}"
if [[ $# -lt 1 ]]; then
usage
fi
if [[ "$1" == "--root" ]]; then
[[ $# -ge 3 ]] || usage
chat_root="$2"
shift 2
fi
rooms_root="${chat_root}/rooms"
mkdir -p "$rooms_root"
command_name="$1"
shift
case "$command_name" in
room)
[[ $# -ge 1 ]] || usage
room_subcommand="$1"
shift
case "$room_subcommand" in
create)
room=""
title=""
purpose=""
while [[ $# -gt 0 ]]; do
case "$1" in
--room) room="$2"; shift 2 ;;
--title) title="$2"; shift 2 ;;
--purpose) purpose="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
[[ -n "$room" ]] || { echo "Error: --room is required" >&2; exit 1; }
sanitize_room_path "$room"
room_dir="$rooms_root/$room"
messages_dir="$room_dir/messages"
mkdir -p "$messages_dir"
if [[ -z "$title" ]]; then
title="$room"
fi
created_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
room_file="$room_dir/room.md"
if [[ ! -f "$room_file" ]]; then
printf '%s\n' \
"---" \
"room: $(yaml_quote "$room")" \
"title: $(yaml_quote "$title")" \
"purpose: $(yaml_quote "$purpose")" \
"created_at: $(yaml_quote "$created_at")" \
"---" \
"" \
"# $(printf '%s' "$title")" \
"" \
"$(printf '%s' "$purpose")" > "$room_file"
fi
printf 'created room %s\n' "$room"
;;
list)
while IFS= read -r room_file; do
room_id="${room_file#"$rooms_root"/}"
room_id="${room_id%/room.md}"
printf '%s\n' "$room_id"
done < <(find "$rooms_root" -name room.md -type f | sort)
;;
show)
room=""
while [[ $# -gt 0 ]]; do
case "$1" in
--room) room="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
[[ -n "$room" ]] || { echo "Error: --room is required" >&2; exit 1; }
sanitize_room_path "$room"
require_room "$room"
room_file="$rooms_root/$room/room.md"
title="$(frontmatter_value "title" "$room_file")"
purpose="$(frontmatter_value "purpose" "$room_file")"
created_at="$(frontmatter_value "created_at" "$room_file")"
message_count="$(find "$rooms_root/$room/messages" -name '*.md' -type f | wc -l | tr -d ' ')"
printf 'room: %s\n' "$room"
printf 'title: %s\n' "$title"
printf 'purpose: %s\n' "$purpose"
printf 'created_at: %s\n' "$created_at"
printf 'messages: %s\n' "$message_count"
;;
*)
usage
;;
esac
;;
post)
room=""
body_input=""
body_file=""
agent_id="${PASEO_AGENT_ID:-manual}"
agent_name=""
reply_to=""
auto_resolve_name=true
while [[ $# -gt 0 ]]; do
case "$1" in
--room) room="$2"; shift 2 ;;
--body) body_input="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
--agent-id) agent_id="$2"; shift 2 ;;
--agent-name) agent_name="$2"; auto_resolve_name=false; shift 2 ;;
--reply-to) reply_to="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
[[ -n "$room" ]] || { echo "Error: --room is required" >&2; exit 1; }
sanitize_room_path "$room"
require_room "$room"
if [[ "$auto_resolve_name" == true && -z "$agent_name" && "$agent_id" != "manual" ]]; then
agent_name="$("$paseo_bin" inspect "$agent_id" --json 2>/dev/null | jq -r '.Name // empty' 2>/dev/null)" || agent_name=""
fi
body="$(load_body "$body_input" "$body_file")"
created_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
created_at_file="$(date -u +'%Y-%m-%dT%H-%M-%SZ')"
message_id="msg-$(uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-' | cut -c1-8)"
agent_slug="$(printf '%s' "$agent_id" | tr '/ :@' '_' | tr -cd '[:alnum:]_.-')"
[[ -n "$agent_slug" ]] || agent_slug="manual"
message_file="$rooms_root/$room/messages/${created_at_file}__${agent_slug}__${message_id}.md"
printf '%s\n' \
"---" \
"room: $(yaml_quote "$room")" \
"message_id: $(yaml_quote "$message_id")" \
"agent_id: $(yaml_quote "$agent_id")" \
"agent_name: $(yaml_quote "$agent_name")" \
"created_at: $(yaml_quote "$created_at")" \
"reply_to: $(yaml_quote "$reply_to")" \
"---" \
"" \
"$body" > "$message_file"
notified_agents=()
if [[ -n "$reply_to" ]]; then
reply_file="$(find_message_file_by_id "$room" "$reply_to" || true)"
if [[ -n "$reply_file" ]]; then
reply_target_agent_id="$(frontmatter_value "agent_id" "$reply_file")"
notify_agent "$reply_target_agent_id" "$room" "$agent_id" "$agent_name" "$message_id" "reply to $reply_to" "$message_file"
notified_agents+=("$reply_target_agent_id")
fi
fi
while IFS= read -r mentioned_agent_id; do
[[ -n "$mentioned_agent_id" ]] || continue
if [[ "$mentioned_agent_id" == "everyone" ]]; then
while IFS= read -r participant_agent_id; do
[[ -n "$participant_agent_id" ]] || continue
already_notified=false
for existing_id in "${notified_agents[@]}"; do
if [[ "$existing_id" == "$participant_agent_id" ]]; then
already_notified=true
break
fi
done
if [[ "$already_notified" == false ]]; then
notify_agent "$participant_agent_id" "$room" "$agent_id" "$agent_name" "$message_id" "@everyone mention" "$message_file"
notified_agents+=("$participant_agent_id")
fi
done < <(room_participant_ids "$room")
continue
fi
already_notified=false
for existing_id in "${notified_agents[@]}"; do
if [[ "$existing_id" == "$mentioned_agent_id" ]]; then
already_notified=true
break
fi
done
if [[ "$already_notified" == false ]]; then
notify_agent "$mentioned_agent_id" "$room" "$agent_id" "$agent_name" "$message_id" "direct mention" "$message_file"
notified_agents+=("$mentioned_agent_id")
fi
done < <(extract_mentions "$body")
printf '%s\n' "$message_id"
;;
read)
room=""
limit="20"
since=""
agent_id_filter=""
while [[ $# -gt 0 ]]; do
case "$1" in
--room) room="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
--since) since="$2"; shift 2 ;;
--agent-id) agent_id_filter="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
[[ -n "$room" ]] || { echo "Error: --room is required" >&2; exit 1; }
sanitize_room_path "$room"
require_room "$room"
all_files=()
while IFS= read -r _f; do
all_files+=("$_f")
done < <(message_files_sorted "$room")
since_file=""
if [[ -n "$since" ]]; then
if [[ "$since" == msg-* ]]; then
since_file="$(find_message_file_by_id "$room" "$since" || true)"
[[ -n "$since_file" ]] || { echo "Error: message not found: $since" >&2; exit 1; }
fi
fi
filtered_files=()
past_since=false
if [[ -z "$since" ]]; then
past_since=true
fi
for file in "${all_files[@]}"; do
if [[ -n "$since_file" ]]; then
if [[ "$file" == "$since_file" ]]; then
past_since=true
continue
fi
if [[ "$past_since" != true ]]; then
continue
fi
elif [[ -n "$since" ]]; then
created_at="$(frontmatter_value "created_at" "$file")"
if [[ "$created_at" < "$since" ]]; then
continue
fi
fi
file_agent_id="$(frontmatter_value "agent_id" "$file")"
if [[ -n "$agent_id_filter" && "$file_agent_id" != "$agent_id_filter" ]]; then
continue
fi
filtered_files+=("$file")
done
start_index=0
if [[ "$limit" != "all" ]]; then
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
echo "Error: --limit must be a number or 'all'" >&2
exit 1
fi
if [[ "${#filtered_files[@]}" -gt "$limit" ]]; then
start_index=$((${#filtered_files[@]} - limit))
fi
fi
print_message_files "${filtered_files[@]:$start_index}"
;;
wait)
room=""
since_message_id=""
timeout_seconds=""
poll_interval_seconds="1"
while [[ $# -gt 0 ]]; do
case "$1" in
--room) room="$2"; shift 2 ;;
--since) since_message_id="$2"; shift 2 ;;
--timeout) timeout_seconds="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
[[ -n "$room" ]] || { echo "Error: --room is required" >&2; exit 1; }
sanitize_room_path "$room"
require_room "$room"
if [[ -n "$timeout_seconds" ]] && ! [[ "$timeout_seconds" =~ ^[0-9]+$ ]]; then
echo "Error: --timeout must be a number" >&2
exit 1
fi
baseline_file=""
if [[ -n "$since_message_id" ]]; then
baseline_file="$(find_message_file_by_id "$room" "$since_message_id" || true)"
[[ -n "$baseline_file" ]] || { echo "Error: message not found: $since_message_id" >&2; exit 1; }
else
while IFS= read -r file; do
baseline_file="$file"
done < <(message_files_sorted "$room")
fi
started_at="$(date +%s)"
trap 'exit 130' INT TERM
while true; do
all_files=()
while IFS= read -r _f; do
all_files+=("$_f")
done < <(message_files_sorted "$room")
new_files=()
after_baseline=false
if [[ -z "$baseline_file" ]]; then
after_baseline=true
fi
for file in "${all_files[@]}"; do
if [[ "$after_baseline" == true ]]; then
new_files+=("$file")
continue
fi
if [[ "$file" == "$baseline_file" ]]; then
after_baseline=true
fi
done
if [[ ${#new_files[@]} -gt 0 ]]; then
print_message_files "${new_files[@]}"
exit 0
fi
if [[ -n "$timeout_seconds" ]]; then
now="$(date +%s)"
if (( now - started_at >= timeout_seconds )); then
exit 124
fi
fi
sleep "$poll_interval_seconds"
done
;;
*)
usage
;;
esac