Skip to content

Commit c98e5aa

Browse files
feat: add export_timeline tool for markdown session reports
Closes #5 Adds a new export_timeline MCP tool that generates structured Markdown reports from timeline data including: - Summary statistics (prompts, commits, corrections, errors) - Event breakdown with visual bars - Daily timeline tables - Correction pattern analysis Supports relative date ranges (e.g. '1week', '30days'), custom titles, and all existing scope/project filters.
1 parent 3952de4 commit c98e5aa

3 files changed

Lines changed: 417 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 { registerExportTimeline } from "./tools/export-timeline.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_timeline", registerExportTimeline],
113115
];
114116

115117
let registered = 0;

src/tools/export-timeline.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
async function getSearchProjects(scope: SearchScope): Promise<string[]> {
23+
const currentProject = process.env.CLAUDE_PROJECT_DIR;
24+
switch (scope) {
25+
case "current":
26+
return currentProject ? [currentProject] : [];
27+
case "related": {
28+
const related = getRelatedProjects();
29+
return currentProject ? [currentProject, ...related] : related;
30+
}
31+
case "all": {
32+
const projects = await listIndexedProjects();
33+
return projects.map((p) => p.project);
34+
}
35+
default:
36+
return currentProject ? [currentProject] : [];
37+
}
38+
}
39+
40+
interface EventGroup {
41+
day: string;
42+
events: any[];
43+
}
44+
45+
function groupByDay(events: any[]): EventGroup[] {
46+
const days = new Map<string, any[]>();
47+
for (const event of events) {
48+
const day = event.timestamp
49+
? new Date(event.timestamp).toISOString().slice(0, 10)
50+
: "unknown";
51+
if (!days.has(day)) days.set(day, []);
52+
days.get(day)!.push(event);
53+
}
54+
// Sort days descending, events within day ascending
55+
const sorted = [...days.keys()].sort().reverse();
56+
return sorted.map((day) => {
57+
const dayEvents = days.get(day)!;
58+
dayEvents.sort((a: any, b: any) => {
59+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
60+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
61+
return ta - tb;
62+
});
63+
return { day, events: dayEvents };
64+
});
65+
}
66+
67+
function computeStats(events: any[]) {
68+
const counts: Record<string, number> = {};
69+
for (const e of events) {
70+
counts[e.type] = (counts[e.type] || 0) + 1;
71+
}
72+
const totalPrompts = counts["prompt"] || 0;
73+
const totalCorrections = counts["correction"] || 0;
74+
const totalCommits = counts["commit"] || 0;
75+
const totalToolCalls = counts["tool_call"] || 0;
76+
const totalErrors = counts["error"] || 0;
77+
const correctionRate =
78+
totalPrompts > 0
79+
? ((totalCorrections / totalPrompts) * 100).toFixed(1)
80+
: "0.0";
81+
return {
82+
total: events.length,
83+
prompts: totalPrompts,
84+
corrections: totalCorrections,
85+
commits: totalCommits,
86+
toolCalls: totalToolCalls,
87+
errors: totalErrors,
88+
correctionRate,
89+
counts,
90+
};
91+
}
92+
93+
const TYPE_ICONS: Record<string, string> = {
94+
prompt: "💬",
95+
assistant: "🤖",
96+
tool_call: "🔧",
97+
correction: "❌",
98+
commit: "📦",
99+
compaction: "🗜️",
100+
sub_agent_spawn: "🚀",
101+
error: "⚠️",
102+
};
103+
104+
function formatEventLine(event: any): string {
105+
const time = event.timestamp
106+
? new Date(event.timestamp).toISOString().slice(11, 16)
107+
: "??:??";
108+
const icon = TYPE_ICONS[event.type] || "❓";
109+
let content = (event.content || event.summary || "")
110+
.slice(0, 200)
111+
.replace(/\n/g, " ");
112+
113+
if (event.type === "commit") {
114+
const hash = event.commit_hash ? event.commit_hash.slice(0, 7) + ": " : "";
115+
content = `\`${hash}${content}\``;
116+
} else if (event.type === "tool_call") {
117+
const tool = event.tool_name || "";
118+
const target = content ? ` → ${content}` : "";
119+
content = `\`${tool}\`${target}`;
120+
} else {
121+
content = content ? `"${content}"` : "";
122+
}
123+
124+
return `| ${time} | ${icon} ${event.type} | ${content} |`;
125+
}
126+
127+
function renderMarkdown(
128+
events: any[],
129+
params: { project?: string; scope: string; since?: string; until?: string; title?: string }
130+
): string {
131+
const groups = groupByDay(events);
132+
const stats = computeStats(events);
133+
const now = new Date().toISOString().slice(0, 10);
134+
const title = params.title || "Session Report";
135+
const proj = params.project || params.scope;
136+
137+
const lines: string[] = [];
138+
139+
// Front matter
140+
lines.push(`# ${title}`);
141+
lines.push("");
142+
lines.push(`**Project:** ${proj} `);
143+
lines.push(`**Generated:** ${now} `);
144+
if (params.since || params.until) {
145+
const range = [params.since || "start", params.until || "now"].join(" → ");
146+
lines.push(`**Period:** ${range} `);
147+
}
148+
lines.push("");
149+
150+
// Summary stats
151+
lines.push("## Summary");
152+
lines.push("");
153+
lines.push(`| Metric | Count |`);
154+
lines.push(`|--------|-------|`);
155+
lines.push(`| Total events | ${stats.total} |`);
156+
lines.push(`| Prompts | ${stats.prompts} |`);
157+
lines.push(`| Tool calls | ${stats.toolCalls} |`);
158+
lines.push(`| Commits | ${stats.commits} |`);
159+
lines.push(`| Corrections | ${stats.corrections} |`);
160+
lines.push(`| Errors | ${stats.errors} |`);
161+
lines.push(`| Correction rate | ${stats.correctionRate}% |`);
162+
lines.push("");
163+
164+
// Breakdown by type
165+
lines.push("## Event Breakdown");
166+
lines.push("");
167+
for (const [type, count] of Object.entries(stats.counts).sort(
168+
(a, b) => b[1] - a[1]
169+
)) {
170+
const icon = TYPE_ICONS[type] || "❓";
171+
const bar = "█".repeat(Math.min(Math.ceil((count / stats.total) * 40), 40));
172+
lines.push(`${icon} **${type}**: ${count} ${bar}`);
173+
}
174+
lines.push("");
175+
176+
// Daily timeline
177+
lines.push("## Daily Timeline");
178+
lines.push("");
179+
180+
for (const { day, events: dayEvents } of groups) {
181+
const dayStats = computeStats(dayEvents);
182+
lines.push(`### ${day} (${dayEvents.length} events)`);
183+
lines.push("");
184+
lines.push(`| Time | Type | Details |`);
185+
lines.push(`|------|------|---------|`);
186+
for (const event of dayEvents) {
187+
lines.push(formatEventLine(event));
188+
}
189+
lines.push("");
190+
191+
// Day summary
192+
if (dayStats.commits > 0) {
193+
lines.push(
194+
`> **${day} summary:** ${dayStats.commits} commits, ${dayStats.prompts} prompts, ${dayStats.corrections} corrections`
195+
);
196+
lines.push("");
197+
}
198+
}
199+
200+
// Trends section (corrections)
201+
if (stats.corrections > 0) {
202+
lines.push("## Correction Patterns");
203+
lines.push("");
204+
const corrections = events.filter((e: any) => e.type === "correction");
205+
for (const c of corrections.slice(0, 10)) {
206+
const content = (c.content || c.summary || "").slice(0, 300).replace(/\n/g, " ");
207+
const day = c.timestamp
208+
? new Date(c.timestamp).toISOString().slice(0, 10)
209+
: "unknown";
210+
lines.push(`- **${day}**: ${content}`);
211+
}
212+
if (corrections.length > 10) {
213+
lines.push(`- _...and ${corrections.length - 10} more_`);
214+
}
215+
lines.push("");
216+
}
217+
218+
lines.push("---");
219+
lines.push("_Generated by [preflight](https://github.com/TerminalGravity/preflight) export-timeline_");
220+
221+
return lines.join("\n");
222+
}
223+
224+
export function registerExportTimeline(server: McpServer) {
225+
server.tool(
226+
"export_timeline",
227+
"Export session timeline data as a structured Markdown report with summary statistics, daily breakdowns, correction patterns, and event trends. Use for weekly summaries, retrospectives, and prompt quality analysis.",
228+
{
229+
scope: z
230+
.enum(["current", "related", "all"])
231+
.default("current")
232+
.describe("Search scope"),
233+
project: z.string().optional().describe("Filter to specific project (overrides scope)"),
234+
since: z
235+
.string()
236+
.optional()
237+
.describe("Start date (ISO or relative like '1week', '30days')"),
238+
until: z.string().optional().describe("End date"),
239+
title: z.string().optional().describe("Report title (default: 'Session Report')"),
240+
limit: z.number().default(500).describe("Max events to include"),
241+
},
242+
async (params) => {
243+
const since = params.since ? parseRelativeDate(params.since) : undefined;
244+
const until = params.until ? parseRelativeDate(params.until) : undefined;
245+
246+
let projectDirs: string[];
247+
if (params.project) {
248+
projectDirs = [params.project];
249+
} else {
250+
projectDirs = await getSearchProjects(params.scope);
251+
}
252+
253+
if (projectDirs.length === 0) {
254+
return {
255+
content: [
256+
{
257+
type: "text",
258+
text: `No projects found for scope "${params.scope}". Set CLAUDE_PROJECT_DIR or onboard projects first.`,
259+
},
260+
],
261+
};
262+
}
263+
264+
const events = await getTimeline({
265+
project_dirs: projectDirs,
266+
project: undefined,
267+
since,
268+
until,
269+
limit: params.limit,
270+
offset: 0,
271+
});
272+
273+
if (events.length === 0) {
274+
return {
275+
content: [
276+
{
277+
type: "text",
278+
text: "No events found for the given filters. Nothing to export.",
279+
},
280+
],
281+
};
282+
}
283+
284+
const markdown = renderMarkdown(events, {
285+
project: params.project,
286+
scope: params.scope,
287+
since: params.since,
288+
until: params.until,
289+
title: params.title,
290+
});
291+
292+
return {
293+
content: [
294+
{
295+
type: "text",
296+
text: markdown,
297+
},
298+
],
299+
};
300+
}
301+
);
302+
}

0 commit comments

Comments
 (0)