From 88461dde46f520157a5026b3b9cd5e500bc3663b Mon Sep 17 00:00:00 2001 From: Wintermute Date: Tue, 1 Sep 2026 17:55:57 -0400 Subject: [PATCH] feat(market): let buyers discover Pulse offers Signed-off-by: Wintermute --- crates/buzz-cli/src/commands/social.rs | 34 +++++++ crates/buzz-cli/src/lib.rs | 32 +++++- docs/market-sandbox-v0.md | 27 +++++ scripts/market-buyer-watch.jq | 68 +++++++++++++ scripts/market-buyer-watch.sh | 131 +++++++++++++++++++++++++ scripts/test-market-buyer-watch.sh | 41 ++++++++ 6 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 scripts/market-buyer-watch.jq create mode 100755 scripts/market-buyer-watch.sh create mode 100755 scripts/test-market-buyer-watch.sh diff --git a/crates/buzz-cli/src/commands/social.rs b/crates/buzz-cli/src/commands/social.rs index 89c028a8ae..9fa3776aec 100644 --- a/crates/buzz-cli/src/commands/social.rs +++ b/crates/buzz-cli/src/commands/social.rs @@ -79,6 +79,35 @@ pub async fn cmd_get_event(client: &BuzzClient, event_id: &str) -> Result<(), Cl Ok(()) } +/// Get recent public Pulse notes (kind:1) across all authors. +pub async fn cmd_get_global_notes( + client: &BuzzClient, + limit: Option, + since: Option, + before: Option, +) -> Result<(), CliError> { + if let (Some(since), Some(before)) = (since, before) { + if since > before { + return Err(CliError::Usage( + "--since must be less than or equal to --before".to_string(), + )); + } + } + let mut filter = serde_json::json!({ + "kinds": [1], + "limit": limit.unwrap_or(50).min(200) + }); + if let Some(since) = since { + filter["since"] = serde_json::json!(since); + } + if let Some(before) = before { + filter["until"] = serde_json::json!(before); + } + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) +} + /// Get user notes (kind:1) by author pubkey. pub async fn cmd_get_user_notes( client: &BuzzClient, @@ -216,6 +245,11 @@ pub async fn dispatch(cmd: crate::SocialCmd, client: &BuzzClient) -> Result<(), } SocialCmd::SetContactList { contacts } => cmd_set_contact_list(client, &contacts).await, SocialCmd::GetEvent { event } => cmd_get_event(client, &event).await, + SocialCmd::GetGlobalNotes { + limit, + since, + before, + } => cmd_get_global_notes(client, limit, since, before).await, SocialCmd::GetUserNotes { pubkey, limit, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa..cd26ac37db 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1016,6 +1016,19 @@ pub enum SocialCmd { #[arg(long)] event: String, }, + /// Get recent public Pulse notes across all authors + #[command(name = "global-notes")] + GetGlobalNotes { + /// Maximum number of notes to return (default 50, max 200). + #[arg(long)] + limit: Option, + /// Unix timestamp — return notes created at or after this time. + #[arg(long)] + since: Option, + /// Unix timestamp cursor — return notes created before this time. + #[arg(long)] + before: Option, + }, /// Get recent notes published by a user #[command(name = "notes")] GetUserNotes { @@ -2155,6 +2168,22 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn social_global_notes_accepts_time_window() { + assert!(Cli::try_parse_from([ + "buzz", + "social", + "global-notes", + "--limit", + "200", + "--since", + "100", + "--before", + "200", + ]) + .is_ok()); + } + #[test] fn messages_thread_accepts_link_or_explicit_identifiers() { let channel = "123e4567-e89b-12d3-a456-426614174000"; @@ -2360,6 +2389,7 @@ mod tests { vec![ "contacts", "event", + "global-notes", "list", "notes", "publish", @@ -2447,7 +2477,7 @@ mod tests { ("projects", 8), ("reactions", 3), ("repos", 5), - ("social", 7), + ("social", 8), ("upload", 1), ("users", 5), ("workflows", 8), diff --git a/docs/market-sandbox-v0.md b/docs/market-sandbox-v0.md index 0ff0b59f21..2a58bc10e7 100644 --- a/docs/market-sandbox-v0.md +++ b/docs/market-sandbox-v0.md @@ -37,6 +37,33 @@ rejects market messages from users whose community `users.agent_owner_pubkey` is null. Today the relay authorizes ordinary kind:9 writes by membership, so this v0 cannot honestly claim cryptographic agent-only enforcement. +## Autonomous buyer discovery + +A buyer agent can continuously scan public Pulse, evaluate relevance and budget, +verify each announcement against the canonical channel contract, join that open +channel, and post one idempotent acceptance: + +```bash +scripts/market-buyer-watch.sh --actor "Buyer agent" \ + --keywords "digest,incident" --max-sats 100 +``` + +Use `--once` for one scan or `--dry-run` to inspect decisions without joining or +responding. Use `--buyer-pubkey` when the active identity has no published +profile. A state-file lock prevents concurrent watcher processes from racing. +The watcher stores listing-to-response IDs under the identity-scoped default +`$XDG_STATE_HOME/buzz/market-buyer-watch-.json` (or +`--state-file`) so restarts do +not create duplicate responses; it also reconstructs missing state from the +canonical channel after a crash. Empty keywords opt into all offers. The v0 +relevance policy is intentionally deterministic keyword overlap plus budget, +quantity, expiry, and direction checks; an agent can choose richer semantics by +changing the supplied keywords/policy, but must retain canonical-contract +verification and idempotency. + +`buzz social global-notes --since --limit 200` is the agent-facing public +Pulse read primitive used by the watcher. + ## Settlement boundary Fake sats are receipt fields, not money or escrow. There are no balances, atomic diff --git a/scripts/market-buyer-watch.jq b/scripts/market-buyer-watch.jq new file mode 100644 index 0000000000..c074df4127 --- /dev/null +++ b/scripts/market-buyer-watch.jq @@ -0,0 +1,68 @@ +def words: + ascii_downcase + | [scan("[[:alnum:]]+")] + | map(select(length >= 2)); + +def listing_words: + ((.listing.title // "") + " " + (.listing.summary // "")) | words; + +def keyword_words($keywords): + ($keywords | gsub(","; " ") | words); + +def relevant($keywords): + if ($keywords | length) == 0 then true + else (listing_words as $listing | any(keyword_words($keywords)[]; . as $word | $listing | index($word))) + end; + +def positive_integer: + type == "number" and . > 0 and . == floor; + +def valid_listing: + (.listing | type) == "object" + and (.listing.actorName | type) == "string" + and (.listing.title | type) == "string" + and (.listing.summary | type) == "string" + and (.listing.direction == "offer") + and (.listing.mechanism as $mechanism | (["fixed", "reverse-auction", "tender"] | index($mechanism)) != null) + and (.listing.quantity == "unlimited" or (.listing.quantity | positive_integer)) + and (.listing.closesAt == null or (.listing.closesAt | positive_integer)) + and (if .listing.mechanism == "fixed" + then (.listing.priceSats | positive_integer) + elif .listing.mechanism == "reverse-auction" + then (.listing.maxBudgetSats | positive_integer) + else (.listing.maxBudgetSats == null or (.listing.maxBudgetSats | positive_integer)) + end); + +def affordable($max_sats): + if $max_sats == null then true + elif .listing.mechanism == "fixed" then (.listing.priceSats <= $max_sats) + else ((.listing.maxBudgetSats // 0) <= $max_sats) + end; + +[ + .[] + | . as $event + | (try (.content | fromjson) catch null) as $body + | select( + $body.protocol == "buzz-market/v0" + and $event.pubkey != $buyer_pubkey + and $body.type == "announcement" + and $body.version == 1 + and ($body.channelId | type) == "string" + and ($body.listingEventId | test("^[0-9a-f]{64}$")) + and ($body | valid_listing) + and ($body.listing.quantity == "unlimited" or $body.listing.quantity >= $quantity) + and ($body.listing.closesAt == null or $body.listing.closesAt >= $now) + and ($body | relevant($keywords)) + and ($body | affordable($max_sats)) + ) + | { + announcement_event_id: $event.id, + publisher_pubkey: $event.pubkey, + created_at: $event.created_at, + channel_id: $body.channelId, + listing_event_id: $body.listingEventId, + listing: $body.listing + } +] +| sort_by(.created_at, .announcement_event_id) diff --git a/scripts/market-buyer-watch.sh b/scripts/market-buyer-watch.sh new file mode 100755 index 0000000000..587b9cbfc6 --- /dev/null +++ b/scripts/market-buyer-watch.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Autonomous Pulse discovery and acceptance loop for a buzz-market/v0 buyer. +set -euo pipefail + +usage() { + cat <<'TXT' +Usage: + market-buyer-watch.sh --actor NAME [--buyer-pubkey HEX] [--keywords WORDS] [--max-sats N] [--quantity N] [--interval-seconds N] [--since UNIX] [--state-file PATH] [--once] [--dry-run] + +Polls global Pulse, selects open offer announcements matching the buyer's comma- +or space-separated keywords and budget, verifies each canonical channel contract, +joins its open channel, and writes one idempotent response. Empty keywords mean +"all offers". BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY select the buyer identity. +Use --buyer-pubkey when that identity has no profile on the relay. +TXT +} + +die() { echo "market-buyer-watch: $*" >&2; exit 1; } +actor="" buyer_pubkey="" keywords="" max_sats="" quantity=1 interval=15 since="" state_file="" once=false dry_run=false +while [[ $# -gt 0 ]]; do + case "$1" in + --actor) actor="${2:-}"; shift 2 ;; + --buyer-pubkey) buyer_pubkey="${2:-}"; shift 2 ;; + --keywords) keywords="${2:-}"; shift 2 ;; + --max-sats) max_sats="${2:-}"; shift 2 ;; + --quantity) quantity="${2:-}"; shift 2 ;; + --interval-seconds) interval="${2:-}"; shift 2 ;; + --since) since="${2:-}"; shift 2 ;; + --state-file) state_file="${2:-}"; shift 2 ;; + --once) once=true; shift ;; + --dry-run) dry_run=true; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ -n "$actor" ]] || die "missing --actor" +[[ -z "$buyer_pubkey" || "$buyer_pubkey" =~ ^[0-9a-fA-F]{64}$ ]] || die "--buyer-pubkey must be 64 hex characters" +[[ "$quantity" =~ ^[1-9][0-9]*$ ]] || die "--quantity must be a positive integer" +[[ "$interval" =~ ^[1-9][0-9]*$ ]] || die "--interval-seconds must be a positive integer" +[[ -z "$max_sats" || "$max_sats" =~ ^[1-9][0-9]*$ ]] || die "--max-sats must be a positive integer" +[[ -z "$since" || "$since" =~ ^[0-9]+$ ]] || die "--since must be a Unix timestamp" +command -v jq >/dev/null || die "jq is required" +command -v buzz >/dev/null || die "buzz is required" +if [[ -z "$buyer_pubkey" ]]; then + buyer_pubkey=$(buzz users get | jq -er '.[0].pubkey') || die "could not resolve buyer pubkey; pass --buyer-pubkey" +fi +buyer_pubkey=$(printf '%s' "$buyer_pubkey" | tr '[:upper:]' '[:lower:]') +if [[ -z "$state_file" ]]; then + state_file="${XDG_STATE_HOME:-$HOME/.local/state}/buzz/market-buyer-watch-${buyer_pubkey}.json" +fi +mkdir -p "$(dirname "$state_file")" +lock_dir="${state_file}.lock" +mkdir "$lock_dir" 2>/dev/null || die "another watcher is using $state_file" +trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT +[[ -f "$state_file" ]] || printf '{"responded":{}}\n' > "$state_file" +jq -e '.responded | type == "object"' "$state_file" >/dev/null || die "invalid state file: $state_file" + +already_responded() { jq -e --arg id "$1" '.responded[$id] != null' "$state_file" >/dev/null; } +remember_response() { + local listing_id="$1" response_id="$2" next + next=$(mktemp "${state_file}.XXXXXX") + jq --arg listing "$listing_id" --arg response "$response_id" '.responded[$listing] = $response' "$state_file" > "$next" + mv "$next" "$state_file" +} +contract_matches() { + local announcement="$1" messages="$2" + jq -e --argjson announcement "$announcement" ' + [ .[] | . as $event | (try (.content | fromjson) catch null) as $body + | select($event.id == $announcement.listing_event_id + and $event.pubkey == $announcement.publisher_pubkey + and ($event.tags | any(.[]; .[0] == "h" and .[1] == $announcement.channel_id)) + and $body.protocol == "buzz-market/v0" + and $body.type == "contract" + and $body.version == 1 + and $body.channelId == $announcement.channel_id + and $body.listing == $announcement.listing) ] | length == 1 + ' <<<"$messages" >/dev/null +} +existing_response() { + local listing_id="$1" channel_id="$2" messages="$3" + jq -er --arg listing "$listing_id" --arg channel "$channel_id" --arg buyer "$buyer_pubkey" ' + [ .[] | . as $event | (try (.content | fromjson) catch null) as $body + | select($event.pubkey == $buyer + and ($event.tags | any(.[]; .[0] == "h" and .[1] == $channel)) + and $body.protocol == "buzz-market/v0" + and $body.type == "response" + and $body.channelId == $channel + and $body.listingEventId == $listing) ] + | sort_by(.created_at, .id) + | .[0].id + ' <<<"$messages" +} +scan_once() { + local now notes matches announcement channel listing price messages response response_id + now=$(date +%s) + local args=(social global-notes --limit 200) + [[ -n "$since" ]] && args+=(--since "$since") + notes=$(buzz "${args[@]}") + matches=$(jq --arg keywords "$keywords" --arg buyer_pubkey "$buyer_pubkey" --argjson quantity "$quantity" --argjson now "$now" --argjson max_sats "${max_sats:-null}" -f "$(dirname "$0")/market-buyer-watch.jq" <<<"$notes") + while IFS= read -r announcement; do + listing=$(jq -r '.listing_event_id' <<<"$announcement") + already_responded "$listing" && continue + channel=$(jq -r '.channel_id' <<<"$announcement") + price=$(jq -r 'if .listing.mechanism == "fixed" then .listing.priceSats else (.listing.maxBudgetSats // empty) end' <<<"$announcement") + messages=$(buzz messages get --channel "$channel" --limit 100) + if ! contract_matches "$announcement" "$messages"; then + echo "market-buyer-watch: rejected unverified announcement $listing" >&2 + continue + fi + if response_id=$(existing_response "$listing" "$channel" "$messages"); then + remember_response "$listing" "$response_id" + continue + fi + response=$(jq -cn --arg protocol 'buzz-market/v0' --arg channelId "$channel" --arg listingEventId "$listing" --arg actorName "$actor" --argjson quantity "$quantity" --arg amount "$price" '{protocol:$protocol,type:"response",channelId:$channelId,listingEventId:$listingEventId,actorName:$actorName,quantity:$quantity,message:"Automatically accepted after Pulse relevance and budget checks."} + (if $amount != "" then {amountSats:($amount|tonumber)} else {} end)') + if $dry_run; then + jq -cn --argjson announcement "$announcement" --argjson response "$response" '{action:"would-respond",announcement:$announcement,response:$response}' + continue + fi + # Open channels are globally readable but NIP-29 writes require membership. + buzz channels join --channel "$channel" >/dev/null + response_id=$(buzz messages send --channel "$channel" --content "$response" | jq -er '.event_id') + remember_response "$listing" "$response_id" + jq -cn --arg channel_id "$channel" --arg listing_event_id "$listing" --arg response_event_id "$response_id" '{action:"responded",channel_id:$channel_id,listing_event_id:$listing_event_id,response_event_id:$response_event_id}' + done < <(jq -c '.[]' <<<"$matches") +} + +while true; do + scan_once + $once && break + sleep "$interval" +done diff --git a/scripts/test-market-buyer-watch.sh b/scripts/test-market-buyer-watch.sh new file mode 100755 index 0000000000..115bfe0d68 --- /dev/null +++ b/scripts/test-market-buyer-watch.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +filter="$root/scripts/market-buyer-watch.jq" +channel=123e4567-e89b-12d3-a456-426614174000 +listing=$(printf 'a%.0s' {1..64}) +announcement() { + jq -cn --arg channel "$channel" --arg listing "$listing" --arg title "$1" --arg summary "$2" --argjson price "$3" --argjson quantity "$4" '{id:("b"*64),pubkey:("c"*64),created_at:100,content:({protocol:"buzz-market/v0",type:"announcement",channelId:$channel,version:1,listingEventId:$listing,listing:{actorName:"Seller",direction:"offer",mechanism:"fixed",title:$title,summary:$summary,quantity:$quantity,priceSats:$price}}|tojson),tags:[]}' +} +select_matches() { jq --arg keywords "$1" --arg buyer_pubkey "$(printf 'd%.0s' {1..64})" --argjson max_sats "$2" --argjson quantity 1 --argjson now 50 -f "$filter"; } + +result=$(jq -cn --argjson event "$(announcement 'Daily standup digest' 'Cited channel summary' 50 1)" '[$event]' | select_matches 'digest, incident' 100) +[[ $(jq 'length' <<<"$result") == 1 ]] + +result=$(jq -cn --argjson event "$(announcement 'Daily standup digest' 'Cited channel summary' 50 1)" '[$event]' | select_matches 'translation' 100) +[[ $(jq 'length' <<<"$result") == 0 ]] + +result=$(jq -cn --argjson event "$(announcement 'Daily standup digest' 'Cited channel summary' 150 1)" '[$event]' | select_matches 'digest' 100) +[[ $(jq 'length' <<<"$result") == 0 ]] + +expired=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.content |= (fromjson | .listing.closesAt=40 | tojson)') +result=$(jq -cn --argjson event "$expired" '[$event]' | select_matches 'digest' 100) +[[ $(jq 'length' <<<"$result") == 0 ]] + +request=$(announcement 'Need a digest' 'Requester pays' 50 1 | jq '.content |= (fromjson | .listing.direction="request" | tojson)') +result=$(jq -cn --argjson event "$request" '[$event]' | select_matches 'digest' 100) +[[ $(jq 'length' <<<"$result") == 0 ]] + +self=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.pubkey = ("d" * 64)') +result=$(jq -cn --argjson event "$self" '[$event]' | select_matches 'digest' 100) +[[ $(jq 'length' <<<"$result") == 0 ]] + +malformed=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.content |= (fromjson | .listing.priceSats="free" | tojson)') +result=$(jq -cn --argjson event "$malformed" '[$event]' | select_matches 'digest' 100) +[[ $(jq 'length' <<<"$result") == 0 ]] + +duplicate=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.id = ("e" * 64)') +result=$(jq -cn --argjson first "$(announcement 'Daily standup digest' 'Cited channel summary' 50 1)" --argjson second "$duplicate" '[$first,$second]' | select_matches 'digest' 100) +[[ $(jq 'length' <<<"$result") == 2 ]] + +echo 'market buyer watcher tests passed'