Skip to content

Commit f321cac

Browse files
feat: add export_report tool for markdown session reports (#5)
Adds a new MCP tool that generates markdown reports from timeline data: - Event breakdown with type distribution percentages - Branch activity summary - Daily activity with visual bars - Commit log with hashes - Correction/error analysis - Quality indicators (correction rate, error rate) - Three formats: summary, detailed, weekly - Supports relative dates (7days, 2weeks, etc.) Closes #5
1 parent d5f0d01 commit f321cac

3 files changed

Lines changed: 466 additions & 0 deletions

File tree

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { registerScanSessions } from "./tools/scan-sessions.js";
4949
import { registerGenerateScorecard } from "./tools/generate-scorecard.js";
5050
import { registerSearchContracts } from "./tools/search-contracts.js";
5151
import { registerEstimateCost } from "./tools/estimate-cost.js";
52+
import { registerExportReport } from "./tools/export-report.js";
5253

5354
// Validate related projects from config
5455
function validateRelatedProjects(): void {
@@ -110,6 +111,7 @@ const toolRegistry: Array<[string, RegisterFn]> = [
110111
["generate_scorecard", registerGenerateScorecard],
111112
["estimate_cost", registerEstimateCost],
112113
["search_contracts", registerSearchContracts],
114+
["export_report", registerExportReport],
113115
];
114116

115117
let registered = 0;

src/tools/export-report.ts

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
import { z } from "zod";
2+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js";
4+
import { getRelatedProjects } from "../lib/config.js";
5+
import type { SearchScope } from "../types.js";
6+
7+
const RELATIVE_DATE_RE = /^(\d+)(days?|weeks?|months?|years?)$/;
8+
9+
function parseRelativeDate(input: string): string {
10+
const match = input.match(RELATIVE_DATE_RE);
11+
if (!match) return input;
12+
const [, numStr, unit] = match;
13+
const num = parseInt(numStr, 10);
14+
const d = new Date();
15+
if (unit.startsWith("day")) d.setDate(d.getDate() - num);
16+
else if (unit.startsWith("week")) d.setDate(d.getDate() - num * 7);
17+
else if (unit.startsWith("month")) d.setMonth(d.getMonth() - num);
18+
else if (unit.startsWith("year")) d.setFullYear(d.getFullYear() - num);
19+
return d.toISOString();
20+
}
21+
22+
const TYPE_LABELS: Record<string, string> = {
23+
prompt: "Prompts",
24+
assistant: "Responses",
25+
tool_call: "Tool Calls",
26+
correction: "Corrections",
27+
commit: "Commits",
28+
compaction: "Compactions",
29+
sub_agent_spawn: "Sub-agent Spawns",
30+
error: "Errors",
31+
};
32+
33+
const TYPE_ICONS: Record<string, string> = {
34+
prompt: "💬",
35+
assistant: "🤖",
36+
tool_call: "🔧",
37+
correction: "❌",
38+
commit: "📦",
39+
compaction: "🗜️",
40+
sub_agent_spawn: "🚀",
41+
error: "⚠️",
42+
};
43+
44+
async function getSearchProjects(scope: SearchScope): Promise<string[]> {
45+
const currentProject = process.env.CLAUDE_PROJECT_DIR;
46+
switch (scope) {
47+
case "current":
48+
return currentProject ? [currentProject] : [];
49+
case "related": {
50+
const related = getRelatedProjects();
51+
return currentProject ? [currentProject, ...related] : related;
52+
}
53+
case "all": {
54+
const projects = await listIndexedProjects();
55+
return projects.map((p) => p.project);
56+
}
57+
default:
58+
return currentProject ? [currentProject] : [];
59+
}
60+
}
61+
62+
interface ReportStats {
63+
totalEvents: number;
64+
byType: Record<string, number>;
65+
byDay: Record<string, number>;
66+
byBranch: Record<string, number>;
67+
corrections: Array<{ timestamp: string; content: string }>;
68+
errors: Array<{ timestamp: string; content: string }>;
69+
commits: Array<{ timestamp: string; content: string; hash?: string }>;
70+
activeDays: number;
71+
dateRange: { start: string; end: string };
72+
}
73+
74+
function computeStats(events: any[]): ReportStats {
75+
const byType: Record<string, number> = {};
76+
const byDay: Record<string, number> = {};
77+
const byBranch: Record<string, number> = {};
78+
const corrections: ReportStats["corrections"] = [];
79+
const errors: ReportStats["errors"] = [];
80+
const commits: ReportStats["commits"] = [];
81+
82+
for (const e of events) {
83+
byType[e.type] = (byType[e.type] || 0) + 1;
84+
85+
const day = e.timestamp ? new Date(e.timestamp).toISOString().slice(0, 10) : "unknown";
86+
byDay[day] = (byDay[day] || 0) + 1;
87+
88+
if (e.branch) {
89+
byBranch[e.branch] = (byBranch[e.branch] || 0) + 1;
90+
}
91+
92+
if (e.type === "correction") {
93+
corrections.push({
94+
timestamp: e.timestamp,
95+
content: (e.content || "").slice(0, 200),
96+
});
97+
}
98+
if (e.type === "error") {
99+
errors.push({
100+
timestamp: e.timestamp,
101+
content: (e.content || "").slice(0, 200),
102+
});
103+
}
104+
if (e.type === "commit") {
105+
let hash: string | undefined;
106+
try {
107+
const meta = JSON.parse(e.metadata || "{}");
108+
hash = meta.hash || e.commit_hash;
109+
} catch {
110+
/* ignore */
111+
}
112+
commits.push({
113+
timestamp: e.timestamp,
114+
content: (e.content || "").slice(0, 120),
115+
hash: hash?.slice(0, 7),
116+
});
117+
}
118+
}
119+
120+
const sortedDays = Object.keys(byDay).sort();
121+
return {
122+
totalEvents: events.length,
123+
byType,
124+
byDay,
125+
byBranch,
126+
corrections,
127+
errors,
128+
commits,
129+
activeDays: sortedDays.length,
130+
dateRange: {
131+
start: sortedDays[0] || "N/A",
132+
end: sortedDays[sortedDays.length - 1] || "N/A",
133+
},
134+
};
135+
}
136+
137+
function generateMarkdownReport(
138+
stats: ReportStats,
139+
projectName: string,
140+
format: "summary" | "detailed" | "weekly",
141+
): string {
142+
const lines: string[] = [];
143+
const now = new Date().toISOString().slice(0, 10);
144+
145+
lines.push(`# ${projectName} — Session Report`);
146+
lines.push(`_Generated ${now} | ${stats.dateRange.start}${stats.dateRange.end}_`);
147+
lines.push("");
148+
149+
// Summary section
150+
lines.push("## Summary");
151+
lines.push("");
152+
lines.push(`- **Total events:** ${stats.totalEvents}`);
153+
lines.push(`- **Active days:** ${stats.activeDays}`);
154+
lines.push(`- **Commits:** ${stats.byType["commit"] || 0}`);
155+
lines.push(`- **Corrections:** ${stats.byType["correction"] || 0}`);
156+
lines.push(`- **Errors:** ${stats.byType["error"] || 0}`);
157+
lines.push("");
158+
159+
// Event breakdown
160+
lines.push("## Event Breakdown");
161+
lines.push("");
162+
const sortedTypes = Object.entries(stats.byType).sort((a, b) => b[1] - a[1]);
163+
for (const [type, count] of sortedTypes) {
164+
const icon = TYPE_ICONS[type] || "❓";
165+
const label = TYPE_LABELS[type] || type;
166+
const pct = ((count / stats.totalEvents) * 100).toFixed(1);
167+
lines.push(`- ${icon} **${label}:** ${count} (${pct}%)`);
168+
}
169+
lines.push("");
170+
171+
// Branch activity
172+
if (Object.keys(stats.byBranch).length > 0) {
173+
lines.push("## Branch Activity");
174+
lines.push("");
175+
const sortedBranches = Object.entries(stats.byBranch).sort((a, b) => b[1] - a[1]);
176+
for (const [branch, count] of sortedBranches) {
177+
lines.push(`- \`${branch}\`: ${count} events`);
178+
}
179+
lines.push("");
180+
}
181+
182+
// Daily activity (for weekly/detailed)
183+
if (format !== "summary") {
184+
lines.push("## Daily Activity");
185+
lines.push("");
186+
const sortedDays = Object.entries(stats.byDay).sort((a, b) => a[0].localeCompare(b[0]));
187+
for (const [day, count] of sortedDays) {
188+
const bar = "█".repeat(Math.min(Math.ceil(count / 5), 20));
189+
lines.push(`- **${day}:** ${count} events ${bar}`);
190+
}
191+
lines.push("");
192+
}
193+
194+
// Commits log
195+
if (stats.commits.length > 0 && format !== "summary") {
196+
lines.push("## Commits");
197+
lines.push("");
198+
for (const c of stats.commits) {
199+
const time = c.timestamp ? new Date(c.timestamp).toISOString().slice(0, 16).replace("T", " ") : "??";
200+
const hash = c.hash ? `\`${c.hash}\` ` : "";
201+
lines.push(`- ${time}${hash}${c.content}`);
202+
}
203+
lines.push("");
204+
}
205+
206+
// Corrections & errors (quality signals)
207+
if (stats.corrections.length > 0) {
208+
lines.push("## Corrections");
209+
lines.push(`_${stats.corrections.length} correction(s) logged — review for recurring patterns._`);
210+
lines.push("");
211+
for (const c of stats.corrections.slice(0, 10)) {
212+
const time = c.timestamp ? new Date(c.timestamp).toISOString().slice(0, 16).replace("T", " ") : "??";
213+
lines.push(`- ${time}: ${c.content}`);
214+
}
215+
if (stats.corrections.length > 10) {
216+
lines.push(`- _...and ${stats.corrections.length - 10} more_`);
217+
}
218+
lines.push("");
219+
}
220+
221+
if (stats.errors.length > 0) {
222+
lines.push("## Errors");
223+
lines.push(`_${stats.errors.length} error(s) captured._`);
224+
lines.push("");
225+
for (const e of stats.errors.slice(0, 10)) {
226+
const time = e.timestamp ? new Date(e.timestamp).toISOString().slice(0, 16).replace("T", " ") : "??";
227+
lines.push(`- ${time}: ${e.content}`);
228+
}
229+
if (stats.errors.length > 10) {
230+
lines.push(`- _...and ${stats.errors.length - 10} more_`);
231+
}
232+
lines.push("");
233+
}
234+
235+
// Prompt quality trends (ratio of corrections to prompts)
236+
const prompts = stats.byType["prompt"] || 0;
237+
const correctionCount = stats.byType["correction"] || 0;
238+
if (prompts > 0) {
239+
lines.push("## Quality Indicators");
240+
lines.push("");
241+
const correctionRate = ((correctionCount / prompts) * 100).toFixed(1);
242+
const errorRate = (((stats.byType["error"] || 0) / stats.totalEvents) * 100).toFixed(1);
243+
lines.push(`- **Correction rate:** ${correctionRate}% of prompts needed correction`);
244+
lines.push(`- **Error rate:** ${errorRate}% of events were errors`);
245+
if (correctionCount === 0) {
246+
lines.push("- ✅ No corrections needed — clean session!");
247+
} else if (parseFloat(correctionRate) > 20) {
248+
lines.push("- ⚠️ High correction rate — consider refining prompts or using `preflight_check`");
249+
}
250+
lines.push("");
251+
}
252+
253+
lines.push("---");
254+
lines.push("_Report generated by preflight `export_report` tool._");
255+
256+
return lines.join("\n");
257+
}
258+
259+
export function registerExportReport(server: McpServer) {
260+
server.tool(
261+
"export_report",
262+
"Generate a markdown report from session timeline data. Includes event breakdown, commit log, correction/error analysis, and quality indicators. Use for weekly summaries, sprint reviews, or prompt quality audits.",
263+
{
264+
scope: z
265+
.enum(["current", "related", "all"])
266+
.default("current")
267+
.describe("Search scope: current project, related projects, or all indexed"),
268+
project: z
269+
.string()
270+
.optional()
271+
.describe("Filter to a specific project (overrides scope)"),
272+
since: z
273+
.string()
274+
.optional()
275+
.describe("Start date (ISO or relative like '7days', '2weeks')"),
276+
until: z
277+
.string()
278+
.optional()
279+
.describe("End date (ISO or relative)"),
280+
branch: z.string().optional().describe("Filter to a specific branch"),
281+
format: z
282+
.enum(["summary", "detailed", "weekly"])
283+
.default("weekly")
284+
.describe(
285+
"Report format: summary (brief overview), detailed (full log), weekly (balanced default)",
286+
),
287+
},
288+
async (params) => {
289+
const since = params.since ? parseRelativeDate(params.since) : undefined;
290+
const until = params.until ? parseRelativeDate(params.until) : undefined;
291+
292+
let projectDirs: string[];
293+
if (params.project) {
294+
projectDirs = [params.project];
295+
} else {
296+
projectDirs = await getSearchProjects(params.scope);
297+
}
298+
299+
if (projectDirs.length === 0) {
300+
return {
301+
content: [
302+
{
303+
type: "text",
304+
text: `## Export Report\n_No projects found for scope "${params.scope}". Onboard a project first with \`onboard_project\`._`,
305+
},
306+
],
307+
};
308+
}
309+
310+
// Fetch all events (high limit for report generation)
311+
const events = await getTimeline({
312+
project_dirs: projectDirs,
313+
branch: params.branch,
314+
since,
315+
until,
316+
limit: 5000,
317+
});
318+
319+
if (events.length === 0) {
320+
return {
321+
content: [
322+
{
323+
type: "text",
324+
text: "## Export Report\n_No events found for the given filters. Try broadening your date range or scope._",
325+
},
326+
],
327+
};
328+
}
329+
330+
const projectName =
331+
params.project || (projectDirs.length === 1 ? projectDirs[0].split("/").pop() : "Multi-project");
332+
333+
const stats = computeStats(events);
334+
const report = generateMarkdownReport(stats, projectName!, params.format);
335+
336+
return { content: [{ type: "text", text: report }] };
337+
},
338+
);
339+
}

0 commit comments

Comments
 (0)