Per NIP-46 (nostr-protocol/nips#2381), client metadata is the 4th
positional connect param. When it is present but the optional secret or
permissions are not, those slots are now back-filled with empty strings
so the metadata always lands at index 3. Parsing maps empty placeholders
back to null. When no metadata is sent, the array stays as short as
possible for backward compatibility.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
Implements nostr-protocol/nips#2381: a client MAY attach an optional
4th positional parameter to the NIP-46 `connect` request carrying a
JSON-stringified `{name, url, image}` object, mirroring the fields
already present in `nostrconnect://` URIs. This lets a bunker:// paired
signer show who is asking to connect.
- quartz: add BunkerClientMetadata and a clientMetadata field on
BunkerRequestConnect; serialize it as the 4th param (omitted when
empty) and parse it back, degrading malformed/empty JSON to null.
- quartz: NostrSignerRemote carries and sends clientMetadata on
connect() and threads it through fromBunkerUri().
- commons: BunkerLoginUseCase.execute() accepts optional clientMetadata.
- desktopApp: advertise Amethyst's metadata on bunker login.
- cli: the receiving bunker logs the connecting client's identity
(display-only; never gates the ACK on it, since the client pubkey is
unauthenticated in bunker:// pairing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
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
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
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
Closes the remaining nsite/napplet runtime gaps, all keeping content
integrity (every blob is sha256-verified) and the sandbox intact:
- resource.bytes blossom: scheme — blossom:<sha256> fetches from the
user's kind:10063 Blossom servers and verifies the hash before
returning. nostr: stays deferred (bytes semantics unspecified).
- Content-type byte-sniffing in the resolver: when a manifest path has
no/unknown extension, sniff magic bytes (png/jpeg/webp/gif/pdf/wasm/...).
Text/markup is never sniffed so HTML detection stays extension-driven.
Unit-tested in quartz.
- kind:10063 server fallback: the launcher augments the manifest's servers
with the author's published Blossom list (best-effort, when cached).
- Blob caching: the host OkHttp client caches blobs on disk with a forced
immutable policy (content-addressed). The resolver re-verifies every
served blob's sha256, so a stale/poisoned cache entry can't be served.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
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
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
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
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
When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.
Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).
Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
The URL detector stripped a single trailing punctuation char unconditionally,
which dropped the closing ")" from legitimate URLs such as
https://en.wikipedia.org/wiki/Bitcoin_(disambiguation).
Make the trailing strip balance-aware: a trailing ")", "}" or "]" is kept when
the URL contains its matching opener (balanced), and only stripped when it is
unbalanced wrapping/sentence punctuation (e.g. "(see example.com)" or
"http://test.com)"). Commas without surrounding spaces were already kept inside
paths; this also adds "]" to the begin/end punctuation sets so an unbalanced
bracket is handled symmetrically with parens and braces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzZzVcMcuzjSdhD3xqCE87
The Blossom auth-header encoding (`Nostr <base64-event>`), the `/upload`
endpoint path, and the `X-Reason` failure header were each re-derived in
both the commons JVM `BlossomClient`/`BlossomAuth` and the Android
`BlossomUploader`, using two different Base64 APIs. Move these
protocol-level facts into the quartz `nipB7Blossom` package, where the
rest of the Blossom protocol lives:
- `BlossomAuthorizationEvent.toAuthorizationHeader()` / `rawToken()` +
`AUTH_HEADER_SCHEME`, mirroring NIP-98's
`HTTPAuthorizationEvent.toAuthToken()` that Blossom auth reuses.
- new `BlossomServerUrl` with `upload()` / `blob()` endpoint builders and
the `REASON_HEADER` constant.
Both transports now call these helpers instead of hand-building strings.
No behavior change for upload (existing desktop BlossomClientTest still
green); the Android delete URL now omits the trailing dot when no file
extension is known, matching BUD-02's `DELETE /<sha256>`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJgwV4Y99brVa97v7p3jJb
Adds the full NIP-5D napplet manifest layer, plus the NIP-5A aggregate-hash
infrastructure it depends on. Follows the nip88Polls package structure (event
class + tags/ + TagArrayExt + TagArrayBuilderExt).
NIP-5A shared infra (nip5aStaticWebsites):
- XTag — the aggregate-hash tag ["x", "<sha256>", "aggregate"].
- SiteAggregateHash — computes/verifies the NIP-5A aggregate hash: sort the
per-path lines "<hash> <path>\n" lexicographically, concat as UTF-8, SHA-256.
Pinned by a test against an independently computed sha256sum vector.
- siteAggregateHash() parse + builder extensions.
NIP-5D napplets (nip5dNapplets):
- NappletSnapshotEvent (5129, regular), RootNappletEvent (15129, replaceable),
NamedNappletEvent (35129, addressable, d-tag) — all built on the NIP-5A
path/server/title/description/source/x tag set.
- RequiresTag — ["requires", "<bare-nap-name>"] capability declarations.
- NappletManifest interface — uniform accessors (paths/servers/requires/title/
…) plus computeAggregateHash()/verifyAggregate() shared across the three kinds.
build() auto-stamps the x aggregate (required for snapshots, recommended for
root/named).
- Registered all three kinds in EventFactory.
This covers the NIP-5D runtime verification contract end-to-end in quartz:
signature (core Event.verify), per-blob sha256 (StaticSiteResolver.verify), and
the aggregate x-tag (NappletManifest.verifyAggregate). Note the napplet kinds
5129/15129/35129 are distinct from the NIP-5A nsite kinds 5128/15128/35128, so
there is no collision.
Tests: SiteAggregateHashTest (vector + order-independence + tamper) and
NappletEventTest (build/parse round-trip for all three kinds, aggregate
verification, tamper detection, EventFactory routing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
An ignored external NIP-55 signer prompt surfaces as
SignerExceptions.TimedOutException. Relay auth (NIP-42) signs replies in a
fire-and-forget scope.launch whose host scope (e.g. viewModelScope) carries no
CoroutineExceptionHandler, so an uncaught timeout there reached the platform
default handler and crashed the app ("Could not sign: User didn't accept or
reject in time.").
Guard the launch in RelayAuthenticator so signing failures are swallowed and
logged (re-throwing only CancellationException). Apply the same guard to
NostrSignerRemote's incoming-bunker-response launch, which decrypts untrusted
relay data on a handler-less scope. Add RelayAuthenticatorTimeoutTest covering
the swallowed-timeout and happy-path-still-sends-AUTH cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RM8zAKJNE8aAQL5nUboso
Wires the quartz NIP-5A resolver end-to-end so it can be exercised against
real manifests (interop / agents), without building the security-sensitive
WebView shell yet.
- commons BlossomClient: add download(url) — a Blossom GET returning raw bytes
(null on non-2xx; connection failures propagate so callers try the next
server). Does not verify the hash; that is the resolver's job.
- cli NsiteCommands: `amy nsite fetch AUTHOR [--d ID] [--path P] [--server …]
[--relay …] [--out FILE] [--timeout SECS] [--max-inline-bytes N]`. Fetches
the manifest (kind 15128 root, or 35128 named with --d) from relays, then
resolves one path through StaticSiteResolver, downloading from the manifest's
Blossom servers (plus any --server fallbacks) and accepting only the first
blob whose sha256 matches the manifest pin. Emits the verified path's bytes
(inlined for small text, or written to --out) with hash/server/content-type,
or a structured not_found / path_not_found / unresolvable error.
Thin-assembly only: all resolution + verification stays in quartz, the byte
fetch in commons. Smoke-tested offline: bad-args, help, and a dead-relay run
that resolves cleanly to not_found in both text and --json modes.
Also converts the StaticSitePathLookup file-overview KDoc to a plain block
comment to satisfy ktlint no-consecutive-comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Adds a platform-agnostic resolver for NIP-5A static-website / napplet (NIP-5D)
manifests in quartz commonMain, under nip5aStaticWebsites/resolver/:
- StaticSitePathLookup: request-path normalization (query/fragment stripping,
leading-slash insensitivity, root/dir -> index.html), slash-insensitive
path lookup over a manifest's path tags, and a web-asset Content-Type guess.
- StaticSiteResolver: hash verification, Blossom candidate-URL assembly, and a
suspend resolve() that downloads each listed server in order and accepts the
first blob whose recomputed sha256 matches the manifest pin. HTTP is injected
via a BlobFetcher typealias so quartz keeps no HTTP dependency.
The trust model is the point: the signed manifest is the authority, the Blossom
server is untrusted. A server that substitutes/corrupts a blob fails
verification and is skipped -- it can withhold content but never forge it.
Tests cover normalization, lookup, MIME guessing, and the security cases
(tampered server skipped -> falls through to honest server; all-tampered ->
Unresolvable; undeclared path -> PathNotInManifest without fetching).
Also adds quartz/plans/2026-06-19-napplet-nip5a-resolver.md documenting the
design and the open event-shape alignment questions (35128 vs 35129 manifest
kind, capability declaration vs NIP-89, aggregate build hash, server ordering)
to raise with the napplet author before the nsite/napplet event shape forks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
Replace startsWith(..., ignoreCase = true) — which case-folds on every
call — with the precomputed DualCase prefixes and the new
String.startsWith(DualCase) helper, so the cashuA/cashuB dispatch only
compares against already-cased strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
- parseCashuA now decodes standard *and* url-safe base64. NUT-00 v3
specifies base64-urlsafe, but legacy encoders (and older Amethyst
builds) emitted standard base64; try standard first, fall back to
url-safe so both round-trip.
- CashuWalletViewModel.redeemToken now redeems every mint/keyset group
in a pasted token instead of only the first, validating all mints are
in the wallet up front and summing the redeemed amounts.
Adds parser coverage for both base64 alphabets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
Audit follow-ups on the cashu token codec move:
- CashuTokenB64Parser.parse() dispatches on the prefix case-insensitively
(matching commons RichTextParser's case-insensitive cashuA/cashuB
detection), but parseCashuA/parseCashuB stripped it with a case-sensitive
removePrefix — so a mixed-case prefix passed dispatch and then fed its
own prefix bytes into the Base64 decoder, failing to parse. Strip the
fixed 6-char prefix with drop() so dispatch and stripping agree.
- hoist a single shared CashuV4Cbor instance instead of allocating a new
Cbor on every encode (V4Encoder) and every cashuB parse.
Adds an acceptsMixedCasePrefix regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
Follow-up to the V4Encoder move. Quartz could encode a cashuB string but
could not parse cashuA/cashuB back, and the parsing lived in amethyst even
though it is pure NUT-00 wire-format protocol. Worse, commons.RichTextParser
already detects cashuA/cashuB words while the parser sat up in the app, so
Desktop (its own rich-text viewer) could not parse a received token at all.
Consolidate the legacy out-of-band redeem stack onto quartz:
- new quartz CashuTokenB64Parser parses cashuA (standard-Base64 JSON, rewritten
off Jackson onto kotlinx.serialization to satisfy quartz's no-Jackson rule)
and cashuB (Base64URL CBOR, reusing the V4Token models), returning quartz
types. It is the inverse of V4Encoder.
- move the CashuToken container model from commons to quartz, switching its
proofs from the duplicate commons Proof (field C, amount Int) onto the
canonical quartz CashuProof (field c, amount Long). The duplicate Proof
type is deleted.
- delete amethyst V3Parser/V3Token/V4Parser; CashuParser/CachedCashuParser
stay as thin amethyst adapters (off-main-thread guard + GenericLoadable +
LruCache) over the quartz parser.
- this removes the manual Proof -> CashuProof conversion shims that
MeltProcessor and CashuWalletViewModel previously carried.
Tests: full cashuA + cashuB vector coverage moves to quartz commonTest
(CashuTokenB64ParserTest, runs on JVM) plus an encode/parse round-trip; the
superseded amethyst CashuV4ParserTest is removed and CashuBTest stays as the
adapter integration test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
The NUT-00 v4 `cashuB` wire-format encoder + CBOR models lived in
`amethyst/service/cashu/v4`, even though they are pure protocol code with
no app/UI dependencies. quartz had the proof/mint primitives but no
`cashuB` string codec at all, so this was a layering gap rather than a
duplicate.
Move `V4Encoder` and the `V4Token`/`V4T`/`V4Proof`/`V4DleqProof` wire
models into `quartz/nip60Cashu/token`, rewriting the JVM-only
`java.util.Base64` to the multiplatform `kotlin.io.encoding.Base64.UrlSafe`
so the codec lives in commonMain. This unblocks sharing CashuWalletOps /
CashuWalletState into commons (V4Encoder was one of the Android-only ties).
- add kotlinx-serialization-cbor (Apache-2.0) to quartz commonMain
- repoint CashuWalletOps (encoder) and V4Parser (V4Token) imports to quartz
- add V4EncoderTest round-trip coverage in quartz commonTest
amethyst's V4Parser stays put: it returns UI types (GenericLoadable +
commons CashuToken) and now just reuses the relocated quartz V4Token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
Align Amethyst's road event cards with the roadstr reference clients
(https://github.com/jooray/roadstr) for both interoperability and a
richer presentation.
Interop:
- Match roadstr's exact emoji set: road_closure 🚫 (was ⛔) and
other ℹ️ (was 📍). The t codes and per-type TTLs already matched.
- Make the kind 1316 NIP-31 alt status-dependent ("Roadstr: event
confirmed" / "Roadstr: event denied") per the spec, instead of a
single "Roadstr: event confirmation".
Rendering:
- Colored teardrop map pin per category, using roadstr's exact color
palette, with the category emoji on the head (new MapPinIcon).
- Freshness: fade the report pin to 0.6 under 25% of effective TTL and
0.4 once effectively expired, matching roadstr's opacity rule.
- Subtitle meta line: "🕒 23m · expires in 1h" / "· Expired" on reports
and "🕒 23m" on confirmations.
- Confirmations get a green ✅ / red ❌ status pin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd
A batchSize <= 0 made the SQLite resumable reindex select no rows yet
never report done, so a caller's loop would spin forever. Clamp the page
size to at least one in both stores and add a regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
A full FTS rebuild can run for a long time on a big store, so add a
resumable, batched overload alongside the one-shot:
reindexFullTextSearch(resumeFrom: String?, batchSize): FtsReindexProgress
Each call processes ~batchSize events in its own write transaction and
returns an opaque cursor + done flag. The caller loops until done and may
stop at any point — the cursor is durable across crash/app-restart, and
the writer lock is released between batches, so "pause" is just "don't
make the next call". The path is additive/refresh and keeps search usable
throughout (no up-front wipe); the one-shot variant remains for a
guaranteed-clean rebuild.
- SQLite: FullTextSearchModule.reindexBatch walks event_headers ordered
by the monotonic row_id (a free, stable cursor), restricted to
searchable kinds, delete-then-insert per event so batches are
idempotent and never duplicate rows.
- Filesystem: FsEventStore walks one idx/kind/<k>/ dir per step (linear,
no re-sort); cursor is the next searchable kind. Idempotent linkFts, so
nothing is wiped. Pauses between kinds.
- Wrappers delegate; new FtsReindexProgress value type carries cursor +
progress + done.
- cli: `amy store reindex-fts` now loops the batched path to completion
and reports processed/batch counts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
The set of event kinds that implement SearchableEvent — and the text
each contributes via indexableContent() — is baked into the quartz
build, so it changes across app versions. Events stored under older code
keep their old (or missing) NIP-50 full-text-search rows, so search
silently misses them after an upgrade.
Add IEventStore.reindexFullTextSearch() so the app can wipe and rebuild
the FTS index from already-stored events when it has spare cycles.
Speed: only kinds that currently map to a SearchableEvent are scanned.
Kind alone selects the event class in EventFactory, so a single probe per
distinct kind is authoritative, letting us push a `kind IN (...)` filter
(SQLite) / skip whole idx/kind dirs (filesystem) so the non-searchable
bulk — reactions, zaps, follow lists — is never deserialised.
- SQLite: FullTextSearchModule.reindexAll drops+recreates the virtual
table (O(1) wipe) then streams only searchable-kind rows in one write
transaction, reusing a single INSERT statement.
- Filesystem: rebuilds only idx/fts/, driving the walk from
idx/kind/<searchable kind>/ via the new FsIndexer.linkFts.
- Wrappers (EventStore, ObservableEventStore, InterningEventStore)
delegate; the observable layer emits nothing since no event changes.
- cli: `amy store reindex-fts`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZqPFds2TPPUKkMmBngwys
ChatMessageEvent (kind 14) is the decrypted NIP-17 rumor; its content is the
plaintext message. Index it so DMs are locally searchable, consistent with
messaging-client search. Opt-in confirmed by the maintainer; private zaps
(9733) remain excluded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Restructure the experimental roadstr events to mirror the nip88Polls
file layout, and wire the kinds into LocalCache (previously unhandled,
so they hit the "Event Not Supported" fallback and were dropped).
- Split into report/ and confirmation/ sub-packages, each with
XEvent.kt + TagArrayBuilderExt.kt + TagArrayExt.kt + tags/, matching
nip88Polls/{poll,response}. build() now uses eventTemplate + typed
builder-ext functions; accessors delegate to TagArray ext functions.
- Bundle each enum into its tag class (RoadEventType in RoadEventTypeTag,
RoadEventStatus in RoadEventStatusTag), like PollType in PollTypeTag.
- Add RoadReportTag (a GenericETag `e`-reference) mirroring PollTag, and
have RoadEventConfirmationEvent implement EventHintProvider so the
referenced report is linkable — parallel to PollResponseEvent.
- Shared lat/lon/geohash tag codecs live in roadstr/tags/ with generic
builder/parse extensions.
- LocalCache: consume kinds 1315/1316 as regular events.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY
Zaps carry an optional human-readable message; index it cheaply:
- LnZapRequestEvent (9734): content (public zap comment). Private-zap messages
live encrypted in the `anon` tag, not content, so they stay out.
- LnZapEvent (9735): the comment is in the embedded zap request, which init{}
already parses into `zapRequest` unconditionally — so indexing
`zapRequest?.content` adds no extra parse cost.
- OnchainZapEvent (8333): content (optional message).
- NutzapEvent (9321): content (nutzap message).
LnZapPrivateEvent (9733) is left out: its content is a decrypted *private* zap
message (same privacy class as DMs) — deferred to an explicit opt-in.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Adds Quartz protocol support and Amethyst rendering for the Roadstr
decentralized traffic-reporting events (https://github.com/jooray/roadstr):
Quartz:
- RoadEventReportEvent (kind 1315) and RoadEventConfirmationEvent
(kind 1316), registered in EventFactory.
- RoadEventType (13 categories with client-side effective TTLs) and
RoadEventStatus enums; lat/lon/status tag codecs with 7-decimal
coordinate formatting; NIP-40 expiration + NIP-31 alt + multi-precision
geohash (4/5/6) tags.
- Generalize the GeoHash encoder/decoder into commonMain
(nip01Core.tags.geohash.GeoHash); the Android module keeps only the
Location <-> GeoHash glue.
- Unit tests for parsing, building, TTLs, coordinate formatting and the
geohash prefix hierarchy.
Amethyst:
- RenderRoadEventReport / RenderRoadEventConfirmation cards wired into
NoteCompose (feed) and ThreadFeedView/NoteMaster (thread), mirroring
the Birdex renderer pattern; reverse-geocoded location line and
localized category labels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY
Adds SearchableEvent to more text-bearing kinds:
- PollEvent (1068, NIP-88): question (content) + option labels
- CodeSnippetEvent (1337, NIP-C0): name, description + code
- GitReplyEvent (1622) and TorrentCommentEvent (2004): reply/comment content
- AudioHeaderEvent (1808): description (content)
- AudioTrackEvent (31337): subject
- InterestSetEvent (30015): title, description + public interest hashtags
(private hashtags in NIP-44 content are not indexed)
- SoftwareReleaseEvent (30063): release notes (content) — note: kind 30063 is
registered to NIP-51 ReleaseArtifactSetEvent in EventFactory, so this is for
completeness and not exercised at runtime.
CommunityRulesEvent (34551) was intentionally left out: it is the NIP-9B
machine-readable rules companion; the human-readable community rules live on
CommunityDefinitionEvent.rules(), which is already indexed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The test asserted exactly two EOSEs (`assertEquals(2, eoseCount)`) and
looped `while (eoseCount < 2)`. But a re-REQ on the same subscription id
silently replaces the previous subscription (NIP-01), and
RelaySession.handleReq cancels the in-flight query coroutine without
emitting an EOSE for the superseded filter. So when both mid-stream
re-subscriptions get collapsed before reaching EOSE, only the final,
never-superseded filter emits one — the consumer loop then blocks on
receive() until the 30s timeout and the assertion fails.
Drain until the relay goes quiet (idle-gap timeout) instead of counting
on a fixed number of EOSEs, and assert only invariants that hold for
every interleaving: at least one EOSE arrives, every non-EOSE entry is a
valid 64-char id, the final advertised-relay-list filter actually
streamed its events, and the total stays under a sane upper bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e1bfPf4HZDCvwcfiEHECk
Adds SearchableEvent to the rest of the event kinds that carry
human-readable fields, indexing only natural-language values (no structural
labels), consistent with the existing implementations.
Public/discovery: relay groups (NIP-29), file/media headers (NIP-94), web
bookmarks (NIP-B0), podcasts (NIP-F4 episode + show), static sites (NIP-5a),
live clips and meeting rooms (NIP-53), contact cards (NIP-85), and the
NIP-51 curation sets (article/video/picture/app curation, media starter
pack, release artifacts).
Personal lists (NIP-51): bookmark/labeled-bookmark/follow/people lists and
relay sets — indexes the list's own title/description so users can find
their lists by name.
Experimental: software apps (NIP-82), fundraisers, interactive stories,
workout records, birdex, attestations, NIP-95 file storage header, profile
gallery.
content is indexed only where it is confirmed human-readable prose
(file caption, podcast notes, clip caption, app/fundraiser/story/workout
body). It is excluded where content is HTML (static sites), base64 (NIP-95),
NIP-44 encrypted (contact cards), an encrypted/JSON private-tag blob (lists),
or always empty — those index their parsed tag fields only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The "title: ", "summary: ", "name: ", "Subject: ", "Option: " etc. prefixes
were tokenized into the single FTS content column as literal words, so every
event of a type matched bare terms like "title" or "summary" and the index
carried useless tokens — the same field-name pollution we avoid for JSON
kinds. The FTS table has one content column and search is plain-text MATCH,
so the labels enabled no fielded search; they were pure noise.
Index bare field values instead (listOfNotNull(...).joinToString("\n")),
which also drops the "null" token the older one-liners produced for absent
fields. Applied across all SearchableEvent implementations for consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Kinds 0 (profile), 40/41 (channel create/metadata) and 31990 (app handler)
store their data as JSON in content. Implement SearchableEvent on them by
parsing the JSON (via the existing UserMetadata/ChannelData/AppMetadata
accessors) and indexing only the meaningful fields — names, bio/about, and
the addresses people search by: nip05 email, lightning addresses
(lud06/lud16), and website/picture/banner URLs. This avoids indexing the
JSON keys and structural punctuation that raw-content indexing would add.
Updates the FsSearchTest "non-searchable" case to use an unknown kind, since
MetadataEvent is now searchable, and adds SearchTest coverage for profile
and channel JSON fields (name, about, email, lightning, URL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Reverts the event_fts rowid-alignment refactor and the background reindex
that it required. Aligning the FTS rowid with event_headers.row_id was a
schema change, which forced a v2->v3 migration to rebuild the index from
~all cached events — and on large caches that reindex was the expensive,
risky part (slow startup, all-or-nothing transaction, resumability and
malformed-row concerns). The cleanup it bought (not tokenizing the numeric
foreign key into the index) isn't worth that cost.
Restores the original design: event_fts keeps its dedicated
event_header_row_id column, queries join on it, DATABASE_VERSION stays 2,
and there is no FTS migration or reindex at all.
Kept: the newly searchable event kinds (they implement SearchableEvent and
work unchanged with the original table) and their test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The v2->v3 upgrade previously rebuilt the entire full-text index inside
the migration transaction. With a large cache (e.g. 100k events) that
blocked every DB operation behind a single long transaction at startup:
the app appeared frozen, risked an ANR if reached on the main thread, and
— because it was all-or-nothing with the version bumped only on success —
a crash, kill, or one malformed cached row could roll everything back and
retry from scratch on every launch (worst case: an unrecoverable boot loop)
while the WAL ballooned.
Decouple the reindex from the migration:
- The migration now only recreates the empty FTS table and writes a
persistent `fts_reindex` marker holding a progress cursor, then bumps the
version. It is cheap and atomic.
- A background coroutine (Dispatchers.IO, cancelled on close()) backfills the
index from event_headers in small committed batches via useWriter, so live
relay inserts/queries interleave between batches instead of waiting.
- Backfill is idempotent (INSERT OR IGNORE), resumable (cursor persists, so a
kill resumes on next launch), and resilient (a row that fails to parse/index
is skipped while the cursor still advances — no stuck retries).
Search is merely degraded (partial results) until the backfill finishes,
never blocked. Adds a test covering backfill + marker clearing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Several event kinds carry human-readable text (titles, summaries,
descriptions, names, free-text content) but were never added to the
full-text search index. Implement SearchableEvent on them so their
natural-language fields become searchable, while keeping non-prose data
(hex ids, URLs, relay hints, hashtags, geohashes, JSON config) out of the
index.
Kinds added:
- Classifieds (30402): title + summary + content
- Calendar (31924) + date/time slots (31922/31923): title + summary + content
- Community Definition (34550): name + description + rules
- Live Activities (30311): title + summary + content
- Meeting Space (30312): room + summary
- Status (30315): content
- Picture (20) and Video (NIP-71, all variants): title + content
- Goal (9041): summary + content
- Torrent (2003): title + content
- Git Repository (30617): name + description
- Git Pull Request (1618): subject + content
- Git Patch (1617): content
- Badge Definition (30009): name + description
- Emoji Pack (30030): title + description
- Feed Definition (31890): title only (content is JSON config)
JSON-content kinds (profile, channel, app handler) are intentionally left
out for now since they require parsing the content JSON to extract only
the natural-language fields. Existing v2->v3 FTS reindex repopulates the
index for already-cached events of these kinds on upgrade.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The FTS table declared event_header_row_id as a regular full-text column,
which means the numeric foreign key was tokenized into the searchable
index — a bare MATCH could match an event by its internal row id, and the
column wasted index space.
Drop the dedicated column and instead align the FTS table's implicit
rowid with event_headers.row_id at insert time, joining on it (rowid
joins are also the fastest possible). This works across fts3/4/5.
Also make FullTextSearchModule.drop() remove its trigger explicitly so
the module is self-contained, and add a v2->v3 migration that rebuilds
the FTS index in place from event_headers, preserving the cached events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
ZapPollEvent.indexableContent() concatenated a List onto a String, so
String.plus(Any?) appended the list's toString() — leaking literal "[",
"]" and ", " separators into the full-text index
(e.g. "Best color?[\nOption: Red, \nOption: Blue]"). Build the string
explicitly so only the natural-language poll descriptors are indexed,
matching the buildString style used by the music events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Adds two user-facing teardown options to the Cashu wallet settings:
- "Stop receiving nutzaps": replaces kind:10019 with an empty event (the
durable signal, honored by every relay since it's a replaceable-event
replacement) and then NIP-09 deletes it (best-effort, since deletions are
optional on Nostr). The wallet and balance are untouched.
- "Delete wallet": withdraws the nutzap advertisement as above, then NIP-09
deletes the kind:17375 wallet definition. Held kind:7375 proofs are not
deleted (the ecash still exists at the mint), with a UI warning that any
remaining balance / unredeemed nutzaps may become unrecoverable.
The on-disk backups of kind:17375 / kind:10019 are cleared when those events
are deleted, so a relaunch doesn't resurrect a deleted wallet from settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew