From 20e1b4d466f37cd215e328784aeeb9f9f03ab698 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 18 Mar 2026 12:46:05 -0700 Subject: [PATCH 1/2] feat: add export_timeline tool for markdown session reports (#5) Adds a new export_timeline MCP tool that generates formatted markdown reports from timeline data, including: - Summary statistics (events, prompts, commits, errors) - Quality indicators (correction rate, error rate, events/day) - Daily breakdowns with chronological event listings - Support for relative date ranges (7days, 2weeks, etc.) - Scope filtering (current/related/all projects) Includes 4 tests covering registration, empty state, report generation with stats, and relative date parsing. Closes #5 --- src/index.ts | 2 + src/tools/export-timeline.ts | 304 ++++++++++++++++++++++++++++ tests/tools/export-timeline.test.ts | 141 +++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 src/tools/export-timeline.ts create mode 100644 tests/tools/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..d4a205d --- /dev/null +++ b/src/tools/export-timeline.ts @@ -0,0 +1,304 @@ +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 TYPE_ICONS: Record = { + prompt: "💬", + assistant: "🤖", + tool_call: "🔧", + correction: "❌", + commit: "📦", + compaction: "🗜️", + sub_agent_spawn: "🚀", + error: "⚠️", +}; + +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 DaySummary { + date: string; + events: any[]; + promptCount: number; + commitCount: number; + errorCount: number; + correctionCount: number; + toolCallCount: number; +} + +function summarizeDay(date: string, events: any[]): DaySummary { + return { + date, + events, + promptCount: events.filter((e) => e.type === "prompt").length, + commitCount: events.filter((e) => e.type === "commit").length, + errorCount: events.filter((e) => e.type === "error").length, + correctionCount: events.filter((e) => e.type === "correction").length, + toolCallCount: events.filter((e) => e.type === "tool_call").length, + }; +} + +function formatMarkdown( + summaries: DaySummary[], + projectName: string, + since?: string, + until?: string, +): string { + const lines: string[] = []; + const totalEvents = summaries.reduce((s, d) => s + d.events.length, 0); + const totalPrompts = summaries.reduce((s, d) => s + d.promptCount, 0); + const totalCommits = summaries.reduce((s, d) => s + d.commitCount, 0); + const totalErrors = summaries.reduce((s, d) => s + d.errorCount, 0); + const totalCorrections = summaries.reduce( + (s, d) => s + d.correctionCount, + 0, + ); + + // Header + lines.push(`# Session Report: ${projectName}`); + lines.push(""); + const dateRange = + since && until + ? `${since} to ${until}` + : summaries.length > 0 + ? `${summaries[summaries.length - 1].date} to ${summaries[0].date}` + : "N/A"; + lines.push(`**Period:** ${dateRange}`); + lines.push(`**Generated:** ${new Date().toISOString().slice(0, 10)}`); + lines.push(""); + + // Summary stats + lines.push("## Summary"); + lines.push(""); + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Total Events | ${totalEvents} |`); + lines.push(`| Days Active | ${summaries.length} |`); + lines.push(`| Prompts | ${totalPrompts} |`); + lines.push(`| Commits | ${totalCommits} |`); + lines.push(`| Corrections | ${totalCorrections} |`); + lines.push(`| Errors | ${totalErrors} |`); + lines.push(""); + + // Quality indicators + if (totalPrompts > 0) { + const correctionRate = ((totalCorrections / totalPrompts) * 100).toFixed(1); + const errorRate = ((totalErrors / totalPrompts) * 100).toFixed(1); + lines.push("## Quality Indicators"); + lines.push(""); + lines.push( + `- **Correction rate:** ${correctionRate}% (${totalCorrections} corrections / ${totalPrompts} prompts)`, + ); + lines.push( + `- **Error rate:** ${errorRate}% (${totalErrors} errors / ${totalPrompts} prompts)`, + ); + lines.push( + `- **Avg events/day:** ${(totalEvents / summaries.length).toFixed(1)}`, + ); + lines.push(""); + } + + // Daily breakdown + lines.push("## Daily Breakdown"); + lines.push(""); + + for (const day of summaries) { + const badges: string[] = []; + if (day.commitCount > 0) badges.push(`${day.commitCount} commits`); + if (day.promptCount > 0) badges.push(`${day.promptCount} prompts`); + if (day.errorCount > 0) badges.push(`⚠️ ${day.errorCount} errors`); + if (day.correctionCount > 0) + badges.push(`❌ ${day.correctionCount} corrections`); + + lines.push(`### ${day.date} (${badges.join(", ")})`); + lines.push(""); + + // Sort events chronologically within the day + const sorted = [...day.events].sort((a, b) => { + const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return ta - tb; + }); + + for (const event of sorted) { + const time = event.timestamp + ? new Date(event.timestamp).toISOString().slice(11, 16) + : "??:??"; + const icon = TYPE_ICONS[event.type] || "❓"; + let content = (event.content || event.summary || "") + .slice(0, 150) + .replace(/\n/g, " "); + + if (event.type === "commit") { + const hash = event.commit_hash + ? `\`${event.commit_hash.slice(0, 7)}\` ` + : ""; + content = `${hash}${content}`; + } else if (event.type === "tool_call") { + const tool = event.tool_name || ""; + const target = content ? ` → ${content}` : ""; + content = `\`${tool}\`${target}`; + } + + lines.push(`- **${time}** ${icon} ${content}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +export function registerExportTimeline(server: McpServer) { + server.tool( + "export_timeline", + "Export timeline data as a formatted markdown report with summary statistics, quality indicators, and daily breakdowns. Useful for weekly summaries, sprint reviews, and tracking prompt quality trends.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe("Search scope"), + project: z + .string() + .optional() + .describe("Filter to a specific project (overrides scope)"), + branch: z.string().optional(), + since: z + .string() + .optional() + .describe( + "Start date (ISO string or relative like '7days', '2weeks', '1month')", + ), + until: z.string().optional().describe("End date (ISO string)"), + type: z + .enum([ + "prompt", + "assistant", + "correction", + "commit", + "tool_call", + "compaction", + "sub_agent_spawn", + "error", + "all", + ]) + .default("all"), + limit: z.number().default(500).describe("Max events to include"), + }, + async (params) => { + // Parse relative dates + const since = params.since ? parseRelativeDate(params.since) : undefined; + 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", + text: `No projects found for scope "${params.scope}". Make sure CLAUDE_PROJECT_DIR is set or projects are onboarded.`, + }, + ], + }; + } + + const events = await getTimeline({ + project_dirs: projectDirs, + branch: params.branch, + since, + until, + type: params.type === "all" ? undefined : params.type, + limit: params.limit, + offset: 0, + }); + + if (events.length === 0) { + return { + content: [ + { + type: "text", + text: "No events found for the given filters. Nothing to export.", + }, + ], + }; + } + + // Group by day + 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 sortedDays = [...days.keys()].sort().reverse(); + const summaries = sortedDays.map((day) => + summarizeDay(day, days.get(day)!), + ); + + const projectName = + params.project || projectDirs[0]?.split("/").pop() || "Unknown"; + const markdown = formatMarkdown(summaries, projectName, since, until); + + return { + content: [ + { + type: "text", + text: markdown, + }, + ], + }; + }, + ); +} + +// Reuse the same relative date parser from timeline-view +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(); +} diff --git a/tests/tools/export-timeline.test.ts b/tests/tools/export-timeline.test.ts new file mode 100644 index 0000000..85357c9 --- /dev/null +++ b/tests/tools/export-timeline.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +// Mock timeline-db +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 { registerExportTimeline } from "../../src/tools/export-timeline.js"; +import { getTimeline } from "../../src/lib/timeline-db.js"; + +const mockedGetTimeline = vi.mocked(getTimeline); + +describe("export_timeline", () => { + let server: McpServer; + let toolHandler: (params: any) => Promise; + + beforeEach(() => { + vi.clearAllMocks(); + // Capture the registered tool handler + server = { + tool: vi.fn((_name: string, _desc: string, _schema: any, handler: any) => { + toolHandler = handler; + }), + } as unknown as McpServer; + registerExportTimeline(server); + }); + + it("registers the export_timeline tool", () => { + expect(server.tool).toHaveBeenCalledWith( + "export_timeline", + expect.any(String), + expect.any(Object), + expect.any(Function), + ); + }); + + it("returns empty message when no events found", async () => { + mockedGetTimeline.mockResolvedValue([]); + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + + const result = await toolHandler({ + scope: "current", + type: "all", + limit: 500, + }); + + expect(result.content[0].text).toContain("No events found"); + }); + + it("generates markdown report with summary stats", async () => { + mockedGetTimeline.mockResolvedValue([ + { + id: "1", + timestamp: "2026-03-15T10:00:00Z", + type: "prompt", + project: "/test/project", + project_name: "project", + branch: "main", + session_id: "s1", + source_file: "f1", + source_line: 1, + content: "Add a new feature", + content_preview: "Add a new feature", + vector: [], + metadata: "{}", + }, + { + id: "2", + timestamp: "2026-03-15T10:05:00Z", + type: "commit", + project: "/test/project", + project_name: "project", + branch: "main", + session_id: "s1", + source_file: "f1", + source_line: 2, + content: "feat: add new feature", + content_preview: "feat: add new feature", + vector: [], + metadata: "{}", + }, + { + id: "3", + timestamp: "2026-03-15T11:00:00Z", + type: "error", + project: "/test/project", + project_name: "project", + branch: "main", + session_id: "s1", + source_file: "f1", + source_line: 3, + content: "Build failed", + content_preview: "Build failed", + vector: [], + metadata: "{}", + }, + ] as any); + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + + const result = await toolHandler({ + scope: "current", + type: "all", + limit: 500, + }); + + const text = result.content[0].text; + expect(text).toContain("# Session Report:"); + expect(text).toContain("## Summary"); + expect(text).toContain("Total Events | 3"); + expect(text).toContain("Prompts | 1"); + expect(text).toContain("Commits | 1"); + expect(text).toContain("Errors | 1"); + expect(text).toContain("## Quality Indicators"); + expect(text).toContain("## Daily Breakdown"); + expect(text).toContain("### 2026-03-15"); + }); + + it("handles relative date parsing", async () => { + mockedGetTimeline.mockResolvedValue([]); + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + + await toolHandler({ + scope: "current", + since: "7days", + type: "all", + limit: 500, + }); + + expect(mockedGetTimeline).toHaveBeenCalledWith( + expect.objectContaining({ + since: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }), + ); + }); +}); From b820f7a147ab9be0f7836e08235308a0fd35d0e5 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 18 Mar 2026 13:33:49 -0700 Subject: [PATCH 2/2] test: add 18 tests for prompt_score scoring logic Export scorePrompt function and add comprehensive test coverage for all four scoring dimensions (specificity, scope, actionability, done-condition), grade calculation, feedback generation, and edge cases. --- src/tools/prompt-score.ts | 2 +- tests/tools/prompt-score.test.ts | 112 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/tools/prompt-score.test.ts diff --git a/src/tools/prompt-score.ts b/src/tools/prompt-score.ts index 1cecf01..9e91532 100644 --- a/src/tools/prompt-score.ts +++ b/src/tools/prompt-score.ts @@ -40,7 +40,7 @@ interface ScoreResult { feedback: string[]; } -function scorePrompt(text: string): ScoreResult { +export function scorePrompt(text: string): ScoreResult { const feedback: string[] = []; let specificity: number; let scope: number; diff --git a/tests/tools/prompt-score.test.ts b/tests/tools/prompt-score.test.ts new file mode 100644 index 0000000..c1eeba0 --- /dev/null +++ b/tests/tools/prompt-score.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { scorePrompt } from "../../src/tools/prompt-score.js"; + +describe("scorePrompt", () => { + it("gives high specificity for file paths", () => { + const result = scorePrompt("Fix the bug in src/utils/parser.ts"); + expect(result.specificity).toBe(25); + }); + + it("gives high specificity for backtick identifiers", () => { + const result = scorePrompt("Rename `fetchUserData` to `getUserById`"); + expect(result.specificity).toBe(25); + }); + + it("gives medium specificity for generic file/function mentions", () => { + const result = scorePrompt("Update the component to handle errors"); + expect(result.specificity).toBe(15); + }); + + it("gives low specificity when nothing specific is mentioned", () => { + const result = scorePrompt("Make it better"); + expect(result.specificity).toBe(5); + }); + + it("gives high scope for bounded tasks", () => { + const result = scorePrompt("Only update the validation logic in this single file"); + expect(result.scope).toBe(25); + }); + + it("penalizes broad scope words", () => { + const result = scorePrompt("Refactor all the tests"); + // "all" → 10, but length > 100 check doesn't apply here + expect(result.scope).toBe(10); + }); + + it("gives high actionability for specific verbs", () => { + const result = scorePrompt("Extract the helper into a separate module"); + expect(result.actionability).toBe(25); + }); + + it("gives medium actionability for vague verbs", () => { + const result = scorePrompt("Make the tests work"); + expect(result.actionability).toBe(15); + }); + + it("gives low actionability with no verbs", () => { + const result = scorePrompt("the login page"); + expect(result.actionability).toBe(5); + }); + + it("gives high done-condition for outcome words", () => { + const result = scorePrompt("Fix the parser so it should return null for empty input"); + expect(result.doneCondition).toBe(25); + }); + + it("gives decent done-condition for questions", () => { + const result = scorePrompt("What is the best approach here?"); + expect(result.doneCondition).toBe(20); + }); + + it("gives low done-condition when outcome is unclear", () => { + const result = scorePrompt("Clean up the code"); + expect(result.doneCondition).toBe(5); + }); + + it("grades A+ for a perfect prompt", () => { + const result = scorePrompt( + "Rename `processQueue` in src/workers/queue.ts to `drainQueue` — only this one function. It should still pass all existing tests." + ); + expect(result.total).toBeGreaterThanOrEqual(90); + expect(result.grade).toBe("A+"); + }); + + it("grades F for a terrible prompt", () => { + const result = scorePrompt("stuff"); + expect(result.total).toBeLessThan(45); + expect(result.grade).toBe("F"); + }); + + it("returns feedback tips for low-scoring prompts", () => { + const result = scorePrompt("stuff"); + expect(result.feedback.length).toBeGreaterThan(0); + expect(result.feedback.some((f) => f.includes("📁"))).toBe(true); + }); + + it("returns congratulatory feedback for perfect scores", () => { + const result = scorePrompt( + "Add a test for `scorePrompt` in tests/tools/prompt-score.test.ts that should assert the total is 100" + ); + if (result.total === 100) { + expect(result.feedback[0]).toContain("🏆"); + } + }); + + it("total is sum of all dimensions", () => { + const result = scorePrompt("Fix the bug in src/index.ts"); + expect(result.total).toBe( + result.specificity + result.scope + result.actionability + result.doneCondition + ); + }); + + it("all dimensions are between 0 and 25", () => { + const prompts = ["x", "Fix src/a.ts only, should return true", "Make stuff work"]; + for (const p of prompts) { + const r = scorePrompt(p); + for (const dim of [r.specificity, r.scope, r.actionability, r.doneCondition]) { + expect(dim).toBeGreaterThanOrEqual(0); + expect(dim).toBeLessThanOrEqual(25); + } + } + }); +});