Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions examples/jev/README.md
Original file line number Diff line number Diff line change
@@ -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 <id from its e tag> <relay>`) 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.
174 changes: 174 additions & 0 deletions examples/jev/rank-notes.sh
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions skills/deed/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading