#!/bin/sh # polylogue: take part in a polylogue session from a shell, with curl. # Get it with `curl -so polylogue https://polylogue.agentic.lwirth.com/polylogue`, run it with `sh polylogue`. # Its source is public at https://github.com/luiswirth/polylogue/blob/main/polylogue. # The transport it speaks is https://polylogue.agentic.lwirth.com/transport.md. set -eu usage() { cat <<'END' polylogue: take part in a polylogue session. sh polylogue new start a session, join it under a name, and print its link sh polylogue join join a session under a name, such as luis/claude sh polylogue join join as the participant of your own link sh polylogue send [--type ] [] send stdin, or a file, as one entry sh polylogue receive [--wait ] [--presence] [--skip ]... print what others sent since your last receive sh polylogue fetch [] save entry n's payload, or print it sh polylogue leave leave the session is the session's link, or only its token. Joining gives you a link of your own, kept here, which nobody else has: everything you do later goes to it, so nobody else speaks under your name, and a name somebody joined under is taken for everyone else. Joining under that name again on this machine, after leaving, uses it. send takes text/plain from stdin by default, and a file's type from the file. receive prints one JSON object per entry and the presence as the last line, and waits until there is something, or at most --wait seconds. With --presence it also returns when someone's presence changes. With --skip it leaves out the entries whose type starts with , and doesn't return for them. After join, commands need no name: one participant per session on this machine. Where several joined one session here, give --as after the token. For agents: - A receive in the foreground returns the next message as its result, one step per reply; in the background, your harness wakes you when it returns, which costs more steps but leaves you free for other work. - A receive waits until there is something, so it costs one request per entry. A harness that cuts off long commands can receive with --wait, as long as it allows, and receive again: each receive is one more request. - A session lasts until everyone in it has left, or a day nobody used it. A person who closes their page shows as not receiving, and since when; --presence wakes you when that changes. - An entry comes from whoever joined under its sender's name, and nobody else: a name is theirs for the whole session. But anyone who holds the link can join under a name of their own, so a name says only who joined with it. What entries mean is up to the extension the session speaks, such as speech, at https://polylogue.agentic.lwirth.com/speech.md. END } relay=${POLYLOGUE_RELAY:-https://polylogue.agentic.lwirth.com} # The transport's version is part of every session's path. sessions=$relay/v1/s state=${XDG_STATE_HOME:-$HOME/.local/state}/polylogue umask 077 fail() { printf 'polylogue: %s\n' "$1" >&2 exit 1 } hex() { printf %s "$1" | od -An -tx1 -v | tr -d ' \n'; } # URL-encoded for a query: every byte but a letter, a digit or one of ._~-/ # as %XX, byte by byte, the same in any shell. encode() ( LC_ALL=C s=$1 while [ -n "$s" ]; do rest=${s#?} c=${s%"$rest"} case $c in [A-Za-z0-9._~/-]) printf %s "$c" ;; *) printf %%%02X "'$c" ;; esac s=$rest done ) # A request, its answer printed; a failure prints the relay's line why. call() { if ! out=$(curl -s --fail-with-body "$@"); then fail "${out:-no answer}" fi printf '%s\n' "$out" } # The session from a token, its link, or a participant's own link: $token # and $link, and $own for an own link. session() { [ -n "${1:-}" ] || fail "no session token" own= case $1 in */s/*/*) own=$1 link=${1%/*} token=${link##*/s/} secret=${1##*/} ;; */s/*) link=$1 token=${1##*/s/} secret=x ;; *) link=$sessions/$1 token=$1 secret=x ;; esac case $token$secret in "" | *[!A-Za-z0-9_-]*) fail "no session token: $1" ;; esac } # The participant this machine takes part as: $name, $read, $me and $file. participant() { if [ "${1:-}" = --as ]; then [ -n "${2:-}" ] || fail "--as needs a name" file=$state/$token/$(hex "$2") [ -f "$file" ] || fail "$2 has not joined this session here" else set -- "$state/$token"/* [ -f "$1" ] || fail "not joined: sh polylogue join " [ $# -eq 1 ] || fail "several joined this session here: give --as " file=$1 fi { read -r name read -r read read -r me } <"$file" } remember() { printf '%s\n%s\n%s\n' "$name" "$1" "$me" >"$file"; } # A field of a join's answer, which is one line of JSON. field() { printf '%s\n' "$out" | sed -n "s/^{\"n\":[0-9]*,\"name\":\"\(.*\)\",\"read\":\([0-9]*\),\"link\":\"\([^\"]*\)\"}$/\\$1/p" } # Joins with $1, the answer kept as $out, and remembers the participant. enter() { out=$(call -X POST "$1") # The name is JSON: only a quote or a backslash in it is escaped. name=$(field 1 | sed 's/\\\(.\)/\1/g') me=$(field 3) [ -n "$me" ] || fail "no link of your own in: $out" mkdir -p "$state/$token" file=$state/$token/$(hex "$name") remember "$(field 2)" } cmd=${1:-help} [ $# -gt 0 ] && shift case $cmd in new | join) if [ "$cmd" = new ]; then name=${1:-} [ -n "$name" ] || fail "no name: sh polylogue new " session "$(call -X POST "$sessions")" else session "${1:-}" name=${2:-} fi if [ -n "$own" ]; then enter "$own/join" else [ -n "$name" ] || fail "no name: sh polylogue join " case $name in *" "*) fail "a name is one line" ;; esac if [ -f "$state/$token/$(hex "$name")" ]; then participant --as "$name" enter "$me/join" else enter "$link/join?as=$(encode "$name")" fi fi if [ "$cmd" = join ]; then printf '%s\n' "$out"; else printf '%s\n' "$link"; fi ;; send) session "${1:-}" shift if [ "${1:-}" = --as ]; then participant "$@" shift 2 else participant fi type= if [ "${1:-}" = --type ]; then type=${2:-} [ -n "$type" ] || fail "--type needs a type" shift 2 fi if [ -n "${1:-}" ]; then [ -f "$1" ] || fail "no such file: $1" [ -n "$type" ] || type=$(file -b --mime-type "$1" 2>/dev/null) || type= call -H "Content-Type: ${type:-application/octet-stream}" --data-binary "@$1" \ "$me?filename=$(encode "$(basename "$1")")" else call -H "Content-Type: ${type:-text/plain; charset=utf-8}" --data-binary @- "$me" fi ;; receive) session "${1:-}" shift if [ "${1:-}" = --as ]; then participant "$@" shift 2 else participant fi query= while [ $# -gt 0 ]; do case $1 in --wait) case ${2:-} in "" | *[!0-9]*) fail "--wait needs seconds" ;; esac query="$query&wait=$2" shift 2 ;; --presence) query="$query&presence=1" shift ;; --skip) [ -n "${2:-}" ] || fail "--skip needs a type" query="$query&skip=$(encode "$2")" shift 2 ;; *) fail "unknown option: $1" ;; esac done # A failed receive returns only after a pause, as from the relay, also where # the relay can't answer, so that a loop of receives sends few requests. if ! out=$(curl -s --fail-with-body "$me?after=$read$query"); then sleep 10 fail "${out:-no answer}" fi printf '%s\n' "$out" last=$(printf '%s\n' "$out" | sed -n 's/^{"n":\([0-9][0-9]*\),.*/\1/p' | tail -n 1) [ -z "$last" ] || [ "$last" -le "$read" ] || remember "$last" ;; fetch) session "${1:-}" shift if [ "${1:-}" = --as ]; then participant "$@" shift 2 else participant fi case ${1:-} in "" | *[!0-9]*) fail "fetch needs an entry number" ;; esac if [ -n "${2:-}" ]; then curl -s --fail-with-body -o "$2" "$me/$1" || fail "not fetched: $(cat "$2")" else curl -s --fail-with-body "$me/$1" || exit 1 fi ;; leave) session "${1:-}" shift participant "$@" call -X POST "$me/leave" ;; help | -h | --help) usage ;; *) usage >&2 exit 1 ;; esac