Step 1+2 of generalizing the DM history pagination into a reusable toolkit.
Move the four transport-agnostic primitives out of the amethyst module into
quartz's jvmAndroid source set, new package
`nip01Core.relay.client.paging`: UntilLimitPager, PerRelayLoadTracker,
WindowLoadTracker, RelayPagingProgress. They were already pure Kotlin (no
Android deps); jvmAndroid keeps their java.util.concurrent / @Synchronized
concurrency without a KMP-atomics rewrite, while making them visible to
amethyst, desktop, and quartz's jvmAndroidTest (geode in-process relay) for
the integration tests to come.
Add BackwardRelayPager<K>: the generic per-relay backward-pagination engine
that collapses the ~80%-identical pager+tracker+status+exhausted bookkeeping
the three DM history loaders each reimplement. It owns the cursors, in-flight
+ silence tracking, stalled set, pinned floor, and the display StateFlows
(relayProgress / exhausted / reachedBack / relayCount / stalledCount); the
caller supplies only the filter builder, the subscription wiring, and a
relaysFor(key) lookup. Not yet wired into the managers — that swap is step 3.
Pure relocation + new component; no behavior change. UntilLimitPagerTest and
WindowLoadTrackerSilenceTest stay in amethyst (they use JUnit) with explicit
imports added, and both still pass against the relocated classes.
The doc had drifted from the implementation on two material points:
- "Update 3" claimed the rooms-list and gift-wrap *history* managers still use
the round model. They were both migrated to the per-relay until+limit model
(commits 60b8629a, 9f0ecd54); all history paging is now per-relay. The round
model (WindowLoadTracker) survives only as the live-tail completion barrier.
- The rooms-list "stall-gate" it described was removed (commit 98fb8720); the
rooms list now pages to exhaustion off marker visibility like the convo.
Restructured so the current architecture is authoritative and up front (two
layers, the two completion models and where each lives, the marker/sentinel
driver, per-relay NIP-04 filter scoping, terminal-state split, reply
placeholder, diagnostics, Tor self-heal), with a component map and review
notes. The superseded time-slice and round-model history are kept clearly
labelled under "Design evolution (historical)".
The terminal state of both DM history cards equated two very different things:
a relay that genuinely bottomed out (empty page = done) and one that merely
stalled (auth CLOSE / offline / 15s silent). `exhausted` flips true when every
relay is done OR stalled, and the card rendered that unconditionally as "All
caught up" — so a chat whose messages sit on a stalled relay claimed completion
while a lot of history was still unfetched. The reply placeholder had the same
flaw and, worse, collapsed to a bare 👀 (post_not_found_short) that told the
user nothing.
Split the terminal state by stalledCount:
- caughtUp (all done): keeps "All caught up" and the lingering collapse.
- incomplete (>=1 stalled): "Some relays didn't respond · N unreachable",
error-coloured glyph, stays put (no collapse).
Both cards are tappable into the existing per-relay popup, so the user can see
exactly which relays stalled (… in error colour) vs reached the bottom (✓).
The reply placeholder now says "Couldn't find this message" with an honest
subtitle — "N relays unreachable · tap to see which" when stalled, or "Searched
every relay · tap to see" when it genuinely isn't in history — instead of 👀.
The card summarized progress but hid the per-relay detail that relayProgress
already carries. Make the card tappable: it opens a popup listing every relay
with its state (✓ done · … stalled · ↓ reaching) and how far back it has paged
('back to <date>'), deepest-reaching first. Passes each loader's relayProgress
through to the card; empty progress keeps the card non-interactive.
The card's count was loadTracker.count() = relays in-flight, which drops to 0
the moment they all stall — so historySubtitle hit its relayCount<=0 guard and
showed just the bare protocol tag ('NIP-17' / 'NIP-04'), with no hint of how
many relays it was waiting on.
Expose a stalledCount (not-done relays that can't be reached right now) from all
three history loaders and render it on the paused card as 'waiting on N relays'
when nothing is in flight. Active fetching still shows 'N relays'; a parked-but-
reachable protocol still shows just the tag (it isn't waiting on anything —
it resumes on scroll).
The demand-driven rewrite dropped the round model's 'load done: all relays'
completion line, leaving no aggregate signal for 'this is as far as history
goes right now'. Log one per protocol on the exhausted false->true edge, with
the done vs stalled relay breakdown, restoring that diagnostic.
The per-relay window-limit sentinel placed its advance() effect inside the
gap row that currently hosts the marker, so its identity rode that row. Any
feed reorder (a live DM bumping a room, or a slow relay dribbling a history
page) moved the gap to a different row, tore the keyed LaunchedEffect down and
recreated it, and re-fired advance() on a static screen — re-arming stalled/
auth relays into a 15s silence-watchdog storm and risking an unprompted
page-back for delivering relays.
Hoist the driver above the list: RelayWindowLimitSentinels now holds one
stable effect per non-done limit (keyed by lim.key) that watches the
LazyListState and fires advance() only when the marker's gap is among the
currently visible rows AND it just scrolled into view OR its reached cursor
moved (a page landed). A reorder that keeps the marker on the same side of the
fold changes neither, so it no longer re-pages. RelayWindowLimitMarkers is now
pure UI. Wired in the rooms list (inline) and the conversation (via a new
sentinels slot on ChatFeedView).
Verified on device: static rooms list drops from a perpetual ~15s re-fire/
silence storm to 2 silence events and only legit cursor-moved advances, while
demand-driven paging (page back while the marker is visible) is preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PerRelayLoadTracker dropped the loading flag the instant in-flight emptied, so
a relay paging page-after-page (each page settles, then the marker fires the
next) flipped loading true->false->true every page — and the card's icon hard-
cut spinner -> paused-glyph -> spinner, a visible blink per page.
Let loading=false linger ~600ms after the last page settles; cancel the linger
the moment the next page starts. Back-to-back pages now show a steady spinner;
the card still flips to paused/done once paging genuinely stops.
The floor (now - 7d) was recomputed on every call, so it drifted forward
over time. For a relay that hadn't delivered yet (reachedUntil == null),
reachedUntilFor returned that drifting floor, so its marker's reached cursor
kept changing, the sentinel's LaunchedEffect(reachedUntil) key changed, and
it re-fired advance() — observed as rooms.nip04.history re-advancing a silent
vitor.nostr1.com every ~15s, with the retry's 'until' moving NEWER each time.
Pin the floor once per account/conversation for the session (matching the
pre-rewrite behavior) in all three history loaders, so an un-delivered relay
parks at a stable cursor and its sentinel fires once.
Also add diagnostics to catch this class of bug quickly: one log line per
marker sentinel fire (key + reachedUntil — a loop repeats the same key, a
drift shows a moving cursor) and one per advanceAll (empty-feed bootstrap).
The per-relay sentinel keyed its LaunchedEffect on (reachedUntil, state).
The state component meant every REACHING<->STALLED transition restarted the
effect and re-issued advance(), so a flaky or auth-walled relay's reconnect/
timeout churn re-paged the window on a completely static screen (observed:
vitor.nostr1.com + auth.nostr1.com re-REQing on every ping timeout / auth
CLOSE with no user interaction).
Key the sentinel on reachedUntil ALONE — a landed page is the only thing
that should pull the next one. A stalled relay now parks until its cursor
moves or its marker is scrolled back into view (one retry, not a loop).
Also: the empty-feed bootstrap now triggers on FeedState.Empty only (not the
transient Loading that navigation flashes through) and is debounced, so
re-opening Messages / a conversation that already has content no longer kicks
a one-round advanceAll across every relay.
Audit follow-ups to the demand-driven paging change:
- UntilLimitPager.onEose: require the reached cursor to move strictly
older each page. A misbehaving relay that returns events but none older
than already reached would otherwise pin the cursor and the on-screen
sentinel would re-request the same window forever; treat it as the bottom.
- updateStatus (gift-wrap + rooms-list NIP-04): only write the shared
display StateFlows for the foreground account, mirroring the existing
exhausted guard, so a background account's late EOSE can't clobber the
on-screen relay count / reached-back / per-relay markers.
- Add UntilLimitPagerTest covering the requested/reached split, park-on-
EOSE, done-on-empty, the strict-older guard, and per-relay independence.
Replace the proactive walk-to-exhaustion with on-demand, per-relay paging
driven by on-screen window-limit markers, so history loads only while the
user is looking at a relay's frontier and a spam-dense relay never floods.
UntilLimitPager: split the cursor into 'requested' (drives the REQ; moves
ONLY on advance()) and 'reached' (oldest delivered; for markers). Leaving
requested untouched on EOSE is what parks a relay — no auto-continuation.
PerRelayLoadTracker (new): tracks which relays have a page in flight (for
the spinner) with a silence watchdog to park relays that accept a REQ then
go quiet. Replaces the window/round bookkeeping for the history loaders.
All three history loaders (gift-wrap account-wide, rooms-list NIP-04,
conversation NIP-04) rewritten: onEose parks instead of self-continuing;
advance(relay) steps one relay one page; advanceAll() bootstraps the
empty/initial boundary; exhausted = every relay done or stalled.
UI: RelayWindowLimitMarkers renders each relay's marker at its reached
depth AND acts as its load sentinel — while the marker is composed
(on/near screen) it pulls that relay's next page (LaunchedEffect keyed on
the reached cursor, so it keeps going page after page while visible) and
stops when the marker scrolls off or the page fills the screen. Wired into
both the conversation (gap markers) and the rooms list (interleaved
between rooms); a BootstrapHistoryWhenEmpty drives advanceAll while the
feed has nothing to scroll.
Removes the round/give-up machinery's last users: drop loadMore/
loadEverything and the scroll-driven WidenHistoryWhen.
Three names didn't describe their behavior:
- removeFromCache → unlinkAndRemove: the method's main job is unlinking the
note from every referrer (parents, channels, the report/card/status/poll
indexes), not just evicting it from the map; the old name only captured
the last step.
- removeAllChildNotes → clearChildLinks: it clears only THIS note's forward
child collections and returns them — it does not touch the children's
replyTo and does not remove anything from the cache. The old name sounded
more aggressive than detachFromChildren(), which is actually the
both-directions op.
- Note.removeOnchainZap(source) → removeOnchainZapBySource(source): too easy
to confuse with removeOnchainZapForSource(txid, pubkey), which is the
verification-verdict removal with anti-spoof guards. The new name matches
its inner helper (innerRemoveOnchainZapBySource) and disambiguates the two.
Pure rename: no behavior change. Test names/comments updated to match.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
The gift-wrap history loader exposed only relayCount + reachedBack, so the
conversation's in-stream marker trail showed NIP-04 relays paging back but
never NIP-17 ones. Give AccountGiftWrapsHistoryEoseManager the same
relayProgress map (per-relay reachedUntil / done / stalled) the
per-conversation NIP-04 loader publishes, refreshed on every page, stall,
CLOSE, and cannot-connect.
Move the shared RelayPagingProgress data class out of the NIP-04 UI-package
assembler into service/relayClient/eoseManagers so the gift-wrap manager
can produce it without the service layer depending on a UI package.
ChatroomView now merges both protocols' progress into the gap markers, each
contributing only while it is still paging; a relay that serves both (the
DM inbox relays) collapses to one marker. Markers hide once both protocols
are exhausted.
Convert ChatroomListNip04HistorySubAssembler from the round-based model to
the per-relay-independent one, matching the per-conversation NIP-04 loader
and the gift-wrap history loader. A single loadMore opens one window
spanning the whole walk; each relay continues itself on its own non-empty
EOSE (beginRound([relay]) + invalidateFilters), and silent/unreachable
relays are marked stalled (kept open) with exhaustion coming from the
WindowLoadTracker's silence + connect-grace backstops (tracksReqSends=true).
The rooms list now pages both protocols identically.
This was the last user of the round-tally + give-up machinery, so remove it
from UntilLimitPager: roundEventCount(), onClosed()/giveUp() and the
givenUp/closedStreak cursor state + GIVE_UP_AFTER_CLOSES. activeRelays now
excludes only done relays. Delete UntilLimitPagerGiveUpTest (tested the
removed give-up path; abandonment is now the WindowLoadTracker's job, which
its own test covers).
Also drop the now-dead loadEverything/autoLoadAll and the no-progress retry
(no callers; with independent paging one loadMore already walks each relay
to its bottom, and the tracker's backstops cover cold starts).
The gift-wrap history loader was round-based: loadMore asked every active
relay together and the next page only went out after the whole round
settled, so one slow or auth-walled relay throttled the cadence and fast
relays idled until the laggards finished. The per-conversation NIP-04
loader already pages each relay independently; this brings NIP-17 to the
same model.
Now a single loadMore opens one window spanning the whole walk, and each
relay continues itself the instant it EOSEs a non-empty page
(pager.beginRound([relay]) + invalidateFilters, which the sub layer diffs
so only the advanced relay re-REQs). Fast relays race to the bottom while
slow ones catch up in the background. The WindowLoadTracker switches to
tracksReqSends=true with an onAbandoned handler that marks silent/
unreachable relays stalled (kept open, still trying) rather than giving up
on them - exhaustion comes from the window settling via the tracker's
silence + connect-grace backstops, which also removes the need for the old
no-progress retry loop.
Drop the now-dead loadEverything/autoLoadAll (no callers; with independent
paging one loadMore already walks each relay to its bottom). Pin the
history floor per window to keep un-advanced relays' filters stable across
the per-EOSE invalidateFilters, and keep reachedBack monotonic over all
relays.
deleteNote() (NIP-09) and removeFromCache() (prune) had drifted into two
near-duplicate "detach a note from the cache" routines. Removal really has
two halves — (1) unlink the note from everything that points AT it, and
(2) handle the note's OWN children — and only the second half differs
between the paths. removeFromCache() already implemented half (1)
completely, so deleteNote() now delegates to it and keeps only its two
delete-specific responsibilities: tearing down gift-wrap hosts and
severing (but keeping) its children via detachFromChildren().
This also fixes a real leak the duplication was hiding. computeReplyTo()
has no ReportEvent branch, so a report note's replyTo is empty and the
report→target link lives only in the explicit reported* index handling.
The old deleteNote() only undid reportedAuthor(), so deleting an
event-level report (reportedPost / reportedAddresses) left the reported
note's `.reports` map holding the removed report note — a partial deletion
that leaked the shell and risked a duplicate Note for the same id.
Delegating to removeFromCache() (author + post + addresses, all
idempotent) closes that gap.
Net behavior change is the report-leak fix only; the redundant
TorrentCommentEvent case is dropped because the torrent target is already
in replyTo (and removed via removeNote), and its @Suppress("DEPRECATION")
goes with it. Adds KDoc to both methods documenting the two-halves model.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Reframe from a forward-looking plan diff to the current architecture, and fix
the onAuthenticated signature to (event): Boolean (pubKey was dropped). All
structural elements already matched the merged code.
The unloaded-reply loader matched the history card's chrome but dropped
its status line. Surface the same detail: which protocol, how many relays
it's still asking, and how far back it has paged - reusing the card's
historySubtitle so the inline loader and the oldest-end card read
identically.
The auth set is already a per-RelaySession instance field (one session per
connect(), fresh policy per connection), so two connections never share auth
state. Add a regression test: authenticate different pubkeys on two connections
of the same server and assert each scope holds only its own — no union leak.
deleteNote() removed the target from its parents, gatherers, and the cache
map, but never cleared its own child collections nor dropped itself from
its children's replyTo. That left a partial deletion: every child kept the
removed shell alive through replyTo (a leak), and a reply resolved later
via computeReplyTo would getOrCreateNote a *second* Note for the same id —
breaking the one-Note-per-id invariant.
Adds Note.detachFromChildren(), which clears the note's forward child
collections (via removeAllChildNotes) and severs this note from each
child's replyTo (keeping any other parents). deleteNote() now calls it
before notes.remove(), so once the note leaves the map nothing points at
the dead shell. Orphaned replies become roots, which is correct once their
parent is hard-deleted from the cache.
Adds detachFromChildren coverage to NotePruningReferenceTest.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
pubKey was always event.pubKey (the NIP-42 signer is the authenticated
identity), so the parameter was pure redundancy. onAuthenticated(event) and
authorize(event) now read event.pubKey directly; the engine still commits
cmd.event.pubKey. Removes the now-unused HexKey imports from IRelayPolicy and
PolicyStack.
Authentication state (who is logged in on a connection) is connection scope,
not a policy decision. It was stored on FullAuthPolicy, which forced the
AuthScopedPolicy marker, the PolicyStack union, and a downcast in
RequestContext just to route it back out as scope.
Now the engine owns it: RelaySession holds the authenticatedUsers set behind
the (now public) requestContext; the data plane and policies read it through
RequestContext. The policy stays pure decision —
- onConnect(scope, send): a per-connection policy captures the read-only scope
to gate on; shared singletons ignore it.
- onAuthenticated(): Boolean: the policy's vote on whether to record the
verified pubkey (default false, so blind-accept policies never record an
unverified identity). FullAuthPolicy runs authorize() then votes true.
- RelaySession.handleAuth performs the single, engine-side commit after the
whole chain approves and a verifying policy votes to record.
FullAuthPolicy keeps all auth logic (challenge, accept(AuthCmd), gating,
authorize) and gains a protected authenticatedUsers accessor over the scope for
subclasses (restricted content / filter rewrite). Deletes AuthScopedPolicy,
PolicyStack.authenticatedUsers, and the RequestContext downcast.
removeFromCache() relied solely on note.inGatherers to detach a pruned
note from its channels, while deleteNote() additionally resolved the
channel via getAnyChannel() and removed the note there too. inGatherers
is normally authoritative (Channel.addNote always calls addGatherer), so
this is defensive rather than a confirmed live leak — but it closes the
divergence so both removal paths detach channels identically. Guards
against any future consume path that adds a note to a getAnyChannel-
resolvable channel without the gatherer link: otherwise the note would
linger in the channel's notes map after leaving the cache, leaking it and
letting a relay echo mint a duplicate Note with the same id.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Give LoadingReplyNote the same chrome as DmHistoryLoadingCard - rounded
translucent surface, spinner-in-a-box, status line - so an unloaded reply
reads as the same 'reaching back into history' state, just inline in the
quote instead of at the oldest end.
Reply quotes inside a conversation rendered the same 'post not found'
BlankNote as the main feeds when the target message had not been paged in
yet. But a reply target in a DM is not missing - it is simply older than
the loaded window, and for NIP-17 the inner rumor id is not even queryable
on relays (only the outer gift-wrap id is), so the only way to surface it
is to keep paging gift-wrap history until the wrap carrying it decrypts.
Add LoadingReplyNote: a custom inner-quote placeholder that shows a small
spinner + 'looking for the original message' and drives the matching
history pager's loadMore in a loop (gated on its own loadingMore/exhausted)
until the target decrypts - at which point WatchNoteEvent crossfades the
real message in and disposes the loader - or the protocol's history runs
dry, settling into the terminal 'not found' text. It runs regardless of
scroll position so opening a thread pulls an off-screen reply target in on
its own; the loop is idempotent so multiple loaders and the scroll loader
coalesce onto one paging window.
Wire it in via a new optional onBlank slot on ChatroomMessageCompose,
chosen by the parent message's protocol (gift-wraps vs NIP-04). Public
chats and marmot groups keep the default blank.
LocalCache must hold a single Note per event id/address and must never
remove a Note from the cache map while another Note still strongly
references it — a dangling reference both leaks the shell and lets a
relay echo mint a second Note with the same id.
Note.onchainZaps (NIP-BC) and Note.nutzaps (NIP-61) were added after the
removal/migration routines were written and were never wired into them,
so a pruned zap-source Note leaked through its target's maps:
- removeNote() only detached reply/boost/reaction/zap/zapPayment/report/
label, leaving the target's onchainZaps/nutzaps entry dangling when the
source note was pruned. Now also calls removeNutzap + a new
source-keyed removeOnchainZap (unconditional cache removal, distinct
from the verdict-respecting removeOnchainZapForSource).
- removeAllChildNotes() cleared onchainZaps but never returned the source
notes for removal from the cache map (asymmetric with nutzaps), so they
lingered orphaned. Now included.
- moveAllReferencesTo() dropped labels, zapPayments, and onchainZaps when
a replaceable's old version was superseded — silent data loss plus
orphaned onchain sources. Now migrated and cleared like the rest.
Adds NotePruningReferenceTest covering all three paths.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Keep the universal policy interface free of NIP-42: instead of a default
authenticatedUsers on IRelayPolicy, add an opt-in AuthScopedPolicy mixin that
only FullAuthPolicy (and PolicyStack, which unions its auth-tracking members)
implements. RequestContext.authenticatedUsers resolves it via an `as?
AuthScopedPolicy` downcast, defaulting to empty — so non-auth relays carry no
auth concept, and the accessor now earns its keep by encapsulating that cast
(ctx.policy is IRelayPolicy and no longer exposes the set directly).
Non-storage relays answer a REQ purely from filters: EventSource.events()
got no session/auth context, even though the connection's policy already
knows the authenticated pubkey(s). That walled the auth state off from the
code that produces events, forcing a shared mutable holder + hand-wired
RelaySession to build any caller-aware relay (NIP-50 search scored from the
viewer, DM-style restricted content, paid/allow-listed sets, per-connection
tenancy).
Introduce RequestContext (connectionId, authenticatedUsers, policy) and pass
it through the read path: RelaySession -> SessionBackend.query/count/
countResult -> EventSourceBackend -> EventSource. authenticatedUsers is now
exposed on IRelayPolicy (default empty, overridden by FullAuthPolicy and
unioned by PolicyStack) and read live, so a REQ after AUTH sees the freshly
authenticated pubkey(s). For richer per-connection state, downcast ctx.policy.
EventSourceServer.serve { } is now usable for auth-scoped relays without a
side channel. Adds a test proving ctx.authenticatedUsers reaches the source
after a NIP-42 handshake; updates RELAY.md.
The additive predicate in ChatroomListNewFeedFilter required
room.senderIntersects(followingKeySet) to be true, the exact inverse of
the full feed() rebuild, which includes a room only when the sender is
NOT followed. So new gift wraps / NIP-04 DMs from strangers streaming in
through the additive update path were all rejected, and the New Requests
list never grew past whatever feed() had computed at screen-open time.
On a heavy account this showed as the list freezing at a handful of rooms
while tens of thousands of events decrypted and history exhausted -
'loading but not changing the screen'. Negate the senderIntersects term
so the additive path matches feed().
The round-model history cards computed reachedBack = deepestUntil over
the ACTIVE relays only. When the deepest relay finished paging and
dropped out of the active set, the min jumped to the next-active (newer)
relay's cursor, so 'back to X' lurched FORWARD to a more recent date —
un-reaching history it had already loaded. Visible in the giftwrap trace:
the deepest relays reach ~2023, then once they finish and only the
shallow inbox.nostr.wine (~70d) remains, deepestUntil(active) snaps back
to ~70d.
Compute it over ALL relays (including finished ones, which keep their
deep cursor), matching the convo path. Now it only ever moves older.
Applied to both round-model managers (giftwrap + rooms NIP-04).
Note: a large *forward* jump (e.g. ~3 years in one step) is still
expected and correct — a dense relay can return a full 10k-event page
spanning years, so the oldest-loaded date legitimately leaps. No
messages are skipped; each relay pages contiguously.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
On a flaky network Arti records circuit failures past the first hop as
"indeterminate" and, once a guard's indeterminate ratio crosses 0.7,
permanently disables it. Disabled guards are never re-enabled nor removed
from the sample (60-day lifetime), and the sample is capped at 60. Arti
normally refills usable guards when they fall below 20, but a full sample
of unusable guards leaves no room — replenishment wedges and every circuit
returns AllGuardsDown. The state persists in guards.json and bootstrap
still "succeeds", so no existing self-heal path fires: Tor stays broken
across restarts. This is the long-standing, hard-to-reproduce production
"can't connect to Tor" bug.
On init, scan guards.json and, if any non-empty guard selection has zero
usable guards (disabled or unlisted_since set), wipe Arti state so the
next bootstrap rebuilds a fresh sample. A single usable guard is enough to
build circuits, so recovery only triggers at the last resort to preserve
guard-set stability (anonymity) and avoid pointless churn on bad networks.
Verified on emulator: poisoned 60/60 -> wipe -> fresh 20/20 usable sample
-> .onion OnOpen, zero AllGuardsDown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>