feat(amethyst): getFeedDigest verb — feed-summary surface for the LLM

New verb: getFeedDigest(hoursBack, maxNotes).

Use when the user asks "summarize my Nostr feed", "give me a digest
of what my follows posted today", "recap Nostr", or any other
summary / digest / recap intent.

Returns a structured snapshot for AI summary instead of a raw note
list: total note count, unique author count, top hashtags (≤10) and
top mentioned users (≤10) — with display names resolved from the
local kind:0 cache — alongside the trimmed note body. The LLM uses
the aggregate signals to write a one-paragraph "the conversation
focused on X, with N people posting about Y" instead of having to
re-derive frequencies from a raw list.

Implementation:
  * Shared core extracted into fetchFollowFeed(account, since, limit)
    so getRecentFromFollows and getFeedDigest don't duplicate the
    drain logic.
  * Over-fetches by 3× the visible cap so stats are computed over a
    larger sample than the LLM sees, capped at 500 events for bounded
    on-device work.
  * Hashtag bucketing: lowercases + strips leading #, so #Bitcoin
    and #bitcoin collapse.
  * Mention bucketing: skips self-mentions (some clients tag the
    author themself, not useful for the digest).

New @AppFunctionSerializable result types:
  * HashtagFrequency, MentionFrequency — count + identifier.
  * FeedDigestResult — windowHours, totalNoteCount, uniqueAuthorCount,
    topHashtags, topMentions, notes.

Total verb count: 22. app_metadata.xml updated so Gemini's tool
picker can pitch the summary surface specifically.

Known scope: currently returns kind:1 from the user's kind:3 follow
list — does NOT match the in-app home feed exactly. The home feed
includes reposts, long-form, polls, comments, etc., respects the
user's currently selected NIP-51 list, and filters muted users.
Aligning the digest to the home feed (via HomeNewThreadFeedFilter
against LocalCache) is a documented follow-up.
This commit is contained in:
Claude
2026-05-26 15:58:30 +00:00
parent 4617be2068
commit dc2c7f9be1
2 changed files with 200 additions and 17 deletions
@@ -175,35 +175,146 @@ class AmethystAppFunctions {
): SearchNotesResult {
val cappedLimit = limit.coerceIn(1, 200)
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return SearchNotesResult.empty()
val client = Amethyst.instance.client
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 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",
* or any "summary / digest / recap of my Nostr timeline" intent.
*
* 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 (with display names resolved from the 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.
* @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.
*/
@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
// Over-fetch a bit so the stats are computed over a larger
// sample than the trimmed `notes` list — the LLM gets richer
// signal without seeing every note. Cap at 500 events to keep
// the on-device work bounded.
val events =
fetchFollowFeed(
account = account,
sinceSecs = sinceSecs,
limit = (cappedMaxNotes * 3).coerceAtMost(500),
)
// Hashtag frequencies — case-folded so `#Bitcoin` and
// `#bitcoin` collapse to one bucket.
val hashtagCounts = HashMap<String, Int>()
// Mention frequencies, keyed by mentioned pubkey hex.
val mentionCounts = HashMap<HexKey, Int>()
val uniqueAuthors = HashSet<HexKey>()
for (ev in events) {
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 = events.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.
notes = events.take(cappedMaxNotes).map { it.toNoteHit() },
)
}
/**
* 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<TextNoteEvent> {
val authors = account.kind3FollowList.flow.value.authors
if (authors.isEmpty()) return SearchNotesResult.empty()
if (authors.isEmpty()) return emptyList()
val relays =
account.homeRelays.flow.value
.ifEmpty { DefaultNIP65RelaySet }
if (relays.isEmpty()) return SearchNotesResult.empty()
if (relays.isEmpty()) return emptyList()
val filter =
Filter(
kinds = listOf(TextNoteEvent.KIND),
authors = authors.toList(),
limit = cappedLimit,
since = sinceSecs,
limit = limit,
)
val events =
client.fetchAll(
return Amethyst.instance.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)
).mapNotNull { it as? TextNoteEvent }
.take(limit)
}
/**
@@ -1641,6 +1752,15 @@ class AmethystAppFunctions {
* 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
}
}
@@ -1676,6 +1796,69 @@ class SearchProfilesResult(
}
}
/** 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<HashtagFrequency>,
/** Most-mentioned users by note count — at most 10. */
val topMentions: List<MentionFrequency>,
/** Notes themselves (truncated to the caller's maxNotes). Newest-
* first; same fields as [NoteHit] returned by the search verbs. */
val notes: List<NoteHit>,
) {
companion object {
fun empty() =
FeedDigestResult(
windowHours = 0,
totalNoteCount = 0,
uniqueAuthorCount = 0,
topHashtags = emptyList(),
topMentions = emptyList(),
notes = emptyList(),
)
}
}
/** Single match in [SearchNotesResult]. */
@AppFunctionSerializable(isDescribedByKDoc = true)
class NoteHit(
+2 -2
View File
@@ -12,5 +12,5 @@
the @AppFunction surface grows.
-->
<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/res-auto"
appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, and send NIP-17 gift-wrapped direct messages — provided the user is signed in with a local key or NIP-46 bunker. NIP-55 external signers (Amber) are read-only from the agent for now; open Amethyst directly to publish."
appfn:displayDescription="Read and write Nostr through Amethyst" />
appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; summarize the feed for a time window (with top hashtags, top mentions, counts pre-extracted for AI digestion); read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, send NIP-17 gift-wrapped direct messages, and zap users or specific notes via Lightning (with NWC auto-pay when configured) — provided the user is signed in with a local key or NIP-46 bunker. NIP-55 external signers (Amber) are read-only from the agent for now."
appfn:displayDescription="Read, write, summarize, and zap Nostr through Amethyst" />