`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
Warms the next/previous few feed notes off the main thread so media and
link previews are ready before the user scrolls to them, and pre-parses
rich-text bodies into the shared cache so scroll-time composition is a
cache hit instead of a UI-thread parse.
Per upcoming note (off Dispatchers.Default, deduped via a per-feed set,
cancelled on a new visible range):
- Pre-parses TextNote/Comment bodies through CachedRichTextParser using the
renderer's exact key, so the composition reads a cached parse. Other kinds
still get their media/links discovered, just without the render-cache warm.
- Prefetches images into Coil — inline content images, NIP-92 imeta blobs,
NIP-94 file-header url tags, and video poster frames — and records each
decoded aspect ratio in MediaAspectRatioCache so the box is reserved on
first layout (no jump). Video poster ratios seed the video URL's box too.
- Warms OpenGraph/link previews via UrlCachedPreviewer.
Gated on showImages()/showUrlPreview() so it honors data-saver/Wi-Fi-only.
Video bytes are deliberately not prefetched (large, HLS, player pool already
starts fast).
Wired centrally at the two feed dispatchers (RenderFeedContentState,
RenderFeedState) so every list feed routed through them is covered without
per-screen wiring, plus direct hooks for the custom-render feeds (hashtag,
profile notes) and a LazyGridState variant for the grid feeds (gallery,
products, discover).
CachedRichTextParser is now content-addressed (memoized contentHash on
ImmutableListOfLists) so an off-thread pre-parse maps to the same entry the
renderer looks up, and its cache grows 50 -> 500 to hold the prefetch +
multi-feed working set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
Four mechanical simplifications to amy with no change to the public
CLI/JSON contract:
1. Drop the Commands.kt pass-through layer. Main.kt now calls each
command object directly; the file is repurposed into Router.kt,
holding a single shared `route(name, tail, usage, routes)` helper.
2. Replace the `Context.open(dataDir)` + `try { } finally { ctx.close() }`
boilerplate (~46 sites) with `Context.open(dataDir).use { ctx -> }`
now that Context is AutoCloseable.
3. Remove the reflection-based `storeIsInitialized()` in Context; track
the lazy event store via `Lazy.isInitialized()` instead.
4. Route every `*Commands.dispatch` through the `route` helper, dropping
the repeated empty-check + unknown-verb `when` boilerplate.
Net -282 lines. Docs (cli/DEVELOPMENT.md, amy-expert skill + template)
updated to the new wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjEvS812aPLZ6nM2XLobzF
Add the first batch of nak parity commands to amy — the army-knife
primitives that operate purely on their arguments, with no account or
network. They dispatch before account resolution (like `use`), so they
run with zero `~/.amy/` state:
- `amy decode ENTITY` NIP-19/21 entity -> JSON
- `amy encode <type> …` raw parts -> NIP-19 entity
- `amy verify [JSON]` id-hash + signature check (reads stdin)
- `amy key generate|public` mint a keypair / derive a pubkey
All four are thin wrappers over quartz (Nip19Parser, the NIP-19
entities, Event.verifyId/verifySignature, KeyPair) per the cli
thin-assembly rule. README + ROADMAP updated with a nak-parity matrix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
justConsumeInnerInner's when(event) has no generic addressable fallback —
its else branch logs "Event Not Supported" and drops the event. A kind-33401
ExerciseTemplateEvent arriving from a relay was therefore never stored as an
AddressableNote, so the fetched template never attached to its placeholder
note and the workout card's exercise title never resolved. Dispatch it to
consumeBaseReplaceable like the other addressable definitions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
The Recommended Apps screen listed every discovered app definition in a
single sorted LazyColumn with no way to filter, so finding a specific app
to recommend meant scrolling the whole list. Users couldn't find a search
because there wasn't one.
Add an always-visible outlined search field below the description that
filters the list by app name (case-insensitive). Rows whose kind 31990
definition hasn't arrived yet have no name to match, so they only appear
when the box is empty. A dedicated empty state shows when a query matches
nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjuK6g3KNkyEEnti6JtDEi
A kind:7 reaction can carry several `e` tags (e.g. the thread root plus
the actually-reacted-to reply). Per NIP-25 the last `e` tag is the event
being reacted to, but the notification consumer resolved the reacted note
via `originalPost().firstOrNull()`, which picked the root. The tray then
showed the like as if it targeted the root note instead of the reply.
Switch to `lastOrNull()` to match NIP-25 and the in-app reaction card,
which already resolves the target with `note.replyTo?.lastOrNull()`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EEQN8Sy7mQ81BEC3rUu5o
Adds a "Show Messages" toggle to the Display section of Notification Settings.
When disabled, direct/group messages (NIP-17 chats, NIP-04 DMs, and Marmot
group messages) are filtered out of the Notification tab, keeping them only in
the Messages tab. Defaults to on, preserving existing behavior.
The setting is persisted per-account in AccountSettings/LocalPreferences and is
included in the notification feed key so the feed refreshes immediately when
toggled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FwwcaBRyvnJFWaDLoQ16d
Audit follow-ups, no behaviour change:
- isNotifiablePublicChatReply bails before allocating the HashSet/ArrayDeque
scratch when the channel message has no parents (top-level posts — the
common case), and builds the deque straight from the parent list.
- Correct the docstring: a kind-42 replyTo holds only the immediate parent
(the channel root is filtered out), so the chain is walked hop-by-hop
through each cached ancestor — the previous wording implied replyTo already
held the ancestors.
- Trim the over-long inline comment on the acceptableEvent gate.
- Make the multi-hop test prove what it claims: assert the immediate parent
is not me, so the walk only passes by climbing to the grandparent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
Audit follow-up. The previous comment claimed an in-place addressable
replacement "not re-emitting here is fine" because AppRow watches each note;
that conflated per-row content (which does stay live) with list membership and
sort order (which do not re-evaluate on a kind-31990 replacement). Documents
the actual behavior and why it is acceptable. Also hoists the nested
remember{} initial-value cache scan out of the collectAsStateWithLifecycle
argument for readability; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
A standalone POWR exercise template (kind 33401) opened via naddr, a quote,
or in a thread previously fell through to RenderTextEvent, showing only its
bare instruction text with no title. Add ExerciseTemplateDisplay (equipment
icon, title, equipment/difficulty subtitle, instructions via the rich-text
pipeline) and dispatch to it from NoteCompose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
Hoist the AddressableNote-vs-id shareId computation out of the two image
share rows, and fix the KDoc rationale: "Share as Image" shares a local PNG
(no relay write); the rows are hidden on private rumors because they expose
the note's content publicly, not because they publish an e-tag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
The screen previously used observeNewEvents purely as an invalidation tick
to re-scan LocalCache.addressables on every app-definition insertion. Since
observeNotes already seeds with the cached kind-31990 notes and re-emits as
new ones arrive, the candidate list now derives straight from those notes:
the appDefinitionsTick counter and the repeated full-cache rescan are gone.
Initial value is seeded from the current cache snapshot to preserve the
first-frame content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
Render real exercise names in POWR workout cards instead of slug-derived
labels by fetching the referenced kind-33401 exercise templates.
- ExerciseTemplateEvent (kind 33401, addressable) with title/format/
format_units/equipment/difficulty accessors; registered in EventFactory.
- WorkoutRecordEvent now implements AddressHintProvider: linkedAddressIds
(deduped 33401 + 33402 coordinates) seed the gatherer so the card
re-renders when a template arrives, and addressHints feed the relay-hint
index with the relay.powr.build hints so the fetch reaches where POWR
published the templates.
- WorkoutDisplay resolves each exercise via LoadAddressableNote +
observeNoteEvent<ExerciseTemplateEvent>, showing the template title once
fetched and the slug until then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
Hoisting the p-tag/public-chat-reply check into a pre-computed val made it
eager: the tag scan (and, for channel messages, the reply-chain walk) ran on
every Note in the cache, even the overwhelming majority rejected by the cheap
`kind in NOTIFICATION_KINDS` check. Inline it back into the && chain in its
original 4th position so that kind check short-circuits ahead of it, and order
the OR so the cheaper tag scan runs before the reply-chain walk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
The blanket NIP-31 alt removal also dropped genuine accessibility
descriptions (image descriptions for the blind) on a few media paths where
the user's caption was only stored in the event-level alt tag. Restore them
via the proper, non-deprecated fields:
- NIP-94 FileHeaderEvent (kind 1063) and NIP-17 encrypted file headers
(kind 15): write the `alt` tag only when the user actually provided a
caption (the NIP-94 accessibility description), never the old boilerplate
fallback.
- MIP-04 encrypted group media (kind 9): route the caption into the imeta
`alt` field via buildMip04IMetaTag instead of an event-level alt tag.
Re-adds the narrowly-scoped TagArrayBuilder.alt() / AltTag.assemble() write
helpers (documented as accessibility-only, not for deprecated NIP-31
boilerplate). Boilerplate alt tags on all other event kinds remain removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
Replaces the global newEventBundles firehose (woke on every new event of
every kind, then filtered for AppDefinitionEvent client-side) with the
indexed LocalCache.observeNewEvents for kind 31990, so the cache delivers
only app-definition insertions to the recompute tick.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
Tray notifications were only suppressed for new events while the app was
foregrounded (MainActivity.isResumed); notifications already posted while
backgrounded lingered even after the user opened the app and viewed them.
Per-event tray notifications are keyed by the triggering event's
id.hashCode(), so when a note bearing such an id is marked read in-app
(loadAndMarkAsRead's onIsNew branch), cancel the matching notification.
Childless group summaries are cleaned up so the tray doesn't keep an
empty summary around. Covers replies/mentions (Notification feed via
NoteCompose) and DMs (ChatMessageCompose); grouped reaction/zap cards
have no 1:1 event mapping and are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsnDifyUtaWUq4wGu4smZf
Per review, the shared element is now just the three true Share options
(browser link, image file, image URL) in ShareActionRows, surfaced by the
ShareOptionsBottomSheet drawer.
- The 3-dot menu keeps its four copy-to-clipboard rows inline, exactly as
before; its share section is now a single "Share" row that opens the drawer
instead of doing the share directly.
- NoteDropDownMenu owns the drawer state and renders the sheet in place of the
menu dialog, so the existing direct callers (MultiSetCompose,
MessageSetCompose) need no changes.
- The reaction-row Share button opens the same drawer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
POWR and RUNSTR both publish kind 1301 but with incompatible tag schemas.
A POWR event previously rendered with the raw "33401:...:back-squat-bb"
coordinate as its activity label, no duration, and none of the set data.
Parse the POWR / NIP-101e dialect in quartz and render it in Amethyst:
- type tag for the activity (strength/circuit/emom/amrap), preferred over
the RUNSTR exercise verb; coordinate-form exercise tags no longer leak as
a verb.
- start/end session timestamps -> derived duration; completed flag.
- structured per-set exercise tags (kg weights, reps, rpe, set_type),
grouped per exercise template with volume/top-weight aggregates.
- WorkoutDisplay now shows Exercises/Sets/Volume stats and a per-exercise
breakdown (e.g. "Back Squat Bb -> 3 x 8 x 84 kg") in the viewer's unit.
Rendering interop only; Amethyst still publishes the RUNSTR-canonical form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
Wires the shared FeedFilterSpinner into the Recommended apps (NIP-89)
screen so it matches the other feed screens. The selection persists per
account via a new defaultAppRecommendationsFollowList setting and resolves
through the existing topNavFilterFlow machinery. App definitions only carry
an author dimension, so the Follows-style filters narrow the list to apps
made by those authors (matchAuthor); hashtag/relay/community variants are
no-ops on apps, as expected. Combines with the local text search and adds a
filter-empty message.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
The NIP-31 event-level "alt" tag is deprecated, so Amethyst no longer
emits it on any event it builds. Removed all `alt(...)` builder calls and
`AltTag.assemble(...)` insertions across every event kind in quartz (and
the few app-side builders), along with the now-unused `ALT`/`ALT_DESCRIPTION`
companion constants and the `TagArrayBuilder.alt()` / `AltTag.assemble()`
write helpers.
Reading alt tags from incoming events is kept (AltTag.parse/match,
TagArray.alt(), Event.alt()) for interop with clients that still send them,
and the imeta media accessibility `alt` field (NIP-92/94) is untouched.
Updated/removed tests that asserted alt-tag presence and refreshed the
deterministic event-id/sig golden masters in UpdateMetadataTest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj