anilist_wrapped
Generate a year-in-review summary for your AniList anime and manga activity, including average score, top titles, genre breakdown, and consumption stats for any given year.
Instructions
Year-in-review summary for a user. Use when the user asks about their anime/manga year, what they watched/read in a given year, or wants a recap. Defaults to the current year. Returns title count, average score, highest rated, most controversial, genre breakdown, and consumption stats.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| username | No | AniList username. Falls back to configured default if not provided. | |
| year | No | Year to summarize. Defaults to the current year. | |
| type | No | Summarize anime, manga, or both | BOTH |
Implementation Reference
- src/tools/recommend.ts:893-1010 (handler)Registration and execute handler for the 'anilist_wrapped' tool. Fetches completed anime/manga entries for a given year via GraphQL pagination, computes wrapped stats, and returns a formatted text summary.
// === Year in Review === server.addTool({ name: "anilist_wrapped", description: "Year-in-review summary for a user. " + "Use when the user asks about their anime/manga year, what they watched/read " + "in a given year, or wants a recap. Defaults to the current year. " + "Returns title count, average score, highest rated, most controversial, genre breakdown, and consumption stats.", parameters: WrappedInputSchema, annotations: { title: "Year in Review", readOnlyHint: true, destructiveHint: false, openWorldHint: true, }, execute: async (args) => { try { const username = getDefaultUsername(args.username); const year = args.year ?? new Date().getFullYear(); const types: Array<"ANIME" | "MANGA"> = args.type === "BOTH" ? ["ANIME", "MANGA"] : [args.type as "ANIME" | "MANGA"]; // Server-side date filter (FuzzyDateInt format: YYYYMMDD) const completedAfter = year * 10000 + 100 + 1; // Jan 1 const completedBefore = year * 10000 + 1231; // Dec 31 // Paginate through results (types fetched in parallel) async function fetchType(type: "ANIME" | "MANGA") { const results: AniListMediaListEntry[] = []; let page = 1; let hasNext = true; while (hasNext) { const data = await anilistClient.query<CompletedByDateResponse>( COMPLETED_BY_DATE_QUERY, { userName: username, type, completedAfter, completedBefore, page, perPage: 50, }, { cache: "list" }, ); results.push(...data.Page.mediaList); hasNext = data.Page.pageInfo.hasNextPage; page++; } return results; } const yearEntries = ( await Promise.all(types.map(fetchType)) ).flat(); if (yearEntries.length === 0) { return `${username} didn't complete any titles in ${year}.`; } const stats = computeWrappedStats(yearEntries, year); const lines: string[] = [`# ${year} Wrapped for ${username}`, ""]; // Headline stats const parts: string[] = []; if (stats.animeCount > 0) parts.push(`${stats.animeCount} anime`); if (stats.mangaCount > 0) parts.push(`${stats.mangaCount} manga`); lines.push(`Completed ${parts.join(" and ")} in ${year}.`); if (stats.scoredCount > 0) { lines.push( `Average score: ${stats.avgScore.toFixed(1)}/10 across ${stats.scoredCount} rated titles.`, ); } if (stats.topRated) { lines.push( `Highest rated: ${stats.topRated.title} (${stats.topRated.score}/10)`, ); } if (stats.controversial) { const c = stats.controversial; lines.push( `Most controversial: ${c.title} ` + `(you: ${c.userScore}/10, community avg: ${(c.communityScore / 10).toFixed(1)}/10 - ` + `${(c.gap / 10).toFixed(1)} pts ${c.direction} consensus)`, ); } if (stats.topGenres.length > 0) { lines.push(""); lines.push("Top genres this year:"); for (const g of stats.topGenres) { lines.push(` ${g.name}: ${g.count} titles`); } } lines.push(""); const consumption: string[] = []; if (stats.totalEpisodes > 0) consumption.push( `${stats.totalEpisodes.toLocaleString()} episodes watched`, ); if (stats.totalChapters > 0) consumption.push( `${stats.totalChapters.toLocaleString()} chapters read`, ); if (consumption.length > 0) lines.push(consumption.join(", ")); return lines.join("\n"); } catch (error) { return throwToolError(error, "generating year summary"); } }, }); - src/schemas.ts:376-395 (schema)WrappedInputSchema: defines input parameters (username optional, year optional, type defaults to BOTH).
export const WrappedInputSchema = z.object({ username: usernameSchema .optional() .describe( "AniList username. Falls back to configured default if not provided.", ), year: z .number() .int() .min(2000) .max(MAX_YEAR) .optional() .describe("Year to summarize. Defaults to the current year."), type: z .enum(["ANIME", "MANGA", "BOTH"]) .default("BOTH") .describe("Summarize anime, manga, or both"), }); export type WrappedInput = z.infer<typeof WrappedInputSchema>; - src/engine/wrapped.ts:39-128 (helper)computeWrappedStats: computes year-in-review stats from completed entries (counts, avg score, top rated, controversial, genres, consumption, score distribution).
export function computeWrappedStats( entries: AniListMediaListEntry[], year: number, ): WrappedStats { const anime = entries.filter((e) => e.media.type === "ANIME"); const manga = entries.filter((e) => e.media.type === "MANGA"); // Scoring const scored = entries.filter((e) => e.score > 0); const avgScore = scored.length > 0 ? scored.reduce((sum, e) => sum + e.score, 0) / scored.length : 0; // Top rated let topRated: WrappedStats["topRated"] = null; if (scored.length > 0) { const top = [...scored].sort((a, b) => b.score - a.score)[0]; topRated = { title: getDisplayTitle(top.media.title), score: top.score, coverUrl: top.media.coverImage.extraLarge, }; } // Most controversial let controversial: WrappedStats["controversial"] = null; const withCommunity = scored .filter((e) => e.media.meanScore !== null) .map((e) => ({ entry: e, gap: Math.abs(e.score * USER_SCORE_SCALE - (e.media.meanScore ?? 0)), })) .sort((a, b) => b.gap - a.gap); if (withCommunity.length > 0 && withCommunity[0].gap >= 20) { const c = withCommunity[0].entry; const cs = c.media.meanScore ?? 0; controversial = { title: getDisplayTitle(c.media.title), userScore: c.score, communityScore: cs, gap: withCommunity[0].gap, direction: c.score * USER_SCORE_SCALE > cs ? "above" : "below", coverUrl: c.media.coverImage.extraLarge, }; } // Genre breakdown const genreCounts = new Map<string, number>(); for (const entry of entries) { for (const genre of entry.media.genres) { genreCounts.set(genre, (genreCounts.get(genre) ?? 0) + 1); } } const topGenres = [...genreCounts.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([name, count]) => ({ name, count })); // Consumption const totalEpisodes = anime.reduce( (sum, e) => sum + (e.media.episodes ?? e.progress ?? 0), 0, ); const totalChapters = manga.reduce( (sum, e) => sum + (e.media.chapters ?? e.progress ?? 0), 0, ); // Score distribution const scoreDistribution: Record<number, number> = {}; for (const e of scored) { scoreDistribution[e.score] = (scoreDistribution[e.score] ?? 0) + 1; } return { year, animeCount: anime.length, mangaCount: manga.length, totalEpisodes, totalChapters, avgScore, scoredCount: scored.length, topRated, controversial, topGenres, scoreDistribution, }; } - src/engine/wrapped.ts:9-34 (helper)WrappedStats interface: type definition for computed year-in-review statistics.
export interface WrappedStats { year: number; animeCount: number; mangaCount: number; totalEpisodes: number; totalChapters: number; avgScore: number; scoredCount: number; topRated: { title: string; score: number; coverUrl: string | null } | null; controversial: { title: string; userScore: number; communityScore: number; gap: number; direction: "above" | "below"; coverUrl: string | null; } | null; topGenres: Array<{ name: string; count: number }>; scoreDistribution: Record<number, number>; } /** Get a display title from a media title object */ function getDisplayTitle(title: { romaji: string | null; english: string | null; }): string { - src/prompts.ts:148-174 (registration)Prompt template that encourages the LLM to use the anilist_wrapped tool for year-in-review requests.
server.addPrompt({ name: "year_in_review", description: "Get your anime/manga year in review wrapped summary.", arguments: [ { name: "year", description: "Year to review. Defaults to current year.", required: false, }, ], async load({ year }) { const y = year ?? new Date().getFullYear().toString(); return { messages: [ { role: "user" as const, content: { type: "text" as const, text: `Use anilist_wrapped to generate my ${y} anime and manga year in review. ` + `Summarize the highlights and interesting patterns.`, }, }, ], }; }, });