feat(cli): graperank refresh rename, unregister verb, operator keys rename

- `graperank update` -> `graperank refresh`: the verb says what it does
  (refresh the WoT record kinds from each author's outbox) and stops
  colliding with the sync/crawl naming tangle. `update` stays as an alias.
- New `graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]`:
  the missing inverse of `register` — removes matching entries (public +
  private) from the account's kind:10040 and re-publishes it; without
  narrowing flags every entry for that provider key is dropped.
- `graperank operator providers` -> `operator keys`: it lists the
  observer -> service-key map, and the old name collided with
  `graperank providers` (the kind:10040 read). `providers` stays as an
  alias; the JSON key `providers` becomes `keys` in both the listing and
  `operator status` (breaking, matches the rename).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
This commit is contained in:
Claude
2026-07-14 20:52:12 +00:00
parent a815614247
commit 8b822029e2
4 changed files with 123 additions and 20 deletions
+5 -3
View File
@@ -392,8 +392,10 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `graperank score` is the local-only variant (same as `--offline`). |
| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. |
| `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. |
| `amy graperank operator [status \| relay <url>… \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. |
| `amy graperank refresh [--down] [--up]` | Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next `score` runs on current data without a full re-crawl. (`update` is the pre-rename alias.) |
| `amy graperank operator [status \| relay <url>… \| keys]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `keys` lists the observer → service-pubkey map (`providers` is the pre-rename alias). |
| `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). |
| `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]` | The inverse of `register`: remove matching entries (public + private) from your kind:10040 and re-publish it. `--service`/`--relay` narrow the match; without them every entry for that provider key is dropped. |
| `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). |
#### GrapeRank scores are persisted locally, then published (NIP-85)
@@ -432,7 +434,7 @@ negentropy-reconcile gets the full set published event-by-event instead. When
the observer is your own account (we hold the key), `publish` also writes their
kind:10040 pointing `30382:rank → serviceKey @ operator relay` to their outbox,
so clients can find the cards. For a third-party observer, `graperank operator
providers` prints the `observer → service-pubkey` mapping to wire their
keys` prints the `observer → service-pubkey` mapping to wire their
kind:10040 out-of-band.
### Direct messages (NIP-17)
@@ -553,7 +555,7 @@ the last facet removes R entirely.
| `amy relay add URL` / `remove URL` | Fan-out to the transport lists (nip65 `both` + `dm` + `key-package`). |
| `amy relay list` | Print every configured relay bucket. |
| `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. |
| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`update`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) |
| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`refresh`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) |
### Local store maintenance
+1 -1
View File
@@ -61,7 +61,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
| NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. |
| NIP-65 outbox model queries | 🆕 | |
| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. |
| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. |
| NIP-72 communities | 🆕 | |
| NIP-78 app-specific data (settings sync) | 🆕 | |
| Long-form (NIP-23) publish / read | 🆕 | |
@@ -646,7 +646,7 @@ private fun printUsage() {
| storm (seeded from the reachability cache).
| graperank probe alias for `relay probe` (the census moved there —
| it feeds the shared NIP-66 reachability cache).
| graperank update [--down] [--up] refresh every locally-known author's WoT record kinds
| graperank refresh [--down] [--up] refresh every locally-known author's WoT record kinds
| [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all
| [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write
| [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per
@@ -654,14 +654,19 @@ private fun printUsage() {
| the deletion settle downloads the relay's kind:5 when
| an uploaded record was rejected (author retracted it).
| Falls back to a full paged download when a relay
| can't reconcile via negentropy.
| can't reconcile via negentropy. (`update` is the
| pre-rename alias.)
| graperank operator [status|relay <url>… manage the machine's operator keys (~/.amy/operator/,
| |providers] independent of accounts): relay sets where cards +
| |keys] independent of accounts): relay sets where cards +
| retractions publish; status shows master + relays;
| providers lists observer -> service-pubkey.
| keys lists observer -> service-pubkey.
| graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so
| [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the
| [--private] 30382:rank provider at your first outbox relay).
| graperank unregister PROVIDER remove matching provider entries (public + private)
| [--service KIND:TAG] [--relay URL] from your kind:10040 and re-publish it; --service/
| --relay narrow the match, else every entry for
| that provider key is dropped.
| graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers
| [--timeout SECS] (default: active account).
|
@@ -102,7 +102,8 @@ import kotlin.math.roundToInt
* store first, `--refresh` to drain providers' relays): the consumer side.
* - `amy graperank register` — advertise a `30382:rank` provider in the
* account's kind:10040 [TrustProviderListEvent] (defaults to self, so a
* provider publishing ranks announces where to find them).
* provider publishing ranks announces where to find them);
* `amy graperank unregister PROVIDER` removes entries again.
* - `amy graperank providers [USER]` — list a user's trusted providers.
*/
object GrapeRankCommand {
@@ -196,16 +197,19 @@ object GrapeRankCommand {
// NIP-05, or nothing) is the OBSERVER positional for a score computation.
when (tail.firstOrNull()) {
"register" -> register(dataDir, tail.drop(1).toTypedArray())
"unregister" -> unregister(dataDir, tail.drop(1).toTypedArray())
"providers" -> providers(dataDir, tail.drop(1).toTypedArray())
"operator" -> operator(dataDir, tail.drop(1).toTypedArray())
// `sync` is the pre-rename name kept as a back-compat alias; `crawl` is
// canonical (disambiguates from negentropy `amy sync` / `graperank update`).
// canonical (disambiguates from negentropy `amy sync` / `graperank refresh`).
"crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray())
// The relay census outgrew graperank (it feeds the shared NIP-66
// reachability cache every command reads) and moved to `amy relay
// probe`; this alias keeps the old spelling working.
"probe" -> RelayCommands.probe(dataDir, tail.drop(1).toTypedArray())
"update" -> update(dataDir, tail.drop(1).toTypedArray())
// `refresh` is canonical (it refreshes the WoT record kinds from each
// author's outbox); `update` is the pre-rename back-compat alias.
"refresh", "update" -> refresh(dataDir, tail.drop(1).toTypedArray())
"score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true)
"publish" -> publish(dataDir, tail.drop(1).toTypedArray())
"rank" -> rank(dataDir, tail.drop(1).toTypedArray())
@@ -544,7 +548,7 @@ object GrapeRankCommand {
}
/**
* `amy graperank update [flags]` — refresh every locally-known author's WoT
* `amy graperank refresh [flags]` (alias: `update`) — refresh every locally-known author's WoT
* record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the
* next `graperank score` runs on current data without a full follow-graph crawl.
*
@@ -563,7 +567,7 @@ object GrapeRankCommand {
* `--report-limit N` (per-relay rows in the JSON, default 50),
* `--down` / `--up` / `--no-sync-deletions`.
*/
private suspend fun update(
private suspend fun refresh(
dataDir: DataDir,
rest: Array<String>,
): Int {
@@ -854,13 +858,15 @@ object GrapeRankCommand {
}
/**
* `amy graperank operator [status | relay <url>… | providers]`
* `amy graperank operator [status | relay <url>… | keys]`
*
* Manage the machine's operator keys used to sign trusted-assertion cards.
* - `status` (default): master pubkey, configured relay(s), provider count.
* - `status` (default): master pubkey, configured relay(s), service-key count.
* - `relay <url>…`: set the operator relay(s) the cards + retractions publish
* to; creates the operator master on first use.
* - `providers`: the observer -> provider-pubkey mapping learned so far.
* - `keys` (alias: the pre-rename `providers`, which collided with
* `graperank providers`): the observer -> service-key mapping derived so
* far — what a third-party observer wires into their kind:10040.
*/
private fun operator(
dataDir: DataDir,
@@ -877,11 +883,11 @@ object GrapeRankCommand {
0
}
"providers" -> {
"keys", "providers" -> {
Output.emit(
mapOf(
"master_pubkey" to if (opKeys.exists()) opKeys.masterPubKey() else null,
"providers" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) },
"keys" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) },
),
)
0
@@ -896,14 +902,14 @@ object GrapeRankCommand {
"initialized" to true,
"master_pubkey" to opKeys.masterPubKey(),
"relays" to opKeys.operatorRelays().map { it.url },
"providers" to opKeys.providers().size,
"keys" to opKeys.providers().size,
),
)
}
0
}
else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | providers)")
else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | keys)")
}
}
@@ -990,6 +996,96 @@ object GrapeRankCommand {
}
}
/**
* `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL] [--timeout SECS]`
*
* The inverse of [register]: drop matching provider entries — public AND
* private — from the account's kind:10040 [TrustProviderListEvent] and
* re-publish it. PROVIDER is required; `--service` / `--relay` narrow the
* match when the same key is listed for several services or relays — without
* them, every entry for that provider key is removed. Fetches the freshest
* list first so the removal applies to the current provider set.
*/
private suspend fun unregister(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val providerArg =
args.positionalOrNull(0)
?: args.flag("provider")
?: return Output.error("bad_args", "usage: amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]")
val serviceArg = args.flag("service")
val relayArg = args.flag("relay")
val timeoutMs = args.longFlag("timeout", 8L) * 1000
val service =
serviceArg?.let {
ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank")
}
val relay =
relayArg?.let {
RelayUrlNormalizer.normalizeOrNull(it) ?: return Output.error("bad_args", "--relay is not a valid relay URL")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val provider = ctx.requireUserHex(providerArg)
val outbox = ctx.outboxRelays()
val latest =
fetchLatestProviderList(ctx, ctx.identity.pubKeyHex, outbox, timeoutMs)
?: return Output.error("not_found", "no kind:10040 provider list found for this account")
fun matches(tag: ServiceProviderTag) =
tag.pubkey == provider &&
(service == null || tag.service == service) &&
(relay == null || tag.relayUrl == relay)
val publicMatches = latest.serviceProviders().filter(::matches)
val privateMatches =
latest
.privateTags(ctx.signer)
?.serviceProviders()
.orEmpty()
.filter(::matches)
val toRemove = (publicMatches + privateMatches).distinct()
if (toRemove.isEmpty()) {
Output.emit(
mapOf(
"provider" to provider,
"changed" to false,
"removed" to emptyList<Any>(),
"based_on" to latest.id,
),
)
return 0
}
// remove() strips the tag from both the public and the private set,
// re-signing each round; only the final version is published.
var event = latest
for (tag in toRemove) {
event = TrustProviderListEvent.remove(event, tag, ctx.signer)
}
val ack = ctx.publish(event, outbox)
Output.emit(
mapOf(
"provider" to provider,
"changed" to true,
"removed" to toRemove.map { mapOf("service" to it.service.toValue(), "relay" to it.relayUrl.url) },
"event_id" to event.id,
"based_on" to latest.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
),
)
return 0
}
}
/**
* `amy graperank providers [USER] [--refresh] [--timeout SECS]`
*