diff --git a/cli/README.md b/cli/README.md index b492dec2f4..48a872fa28 100644 --- a/cli/README.md +++ b/cli/README.md @@ -70,11 +70,25 @@ from the store first and only fall back to a relay fetch on miss. Three convenience helpers exist on `Context`: ```kotlin -ctx.profileOf(pubKey) // latest kind:0 (NIP-01) -ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65) -ctx.contactsOf(pubKey) // latest kind:3 (NIP-02) +ctx.profileOf(pubKey) // latest kind:0 (NIP-01) +ctx.relaysOf(pubKey) // latest kind:10002 (NIP-65) +ctx.contactsOf(pubKey) // latest kind:3 (NIP-02) +ctx.dmInboxOf(pubKey) // latest kind:10050 (NIP-17 DM inbox) +ctx.keyPackageRelaysOf(pubKey) // latest kind:10051 (MIP-00 KP relays) +ctx.cachedRelayListsOf(pubKey) // RecipientRelayFetcher.Lists from cache ``` +Commands that already read these cache-first: + +- `amy profile show` — `--refresh` to bypass. +- `amy feed --following` — local kind:3 served via slot lookup; falls + back to a relay drain on first run. +- `amy dm send` — recipient's kind:10050 / 10051 / 10002 served from + cache before falling back to `RecipientRelayFetcher`. +- `amy marmot key-package check` and `amy marmot await key-package` + — same recipient-relay lookup, cache-first. +- `amy marmot group add` — invitee relay lists served from cache. + The store implements every feature of the Quartz SQLite store — NIP-01 replaceable / addressable uniqueness, NIP-09 deletion tombstones, NIP-40 expiration, NIP-50 search, NIP-62 right-to-vanish, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 096c3f8b72..ca6b1d8fd3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.marmot.ingest import com.vitorpamplona.quartz.marmot.MarmotFilters +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -44,6 +46,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.channels.Channel @@ -333,6 +336,54 @@ class Context( Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1), ).firstOrNull() as? ContactListEvent + /** + * Latest known kind:10050 chat-message (NIP-17 DM) inbox relay list + * for [pubKey], or `null` if Amy has never observed one. Used by + * `dm send` to resolve where to deliver a wrap. + */ + fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? = + store + .query( + Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1), + ).firstOrNull() as? ChatMessageRelayListEvent + + /** + * Latest known kind:10051 KeyPackage relay list (MIP-00) for + * [pubKey], or `null` if Amy has never observed one. Used by + * `marmot key-package check` and `marmot await key-package` to + * locate where the recipient publishes their KeyPackages. + */ + fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? = + store + .query( + Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1), + ).firstOrNull() as? KeyPackageRelayListEvent + + /** + * Assemble a [RecipientRelayFetcher.Lists] from the local store — + * the same shape callers get from [RecipientRelayFetcher.fetchRelayLists] + * after a network drain, but no network round-trip. Returns `null` + * only when the cache has *no* relay-list events at all for + * [pubKey] (none of kind 10050 / 10051 / 10002), so callers can + * trivially fall back to the network fetcher with `?:`. + * + * Stale-data caveat: replaceable events are immutable per snapshot + * — if the recipient has rotated their inbox since we last saw them, + * we'll still hand back the old list. Commands that care can drain + * (which re-populates the cache) or expose a `--refresh` flag. + */ + fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? { + val dm = dmInboxOf(pubKey) + val kp = keyPackageRelaysOf(pubKey) + val nip65 = relaysOf(pubKey) + if (dm == null && kp == null && nip65 == null) return null + return RecipientRelayFetcher.Lists( + dmInbox = dm?.relays().orEmpty(), + keyPackage = kp?.relays().orEmpty(), + nip65 = nip65, + ) + } + /** * Pull down everything needed to bring local Marmot state current: * - kind:1059 gift wraps on inbox relays → try to unwrap Welcomes diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt index 31e698c0ee..a256818f5d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -76,7 +76,13 @@ object AwaitCommands { // target hasn't published either list yet, fall back to the // bootstrap pool so the loop still has something to query. val seed = ctx.bootstrapRelays() - val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, target, seed) + // Cache-first: relay lists are kind:10050 / 10051 / 10002 — + // all replaceable, served from the local store via the slot + // shortcut. Falls back to a network drain only if Amy has + // never observed any of them for `target`. + val lists = + ctx.cachedRelayListsOf(target) + ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, target, seed) val relays = KeyPackageFetcher.fetchRelaysFor( targetKeyPackageRelays = lists.keyPackage, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index d48652a860..9153c16649 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -415,7 +415,13 @@ object DmCommands { allowFallback: Boolean, ): RelaySet { val seed = ctx.bootstrapRelays() - val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed) + // Cache-first: if Amy has previously seen the recipient's + // kind:10050 / 10051 / 10002 events, use the local copy and + // skip the network drain entirely. Falls back to the live + // fetcher only if the local store has nothing. + val lists = + ctx.cachedRelayListsOf(recipient) + ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed) val dmInbox = lists.dmInbox.toSet() if (dmInbox.isNotEmpty()) return RelaySet(dmInbox, "kind_10050") if (!allowFallback) return RelaySet(emptySet(), "kind_10050") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index 06ab61b3cd..49f643c067 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -155,6 +155,13 @@ object FeedCommand { ctx: Context, timeoutMs: Long, ): List { + // Cache-first: contact lists are kind:3 (replaceable). If we've + // ever seen ours, the local store has it already and reading is + // a slot lookup — no relay round-trip. + ctx.contactsOf(ctx.identity.pubKeyHex)?.let { + return it.verifiedFollowKeySet().toList() + } + val relays = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } if (relays.isEmpty()) return emptyList() val filter = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt index ab18c55564..b6e2ca9f17 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupAddMemberCommand.kt @@ -72,12 +72,17 @@ object GroupAddMemberCommand { // this the inviter can only broadcast to their own relays, // which silently fails the moment the two users have // disjoint relay configs. + // + // Cache-first via Context.cachedRelayListsOf — every + // relay list seen previously is in the local store + // already. val recipient = - RecipientRelayFetcher.fetchRelayLists( - client = ctx.client, - pubKey = pub, - seedRelays = seed, - ) + ctx.cachedRelayListsOf(pub) + ?: RecipientRelayFetcher.fetchRelayLists( + client = ctx.client, + pubKey = pub, + seedRelays = seed, + ) // KeyPackage discovery (MIP-00): prefer the invitee's own // kind:10051, then their kind:10002 write marker, then our diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt index 91129df27c..436ee3549f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyPackageCommands.kt @@ -77,7 +77,11 @@ object KeyPackageCommands { // when the target and inviter share no relays. val seed = ctx.bootstrapRelays() if (seed.isEmpty()) return Json.error("no_relays", "configure relays first") - val recipient = RecipientRelayFetcher.fetchRelayLists(ctx.client, targetHex, seed) + // Cache-first via Context.cachedRelayListsOf — replaceable + // events live in the local store after the first sync. + val recipient = + ctx.cachedRelayListsOf(targetHex) + ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, targetHex, seed) val relays = KeyPackageFetcher.fetchRelaysFor( targetKeyPackageRelays = recipient.keyPackage,