mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
feat(amethyst): two more read-only Gemini verbs + signer-prompt plan
Read-only surface for the Gemini bridge now covers profiles, notes, and
the active account's follow set:
* searchNotes(query, limit) — NIP-50 search over kind:1 short text
notes via SearchActions.searchNotesFilter + INostrClient.fetchAll.
Returns NoteHit list (eventId, npub, content, createdAt). alpha09
of androidx.appfunctions doesn't support List<Int> parameters, so
no caller-configurable kinds — kind:1 only for now.
* getFollowing(limit) — reads account.kind3FollowList.userList.value
(already resolved through LocalCache) and projects to FollowedUser
with display-name / nip05 / picture from cached kind:0. Reports
totalFollowing so the caller knows when limit truncated the list.
Both verbs follow the searchProfiles pattern: snapshot active account +
client at entry, never re-query sessionManager during the dispatch.
Plus amethyst/plans/2026-05-25-appfunctions-signer-prompts.md —
design plan for write verbs. Three signers (Internal / Remote /
External), three different latency + interaction models. Concrete
proposal: Internal first via postNote pilot, Remote as a follow-up,
External via PendingIntent (Option A) or NotSupportedException
(Option B — recommended for v1) depending on what bundle keys the
system shell respects. Open questions enumerated so the experiment
day is bounded.
This commit is contained in:
@@ -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.
|
||||
+149
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.actions.SearchActions
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
|
||||
/**
|
||||
@@ -126,6 +127,100 @@ class AmethystAppFunctions {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Int>` 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 { ev ->
|
||||
NoteHit(
|
||||
eventId = ev.id,
|
||||
npub = NPub.create(ev.pubKey),
|
||||
pubkeyHex = ev.pubKey,
|
||||
createdAt = ev.createdAt,
|
||||
content = ev.content,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -167,3 +262,57 @@ class SearchProfilesResult(
|
||||
fun empty() = SearchProfilesResult(matches = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** Single match in [SearchNotesResult]. */
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
class NoteHit(
|
||||
/** Hex event id of the note. */
|
||||
val eventId: String,
|
||||
/** Bech32 npub of the note's author. */
|
||||
val npub: String,
|
||||
/** Hex pubkey of the note's author. */
|
||||
val pubkeyHex: String,
|
||||
/** Unix-seconds timestamp the note was created at. */
|
||||
val createdAt: Long,
|
||||
/** Raw content of the note (plain text, may contain Nostr URIs / hashtags). */
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@AppFunctionSerializable(isDescribedByKDoc = true)
|
||||
class SearchNotesResult(
|
||||
/** Matched notes, sorted newest-first by created_at. */
|
||||
val matches: List<NoteHit>,
|
||||
) {
|
||||
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<FollowedUser>,
|
||||
) {
|
||||
companion object {
|
||||
fun empty() = FollowingResult(totalFollowing = 0, returned = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user