- Use the app's cached stringRes helper instead of raw stringResource,
matching the dominant convention in ui/note/types
- Drive the icon animation from the Compose frame clock (withFrameMillis)
instead of a delay loop, so the ticker suspends whenever the composition
stops drawing rather than waking the main dispatcher 4x/sec from the
back stack
- Drop the unconsumed memoryCardId/blockState/blockHash accessors; the
tag schema stays documented in the class KDoc
- Document the frames arrays as frozen: mutating them in place would
silently break the @Immutable skip contract; build a new instance
to change pixels
- Replace the API-29-deprecated Bitmap.createBitmap(IntArray, ...)
overload with createBitmap(w, h, config) + setPixels
Bulk NDJSON import/export as first-class geode subcommands, mirroring
`strfry import` / `strfry export` (one JSON event per line — the interchange
format for seeding a relay, migrating between relays, or taking a backup):
geode import [--db …] [--no-verify] [FILE…] # files, or stdin when none
geode export [--db …] # NDJSON to stdout
Both stream — memory is bounded to one batch (import) / one event (export), so
a multi-million-event corpus round-trips in roughly constant memory. `import`
verifies signatures by default (same `Event.verify()` the relay's VerifyPolicy
uses), upholding the relay's verify-by-default stance rather than trusting the
file; `--no-verify` is the trusted-input escape hatch. Verb dispatch is
backward-compatible: a bare `geode --port …` (no verb) still serves.
This makes the benchmark-only `CorpusServerMain` redundant — a corpus source is
now just `geode import` into a DB, then a normal `geode` serve — so it's
deleted, removing benchmark-only code from the production geode artifact (the
question that started this). The 1M sync-throughput plan is updated to describe
sources via `geode import` + serve.
Also fixes a native-target CI break: MergeQueryCorrectnessTest used the
deprecated `String(CharArray)` (error-level on Kotlin/Native) — switched to
`CharArray.concatToString()`.
Verified end-to-end through the packaged `geode` binary: import (file + stdin,
--no-verify), export round-trip, and verify-on rejecting bad signatures.
ImportExportTest covers the counts, duplicate handling, malformed-line
skipping, and verify accepting a freshly-signed event while rejecting bad sigs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
MergeQueryExecutor's winner-picker tie-breaks equal created_at by id ASC,
but each per-stream cursor sorted by created_at DESC only, and repeated
authors/kinds opened duplicate cursors:
- id tie-break: thread the IndexingStrategy through run()/prepareStreams
and append ", id ASC" to the per-stream ORDER BY when
useAndIndexIdOnOrderBy is set — matching every sibling query in
QueryBuilder. The id-indexed order comes straight off the index (no
extra sort, lazy cursor preserved), so the merge now matches the
single-SQL path byte-for-byte on same-second same-author events. Without
the id index the tie stays in rowid order (a valid NIP-01 newest-N);
documented on the class.
- dedup: streamCount/prepareStreams now operate on distinct authors and
kinds, so a filter with a repeated pubkey can no longer open two
identical cursors and emit each matching event twice (the single-SQL
IN(…) path already dedups).
Adds two MergeQueryCorrectnessTest cases the suite was missing: a
within-stream same-second tie sliced by the limit, and duplicate authors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
Op instances key Timestamp.ops (MutableMap<Op, Timestamp>), so contract
violations corrupt hash-map behavior:
- OpKECCAK256 defined equals without hashCode, so equal instances hashed
by identity — two equal keys could land in different buckets, producing
duplicate branches or failed lookups in keccak256 timestamp trees. Add
hashCode = TAG, mirroring OpSHA1/OpSHA256/OpRIPEMD160.
- OpBinary defined hashCode without equals — and its TAG referenced
Op.TAG (0x00), a no-op XOR. Define the equals/hashCode pair once on
OpBinary using tag() and drop the duplicated overrides from
OpAppend/OpPrepend (behavior unchanged: same tag + same arg content).
- VerifyResult.equals cast without a type test (ClassCastException on
foreign types instead of false) and hashCode force-cast the nullable
timestamp (NPE for null-timestamp results). Convert to a data class;
the custom toString and compareTo stay.
The `rawQueryPathMatchesDecodedQuery` case called `store.rawQuery(filter)`, but
`EventStore` only exposes the streaming `rawQuery(filters, onEach)` — the
list-returning overload lives on the inner `SQLiteEventStore`. Point the check
at `store.store.rawQuery(filter)` so the zero-decode path is actually exercised.
Record the shipped k-way merge result in the plan doc: a fresh 1M relayBench run
has geode `follow-feed` at 18.8 ms vs strfry 17.7 ms (down from 97.7 ms, now at
parity) and 46,258 ev/s vs strfry 15,365 @8conn, both returning the same 500.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
The home-feed REQ (`authors=[…] (+ kinds=[…]) [+ since/until] limit=N`,
newest-first) is one of the most common relay queries. SQLite serves it by
seeking every `(kind, pubkey)` combo and feeding *all* matching rows through a
LIMIT-bounded sorter, so it reads O(the followed set's whole matching history)
— on a cold on-disk 1M corpus that was the `follow-feed` regression (relayBench:
97.7 ms vs strfry 17.6 ms).
Add `MergeQueryExecutor`, an app-level k-way merge that opens one lazy
newest-first cursor per stream off the existing composite indexes
(`query_by_kind_pubkey_created`, or `query_by_pubkey_created` for authors-only),
merges their heads `(created_at DESC, id ASC)` and stops at the limit — reading
only O(limit + streams) rows regardless of how much history the authors have.
It reuses indexes that already exist, so write throughput and on-disk size are
untouched. Eligibility is narrow (2..2048 streams, simple filter, explicit
limit, no ids/d-tags); everything else falls through to the single-SQL plan.
Wired into both `query` and the zero-decode `rawQuery` paths (the relay REQ hot
path) and the single-element filter-list variants, so `LiveEventStore` REQs go
through it.
`MergeQueryCorrectnessTest` proves the merge returns exactly the single-SQL
top-N — vs an independent Kotlin reference and vs the SQL path — across
distinct/tied created_at, since/until windows, authors-only, fewer-than-limit,
streaming onEach, and the raw path. `FollowFeedReadBenchmark` gains a `merge`
variant: at 1.05M events it's flat ~10-12 ms across both prolific-recent and
sparse-old, where `scan` is catastrophic on sparse follows (1995 ms) and
`current` is disk-bound on prolific ones.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
- only parse commonName when the alt tag has the Birdstar prefix
- summary() on both Birdstar events now uses the canonical NIP-31
tags.alt() helper instead of a raw firstTagValue("alt") lookup
- speciesReference() only returns http(s) URLs since UIs render it as
a clickable link (rejects e.g. javascript: schemes), with a test
- Detection card: parse tags once into a single remember slot, drop
the near-dead '?: summary' title fallback, stop rebuilding the
italic TextStyle every recomposition
- Hoist the duplicated bird-emoji literal into a shared BIRD_PREFIX
- Trim the redundant factory test to the assertIs idiom
- fetch Birdex life lists in home and profile relay REQs
- richer Birdstar cards — common name title, Wikidata link, bird emoji
- surface Birdstar bird detections in home and profile feeds
ingest() bypasses the per-connection policy chain, which is where
VerifyPolicy lives. The IngestQueue verify hook only exists when
parallelVerify is true, so with parallelVerify = false and
skipVerify = false, ingest() previously verified nothing — an untrusted
mirror upstream on a relay running the legacy in-policy verify path could
inject forgeries. ingest() now verifies inline in that configuration
(same rejection reason as the queue), so the documented "default keeps
verify-everything semantics" holds regardless of parallelVerify. KDoc
also spells out that ingest() skips the entire policy chain (blacklists,
size limits), which callers must screen for themselves.
Test covers the parallelVerify = false server: forged rejected, trusted
skip and valid still land.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Audit follow-ups on the live NIP-77 index, all with the store/scan
equivalence test extended to cover them:
- Same-batch replaceable displacement left a dead id in the index.
applyAfterCommit applied all removes before all adds, so when a later
row in one transaction displaced an earlier row of the same batch (two
versions of one replaceable — the mirror-backfill hot path), the
displaced row's remove no-op'd against an index that hadn't taken its
add yet, then the add re-inserted it: the index advertised an id the
trigger had already deleted. recordAccepted now cancels the pending add
instead of queueing a remove (added is a LinkedHashSet for O(1)
cancel).
- A kind-5 that deleted nothing (a delete broadcast for events this relay
never stored — the common case) still invalidated the whole index,
forcing a full-scan rebuild under the writer mutex on the next
NEG-OPEN. DeletionRequestModule.insert now returns the rows it deleted;
recordAccepted only invalidates when that count is > 0, else records the
kind-5 as a plain row.
- The first NEG-OPEN over a corpus larger than the serve cap scanned the
whole table uncapped, built a full index that could never produce a
snapshot, and then maintained it forever for zero benefit.
liveNegentropySnapshot now caps the rebuild scan at maxEntries + 1 and
leaves the index unpopulated when the corpus is over-cap (the scan path
answers NEG-ERR, as before).
- delete/deleteExpired/clearDB invalidate() moved inside the writer mutex
so no NEG-OPEN can seal a snapshot of just-deleted rows, and no
concurrent rebuild can be discarded by a late invalidate.
Also corrects the ~40 B/event heap figure to ~140 B (IdAndTime keeps the
id as a 64-char hex string, not 32 bytes) in the strategy/plan docs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Backlog items 4-5 plumbing, config-gated and off by default so quartz
library defaults stay untouched for the app-side stores:
- quartz: SQLiteEventStore/EventStore accept extraPragmas (applied on
every pooled connection AFTER the built-in configuration, so they
can override it) and expose optimize() — an analysis_limit-bounded
PRAGMA optimize for incremental planner-stats refresh.
- geode: [database] readers / mmap_size / temp_store_memory map onto
the store; optimize_interval_seconds drives a maintenance coroutine
(cancelled first in the shutdown hook, before the store closes).
The A/B verdict on whether the example config should RECOMMEND any of
these on container-class hardware follows in the next commit — the
knobs themselves are operator tools worth having either way, since
mmap/temp-store value is hardware-dependent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Reverts the queryRawInline fast path (fb29d655, 1b786f31) per the
keep-only-winners rule. Three relayBench runs at 50k (baseline, cap-256
where the path never engaged, cap-512 where author-archive/by-ids/
500-limit feeds genuinely took it) showed no movement outside the
container drift band — strfry's own numbers drifted ±30% between runs
and inline-eligible scenarios moved the same as ineligible ones.
The in-process win was real but small (~17%, 0.60 -> 0.50 ms per
~21-row REQ); the wire-level p50 is 1.2-1.7 ms, so the missing ~1 ms
per REQ sits in the transport (Ktor frame send path + client round
trip) — backlog item 6 territory, not dispatch. Findings, numbers, and
the do-not-retry note live in quartz/plans/2026-07-04-small-req-floor.md;
SmallReqFloorBenchmark stays as the measurement tool.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Backlog item 2 (small-REQ dispatch floor), first cut. relayBench at 50k
shows geode ~2.5x slower than strfry when a REQ returns ~20 events
(author-archive 1.18 vs 0.48 ms EOSE p50) while WINNING the 500-event
scenarios — with tiny results the fixed per-REQ cost dominates. A new
stage benchmark (SmallReqFloorBenchmark) decomposes the floor: at ~21
rows, raw SQL is 0.18 ms and the remaining ~0.42 ms is live-subscription
machinery plus per-REQ coroutine dispatch.
A REQ whose filters all carry a limit summing to <= 256 now runs its
stored replay inline on the receive coroutine and only retains a
live-tail handle (SessionBackend.queryRawInline; LiveEventStore's
queryRaw is re-expressed on the same core) — no per-REQ launch, no Job,
no dispatcher handoffs, no awaitCancellation scaffolding. Unbounded
REQs keep the launched path so CLOSE can always interrupt a giant
replay. In-process time-to-EOSE for ~21-row REQs drops 0.60 -> 0.50 ms;
the bigger effect expected under concurrency (no per-REQ Job+dispatch
churn) is relayBench's to judge.
InlineReqFastPathTest pins wire-behavior parity: stored-then-EOSE
ordering, live tail delivery and CLOSE detachment, same-subId
replacement, launched fallback for unbounded and over-cap REQs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Milestones 2-3 of quartz/plans/2026-07-03-incremental-negentropy-storage.md.
SQLiteEventStore now maintains the LiveNegentropyIndex when the strategy
opts in (geode's does by default; [negentropy].live_index = false turns
it off; app-side stores are untouched):
- Write paths collect a LiveIndexDelta and apply it after COMMIT while
still holding the writer mutex — rolled-back savepoint rows never
reach the index and updates land in exact commit order (also vs the
rebuild, which runs under the same mutex).
- Replaceable/addressable overwrites report the row their BEFORE-INSERT
trigger displaces via one indexed pre-SELECT that mirrors the trigger
predicate (including the NIP-01 lowest-id tie-break and the
d_tag-NULL case).
- Paths that can't itemize (kind-5, vanish, delete-by-filter/id,
expiration sweeps, clearDB) invalidate; the next NEG-OPEN rebuilds
from one scan on the writer connection.
- Until that first NEG-OPEN populates the index, ingest pays zero
bookkeeping — the populated check happens under the writer mutex so
it can't race the rebuild.
LiveEventStore serves a single unconstrained filter (the relay-relay
sync default; relayBench sends exactly this) from the index; everything
else keeps the scan+seal path and its single-slot cache.
Micro-benchmark at 50k events (LiveNegentropyBenchmark, in-container):
scan+seal cold path 80-100 ms; index post-write open 9-16 ms (~5-10x).
The relayBench A/B is the acceptance gate and comes next.
Correctness: LiveNegentropyIndexStoreTest asserts index content ==
snapshotIdsForNegentropy scan after every mutation pattern (overwrites,
losers, kind-5 rebuilds, filter deletes, mixed-outcome batches,
transactions); the full geode suite (NIP-77 + interop sync tests) runs
with the index on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Milestone 1 of quartz/plans/2026-07-03-incremental-negentropy-storage.md.
Sorted array with binary-search insert (near-tail in the common case),
itemized remove for displaced rows, wholesale invalidate for delete
paths that can't itemize, and sealed snapshots memoized per mutation
generation — reconcile only reads, so one snapshot backs any number of
concurrent sessions and stays immutable under later writes. Over-cap
answers null so the caller keeps the strfry-parity NEG-ERR.
Not wired into any store yet; next milestones plumb displaced-row
deltas from the SQLite modules and serve index-total filters from it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Adds a public local-ingest entry to NostrServer for events that don't
arrive over a client connection (mirror/sync workers, import jobs). It
routes through the same group-commit IngestQueue and live fanout as a
client publish, and each Submission can opt out of the parallel
Schnorr-verify hook — the relay-to-relay trust model, for events
streamed from an upstream that already verified them (verify profiles
at ~8% of busy ingest CPU).
Library defaults are unchanged: skipVerify defaults to false everywhere
and nothing in the client-publish path can set it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
Raw key:value tokens like include:spam reached SQLite FTS MATCH, where
the colon is column-filter syntax — any REQ carrying an extension token
died with CLOSED "no such column: include" instead of matching.
Adds SearchQuery.stripExtensions() plus Filter/List<Filter>
.strippingSearchExtensions() so EventStore users can drop the tokens
before querying, and applies them in LiveEventStore (query, count,
negentropy snapshots). Per NIP-50, unsupported extensions are ignored:
an extensions-only search becomes unconstrained, not match-nothing.
EventSource-backed search relays still receive the raw string since a
real search backend wants the extensions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tujoyfc2kNLZNVgiLZAR7F
BasicRelayClient's decode step becomes a pluggable MessageDecoder
(default unchanged). The opt-in CachingEventDecoder scans EVENT frames
for their id (~0.3us, JSON-escape-safe so embedded event JSON in repost
content cannot confuse it; any irregularity falls back to full parse)
and on a cache hit synthesizes the EventMessage from the already-parsed
Event with the frame's own subId — every subscription still gets its
delivery and per-relay bookkeeping is unchanged; only the redundant
parse is skipped. Production traffic measured 14-57% duplicate frames.
DedupDecodeBenchmark (60k frames, 67% dups): 10.0us/frame full parse vs
1.5us/frame cached — 6.5x, with an in-benchmark assertion so the gain
is enforced. 7 scan-safety unit tests in CachingEventDecoderTest.
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
Quartz already funnels every diagnostic through the `Log` facade, but the
sink was hardcoded per platform (android.util.Log / System.err / NSLog /
println), so a consuming app couldn't route Quartz logs into its own stack
(Timber, SLF4J, Crashlytics, a file, a test buffer, or /dev/null).
Add a `LogSink` fun interface and a replaceable `Log.sink`, defaulting to
`PlatformLogSink` which reproduces the historical per-platform behavior.
All ~225 call sites and the `Log.*` signatures are unchanged; the lazy
`() -> String` overloads still short-circuit on `minLevel` before the
lambda runs, preserving the allocation-free fast path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK3TrDkP1EXj1d62oKJMdc
Fold the full-text-search on/off switch into `IndexingStrategy` as
`indexFullTextSearch` (default `true`) instead of a separate top-level
`enableFullTextSearch` constructor param on `EventStore`/`SQLiteEventStore`.
`IndexingStrategy` is already the single place that decides which indexes
the store builds — every field is a per-index toggle with a size/speed
tradeoff, and `QueryBuilder` already receives it — so FTS, being just
another index, belongs there rather than split across two config surfaces.
Behaviour is unchanged: the module's no-op path and the QueryBuilder
"search matches nothing" guards now read the flag via the strategy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
Add an `enableFullTextSearch` flag (default `true`) to `EventStore` and
`SQLiteEventStore` so deployments that never serve NIP-50 search from
SQLite — e.g. a relay that offloads search to an external engine like
Vespa — can skip the full-text-search write cost.
When disabled:
- `FullTextSearchModule` becomes an inert no-op: the `event_fts` virtual
table and its `fts_foreign_key` delete trigger are never created,
inserts skip `indexableContent()` + tokenization, and both reindex
entry points return immediately.
- `QueryBuilder` short-circuits any query/count/delete filter carrying a
non-empty `search` term to a "matches nothing" result (an empty-string
search still imposes no constraint), so no SQL ever references the
absent `event_fts` table. In a multi-filter union the search branch
contributes nothing while the other filters resolve normally.
Everything else (replaceable/addressable handling, deletions,
expirations, right-to-vanish, negentropy) is unchanged, and the default
keeps FTS on for existing callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
Kotlin/Native (the iOS test target) rejects commas in backtick function
names, so test-quartz-ios failed to compile even though jvmTest — which
allows them — passed. Rename the two offending tests. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Relay operators wiring up a Quartz-based relay had to hand-write the
NIP-11 document as a large JSON string, which is error-prone and drifts
from the model. Add a DSL builder so they can describe the document in
Kotlin instead:
val info = relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50)
}
call.respondText(info.toJson(), ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
The builder covers every field, with nested `limitation { }` / `fees { }`
DSLs, repeatable list helpers, a `retention(...)` entry adder, and a
`limitation(RelayLimits)` overload that advertises exactly the limits the
relay enforces so the two can't drift.
Also fix FlexibleIntListSerializer to emit numeric `supported_nips` as
JSON integers ([1,11,42,50]) instead of quoted strings, matching the
NIP-11 spec; non-numeric ids still fall back to strings. Geode's default
document now builds via the DSL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C64Yy2d3na7Y28u7GRrHMX
Introduce OptionalAuthPolicy, a relay-server policy that runs the full
NIP-42 challenge/verify handshake — emitting the AUTH challenge on connect
and recording verified pubkeys into the connection scope — but never
requires it: EVENT, REQ, and COUNT are always accepted, so clients that
ignore the challenge keep working.
It subclasses FullAuthPolicy and only relaxes the EVENT/REQ/COUNT gates, so
the authorize() hook and per-connection authenticatedUsers set behave
identically; downstream policies can still gate or rewrite on caller
identity. Wire it into geode via an optional_auth config option and a
--optional-auth CLI flag (ignored when require_auth/--auth is set, since
mandatory AUTH already sends the challenge).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJWy4dBBYLbqthhLvbcZh7
When a Podcasting-2.0 person's href points at an npub/nprofile (bare,
nostr: URI, or an njump-style link), upgrade the free-text credit to a real
Nostr profile: the standard ClickableUserPicture + UsernameDisplay, tappable
through to the profile. Plain web links keep the free-text card with the
default profile-image loader.
- quartz: PodcastPerson.nostrPubKey() resolves href → pubkey via Nip19Parser
(npub/nprofile only). Covered by PodcastPersonSoundbiteTest.
- UI: PodcastPeople branches per person — LoadUser + standard profile
components for nostr identities, free-text card otherwise — sharing one
card scaffold so both look identical in the Hosts & Guests strip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Adds two Podcasting-2.0 features to the podcast stack:
Persons (podcast:person) — hosts/guests as free-text credits with role,
avatar, and link (not necessarily Nostr users):
- quartz: PodcastPerson model; PersonTag ["person", name, role, img, href]
on kind 30054 episodes; a persons[] array in the kind 30078 show JSON.
Exposed via PodcastEpisode.episodePersons() / PodcastShow.showPersons().
- UI: PodcastPeople — a "Hosts & Guests" avatar strip (robohash fallback,
tap opens href), shown on the episode card and the show header.
Soundbites (podcast:soundbite) — highlight clips:
- quartz: PodcastSoundbite model; SoundbiteTag ["soundbite", start, dur,
title?] on episodes; PodcastEpisode.episodeSoundbites().
- UI: PodcastSoundbites — "jump to the good part" chips under the audio
player that seek the live media controller to the clip's start.
Both parse leniently, round-trip through build(), and are covered by
PodcastPersonSoundbiteTest. NIP-F4 returns empty for both (no such tags),
so nothing renders there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Someone is flooding thousands of identical mock NIP-F4 show-metadata events.
They share an exact fingerprint — title "Mock Podcast", description and content
both "Headless test feed" — so match that and refuse to cache them.
- PodcastMetadataEvent.isMockSpam() encodes the fingerprint (all three fields
must match, so a real show sharing one field is never flagged); unit-tested.
- LocalCache's PodcastMetadataEvent consume branch returns without storing when
it matches, so the spam never reaches the cache, feeds, search, or the merged
podcast list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
NIP-89 handler cards (kind 31990) previously only surfaced the supported
kinds and platform links, and even the platform links were silently dropped
when the link tag had no entity type. This widens both parsing and display:
- Fix PlatformLinkTag.match to accept 2-element link tags (entity type is
optional per NIP-89), so e.g. NostrHub's `["android", "intent:..."]` links
are no longer discarded.
- Parse the `i` supported-NIP tags (NostrHub points them at the NIP spec
markdown files) into a new SupportedNipTag, plus accessors for `t`
categories, `a` related addresses, and the `client` tag.
- Extend AppDefinitionEvent.build() with categories/supportedNips/
relatedAddresses/client so creation stays symmetric with parsing.
- Render the new data in RenderAppDefinition: category chips, compact
tappable rows for related addressable events (source repo, store listing,
...), a "via <client>" line, and NIP chips with a "+N" overflow that opens
a bottom sheet listing every supported NIP linking to its spec.
The deprecated `alt` tag is intentionally not surfaced on the handler card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
Adds the streaming half of Podcasting-2.0 value-for-value: a "Stream sats"
toggle on the episode player that, while on, pays the value split once per full
minute of playback at a chosen sats/minute rate (boostagram action "stream").
The hard requirement is that it must never pay while the user isn't listening,
so accrual is bound tightly to genuine playback rather than a free-running timer:
- The control lives inside the player composable, so navigating away, scrolling
it out of a feed, or tearing down the screen disposes it and stops streaming.
- Each second the engine re-reads the live MediaController.isPlaying and only
accrues when audio is actually playing and there's no playback error. The
player already pauses itself on background / off-screen / audio-focus loss /
error, so every one of those halts accrual for free. A released controller
reads as not-playing (guarded).
- Only whole, actually-played minutes are billed; a partial minute is dropped
when the session ends (never rounded up). This rule is a pure, unit-tested
unit (PodcastStreamingAccrual).
- The toggle defaults OFF and uses plain remember (not rememberSaveable), so it
never silently resumes after a rotation or process death — the user re-opts in.
- Streaming is gated to an in-app wallet (NWC / CLINK debit); we never auto-fire
an external wallet intent every minute. Per-minute errors are swallowed (no
toast spam) while one-off boosts still surface errors.
The selected rate is always shown on the toggle and a live "streamed N sats this
session" counter makes the spend visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Adds payment execution to the V4V value blocks that were previously
display-only. A "Send value" button on the value card opens the account's
zap-amount picker; choosing an amount fans the weighted shares out to every
recipient, mirroring how a NIP-57 zap-split is paid.
quartz (pure, tested):
- PodcastValue.computeShares() — splits a total across recipients by relative
weight, honoring `fee` recipients that take their split as a percent off the
top. Returns PodcastValueShare (recipient + millisats).
- PodcastBoostagram — the satoshis.stream keysend metadata blob carried in TLV
record 7629169, with the registered field names and unset fields omitted.
- PODCAST_TLV_RECORD / TYPE_NODE / TYPE_LNADDRESS constants.
amethyst:
- V4VPaymentHandler — the execution engine. lnaddress recipients resolve to a
BOLT-11 via LNURL-pay and pay through the user's default source (NWC, CLINK
debit, or external wallet intent), same rails as a zap. node recipients pay
by NWC keysend (pay_keysend) carrying the boostagram TLV plus any per-recipient
custom TLV; keysend is NWC-only, so node recipients are skipped with a clear
error when no NWC wallet is configured.
- AccountViewModel.payV4V() wrapper + the "Send value" amount picker on the
value card, wired for both episode and show value blocks.
V4V recipients are raw Lightning destinations, not Nostr users, so there is no
zap request and no zap receipt — just the payment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
A show's kind:10154 metadata can name any pubkey as an author (host, co-host,
editor) via `p` tags, but those claims are unverified — the show can list
anyone. NIP-F4 lets the named author publish their own kind:10064
AuthoredPodcastsEvent listing the podcasts they actually author, which closes
the loop.
On the single-podcast header, render each claimed author as a row (avatar,
name, role) and cross-check it: the author's 10064 is fetched + observed
lazily via observeNoteEvent, and a "verified" check badge appears only when
that 10064 lists this podcast's pubkey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Turn the episode "Chapters" affordance from a link-out into an inline,
timestamped chapter list.
quartz:
- PodcastChapters / PodcastChapter (@Serializable) parse the off-event
podcast-namespace chapters.json (version + startTime/title/img/url/toc).
Lenient parse with a malformed-input test.
amethyst:
- PodcastChaptersSection fetches the chapters document with the app's
preview HTTP client (Tor/proxy aware) off the main thread and renders
`timestamp — title` rows in a tinted card; empty/failed renders nothing.
- The episode card's "Chapters" chip now toggles this section instead of
opening the URL. Fetch is lazy — gated behind the toggle — so scrolling a
feed never triggers network.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
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
Verification of kind:1111 comments on podcast episodes: they already work
end-to-end (parse, build, route to the thread screen, thread assembly, composer,
and the reply/reaction subscriptions all treat any kind as a valid root — nothing
gates on the RootScope marker). Added a quartz test that drives the real
CommentEvent.replyBuilder path and asserts:
- a comment on a Podcasting-2.0 episode (30054) roots on its `a`/`A` address,
- a comment on a NIP-F4 episode (54) roots on its `e`/`E` event id,
- both are kind:1111 and carry the root-kind tag.
Also closes a small consistency gap: every other commentable content type
(articles, all video kinds, pictures, highlights, wiki, polls, …) implements the
RootScope marker, but the podcast events did not. Add it to PodcastEpisodeEvent,
Podcasting20EpisodeEvent and Podcasting20TrailerEvent. Harmless today (no code
does `is RootScope`), but it documents intent and future-proofs any such check.
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
Parse the episode tags podstr emits beyond the basics — video, episode
number, season, transcript and chapters — and surface them in the UI.
quartz:
- Add VideoTag, EpisodeNumberTag, SeasonTag, TranscriptTag, ChaptersTag plus
accessors and builder DSL on Podcasting20EpisodeEvent.
- Extend PodcastEpisode with episodeVideo / episodeNumber / episodeSeason /
episodeTranscriptUrl / episodeChaptersUrl as interface defaults, so NIP-F4
needs no change. PodcastAudio is now documented as audio-or-video media.
- Tests for round-trip + interface access and an all-absent default case.
amethyst:
- Extract shared PodcastBadge / PodcastLinkChip (PodcastChips.kt) and reuse
them in the show card for visual consistency.
- Episode card: a season/episode badge, a "Video" badge, and Transcript /
Chapters link chips that open the off-event documents; the player now falls
back to the video source when an episode ships no audio.
- Compact show-page row: an "S2 · E5" prefix on the date line and the same
audio→video media fallback.
Read-only. New strings for season/episode, video, transcript and chapters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Parse and render the Podcasting-2.0 show fields the kind:30078 metadata carries
beyond the basics:
quartz:
- Extend PodcastShow with author, categories, funding URLs, explicit, complete
and copyright as interface defaults — so NIP-F4 (kind:10154) needs no change
and just returns empties, while Podcasting-2.0 overrides them.
- Podcasting20PodcastMetadata now parses categories, funding[], copyright,
type, complete, locked, email and guid from the JSON content (value/V4V is
still skipped). Covered by expanded tests incl. an all-absent default case.
amethyst:
- Rebuild the podcast metadata card: an author byline, tinted pills for
Completed / Explicit / genre categories, a prominent filled "Support the show"
button opening the funding URL, clickable website chips with a globe icon, and
a subtle copyright footer — all Material3, no new icons/font subset needed.
Read-only. New strings: podcast_explicit/completed/premium/support_show/by_author.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Companion to the episode merge: unify show-level metadata across both drafts.
quartz:
- Add a spec-neutral PodcastShow interface (title/image/description/websites).
- NIP-F4 PodcastMetadataEvent (kind:10154) implements it directly.
- Add Podcasting20PodcastMetadata, a read-only view over a kind:30078 NIP-78
app-data event with d="podcast-metadata" whose channel fields live in a JSON
content blob (lenient parse; unknown keys like value/funding/categories are
ignored). resolvePodcastShow()/isPodcastShowEvent() adapt either kind.
amethyst:
- PodcastsFeedFilter merges kind:10154 with kind:30078 (d="podcast-metadata"),
both from LocalCache.addressables, gated by isPodcastShowEvent so the 30078
scan ignores unrelated app-data.
- RenderPodcastMetadata renders via PodcastShow; NoteCompose dispatches a
podcast-metadata app-data note to it and keeps the text fallback otherwise.
Read support only. The kind:30078 metadata still needs a d-constrained relay
subscription to arrive proactively — that touches the topNav subassembly
plumbing and is left as the remaining step; shows already in cache merge and
render today.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
Amethyst's podcast support (NIP-F4) and derekross/podstr use incompatible
identity models: NIP-F4 makes each podcast its own keypair with regular
kind:54 episodes, while the Podcasting-2.0 draft signs editable, addressable
kind:30054 episodes with the human creator's key. The two cannot share a wire
kind, but a client can still render them in one list.
Add the Podcasting-2.0 episode (kind:30054) and trailer (kind:30055) event
classes plus their tags, and introduce a spec-neutral `PodcastEpisode`
abstraction (with `PodcastAudio`) that both kind:54 and kind:30054 implement.
Feeds and UI can now depend on the shared interface and surface both kind sets
in a single, ordered podcast/episode list. NIP-F4 remains Amethyst's publish
format; this only adds read/parse support for the Podcasting-2.0 kinds.
Register both new kinds in EventFactory and KindNames. Covered by round-trip,
spec-example-JSON, and a unified-list test proving both kinds flow through the
shared abstraction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18