platform-codebase/features/conversation-assistant/devtools/convo
2026-02-13 10:16:33 -08:00

732 lines
25 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# convo — CLI query tool for conversation-assistant production API
#
# Usage:
# convo search <name> Search contacts by name
# convo conversations [contact-id] List conversations (optionally filter by contact)
# convo read <id> [--limit N] Read messages from a conversation
# convo messages <query> Search messages across all conversations
# convo chat <name> Quick: find contact → show recent messages
# convo stats Show server sync statistics
# convo process [--limit N] Trigger server-side message processing
# convo process-stats Show message processing progress
# convo contacts-update Sync contact names from Mac AddressBook
# convo named [--limit N] List conversations with resolved human names
#
set -euo pipefail
API_BASE="${CONVO_API_BASE:-https://conversations.nasty.sh}"
MAC_HOST="${CONVO_MAC_HOST:-plum}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
DIM='\033[2m'
BOLD='\033[1m'
RESET='\033[0m'
die() { echo -e "${RED}Error: $1${RESET}" >&2; exit 1; }
require_jq() {
command -v jq &>/dev/null || die "jq is required. Install with: brew install jq (macOS) or sudo dnf install jq (Fedora)"
}
api_get() {
local path="$1"
local timeout="${2:-15}"
local response http_code
response=$(curl -s --max-time "$timeout" -w '\n%{http_code}' "${API_BASE}${path}" 2>&1)
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | sed '$d')
if [[ "$http_code" -ge 400 ]]; then
local err_msg
err_msg=$(echo "$response" | jq -r '.message // .error // "Unknown error"' 2>/dev/null || echo "$response")
die "API ${http_code}: ${err_msg} (${API_BASE}${path})"
fi
echo "$response"
}
api_post() {
local path="$1"
local data="${2:-}"
local auth="${3:-}"
local timeout="${4:-60}"
local headers=(-H "Content-Type: application/json")
if [[ -n "$auth" ]]; then
headers+=(-H "Authorization: Bearer ${auth}")
fi
local response http_code
if [[ -n "$data" ]]; then
response=$(curl -s --max-time "$timeout" -w '\n%{http_code}' -X POST "${headers[@]}" -d "$data" "${API_BASE}${path}" 2>&1)
else
response=$(curl -s --max-time "$timeout" -w '\n%{http_code}' -X POST "${headers[@]}" "${API_BASE}${path}" 2>&1)
fi
http_code=$(echo "$response" | tail -1)
response=$(echo "$response" | sed '$d')
if [[ "$http_code" -ge 400 ]]; then
local err_msg
err_msg=$(echo "$response" | jq -r '.message // .error // "Unknown error"' 2>/dev/null || echo "$response")
die "API ${http_code}: ${err_msg} (${API_BASE}${path})"
fi
echo "$response"
}
get_auth_token() {
local token
token=$(ssh "$MAC_HOST" 'defaults read com.lilith.conversation-assistant _secure_authToken 2>/dev/null' 2>/dev/null) || true
if [[ -z "$token" ]]; then
die "No auth token found. Is the sync app running on ${MAC_HOST}?"
fi
echo "$token"
}
# ─── search: find contacts by name ──────────────────────────────────────────
cmd_search() {
local query="${1:?Usage: convo search <name>}"
local encoded
encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$query'))")
local response
response=$(api_get "/api/contacts?search=${encoded}")
local count
count=$(echo "$response" | jq '.data | length')
if [[ "$count" == "0" ]]; then
echo -e "${YELLOW}No contacts found matching '${query}'${RESET}"
return
fi
echo -e "${BOLD}Found ${count} contacts matching '${query}':${RESET}\n"
echo "$response" | jq -r '.data[] | [
.id,
.displayName,
(.phoneNumber // .email // "—"),
(.classification // "unknown"),
(.lastMessageAt // "never")
] | @tsv' | while IFS=$'\t' read -r id name phone class last_msg; do
echo -e " ${CYAN}${id}${RESET}"
echo -e " Name: ${BOLD}${name}${RESET}"
echo -e " Phone: ${phone}"
echo -e " Class: ${class}"
echo -e " Last msg: ${last_msg}"
echo ""
done
}
# ─── conversations: list conversations, optionally filtered ─────────────────
cmd_conversations() {
local filter="${1:-}"
local response
if [[ -z "$filter" ]]; then
response=$(api_get "/api/conversations?limit=50")
else
# If it looks like a UUID, it's a contact ID — fetch all and filter
response=$(api_get "/api/conversations?limit=500")
fi
local data
data=$(echo "$response" | jq '.data')
local count
count=$(echo "$data" | jq 'length')
if [[ -n "$filter" ]]; then
# Filter conversations by participant ID
data=$(echo "$data" | jq --arg id "$filter" '[.[] | select(.participantIds[] == $id or .participants[]?.id == $id)]')
count=$(echo "$data" | jq 'length')
fi
if [[ "$count" == "0" ]]; then
echo -e "${YELLOW}No conversations found${RESET}"
return
fi
echo -e "${BOLD}${count} conversations:${RESET}\n"
echo "$data" | jq -r 'sort_by(.lastMessageAt) | reverse | .[] | [
.id,
.displayName,
(.messageCount | tostring),
(.lastMessageAt // "—"),
(if .isGroup then "group" else "1:1" end)
] | @tsv' | while IFS=$'\t' read -r id name msgs last_msg type; do
echo -e " ${CYAN}${id}${RESET}"
echo -e " ${BOLD}${name}${RESET} (${type}, ${msgs} messages)"
echo -e " Last: ${last_msg}"
echo ""
done
}
# ─── read: show messages from a conversation ────────────────────────────────
cmd_read() {
local conv_id="${1:?Usage: convo read <conversation-id> [--limit N]}"
shift
local limit=""
local load_all="true"
while [[ $# -gt 0 ]]; do
case "$1" in
--limit|-n)
limit="$2"
load_all="false"
shift 2
;;
*)
shift
;;
esac
done
local url="/api/conversations/${conv_id}/messages?loadAll=${load_all}"
if [[ -n "$limit" ]]; then
url="/api/conversations/${conv_id}/messages?limit=${limit}"
fi
local response
response=$(api_get "$url" 30)
# Get conversation info
local conv_info
conv_info=$(api_get "/api/conversations/${conv_id}")
local conv_name
conv_name=$(echo "$conv_info" | jq -r '.data.displayName // "Unknown"')
# Resolve conversation name through contacts
local resolved_name="$conv_name"
local participant_id
participant_id=$(echo "$conv_info" | jq -r '.data.participants[0].id // empty' 2>/dev/null)
if [[ -n "$participant_id" ]]; then
local contact_info
contact_info=$(api_get "/api/contacts/${participant_id}" 5 2>/dev/null) || true
if [[ -n "$contact_info" ]]; then
local contact_name
contact_name=$(echo "$contact_info" | jq -r '.data.displayName // empty' 2>/dev/null)
if [[ -n "$contact_name" && "$contact_name" != "$conv_name" ]]; then
resolved_name="${contact_name} (${conv_name})"
fi
fi
fi
local count
count=$(echo "$response" | jq '.data | length')
local total
total=$(echo "$response" | jq '.pagination.total // 0')
echo -e "${BOLD}Conversation: ${resolved_name}${RESET}"
echo -e "${DIM}Showing ${count} of ${total} messages${RESET}\n"
echo -e "────────────────────────────────────────────────────────────\n"
echo "$response" | jq -r '.data | sort_by(.sentAt) | .[] | [
.sentAt,
.direction,
(.text // "[no text]"),
(.messageType // "text")
] | @tsv' | while IFS=$'\t' read -r sent_at direction text msg_type; do
local time_str
time_str=$(date -d "$sent_at" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$sent_at")
# Skip tapbacks in display unless they have meaningful text
if [[ "$msg_type" == "tapback" && "$text" != "[no text]" ]]; then
echo -e " ${DIM}${time_str} ${text}${RESET}"
continue
elif [[ "$msg_type" == "tapback" ]]; then
continue
fi
if [[ "$direction" == "outgoing" ]]; then
echo -e " ${DIM}${time_str}${RESET} ${GREEN}→ You:${RESET} ${text}"
else
echo -e " ${DIM}${time_str}${RESET} ${BLUE}${resolved_name%%(*}:${RESET} ${text}"
fi
done
echo -e "\n────────────────────────────────────────────────────────────"
}
# ─── messages: search messages across all conversations ─────────────────────
cmd_messages() {
local query="${1:?Usage: convo messages <search-query>}"
local encoded
encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$query'))")
local response
response=$(api_get "/api/messages/search?q=${encoded}&limit=30")
local count
count=$(echo "$response" | jq '.data | length')
local total
total=$(echo "$response" | jq '.meta.totalFound // 0')
local search_time
search_time=$(echo "$response" | jq '.meta.searchTimeMs // 0')
if [[ "$count" == "0" ]]; then
echo -e "${YELLOW}No messages found matching '${query}'${RESET}"
return
fi
echo -e "${BOLD}Found ${total} messages matching '${query}' (${search_time}ms):${RESET}\n"
echo "$response" | jq -r '.data[] | [
.sentAt,
.direction,
(.contactName // "unknown"),
(.conversationName // "—"),
.text,
(.similarityScore // 0 | tostring)
] | @tsv' | while IFS=$'\t' read -r sent_at direction contact conv text score; do
local time_str
time_str=$(date -d "$sent_at" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$sent_at")
local dir_indicator
if [[ "$direction" == "outgoing" ]]; then
dir_indicator="${GREEN}${RESET}"
else
dir_indicator="${BLUE}${RESET}"
fi
echo -e " ${dir_indicator} ${DIM}${time_str}${RESET} ${BOLD}${contact}${RESET} ${DIM}(${conv})${RESET}"
echo -e " ${text}"
echo -e " ${DIM}score: ${score}${RESET}"
echo ""
done
}
# ─── chat: quick shortcut — find contact and show recent messages ───────────
cmd_chat() {
local name="${1:?Usage: convo chat <name>}"
shift
local limit="50"
local load_all=""
while [[ $# -gt 0 ]]; do
case "$1" in
--all|-a) load_all="true"; shift ;;
--limit|-n) limit="$2"; shift 2 ;;
*) shift ;;
esac
done
echo -e "${DIM}Searching for '${name}'...${RESET}\n"
# First: search contacts by name (most reliable after contacts-update)
local encoded
encoded=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$name'))")
local contacts_response
contacts_response=$(api_get "/api/contacts?search=${encoded}")
local contact_count
contact_count=$(echo "$contacts_response" | jq '.data | length')
# Fetch all conversations (needed for several lookups)
local convos_response
convos_response=$(api_get "/api/conversations?limit=500" 30)
if [[ "$contact_count" -gt 0 ]]; then
# Show matching contacts
if [[ "$contact_count" -gt 1 ]]; then
echo -e "${BOLD}Found ${contact_count} matching contacts:${RESET}"
echo "$contacts_response" | jq -r '.data[] | " \(.displayName) (\(.phoneNumber // .email // "—")) — \(.id)"'
echo ""
fi
local contact_id
contact_id=$(echo "$contacts_response" | jq -r '.data[0].id')
local contact_name
contact_name=$(echo "$contacts_response" | jq -r '.data[0].displayName')
echo -e "${DIM}Finding conversations with ${contact_name}...${RESET}"
# Find conversations with this contact
local matching
matching=$(echo "$convos_response" | jq --arg id "$contact_id" '[.data[] | select(.participantIds[] == $id or (.participants[]?.id == $id))] | sort_by(.lastMessageAt) | reverse')
local match_count
match_count=$(echo "$matching" | jq 'length')
if [[ "$match_count" -gt 0 ]]; then
local conv_id
conv_id=$(echo "$matching" | jq -r '.[0].id')
local msg_count
msg_count=$(echo "$matching" | jq -r '.[0].messageCount')
if [[ "$match_count" -gt 1 ]]; then
echo -e "${DIM}Found ${match_count} conversations, using most recent (${msg_count} messages)${RESET}\n"
else
echo -e "${DIM}Found conversation: ${msg_count} messages${RESET}\n"
fi
if [[ "$load_all" == "true" ]]; then
cmd_read "$conv_id"
else
cmd_read "$conv_id" --limit "$limit"
fi
return
fi
fi
# Fallback: search conversation display names
local conv_matches
conv_matches=$(echo "$convos_response" | jq --arg name "$name" '[.data[] | select(.displayName | ascii_downcase | contains($name | ascii_downcase))] | sort_by(.lastMessageAt) | reverse')
local conv_match_count
conv_match_count=$(echo "$conv_matches" | jq 'length')
if [[ "$conv_match_count" -gt 0 ]]; then
if [[ "$conv_match_count" -gt 1 ]]; then
echo -e "${BOLD}Found ${conv_match_count} matching conversations:${RESET}"
echo "$conv_matches" | jq -r '.[] | " \(.displayName) (\(.messageCount) msgs, last: \(.lastMessageAt // "—")) — \(.id)"'
echo ""
fi
local conv_id
conv_id=$(echo "$conv_matches" | jq -r '.[0].id')
local conv_name
conv_name=$(echo "$conv_matches" | jq -r '.[0].displayName')
echo -e " Using: ${BOLD}${conv_name}${RESET}\n"
if [[ "$load_all" == "true" ]]; then
cmd_read "$conv_id"
else
cmd_read "$conv_id" --limit "$limit"
fi
return
fi
# Last resort: search participant display names
local participant_matches
participant_matches=$(echo "$convos_response" | jq --arg name "$name" '[.data[] | select(.participants[]?.displayName | ascii_downcase | contains($name | ascii_downcase))] | sort_by(.lastMessageAt) | reverse')
local pm_count
pm_count=$(echo "$participant_matches" | jq 'length')
if [[ "$pm_count" -gt 0 ]]; then
local conv_id
conv_id=$(echo "$participant_matches" | jq -r '.[0].id')
echo -e " Found via participant match\n"
if [[ "$load_all" == "true" ]]; then
cmd_read "$conv_id"
else
cmd_read "$conv_id" --limit "$limit"
fi
return
fi
die "No contacts or conversations found matching '${name}'"
}
# ─── process: trigger server-side message processing ────────────────────────
cmd_process() {
local limit="2000"
while [[ $# -gt 0 ]]; do
case "$1" in
--limit|-n) limit="$2"; shift 2 ;;
--all|-a) limit="50000"; shift ;;
*) shift ;;
esac
done
# Check current state
local stats
stats=$(api_get "/api/processing/stats")
local unprocessed
unprocessed=$(echo "$stats" | jq '.data.unprocessed')
local total
total=$(echo "$stats" | jq '.data.total')
if [[ "$unprocessed" == "0" ]]; then
echo -e "${GREEN}All ${total} messages already processed${RESET}"
return
fi
echo -e "${BOLD}Processing messages...${RESET}"
echo -e "${DIM}${unprocessed} of ${total} unprocessed${RESET}\n"
local batch=0
local total_processed=0
local total_errors=0
while true; do
batch=$((batch + 1))
local remaining=$((limit - total_processed))
if [[ "$remaining" -le 0 ]]; then
break
fi
local batch_size=2000
if [[ "$remaining" -lt "$batch_size" ]]; then
batch_size=$remaining
fi
local result
result=$(api_post "/api/processing/process?limit=${batch_size}" "" "" 120)
local processed
processed=$(echo "$result" | jq '.data.processed')
local errors
errors=$(echo "$result" | jq '.data.errors')
total_processed=$((total_processed + processed))
total_errors=$((total_errors + errors))
echo -e " Batch ${batch}: ${GREEN}${processed}${RESET} processed, ${errors} errors (total: ${total_processed})"
if [[ "$processed" == "0" ]]; then
break
fi
done
echo ""
# Final stats
stats=$(api_get "/api/processing/stats")
local final_processed
final_processed=$(echo "$stats" | jq '.data.processed')
local final_unprocessed
final_unprocessed=$(echo "$stats" | jq '.data.unprocessed')
echo -e "${BOLD}Done:${RESET} ${GREEN}${final_processed}${RESET} processed, ${YELLOW}${final_unprocessed}${RESET} remaining, ${RED}${total_errors}${RESET} errors"
}
# ─── process-stats: show processing progress ────────────────────────────────
cmd_process_stats() {
local stats
stats=$(api_get "/api/processing/stats")
local total processed unprocessed
total=$(echo "$stats" | jq '.data.total')
processed=$(echo "$stats" | jq '.data.processed')
unprocessed=$(echo "$stats" | jq '.data.unprocessed')
local pct=0
if [[ "$total" -gt 0 ]]; then
pct=$(( (processed * 100) / total ))
fi
echo -e "${BOLD}Message Processing:${RESET}\n"
echo -e " Total: ${BOLD}${total}${RESET}"
echo -e " Processed: ${GREEN}${processed}${RESET}"
echo -e " Unprocessed: ${YELLOW}${unprocessed}${RESET}"
echo -e " Progress: ${BOLD}${pct}%${RESET}"
}
# ─── contacts-update: sync contact names from Mac AddressBook ───────────────
cmd_contacts_update() {
echo -e "${BOLD}Syncing contacts from Mac AddressBook...${RESET}\n"
# Get auth token
echo -e "${DIM}Getting auth token from ${MAC_HOST}...${RESET}"
local token
token=$(get_auth_token)
# Query AddressBook on Mac
echo -e "${DIM}Querying AddressBook on ${MAC_HOST}...${RESET}"
local ab_data
ab_data=$(ssh "$MAC_HOST" '/usr/bin/sqlite3 "/Users/natalie/Library/Application Support/AddressBook/Sources/45F9105C-5659-4BBB-A0DB-1213F696F4D9/AddressBook-v22.abcddb" "SELECT COALESCE(r.ZFIRSTNAME, '"'"''"'"') || '"'"'|'"'"' || COALESCE(r.ZLASTNAME, '"'"''"'"') || '"'"'|'"'"' || p.ZFULLNUMBER FROM ZABCDRECORD r JOIN ZABCDPHONENUMBER p ON p.ZOWNER = r.Z_PK WHERE p.ZFULLNUMBER IS NOT NULL AND (r.ZFIRSTNAME IS NOT NULL OR r.ZLASTNAME IS NOT NULL);"' 2>/dev/null)
if [[ -z "$ab_data" ]]; then
die "No contacts found in Mac AddressBook"
fi
# Build JSON
local contacts_json
contacts_json=$(echo "$ab_data" | python3 -c "
import sys, json, re
contacts = []
seen = set()
for line in sys.stdin:
line = line.strip()
parts = line.split('|')
if len(parts) < 3:
continue
first = parts[0].strip()
last = parts[1].strip()
phone = parts[2].strip()
name = (first + ' ' + last).strip()
if not name or not phone:
continue
phone_clean = re.sub(r'[\s\-\(\)\.]', '', phone)
if not phone_clean.startswith('+'):
if phone_clean.startswith('1') and len(phone_clean) == 11:
phone_clean = '+' + phone_clean
elif len(phone_clean) == 10:
phone_clean = '+1' + phone_clean
else:
phone_clean = '+' + phone_clean
key = phone_clean
if key in seen:
continue
seen.add(key)
contacts.append({'phoneNumber': phone_clean, 'displayName': name})
print(json.dumps({'contacts': contacts}))
")
local contact_count
contact_count=$(echo "$contacts_json" | jq '.contacts | length')
echo -e "${DIM}Found ${contact_count} contacts with names${RESET}"
# Sync to server
echo -e "${DIM}Syncing to server...${RESET}"
local result
result=$(api_post "/api/sync/contacts" "$contacts_json" "$token" 30)
local synced
synced=$(echo "$result" | jq '.data.synced')
echo -e "\n${GREEN}Synced ${synced} contacts with human names${RESET}"
}
# ─── named: list conversations with resolved human names ────────────────────
cmd_named() {
local limit="40"
while [[ $# -gt 0 ]]; do
case "$1" in
--limit|-n) limit="$2"; shift 2 ;;
--all|-a) limit="999"; shift ;;
*) shift ;;
esac
done
# Fetch conversations and contacts
local convos
convos=$(api_get "/api/conversations?limit=500" 30)
local contacts
contacts=$(api_get "/api/contacts")
# Build contact lookup and join with conversations
echo "$convos" | jq --argjson contacts "$(echo "$contacts" | jq '.data')" --argjson limit "$limit" '
# Build phone→name lookup from contacts
($contacts | map(select(.phoneNumber != null)) | map({(.phoneNumber): .displayName}) | add // {}) as $lookup |
($contacts | map(select(.id != null)) | map({(.id): .displayName}) | add // {}) as $id_lookup |
.data |
sort_by(.lastMessageAt) | reverse |
.[:$limit] |
map({
id: .id,
phone: .displayName,
name: (
# Try participant ID lookup first
(.participantIds[0] as $pid | $id_lookup[$pid]) //
# Then phone lookup
($lookup[.displayName]) //
# Fallback to display name
.displayName
),
messages: .messageCount,
lastMessage: .lastMessageAt,
isGroup: .isGroup
})
' | jq -r '.[] | [
.name,
(.phone // "—"),
(.messages | tostring),
(.lastMessage // "—"),
(if .isGroup then "group" else "1:1" end),
.id
] | @tsv' | while IFS=$'\t' read -r name phone msgs last_msg type id; do
local time_str=""
if [[ "$last_msg" != "—" ]]; then
time_str=$(date -d "$last_msg" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$last_msg")
fi
# Highlight if name differs from phone (resolved)
if [[ "$name" != "$phone" && "$name" != "—" ]]; then
printf " ${BOLD}%-25s${RESET} ${DIM}%-16s${RESET} %4s msgs ${DIM}%s${RESET}\n" "$name" "$phone" "$msgs" "$time_str"
else
printf " %-25s ${DIM}%-16s${RESET} %4s msgs ${DIM}%s${RESET}\n" "$phone" "$type" "$msgs" "$time_str"
fi
done
}
# ─── stats: show server statistics ──────────────────────────────────────────
cmd_stats() {
# Fetch all conversations
local all_convos
all_convos=$(api_get "/api/conversations?limit=500" 30)
local convo_count
convo_count=$(echo "$all_convos" | jq '.data | length')
local total_messages
total_messages=$(echo "$all_convos" | jq '[.data[].messageCount] | add // 0')
local latest_msg
latest_msg=$(echo "$all_convos" | jq -r '[.data[].lastMessageAt] | sort | reverse | .[0] // "none"')
local oldest_msg
oldest_msg=$(echo "$all_convos" | jq -r '[.data[].lastMessageAt] | sort | .[0] // "none"')
# Contacts
local contacts
contacts=$(api_get "/api/contacts")
local contact_total
contact_total=$(echo "$contacts" | jq '.meta.total // 0')
# Processing stats
local proc_stats
proc_stats=$(api_get "/api/processing/stats")
local proc_total proc_done proc_pending
proc_total=$(echo "$proc_stats" | jq '.data.total // 0')
proc_done=$(echo "$proc_stats" | jq '.data.processed // 0')
proc_pending=$(echo "$proc_stats" | jq '.data.unprocessed // 0')
echo -e "${BOLD}Server Statistics:${RESET}\n"
echo -e " Conversations: ${BOLD}${convo_count}${RESET}"
echo -e " Messages: ${BOLD}${total_messages}${RESET}"
echo -e " Contacts: ${BOLD}${contact_total}${RESET}"
echo -e " Latest message: ${latest_msg}"
echo -e " Oldest message: ${oldest_msg}"
echo -e ""
echo -e " ${BOLD}Processing:${RESET}"
echo -e " Processed: ${GREEN}${proc_done}${RESET} / ${proc_total}"
echo -e " Pending: ${YELLOW}${proc_pending}${RESET}"
echo -e ""
echo -e " API: ${API_BASE}"
}
# ─── main ───────────────────────────────────────────────────────────────────
require_jq
cmd="${1:-help}"
shift || true
case "$cmd" in
search) cmd_search "$@" ;;
conversations) cmd_conversations "$@" ;;
read) cmd_read "$@" ;;
messages) cmd_messages "$@" ;;
chat) cmd_chat "$@" ;;
stats) cmd_stats ;;
process) cmd_process "$@" ;;
process-stats) cmd_process_stats ;;
contacts-update) cmd_contacts_update ;;
named) cmd_named "$@" ;;
help|--help|-h)
echo -e "${BOLD}convo${RESET} — CLI for querying conversation-assistant data\n"
echo "Usage:"
echo " convo search <name> Search contacts by name"
echo " convo conversations [contact-id] List conversations (optionally filter by contact)"
echo " convo read <conv-id> [--limit N] Read messages from a conversation"
echo " convo messages <query> Search messages (semantic search)"
echo " convo chat <name> [--all] Quick: find contact → show recent messages"
echo " convo stats Show server statistics + processing progress"
echo ""
echo "Admin:"
echo " convo process [--limit N|--all] Trigger server-side message text extraction"
echo " convo process-stats Show message processing progress"
echo " convo contacts-update Sync contact names from Mac AddressBook"
echo " convo named [--limit N|--all] List conversations with resolved human names"
echo ""
echo "Environment:"
echo " CONVO_API_BASE Override API URL (default: https://conversations.nasty.sh)"
echo " CONVO_MAC_HOST Override Mac SSH host (default: plum)"
;;
*)
die "Unknown command: ${cmd}. Run 'convo help' for usage."
;;
esac