mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
+198
-15
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user