UNPKG

@ankimcp/anki-mcp-server

Version:

Model Context Protocol server for Anki - enables AI assistants to interact with your Anki flashcards

233 lines • 11.1 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var ReviewStatsTool_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.ReviewStatsTool = void 0; const common_1 = require("@nestjs/common"); const microservices_1 = require("@nestjs/microservices"); const mcp_nest_1 = require("@rekog/mcp-nest"); const zod_1 = require("zod"); const anki_connect_client_1 = require("../../../../clients/anki-connect.client.js"); const anki_utils_1 = require("../../../../utils/anki.utils.js"); const stats_utils_1 = require("../../../../utils/stats.utils.js"); const date_utils_1 = require("../../../../utils/date.utils.js"); let ReviewStatsTool = ReviewStatsTool_1 = class ReviewStatsTool { ankiClient; logger = new common_1.Logger(ReviewStatsTool_1.name); constructor(ankiClient) { this.ankiClient = ankiClient; } async execute(params) { try { const { start_date } = params; const deck = params.deck && params.deck.length > 0 ? params.deck : undefined; const deckLabel = deck ?? "All Decks"; const todayKey = this.getTodayKey(); const end_date = params.end_date || todayKey; this.logger.log(`Getting review statistics from ${start_date} to ${end_date} for deck: ${deckLabel}`); const startTimestamp = (0, date_utils_1.localDayStartMs)(start_date); const endTimestamp = (0, date_utils_1.localDayStartMs)((0, date_utils_1.addDaysToDayKey)(end_date, 1)); this.logger.log(`Fetching detailed review data for deck: ${deckLabel}...`); const reviews = deck ? await this.ankiClient.invoke("cardReviews", { startID: startTimestamp, deck, }) : await this.fetchCollectionReviews(startTimestamp); const filteredReviews = reviews.filter((review) => review[0] <= endTimestamp); this.logger.log("Calculating daily review counts from reviews..."); const reviewsByDayMap = new Map(); for (const review of filteredReviews) { const date = (0, date_utils_1.toLocalDayKey)(review[0]); reviewsByDayMap.set(date, (reviewsByDayMap.get(date) ?? 0) + 1); } const reviewsByDay = Array.from(reviewsByDayMap.entries()) .map(([date, count]) => ({ date, count })) .sort((a, b) => a.date.localeCompare(b.date)); const buttonPresses = filteredReviews.map((review) => review[3]); this.logger.log("Computing retention metrics..."); const retention = (0, stats_utils_1.computeRetention)(buttonPresses); this.logger.log("Calculating summary statistics..."); const totalReviews = reviewsByDay.reduce((sum, r) => sum + r.count, 0); const daysStudied = reviewsByDay.filter((r) => r.count > 0).length; const averagePerDay = reviewsByDay.length > 0 ? totalReviews / reviewsByDay.length : 0; const nonZeroDays = reviewsByDay.filter((r) => r.count > 0); const maxDay = nonZeroDays.length > 0 ? nonZeroDays.reduce((max, r) => (r.count > max.count ? r : max)) : null; const minDay = nonZeroDays.length > 0 ? nonZeroDays.reduce((min, r) => (r.count < min.count ? r : min)) : null; const streak = (0, stats_utils_1.calculateStreak)(reviewsByDay, todayKey); const result = { period: { start: start_date, end: end_date, }, deck: deckLabel, reviews_by_day: reviewsByDay, summary: { total_reviews: totalReviews, average_per_day: averagePerDay, days_studied: daysStudied, max_day: maxDay, min_day: minDay, streak, }, retention, }; this.logger.log(`Successfully retrieved review statistics: ${totalReviews} total reviews, ` + `${daysStudied} days studied, ${(retention.overall * 100).toFixed(1)}% retention, ` + `${streak} day streak`); return result; } catch (error) { this.logger.error(`Failed to get review statistics`, error); return (0, anki_utils_1.createErrorResponse)(error, { hint: "This can happen when Anki is not running or a date is not in YYYY-MM-DD format. When filtering by deck, listDecks lists the valid deck names.", }); } } async fetchCollectionReviews(startTimestamp) { const cardIds = await this.ankiClient.invoke("findCards", { query: "deck:*", }); if (!cardIds || cardIds.length === 0) { return []; } const reviewsByCard = await this.ankiClient.invoke("getReviewsOfCards", { cards: cardIds }); const tuples = []; for (const [cardId, cardReviews] of Object.entries(reviewsByCard)) { const cid = Number(cardId); for (const r of cardReviews) { if (r.id <= startTimestamp) { continue; } tuples.push([ r.id, cid, r.usn, r.ease, r.ivl, r.lastIvl, r.factor, r.time, r.type, ]); } } return tuples; } getTodayKey() { return (0, date_utils_1.toLocalDayKey)(Date.now()); } }; exports.ReviewStatsTool = ReviewStatsTool; __decorate([ (0, mcp_nest_1.Tool)({ name: "review_stats", description: "Get review history analysis including temporal patterns, retention metrics, and study streak information. " + "Use this to analyze learning progress over time, identify review patterns, and track consistency. " + "Requires a start date; the deck is optional - omit it to analyze the entire collection (all decks). " + "End date defaults to today. " + "Days are calendar days (midnight to midnight) in the timezone of the machine running the server, assumed to be the machine running Anki; Anki's 'next day starts at' hour is not applied. " + "Reviews of cards that were deleted afterwards are not included, so totals can be lower than Anki's own statistics.", parameters: zod_1.z .object({ deck: zod_1.z .string() .optional() .describe("Deck name to filter reviews. Matches the EXACT deck only - subdecks are NOT included, " + "so a parent deck still returns reviews for cards assigned directly to it (but not its subdecks' cards). " + "Omit (or pass an empty string) to analyze the entire collection (all decks)."), start_date: zod_1.z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO date format: YYYY-MM-DD") .refine((date) => !isNaN(Date.parse(date)), { message: "Must be a valid date", }) .describe("Start date for analysis (ISO format: YYYY-MM-DD) - REQUIRED"), end_date: zod_1.z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO date format: YYYY-MM-DD") .refine((date) => !isNaN(Date.parse(date)), { message: "Must be a valid date", }) .optional() .describe("End date (defaults to today)"), }) .refine((data) => { if (!data.end_date) return true; return new Date(data.start_date) <= new Date(data.end_date); }, { message: "start_date must be less than or equal to end_date", path: ["start_date"], }), outputSchema: zod_1.z.object({ period: zod_1.z.object({ start: zod_1.z.string(), end: zod_1.z.string(), }), deck: zod_1.z.string(), reviews_by_day: zod_1.z.array(zod_1.z.object({ date: zod_1.z.string(), count: zod_1.z.number(), })), summary: zod_1.z.object({ total_reviews: zod_1.z.number(), average_per_day: zod_1.z.number(), days_studied: zod_1.z.number(), max_day: zod_1.z .object({ date: zod_1.z.string(), count: zod_1.z.number(), }) .nullable(), min_day: zod_1.z .object({ date: zod_1.z.string(), count: zod_1.z.number(), }) .nullable(), streak: zod_1.z.number(), }), retention: zod_1.z.object({ overall: zod_1.z.number(), by_rating: zod_1.z.object({ again: zod_1.z.number(), hard: zod_1.z.number(), good: zod_1.z.number(), easy: zod_1.z.number(), }), }), }), annotations: { title: "Review Statistics", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }), __param(0, (0, microservices_1.Payload)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], ReviewStatsTool.prototype, "execute", null); exports.ReviewStatsTool = ReviewStatsTool = ReviewStatsTool_1 = __decorate([ (0, mcp_nest_1.McpController)(), __metadata("design:paramtypes", [anki_connect_client_1.AnkiConnectClient]) ], ReviewStatsTool); //# sourceMappingURL=review-stats.tool.js.map