From af351b289fbe5a16bd8af0ac7ca1daed7bd12392 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 18 Mar 2026 11:45:29 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20export=5Freport=20tool=20?= =?UTF-8?q?=E2=80=94=20markdown=20reports=20from=20timeline=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #5. Generates session reports with: - Summary stats (events, sessions, active days) - Activity breakdown by event type - Correction rate trends (prompt quality over time) - Daily activity heatmap - Detailed daily breakdown with notable events Supports day/week/month/quarter periods, optional file output, and current/related/all project scopes. Closes #5 --- src/index.ts | 2 + src/tools/export-report.ts | 305 +++++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 src/tools/export-report.ts diff --git a/src/index.ts b/src/index.ts index e7e9d00..1528c44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ import { registerScanSessions } from "./tools/scan-sessions.js"; import { registerGenerateScorecard } from "./tools/generate-scorecard.js"; import { registerSearchContracts } from "./tools/search-contracts.js"; import { registerEstimateCost } from "./tools/estimate-cost.js"; +import { registerExportReport } from "./tools/export-report.js"; // Validate related projects from config function validateRelatedProjects(): void { @@ -110,6 +111,7 @@ const toolRegistry: Array<[string, RegisterFn]> = [ ["generate_scorecard", registerGenerateScorecard], ["estimate_cost", registerEstimateCost], ["search_contracts", registerSearchContracts], + ["export_report", registerExportReport], ]; let registered = 0; diff --git a/src/tools/export-report.ts b/src/tools/export-report.ts new file mode 100644 index 0000000..ab1271d --- /dev/null +++ b/src/tools/export-report.ts @@ -0,0 +1,305 @@ +// ============================================================================= +// export_report — Generate markdown reports from timeline data +// Addresses issue #5: Export timeline to markdown/PDF reports +// ============================================================================= + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + getTimeline, + listIndexedProjects, + EVENT_TYPES, + type TimelineRecord, + type EventType, +} from "../lib/timeline-db.js"; +import { getRelatedProjects } from "../lib/config.js"; +import type { SearchScope } from "../types.js"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const TYPE_ICONS: Record = { + prompt: "💬", + assistant: "🤖", + tool_call: "🔧", + correction: "❌", + commit: "📦", + compaction: "🗜️", + sub_agent_spawn: "🚀", + error: "⚠️", +}; + +function relativeDate(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString(); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function groupByDate(events: TimelineRecord[]): Map { + const groups = new Map(); + for (const e of events) { + const day = e.timestamp.slice(0, 10); + if (!groups.has(day)) groups.set(day, []); + groups.get(day)!.push(e); + } + return groups; +} + +function groupByType(events: TimelineRecord[]): Map { + const counts = new Map(); + for (const e of events) { + counts.set(e.type, (counts.get(e.type) || 0) + 1); + } + return counts; +} + +function truncate(s: string, max = 120): string { + if (s.length <= max) return s; + return s.slice(0, max) + "…"; +} + +// ── Report Generation ────────────────────────────────────────────────────── + +interface ReportOptions { + events: TimelineRecord[]; + projectName: string; + period: string; + since: string; + until: string; +} + +function generateSummarySection(opts: ReportOptions): string { + const { events, period, since, until } = opts; + const typeCounts = groupByType(events); + const days = groupByDate(events); + + const lines: string[] = []; + lines.push(`## Summary`); + lines.push(""); + lines.push(`| Metric | Value |`); + lines.push(`| --- | --- |`); + lines.push(`| Period | ${period} |`); + lines.push(`| Date range | ${formatDate(since)} → ${formatDate(until)} |`); + lines.push(`| Total events | ${events.length} |`); + lines.push(`| Active days | ${days.size} |`); + lines.push(`| Sessions | ${new Set(events.map((e) => e.session_id)).size} |`); + lines.push(""); + + // Activity breakdown + lines.push(`### Activity Breakdown`); + lines.push(""); + const sorted = [...typeCounts.entries()].sort((a, b) => b[1] - a[1]); + for (const [type, count] of sorted) { + const icon = TYPE_ICONS[type] || "•"; + const pct = ((count / events.length) * 100).toFixed(1); + lines.push(`- ${icon} **${type}**: ${count} (${pct}%)`); + } + lines.push(""); + + return lines.join("\n"); +} + +function generateDailyBreakdown(events: TimelineRecord[]): string { + const days = groupByDate(events); + const lines: string[] = []; + lines.push(`## Daily Breakdown`); + lines.push(""); + + for (const [date, dayEvents] of days) { + const typeCounts = groupByType(dayEvents); + const typeStr = [...typeCounts.entries()] + .map(([t, c]) => `${TYPE_ICONS[t] || "•"} ${t}×${c}`) + .join(" "); + lines.push(`### ${formatDate(date)} (${dayEvents.length} events)`); + lines.push(`${typeStr}`); + lines.push(""); + + // Show commits and corrections in detail + const notable = dayEvents.filter( + (e) => e.type === "commit" || e.type === "correction" || e.type === "error" + ); + if (notable.length > 0) { + for (const e of notable) { + const icon = TYPE_ICONS[e.type] || "•"; + lines.push( + `- ${icon} ${truncate(e.content_preview || e.content)}` + ); + } + lines.push(""); + } + } + + return lines.join("\n"); +} + +function generateTrendSection(events: TimelineRecord[]): string { + const days = groupByDate(events); + const lines: string[] = []; + lines.push(`## Trends`); + lines.push(""); + + // Prompt quality trend (corrections / prompts ratio per day) + const dayStats: Array<{ date: string; prompts: number; corrections: number; ratio: number }> = []; + for (const [date, dayEvents] of days) { + const prompts = dayEvents.filter((e) => e.type === "prompt").length; + const corrections = dayEvents.filter((e) => e.type === "correction").length; + const ratio = prompts > 0 ? corrections / prompts : 0; + dayStats.push({ date, prompts, corrections, ratio }); + } + + if (dayStats.some((d) => d.prompts > 0)) { + lines.push(`### Correction Rate (lower is better)`); + lines.push(""); + for (const d of dayStats) { + const bar = "█".repeat(Math.round(d.ratio * 20)); + const pct = (d.ratio * 100).toFixed(0); + lines.push(`${d.date} ${bar || "▏"} ${pct}% (${d.corrections}/${d.prompts})`); + } + lines.push(""); + } + + // Activity heatmap + lines.push(`### Daily Activity`); + lines.push(""); + for (const [date, dayEvents] of days) { + const bar = "█".repeat(Math.min(dayEvents.length, 50)); + lines.push(`${date} ${bar} ${dayEvents.length}`); + } + lines.push(""); + + return lines.join("\n"); +} + +function generateFullReport(opts: ReportOptions): string { + const { projectName } = opts; + const lines: string[] = []; + lines.push(`# ${projectName} — Session Report`); + lines.push(`_Generated ${new Date().toISOString()}_`); + lines.push(""); + lines.push(generateSummarySection(opts)); + lines.push(generateTrendSection(opts.events)); + lines.push(generateDailyBreakdown(opts.events)); + return lines.join("\n"); +} + +// ── Tool Registration ────────────────────────────────────────────────────── + +export function registerExportReport(server: McpServer): void { + server.tool( + "export_report", + "Generate a markdown report from timeline data — weekly summaries, prompt quality trends, activity breakdown", + { + period: z + .enum(["day", "week", "month", "quarter"]) + .default("week") + .describe("Report period"), + project: z + .string() + .optional() + .describe("Project directory (defaults to current)"), + output_path: z + .string() + .optional() + .describe("Save report to file path (optional, returns content if omitted)"), + scope: z + .enum(["current", "related", "all"] satisfies SearchScope[]) + .default("current") + .describe("Search scope"), + }, + async (params) => { + const periodDays: Record = { + day: 1, + week: 7, + month: 30, + quarter: 90, + }; + const days = periodDays[params.period]; + const since = relativeDate(days); + const until = new Date().toISOString(); + + // Resolve project directories + let projectDirs: string[] = []; + if (params.project) { + projectDirs = [params.project]; + } else if (params.scope === "current") { + if (process.env.CLAUDE_PROJECT_DIR) { + projectDirs = [process.env.CLAUDE_PROJECT_DIR]; + } + } else if (params.scope === "related") { + const related = getRelatedProjects(); + projectDirs = related; + if (process.env.CLAUDE_PROJECT_DIR) { + projectDirs.unshift(process.env.CLAUDE_PROJECT_DIR); + } + } + // scope=all leaves projectDirs empty → searches all indexed + + const events = await getTimeline({ + project_dirs: projectDirs.length > 0 ? projectDirs : undefined, + since, + until, + limit: 5000, + }); + + if (events.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No timeline events found for the past ${params.period}. Make sure the project is indexed with \`onboard_project\`.`, + }, + ], + }; + } + + // Determine project name + const projectName = + events[0]?.project_name || events[0]?.project || "Unknown Project"; + + const report = generateFullReport({ + events, + projectName, + period: params.period, + since, + until, + }); + + // Optionally write to file + if (params.output_path) { + const dir = params.output_path.includes("/") + ? params.output_path.slice(0, params.output_path.lastIndexOf("/")) + : "."; + await mkdir(dir, { recursive: true }).catch(() => {}); + await writeFile(params.output_path, report, "utf-8"); + return { + content: [ + { + type: "text" as const, + text: `Report saved to ${params.output_path}\n\n${report}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: report, + }, + ], + }; + } + ); +}