From 400613b3756c6bf200da8e3742c42350d8d4fdd5 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Sat, 7 Mar 2026 02:15:20 -0700 Subject: [PATCH] feat: add export_timeline tool for markdown session reports Adds a new export_timeline MCP tool that generates structured markdown reports from timeline data. Includes: - Aggregate event stats with correction rate calculation - Per-day breakdowns (summary or detailed mode) - Daily trends table for multi-day reports - Relative date parsing (7days, 1week, 1month) - Author filtering for commit events Closes #5 --- src/index.ts | 2 + src/tools/export-timeline.ts | 306 ++++++++++++++++++++++++++++++++++ tests/export-timeline.test.ts | 80 +++++++++ 3 files changed, 388 insertions(+) create mode 100644 src/tools/export-timeline.ts create mode 100644 tests/export-timeline.test.ts diff --git a/src/index.ts b/src/index.ts index e7e9d00..c2a525a 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 { registerExportTimeline } from "./tools/export-timeline.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_timeline", registerExportTimeline], ]; let registered = 0; diff --git a/src/tools/export-timeline.ts b/src/tools/export-timeline.ts new file mode 100644 index 0000000..5bd9cbb --- /dev/null +++ b/src/tools/export-timeline.ts @@ -0,0 +1,306 @@ +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js"; +import { getRelatedProjects } from "../lib/config.js"; +import type { SearchScope } from "../types.js"; + +const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/; + +function parseRelativeDate(input: string): string { + const match = input.match(RELATIVE_DATE_RE); + if (!match) return input; + const [, numStr, unit] = match; + const num = parseInt(numStr, 10); + const d = new Date(); + if (unit.startsWith("day")) d.setDate(d.getDate() - num); + else if (unit.startsWith("week")) d.setDate(d.getDate() - num * 7); + else if (unit.startsWith("month")) d.setMonth(d.getMonth() - num); + else if (unit.startsWith("year")) d.setFullYear(d.getFullYear() - num); + return d.toISOString(); +} + +const TYPE_LABELS: Record = { + prompt: "Prompt", + assistant: "Response", + tool_call: "Tool Call", + correction: "Correction", + commit: "Commit", + compaction: "Compaction", + sub_agent_spawn: "Sub-agent Spawn", + error: "Error", +}; + +async function getSearchProjects(scope: SearchScope): Promise { + const currentProject = process.env.CLAUDE_PROJECT_DIR; + switch (scope) { + case "current": + return currentProject ? [currentProject] : []; + case "related": { + const related = getRelatedProjects(); + return currentProject ? [currentProject, ...related] : related; + } + case "all": { + const projects = await listIndexedProjects(); + return projects.map((p) => p.project); + } + default: + return currentProject ? [currentProject] : []; + } +} + +interface TypeStats { + [type: string]: number; +} + +interface DaySummary { + date: string; + events: any[]; + stats: TypeStats; +} + +function buildDaySummaries(events: any[]): DaySummary[] { + const days = new Map(); + for (const event of events) { + const day = event.timestamp + ? new Date(event.timestamp).toISOString().slice(0, 10) + : "unknown"; + if (!days.has(day)) days.set(day, []); + days.get(day)!.push(event); + } + + const summaries: DaySummary[] = []; + for (const [date, dayEvents] of days) { + dayEvents.sort((a: any, b: any) => { + const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return ta - tb; + }); + const stats: TypeStats = {}; + for (const e of dayEvents) { + stats[e.type] = (stats[e.type] || 0) + 1; + } + summaries.push({ date, events: dayEvents, stats }); + } + + summaries.sort((a, b) => b.date.localeCompare(a.date)); + return summaries; +} + +function renderMarkdownReport( + summaries: DaySummary[], + totalEvents: number, + projectLabel: string, + format: "detailed" | "summary" +): string { + const lines: string[] = []; + const dateRange = + summaries.length > 1 + ? `${summaries[summaries.length - 1].date} โ†’ ${summaries[0].date}` + : summaries[0]?.date || "N/A"; + + lines.push(`# Session Report: ${projectLabel}`); + lines.push(""); + lines.push(`**Period:** ${dateRange} `); + lines.push(`**Total Events:** ${totalEvents} `); + lines.push(`**Days Active:** ${summaries.length}`); + lines.push(""); + + // Aggregate stats + const totals: TypeStats = {}; + for (const s of summaries) { + for (const [type, count] of Object.entries(s.stats)) { + totals[type] = (totals[type] || 0) + count; + } + } + + lines.push("## Overview"); + lines.push(""); + lines.push("| Event Type | Count |"); + lines.push("|------------|-------|"); + for (const [type, count] of Object.entries(totals).sort( + (a, b) => b[1] - a[1] + )) { + lines.push(`| ${TYPE_LABELS[type] || type} | ${count} |`); + } + lines.push(""); + + // Correction rate + const prompts = totals["prompt"] || 0; + const corrections = totals["correction"] || 0; + if (prompts > 0) { + const rate = ((corrections / prompts) * 100).toFixed(1); + lines.push(`**Correction Rate:** ${corrections}/${prompts} prompts (${rate}%)`); + lines.push(""); + } + + // Per-day breakdown + for (const day of summaries) { + lines.push(`## ${day.date}`); + lines.push(""); + + const dayStats = Object.entries(day.stats) + .map(([t, c]) => `${TYPE_LABELS[t] || t}: ${c}`) + .join(" ยท "); + lines.push(`_${dayStats}_`); + lines.push(""); + + if (format === "detailed") { + for (const event of day.events) { + const time = event.timestamp + ? new Date(event.timestamp).toISOString().slice(11, 16) + : "??:??"; + const label = TYPE_LABELS[event.type] || event.type; + let content = (event.content || event.summary || "") + .slice(0, 200) + .replace(/\n/g, " "); + + if (event.type === "commit") { + const hash = event.commit_hash + ? event.commit_hash.slice(0, 7) + : ""; + lines.push(`- **${time}** [${label}] \`${hash}\` ${content}`); + } else if (event.type === "tool_call") { + const tool = event.tool_name || ""; + lines.push( + `- **${time}** [${label}] \`${tool}\`${content ? ` โ€” ${content}` : ""}` + ); + } else { + lines.push(`- **${time}** [${label}] ${content}`); + } + } + lines.push(""); + } + } + + // Trends section + if (summaries.length >= 2) { + lines.push("## Trends"); + lines.push(""); + + const dailyPrompts = summaries.map((s) => ({ + date: s.date, + prompts: s.stats["prompt"] || 0, + corrections: s.stats["correction"] || 0, + commits: s.stats["commit"] || 0, + })); + + lines.push("| Date | Prompts | Corrections | Commits |"); + lines.push("|------|---------|-------------|---------|"); + for (const d of dailyPrompts) { + lines.push(`| ${d.date} | ${d.prompts} | ${d.corrections} | ${d.commits} |`); + } + lines.push(""); + } + + lines.push( + `_Generated ${new Date().toISOString().slice(0, 19).replace("T", " ")} UTC by preflight export_timeline_` + ); + + return lines.join("\n"); +} + +export function registerExportTimeline(server: McpServer) { + server.tool( + "export_timeline", + "Generate a markdown session report from timeline data. Includes event stats, correction rates, daily breakdowns, and trends. Use for weekly summaries and prompt quality analysis.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe("Search scope"), + project: z + .string() + .optional() + .describe("Filter to a specific project (overrides scope)"), + since: z + .string() + .optional() + .describe( + 'Start date โ€” ISO string or relative like "7days", "1week", "1month"' + ), + until: z.string().optional().describe("End date"), + format: z + .enum(["detailed", "summary"]) + .default("summary") + .describe( + "detailed = full event listing per day; summary = stats only" + ), + branch: z.string().optional(), + author: z.string().optional().describe("Filter commits by author"), + }, + async (params) => { + const since = params.since + ? parseRelativeDate(params.since) + : parseRelativeDate("7days"); + const until = params.until ? parseRelativeDate(params.until) : undefined; + + let projectDirs: string[]; + if (params.project) { + projectDirs = [params.project]; + } else { + projectDirs = await getSearchProjects(params.scope); + } + + if (projectDirs.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No projects found for scope "${params.scope}". Set CLAUDE_PROJECT_DIR or onboard projects first.`, + }, + ], + }; + } + + let events = await getTimeline({ + project_dirs: projectDirs, + project: undefined, + branch: params.branch, + since, + until, + type: undefined, + limit: 2000, + offset: 0, + }); + + // Filter by author if specified + if (params.author) { + const authorLower = params.author.toLowerCase(); + events = events.filter((e: any) => { + if (e.type !== "commit") return true; + try { + const meta = JSON.parse(e.metadata || "{}"); + return (meta.author || "").toLowerCase().includes(authorLower); + } catch { + return true; + } + }); + } + + if (events.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No events found for the given filters. Try a wider date range or different scope.", + }, + ], + }; + } + + const projectLabel = + params.project || (params.scope === "current" ? "Current Project" : params.scope); + const summaries = buildDaySummaries(events); + const report = renderMarkdownReport( + summaries, + events.length, + projectLabel, + params.format + ); + + return { + content: [{ type: "text" as const, text: report }], + }; + } + ); +} diff --git a/tests/export-timeline.test.ts b/tests/export-timeline.test.ts new file mode 100644 index 0000000..0bf3c69 --- /dev/null +++ b/tests/export-timeline.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock timeline-db before importing the module +vi.mock("../src/lib/timeline-db.js", () => ({ + getTimeline: vi.fn(), + listIndexedProjects: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../src/lib/config.js", () => ({ + getRelatedProjects: vi.fn().mockReturnValue([]), +})); + +import { getTimeline } from "../src/lib/timeline-db.js"; + +// We test the tool by calling its handler via a mock McpServer +describe("export_timeline", () => { + let toolHandler: Function; + + beforeEach(async () => { + vi.clearAllMocks(); + // Capture the tool handler when registerExportTimeline calls server.tool() + const mockServer = { + tool: vi.fn((_name: string, _desc: string, _schema: any, handler: Function) => { + toolHandler = handler; + }), + }; + + const { registerExportTimeline } = await import("../src/tools/export-timeline.js"); + registerExportTimeline(mockServer as any); + }); + + it("returns no-projects message when scope yields nothing", async () => { + delete process.env.CLAUDE_PROJECT_DIR; + const result = await toolHandler({ scope: "current", format: "summary" }); + expect(result.content[0].text).toContain("No projects found"); + }); + + it("generates a summary report from timeline events", async () => { + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + const mockEvents = [ + { timestamp: "2026-03-01T10:00:00Z", type: "prompt", content: "Hello", project: "/test/project", branch: "main", session_id: "s1", source_file: "f", source_line: 1 }, + { timestamp: "2026-03-01T10:01:00Z", type: "assistant", content: "Hi there", project: "/test/project", branch: "main", session_id: "s1", source_file: "f", source_line: 2 }, + { timestamp: "2026-03-01T10:05:00Z", type: "correction", content: "Wrong answer", project: "/test/project", branch: "main", session_id: "s1", source_file: "f", source_line: 3 }, + { timestamp: "2026-03-02T09:00:00Z", type: "commit", content: "fix bug", commit_hash: "abc1234", project: "/test/project", branch: "main", session_id: "s1", source_file: "f", source_line: 4 }, + ]; + vi.mocked(getTimeline).mockResolvedValue(mockEvents as any); + + const result = await toolHandler({ scope: "current", format: "summary", since: "7days" }); + const text = result.content[0].text; + + expect(text).toContain("# Session Report"); + expect(text).toContain("Total Events:** 4"); + expect(text).toContain("Days Active:** 2"); + expect(text).toContain("Correction Rate"); + expect(text).toContain("## Trends"); + }); + + it("generates detailed report with event listings", async () => { + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + const mockEvents = [ + { timestamp: "2026-03-01T10:00:00Z", type: "prompt", content: "Hello world", project: "/test/project", branch: "main", session_id: "s1", source_file: "f", source_line: 1 }, + { timestamp: "2026-03-01T10:01:00Z", type: "tool_call", tool_name: "Read", content: "file.ts", project: "/test/project", branch: "main", session_id: "s1", source_file: "f", source_line: 2 }, + ]; + vi.mocked(getTimeline).mockResolvedValue(mockEvents as any); + + const result = await toolHandler({ scope: "current", format: "detailed", since: "7days" }); + const text = result.content[0].text; + + expect(text).toContain("**10:00** [Prompt]"); + expect(text).toContain("**10:01** [Tool Call] `Read`"); + }); + + it("returns no-events message when timeline is empty", async () => { + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + vi.mocked(getTimeline).mockResolvedValue([]); + + const result = await toolHandler({ scope: "current", format: "summary", since: "7days" }); + expect(result.content[0].text).toContain("No events found"); + }); +});