feat(market): let buyers discover Pulse offers

Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
Wintermute
2026-09-01 17:55:57 -04:00
parent fbbed967d7
commit 88461dde46
6 changed files with 332 additions and 1 deletions

View File

@@ -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<u32>,
since: Option<i64>,
before: Option<i64>,
) -> 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,

View File

@@ -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<u32>,
/// Unix timestamp — return notes created at or after this time.
#[arg(long)]
since: Option<i64>,
/// Unix timestamp cursor — return notes created before this time.
#[arg(long)]
before: Option<i64>,
},
/// 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),

View File

@@ -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-<pubkey>.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 <unix> --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

View File

@@ -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)

131
scripts/market-buyer-watch.sh Executable file
View File

@@ -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

View File

@@ -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'