From 190043ad121e5c90267a31fc63de140ba3172c23 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Thu, 24 Sep 2026 15:09:54 +0300 Subject: [PATCH] docs: an example that ranks a nostr feed with Jev examples/jev/rank-notes.sh fetches the newest notes from five relays into a local store, drops the app data some clients publish as kind:1, and asks Jev three typed questions about each note (topic, substance, spam). The thresholds that decide what is spam and when a topic is too uncertain are constants at the top of the script. Rate limits and server errors are retried with a growing pause; any other failure skips that note. --dry-run prints the requests without calling the API. The README records a real run over 1,000 notes and what it cost, and what to watch for: automated news posts near the spam line, replies judged without their parent, lower accuracy outside English. The skill and the README point at it, and CI now checks that example scripts parse and stay ASCII. --- .github/workflows/ci.yml | 5 +- README.md | 4 + examples/jev/README.md | 53 +++++++++++ examples/jev/rank-notes.sh | 174 +++++++++++++++++++++++++++++++++++++ skills/deed/SKILL.md | 4 + 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 examples/jev/README.md create mode 100755 examples/jev/rank-notes.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d37927c..55a57ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: run: | set -euo pipefail status=0 - for f in scripts/*.sh; do + for f in scripts/*.sh examples/*/*.sh; do n="$(tr -d '\000-\177' < "$f" | wc -c | tr -d ' ')" if [ "$n" != "0" ]; then echo "$f carries $n bytes outside ASCII" >&2 @@ -64,6 +64,9 @@ jobs: - name: The installer parses run: bash -n scripts/install.sh + - name: The examples parse + run: for f in examples/*/*.sh; do bash -n "$f"; done + # End to end against a real published release: resolve the artifact, fetch # it and the digest beside it, verify, install, and run what was installed. # Asking only whether the script parses would pass on an installer that diff --git a/README.md b/README.md index e8d0ffa..22a65d7 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ npx skills add zig-nostr/deed In Claude Code it also installs as a plugin: `/plugin marketplace add zig-nostr/deed`, then `/plugin install deed@deed`. +## Examples + +- [`examples/jev`](examples/jev): fetch the newest thousand notes from five relays and rank them by substance with [Jev](https://docs.typesafe.ai), dropping spam and app data on the way. A real run and what it cost are in its README. + ## How fast it is A one-shot command runs in about 2.3 ms and under 2 MB of memory. deed signs and verifies about 31,000 events a second each, stores 100,000 events from a relay at about 17,000 a second, and answers a lookup from that store in about 3 ms, start to finish. The binaries are 2.3 to 2.7 MB. [BENCHMARKS.md](BENCHMARKS.md) has the full set and how to reproduce every number. diff --git a/examples/jev/README.md b/examples/jev/README.md new file mode 100644 index 0000000..734dc40 --- /dev/null +++ b/examples/jev/README.md @@ -0,0 +1,53 @@ +# Ranking a nostr feed with Jev + +[`rank-notes.sh`](rank-notes.sh) fetches a global feed of notes with deed and has [Jev](https://docs.typesafe.ai), TypeSafe's classification model, judge every one: what it is about, how much substance it carries, and whether it is spam. Then it prints the notes that are not spam, most substance first. + +The two halves do different jobs. deed does the nostr part: it asks the relays, checks every signature, and keeps the notes in a local store, so each note is judged once however many relays sent it. Jev answers typed questions and returns numbers with probabilities and a confidence, not prose, so the decisions (what counts as spam, when a topic is too uncertain to use) are plain thresholds at the top of the script, where you can read and change them. + +## Run it + +It needs deed, `jq`, `curl`, and a TypeSafe API key from the [console](https://console.typesafe.ai/keys). Jev is paid per input token. + +```sh +export TYPESAFE_API_KEY=... +examples/jev/rank-notes.sh 1000 > ranked.jsonl +``` + +The first argument is how many notes to judge (default 1000); any after it are the relays to ask (default: five large public relays). `--dry-run` fetches the notes and prints the requests it would send, without calling Jev or spending anything. + +Each line of output is one note: + +```json +{"id":"…","pubkey":"…","topic":"tech","topic_confidence":0.9,"substance":2.78,"spam":0.13,"reply":false,"tokens":638,"text":"ESP32-P4 running Linux is a sovereignty win. …"} +``` + +`substance` runs from 0 (a greeting, a reaction, an empty link) to 3 (an argument or a finding worth reading). `spam` is the probability Jev gives that the note is spam, a scam or automated promotion. `reply` says whether the note answers another one, which matters below. A summary goes to stderr. + +## What a run looks like + +One run over the newest 1,000 notes from five relays, on 24 September 2026, with `jev-1.13.0`: + +| | | +| --- | --- | +| notes fetched | 1,000 | +| app data posted as notes, dropped before Jev | 371 | +| notes judged | 629 | +| dropped as spam (probability 0.5 or more) | 157 | +| topics of the rest | news 118, personal 110, tech 61, media 44, bitcoin 43, art 13, nostr 11, other 1, unsure 71 | +| input tokens | 539,784 | +| cost | about $0.023 | + +Reading the output: the top of the ranking was notes with information in them (market readouts from bitcoin bots, a note on bond yields, a French regional budget story, a line of Spanish prose, a reply thread on how discoveries get made), and the bottom was "GM ☕" and its relatives. In a separate sample of 100 notes that I read by hand, what Jev scored as spam was drug and weapon adverts, cult recruitment, leaked account credentials, and self-promotion, plus the automated news posts below. + +## Things to know + +- **A lot of kind:1 is not text.** 371 of the 1,000 notes were JSON objects that some client publishes as notes for its own use. The script drops them in code before anything is sent, because they cost tokens and carry nothing to judge. +- **Automated news posts sit near the spam line.** Accounts that repost headlines with a link scored between about 0.6 and 0.85, which is fair ("automated promotion") but may not be what you want. Raise `SPAM_AT` to keep them. +- **Replies are judged without their parent.** "Spot on, and I've found…" has no topic on its own, and Jev often says so: those come back with low confidence, and the script labels them `unsure` rather than guessing. To judge a reply properly, fetch its parent (`deed req -i `) and put both in the state. +- **English works best.** Jev reads other languages, including Japanese and Chinese, but its documentation says accuracy is lower there; the confidence field is the thing to watch. +- **The model can change.** `jev-latest` moves when TypeSafe ships a new version. Pin a version such as `jev-1.13.0` if you tune the thresholds against it. +- **Long notes are cut.** A note longer than `MAX_CHARS` (8,000) is judged on its opening, because a request has a size limit and the opening says what a note is about. + +## Changing it + +The questions are in the `request` function, as JSON. Add a topic by adding a line to the `criteria` of the `topic` question; ask something new by adding a question beside the other three, since the note is sent once however many questions ask about it. TypeSafe's [primitives](https://docs.typesafe.ai/primitives.md) page describes the three question types, and their [agent skill](https://docs.typesafe.ai/agent-skill.md) teaches a coding agent the API. diff --git a/examples/jev/rank-notes.sh b/examples/jev/rank-notes.sh new file mode 100755 index 0000000..2d898dd --- /dev/null +++ b/examples/jev/rank-notes.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Rank a global nostr feed by how much there is to read in each note. +# +# deed fetches the notes, checks every signature, and keeps them in a local +# store. Jev, TypeSafe's classification model, then answers three typed +# questions about each note: what it is about, how much substance it carries, +# and whether it is spam. The decisions stay in this script: the thresholds +# below say what counts as spam and when a topic is too uncertain to use. +# +# TYPESAFE_API_KEY=... examples/jev/rank-notes.sh [limit] [relay...] +# examples/jev/rank-notes.sh --dry-run [limit] [relay...] +# +# Prints one JSON object per note, most substance first, and a summary with +# the token count and what the run cost on stderr. --dry-run fetches the notes +# and prints the requests it would send, without calling Jev. +# +# Needs deed, jq and curl. Jev is a paid API, charged per input token; see +# https://docs.typesafe.ai/models for the current price. +set -euo pipefail + +# A note Jev calls spam with at least this probability is dropped. +SPAM_AT=0.5 +# A topic answered with less confidence than this is reported as "unsure". +SURE_AT=0.6 +# Requests in flight at once. Jev's limits are per minute and per second. +JOBS="${JOBS:-8}" +# The most of a note Jev is shown, in characters. A few notes run to tens of +# thousands of characters, past what one request may carry, and the opening +# says what a note is about. +MAX_CHARS=8000 +# What one million input tokens costs, in US dollars, for the summary line. +PRICE_PER_MTOK="${PRICE_PER_MTOK:-0.042}" + +dry_run=0 +if [ "${1:-}" = "--dry-run" ]; then + dry_run=1 + shift +fi +limit="${1:-1000}" +shift || true +if [ "$#" -gt 0 ]; then + relays=("$@") +else + relays=(wss://relay.damus.io wss://nos.lol wss://relay.primal.net wss://offchain.pub wss://nostr.mom) +fi + +if [ "$dry_run" = 0 ] && [ -z "${TYPESAFE_API_KEY:-}" ]; then + echo "TYPESAFE_API_KEY is not set (or pass --dry-run to see the requests)" >&2 + exit 2 +fi + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +store="${DEED_STORE:-$work/db}" + +# The nostr half. The first run asks every relay and keeps what comes back; +# the second answers from the store alone, so a note two relays both sent is +# judged once. +deed req -k 1 -l "$limit" --store "$store" "${relays[@]}" >/dev/null +deed req -k 1 -l "$limit" --store "$store" --local >"$work/notes" + +# Some clients publish their own app data as kind:1 notes, as a JSON object or +# array. That is not text anybody reads, so it is dropped here, in code, rather +# than paid for and judged. +jq -c 'select(.content | test("\\S")) + | select((.content | try (fromjson | type) catch "text") as $t + | $t != "object" and $t != "array")' "$work/notes" >"$work/text" + +fetched="$(wc -l <"$work/notes" | tr -d ' ')" +readable="$(wc -l <"$work/text" | tr -d ' ')" +echo "fetched $fetched notes, $readable of them text" >&2 + +# One request per note, the three questions together. +request() { + jq -c --argjson max "$MAX_CHARS" '{ + state: .content[0:$max], + model: "jev-latest", + questions: { + topic: { + type: "choice", + instructions: "What is this nostr note mainly about?", + criteria: { + bitcoin: "Bitcoin, Lightning, zaps, money", + nostr: "Nostr itself: clients, relays, NIPs, the network", + tech: "Software, hardware or science other than nostr and bitcoin", + news: "Current events, politics, society", + art: "Art, music, photography, writing, culture", + personal: "Daily life, greetings, feelings, jokes", + media: "Mostly a link, image or video with little text", + other: null + } + }, + substance: { + type: "score", + instructions: "How much substance does this note carry for a reader who does not know the author?", + criteria: [ + "Nothing: a greeting, a reaction, or an empty link", + "A little: a passing remark", + "Some: a real point or a piece of information", + "A lot: an argument, an explanation or a finding worth reading" + ] + }, + spam: { + type: "noul", + instructions: "Is this note spam, a scam, or automated promotion?" + } + } + }' +} + +# Sends one note and prints its answers beside its id. A rate limit or a +# server error is retried with a growing pause; anything else is reported and +# the note skipped, so one bad request does not end the run. +judge() { + local note="$1" body answer status attempt=0 + body="$(printf '%s' "$note" | request)" + while :; do + answer="$(curl -sS --max-time 60 -w '\n%{http_code}' \ + https://api.typesafe.ai/v1/systemone \ + -H "Authorization: Bearer $TYPESAFE_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$body")" || answer=$'\n000' + status="${answer##*$'\n'}" + answer="${answer%$'\n'*}" + [ "$status" = 200 ] && break + attempt=$((attempt + 1)) + if [ "$attempt" -ge 5 ] || { [ "$status" != 429 ] && [ "$status" != 000 ] && [ "$status" -lt 500 ]; }; then + echo "skipped $(printf '%s' "$note" | jq -r .id): HTTP $status $answer" >&2 + return 0 + fi + sleep $((1 << attempt)) + done + jq -cn --argjson note "$note" --argjson a "$answer" '{ + id: $note.id, + pubkey: $note.pubkey, + topic: $a.answers.topic.choice, + topic_confidence: $a.answers.topic.confidence, + substance: $a.answers.substance.score, + spam: $a.answers.spam.noul, + reply: ($note.tags | any(.[0] == "e")), + tokens: $a.usage.input_tokens, + text: ($note.content | gsub("\\s+"; " ") | .[0:120]) + }' +} +export MAX_CHARS +export -f request judge + +if [ "$dry_run" = 1 ]; then + while IFS= read -r note; do printf '%s' "$note" | request; done <"$work/text" + exit 0 +fi + +# The single quotes are deliberate: "$1" is expanded by the bash that xargs +# starts, once per note. +# shellcheck disable=SC2016 +tr '\n' '\0' <"$work/text" \ + | xargs -0 -n 1 -P "$JOBS" "$BASH" -c 'judge "$1"' _ >"$work/judged" + +# The decisions, in code. +jq -c --argjson spam_at "$SPAM_AT" --argjson sure_at "$SURE_AT" ' + select(.spam < $spam_at) + | if .topic_confidence < $sure_at then .topic = "unsure" else . end' \ + "$work/judged" | jq -sc 'sort_by(-.substance) | .[]' + +jq -rs --argjson spam_at "$SPAM_AT" --argjson sure_at "$SURE_AT" \ + --argjson price "$PRICE_PER_MTOK" ' + (map(.tokens) | add // 0) as $tokens + | map(select(.spam < $spam_at)) as $kept + | "judged \(length) notes, dropped \(length - ($kept | length)) as spam", + "topics: \($kept + | map(if .topic_confidence < $sure_at then "unsure" else .topic end) + | group_by(.) | map("\(.[0]) \(length)") | join(", "))", + "\($tokens) input tokens, about $\($tokens * $price / 1000000 * 10000 | round / 10000)"' \ + "$work/judged" >&2 diff --git a/skills/deed/SKILL.md b/skills/deed/SKILL.md index fda1fe8..9eee0dd 100644 --- a/skills/deed/SKILL.md +++ b/skills/deed/SKILL.md @@ -76,6 +76,10 @@ Filters for `req`: `-k` kind, `-a` author, `-i` id, `-e` / `-p` / `-t` tag value - deed does not yet find an author's relays on its own: name the relays to ask. - There is no Windows build yet. +## Judging what you fetch + +deed's output is one JSON event per line, so it pipes straight into anything that classifies text. [`examples/jev`](https://github.com/zig-nostr/deed/tree/main/examples/jev) ranks a global feed with Jev, TypeSafe's classification model: topic, substance and spam for each note, with the thresholds in the script. Jev is a paid API and needs `TYPESAFE_API_KEY`; run it with `--dry-run` first to see the requests without spending anything, and ask the user before a paid run. + ## More - Source and full reference: https://github.com/zig-nostr/deed