diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts
index 7bf0cda845..1a0a0471c6 100644
--- a/amethyst/build.gradle.kts
+++ b/amethyst/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
+ alias(libs.plugins.googleKsp)
}
fun getCurrentBranch(): String =
@@ -271,6 +272,18 @@ android {
}
}
+// androidx.appfunctions-compiler runs in a per-module mode by default,
+// emitting only the dispatcher Kotlin code. The aggregator that builds
+// the `app_functions.xml` asset (which the system reads to discover our
+// @AppFunction methods) is gated behind this KSP argument — without it,
+// the manifest's `android.app.appfunctions` property points at a file
+// that doesn't exist and the System UI logs "Unable to resolve
+// AppFunctionMetadata." Set on the app module only; library modules
+// (commons/quartz) would set it to "false".
+ksp {
+ arg("appfunctions:aggregateAppFunctions", "true")
+}
+
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
val tink = "com.google.crypto.tink:tink-android:1.17.0"
@@ -413,6 +426,15 @@ dependencies {
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
"playImplementation"(libs.play.services.cast.framework)
+ // androidx.appfunctions — Gemini App Functions adapter. Pre-stable
+ // (alpha) as of May 2026 — scoped to the play channel so the F-Droid
+ // build stays free of Google AI dependencies. Surface is an
+ // AppFunctionService registered in amethyst/src/play/AndroidManifest.xml,
+ // generated at compile time by the KSP-driven appfunctions-compiler.
+ "playImplementation"(libs.androidx.appfunctions)
+ "playImplementation"(libs.androidx.appfunctions.service)
+ "kspPlay"(libs.androidx.appfunctions.compiler)
+
// Charts
implementation(libs.vico.charts.compose)
implementation(libs.vico.charts.m3)
diff --git a/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md b/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md
new file mode 100644
index 0000000000..787b30bf26
--- /dev/null
+++ b/amethyst/plans/2026-05-25-appfunctions-signer-prompts.md
@@ -0,0 +1,239 @@
+# AppFunctions signer prompts — design
+
+**Date:** 2026-05-25
+**Status:** Draft — no code yet
+
+How write verbs invoked from background Gemini context (via
+`androidx.appfunctions` 1.0.0-alpha09 → `PlatformAppFunctionService`)
+acquire a signature from each of Amethyst's three signer types. This
+is the gating concern that has us only exposing read-only verbs so far
+(`searchProfiles`, `searchNotes`, `getFollowing`).
+
+## The three signer types and what each needs
+
+| Signer | Where the private key lives | Sign call latency | Needs user interaction? |
+|---|---|---|---|
+| **`NostrSignerInternal`** | In-process keypair, loaded at login | Synchronous, microseconds | No |
+| **`NostrSignerRemote`** (NIP-46 bunker) | Remote process — a wallet app, browser tab, separate device | Network round-trip via relays, seconds | Yes — the bunker app pops a confirmation on the user's other device |
+| **`NostrSignerExternal`** (NIP-55, e.g. Amber) | Another Android app on the same device | Bound-service IPC + activity bounce | Yes — Amber shows an activity in the foreground asking the user to approve |
+
+Each signer surfaces the same `suspend fun sign(...)` API. The difference is
+**what happens to the foreground UI** while the sign is in flight.
+
+## What App Functions gives us to work with
+
+From the alpha09 artifact (`androidx.appfunctions:appfunctions-service`):
+
+- **Suspending dispatch.** `executeFunction` is a suspend function — a slow
+ signer (NIP-46 round-trip) doesn't block the system shell.
+- **Typed exceptions.** `AppFunctionPermissionRequiredException`,
+ `AppFunctionDeniedException`, `AppFunctionCancelledException`,
+ `AppFunctionAppException`. The non-default constructors take a `Bundle` —
+ the system shell can interpret known keys (e.g. a `PendingIntent` to launch
+ an in-app confirmation). Concrete bundle contract is undocumented in
+ alpha09; needs a sample-app check or an experiment.
+- **`PendingIntent`** is listed as a supported parameter type, which strongly
+ implies a returned `PendingIntent` can prompt the system to launch the
+ app's UI for follow-up.
+- **No streaming.** Functions return one value or throw. There's no native
+ "in progress" / "user is approving" signal back to Gemini.
+
+## Per-signer approach
+
+### NostrSignerInternal — just works
+
+Verb runs end-to-end inside the dispatch coroutine. `signer.sign(...)` is
+synchronous. Publish via `client.publish(...)`. Return success.
+
+**Verbs this covers immediately:** post, follow/unfollow, search-relay
+list updates, kind:10002 changes — anything where the signed event is
+sent and forgotten.
+
+**Edge case — background `app.client`.** When the app process is
+foreground-bound but the user is in Gemini, the client should be
+connected. When the user has killed Amethyst recently, the service
+process might be cold-started and the client not yet connected to any
+relay. The verb needs to either:
+- Wait for `client.connect()` (~hundreds of ms once the WebSocket is
+ established) — acceptable inside the 5-10s window.
+- Use `INostrClient.publish(...)` which queues the publish for when the
+ connection comes up. Quartz needs to confirm this is the actual
+ behavior; might require `withTimeout` around the publish.
+
+### NostrSignerRemote — the cleanest async case
+
+The bunker sends a NIP-46 request to a relay, the bunker app sees it on
+the user's other device, the user approves, the signed event comes back.
+`signer.sign(...)` suspends until the response arrives or its internal
+timeout fires (default 30s).
+
+**Approach:** call `signer.sign(...)` from within the verb, with a
+`withTimeout` budget aligned to App Functions UX expectations (Gemini
+typically waits ~30s before showing the user "no response"). On
+timeout, throw `AppFunctionCancelledException`. On success, publish and
+return.
+
+**Open question — concurrent foreground signing.** If the user is also
+trying to send a post from the foreground UI at the same moment, the
+bunker app gets two simultaneous requests. NIP-46 handles this — each
+request has a unique id — but Amber-like bunker apps may queue both
+prompts confusingly. Worth a manual test.
+
+### NostrSignerExternal — the hard case
+
+NIP-55 bounces to a separate Android app's activity. From a background
+`PlatformAppFunctionService`, we can't directly `startActivity(...)` —
+there's no foreground intent stack to attach to.
+
+**Two viable approaches:**
+
+#### Option A — throw a typed exception with a PendingIntent
+
+```kotlin
+@AppFunction
+suspend fun postNote(ctx: AppFunctionContext, text: String): PostResult {
+ val account = activeAccount() ?: throw AppFunctionDeniedException("not signed in")
+ if (account.signer is NostrSignerExternal) {
+ // Build a PendingIntent that opens Amethyst at a "approve this
+ // post" screen, with the draft text passed through extras.
+ val approvalIntent = buildApprovePostPendingIntent(account, text)
+ throw AppFunctionPermissionRequiredException(
+ message = "Amethyst needs to launch the external signer to approve this post.",
+ extras = bundleOf("pending_intent" to approvalIntent),
+ )
+ }
+ // … happy path for the in-process signer
+}
+```
+
+The system shell renders "Open Amethyst to continue", user taps,
+Amethyst opens, user approves through Amber's activity, post lands. The
+Gemini conversation doesn't see the final result — the user has to come
+back to Gemini and re-confirm.
+
+**UX gap.** No way to communicate the eventual outcome back to Gemini's
+chat. Acceptable for v1.
+
+#### Option B — refuse write verbs when the signer is NIP-55
+
+Throw `AppFunctionNotSupportedException` immediately. User configures a
+different signer (local or NIP-46) to enable Gemini-driven writes.
+Simpler, cleaner, but limits the audience — many Amethyst users on
+Amber would lose the feature.
+
+**Recommendation:** start with Option B, ship Internal + Remote support,
+then add Option A behind a feature flag in a follow-up. Option B
+unblocks the feature for ~70% of users today; Option A is more work
+and has the unresolved "result doesn't get back to Gemini" wrinkle.
+
+## Per-write-verb concerns
+
+### postNote(text)
+- Internal: sign → publish to outbox. Done.
+- Remote: sign (suspends) → publish. Done.
+- External: throw NotSupported, or PendingIntent dance.
+- Side concern: should this go into the user's drafts vs immediately
+ publish? Gemini-issued posts feel like they should publish (the
+ user asked for it), but a "review before post" screen via PendingIntent
+ is a nice safety net even for the local-signer path.
+
+### follow(npub) / unfollow(npub)
+- Same signer paths as postNote, simpler payload.
+- Reads the current kind:3, modifies, signs, publishes — `FollowActions`
+ is ready.
+- **No** "preview" step needed — follow/unfollow is reversible.
+
+### sendDm(recipient, text)
+- Same signer paths.
+- `DmActions.buildTextDm` is ready, plus `resolveDmRelays`.
+- **Concern**: strict mode (default) refuses to send when recipient has
+ no kind:10050. Should Gemini's `sendDm` default to strict or
+ permissive? Argument for strict: it's NIP-17 spec behavior. Argument
+ for permissive: Gemini users won't know what kind:10050 is and will
+ see confusing failures. **Lean: permissive by default**, surface the
+ source in the result.
+
+### zapUser(npub, sats, comment?)
+- Same signer paths for the kind:9734 zap request.
+- But there's a *second* signing-like step: an LN payment via NWC
+ (if configured). NWC has its own permission model and can also fail.
+- For v1: build the zap request, fetch the BOLT11 invoice, return the
+ invoice in the result. User pays via their wallet. Skip NWC
+ auto-payment.
+
+### zapEvent(eventId, sats, comment?)
+- Same as zapUser but uses `ZapActions.buildEventZapRequestsForSplits`
+ so multi-party notes route correctly.
+- May return multiple invoices (one per split recipient).
+
+## Account selection
+
+All verbs read `Amethyst.instance.sessionManager.loggedInAccount()` once
+at entry. **Multi-account question**: should Gemini be able to specify
+*which* account to act as? Two answers:
+
+- v1: no — always act as the currently-active account. Matches what the
+ user sees in the foreground UI. Simpler.
+- Later: add an optional `accountNpub: String?` parameter to each write
+ verb. Defaults to the active account.
+
+Start with v1.
+
+## Permissions surfaced to Gemini
+
+The App Functions schema XML (auto-generated by KSP) lists each verb
+plus its parameters. Gemini's tool picker shows these to the user. We
+should add a `description` (via `isDescribedByKDoc = true`, which we
+already do) that makes write verbs sound consequential — "Publishes a
+note to your Nostr followers", not "Calls postNote".
+
+## Open questions for an experiment day
+
+1. What concrete `Bundle` keys does the system shell respect on
+ `AppFunctionPermissionRequiredException`? Run a tiny test app, throw
+ the exception with various bundle contents, observe what Gemini
+ surfaces.
+2. Can the user approve a Gemini-issued write from within the Gemini
+ chat (inline confirmation) or only by opening Amethyst? Affects
+ Option A's UX.
+3. Does `INostrClient.publish(...)` actually queue when the relay
+ pool is disconnected, or does it return immediately with no
+ delivery? Determines whether the verb needs an explicit
+ "wait for at least one OK" gate.
+4. NWC and Gemini: if the user has a NWC wallet configured, should
+ `zapUser` auto-pay? Adds another consent layer.
+
+## Minimum viable first write verb
+
+Pick **`postNote(text)`** as the pilot.
+
+Why:
+- Simplest: one signed event, one publish, one ack.
+- Read-back is straightforward: return the event id + the relays it
+ landed on. Gemini can compose "Posted! Here's the link: nostr:nevent…".
+- Failure modes are well-bounded (signer error, no outbox relays, all
+ relays rejected).
+- No multi-party complexity (zap splits, DM strict mode).
+
+Scope of the pilot:
+- `NostrSignerInternal` only (Option B for NIP-55, Remote in a
+ follow-up). Document the cutoff in kdoc.
+- Returns `PostNoteResult(eventId, publishedTo, rejectedBy)` — an
+ `@AppFunctionSerializable`.
+- Builds on the existing `commons/.../quartz/.../TextNoteEvent.build`
+ and `client.publish` — no new actions needed.
+- ~50 lines of new code in `AmethystAppFunctions.kt`, plus the result
+ class.
+
+Once shipped, follow-ups in order:
+1. `follow(npub)` / `unfollow(npub)` — same signer caveat.
+2. NIP-46 (Remote) signer support — change the gate from "signer is
+ internal" to "signer can sign in-process".
+3. `sendDm(recipient, text)` — first write verb that uses encryption.
+4. `zapUser` / `zapEvent` — non-trivial because of LN flow.
+5. NIP-55 support via PendingIntent (Option A) once the system-shell
+ contract is understood.
+
+No write-verb code lands until question (1) above is answered —
+otherwise the NIP-55 path is undefined and we ship something that
+"sort of works" for half our users.
diff --git a/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md b/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md
new file mode 100644
index 0000000000..e66007e512
--- /dev/null
+++ b/amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md
@@ -0,0 +1,131 @@
+# Verifying Gemini-side AppFunctions discovery
+
+**Date:** 2026-05-26
+**Status:** Active — answers the open question from
+`2026-05-25-appfunctions-signer-prompts.md`
+
+The Phase 2 work proves the app side: 21 `@AppFunction` verbs are
+registered, indexed by `AppFunctionManagerService`, and dispatchable
+via `adb shell cmd app_function execute-app-function`. The remaining
+unknown is whether **Gemini's chat UI** actually surfaces our verbs to
+the user — that's a separate layer (model-side tool picker) we can't
+exercise from the test command.
+
+## What we know
+
+* **Library state.** Built against `androidx.appfunctions
+ 1.0.0-alpha09`. Schemas (`@AppFunctionSchemaDefinition`) are
+ optional and the official Google sample (`android/appfunctions`
+ ChatApp) doesn't use them — meaning we're not at a structural
+ disadvantage by not defining our own. There's no canonical
+ `nostr.social` schema registry yet.
+* **Discovery strategy.** Without schemas, Gemini's tool picker
+ matches on the function's natural-language description (KDoc, via
+ `@AppFunction(isDescribedByKDoc = true)`) and the parameter
+ descriptions. We've reworked every verb's first sentence to be a
+ use-when imperative — "Find a person on Nostr by name…" — instead
+ of an implementation description ("Searches kind:0 metadata…").
+
+## What we don't know yet
+
+* Whether Gemini's model picks up our verbs at all from a typical
+ user query.
+* Whether Gemini's `AppFunctionSearchSpec` filters by
+ `schemaCategory` / `schemaName` (in which case we're invisible
+ until we annotate) or by description (in which case we should
+ surface).
+* What feature flags / Gemini-app versions are required. App
+ Functions is generally available on Android 16+, but Gemini's
+ third-party tool picker has shipped in waves.
+
+## Verification protocol
+
+### 1. Confirm the device is set up
+
+```bash
+# Pixel 8 or newer on Android 16 QPR1+
+adb shell getprop ro.build.version.release
+adb shell pm list packages | grep -i gemini # com.google.android.apps.bard
+```
+
+### 2. Reinstall the Play debug APK with the new descriptions
+
+```bash
+./gradlew :amethyst:assemblePlayDebug
+adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
+adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity
+# sign in if needed, give Amethyst a few seconds to register
+```
+
+### 3. Confirm metadata is indexed end-to-end
+
+```bash
+adb shell cmd app_function list-app-functions | grep -c amethyst
+# should print ≥ 21 — one entry per @AppFunction across our class
+```
+
+### 4. Test prompts in Gemini
+
+These are deliberately mapped to one specific verb each. Run them in
+order, take notes on which surface a tool call and which don't.
+
+| Prompt to Gemini | Should pick |
+|---|---|
+| "Find vitorpamplona on Nostr" | searchProfiles |
+| "What's happening on Nostr today?" | getRecentFromFollows |
+| "Who am I logged in as on Nostr?" | getActiveAccountInfo |
+| "Did anyone DM me on Nostr recently?" | getRecentDms |
+| "How many sats did I earn on Nostr this week?" | getZapsReceived |
+| "Show me Nostr posts about bitcoin" | searchByHashtag |
+| "Tell me about npub1xq5eqwlhxy3ldakahsfglccvzy4j6ayyxje5a92zu90hc05dxn7qrsns90" | getProfile |
+| "What are people I follow saying on Nostr?" | getRecentFromFollows |
+| "Catch me up on what Snowden's been posting" | getNotesByUser |
+
+For each: did Gemini offer to call the tool? Did it call the right
+one? Did it render the result?
+
+### 5. Diagnose any miss
+
+If Gemini doesn't surface a verb:
+
+1. **Check Gemini's tools view.** In the Gemini app:
+ Settings → Apps. Our package should appear in the list of apps
+ the assistant can interact with. If it's not there at all, the
+ system hasn't told Gemini about us yet — wait a few minutes after
+ install or force-reindex by clearing AppSearch.
+2. **Force a re-index.**
+ ```bash
+ adb shell pm clear --user 0 com.android.appsearch || true
+ adb shell am force-stop com.vitorpamplona.amethyst.debug
+ adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity
+ ```
+3. **Verify per-prompt.** If the package is listed but a specific
+ prompt doesn't trigger a tool call, the issue is description
+ matching — our use-when phrasing isn't catching that query.
+ Adjust the kdoc and rebuild.
+
+## When schemas become worth doing
+
+We'll move from "skipped" to "implement" if:
+
+1. Step 4 above shows Gemini consistently fails to surface verbs that
+ should obviously match (suggesting it's filtering by schema, not
+ description), OR
+2. Another Nostr Android client ships AppFunctions and wants to
+ co-implement a shared schema namespace (so a Nostr-aware agent
+ could route to whichever client is installed).
+
+Until either of those happens, the simpler description-matching path
+is in place and is what every public AppFunctions sample uses today.
+
+## Open follow-ups (independent of this verification)
+
+* NIP-55 (Amber) signer support — write verbs currently refuse with
+ `AppFunctionNotSupportedException` because we can't launch Amber's
+ approval activity from a background dispatch. The PendingIntent
+ escape hatch (Option A in the signer-prompt plan) is the next move
+ if NIP-55 usage matters.
+* NWC auto-pay for `zapUser` / `zapEvent` — today we return the
+ BOLT11 invoice; the caller pastes it into a wallet. With NWC
+ configured we could pay automatically.
+* Schema definitions if step 4 above shows we need them.
diff --git a/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md b/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md
new file mode 100644
index 0000000000..f149d00818
--- /dev/null
+++ b/amethyst/plans/2026-05-26-appfunctions-screens-as-verbs.md
@@ -0,0 +1,191 @@
+# All Amethyst screens as AppFunctions / MCP endpoints
+
+**Date:** 2026-05-26
+**Status:** Active — informs the v1 read-verb surface and guides
+future MCP work
+
+## The principle
+
+Every screen in Amethyst has a dedicated `FeedContentState` driven by
+a `*FeedFilter` that reads from `LocalCache`. The list is in
+`amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt`
+— there are ~30 entries today.
+
+> Every Amethyst screen → one AppFunction verb. The verb invokes the
+> same `*FeedFilter` the screen uses, runs `feed()` against
+> `LocalCache`, and projects the result into a Gemini-friendly
+> `NoteHit` / `ProfileHit` / etc.
+
+This keeps the agent surface in sync with what the user sees, with no
+duplicate filtering logic.
+
+## Why this works
+
+* `*FeedFilter.feed()` is stateless and idempotent — it reads
+ `LocalCache` (a global singleton) and `account` state. Safe to
+ invoke from any thread, any process state, no UI lifecycle
+ required.
+* `AccountFeedContentStates` itself is owned by `AccountViewModel`,
+ but we don't need the precached state — we just need the filter
+ class. Invoking it on each AppFunction call is acceptable (a few
+ ms even on large caches).
+* The catch: `LocalCache` only contains what the foreground app
+ subscriptions have already fetched. If the user hasn't opened
+ Amethyst in days, the cache may be sparse. Acceptable trade-off:
+ the agent reflects "what's on your screen now", not "what exists
+ on Nostr right now". For freshness, the user can open the app or
+ the verb can fall back to a relay drain.
+
+## Existing verb → feed mapping
+
+| AppFunction verb | Feed source | Notes |
+|---|---|---|
+| `getFeedDigest` | `HomeNewThreadFeedFilter` | Matches the home page (new threads only, all kinds, mute-filtered) |
+| `getRecentFromFollows` | direct `INostrClient.fetchAll` (kind:1 only) | Pure kind:1 from kind:3 follows. Different shape than home — keeping both: `getRecentFromFollows` is fast / always fresh, `getFeedDigest` is "what's on my screen" |
+| `getMyMentions` | direct relay drain | Could move to `NotificationFeedFilter` |
+| `getRecentDms` | direct relay drain + decrypt | Could move to `ChatroomListKnownFeedFilter` / `ChatroomListNewFeedFilter` |
+| `getLiveStreams` | direct relay drain | Could move to `LiveStreamsFeedFilter` |
+| `searchArticles` | direct relay drain (NIP-50) | Read-side only; users already in cache via `ArticlesFeedFilter` could be merged |
+
+## Unmapped feeds (proposed verbs)
+
+These all have existing `FeedContentState`s. Adding a verb each is
+~30 lines of glue.
+
+| Screen | FeedContentState | Proposed verb name | User intent |
+|---|---|---|---|
+| Home — replies | `homeReplies` | `getRecentReplies` | "what conversations am I in?" |
+| Home — everything | `homeEverything` | `getEverythingFeed` | "the full firehose of my follows" |
+| Home — live | `homeLive` | `getLiveActivityFromFollows` | "what's live from my follows?" |
+| Video | `videoFeed` | `getVideoFeed` | "show me Nostr videos" |
+| Pictures | `picturesFeed` | `getPictureFeed` | "what photos are people posting?" |
+| Shorts | `shortsFeed` | `getShortVideoFeed` | NIP-71 short video |
+| Long-form (your follows) | `longsFeed` | `getLongFormFromFollows` | "what articles are my follows publishing?" |
+| Long-form (discover) | `discoverReads` | `discoverArticles` | "find interesting Nostr articles" |
+| Marketplace | `discoverMarketplace` | `discoverMarketplaceListings` | "what's for sale on Nostr?" |
+| Communities (discover) | `discoverCommunities` | `discoverCommunities` | "find Nostr communities" |
+| Communities (list) | `communitiesList` | `getMyCommunities` | "communities I'm a member of" |
+| Public chats (discover) | `discoverPublicChats` | `discoverPublicChats` | "find Nostr chat channels" |
+| Public chats (list) | `publicChatsFeed` | `getMyPublicChats` | "chats I'm in" |
+| DVMs | `discoverDVMs` | `discoverDvms` | "what compute services are available?" |
+| Follow sets | `discoverFollowSets` | `discoverFollowSets` | "find curated follow lists" |
+| Live streams | `liveStreamsFeed` | (replace `getLiveStreams`) | already exists |
+| Nests | `nestsFeed` | `getNests` | "audio rooms" |
+| Articles (mine + follows) | `articlesFeed` | `getMyArticles` | combined long-form |
+| Polls (open) | `openPollsFeed` | `getOpenPolls` | "what should I vote on?" |
+| Polls (closed) | `closedPollsFeed` | `getRecentPollResults` | "what did people vote on?" |
+| All polls | `pollsFeed` | (combined; less useful as a verb) | — |
+| Badges | `badgesFeed` | `getBadges` | "show me my Nostr badges" |
+| Software apps | `softwareAppsFeed` | `discoverNostrApps` | "what apps exist on Nostr?" |
+| Emoji packs | `browseEmojiSetsFeed` | `discoverEmojiPacks` | "find custom emoji" |
+| Follow packs | `followPacksFeed` | `discoverFollowPacks` | "find people to follow by topic" |
+| Products | `productsFeed` | `getProductListings` | "what products are listed?" |
+| Calendar appointments | `calendarAppointmentsFeed` | `getUpcomingEvents` | "what Nostr events are coming up?" |
+| Calendar collections | `calendarCollectionsFeed` | `getEventCollections` | "what conferences are happening?" |
+| Notifications (all) | `notifications` | `getRecentNotifications` | "what's happened to me on Nostr?" |
+| Notifications (follows) | `notificationsFollowing` | (variant param) | "notifications from follows" |
+| Notifications (everyone) | `notificationsEveryone` | (variant param) | "all notifications" |
+| Drafts | `drafts` | `getMyDrafts` | "what did I start writing?" |
+| Web bookmarks | `webBookmarks` | `getMyBookmarks` | "what did I bookmark?" |
+
+That's ~25 unmapped feeds. Each verb is a ~30-line wrapper following
+the `getFeedDigest` shape — read filter, project to result type,
+return.
+
+## Implementation pattern
+
+```kotlin
+@AppFunction(isDescribedByKDoc = true)
+suspend fun getMyBookmarks(
+ appFunctionContext: AppFunctionContext,
+ hoursBack: Int = 168,
+ maxNotes: Int = 50,
+): SearchNotesResult {
+ val account = Amethyst.instance.sessionManager.loggedInAccount()
+ ?: return SearchNotesResult.empty()
+ val sinceSecs = TimeUtils.now() - hoursBack.coerceIn(1, 24 * 365).toLong() * 3600L
+
+ val feed = WebBookmarkFeedFilter(account).feed()
+ .asSequence()
+ .mapNotNull { it.event }
+ .filter { it.createdAt >= sinceSecs }
+ .sortedByDescending { it.createdAt }
+ .take(maxNotes.coerceIn(1, 200))
+ .map { it.toFeedNoteHit() }
+ .toList()
+
+ return SearchNotesResult(matches = feed)
+}
+```
+
+The pattern is genuinely uniform. Most verbs would even share a
+helper like `feedAsResult(filter, sinceHours, max) -> SearchNotesResult`.
+
+## Result-type strategy
+
+Most feeds project to `SearchNotesResult` since the screen is "a list
+of notes." A few need bespoke types:
+* Notifications — could return a `NotificationHit` carrying the
+ notification kind (reply, mention, zap, repost, reaction) since
+ the LLM needs to know "you got 3 zaps and 1 reply".
+* Calendar events — natural fit for an `EventHit` with start/end
+ times.
+* Communities / public chats — list-of-rooms more than list-of-notes.
+
+Default to reusing `NoteHit` (now carries `kind`); add bespoke types
+only when the LLM needs structure the LLM can't derive from `kind` +
+`content`.
+
+## What doesn't fit cleanly
+
+Some screens are too interactive for a single AppFunction call:
+* **Chats / DMs** — sending and reading a stream of messages is
+ more conversational; the agent loop should handle it. `sendDm` +
+ `getRecentDms` cover the basics.
+* **Profile pages** — already covered by `getProfile` +
+ `getNotesByUser` rather than a "profile feed."
+* **Settings screens** — out of scope; the agent shouldn't mutate
+ user prefs.
+
+## When the cache is cold
+
+For verbs backed by `LocalCache` (the screens), the result is sparse
+when the user hasn't opened the app recently. The mitigation strategy
+is:
+
+1. The relay-drain verbs (`searchProfiles`, `searchNotes`,
+ `searchByHashtag`, `searchArticles`, `getRecentFromFollows`,
+ `getNotesByUser`, `getProfile`, `getRecentDms`,
+ `getZapsReceived`, `getLiveStreams`) all do their own fetch.
+ Use these when freshness matters.
+2. The screen-mirror verbs (`getFeedDigest` and the proposed
+ additions) reflect what the foreground saw. Use these when
+ "what was the user looking at?" is the semantic.
+
+Both shapes have value. The agent's prompt-matching kdoc decides
+which gets called.
+
+## MCP angle
+
+When we add an MCP server for Amethyst (separate effort), the same
+`*FeedFilter.feed()` calls power the MCP tool implementations. The
+AppFunctions adapter and the MCP server share the projection
+helpers (`toFeedNoteHit`, `toProfileHit`, etc.) and the result
+types. The transport layer is the only difference.
+
+The screen-feed mapping above is the source of truth for both
+surfaces.
+
+## Concrete next steps
+
+Highest user-value follow-ups (each ~30 min):
+
+1. `getRecentNotifications` — answers "what's been happening to me
+ on Nostr?" without a per-kind walk.
+2. `getMyBookmarks` — agent recall over saved Nostr content.
+3. `getOpenPolls` — "what should I vote on?"
+4. `getMyDrafts` — "what was I writing?"
+5. `getUpcomingEvents` — calendar / agenda integration.
+
+After these, the rest are mostly "discover X" variants that follow
+the same template.
diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml
index aafa18bfb6..60c2dfb7a1 100644
--- a/amethyst/src/play/AndroidManifest.xml
+++ b/amethyst/src/play/AndroidManifest.xml
@@ -38,6 +38,23 @@
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" />
+
+
+
\ No newline at end of file
diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt
new file mode 100644
index 0000000000..5f5e1b0f22
--- /dev/null
+++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt
@@ -0,0 +1,2292 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.appfunctions
+
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionInvalidArgumentException
+import androidx.appfunctions.AppFunctionNotSupportedException
+import androidx.appfunctions.AppFunctionSerializable
+import androidx.appfunctions.service.AppFunction
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.commons.actions.DmActions
+import com.vitorpamplona.amethyst.commons.actions.FollowActions
+import com.vitorpamplona.amethyst.commons.actions.SearchActions
+import com.vitorpamplona.amethyst.commons.actions.ZapActions
+import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
+import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
+import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
+import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
+import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.toHexKey
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
+import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
+import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
+import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
+import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
+import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
+import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
+import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
+import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
+import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
+import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
+import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
+import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
+import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
+import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
+import com.vitorpamplona.quartz.utils.TimeUtils
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * Bridge that exposes Amethyst's "verbs" (commons/.../actions/) to the Android
+ * App Functions runtime, which Gemini and other system agents can drive.
+ *
+ * **Status — pre-stable.** Built against androidx.appfunctions 1.0.0-alpha09.
+ * The API is still moving; treat every release as ABI-breaking until 1.0.0
+ * ships. Scoped to the `play` build flavor only — the F-Droid channel
+ * ships without any Google AI dependencies.
+ *
+ * Plain class, no inheritance — the KSP compiler discovers `@AppFunction`
+ * methods and generates the dispatcher glue (see
+ * `amethyst/build/generated/ksp/playDebug/.../$AmethystAppFunctions_AppFunctionInvoker.kt`).
+ * The generated invoker constructs this class via its default no-arg
+ * constructor, so no `AppFunctionConfiguration.Provider` is required on
+ * the Application. If we ever add an @AppFunction host class with
+ * constructor parameters, we'll need to register a factory via Provider —
+ * the docs nudge that direction, but the runtime does not require it for
+ * default-constructed classes.
+ *
+ * Read verbs work with any account state. Write verbs (post / follow /
+ * unfollow / sendDm) require a signer that can sign in-process — i.e.
+ * a local [NostrSignerInternal] or a remote NIP-46 bunker. NIP-55
+ * external signers (Amber) are refused with
+ * [AppFunctionNotSupportedException] for now because the agent
+ * dispatch happens outside the foreground task stack — the user can't
+ * see the Amber approval activity from inside Gemini. See
+ * `amethyst/plans/2026-05-25-appfunctions-signer-prompts.md` for the
+ * design and the planned PendingIntent escape hatch.
+ *
+ * Account scoping uses the currently active account from
+ * [com.vitorpamplona.amethyst.Amethyst.instance.sessionManager] — the same
+ * Account the foreground UI is bound to. When no account is signed in,
+ * every function returns an empty result rather than failing the call.
+ */
+class AmethystAppFunctions {
+ /**
+ * Find a person on Nostr by name, handle, or NIP-05. Use when the user
+ * wants to look someone up on Nostr ("find vitor on nostr", "search for
+ * jack dorsey", "who is alice@damus on nostr"), translate a display
+ * name to an npub, or discover a user before following / DMing /
+ * zapping them.
+ *
+ * Backed by NIP-50 full-text search across the active account's
+ * configured search relays (kind:10007), with a fallback to
+ * Amethyst's curated default search-relay set.
+ *
+ * @param query free-form search text (display name, NIP-05 handle, etc.)
+ * @param limit max number of profiles to return — capped to 50.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun searchProfiles(
+ appFunctionContext: AppFunctionContext,
+ query: String,
+ limit: Int = 10,
+ ): SearchProfilesResult {
+ val cappedLimit = limit.coerceIn(1, 50)
+ val filter = SearchActions.searchProfilesFilter(query, cappedLimit) ?: return SearchProfilesResult.empty()
+
+ // Snapshot the active account + relay set + client at function entry
+ // and never touch sessionManager again from this dispatch. If the
+ // user switches account mid-fetch, this snapshot keeps the request
+ // routed to the relays we originally queried — caller still gets a
+ // coherent result rather than events mixed across accounts.
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty()
+ val client = Amethyst.instance.client
+
+ // SearchRelayListState's flow already resolves to a concrete relay
+ // set: NIP-44-decrypted private entries + public entries, or the
+ // curated default set when the user has no kind:10007. Same source
+ // of truth the foreground UI uses.
+ val relays = account.searchRelayList.flow.value
+ if (relays.isEmpty()) return SearchProfilesResult.empty()
+
+ // Quartz's INostrClient.fetchAll handles subscribe → drain on
+ // EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort
+ // newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs)
+ // so a slow relay can't stall the dispatch.
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? MetadataEvent }
+ .distinctBy { it.pubKey }
+ .sortedByDescending { it.createdAt }
+ .take(cappedLimit)
+ .map { it.toProfileHit() }
+
+ return SearchProfilesResult(matches = hits)
+ }
+
+ /**
+ * Read the user's Nostr timeline / home feed. Use when the user asks
+ * "what's new on Nostr", "what's happening on Nostr today", "catch me
+ * up on my Nostr feed", or wants a summary of recent posts from
+ * people they follow.
+ *
+ * Drains recent kind:1 short text notes from the people the active
+ * account follows; the same query the Amethyst home-feed UI runs,
+ * truncated to one batch. Queries the account's home relays (NIP-65
+ * outbox + any private storage + local relays).
+ *
+ * @param limit max notes to return, capped to 200. Default 30.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getRecentFromFollows(
+ appFunctionContext: AppFunctionContext,
+ limit: Int = 30,
+ ): SearchNotesResult {
+ val cappedLimit = limit.coerceIn(1, 200)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+
+ val events = fetchFollowFeed(account = account, sinceSecs = null, limit = cappedLimit)
+ return SearchNotesResult(matches = events.map { it.toNoteHit() })
+ }
+
+ /**
+ * Build a structured digest of the user's Nostr home feed for an
+ * AI summary. Use when the user asks "summarize my Nostr feed",
+ * "give me a digest of what my follows posted today", "recap
+ * Nostr for me", "what have people been talking about on Nostr",
+ * "summarize what's on my Nostr home screen", or any "summary /
+ * digest / recap of my Nostr timeline" intent.
+ *
+ * **Mirrors the home page exactly.** Runs the same
+ * `HomeNewThreadFeedFilter` the Amethyst home screen uses against
+ * the local event cache — so the LLM sees what the user would see
+ * if they opened the app: short text notes, reposts (deduped),
+ * long-form articles, polls, comments, audio, classifieds,
+ * highlights, and the rest. Respects the user's currently selected
+ * NIP-51 follow list (not just plain kind:3), filters muted users,
+ * and excludes replies (top-level threads only).
+ *
+ * Because the feed is read from the local cache rather than drained
+ * fresh from relays, the digest reflects what the foreground app
+ * has previously gathered — sparse if the app hasn't been opened
+ * recently. Open Amethyst before asking the agent to summarise if
+ * you want the freshest possible result.
+ *
+ * Returns the raw notes plus pre-extracted signals the LLM needs
+ * to write a useful summary without re-deriving them: total note
+ * count, unique author count, top hashtags in the window, and the
+ * most-mentioned users (display names resolved from local kind:0
+ * cache). The LLM composes the natural-language summary from
+ * these.
+ *
+ * @param hoursBack window size in hours. Capped to 168 (7 days),
+ * default 12. Events older than this are excluded from both the
+ * stats and the body.
+ * @param maxNotes max notes returned in the body. Capped to 200.
+ * Default 60 — big enough for a meaningful summary, small enough
+ * to fit comfortably in the LLM's prompt. Stats are computed
+ * over the full in-window set, not just the trimmed body.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getFeedDigest(
+ appFunctionContext: AppFunctionContext,
+ hoursBack: Int = 12,
+ maxNotes: Int = 60,
+ ): FeedDigestResult {
+ val cappedHours = hoursBack.coerceIn(1, 24 * 7)
+ val cappedMaxNotes = maxNotes.coerceIn(1, 200)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FeedDigestResult.empty()
+
+ val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L
+
+ // Use the same filter the home screen uses, so the digest
+ // mirrors what the user actually sees in the UI. The filter
+ // reads from LocalCache (already maintained by the foreground
+ // subscriptions) and applies the user's selected follow list,
+ // mutes, repost dedup, and new-thread-only rule.
+ val feed =
+ HomeNewThreadFeedFilter(account)
+ .feed()
+ .asSequence()
+ .mapNotNull { it.event }
+ .filter { it.createdAt >= sinceSecs }
+ .toList()
+
+ // Hashtag frequencies — case-folded so `#Bitcoin` and
+ // `#bitcoin` collapse to one bucket.
+ val hashtagCounts = HashMap()
+ // Mention frequencies, keyed by mentioned pubkey hex.
+ val mentionCounts = HashMap()
+ val uniqueAuthors = HashSet()
+
+ for (ev in feed) {
+ uniqueAuthors.add(ev.pubKey)
+ for (tag in ev.tags) {
+ if (tag.size < 2) continue
+ when (tag[0]) {
+ "t" -> {
+ val cleaned = tag[1].trim().removePrefix("#").lowercase()
+ if (cleaned.isNotEmpty()) {
+ hashtagCounts.merge(cleaned, 1, Int::plus)
+ }
+ }
+ "p" -> {
+ // Skip self-mentions (the author tags themself
+ // in some clients) — not useful for the digest.
+ if (tag[1].length == 64 && tag[1] != ev.pubKey) {
+ mentionCounts.merge(tag[1], 1, Int::plus)
+ }
+ }
+ }
+ }
+ }
+
+ val topHashtags =
+ hashtagCounts.entries
+ .sortedByDescending { it.value }
+ .take(TOP_HASHTAGS_LIMIT)
+ .map { HashtagFrequency(tag = it.key, noteCount = it.value) }
+
+ val topMentions =
+ mentionCounts.entries
+ .sortedByDescending { it.value }
+ .take(TOP_MENTIONS_LIMIT)
+ .map { (pub, count) ->
+ MentionFrequency(
+ npub = NPub.create(pub),
+ pubkeyHex = pub,
+ displayName = displayNameOf(pub),
+ mentionCount = count,
+ )
+ }
+
+ return FeedDigestResult(
+ windowHours = cappedHours,
+ totalNoteCount = feed.size,
+ uniqueAuthorCount = uniqueAuthors.size,
+ topHashtags = topHashtags,
+ topMentions = topMentions,
+ // Truncate to the caller-requested limit for the body —
+ // the LLM has the stats either way and doesn't need every
+ // note quoted. Sorted newest-first.
+ notes =
+ feed
+ .sortedByDescending { it.createdAt }
+ .take(cappedMaxNotes)
+ .map { it.toFeedNoteHit() },
+ )
+ }
+
+ /**
+ * Shared core of [getRecentFromFollows] and [getFeedDigest]: drain
+ * recent kind:1 notes from the active account's follow set,
+ * optionally filtered by `since`. Returns empty when there's no
+ * account, no follows, or no relays configured.
+ */
+ private suspend fun fetchFollowFeed(
+ account: com.vitorpamplona.amethyst.model.Account,
+ sinceSecs: Long?,
+ limit: Int,
+ ): List {
+ val authors = account.kind3FollowList.flow.value.authors
+ if (authors.isEmpty()) return emptyList()
+
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return emptyList()
+
+ val filter =
+ Filter(
+ kinds = listOf(TextNoteEvent.KIND),
+ authors = authors.toList(),
+ since = sinceSecs,
+ limit = limit,
+ )
+ return Amethyst.instance.client
+ .fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ ).mapNotNull { it as? TextNoteEvent }
+ .take(limit)
+ }
+
+ /**
+ * Read recent Nostr posts from a specific user. Use when the user
+ * asks "what did Snowden post recently on Nostr", "catch me up on
+ * what Jack has been posting", "show me Alice's latest notes", or
+ * wants to see one specific Nostr user's activity.
+ *
+ * Pass the target user's npub or hex pubkey — use [searchProfiles]
+ * first if you only have a display name. Queries the target's
+ * NIP-65 write relays when cached, falling back to the active
+ * account's home relays.
+ *
+ * @param user npub (`npub1…`) or 64-character hex pubkey.
+ * @param limit max notes to return, capped to 100. Default 20.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getNotesByUser(
+ appFunctionContext: AppFunctionContext,
+ user: String,
+ limit: Int = 20,
+ ): SearchNotesResult {
+ val cappedLimit = limit.coerceIn(1, 100)
+ val pubkey = decodeUserOrThrow(user)
+
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+ val client = Amethyst.instance.client
+
+ val targetWriteRelays =
+ account.cache
+ .checkGetOrCreateUser(pubkey)
+ ?.outboxRelays()
+ ?.toSet()
+ .orEmpty()
+ val relays =
+ targetWriteRelays
+ .ifEmpty { account.homeRelays.flow.value }
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return SearchNotesResult.empty()
+
+ val filter =
+ Filter(
+ kinds = listOf(TextNoteEvent.KIND),
+ authors = listOf(pubkey),
+ limit = cappedLimit,
+ )
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? TextNoteEvent }
+ .filter { it.pubKey == pubkey }
+ .take(cappedLimit)
+ .map { it.toNoteHit() }
+
+ return SearchNotesResult(matches = hits)
+ }
+
+ /**
+ * Look up one Nostr profile by npub or hex pubkey. Use when the user
+ * asks "who is npub1…", "tell me about [npub]", "what's [user]'s
+ * Nostr profile", or wants the bio / NIP-05 / Lightning address of a
+ * specific Nostr user.
+ *
+ * Returns the latest kind:0 metadata — cache-first, with a short
+ * network fallback when the user's profile hasn't been observed
+ * locally yet.
+ *
+ * @param user npub (`npub1…`) or 64-character hex pubkey.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getProfile(
+ appFunctionContext: AppFunctionContext,
+ user: String,
+ ): GetProfileResult {
+ val pubkey = decodeUserOrThrow(user)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return GetProfileResult.notFound(pubkey)
+
+ // Cache-first: the foreground UI keeps observed metadata around.
+ val cached =
+ account.cache
+ .checkGetOrCreateUser(pubkey)
+ ?.metadataOrNull()
+ if (cached != null) {
+ val info = cached.flow.value?.info
+ return GetProfileResult(
+ found = true,
+ profile =
+ ProfileHit(
+ npub = NPub.create(pubkey),
+ pubkeyHex = pubkey,
+ displayName = cached.bestName(),
+ about = info?.about,
+ nip05 = cached.nip05(),
+ picture = cached.profilePicture(),
+ lnAddress = cached.lnAddress(),
+ ),
+ )
+ }
+
+ // Cache miss: drain bootstrap relays for the latest kind:0.
+ val client = Amethyst.instance.client
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return GetProfileResult.notFound(pubkey)
+
+ val filter =
+ Filter(
+ kinds = listOf(MetadataEvent.KIND),
+ authors = listOf(pubkey),
+ limit = 1,
+ )
+ val event =
+ client
+ .fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ ).mapNotNull { it as? MetadataEvent }
+ .filter { it.pubKey == pubkey }
+ .maxByOrNull { it.createdAt }
+ ?: return GetProfileResult.notFound(pubkey)
+
+ return GetProfileResult(found = true, profile = event.toProfileHit())
+ }
+
+ /**
+ * Find Nostr posts about a topic via hashtag. Use when the user asks
+ * "show me Nostr posts about Bitcoin", "find Nostr discussion of
+ * #Tor", "what's the Nostr take on [topic]", or wants to browse
+ * conversation about a specific subject.
+ *
+ * Pass the tag value without the leading `#` — "bitcoin", not
+ * "#bitcoin". The hashtag is lowercased before matching (the
+ * convention most Nostr clients follow).
+ *
+ * @param hashtag the tag value without the leading `#`.
+ * @param limit max notes to return, capped to 100. Default 30.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun searchByHashtag(
+ appFunctionContext: AppFunctionContext,
+ hashtag: String,
+ limit: Int = 30,
+ ): SearchNotesResult {
+ val tag = hashtag.trim().removePrefix("#").lowercase()
+ if (tag.isEmpty()) throw AppFunctionInvalidArgumentException("hashtag must not be blank")
+ val cappedLimit = limit.coerceIn(1, 100)
+
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+ val client = Amethyst.instance.client
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return SearchNotesResult.empty()
+
+ val filter =
+ Filter(
+ kinds = listOf(TextNoteEvent.KIND),
+ tags = mapOf("t" to listOf(tag)),
+ limit = cappedLimit,
+ )
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? TextNoteEvent }
+ .take(cappedLimit)
+ .map { it.toNoteHit() }
+
+ return SearchNotesResult(matches = hits)
+ }
+
+ /**
+ * Report who the user is signed in as on Nostr. Use when the user
+ * asks "who am I logged in as on Nostr", "what's my npub", "what's
+ * my Nostr identity", "how many people do I follow on Nostr", or
+ * any other "tell me about my Nostr account" query.
+ *
+ * Returns the active account's npub, display name, NIP-05 handle,
+ * follow count, and how many relays are configured for outbox /
+ * DM inbox. Use this for Nostr-side diagnostics rather than as a
+ * general "who am I" answer.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getActiveAccountInfo(appFunctionContext: AppFunctionContext): AccountInfoResult {
+ val account =
+ Amethyst.instance.sessionManager.loggedInAccount() ?: return AccountInfoResult.signedOut()
+
+ val myPub = account.signer.pubKey
+ val myUser = account.cache.checkGetOrCreateUser(myPub)
+ val meta = myUser?.metadataOrNull()
+
+ return AccountInfoResult(
+ signedIn = true,
+ npub = NPub.create(myPub),
+ pubkeyHex = myPub,
+ displayName = meta?.bestName(),
+ nip05 = meta?.nip05(),
+ followCount = account.kind3FollowList.userList.value.size,
+ outboxRelayCount = account.homeRelays.flow.value.size,
+ dmRelayCount = account.dmRelays.flow.value.size,
+ )
+ }
+
+ /**
+ * Most recent kind:1 notes the active account itself published. For
+ * "what did I post recently?" — drains the user's own outbox relays
+ * filtered to their own pubkey.
+ *
+ * @param limit max notes to return, capped to 100. Default 20.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getMyRecentNotes(
+ appFunctionContext: AppFunctionContext,
+ limit: Int = 20,
+ ): SearchNotesResult {
+ val cappedLimit = limit.coerceIn(1, 100)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+ val client = Amethyst.instance.client
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return SearchNotesResult.empty()
+
+ val myPub = account.signer.pubKey
+ val filter =
+ Filter(
+ kinds = listOf(TextNoteEvent.KIND),
+ authors = listOf(myPub),
+ limit = cappedLimit,
+ )
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? TextNoteEvent }
+ .filter { it.pubKey == myPub }
+ .take(cappedLimit)
+ .map { it.toNoteHit() }
+
+ return SearchNotesResult(matches = hits)
+ }
+
+ /**
+ * Notes where someone tagged the active account with a `p` tag —
+ * the Nostr equivalent of being @-mentioned. Use this for "did
+ * anyone mention me recently?".
+ *
+ * @param limit max notes to return, capped to 100. Default 20.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getMyMentions(
+ appFunctionContext: AppFunctionContext,
+ limit: Int = 20,
+ ): SearchNotesResult {
+ val cappedLimit = limit.coerceIn(1, 100)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+ val client = Amethyst.instance.client
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return SearchNotesResult.empty()
+
+ val myPub = account.signer.pubKey
+ val filter =
+ Filter(
+ kinds = listOf(TextNoteEvent.KIND),
+ tags = mapOf("p" to listOf(myPub)),
+ limit = cappedLimit,
+ )
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? TextNoteEvent }
+ .take(cappedLimit)
+ .map { it.toNoteHit() }
+
+ return SearchNotesResult(matches = hits)
+ }
+
+ /**
+ * Replies to a specific note (kind:1 events with an `e` tag
+ * pointing at [eventId]). Used for "did anyone respond to my last
+ * post?" — pass `getMyRecentNotes(1).matches.first().eventId`
+ * from a previous call, or any other note you want to track.
+ *
+ * @param eventId 64-character hex id of the note being replied to.
+ * @param limit max replies to return, capped to 100. Default 20.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getRepliesToNote(
+ appFunctionContext: AppFunctionContext,
+ eventId: String,
+ limit: Int = 20,
+ ): SearchNotesResult {
+ if (eventId.length != 64) {
+ throw AppFunctionInvalidArgumentException("eventId must be 64-character hex (nevent bech32 not yet supported)")
+ }
+ val cappedLimit = limit.coerceIn(1, 100)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+ val client = Amethyst.instance.client
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return SearchNotesResult.empty()
+
+ val filter =
+ Filter(
+ kinds = listOf(TextNoteEvent.KIND),
+ tags = mapOf("e" to listOf(eventId)),
+ limit = cappedLimit,
+ )
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? TextNoteEvent }
+ .filter { it.id != eventId } // self-reference safety
+ .take(cappedLimit)
+ .map { it.toNoteHit() }
+
+ return SearchNotesResult(matches = hits)
+ }
+
+ /**
+ * Report how many sats the user earned on Nostr in a time window.
+ * Use when the user asks "did I get any zaps today", "how many sats
+ * did I earn on Nostr this week", "did anyone zap my last post",
+ * or wants a summary of incoming NIP-57 Lightning zaps.
+ *
+ * Drains kind:9735 zap receipts addressed to the user in the window
+ * and parses the bolt11 invoice from each to compute total sats.
+ * Returns total + per-window zap count + unique zapper count.
+ *
+ * @param hoursBack window size in hours. Capped to 168 (7 days),
+ * default 24.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getZapsReceived(
+ appFunctionContext: AppFunctionContext,
+ hoursBack: Int = 24,
+ ): ZapsReceivedResult {
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return ZapsReceivedResult.empty()
+ val client = Amethyst.instance.client
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return ZapsReceivedResult.empty()
+
+ val cappedHours = hoursBack.coerceIn(1, 24 * 7)
+ val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L
+ val myPub = account.signer.pubKey
+
+ val filter =
+ Filter(
+ kinds = listOf(LnZapEvent.KIND),
+ tags = mapOf("p" to listOf(myPub)),
+ since = sinceSecs,
+ limit = 500,
+ )
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val receipts = events.mapNotNull { it as? LnZapEvent }
+ val zapperIds = mutableSetOf()
+ var totalSats = 0L
+ var unparseable = 0
+ for (z in receipts) {
+ val bolt11 =
+ z.tags
+ .firstOrNull { it.size > 1 && it[0] == "bolt11" }
+ ?.get(1)
+ val sats =
+ bolt11
+ ?.let { runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull() }
+ ?: run {
+ unparseable++
+ 0L
+ }
+ totalSats += sats
+ // Zap sender is recorded in the description's signed kind:9734;
+ // we only have it as a pubkey-id mention via `P` tag on some
+ // receipts. Best-effort:
+ z.tags
+ .firstOrNull { it.size > 1 && (it[0] == "P" || it[0] == "p" && it[1] != myPub) }
+ ?.get(1)
+ ?.let { zapperIds.add(it) }
+ }
+
+ return ZapsReceivedResult(
+ windowHours = cappedHours,
+ totalSats = totalSats,
+ zapCount = receipts.size,
+ uniqueZapperCount = zapperIds.size,
+ unparseableInvoiceCount = unparseable,
+ )
+ }
+
+ /**
+ * Read recent Nostr direct messages. Use when the user asks "did I
+ * get any Nostr DMs", "what did Alice DM me", "show me my recent
+ * Nostr messages", "summarize my unread Nostr DMs", or wants
+ * decrypted message content (not just notifications) from Nostr.
+ *
+ * Drains NIP-17 gift wraps from the active account's DM-inbox
+ * relays, decrypts each in-process (Amethyst is the only place
+ * the user's NIP-44 keys live), and returns the inner kind:14
+ * messages with sender display names attached. File-attachment
+ * DMs (kind:15) are filtered out for now to keep the response
+ * small.
+ *
+ * @param peer optional npub/hex; when set, only returns messages
+ * to/from that specific peer. When null, returns conversations
+ * with anyone.
+ * @param hoursBack window size in hours. Capped to 168 (7 days),
+ * default 24.
+ * @param limit max messages to return, capped to 100. Default 20.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getRecentDms(
+ appFunctionContext: AppFunctionContext,
+ peer: String?,
+ hoursBack: Int = 24,
+ limit: Int = 20,
+ ): DmsResult {
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return DmsResult.empty()
+ val client = Amethyst.instance.client
+ val cappedHours = hoursBack.coerceIn(1, 24 * 7)
+ val cappedLimit = limit.coerceIn(1, 100)
+ val peerPub = peer?.takeIf { it.isNotBlank() }?.let { decodeUserOrThrow(it) }
+
+ // DM-inbox relays per kind:10050; fall back to home relays if the
+ // user never published a kind:10050 (interop with stale clients).
+ val relays =
+ account.dmRelays.flow.value
+ .ifEmpty { account.homeRelays.flow.value }
+ if (relays.isEmpty()) return DmsResult.empty()
+
+ val myPub = account.signer.pubKey
+ val sinceSecs = TimeUtils.now() - cappedHours.toLong() * 3600L
+
+ // NIP-59 gift wraps randomise their `created_at` up to two days
+ // in the past, so we widen the filter by 2 days. Same trick the
+ // foreground client and amy use.
+ val filter =
+ Filter(
+ kinds = listOf(GiftWrapEvent.KIND),
+ tags = mapOf("p" to listOf(myPub)),
+ since = sinceSecs - TimeUtils.twoDays(),
+ limit = 200,
+ )
+ val wraps =
+ client
+ .fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ ).mapNotNull { it as? GiftWrapEvent }
+
+ val seen = HashSet()
+ val messages = mutableListOf()
+ for (wrap in wraps) {
+ val inner = wrap.unwrapAndUnsealOrNull(account.signer) ?: continue
+ if (inner !is BaseDMGroupEvent) continue
+ if (inner !is ChatMessageEvent) continue // skip file headers for v1; keep payload small
+ if (!seen.add(inner.id)) continue
+ // After widening for randomised `created_at`, drop anything
+ // outside the requested window so the result honours the
+ // caller's hoursBack.
+ if (inner.createdAt < sinceSecs) continue
+ if (peerPub != null && peerPub !in inner.groupMembers()) continue
+
+ messages.add(
+ DmMessage(
+ fromNpub = NPub.create(inner.pubKey),
+ fromPubkeyHex = inner.pubKey,
+ fromDisplayName = displayNameOf(inner.pubKey),
+ sentByMe = inner.pubKey == myPub,
+ content = inner.content,
+ createdAt = inner.createdAt,
+ ),
+ )
+ }
+
+ return DmsResult(
+ windowHours = cappedHours,
+ messages =
+ messages
+ .sortedByDescending { it.createdAt }
+ .take(cappedLimit),
+ )
+ }
+
+ /**
+ * NIP-50 search restricted to NIP-23 long-form articles
+ * (kind:30023). Use for "find Nostr articles about [topic]" when
+ * the user wants written-up posts rather than short notes.
+ *
+ * @param query free-form search text.
+ * @param limit max articles to return, capped to 50. Default 10.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun searchArticles(
+ appFunctionContext: AppFunctionContext,
+ query: String,
+ limit: Int = 10,
+ ): SearchNotesResult {
+ val cappedLimit = limit.coerceIn(1, 50)
+ val filter =
+ SearchActions.searchNotesFilter(
+ query = query,
+ kinds = listOf(LongTextNoteEvent.KIND),
+ limit = cappedLimit,
+ ) ?: return SearchNotesResult.empty()
+
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+ val client = Amethyst.instance.client
+ val relays = account.searchRelayList.flow.value
+ if (relays.isEmpty()) return SearchNotesResult.empty()
+
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? LongTextNoteEvent }
+ .take(cappedLimit)
+ .map { (it as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit() }
+
+ return SearchNotesResult(matches = hits)
+ }
+
+ /**
+ * Live audio/video streams currently broadcasting on Nostr (NIP-53
+ * kind:30311 events with `status=live`). Use for "what's live on
+ * Nostr right now?".
+ *
+ * @param limit max streams to return, capped to 50. Default 20.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getLiveStreams(
+ appFunctionContext: AppFunctionContext,
+ limit: Int = 20,
+ ): LiveStreamsResult {
+ val cappedLimit = limit.coerceIn(1, 50)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return LiveStreamsResult.empty()
+ val client = Amethyst.instance.client
+ val relays =
+ account.homeRelays.flow.value
+ .ifEmpty { DefaultNIP65RelaySet }
+ if (relays.isEmpty()) return LiveStreamsResult.empty()
+
+ // NIP-53 has no `since` semantics — a live activity can have an
+ // arbitrarily old createdAt. We over-fetch and post-filter for
+ // `isLive()`, which also applies the 8-hour staleness guard
+ // (status=live + recent createdAt) baked into quartz.
+ val filter =
+ Filter(
+ kinds = listOf(LiveActivitiesEvent.KIND),
+ limit = cappedLimit * 4,
+ )
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val streams =
+ events
+ .mapNotNull { it as? LiveActivitiesEvent }
+ .filter { it.isLive() }
+ .take(cappedLimit)
+ .map { ev ->
+ val hostPub = ev.host()?.pubKey
+ LiveStreamHit(
+ eventId = ev.id,
+ title = ev.title(),
+ summary = ev.summary(),
+ streamingUrl = ev.streaming(),
+ hostNpub = hostPub?.let { NPub.create(it) },
+ hostPubkeyHex = hostPub,
+ hostDisplayName = hostPub?.let { displayNameOf(it) },
+ startsAt = ev.starts(),
+ createdAt = ev.createdAt,
+ )
+ }
+
+ return LiveStreamsResult(streams = streams)
+ }
+
+ // ------------------------------------------------------------------
+ // Write verbs — gated on a signer that can sign without launching a
+ // foreground activity. See requireInProcessSigner below.
+ // ------------------------------------------------------------------
+
+ /**
+ * Publish a short text note on Nostr. Use when the user asks "post
+ * this to Nostr", "tweet this on Nostr", "share [X] on Nostr",
+ * "publish a Nostr note saying [X]", or any other "send to Nostr"
+ * intent for plain-text content.
+ *
+ * Publishes a NIP-10 kind:1 short text note as the signed-in user,
+ * broadcast to the account's configured outbox relays. Returns per-
+ * relay ack so the caller can confirm the post landed.
+ *
+ * @param text the note body. Cannot be blank; capped at 8000
+ * characters so an accidentally-pasted document doesn't try to
+ * become a Nostr post.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun postNote(
+ appFunctionContext: AppFunctionContext,
+ text: String,
+ ): WriteResult {
+ val body = text.trim()
+ if (body.isEmpty()) throw AppFunctionInvalidArgumentException("text cannot be blank")
+ if (body.length > MAX_NOTE_LENGTH) {
+ throw AppFunctionInvalidArgumentException("text is $${body.length} chars; cap is $MAX_NOTE_LENGTH")
+ }
+
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
+ requireInProcessSigner(account.signer)
+ val relays = account.outboxRelays.flow.value
+ if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured")
+
+ val template = TextNoteEvent.build(body)
+ val signed = account.signer.sign(template)
+ // Mirror the foreground UI: cache the freshly-signed event so
+ // subsequent reads see it without waiting for a relay echo.
+ account.cache.justConsumeMyOwnEvent(signed)
+ val ack = Amethyst.instance.client.publishAndConfirmDetailed(signed, relays, PUBLISH_TIMEOUT_SECS)
+
+ return WriteResult.from(signed.id, ack)
+ }
+
+ /**
+ * Follow a user on Nostr. Use when the user asks "follow [X] on
+ * Nostr", "add [npub] to my Nostr follows", or "subscribe to
+ * [user]" with a Nostr context. Idempotent — re-following someone
+ * already followed is a safe no-op.
+ *
+ * Adds [user] to the signed-in account's NIP-02 kind:3 follow list
+ * and publishes the updated list. [WriteResult.changed] reports
+ * `false` when the user is already followed.
+ *
+ * @param user npub (`npub1…`) or 64-character hex pubkey.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun followUser(
+ appFunctionContext: AppFunctionContext,
+ user: String,
+ ): WriteResult {
+ val target = decodeUserOrThrow(user)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
+ if (target == account.signer.pubKey) {
+ throw AppFunctionInvalidArgumentException("cannot follow yourself")
+ }
+ requireInProcessSigner(account.signer)
+ val relays = account.outboxRelays.flow.value
+ if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured")
+
+ val currentList = account.kind3FollowList.getFollowListEvent()
+ if (currentList != null && currentList.isTaggedUser(target)) {
+ return WriteResult.unchanged()
+ }
+
+ // Relay hint from cached kind:10002 so the follow tag points
+ // readers at where the target publishes.
+ val relayHint =
+ account.cache
+ .checkGetOrCreateUser(target)
+ ?.outboxRelays()
+ ?.firstOrNull()
+
+ val newList =
+ FollowActions.buildFollow(
+ signer = account.signer,
+ pubkeyToFollow = target,
+ currentContactList = currentList,
+ relayHint = relayHint,
+ )
+ account.cache.justConsumeMyOwnEvent(newList)
+ val ack = Amethyst.instance.client.publishAndConfirmDetailed(newList, relays, PUBLISH_TIMEOUT_SECS)
+ return WriteResult.from(newList.id, ack)
+ }
+
+ /**
+ * Unfollow a user on Nostr. Use when the user asks "unfollow [X]
+ * on Nostr", "remove [npub] from my Nostr follows", or "stop
+ * following [user]" with a Nostr context. Idempotent — unfollowing
+ * someone the user wasn't following is a safe no-op.
+ *
+ * Removes [user] from the signed-in account's NIP-02 kind:3
+ * follow list and publishes the updated list. [WriteResult.changed]
+ * reports `false` when the user wasn't followed.
+ *
+ * @param user npub (`npub1…`) or 64-character hex pubkey.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun unfollowUser(
+ appFunctionContext: AppFunctionContext,
+ user: String,
+ ): WriteResult {
+ val target = decodeUserOrThrow(user)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
+ requireInProcessSigner(account.signer)
+ val relays = account.outboxRelays.flow.value
+ if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured")
+
+ val currentList = account.kind3FollowList.getFollowListEvent()
+ if (currentList == null || !currentList.isTaggedUser(target)) {
+ return WriteResult.unchanged()
+ }
+
+ val newList =
+ FollowActions.buildUnfollow(
+ signer = account.signer,
+ pubkeyToUnfollow = target,
+ currentContactList = currentList,
+ ) ?: return WriteResult.unchanged()
+ account.cache.justConsumeMyOwnEvent(newList)
+ val ack = Amethyst.instance.client.publishAndConfirmDetailed(newList, relays, PUBLISH_TIMEOUT_SECS)
+ return WriteResult.from(newList.id, ack)
+ }
+
+ /**
+ * Send a direct message to a user on Nostr. Use when the user asks
+ * "DM [X] on Nostr", "send a Nostr message to [user] saying [Y]",
+ * "message [npub] on Nostr", or any other "send a private message"
+ * intent in a Nostr context.
+ *
+ * The message is gift-wrapped (kind:1059) per NIP-59 — only the
+ * recipient (and the signed-in user, who keeps their own copy)
+ * can decrypt it. Recipients without a published kind:10050
+ * DM-inbox list fall back through NIP-65 read relays then
+ * bootstrap relays.
+ *
+ * @param recipient npub (`npub1…`) or 64-character hex pubkey.
+ * @param text the message body. Cannot be blank; capped at 8000
+ * characters.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun sendDm(
+ appFunctionContext: AppFunctionContext,
+ recipient: String,
+ text: String,
+ ): SendDmResult {
+ val body = text.trim()
+ if (body.isEmpty()) throw AppFunctionInvalidArgumentException("text cannot be blank")
+ if (body.length > MAX_NOTE_LENGTH) {
+ throw AppFunctionInvalidArgumentException("text is $${body.length} chars; cap is $MAX_NOTE_LENGTH")
+ }
+ val recipientPub = decodeUserOrThrow(recipient)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
+ requireInProcessSigner(account.signer)
+
+ val client = Amethyst.instance.client
+ val result = DmActions.buildTextDm(account.signer, recipientPub, body)
+
+ // One wrap per recipient — for a 1:1 DM that's two (the recipient's
+ // copy + the sender's own copy on the sender's inbox).
+ val deliveries = mutableListOf()
+ for (wrap in result.wraps) {
+ val target = wrap.recipientPubKey() ?: continue
+ // Fetch the recipient's kind:10050 / 10051 / 10002 fresh — local
+ // cache may be stale for users we rarely interact with, and the
+ // cost is one short drain on already-warmed sockets.
+ val lists =
+ RecipientRelayFetcher.fetchRelayLists(client, target, account.outboxRelays.flow.value)
+ val resolution =
+ DmActions.resolveDmRelays(
+ recipientLists = lists,
+ bootstrap = account.outboxRelays.flow.value,
+ allowFallback = true,
+ )
+ if (resolution.relays.isEmpty()) {
+ deliveries.add(
+ DmDelivery(
+ recipientNpub = NPub.create(target),
+ recipientPubkeyHex = target,
+ wrapId = wrap.id,
+ publishedTo = emptyList(),
+ rejectedBy = emptyList(),
+ relaySource = resolution.source.name.lowercase(),
+ ),
+ )
+ continue
+ }
+ val ack = client.publishAndConfirmDetailed(wrap, resolution.relays, PUBLISH_TIMEOUT_SECS)
+ deliveries.add(
+ DmDelivery(
+ recipientNpub = NPub.create(target),
+ recipientPubkeyHex = target,
+ wrapId = wrap.id,
+ publishedTo = ack.filterValues { it }.keys.map { it.url },
+ rejectedBy = ack.filterValues { !it }.keys.map { it.url },
+ relaySource = resolution.source.name.lowercase(),
+ ),
+ )
+ }
+ // Cache the inner kind:14 so the foreground UI sees the message
+ // immediately in the relevant DM thread.
+ account.cache.justConsumeMyOwnEvent(result.msg)
+
+ return SendDmResult(
+ messageEventId = result.msg.id,
+ deliveries = deliveries,
+ )
+ }
+
+ /**
+ * Tip a Nostr user with Lightning sats (NIP-57 profile zap). Use
+ * when the user asks "zap [X] on Nostr", "tip [user] [N] sats",
+ * "send a Lightning tip to [npub]", or "thank [user] with sats".
+ *
+ * Builds the NIP-57 kind:9734 zap request, fetches a BOLT11
+ * invoice from the recipient's Lightning service, and — when the
+ * user has a Nostr Wallet Connect wallet configured in Amethyst —
+ * pays the invoice automatically over NIP-47. Falls back to
+ * returning the invoice for manual payment when no NWC wallet is
+ * set up.
+ *
+ * Defaults to 21 sats — the canonical "small thank-you" zap. Cap
+ * is 1,000,000 sats so an accidental tip can't drain a wallet.
+ *
+ * @param user npub (`npub1…`) or 64-character hex pubkey of the
+ * zap recipient.
+ * @param sats amount to zap, in whole sats. Capped at 1,000,000
+ * sats. Default 21.
+ * @param comment optional message to attach to the zap. Capped at
+ * 280 characters.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun zapUser(
+ appFunctionContext: AppFunctionContext,
+ user: String,
+ sats: Long = 21,
+ comment: String? = null,
+ ): ZapResult {
+ val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS)
+ val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH)
+ val recipientPub = decodeUserOrThrow(user)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
+ requireInProcessSigner(account.signer)
+ val client = Amethyst.instance.client
+
+ // Pull the recipient's kind:0 — needs lnAddress to receive the zap.
+ val metadata =
+ account.cache
+ .checkGetOrCreateUser(recipientPub)
+ ?.metadataOrNull()
+ ?.flow
+ ?.value
+ ?.info
+ ?.let { extractLnAddressFromMetadata(it) }
+ ?: fetchProfileForZap(client, account, recipientPub)
+ ?: throw AppFunctionInvalidArgumentException(
+ "No kind:0 metadata for $user — recipient must have a Nostr profile first.",
+ )
+ val lnAddress =
+ metadata.takeIf { it.isNotBlank() }
+ ?: throw AppFunctionInvalidArgumentException(
+ "Recipient has no lud16 or lud06 in their profile — they can't receive Lightning zaps.",
+ )
+
+ val zapRequest =
+ ZapActions.buildUserZapRequest(
+ signer = account.signer,
+ recipientPubkey = recipientPub,
+ amountMillisats = ZapActions.satsToMillisats(cappedSats),
+ inboxRelays = account.nip65RelayList.inboxFlow.value,
+ comment = trimmedComment,
+ zapType = LnZapEvent.ZapType.PUBLIC,
+ )
+
+ val invoice = fetchInvoiceOrThrow(lnAddress, cappedSats, trimmedComment, zapRequest)
+
+ // If the user has a Nostr Wallet Connect wallet configured, pay
+ // the invoice automatically over NIP-47 so Gemini can answer
+ // "I zapped Alice 21 sats" instead of "here's a BOLT11 invoice
+ // for you to paste somewhere." Falls back to manual when NWC
+ // isn't set up or the wallet declines.
+ val nwc = payViaNwcOrNull(account, invoice, null)
+
+ return ZapResult(
+ recipientNpub = NPub.create(recipientPub),
+ recipientPubkeyHex = recipientPub,
+ recipientDisplayName = displayNameOf(recipientPub),
+ lnAddress = lnAddress,
+ amountSats = cappedSats,
+ comment = trimmedComment,
+ invoice = invoice,
+ zapRequestId = zapRequest.id,
+ nwcAttempted = nwc != null,
+ nwcPaid = nwc?.success == true,
+ nwcPreimage = nwc?.preimage,
+ nwcError = nwc?.errorMessage,
+ )
+ }
+
+ /**
+ * Zap a specific Nostr note (NIP-57 event zap). Use when the user
+ * asks "zap this Nostr post", "tip the author of [event id]",
+ * "send sats for that Nostr note about [X]", or "boost this Nostr
+ * post with sats".
+ *
+ * Honors NIP-57 zap-split tags — a post with multiple `zap` tags
+ * produces one invoice per recipient, proportional to weight, so
+ * a multi-party collab post pays everyone correctly. When the user
+ * has a Nostr Wallet Connect wallet configured, every split is
+ * paid automatically over NIP-47 and the result reports per-
+ * recipient success / failure. Without NWC, the verb returns the
+ * BOLT11 invoices for manual payment.
+ *
+ * @param eventId 64-character hex id of the note to zap. Must be
+ * in the local cache — get it via [getNotesByUser] /
+ * [getRecentFromFollows] / [searchByHashtag] / [searchNotes]
+ * first.
+ * @param sats total amount to zap, in whole sats. Capped at
+ * 1,000,000.
+ * @param comment optional message attached to every zap request.
+ * Capped at 280 characters.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun zapEvent(
+ appFunctionContext: AppFunctionContext,
+ eventId: String,
+ sats: Long = 21,
+ comment: String? = null,
+ ): ZapEventResult {
+ if (eventId.length != 64) {
+ throw AppFunctionInvalidArgumentException("eventId must be 64-character hex")
+ }
+ val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS)
+ val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH)
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
+ requireInProcessSigner(account.signer)
+
+ val note =
+ account.cache.getNoteIfExists(eventId)
+ ?: throw AppFunctionInvalidArgumentException(
+ "Event $eventId not in local cache. Fetch it via getNotesByUser or " +
+ "getRecentFromFollows first, or open the note in Amethyst.",
+ )
+ val event =
+ note.event
+ ?: throw AppFunctionInvalidArgumentException(
+ "Event $eventId is referenced locally but its content hasn't been observed yet.",
+ )
+
+ val client = Amethyst.instance.client
+ val totalMsats = ZapActions.satsToMillisats(cappedSats)
+
+ // Lookups for the split resolver — first try the local cache,
+ // then fall back to a one-shot network drain.
+ val lookupLnAddress: suspend (HexKey) -> String? = { pk ->
+ account.cache
+ .checkGetOrCreateUser(pk)
+ ?.metadataOrNull()
+ ?.lnAddress()
+ ?: fetchProfileForZap(client, account, pk)
+ }
+ val lookupInboxRelays: suspend (HexKey) -> Set = { pk ->
+ account.cache
+ .checkGetOrCreateUser(pk)
+ ?.inboxRelays()
+ ?.toSet()
+ .orEmpty()
+ }
+
+ val requests =
+ ZapActions.buildEventZapRequestsForSplits(
+ signer = account.signer,
+ zappedEvent = event,
+ totalAmountMillisats = totalMsats,
+ senderInboxRelays = account.nip65RelayList.inboxFlow.value,
+ lookupLnAddress = lookupLnAddress,
+ lookupInboxRelays = lookupInboxRelays,
+ comment = trimmedComment,
+ zapType = LnZapEvent.ZapType.PUBLIC,
+ )
+ if (requests.isEmpty()) {
+ throw AppFunctionInvalidArgumentException(
+ "No payable recipients — neither the author nor any zap-split recipient has a usable Lightning address.",
+ )
+ }
+
+ val invoices =
+ requests.map { req ->
+ val shareSats = req.amountMillisats / 1000
+ val invoiceResult =
+ runCatching {
+ fetchInvoiceOrThrow(
+ lnAddress = req.recipient.lnAddress,
+ sats = shareSats,
+ comment = trimmedComment,
+ zapRequest = req.request,
+ )
+ }
+ val invoice = invoiceResult.getOrNull()
+ // Try NWC for every invoice that came back. Failed splits
+ // stay as a manual invoice with nwcError set — the others
+ // still go through.
+ val nwc = invoice?.let { payViaNwcOrNull(account, it, note) }
+ ZapInvoice(
+ recipientNpub = req.recipient.pubkey?.let { NPub.create(it) },
+ recipientPubkeyHex = req.recipient.pubkey,
+ recipientDisplayName = req.recipient.pubkey?.let { displayNameOf(it) },
+ lnAddress = req.recipient.lnAddress,
+ weight = req.recipient.weight,
+ amountSats = shareSats,
+ invoice = invoice,
+ invoiceError = invoiceResult.exceptionOrNull()?.message,
+ zapRequestId = req.request.id,
+ nwcAttempted = nwc != null,
+ nwcPaid = nwc?.success == true,
+ nwcPreimage = nwc?.preimage,
+ nwcError = nwc?.errorMessage,
+ )
+ }
+
+ return ZapEventResult(
+ zappedEventId = eventId,
+ requestedSats = cappedSats,
+ billedSats = invoices.sumOf { it.amountSats },
+ comment = trimmedComment,
+ invoices = invoices,
+ )
+ }
+
+ /** Read lnAddress out of an already-resolved UserMetadata. */
+ private fun extractLnAddressFromMetadata(info: com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata): String? = info.lnAddress()
+
+ /**
+ * Cache miss path for zap recipient profile lookup. Drain the
+ * recipient's NIP-65 outbox / our home relays for their kind:0;
+ * returns the lnAddress directly so callers don't have to re-parse
+ * the metadata blob.
+ */
+ private suspend fun fetchProfileForZap(
+ client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient,
+ account: com.vitorpamplona.amethyst.model.Account,
+ pubkey: HexKey,
+ ): String? {
+ val relays =
+ account.cache
+ .checkGetOrCreateUser(pubkey)
+ ?.outboxRelays()
+ ?.toSet()
+ ?.ifEmpty { account.homeRelays.flow.value }
+ ?: account.homeRelays.flow.value
+ if (relays.isEmpty()) return null
+
+ val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey), limit = 1)
+ return client
+ .fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ ).mapNotNull { it as? MetadataEvent }
+ .maxByOrNull { it.createdAt }
+ ?.contactMetaData()
+ ?.lnAddress()
+ }
+
+ /** Internal result of [payViaNwcOrNull]. */
+ private data class NwcOutcome(
+ val success: Boolean,
+ val preimage: String?,
+ val errorMessage: String?,
+ )
+
+ /**
+ * Try to pay [bolt11] through the active account's Nostr Wallet
+ * Connect setup. Returns null when no NWC wallet is configured —
+ * caller should fall back to surfacing the invoice for manual
+ * payment. Returns an outcome with [NwcOutcome.success] = true on
+ * a wallet-confirmed payment, false (with [NwcOutcome.errorMessage]
+ * set) on rejection or timeout.
+ *
+ * The wallet's response can take a few seconds; bounded by
+ * [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall the
+ * dispatch.
+ */
+ private suspend fun payViaNwcOrNull(
+ account: com.vitorpamplona.amethyst.model.Account,
+ bolt11: String,
+ zappedNote: com.vitorpamplona.amethyst.model.Note?,
+ ): NwcOutcome? {
+ if (!account.nip47SignerState.hasWalletConnectSetup()) return null
+
+ val deferred = CompletableDeferred()
+ // sendZapPaymentRequestFor fires onResponse exactly once when
+ // the wallet replies (success, error, or NwcError). On timeout
+ // we discard the late response.
+ account.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
+ if (!deferred.isCompleted) deferred.complete(response)
+ }
+ val response =
+ withTimeoutOrNull(NWC_PAYMENT_TIMEOUT_MS) { deferred.await() }
+ ?: return NwcOutcome(
+ success = false,
+ preimage = null,
+ errorMessage =
+ "NWC wallet didn't respond within ${NWC_PAYMENT_TIMEOUT_MS / 1000}s, " +
+ "or returned a malformed reply we couldn't decrypt",
+ )
+
+ return when (response) {
+ is PayInvoiceSuccessResponse ->
+ NwcOutcome(
+ success = true,
+ preimage = response.result?.preimage,
+ errorMessage = null,
+ )
+ is PayInvoiceErrorResponse ->
+ NwcOutcome(
+ success = false,
+ preimage = null,
+ errorMessage =
+ response.error?.message
+ ?: response.error?.code?.name
+ ?: "wallet returned an unspecified pay_invoice error",
+ )
+ is NwcErrorResponse ->
+ NwcOutcome(
+ success = false,
+ preimage = null,
+ errorMessage =
+ response.error?.message
+ ?: response.error?.code?.name
+ ?: "wallet returned an NWC error",
+ )
+ else ->
+ NwcOutcome(
+ success = false,
+ preimage = null,
+ errorMessage = "Unexpected NWC response type: ${response::class.simpleName}",
+ )
+ }
+ }
+
+ /**
+ * LNURL-pay round-trip: resolves the LN address to a callback URL,
+ * posts the zap request, returns the BOLT11 invoice. Uses
+ * Amethyst's roleBasedHttpClientBuilder so the request honors the
+ * user's Tor / money-routing preferences.
+ */
+ private suspend fun fetchInvoiceOrThrow(
+ lnAddress: String,
+ sats: Long,
+ comment: String,
+ zapRequest: com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent,
+ ): String {
+ // Compute the LNURL-pay endpoint so we can ask the privacy-aware
+ // HttpClient builder for the right OkHttpClient for that host.
+ val endpointUrl =
+ LightningAddressResolver(httpClient = okhttp3.OkHttpClient()).assembleUrl(lnAddress)
+ ?: throw AppFunctionInvalidArgumentException("Couldn't resolve LN address '$lnAddress' to an LNURL-pay URL.")
+ val client = Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForMoney(endpointUrl)
+ val resolver = LightningAddressResolver(httpClient = client)
+ val result =
+ resolver.fetchInvoice(
+ lnAddress = lnAddress,
+ milliSats = ZapActions.satsToMillisats(sats),
+ message = comment,
+ zapRequest = zapRequest,
+ )
+ return when (result) {
+ is LightningAddressResolver.Result.Success -> result.invoice
+ is LightningAddressResolver.Result.Error ->
+ throw AppFunctionInvalidArgumentException("Lightning service rejected the zap: ${result.message}")
+ }
+ }
+
+ /**
+ * Reject the call when the active signer can't sign in-process —
+ * NIP-55 external signers (Amber) need a foreground activity to
+ * show the user an approval prompt, which we can't launch from a
+ * background AppFunctionService dispatch.
+ *
+ * Throws [AppFunctionNotSupportedException] when the user's signer
+ * is read-only, and a typed [AppFunctionNotSupportedException]
+ * with a clarifying message when it's an external signer.
+ */
+ private fun requireInProcessSigner(signer: NostrSigner) {
+ if (!signer.isWriteable()) {
+ throw AppFunctionNotSupportedException(
+ "Active Amethyst account is read-only (npub login). Sign in with a private key or NIP-46 bunker to publish.",
+ )
+ }
+ // NostrSignerExternal lives in quartz/androidMain and isn't visible
+ // to commonMain — but we're already in android-app code, so the
+ // class is on the classpath. Reflective name-check keeps the
+ // dependency edge clean and avoids hard-coupling the bridge to
+ // the NIP-55 implementation class.
+ val klass = signer::class.qualifiedName
+ if (klass == "com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal") {
+ throw AppFunctionNotSupportedException(
+ "Amethyst is configured to use an external NIP-55 signer (Amber). " +
+ "Write actions from Gemini aren't supported with this signer yet — " +
+ "they require Amber's approval activity which can't launch from a " +
+ "background dispatch. Open Amethyst directly to complete the action.",
+ )
+ }
+ }
+
+ private fun notSignedIn(): AppFunctionNotSupportedException = AppFunctionNotSupportedException("No Amethyst account is signed in.")
+
+ /**
+ * Accepts either an npub bech32 (`npub1…`) or 64-character hex
+ * pubkey and returns the 64-char lowercase hex. Throws
+ * [AppFunctionInvalidArgumentException] on anything else so the
+ * caller sees a typed error rather than a generic crash.
+ */
+ private fun decodeUserOrThrow(input: String): HexKey =
+ runCatching { decodePublicKey(input.trim()).toHexKey() }
+ .getOrElse {
+ throw AppFunctionInvalidArgumentException(
+ "Could not decode user '$input' — expected npub1… or 64-char hex pubkey.",
+ )
+ }
+
+ /**
+ * Look up the cached display name for a pubkey. Returns null when no
+ * kind:0 has been observed for this user yet — caller renders the
+ * npub instead.
+ *
+ * Cheap in-memory read against the same LocalCache the foreground UI
+ * uses; no relay round-trip, no allocation beyond the lookup.
+ */
+ private fun displayNameOf(pubkey: HexKey): String? =
+ Amethyst.instance.cache
+ .checkGetOrCreateUser(pubkey)
+ ?.metadataOrNull()
+ ?.bestName()
+
+ private fun TextNoteEvent.toNoteHit(): NoteHit = (this as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit()
+
+ /**
+ * Project any home-feed-eligible event into a [NoteHit]. Covers
+ * the broader event set the home filter accepts (kind:1, kind:6
+ * reposts, kind:30023 long-form, polls, comments, etc.), so a
+ * digest can carry whatever the user actually sees.
+ *
+ * Long content is snippet-truncated so a book-length article
+ * doesn't blow up the AppFunctions response — Gemini can ask the
+ * user whether to fetch the full article through a follow-up
+ * verb call.
+ */
+ private fun com.vitorpamplona.quartz.nip01Core.core.Event.toFeedNoteHit(): NoteHit {
+ val snippet =
+ if (content.length > LONG_FORM_SNIPPET_LIMIT) {
+ content.take(LONG_FORM_SNIPPET_LIMIT) + "…"
+ } else {
+ content
+ }
+ return NoteHit(
+ eventId = id,
+ kind = kind,
+ npub = NPub.create(pubKey),
+ pubkeyHex = pubKey,
+ authorDisplayName = displayNameOf(pubKey),
+ createdAt = createdAt,
+ content = snippet,
+ )
+ }
+
+ private fun MetadataEvent.toProfileHit(): ProfileHit {
+ val meta = contactMetaData()
+ return ProfileHit(
+ npub = NPub.create(pubKey),
+ pubkeyHex = pubKey,
+ displayName = meta?.bestName(),
+ about = meta?.about,
+ nip05 = meta?.nip05,
+ picture = meta?.picture,
+ lnAddress = meta?.lnAddress(),
+ )
+ }
+
+ /**
+ * Searches Nostr notes for [query] via NIP-50 full-text search across the
+ * active account's configured search relays (kind:10007), falling back to
+ * Amethyst's curated default search-relay set when none is configured.
+ *
+ * Defaults to short text notes (kind:1) only. Currently no way to widen
+ * to long-form or other kinds — add a parameter when the need is real;
+ * App Functions doesn't support `List` parameters in alpha09.
+ *
+ * @param query free-form search text.
+ * @param limit max number of notes to return — capped to 50.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun searchNotes(
+ appFunctionContext: AppFunctionContext,
+ query: String,
+ limit: Int = 20,
+ ): SearchNotesResult {
+ val cappedLimit = limit.coerceIn(1, 50)
+ val filter = SearchActions.searchNotesFilter(query, limit = cappedLimit) ?: return SearchNotesResult.empty()
+
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
+ val client = Amethyst.instance.client
+
+ val relays = account.searchRelayList.flow.value
+ if (relays.isEmpty()) return SearchNotesResult.empty()
+
+ val events =
+ client.fetchAll(
+ filters = relays.associateWith { listOf(filter) },
+ timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
+ )
+
+ val hits =
+ events
+ .mapNotNull { it as? TextNoteEvent }
+ // Sorted newest-first by fetchAll already, but the cast may
+ // have dropped non-kind:1 events from a relay that ignored
+ // our kinds filter.
+ .take(cappedLimit)
+ .map { it.toNoteHit() }
+
+ return SearchNotesResult(matches = hits)
+ }
+
+ /**
+ * Lists the active account's current follow set — the people the signed-in
+ * user follows per their latest NIP-02 kind:3 contact list.
+ *
+ * Returned entries include best-effort display names sourced from each
+ * user's cached kind:0; users with no cached metadata appear with
+ * [FollowedUser.displayName] null. The order matches the on-disk follow
+ * list (which is the order the user followed them in).
+ *
+ * @param limit cap on entries returned — capped to 500. Set to 0 for the
+ * full list when there's no specific bound.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun getFollowing(
+ appFunctionContext: AppFunctionContext,
+ limit: Int = 100,
+ ): FollowingResult {
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return FollowingResult.empty()
+
+ // userList resolves authors through LocalCache so display names /
+ // pictures / nip05 are filled in for anyone whose kind:0 we've seen.
+ val users = account.kind3FollowList.userList.value
+ val effectiveLimit = if (limit <= 0) users.size else limit.coerceIn(1, 500)
+
+ val out =
+ users
+ .take(effectiveLimit)
+ .map { user ->
+ val meta = user.metadataOrNull()
+ FollowedUser(
+ npub = NPub.create(user.pubkeyHex),
+ pubkeyHex = user.pubkeyHex,
+ displayName = meta?.bestName(),
+ nip05 = meta?.nip05(),
+ picture = meta?.profilePicture(),
+ )
+ }
+
+ return FollowingResult(totalFollowing = users.size, returned = out)
+ }
+
+ companion object {
+ /**
+ * 6-second fetch window. App Functions invocations are user-initiated
+ * foreground requests in the Gemini UI — anything beyond a few seconds
+ * is a poor user experience.
+ */
+ private const val GEMINI_FETCH_TIMEOUT_MS = 6_000L
+
+ /**
+ * Cap on the content payload returned from [searchArticles] — NIP-23
+ * articles can be book-length; truncate so the AppFunctions response
+ * stays bounded. Gemini can show the snippet and ask the user
+ * whether to fetch the full article.
+ */
+ private const val LONG_FORM_SNIPPET_LIMIT = 2_000
+
+ /**
+ * Cap on the body of a Gemini-driven write (postNote / sendDm).
+ * Anything larger is almost certainly an accidentally-pasted
+ * document; bail out with a typed error instead of silently
+ * publishing a wall of text to relays.
+ */
+ private const val MAX_NOTE_LENGTH = 8_000
+
+ /**
+ * Per-publish ack window. We wait this long for OK responses
+ * from each relay; relays that don't answer in time are
+ * reported as `rejectedBy` (no ack, no event). 15 s lines up
+ * with what `cli/Context.publish` uses.
+ */
+ private const val PUBLISH_TIMEOUT_SECS = 15L
+
+ /** Upper bound on a single zap. Anything above this is almost
+ * certainly a typo; bail out instead of letting Gemini bill
+ * the user a million sats by accident. */
+ private const val MAX_ZAP_SATS = 1_000_000L
+
+ /** LN providers typically reject longer comments — capping at
+ * 280 keeps us under the most aggressive ceilings while still
+ * fitting a tweet-length thank-you note. */
+ private const val MAX_ZAP_COMMENT_LENGTH = 280
+
+ /** Max time to wait for an NWC wallet to respond to a
+ * pay_invoice request. Mobile wallets typically settle in
+ * a few seconds; 30s is generous without letting a stuck
+ * wallet stall the dispatch indefinitely. */
+ private const val NWC_PAYMENT_TIMEOUT_MS = 30_000L
+
+ /** Cap on the number of distinct hashtags surfaced in a feed
+ * digest. Picked to fit a one-paragraph summary without
+ * noise — the long tail won't help the LLM. */
+ private const val TOP_HASHTAGS_LIMIT = 10
+
+ /** Cap on the number of mentioned users surfaced in a feed
+ * digest. Same rationale as TOP_HASHTAGS_LIMIT. */
+ private const val TOP_MENTIONS_LIMIT = 10
+ }
+}
+
+/**
+ * Single match in [SearchProfilesResult]. Nullable fields let callers
+ * render whatever subset of metadata the profile happens to publish.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class ProfileHit(
+ /** Bech32 npub identifier (`npub1…`) for the matched profile. */
+ val npub: String,
+ /** Hex-encoded pubkey (same identity as [npub], non-bech32 form). */
+ val pubkeyHex: String,
+ /** Best-effort display name (display_name then name). */
+ val displayName: String?,
+ /** Profile bio / about. */
+ val about: String?,
+ /** NIP-05 verified handle, e.g. `alice@example.com`. */
+ val nip05: String?,
+ /** Avatar image URL. */
+ val picture: String?,
+ /** Lightning address (lud16 preferred, otherwise lud06 LNURL). */
+ val lnAddress: String?,
+)
+
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class SearchProfilesResult(
+ /** Matched profiles, deduplicated by pubkey and sorted newest-first. */
+ val matches: List,
+) {
+ companion object {
+ fun empty() = SearchProfilesResult(matches = emptyList())
+ }
+}
+
+/** One hashtag and how many notes in the digest window carried it.
+ * Lowercased and stripped of the leading `#`. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class HashtagFrequency(
+ /** The hashtag value without the leading `#`, lowercased. */
+ val tag: String,
+ /** Number of notes in the digest window that carried this tag. */
+ val noteCount: Int,
+)
+
+/** One pubkey that was mentioned via `p` tags in the digest window,
+ * with display name resolved from the local kind:0 cache when known. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class MentionFrequency(
+ /** Bech32 npub of the mentioned user. */
+ val npub: String,
+ /** Hex pubkey of the mentioned user. */
+ val pubkeyHex: String,
+ /** Best-effort display name from the local kind:0 cache. Null when
+ * the user's profile hasn't been seen yet — caller falls back to
+ * the npub. */
+ val displayName: String?,
+ /** Number of notes in the digest window that mention this user. */
+ val mentionCount: Int,
+)
+
+/**
+ * Structured snapshot of the active account's Nostr feed for
+ * [AmethystAppFunctions.getFeedDigest]. The LLM uses the aggregate
+ * signals (counts + top hashtags + top mentions) to write a one- or
+ * two-paragraph summary; the raw [notes] list is included for
+ * follow-up questions ("which post was about X?").
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class FeedDigestResult(
+ /** Window size in hours actually queried (after capping). */
+ val windowHours: Int,
+ /** Total notes scanned for stats. May exceed [notes].size when the
+ * body was truncated to fit the LLM prompt. */
+ val totalNoteCount: Int,
+ /** Distinct authors who posted in the window. */
+ val uniqueAuthorCount: Int,
+ /** Top hashtags by note count — at most 10. */
+ val topHashtags: List,
+ /** Most-mentioned users by note count — at most 10. */
+ val topMentions: List,
+ /** Notes themselves (truncated to the caller's maxNotes). Newest-
+ * first; same fields as [NoteHit] returned by the search verbs. */
+ val notes: List,
+) {
+ companion object {
+ fun empty() =
+ FeedDigestResult(
+ windowHours = 0,
+ totalNoteCount = 0,
+ uniqueAuthorCount = 0,
+ topHashtags = emptyList(),
+ topMentions = emptyList(),
+ notes = emptyList(),
+ )
+ }
+}
+
+/** Single match in [SearchNotesResult] or entry in a feed result. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class NoteHit(
+ /** Hex event id of the note. */
+ val eventId: String,
+ /** Nostr event kind. 1 = short text note, 6 = repost, 30023 =
+ * long-form article, 1111 = comment, 9802 = highlight, 1068 =
+ * poll, etc. Lets the LLM distinguish "Alice posted a note"
+ * from "Alice published an article" or "Alice ran a poll". */
+ val kind: Int,
+ /** Bech32 npub of the note's author. */
+ val npub: String,
+ /** Hex pubkey of the note's author. */
+ val pubkeyHex: String,
+ /** Best-effort display name of the author from the local kind:0 cache.
+ * Null when the author's profile hasn't been seen yet — caller renders
+ * the npub instead. */
+ val authorDisplayName: String?,
+ /** Unix-seconds timestamp the note was created at. */
+ val createdAt: Long,
+ /** Raw content of the note (plain text, may contain Nostr URIs /
+ * hashtags). Truncated at ~2000 chars for very long content; the
+ * full event is reachable by its [eventId] via a follow-up verb. */
+ val content: String,
+)
+
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class SearchNotesResult(
+ /** Matched notes, sorted newest-first by created_at. */
+ val matches: List,
+) {
+ companion object {
+ fun empty() = SearchNotesResult(matches = emptyList())
+ }
+}
+
+/** Single entry in [FollowingResult]. Metadata fields may be null when the
+ * user's kind:0 hasn't been cached locally. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class FollowedUser(
+ /** Bech32 npub of the followed user. */
+ val npub: String,
+ /** Hex pubkey of the followed user. */
+ val pubkeyHex: String,
+ /** Best-effort display name (display_name then name). */
+ val displayName: String?,
+ /** NIP-05 verified handle, e.g. `alice@example.com`. */
+ val nip05: String?,
+ /** Avatar image URL. */
+ val picture: String?,
+)
+
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class FollowingResult(
+ /** Total number of follows in the active account's kind:3 — may exceed
+ * [returned] when the caller passed a limit. */
+ val totalFollowing: Int,
+ /** Subset of follows returned to the caller, in original on-disk order. */
+ val returned: List,
+) {
+ companion object {
+ fun empty() = FollowingResult(totalFollowing = 0, returned = emptyList())
+ }
+}
+
+/**
+ * Result of [AmethystAppFunctions.getProfile]. Distinguishes "user has no
+ * cached + observable kind:0 metadata" (`found = false`) from "user has a
+ * stub profile with empty fields" — the latter shouldn't normally happen
+ * but the explicit flag keeps callers from rendering a hollow card.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class GetProfileResult(
+ /** True when a kind:0 was found (cache or relay). False means the
+ * user exists as a pubkey but no profile event was reachable. */
+ val found: Boolean,
+ /** Resolved profile when [found] is true, otherwise a stub with
+ * pubkey-only fields populated. */
+ val profile: ProfileHit?,
+) {
+ companion object {
+ fun notFound(pubkeyHex: String) =
+ GetProfileResult(
+ found = false,
+ profile =
+ ProfileHit(
+ npub = NPub.create(pubkeyHex),
+ pubkeyHex = pubkeyHex,
+ displayName = null,
+ about = null,
+ nip05 = null,
+ picture = null,
+ lnAddress = null,
+ ),
+ )
+ }
+}
+
+/**
+ * Aggregate of NIP-57 zaps received in a recent time window. Returned
+ * by [AmethystAppFunctions.getZapsReceived].
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class ZapsReceivedResult(
+ /** Window size in hours that was actually queried (after capping). */
+ val windowHours: Int,
+ /** Sum of sats from every parseable bolt11 invoice in the window. */
+ val totalSats: Long,
+ /** Total kind:9735 receipts observed — includes ones with unparseable invoices. */
+ val zapCount: Int,
+ /** Distinct zapping pubkeys, best-effort from the `P` / second-`p` tag. */
+ val uniqueZapperCount: Int,
+ /** Receipts whose bolt11 couldn't be parsed and didn't contribute to [totalSats]. */
+ val unparseableInvoiceCount: Int,
+) {
+ companion object {
+ fun empty() =
+ ZapsReceivedResult(
+ windowHours = 0,
+ totalSats = 0L,
+ zapCount = 0,
+ uniqueZapperCount = 0,
+ unparseableInvoiceCount = 0,
+ )
+ }
+}
+
+/** One decrypted NIP-17 direct message returned by [AmethystAppFunctions.getRecentDms]. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class DmMessage(
+ /** Bech32 npub of the sender. */
+ val fromNpub: String,
+ /** Hex pubkey of the sender. */
+ val fromPubkeyHex: String,
+ /** Best-effort display name of the sender from the local kind:0 cache. */
+ val fromDisplayName: String?,
+ /** True when the active account sent this message — useful for the
+ * caller to distinguish "Alice said X" from "I said Y" when both
+ * appear in the same thread snapshot. */
+ val sentByMe: Boolean,
+ /** Plaintext message body. */
+ val content: String,
+ /** Unix-seconds timestamp of the inner kind:14 event. */
+ val createdAt: Long,
+)
+
+/** Decrypted recent NIP-17 DMs in a time window. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class DmsResult(
+ /** Window size in hours that was actually queried. */
+ val windowHours: Int,
+ /** Messages, newest first. Capped to the caller's limit. */
+ val messages: List,
+) {
+ companion object {
+ fun empty() = DmsResult(windowHours = 0, messages = emptyList())
+ }
+}
+
+/** Single hit from [AmethystAppFunctions.getLiveStreams]. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class LiveStreamHit(
+ /** Hex event id of the kind:30311 announcement. */
+ val eventId: String,
+ /** Stream title from the `title` tag, or null when absent. */
+ val title: String?,
+ /** Short description from the `summary` tag, or null when absent. */
+ val summary: String?,
+ /** The URL where the stream is playable (HLS / WebRTC / etc.) from
+ * the `streaming` tag. Null when the announcement carries no
+ * streaming endpoint — caller has nothing to play. */
+ val streamingUrl: String?,
+ /** Bech32 npub of the host, when a host tag is present. */
+ val hostNpub: String?,
+ /** Hex pubkey of the host, when a host tag is present. */
+ val hostPubkeyHex: String?,
+ /** Best-effort display name of the host from the local kind:0 cache. */
+ val hostDisplayName: String?,
+ /** Unix-seconds timestamp the stream's `starts` tag points to. */
+ val startsAt: Long?,
+ /** Unix-seconds timestamp of the kind:30311 event itself. */
+ val createdAt: Long,
+)
+
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class LiveStreamsResult(
+ /** Currently-live streams, in the order they were observed. */
+ val streams: List,
+) {
+ companion object {
+ fun empty() = LiveStreamsResult(streams = emptyList())
+ }
+}
+
+/**
+ * Summary of the active Nostr account on this device. Returned by
+ * [AmethystAppFunctions.getActiveAccountInfo].
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class AccountInfoResult(
+ /** False when no account is currently logged into Amethyst. */
+ val signedIn: Boolean,
+ /** Bech32 npub of the active account, or null when signed out. */
+ val npub: String?,
+ /** Hex pubkey of the active account, or null when signed out. */
+ val pubkeyHex: String?,
+ /** Best-effort display name from cached kind:0. */
+ val displayName: String?,
+ /** NIP-05 verified handle. */
+ val nip05: String?,
+ /** Number of pubkeys in the user's current kind:3 follow list. */
+ val followCount: Int,
+ /** Number of NIP-65 outbox / home relays configured. */
+ val outboxRelayCount: Int,
+ /** Number of NIP-17 DM-inbox relays (kind:10050) configured. */
+ val dmRelayCount: Int,
+) {
+ companion object {
+ fun signedOut() =
+ AccountInfoResult(
+ signedIn = false,
+ npub = null,
+ pubkeyHex = null,
+ displayName = null,
+ nip05 = null,
+ followCount = 0,
+ outboxRelayCount = 0,
+ dmRelayCount = 0,
+ )
+ }
+}
+
+/**
+ * Result of a single-event write verb (postNote / followUser /
+ * unfollowUser). When the verb is a no-op — already following, not
+ * following, content unchanged — [changed] is false and [eventId] is
+ * null; the relay lists are empty for the same reason.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class WriteResult(
+ /** True when a new event was actually signed and published. */
+ val changed: Boolean,
+ /** Hex event id of the signed event, or null when the verb was a no-op. */
+ val eventId: String?,
+ /** Relays that ACK'd the publish. */
+ val publishedTo: List,
+ /** Relays that rejected the event or didn't answer in time. */
+ val rejectedBy: List,
+) {
+ companion object {
+ fun unchanged() =
+ WriteResult(
+ changed = false,
+ eventId = null,
+ publishedTo = emptyList(),
+ rejectedBy = emptyList(),
+ )
+
+ fun from(
+ eventId: String,
+ ack: Map,
+ ) = WriteResult(
+ changed = true,
+ eventId = eventId,
+ publishedTo = ack.filterValues { it }.keys.map { it.url },
+ rejectedBy = ack.filterValues { !it }.keys.map { it.url },
+ )
+ }
+}
+
+/**
+ * Per-recipient delivery status for a NIP-17 DM send. A 1:1 DM
+ * produces two entries — the recipient's wrap and the sender's own
+ * copy on their own DM-inbox relays. [relaySource] reports which
+ * bucket the relays were drawn from: `kind_10050`, `nip65_read`,
+ * `bootstrap`, or `none`.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class DmDelivery(
+ /** Bech32 npub of the recipient this wrap was addressed to. */
+ val recipientNpub: String,
+ /** Hex pubkey of the recipient. */
+ val recipientPubkeyHex: String,
+ /** Hex event id of the kind:1059 gift wrap published to this recipient. */
+ val wrapId: String,
+ /** Relays that ACK'd this wrap. */
+ val publishedTo: List,
+ /** Relays that rejected this wrap or didn't answer in time. */
+ val rejectedBy: List,
+ /** Bucket the relays were resolved from: kind_10050 / nip65_read / bootstrap / none. */
+ val relaySource: String,
+)
+
+/** Result of [AmethystAppFunctions.sendDm]. */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class SendDmResult(
+ /** Hex event id of the inner kind:14 (the plaintext message — only the
+ * signer and the recipient know it; relays only see the kind:1059 wraps). */
+ val messageEventId: String,
+ /** One entry per gift-wrap delivery. */
+ val deliveries: List,
+)
+
+/**
+ * Result of [AmethystAppFunctions.zapUser]. Always carries the BOLT11
+ * invoice so the caller can fall back to manual payment; when the
+ * user has a Nostr Wallet Connect (NIP-47) wallet configured, the
+ * verb also attempts to pay the invoice via that wallet and the
+ * `nwc*` fields report the outcome.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class ZapResult(
+ /** Bech32 npub of the zap recipient. */
+ val recipientNpub: String,
+ /** Hex pubkey of the recipient. */
+ val recipientPubkeyHex: String,
+ /** Best-effort display name from the local kind:0 cache. */
+ val recipientDisplayName: String?,
+ /** LN address the invoice was fetched from. */
+ val lnAddress: String,
+ /** Amount actually requested (after capping). */
+ val amountSats: Long,
+ /** Comment attached to the zap (truncated to 280 chars). */
+ val comment: String,
+ /** BOLT11 invoice — always set. Pay this manually if [nwcPaid]
+ * is false. */
+ val invoice: String,
+ /** Hex event id of the signed kind:9734 zap request. */
+ val zapRequestId: String,
+ /** True when the user has NWC configured and we tried to auto-pay.
+ * False means the caller should surface the invoice for manual
+ * payment. */
+ val nwcAttempted: Boolean,
+ /** True only when an NWC wallet confirmed the payment. */
+ val nwcPaid: Boolean,
+ /** Payment preimage from the wallet on success, otherwise null. */
+ val nwcPreimage: String?,
+ /** Failure reason when [nwcAttempted] is true but [nwcPaid] is false. */
+ val nwcError: String?,
+)
+
+/**
+ * Per-recipient BOLT11 invoice for an event zap. Multiple invoices
+ * appear when the zapped note carries NIP-57 zap-split tags. When NWC
+ * is configured we try to pay each invoice automatically; per-split
+ * NWC results are reported in the `nwc*` fields.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class ZapInvoice(
+ /** Bech32 npub of the recipient, or null when the split tag carried
+ * only an LN address with no pubkey. */
+ val recipientNpub: String?,
+ /** Hex pubkey of the recipient, or null when only an LN address was given. */
+ val recipientPubkeyHex: String?,
+ /** Best-effort display name from the cache, when the recipient is known. */
+ val recipientDisplayName: String?,
+ /** LN address the invoice was fetched from. */
+ val lnAddress: String,
+ /** Relative weight in the zap split — 1.0 for unweighted recipients. */
+ val weight: Double,
+ /** This recipient's share of the total in whole sats. */
+ val amountSats: Long,
+ /** BOLT11 invoice, or null when the Lightning provider failed
+ * (see [invoiceError] for the reason). */
+ val invoice: String?,
+ /** Failure reason from the Lightning provider when [invoice] is null. */
+ val invoiceError: String?,
+ /** Hex event id of this recipient's kind:9734 zap request. */
+ val zapRequestId: String,
+ /** True when NWC was configured and we tried to auto-pay this
+ * invoice. False when no NWC was set up or the invoice itself
+ * couldn't be fetched. */
+ val nwcAttempted: Boolean,
+ /** True only when an NWC wallet confirmed the payment for this split. */
+ val nwcPaid: Boolean,
+ /** Payment preimage from the wallet on success, otherwise null. */
+ val nwcPreimage: String?,
+ /** Failure reason when [nwcAttempted] is true but [nwcPaid] is false. */
+ val nwcError: String?,
+)
+
+/**
+ * Result of [AmethystAppFunctions.zapEvent]. Total billed sats may
+ * differ from requested by a few sats due to whole-sat rounding in the
+ * splits — same drift the foreground UI has.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class ZapEventResult(
+ /** Hex event id of the note being zapped. */
+ val zappedEventId: String,
+ /** Total sats the caller asked for (capped, post-validation). */
+ val requestedSats: Long,
+ /** Sum of per-recipient sats actually billed across all invoices. */
+ val billedSats: Long,
+ /** Comment attached to every zap request. */
+ val comment: String,
+ /** One invoice per recipient — multiple entries when the note has
+ * NIP-57 zap-split tags. Pay each one in a Lightning wallet to
+ * complete the zap; invoices with non-null [ZapInvoice.invoiceError]
+ * couldn't be fetched and won't go through. */
+ val invoices: List,
+)
diff --git a/amethyst/src/play/res/xml/app_metadata.xml b/amethyst/src/play/res/xml/app_metadata.xml
new file mode 100644
index 0000000000..4ef84570f9
--- /dev/null
+++ b/amethyst/src/play/res/xml/app_metadata.xml
@@ -0,0 +1,16 @@
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 8d1b957095..4ffcd6c263 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -12,6 +12,7 @@ plugins {
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.serialization)
+ alias(libs.plugins.googleKsp) apply false
}
// Shared app version for all subprojects — read from gradle/libs.versions.toml.
diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
index db8818f3d7..6d9c0d8fec 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt
@@ -159,6 +159,22 @@ private suspend fun dispatch(argv: Array): Int {
Commands.store(dataDir, tail)
}
+ "follow" -> {
+ Commands.follow(dataDir, tail)
+ }
+
+ "unfollow" -> {
+ Commands.unfollow(dataDir, tail)
+ }
+
+ "search" -> {
+ Commands.search(dataDir, tail)
+ }
+
+ "zap" -> {
+ Commands.zap(dataDir, tail)
+ }
+
else -> {
System.err.println("unknown subcommand: $head")
printUsage()
@@ -327,6 +343,28 @@ private fun printUsage() {
| [--since TS] [--until TS]
| [--timeout SECS]
|
+ |Contacts (NIP-02 kind:3):
+ | follow USER [--timeout SECS] add USER to your contact list
+ | unfollow USER [--timeout SECS] remove USER from your contact list
+ | (USER: npub|nprofile|hex|name@domain)
+ |
+ |Zaps (NIP-57):
+ | zap user USER SATS build a profile zap-request, fetch a BOLT11
+ | [--comment X] [--anon|--private] invoice from the recipient's LN service
+ | [--timeout SECS] (no auto-payment — paste invoice into a wallet)
+ | zap event EVENT-ID SATS same, but attribute the zap to a specific
+ | [--comment X] [--anon|--private] event (must be in local store)
+ | [--timeout SECS]
+ |
+ |Search (NIP-50):
+ | search user QUERY [--limit N] search kind:0 profiles
+ | [--timeout SECS]
+ | search note QUERY [--limit N] search event content
+ | [--kinds K[,K…]] (default kind:1; e.g. 1,30023)
+ | [--timeout SECS]
+ | uses your kind:10007 search-relay
+ | list, falls back to Amethyst defaults
+ |
|Direct messages (NIP-17):
| dm send RECIPIENT TEXT send a gift-wrapped DM
| [--allow-fallback] (default: only deliver to recipient's kind:10050)
diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt
index 83a111bd24..446b3acf1c 100644
--- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt
@@ -95,4 +95,24 @@ object Commands {
dataDir: DataDir,
tail: Array,
): Int = StoreCommands.dispatch(dataDir, tail)
+
+ suspend fun follow(
+ dataDir: DataDir,
+ tail: Array,
+ ): Int = FollowCommand.follow(dataDir, tail)
+
+ suspend fun unfollow(
+ dataDir: DataDir,
+ tail: Array,
+ ): Int = FollowCommand.unfollow(dataDir, tail)
+
+ suspend fun search(
+ dataDir: DataDir,
+ tail: Array,
+ ): Int = SearchCommand.dispatch(dataDir, tail)
+
+ suspend fun zap(
+ dataDir: DataDir,
+ tail: Array,
+ ): Int = ZapCommand.dispatch(dataDir, tail)
}
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 9fccae5dcc..9cd003ae30 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
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
+import com.vitorpamplona.amethyst.commons.actions.DmActions
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
@@ -34,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
-import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
@@ -86,8 +86,7 @@ object DmCommands {
try {
ctx.prepare()
val recipient = ctx.requireUserHex(rest[0])
- val template = ChatMessageEvent.build(text, listOf(PTag(recipient)))
- val result = NIP17Factory().createMessageNIP17(template, ctx.signer)
+ val result = DmActions.buildTextDm(ctx.signer, recipient, text)
return publishWraps(ctx, result, allowFallback)
} finally {
ctx.close()
@@ -124,27 +123,34 @@ object DmCommands {
ctx.prepare()
val recipient = ctx.requireUserHex(recipientInput)
- val (template, summary) =
+ val (result, summary) =
if (args.flag("file") != null) {
- buildUploadModeTemplate(ctx, recipient, args)
+ buildUploadedFileDm(ctx, recipient, args)
?: return 1
} else {
- buildReferenceModeTemplate(args, recipient)
+ buildReferencedFileDm(ctx, recipient, args)
?: return 1
}
- val result = NIP17Factory().createEncryptedFileNIP17(template, ctx.signer)
return publishWraps(ctx, result, allowFallback, extra = summary)
} finally {
ctx.close()
}
}
- private suspend fun buildUploadModeTemplate(
+ /**
+ * Upload mode: read the local file, encrypt with a fresh AESGCM key,
+ * push the ciphertext to a Blossom server, then call into
+ * [DmActions.buildFileDmReference] with the resulting URL + metadata.
+ * Returns the gift-wrap result plus an `extra` map that surfaces the
+ * upload's cipher material on stdout so callers can re-share or
+ * republish the same blob without re-uploading.
+ */
+ private suspend fun buildUploadedFileDm(
ctx: Context,
- recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey,
+ recipient: HexKey,
args: Args,
- ): Pair, Map>? {
+ ): Pair>? {
val file = java.io.File(args.requireFlag("file"))
if (!file.exists()) {
Output.error("bad_args", "file does not exist: ${file.absolutePath}")
@@ -169,9 +175,10 @@ object DmCommands {
.DimensionTag(w, h)
}
}
- val template =
- ChatMessageEncryptedFileHeaderEvent.build(
- to = listOf(PTag(recipient)),
+ val result =
+ DmActions.buildFileDmReference(
+ signer = ctx.signer,
+ recipient = recipient,
url = uploadedUrl,
cipher = cipher,
mimeType = mimeType,
@@ -181,8 +188,6 @@ object DmCommands {
blurhash = uploaded.metadata.blurhash,
originalHash = uploaded.metadata.sha256,
)
- // Surface the cipher material on stdout so callers can re-share
- // or republish the same encrypted blob without re-uploading.
val summary =
mapOf(
"url" to uploadedUrl,
@@ -193,13 +198,19 @@ object DmCommands {
"original_hash" to uploaded.metadata.sha256,
"mime_type" to mimeType,
)
- return template to summary
+ return result to summary
}
- private fun buildReferenceModeTemplate(
+ /**
+ * Reference mode: the file is already uploaded somewhere; the user
+ * hands us the URL + cipher key/nonce + whatever metadata they want
+ * stamped onto the kind:15.
+ */
+ private suspend fun buildReferencedFileDm(
+ ctx: Context,
+ recipient: HexKey,
args: Args,
- recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey,
- ): Pair, Map>? {
+ ): Pair>? {
val url =
args.positionalOrNull(0) ?: run {
Output.error("bad_args", USAGE_SEND_FILE)
@@ -236,9 +247,10 @@ object DmCommands {
val cipher =
com.vitorpamplona.quartz.utils.ciphers
.AESGCM(keyBytes, nonceBytes)
- val template =
- ChatMessageEncryptedFileHeaderEvent.build(
- to = listOf(PTag(recipient)),
+ val result =
+ DmActions.buildFileDmReference(
+ signer = ctx.signer,
+ recipient = recipient,
url = url,
cipher = cipher,
mimeType = mimeType,
@@ -248,7 +260,7 @@ object DmCommands {
blurhash = blurhash,
originalHash = originalHash,
)
- return template to emptyMap()
+ return result to emptyMap()
}
private const val USAGE_SEND_FILE: String =
@@ -278,7 +290,7 @@ object DmCommands {
"wrap_id" to wrap.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
"relays_tried" to resolution.relays.map { it.url },
- "relay_source" to resolution.source,
+ "relay_source" to resolution.source.name.lowercase(),
),
)
}
@@ -402,39 +414,29 @@ object DmCommands {
}
/**
- * Per NIP-17: kind:1059 should only be delivered to relays the recipient
- * has advertised in their kind:10050. When that list is empty:
- * - strict (default): refuse with no_dm_relays — caller must fix or
- * explicitly opt into a fallback.
- * - allowFallback=true: fall through to the NIP-65 read marker and then
- * to our bootstrap pool.
+ * Cache-first relay lookup. If amy has previously seen the recipient's
+ * kind:10050 / 10051 / 10002 events, use the local copy and skip the
+ * network drain entirely. Otherwise drain `seedRelays` for them. Then
+ * hands the resulting [RecipientRelayFetcher.Lists] off to
+ * [DmActions.resolveDmRelays] which applies the strict-kind:10050 /
+ * fallback policy.
*/
private suspend fun resolveDmRelays(
ctx: Context,
recipient: HexKey,
allowFallback: Boolean,
- ): RelaySet {
+ ): DmActions.DmRelaySet {
val seed = ctx.bootstrapRelays()
- // 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")
- val nip65Read = lists.nip65Read().toSet()
- if (nip65Read.isNotEmpty()) return RelaySet(nip65Read, "nip65_read")
- return RelaySet(seed, "bootstrap")
+ return DmActions.resolveDmRelays(
+ recipientLists = lists,
+ bootstrap = seed,
+ allowFallback = allowFallback,
+ )
}
- private data class RelaySet(
- val relays: Set,
- val source: String,
- )
-
private sealed interface DecryptedDm {
val id: HexKey
val wrapId: HexKey
diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt
new file mode 100644
index 0000000000..c96234c8f4
--- /dev/null
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FollowCommand.kt
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.cli.commands
+
+import com.vitorpamplona.amethyst.cli.Args
+import com.vitorpamplona.amethyst.cli.Context
+import com.vitorpamplona.amethyst.cli.DataDir
+import com.vitorpamplona.amethyst.cli.Output
+import com.vitorpamplona.amethyst.commons.actions.FollowActions
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
+import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
+
+/**
+ * `amy follow ` and `amy unfollow ` — update the active
+ * account's NIP-02 kind:3 contact list.
+ *
+ * Both commands fetch the user's latest kind:3 from their outbox relays
+ * before mutating, so concurrent follows from another client are preserved
+ * (the new event is built on top of the freshest known list).
+ *
+ * Identifier formats accepted by ``: npub / nprofile / 64-hex /
+ * `name@domain.tld` — same set [Context.requireUserHex] handles.
+ */
+object FollowCommand {
+ suspend fun follow(
+ dataDir: DataDir,
+ rest: Array,
+ ): Int = run(dataDir, rest, FollowOp.FOLLOW)
+
+ suspend fun unfollow(
+ dataDir: DataDir,
+ rest: Array,
+ ): Int = run(dataDir, rest, FollowOp.UNFOLLOW)
+
+ private enum class FollowOp { FOLLOW, UNFOLLOW }
+
+ private suspend fun run(
+ dataDir: DataDir,
+ rest: Array,
+ op: FollowOp,
+ ): Int {
+ if (rest.isEmpty()) {
+ val verb = if (op == FollowOp.FOLLOW) "follow" else "unfollow"
+ return Output.error("bad_args", "$verb [--timeout SECS]")
+ }
+ val userArg = rest[0]
+ val args = Args(rest.drop(1).toTypedArray())
+ val timeoutSecs = args.longFlag("timeout", 8L)
+
+ val ctx = Context.open(dataDir)
+ try {
+ ctx.prepare()
+ val target = ctx.requireUserHex(userArg)
+ val self = ctx.identity.pubKeyHex
+ if (target == self) {
+ return Output.error("bad_args", "cannot follow/unfollow yourself")
+ }
+
+ val outbox = ctx.outboxRelays()
+ if (outbox.isEmpty()) {
+ return Output.error("no_relays", "no outbox relays configured; run `amy relay add` or `amy create`")
+ }
+
+ val latest = fetchLatestContactList(ctx, self, outbox, timeoutSecs * 1000)
+ val previouslyFollowed = latest?.isTaggedUser(target) ?: false
+
+ // Relay hint embedded in the `p` tag for new follows — points
+ // readers at a relay where they'll find the target's events.
+ // Best-effort: first write relay from the target's cached
+ // kind:10002 advertised relay list, null if we've never seen
+ // one. Mirrors User.bestRelayHint() in the Android UI.
+ val targetRelayHint =
+ if (op == FollowOp.FOLLOW) {
+ ctx
+ .relaysOf(target)
+ ?.writeRelaysNorm()
+ ?.firstOrNull()
+ } else {
+ null
+ }
+
+ val newEvent: ContactListEvent? =
+ when (op) {
+ FollowOp.FOLLOW ->
+ FollowActions.buildFollow(
+ signer = ctx.signer,
+ pubkeyToFollow = target,
+ currentContactList = latest,
+ relayHint = targetRelayHint,
+ )
+ FollowOp.UNFOLLOW ->
+ FollowActions.buildUnfollow(
+ signer = ctx.signer,
+ pubkeyToUnfollow = target,
+ currentContactList = latest,
+ )
+ }
+
+ // No-op cases: already following / not following.
+ if (newEvent == null || newEvent.id == latest?.id) {
+ Output.emit(
+ mapOf(
+ "target" to target,
+ "op" to op.name.lowercase(),
+ "changed" to false,
+ "previously_followed" to previouslyFollowed,
+ "based_on" to latest?.id,
+ "follow_count" to (latest?.verifiedFollowKeySet()?.size ?: 0),
+ ),
+ )
+ return 0
+ }
+
+ val ack = ctx.publish(newEvent, outbox)
+ Output.emit(
+ mapOf(
+ "target" to target,
+ "op" to op.name.lowercase(),
+ "changed" to true,
+ "previously_followed" to previouslyFollowed,
+ "event_id" to newEvent.id,
+ "created_at" to newEvent.createdAt,
+ "based_on" to latest?.id,
+ "follow_count" to newEvent.verifiedFollowKeySet().size,
+ "published_to" to ack.filterValues { it }.keys.map { it.url },
+ "rejected_by" to ack.filterValues { !it }.keys.map { it.url },
+ ),
+ )
+ return 0
+ } finally {
+ ctx.close()
+ }
+ }
+
+ /**
+ * Fetch the freshest kind:3 for [pubKey] from [relays]. Returns null when
+ * no relay surfaces one within the timeout. We never trust the local
+ * store alone for the base event — a stale read here would silently drop
+ * follows the user made from another client.
+ */
+ private suspend fun fetchLatestContactList(
+ ctx: Context,
+ pubKey: HexKey,
+ relays: Set,
+ timeoutMs: Long,
+ ): ContactListEvent? {
+ if (relays.isEmpty()) return null
+ val filter = Filter(kinds = listOf(ContactListEvent.KIND), authors = listOf(pubKey), limit = 1)
+ val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs)
+ return received
+ .mapNotNull { (_, ev) -> ev as? ContactListEvent }
+ .filter { it.pubKey == pubKey }
+ .maxByOrNull { it.createdAt }
+ }
+}
diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt
new file mode 100644
index 0000000000..0c52732a96
--- /dev/null
+++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.cli.commands
+
+import com.vitorpamplona.amethyst.cli.Args
+import com.vitorpamplona.amethyst.cli.Context
+import com.vitorpamplona.amethyst.cli.DataDir
+import com.vitorpamplona.amethyst.cli.Output
+import com.vitorpamplona.amethyst.commons.actions.SearchActions
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
+import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
+import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
+
+/**
+ * `amy search ` — NIP-50 full-text search across the
+ * caller's configured search relays (kind:10007) or, when none is set,
+ * Amethyst's curated default search-relay list.
+ *
+ * Two subcommands:
+ * * `search user ` drains kind:0 metadata events whose content
+ * matches [query] — useful for resolving a partial display name to
+ * an npub before a follow / DM.
+ * * `search note ` drains kind:1 short text notes matching
+ * [query]. Use `--kinds 1,30023` to widen to long-form articles.
+ *
+ * Output is the raw relay-side hit set deduped by event id and sorted
+ * by `created_at` descending. Client-side pseudo-kind filters
+ * (`reply` / `media`) live in
+ * [com.vitorpamplona.amethyst.commons.search.SearchResultFilter] and
+ * are not exposed here yet.
+ */
+object SearchCommand {
+ suspend fun dispatch(
+ dataDir: DataDir,
+ tail: Array,
+ ): Int {
+ if (tail.isEmpty()) return Output.error("bad_args", "search [--limit N] [--timeout SECS]")
+ val rest = tail.drop(1).toTypedArray()
+ return when (tail[0]) {
+ "user" -> searchUsers(dataDir, rest)
+ "note" -> searchNotes(dataDir, rest)
+ else -> Output.error("bad_args", "search ${tail[0]} — expected user|note")
+ }
+ }
+
+ private suspend fun searchUsers(
+ dataDir: DataDir,
+ rest: Array,
+ ): Int {
+ if (rest.isEmpty()) return Output.error("bad_args", "search user [--limit N] [--timeout SECS]")
+ val query = rest[0]
+ val args = Args(rest.drop(1).toTypedArray())
+ val limit = args.longFlag("limit", 20L).toInt()
+ val timeoutMs = args.longFlag("timeout", 8L) * 1000
+
+ val filter =
+ SearchActions.searchProfilesFilter(query, limit)
+ ?: return Output.error("bad_args", "query must not be blank")
+
+ return runSearch(dataDir, query, filter, timeoutMs) { events ->
+ events
+ .mapNotNull { it as? MetadataEvent }
+ // Dedup by pubkey, not event id — multiple relays may return
+ // different kind:0 revisions for the same author; keep only
+ // the freshest. Matches the App Functions adapter so amy
+ // and Gemini surface the same profile count for a query.
+ .sortedByDescending { it.createdAt }
+ .distinctBy { it.pubKey }
+ .map { ev ->
+ val parsed =
+ try {
+ Output.mapper.readTree(ev.content)
+ } catch (_: Exception) {
+ null
+ }
+ mapOf(
+ "event_id" to ev.id,
+ "pubkey" to ev.pubKey,
+ "created_at" to ev.createdAt,
+ "metadata" to (parsed ?: emptyMap()),
+ )
+ }
+ }
+ }
+
+ private suspend fun searchNotes(
+ dataDir: DataDir,
+ rest: Array,
+ ): Int {
+ if (rest.isEmpty()) return Output.error("bad_args", "search note [--limit N] [--timeout SECS] [--kinds K[,K…]]")
+ val query = rest[0]
+ val args = Args(rest.drop(1).toTypedArray())
+ val limit = args.longFlag("limit", 50L).toInt()
+ val timeoutMs = args.longFlag("timeout", 8L) * 1000
+ val kindList =
+ args.flags["kinds"]
+ ?.split(',')
+ ?.mapNotNull { it.trim().toIntOrNull() }
+ ?.takeIf { it.isNotEmpty() }
+ ?: SearchActions.DEFAULT_NOTE_KINDS
+
+ val filter =
+ SearchActions.searchNotesFilter(query, kinds = kindList, limit = limit)
+ ?: return Output.error("bad_args", "query must not be blank")
+
+ return runSearch(dataDir, query, filter, timeoutMs) { events ->
+ events
+ .filter { it.kind in kindList }
+ .map { ev ->
+ mapOf(
+ "event_id" to ev.id,
+ "pubkey" to ev.pubKey,
+ "kind" to ev.kind,
+ "created_at" to ev.createdAt,
+ "content" to ev.content,
+ )
+ }
+ }
+ }
+
+ private suspend fun runSearch(
+ dataDir: DataDir,
+ query: String,
+ filter: Filter,
+ timeoutMs: Long,
+ render: (List) -> List