Adds `amy logoff [--yes] [--keep-events]`, the CLI counterpart to logging
out: it removes everything an account left on the machine.
- the identity file and any backend-held secret (keychain / ncryptsec /
plaintext), via DataDir.deleteIdentity
- the rest of the per-account directory ~/.amy/<account>/ (run-state
cursors, aliases, cashu counters, all Marmot/MLS state)
- the ~/.amy/current pin, when it points at this account
- the account's events in the SHARED ~/.amy/shared/events-store/
The event store is shared across accounts, so logoff does not wipe it
wholesale — it deletes only the events that involve this account: those it
authored plus those addressed to it via a #p tag (gift wraps, nutzaps,
reactions, mentions). Other accounts' cached events are left untouched.
`--keep-events` skips the shared-cache purge entirely.
The public key is read straight from identity.json (never unlocking the
private key), so logoff needs no passphrase and pops no keychain prompt.
Destructive and irreversible, so it follows the `marmot reset` precedent:
`--yes` is required to execute; without it the command prints a dry run of
what would be deleted and exits 2.
Thin-assembly only — event deletion is quartz's FsEventStore.delete; this
just resolves the account, counts, and wires the filesystem teardown.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PH3rqz5KaA7CYFPAtxgoz1
- Suppress DEPRECATION on REASONABLE_SIGN_KINDS, which intentionally lists the
deprecated TorrentCommentEvent kind.
- Replace deprecated readLine() with readlnOrNull() in SecureKeyStorage.
- Drop unnecessary !! non-null assertions in KeyCommands and NostrConnect where
the receiver is already smart-cast to non-null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
Audit follow-ups before merge:
- amy fetch default limit is now the same on both paths: absent --limit → 100
for plain AND --paginate (previously --paginate silently meant "unbounded").
`--limit 0` is the explicit opt-in to drain everything (unbounded); negative
is rejected. The effective limit is carried on the filter so both paths agree.
- drainAllPages sizes its SeenIds for CLI-scale fetches (initialSlotsPow2 = 12,
~64 KB) instead of the large-walk default (~16 MB eagerly allocated per fetch);
it grows if an unbounded drain needs it.
- fetchAllPages clamps the inclusive advance to `min(pageMinTs, boundary)` so a
misbehaving relay that answers with an event past the requested `until` can't
push the cursor upward — the boundary dedup and termination rely on `until`
never increasing. No-op for honest relays (they only return events ≤ until).
Verified live: default and --paginate both cap at 100; --limit 50 → 50; --limit 0
--paginate drains the full window (>100); paging tests + SeenIds tests still pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
Two changes to the paginated fetch path:
- Cross-relay dedup before verify. drainAllPages' single consumer now runs a
SeenIds filter: the same widely-mirrored event arrives once per relay, and the
repeats are dropped BEFORE the expensive Schnorr verify + store instead of
after (they were only trimmed by FetchCommand's distinctBy). An id is marked
seen only once it verifies, so a forged copy (valid id, bad sig) delivered
first can't suppress the genuine one from another relay. Adds SeenIds.contains
(peek without recording) for that check-then-add.
- `amy fetch --paginate` no longer forces a --limit. With --limit N it still
pages up to N per relay; WITHOUT --limit it drains the whole filter unbounded
(the filter's null limit flows straight through). Plain (non-paginate) fetch
still trims to the default 100.
Verified live: unbounded --paginate over a ~20-min nos.lol firehose window
returns 406 (all unique, 3s) vs the old 100 cap; --limit 50 caps at 50; default
caps at 100; cross-relay fetch stays count==uniq.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
Amy's one-shot queries all go through Context.drain, a single REQ drained to
EOSE — so a relay that caps its REQ response (strfry's per-REQ limit, ~500)
silently truncates the result with no way to page past it.
Extract the per-relay fetchAllPages fan-out that already lived privately in
EventSync into a reusable quartz accessory, fetchAllPagesFromPool: a
sliding-window pool (maxConcurrentRelays) that paginates each relay on its own
`until` cursor, tags every event with its source relay, and does not dedup
across relays. EventSync now delegates to it (its private downloadPool/
downloadFromRelay are deleted — no behavior change: perRelayFilters is already
ordered by and complete over the relay list).
Add Context.drainAllPages, the paged sibling of drain: same verify+store and
per-relay tagging, but fully draining sets larger than one REQ. Wire it into
`amy fetch` behind --paginate/--all (filter mode only), pushing the limit into
the filter so paging stays bounded. sync (NIP-77) and fetch stay separate
interfaces.
Tests: fetchAllPagesFromPool fan-out/tagging/no-cross-relay-dedup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
Restructure `amy relay` from verb-first `relay add URL --type T` to noun-first
`relay <noun> <verb>`, matching amy's `marmot group …` / `cashu mint …`
convention. The relay-list type is now a required path segment (no implicit
default), and a bare noun lists that bucket.
NIP-65 (kind:10002) is fronted by two facet-nouns, `outbox` (write) and `inbox`
(read), replacing the `--marker` flag. They edit the single 10002 event and
apply the spec's merge rules:
- outbox add R on a read-only R → both
- inbox add R on a write-only R → both
- outbox remove R on a both-R → read (stays in inbox)
- inbox remove R on a both-R → write (stays in outbox)
- dropping the last facet removes R entirely
`relay nip65` shows the combined view; `nip65 remove`/`clear` edit the whole
event.
Other buckets are noun+verb: `relay dm|key-package|search|private|blocked|
trusted|proxy|indexer|broadcast|feeds <add|remove|set|clear|list>`. `set` needs
≥1 URL; `clear` empties. `relay add|remove URL` (no noun) stays as the
transport fan-out (nip65 both + dm + key-package).
BREAKING (cli --json/args): removes `relay add/remove/set --type T` and
`--marker`; `relay list` overview now keys nip65 as `outbox`/`inbox`/`nip65`
and the DM bucket as `dm` (was `inbox`). In-repo harnesses updated
(cache/dm/marmot setup drop `--type all`; cache T5 asserts `.dm`).
Verified end-to-end: merge semantics, encrypted NIP-51 round-trips, facet
set/clear, fan-out, aliases, and error paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
`relay set --type T` with no URLs is now rejected (bad_args, exit 2) instead
of silently wiping the list — a bare empty `set` is almost always a shell
variable that expanded to nothing. Emptying a bucket is explicit: pass
`--clear` (mutually exclusive with URLs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
Expand `amy relay` from the 3 transport lists (nip65/inbox/key_package) to
every relay-list bucket Amethyst's relay-settings screen manages, and add
remove/set verbs alongside add/list.
Buckets (kind): nip65 (10002, read/write markers), inbox/dm (10050),
key_package (10051), search (10007), private (10013), blocked (10006),
trusted (10089), proxy (10087), indexer (10086), broadcast (10088),
feeds/favorites (10012). The private NIP-51 lists are signed NIP-44-encrypted
via the quartz event factories, exactly like the app. Local relays (device
pref, no event) and named relay sets (30002) are intentionally out of scope.
New/changed commands:
- `relay add URL --type T [--marker read|write|both]` — `--marker` sets the
nip65 role; `all` still means nip65+inbox+key_package.
- `relay remove URL --type T` — new.
- `relay set --type T [URL…] [--marker …]` — new; replace a whole bucket
(no URLs clears it).
- `relay list [--type T]` — lists every bucket, or one.
- `relay publish-lists` — now broadcasts every configured list.
Thin-assembly only: buckets are a small registry over the existing quartz
`create`/`relays` factories; adds one generic `Context.latestReplaceable`
helper. `--json` is additive — legacy keys (`nip65`/`inbox`/`key_package`,
`nip65_event_id`/…) are unchanged, so the existing test harnesses keep passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
Found while attributing the small-REQ wire floor (backlog item 6,
latency half): geode's new WireReqFloorBenchmark measured a flat
43.7 ms per REQ round trip that survived every server-side change —
store configs, dispatchers, the pump — and then vanished when the
round's preceding CLOSE was dropped. Root cause is client-side: OkHttp
does not set TCP_NODELAY, relays never answer a CLOSE (NIP-01), so its
bytes sit unACKed for the peer's ~40 ms delayed-ACK window and Nagle
holds the next REQ behind them. CLOSE-then-REQ is a Nostr client's
hottest pattern — every feed/filter switch.
relayBench's harness client already shipped a no-delay socket factory
(which is why benchmark numbers never showed the stall) but the
production clients did not. New TcpNoDelaySocketFactory (quartz
jvmAndroid, next to BasicOkHttpWebSocket) is now used by the Android
relay pool factory, the Desktop relay client, amy's relay connections,
and geode's mirror worker. Direct connections only — SOCKS/Tor paths
are untouched.
With the factory, the benchmark puts geode's ~21-row REQ at ~1.25 ms
on the wire (matching relayBench): ~0.6 ms Ktor CIO+OkHttp loopback
floor, ~0.5 ms per-REQ server work (already investigated). Per-frame
burst cost measured negligible and the pump adds ~nothing, so the
send-path latency angle of backlog item 6 is closed as not-a-problem;
its ingest-CPU share remains a separate throughput question. Findings
recorded in quartz/plans/2026-07-04-small-req-floor.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Replaces the hand-rolled raw-WebSocket NIP-77 negotiate loop (single
un-windowed session — a strfry max_sync_events overflow was a hard
error) with quartz's negentropyReconcile: created_at window splitting
on overflow, keep-alive connection pinning, and streaming id batches.
Downloads and uploads now pipeline with the remaining reconcile
rounds: need-id batches feed 4 concurrent by-id drains, have-ids feed
an uploader (peak 7 subscriptions, under the common relay cap of 20).
Every downloaded event still funnels through the verify-and-store
path. Output field 'rounds' (protocol round-trips) is now 'windows'
(created_at splits).
Verified end-to-end against embedded geode relays: down-only 25/25,
up-only 5/5, and bidirectional re-runs converge to a zero diff.
Also records both adoptions in the perf plan doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
Passes decoder = CachingEventDecoder() at all four NostrClient
construction sites: the Android app pool (AppModules), the Android
crawl client (buildCrawlClient — Event Sync / Cashu discovery, the
duplicate-heaviest path), the desktop RelayConnectionManager, and
amy's Context. Duplicate EVENT frames (14-57% of production traffic)
now skip the full JSON re-parse; dispatch semantics unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
Splits the reconcile out of negentropySync so callers decide how to
load: negentropyReconcile streams needIds (relay has, local lacks —
download) and haveIds (local has, relay lacks — publish) in batchSize
chunks with back-pressure, taking local state as List<IdAndTime> and
slicing it per created_at window on overflow splits; the accumulating
negentropyReconcileIds convenience returns both lists. negentropySync
now delegates to the same window engine.
NegentropySession's primary constructor takes List<IdAndTime> (JVM
erasure forbids a List<Event> overload); the event-list form moved to
NegentropySession.fromEvents, mirroring NegentropyServerSession, with
all call sites migrated.
Adds NostrClientNegentropyReconcileTest (empty local set, partial
overlap both directions, identical sets, batch streaming, since/until
window slicing) — 49 negentropy tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
relay.nostr.band has been decommissioned. Remove it from every runtime
relay list and route search-relay defaults through the shared
AmethystDefaults.DefaultSearchRelayList in commons:
- amy NipCommand: SEARCH_RELAYS now = DefaultSearchRelayList (drops the
hardcoded relay.nostr.band/nostr.wine pair; RelayUrlNormalizer import
no longer needed).
- desktop DesktopRelayCategories: DEFAULT_SEARCH_RELAYS now =
DefaultSearchRelayList instead of a single relay.nostr.band entry
(which would otherwise be empty after removal).
- desktop DefaultRelays and FollowPacks DISCOVERY_RELAYS: drop
relay.nostr.band.
- Update NIP-50 example hostnames in desktop comments, the search-relay
editor help text, and the localized search_relays_not_found_examples
string across all locales to nostr.wine.
Preview sample data, captured sample-event JSON, and quartz test
fixtures that mention relay.nostr.band are left untouched (no runtime
effect).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
relay.damus.io is being decommissioned, so remove it from every runtime
default/fallback relay set to stop the app and amy from wasting connection
slots on a dead host:
- commons Constants: remove `damus`; it dropped out of `bootstrapInbox`
(default NIP-65 inbox) and `eventFinderRelays` (default outbox/fallback),
both still carrying 6 healthy relays.
- ChessConfig: remove damus from CHESS_RELAYS / CHESS_RELAY_NAMES, leaving
the 3 relays the FETCH_TIMEOUT comment already assumes.
- desktop DefaultRelays: remove damus and the also-dead relay.snort.social.
- desktop FollowPacks DISCOVERY_RELAYS: remove damus.
- amy NipCommand SEARCH_RELAYS: swap damus for the NIP-50-capable nostr.wine.
Comments, @Preview sample data, and test fixtures that mention damus.io are
left untouched — they have no runtime effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.
Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:
shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4
docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
Make the Podcasting-2.0 `value` block first-class across read and publish. Actual
Lightning execution (keysend to node recipients, LNURL fan-out to lnaddress
recipients, weighted by split) is a separate wallet/NWC effort and is NOT done
here — this lands the data model, display, and authoring.
quartz:
- PodcastValue / PodcastValueRecipient (@Serializable): amount, currency,
recipients[] (name, type node|lnaddress, address, split weight, fee, custom*).
- Episode `["value", "<json>"]` tag (ValueTag) + accessor/builder; show value is
parsed from the kind:30078 JSON. Exposed via the shared abstraction as
PodcastEpisode.episodeValue() and PodcastShow.showValue() (interface defaults,
so NIP-F4 returns null). Round-trip + JSON-parse tests.
amethyst:
- PodcastValueSplits: a tinted "Value-for-Value" card listing each recipient
with its address and computed share, rendered on both the episode and show
cards when a value block is present.
cli:
- `podcast20 episode`/`metadata` gain `--value-json` to publish the block;
malformed JSON is rejected as bad_args. Verified end-to-end against the CLI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Add a separate command group for authoring the Podcasting-2.0 (podstr) kinds,
kept distinct from the NIP-F4 `podcast` commands because the models differ —
here the logged-in account is the creator and signs everything with its own key,
and episodes/trailers are addressable (d-tag) events.
amy podcast20 metadata --title T [...] kind:30078 show metadata (JSON body)
amy podcast20 episode --title T --audio URL[,URL] [...] kind:30054 episode
amy podcast20 trailer --title T --url URL [...] kind:30055 trailer
amy podcast20 list [USER] [--limit N] metadata + episodes + trailers
Episodes accept the full rich tag set (video, episode/season, transcript,
chapters, topics, duration); d-tags and the RFC2822 pubdate are auto-generated
when omitted. Thin assembly only — added Podcasting20PodcastMetadata.build() in
quartz so JSON-body construction stays out of cli (covered by a round-trip test).
Verified end-to-end against the running CLI: all three commands build, sign and
emit the expected kinds (30078/30054/30055) with correct d-tags and the --json
single-line contract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
`amy namecoin resolve --server` hand-rolled its own ElectrumX server-string
parser that constructed `ElectrumxServer(host, port, useSsl)` and left
`usePinnedTrustStore` at its `false` default. The Namecoin ElectrumX servers
use self-signed certs, so a TLS connection with the default system trust
manager fails the handshake — meaning `--server electrumx.testls.space:50002`
could not connect even though that exact host resolves fine via the default
list. It also duplicated logic already in `commons`, violating the cli
thin-assembly-layer rule.
Delegate each comma-separated entry to the shared
`NamecoinSettings.parseServerString` (the same parser the Android/Desktop
Settings use), so the CLI inherits both the `host:port[:tcp]` syntax and
`usePinnedTrustStore = true`. The README claim that it "reuses the same …
pinned trust store as the apps" is now actually true for `--server` overrides.
Also: reject a non-integer `--timeout` as bad_args instead of silently
falling back to the default, and document exit code 2 + the `host:port[:tcp]`
syntax accurately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Namecoin NIP-05 resolution to the amy CLI as a stateless verb
group, matching the Android and Desktop apps' resolution surface.
amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS]
amy namecoin servers
IDENT accepts the same shapes the apps accept: raw `d/` / `id/`
names, bare `.bit` domains, and `alice@example.bit` NIP-05-style
local-parts. Output is the resolved Nostr pubkey + relay list (+ the
resolved Namecoin name + matched local-part) as machine-readable
JSON (with `--json`) or human-readable text.
The verb is stateless — no account, no `~/.amy/`, no relays — so it
dispatches alongside `decode`/`encode`/`verify`/`nip`/`kind` before
account resolution and the secret store.
Zero new logic in cli/: the implementation is a thin command-file
wrapper around quartz's existing `NamecoinNameResolver` +
`ElectrumXClient` + the canonical `DEFAULT_ELECTRUMX_SERVERS` set
the apps already ship with, including the pinned trust store for
the self-signed Namecoin ElectrumX ecosystem.
amy is headless so no UI piece is wired in. The `--server` flag
accepts `host`, `host:port`, `tcp://`, `tls://`, `ssl://` per entry
(defaults to TLS on 50002); empty / malformed entries fail with
`bad_args` rather than silently using the default set, so a fat-
fingered override can't go unnoticed.
Outcomes from `NamecoinResolveOutcome` map to amy error codes:
Success -> emit JSON, exit 0
NameNotFound -> error not_found
NoNostrField -> error no_nostr_field
MalformedRecord -> error malformed_record (+ namecoin_name extra)
ServersUnreachable-> error servers_unreachable
InvalidIdentifier -> error invalid_identifier
Timeout -> error timeout
Smoke-tested end-to-end on macOS arm64 against the live ElectrumX
fleet:
$ amy --json namecoin resolve d/testls
{"identifier":"d/testls","namecoin_name":"d/testls",
"local_part":"_","pubkey":"460c25e6…","relays":[]}
$ amy namecoin servers
count: 6
servers:
- host: electrumx.testls.space
port: 50002
tls: yes
…
No new runtime deps. The "no Compose UI in the amy image" CI
assertion still passes — `NamecoinNameResolver` + `ElectrumXClient`
are pure JVM (kotlinx.coroutines + kotlinx.serialization, both
already on the CLI classpath via :quartz).
Tests: the resolver, ElectrumX client, identifier parser, and the
default server set already have JVM tests under
`quartz/src/jvmTest/.../namecoin/` — no new core code in this PR,
so the existing coverage applies. CLI verbs are exercised via the
shell harnesses in `cli/tests/`; a Namecoin harness fits the same
pattern but isn't included here.
Parity matrix in `cli/ROADMAP.md` flags `name_history` and the
Namecoin Core JSON-RPC backend as pending separate PRs — both
already exist on Android and Desktop but aren't on upstream main
yet (open PRs against this repo carry them).
Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.
Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add `amy napplet list <author>` and `amy nsite list <author>`: fetch the
author's root + named manifests (15129/35129 for napplets, 15128/35128 for
nsites), keep the latest per identifier, and emit a summary of each (kind, d,
title, description, path count, servers, requires/aggregate, event id,
created_at). Thin assembly over ctx.drain + the quartz manifest accessors.
Harness README notes `amy napplet list` for enumerating what you've published.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add `amy nsite serve` and `amy napplet serve <author> [--d ID] [--port N]`:
fetch the manifest and serve its content over a local HTTP server, resolving
each request through quartz StaticSiteResolver (blob downloaded from Blossom
and sha256-verified per request, same as the device host) with SPA fallback to
index.html. Lets you open a published site/napplet in a browser to confirm it
loads and routes. (Static content only — a napplet's window.napplet.* runtime
still needs the Amethyst host; documented in the command + harness README.)
Implemented as thin cli glue (StaticSiteServe) over the resolver + commons
BlossomClient + the JDK HTTP server.
Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the
single source of truth; the harness README documents publish + serve via amy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.
- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
walks a directory (or single file), content-addresses each file, BUD-02
signed-uploads it via BlossomClient, and maps it to an absolute web path
(/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
hands the path tags to a kind-specific builder, signs with the account key,
and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.
Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Upstream reworked kind:10019 to advertise the account's NIP-65 inbox (read)
relays as nutzap-receiving relays (was outbox). Realign amy:
`cashu wallet create` now defaults nutzapRelays to a new
Context.nip65ReadRelays() (kind:10002 read relays, falling back to outbox)
instead of outboxRelays(), so amy's kind:10019 matches the Android wallet's
again. The event's publish destination (anyRelays / sendLiterallyEverywhere)
is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Re-introspected the real nak binary: 34 functional commands. Fixes the
stale count — adds nsite to full (amy has NsiteCommands), corrects missing
to 7, and clarifies group/nip29 is an intentional MLS/Marmot divergence,
not a gap. Drops the stale "validate" cheap-win (key validate shipped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Update the amy docs to reflect the new command surface:
- cli/README.md — add the Cashu (NIP-60/61), Relay management (NIP-86
admin), and Run-a-relay (serve) sections; document fetch's nip19/nip05
code mode and key validate.
- cli/DEVELOPMENT.md — add cashu.json to the on-disk layout and pin the
command-family --json contracts (cashu keys/error codes + pointer to the
cashu plan, admin {relay,method,result}, serve startup object).
- .claude/skills/amy-expert/SKILL.md — extend the "where things live" tree
with AdminCommand/ServeCommand/cashu/, the commons/cashu + relayManagement
shared modules, and the allowed :geode dependency.
- .claude/CLAUDE.md — note cli may depend on :geode (for serve), never on
:amethyst/:desktopApp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
- `key validate PUBKEY` — nak's `key validate`: parse an npub or 64-hex
pubkey and report {valid, pubkey, npub}; never errors on bad input
(reports valid:false) so scripts branch on the field.
- `fetch <nevent|naddr|nprofile|npub|note|nip05>` — code mode that resolves
relays the way the Android app opens a shared link: the relay hints
embedded in the nip19 code UNION the author's NIP-65 write (outbox)
relays, draining the author's kind:10002 on a cache miss. This is nak's
`fetch` (nip19-hint resolution); filter mode is unchanged.
Verified: key validate accepts fiatjaf's npub/hex and rejects garbage +
bad-checksum npubs; `fetch <npub>` drained the author's kind:10002,
queried their actual advertised write relays, and returned kind:0.
nak parity: 23 full / 3 partial / 6 missing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Two new nak-parity commands:
- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
event, allow/disallow kind, block/unblock IP, change name/description/icon,
and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
commons/jvmAndroid so amy and the Android relay-management screen share it.
- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
embedding geode (the standalone Ktor relay on quartz's relay-server code).
In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
account's own pubkey is always an admin, so `amy admin` works against it out
of the box. cli gains a :geode dependency (geode depends only on :quartz) and
kotlinx-serialization-json (to render NIP-86 JSON results).
Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Two behavioral divergences from the Android wallet, found while auditing
for parity:
- Publish targets: Amethyst sends every cashu event via
sendLiterallyEverywhere (all the user's relays). amy published only to
outboxRelays(); switch to anyRelays() (outbox + inbox + keypackage), the
CLI's closest analog, so the wallet lands on the same broad relay set.
- Nutzap relays on create: Amethyst always advertises the account's outbox
relays in kind:10019 (so senders publish nutzaps where the user reads).
amy defaulted to none; default to outboxRelays() unless --relay overrides.
Also align cashuSnapshot()'s store query with commons'
CashuWalletFilterAssembler exactly (six authored kinds by authors=[pk],
inbound nutzaps by #p) so amy projects the same event set the app
subscribes to. Verified the emitted kind:10019 now carries relay/mint/
pubkey tags identical to NutzapInfoEvent.build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add the offline Cashu command tier to amy, all driven by the shared
commons wallet code so amy exercises the same path as the Android app:
amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
amy cashu wallet show
amy cashu wallet export-key
amy cashu wallet destroy
amy cashu mint ping URL (stateless)
amy cashu mint info URL (stateless)
amy cashu balance [--mint URL]
- create/destroy reuse commons CashuWalletOps.publishWalletEvents /
deleteWallet; show/balance reuse the CashuWalletReader projection over
the local event store; mint ping/info hit quartz's MintHttpClient.
- Context gains cashuOps() (wired to the file NUT-13 counter store + a
per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json.
- Extraction D: CashuKeysetCounterStore contract in commons +
FileCashuKeysetCounterStore (atomic ~/.amy/<account>/cashu.json).
PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline
tier). receive/send/maintenance/mint-rec + interop harness still pending.
Verified end-to-end against mint.minibits.cash and live relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Fill the KindNames registry from 145 to 280 entries so every event kind
quartz defines a class for has a canonical English label + NIP. Adds the
whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61
Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC),
marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation
sets, NIP-85 assertions, Marmot MLS events, and more.
For the handful of kind numbers shared by multiple classes, the registry
keeps one canonical entry (e.g. CashuToken over the deprecated nip61
TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over
SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add
helper that reuses it.
Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind
30817 (NipTextEvent) alongside the wiki and long-form kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Introduce a canonical, i18n-free event-kind registry in quartz:
`com.vitorpamplona.quartz.kinds.KindNames` maps each of the 145 known
kinds to an English label + the defining NIP. The data is ported from
Amethyst's relay-view `kindDisplayName` mapping (the NIP derived from
each event class's package), so the "what is kind N" knowledge now lives
once in quartz instead of only in the Android UI.
`amy kind <N|NAME>` looks a kind up by number (label + NIP) or searches
labels by name — a thin wrapper over KindNames, dispatched statelessly
(no account/network).
i18n split: quartz holds the canonical English (quartz is intentionally
translation-free); localized front ends overlay their own strings and can
fall back to KindNames.nameFor() for kinds they don't translate. amy
prints the English directly.
Verified: kind 1/0/30023/1059/24133 labels+NIPs, name search ("podcast"
→ 4), unknown → known:false, text + JSON output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
- `amy key encrypt|decrypt` (NIP-49): encrypt a secret key to ncryptsec1…
and back. Bidirectionally interop-verified vs the real nak binary
(amy-encrypt → nak-decrypt and nak-encrypt → amy-decrypt both match).
- `amy nip N` / `amy nip list`: look up a NIP — the nostr-protocol/nips
git repo FIRST, then a Nostr fallback (NIP-50 search over wiki kind:30818
+ long-form kind:30023 on search-capable relays). Slug normalization
handles 1→01 and hex-suffixed NIPs (7d→7D, 5a→5A); titles parsed from the
setext headings NIP docs use.
- `amy blossom check|mirror`: HEAD-check blobs (exit 1 if any missing, like
nak) and request BUD-04 mirroring of a blob from a source URL.
Also persists the full introspected nak comparison into ROADMAP. `kind`
stays deferred — it needs a kind→schema registry that doesn't exist in
quartz (would be data/logic that doesn't belong in cli).
Verified: key round-trip + nak cross-impl both ways; nip repo titles for
01/46/5A/7D + Nostr fallback on miss; blossom check 200/404 + exit codes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote
signer, so amy (and Amethyst) can use auth-requiring bunkers like
nsec.app / nsecbunker:
- Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson
one actually used by OptimizedJsonMapper) now special-case
`{result:"auth_url", error:<url>}` BEFORE the generic error branch,
which previously flattened it to a plain error and dropped the marker.
- RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on
an auth_url response it surfaces the URL once and keeps waiting for the
real response under the same request id (UNLIMITED channel so both are
buffered). newResponse peeks the pending entry (get, not remove) so the
follow-up isn't dropped; the waiter still removes it in finally.
- NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to
stderr and the pending command keeps waiting.
Tests: a deserializer test (auth_url keeps result+error) and a manager
test (auth_url surfaced via callback, then the real response resumes the
request) — both green; existing duplicate/late-response manager tests
still pass after the get-vs-remove change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add the client-initiated NostrConnect flow to complete NIP-46 parity:
- `amy login --nostrconnect [--relay …] [--name N]` (client) mints a
transport keypair, prints a nostrconnect:// offer, subscribes, and
waits for the signer's connect ACK (a kind:24133 whose decrypted
result echoes our secret). The ACK's author is the signer; it persists
a bunker account acting as that key.
- `amy bunker connect nostrconnect://…` (signer) parses a client offer,
sends the secret-echo ACK, then services that client's requests on the
offer's relays. Shares the serve loop with `amy bunker`.
NostrConnect.kt holds the offer parse/build (+percent-decode) and the
client handshake. BunkerCommand grew a `connect` sub-mode and factored
the request loop into serve().
Verified end-to-end:
- amy client ⇄ real `nak bunker connect`: amy learns nak's key and signs;
event authored by nak, signature valid.
- amy client ⇄ amy `bunker connect`: same, authored by the host key.
Only `auth_url` challenges remain unimplemented in the NIP-46 surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Close the NIP-46 interop gap against the real nak binary:
- `Identity.fromBunkerUri` now URL-decodes the relay/secret params. nak
emits `bunker://<pk>?relay=wss%3A%2F%2F…&secret=…`; without decoding,
`amy login` of a nak bunker URI produced a broken relay and never
connected. (This was the one real break — found by testing vs nak.)
- `amy bunker` now percent-encodes the relay/secret in the URI it prints,
matching nak's output format.
- `amy bunker` implements `get_relays` (returns its relay set), so nak
clients that probe it get a proper reply instead of an error.
Verified end-to-end with `go install`-built nak over relay.damus.io,
both directions:
- `amy login bunker://` ⇄ `nak bunker`: amy signs, event authored by
nak's key, signature valid.
- `nak event --sec bunker://<amy>` ⇄ `amy bunker`: nak signs through
amy, event authored by amy's key; amy logs connect→ok, sign_event→ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add both halves of NIP-46 remote signing so two amy processes interop:
- `amy bunker [--relay …] [--secret S] [--timeout SECS]` runs a remote
signer for the active local-key account: prints a bunker:// URI, then
subscribes to kind:24133, decrypts each BunkerRequest, dispatches to
ctx.signer (connect/get_public_key/sign_event/nip04/nip44/ping), and
publishes the encrypted BunkerResponse. Long-running like `subscribe`.
- `amy login bunker://PUBKEY?relay=…&secret=…` creates a remote-signer
account: mints a local transport keypair, records the connection, and
acts as PUBKEY. Context builds a NostrSignerRemote (vs the local
NostrSignerInternal) and runs openSubscription()+connect() in prepare();
every signing/encryption call is delegated to the bunker.
Storage: IdentityFile gains a `bunker` block; the transport key is kept
in the existing SecretStore `secret` field. Identity/DataDir load+save
handle the remote-signer account type; `canSign` reflects "writeable via
bunker".
Thin assembly over quartz nip46RemoteSigner (NostrSignerRemote,
BunkerRequest*/BunkerResponse*, NostrConnectEvent). Verified end-to-end
over relay.damus.io: alice hosts a bunker, bob logs in and `amy event`
signs remotely — the note is authored by alice's key and verifies
(id_ok + signature_ok); bunker logs connect→ok, sign_event→ok.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
git (repository metadata + collaboration events; clone/push packfile
transport is out of scope):
- git announce publish a kind:30617 repository announcement
- git list list a user's repo announcements
- git show print one announcement (naddr or kind:pubkey:id), cache-first
- git issue publish a kind:1621 issue against a repo (fetches the repo
announcement to build the EventHintBundle)
podcast (NIP-F4):
- podcast metadata publish kind:10154 show metadata (replaceable)
- podcast publish publish a kind:54 episode (--audio URL[,URL])
- podcast list list a user's metadata + episodes
Thin assembly over quartz GitRepositoryEvent / GitIssueEvent /
PodcastMetadataEvent / PodcastEpisodeEvent. Verified offline: announce->
show cache round-trip (name/clone/hashtags), issue kind:1621 against the
repo, podcast metadata kind:10154 + episode kind:54.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
`amy sync --relay URL [filter] [--down] [--up]` reconciles the local
event store with a relay using NIP-77 Negentropy:
- negotiate the symmetric difference under the filter (drives quartz
NegentropySession over a raw WebSocket, mirroring geode's interop
driver),
- --down (default) downloads the ids the relay has and we lack via
Context.drain,
- --up uploads the events we have and the relay lacks via
Context.publish.
Filter flags match fetch/subscribe; an empty filter reconciles the
whole store. Verified against relay.damus.io: cold store -> need=60,
downloaded=60; immediate re-sync -> need=0 (idempotent).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Integrate the `refactor(cli): simplify amy dispatch, Context lifecycle,
and store handling` change (Commands.kt removed, new route() sub-verb
helper, Context.open(dataDir).use { } lifecycle) with the nak-parity
Tier-1 commands added on this branch.
Re-wiring:
- Dropped Commands.kt; wired the 16 new verbs directly into Main.kt's
dispatch (stateless: decode/encode/verify/key/filter + relay info
interception; account-path: event/publish/fetch/subscribe/count/
encrypt/decrypt/gift/outbox/blossom).
- Converted every new command from hand-rolled try/finally to
Context.open(dataDir).use { ctx -> } and the multi-verb dispatchers
(gift/blossom/key) to the shared route() helper.
- RelayCommands.dispatch now routes via route(); `relay info` stays a
stateless Main interception and is also in the route map.
Verified post-merge: build green + smoke test across stateless,
account-path, and route() sub-verb paths (decode/key/filter, event->
verify round-trip, gift/blossom bad-verb errors, relay info on damus).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Sixth batch of nak parity — Blossom (NIP-B7 / BUD-01/02/04):
- `amy blossom upload --server URL FILE [--mime-type M]` — authed upload,
prints the blob URL + sha256.
- `amy blossom download URL|HASH [--out FILE]` — public download (accepts
a full URL or a HASH + --server).
- `amy blossom list --server URL [USER]` — list a user's blobs (authed).
- `amy blossom delete HASH --server URL` — delete a blob you own.
Reuses commons BlossomClient + BlossomAuth and quartz
BlossomAuthorizationEvent / sha256; list/delete use OkHttp directly with
the quartz-built kind:24242 auth header. Verified a full upload->download
sha256 round-trip against blossom.primal.net and an authed list.
This completes the Tier-1 nak-parity surface (decode/encode/verify/key/
event/publish/fetch/subscribe/count/encrypt/decrypt/gift/filter/relay
info/outbox/blossom); only the kind/nip reference lookups remain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Fifth batch of nak parity:
- `amy filter [filter flags]` — stateless: assemble + print a NIP-01
filter JSON from the same flags fetch/subscribe use (no query sent).
- `amy outbox USER` — show a user's NIP-65 read/write relays (outbox
model), cache-first with a relay-drain fallback / --refresh.
- `amy relay info URL` — stateless NIP-11 info-document fetch over HTTP
(Accept: application/nostr+json), parsed by quartz
Nip11RelayInformation.
filter + relay info dispatch before account resolution (no ~/.amy
needed). Verified against relay.damus.io (NIP-11 doc) and fiatjaf's
kind:10002 (outbox read/write sets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Fourth batch of nak parity:
- `amy count [filter]` — NIP-45 COUNT, per-relay match counts (reuses
quartz INostrClient.count). Reports per-relay + max as `total`.
- `amy encrypt --to USER [TEXT]` / `amy decrypt --from USER [CIPHER]` —
raw NIP-44 (default) or NIP-04 (--nip04) with the active account key.
- `amy gift wrap --to USER [EVENT]` / `amy gift unwrap [WRAP]` — NIP-59
seal+wrap and unwrap+unseal (reuses SealedRumorEvent/GiftWrapEvent).
Text/ciphertext/JSON all read from arg or stdin. Verified with two
local accounts: NIP-44 + NIP-04 round-trips, gift wrap(1059)->unwrap
recovering the inner note + author, and count=60 against relay.damus.io.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Third batch of nak parity — the fetch-vs-subscribe split of nak's `req`:
- `amy fetch [filter] [--timeout SECS]` — one-shot query: open a
subscription, collect until every relay sends EOSE (or timeout),
dedupe by id, sort newest-first, cap at --limit (default 100), exit.
- `amy subscribe [filter] [--timeout SECS]` — live stream: print each
matching event as it arrives (NDJSON under --json), until timeout or
interrupt.
Shared filter flags (--kind/--author/--id/--tag/--since/--until/--limit
/--search) are assembled by RawEventSupport.buildFilter; --author/--id
accept npub/nevent/note/naddr or hex (local decode). fetch reuses
Context.drain; subscribe uses the client subscription directly and
verifies each event before printing. Verified against relay.damus.io
(kind:0 by author, kind:1 stream).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Second batch of nak parity:
- `amy event --kind N [--content …] [--tags JSON] [--created-at TS]`
builds and signs an arbitrary event with the active account. Prints
the signed event by default; `--publish` / `--relay` broadcasts it.
- `amy publish [EVENT-JSON] [--relay …]` broadcasts a pre-made, signed
event (verified before broadcast; reads stdin when no arg).
Both reuse quartz EventTemplate/NostrSigner.sign and the existing
Context.publish path. New RawEventSupport holds the shared arg/stdin +
relay-target helpers for the raw-event verbs. Verified offline via an
event -> verify round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY