Skip to content

Commit 36dfbdc

Browse files
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: - Summary stats (event counts, correction/error rates) - Daily activity breakdown - Commit log - Correction patterns analysis - Error listing - Optional full timeline Supports relative date ranges (7days, 2weeks), configurable sections, and all existing scope/project filters. Closes #5
1 parent c17f463 commit 36dfbdc

2 files changed

Lines changed: 335 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: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
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_ICONS: Record<string, string> = {
23+
prompt: "💬",
24+
assistant: "🤖",
25+
tool_call: "🔧",
26+
correction: "❌",
27+
commit: "📦",
28+
compaction: "🗜️",
29+
sub_agent_spawn: "🚀",
30+
error: "⚠️",
31+
};
32+
33+
async function getSearchProjects(scope: SearchScope): Promise<string[]> {
34+
const currentProject = process.env.CLAUDE_PROJECT_DIR;
35+
switch (scope) {
36+
case "current":
37+
return currentProject ? [currentProject] : [];
38+
case "related": {
39+
const related = getRelatedProjects();
40+
return currentProject ? [currentProject, ...related] : related;
41+
}
42+
case "all": {
43+
const projects = await listIndexedProjects();
44+
return projects.map((p) => p.project);
45+
}
46+
default:
47+
return currentProject ? [currentProject] : [];
48+
}
49+
}
50+
51+
interface TimelineEvent {
52+
timestamp?: string;
53+
type: string;
54+
content?: string;
55+
summary?: string;
56+
commit_hash?: string;
57+
tool_name?: string;
58+
metadata?: string;
59+
}
60+
61+
interface ReportStats {
62+
total: number;
63+
byType: Record<string, number>;
64+
byDay: Map<string, TimelineEvent[]>;
65+
promptCount: number;
66+
commitCount: number;
67+
errorCount: number;
68+
correctionCount: number;
69+
toolCallCount: number;
70+
}
71+
72+
function computeStats(events: TimelineEvent[]): ReportStats {
73+
const byType: Record<string, number> = {};
74+
const byDay = new Map<string, TimelineEvent[]>();
75+
76+
for (const e of events) {
77+
byType[e.type] = (byType[e.type] || 0) + 1;
78+
const day = e.timestamp
79+
? new Date(e.timestamp).toISOString().slice(0, 10)
80+
: "unknown";
81+
if (!byDay.has(day)) byDay.set(day, []);
82+
byDay.get(day)!.push(e);
83+
}
84+
85+
return {
86+
total: events.length,
87+
byType,
88+
byDay,
89+
promptCount: byType["prompt"] || 0,
90+
commitCount: byType["commit"] || 0,
91+
errorCount: byType["error"] || 0,
92+
correctionCount: byType["correction"] || 0,
93+
toolCallCount: byType["tool_call"] || 0,
94+
};
95+
}
96+
97+
function generateMarkdownReport(
98+
events: TimelineEvent[],
99+
stats: ReportStats,
100+
options: { title: string; since?: string; until?: string; sections: string[] }
101+
): string {
102+
const lines: string[] = [];
103+
const now = new Date().toISOString().slice(0, 10);
104+
105+
lines.push(`# ${options.title}`);
106+
lines.push(`_Generated ${now}_`);
107+
if (options.since || options.until) {
108+
const range = [options.since || "beginning", options.until || "now"].join(
109+
" → "
110+
);
111+
lines.push(`_Period: ${range}_`);
112+
}
113+
lines.push("");
114+
115+
// Summary section
116+
if (options.sections.includes("summary")) {
117+
lines.push("## Summary");
118+
lines.push("");
119+
lines.push(`| Metric | Count |`);
120+
lines.push(`|--------|-------|`);
121+
lines.push(`| Total events | ${stats.total} |`);
122+
lines.push(`| Prompts | ${stats.promptCount} |`);
123+
lines.push(`| Commits | ${stats.commitCount} |`);
124+
lines.push(`| Tool calls | ${stats.toolCallCount} |`);
125+
lines.push(`| Corrections | ${stats.correctionCount} |`);
126+
lines.push(`| Errors | ${stats.errorCount} |`);
127+
lines.push("");
128+
129+
if (stats.total > 0) {
130+
const correctionRate = (
131+
(stats.correctionCount / stats.promptCount) *
132+
100
133+
).toFixed(1);
134+
const errorRate = ((stats.errorCount / stats.total) * 100).toFixed(1);
135+
lines.push(`**Correction rate:** ${correctionRate}% of prompts`);
136+
lines.push(`**Error rate:** ${errorRate}% of events`);
137+
lines.push("");
138+
}
139+
}
140+
141+
// Activity breakdown
142+
if (options.sections.includes("activity")) {
143+
lines.push("## Daily Activity");
144+
lines.push("");
145+
const sortedDays = [...stats.byDay.keys()].sort().reverse();
146+
for (const day of sortedDays) {
147+
const dayEvents = stats.byDay.get(day)!;
148+
const dayCounts: Record<string, number> = {};
149+
for (const e of dayEvents) {
150+
dayCounts[e.type] = (dayCounts[e.type] || 0) + 1;
151+
}
152+
const parts = Object.entries(dayCounts)
153+
.map(([t, c]) => `${TYPE_ICONS[t] || "❓"} ${t}: ${c}`)
154+
.join(", ");
155+
lines.push(`- **${day}** (${dayEvents.length} events) — ${parts}`);
156+
}
157+
lines.push("");
158+
}
159+
160+
// Commits section
161+
if (options.sections.includes("commits")) {
162+
const commits = events.filter((e) => e.type === "commit");
163+
if (commits.length > 0) {
164+
lines.push("## Commits");
165+
lines.push("");
166+
for (const c of commits) {
167+
const hash = c.commit_hash ? c.commit_hash.slice(0, 7) : "???????";
168+
const msg = (c.content || c.summary || "").slice(0, 120).replace(/\n/g, " ");
169+
const time = c.timestamp
170+
? new Date(c.timestamp).toISOString().slice(0, 16).replace("T", " ")
171+
: "";
172+
lines.push(`- \`${hash}\` ${msg} _(${time})_`);
173+
}
174+
lines.push("");
175+
}
176+
}
177+
178+
// Corrections section
179+
if (options.sections.includes("corrections")) {
180+
const corrections = events.filter((e) => e.type === "correction");
181+
if (corrections.length > 0) {
182+
lines.push("## Corrections");
183+
lines.push("");
184+
lines.push(
185+
"_Patterns in corrections can reveal prompt quality issues._"
186+
);
187+
lines.push("");
188+
for (const c of corrections) {
189+
const msg = (c.content || c.summary || "").slice(0, 200).replace(/\n/g, " ");
190+
lines.push(`- ${msg}`);
191+
}
192+
lines.push("");
193+
}
194+
}
195+
196+
// Errors section
197+
if (options.sections.includes("errors")) {
198+
const errors = events.filter((e) => e.type === "error");
199+
if (errors.length > 0) {
200+
lines.push("## Errors");
201+
lines.push("");
202+
for (const e of errors) {
203+
const msg = (e.content || e.summary || "").slice(0, 200).replace(/\n/g, " ");
204+
lines.push(`- ⚠️ ${msg}`);
205+
}
206+
lines.push("");
207+
}
208+
}
209+
210+
// Timeline section
211+
if (options.sections.includes("timeline")) {
212+
lines.push("## Full Timeline");
213+
lines.push("");
214+
const sortedDays = [...stats.byDay.keys()].sort().reverse();
215+
for (const day of sortedDays) {
216+
lines.push(`### ${day}`);
217+
const dayEvents = stats.byDay.get(day)!;
218+
dayEvents.sort((a, b) => {
219+
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
220+
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
221+
return ta - tb;
222+
});
223+
for (const event of dayEvents) {
224+
const time = event.timestamp
225+
? new Date(event.timestamp).toISOString().slice(11, 16)
226+
: "??:??";
227+
const icon = TYPE_ICONS[event.type] || "❓";
228+
const content = (event.content || event.summary || "")
229+
.slice(0, 120)
230+
.replace(/\n/g, " ");
231+
lines.push(`- ${time} ${icon} ${content}`);
232+
}
233+
lines.push("");
234+
}
235+
}
236+
237+
return lines.join("\n");
238+
}
239+
240+
export function registerExportTimeline(server: McpServer) {
241+
server.tool(
242+
"export_timeline",
243+
"Generate a markdown report from timeline data. Produces session summaries with stats, commit logs, correction patterns, and daily activity breakdowns.",
244+
{
245+
scope: z
246+
.enum(["current", "related", "all"])
247+
.default("current")
248+
.describe("Search scope"),
249+
project: z.string().optional().describe("Specific project (overrides scope)"),
250+
since: z
251+
.string()
252+
.optional()
253+
.describe("Start date (ISO or relative like '7days', '2weeks')"),
254+
until: z.string().optional().describe("End date"),
255+
title: z
256+
.string()
257+
.default("Session Report")
258+
.describe("Report title"),
259+
sections: z
260+
.array(
261+
z.enum([
262+
"summary",
263+
"activity",
264+
"commits",
265+
"corrections",
266+
"errors",
267+
"timeline",
268+
])
269+
)
270+
.default(["summary", "activity", "commits", "corrections", "errors"])
271+
.describe("Which sections to include"),
272+
limit: z.number().default(500).describe("Max events to include"),
273+
},
274+
async (params) => {
275+
const since = params.since
276+
? parseRelativeDate(params.since)
277+
: undefined;
278+
const until = params.until
279+
? parseRelativeDate(params.until)
280+
: undefined;
281+
282+
let projectDirs: string[];
283+
if (params.project) {
284+
projectDirs = [params.project];
285+
} else {
286+
projectDirs = await getSearchProjects(params.scope);
287+
}
288+
289+
if (projectDirs.length === 0) {
290+
return {
291+
content: [
292+
{
293+
type: "text",
294+
text: `No projects found for scope "${params.scope}". Make sure CLAUDE_PROJECT_DIR is set or projects are onboarded.`,
295+
},
296+
],
297+
};
298+
}
299+
300+
const events = await getTimeline({
301+
project_dirs: projectDirs,
302+
project: undefined,
303+
since,
304+
until,
305+
limit: params.limit,
306+
offset: 0,
307+
});
308+
309+
if (events.length === 0) {
310+
return {
311+
content: [
312+
{
313+
type: "text",
314+
text: "No events found for the given filters. Nothing to report.",
315+
},
316+
],
317+
};
318+
}
319+
320+
const stats = computeStats(events);
321+
const report = generateMarkdownReport(events, stats, {
322+
title: params.title,
323+
since: params.since,
324+
until: params.until,
325+
sections: params.sections,
326+
});
327+
328+
return {
329+
content: [{ type: "text", text: report }],
330+
};
331+
}
332+
);
333+
}

0 commit comments

Comments
 (0)