From ec643783a95b886f31d436eb4b2b571baa379f00 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 09:45:23 -0700 Subject: [PATCH 1/3] 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 (prompts, commits, corrections, errors, tool calls) - Correction rate and avg events/day metrics - Top tools breakdown - Daily breakdown with commits, corrections, errors highlighted - Collapsible prompt listings per day - Optional save-to-file support - Relative date parsing (7days, 2weeks, etc.) Closes #5 --- src/index.ts | 2 + src/tools/export-timeline.ts | 315 +++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 src/tools/export-timeline.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..1a0c3f4 --- /dev/null +++ b/src/tools/export-timeline.ts @@ -0,0 +1,315 @@ +// ============================================================================= +// export_timeline — Generate markdown session reports from timeline data +// Addresses GitHub issue #5: Export timeline to markdown/PDF reports +// ============================================================================= + +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 { writeFile, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import type { SearchScope } from "../types.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const TYPE_ICONS: Record = { + prompt: "💬", + assistant: "🤖", + tool_call: "🔧", + correction: "❌", + commit: "📦", + compaction: "🗜️", + sub_agent_spawn: "🚀", + error: "⚠️", +}; + +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(); +} + +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] : []; + } +} + +// ── Stats computation ────────────────────────────────────────────────────── + +interface ReportStats { + totalEvents: number; + prompts: number; + corrections: number; + commits: number; + toolCalls: number; + errors: number; + compactions: number; + subAgentSpawns: number; + correctionRate: string; + topTools: [string, number][]; + activeDays: number; + avgEventsPerDay: string; +} + +function computeStats(events: any[]): ReportStats { + const counts = { prompts: 0, corrections: 0, commits: 0, toolCalls: 0, errors: 0, compactions: 0, subAgentSpawns: 0 }; + const toolNames = new Map(); + const days = new Set(); + + for (const e of events) { + if (e.timestamp) days.add(new Date(e.timestamp).toISOString().slice(0, 10)); + switch (e.type) { + case "prompt": counts.prompts++; break; + case "correction": counts.corrections++; break; + case "commit": counts.commits++; break; + case "tool_call": + counts.toolCalls++; + if (e.tool_name) toolNames.set(e.tool_name, (toolNames.get(e.tool_name) || 0) + 1); + break; + case "error": counts.errors++; break; + case "compaction": counts.compactions++; break; + case "sub_agent_spawn": counts.subAgentSpawns++; break; + } + } + + const topTools = [...toolNames.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); + const activeDays = days.size || 1; + + return { + totalEvents: events.length, + ...counts, + correctionRate: counts.prompts > 0 ? ((counts.corrections / counts.prompts) * 100).toFixed(1) : "0.0", + topTools, + activeDays, + avgEventsPerDay: (events.length / activeDays).toFixed(1), + }; +} + +// ── Markdown generation ──────────────────────────────────────────────────── + +function generateMarkdownReport( + events: any[], + opts: { project: string; since?: string; until?: string; scope: string } +): string { + const stats = computeStats(events); + const now = new Date().toISOString().slice(0, 10); + + // Group events 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 dateRange = opts.since || opts.until + ? `${opts.since || "beginning"} → ${opts.until || "now"}` + : sortedDays.length > 1 + ? `${sortedDays[sortedDays.length - 1]} → ${sortedDays[0]}` + : sortedDays[0] || now; + + const lines: string[] = []; + + // Title + lines.push(`# Session Report: ${opts.project}`); + lines.push(`_Generated ${now} | ${dateRange}_`); + lines.push(""); + + // Summary stats + lines.push("## Summary"); + lines.push(""); + lines.push(`| Metric | Value |`); + lines.push(`|--------|-------|`); + lines.push(`| Active days | ${stats.activeDays} |`); + lines.push(`| Total events | ${stats.totalEvents} |`); + lines.push(`| Prompts | ${stats.prompts} |`); + lines.push(`| Commits | ${stats.commits} |`); + lines.push(`| Tool calls | ${stats.toolCalls} |`); + lines.push(`| Corrections | ${stats.corrections} (${stats.correctionRate}% rate) |`); + lines.push(`| Errors | ${stats.errors} |`); + lines.push(`| Compactions | ${stats.compactions} |`); + lines.push(`| Sub-agent spawns | ${stats.subAgentSpawns} |`); + lines.push(`| Avg events/day | ${stats.avgEventsPerDay} |`); + lines.push(""); + + // Top tools + if (stats.topTools.length > 0) { + lines.push("## Top Tools"); + lines.push(""); + for (const [name, count] of stats.topTools) { + lines.push(`- **${name}**: ${count} calls`); + } + lines.push(""); + } + + // Daily breakdown + lines.push("## Daily Breakdown"); + lines.push(""); + + for (const day of sortedDays) { + const dayEvents = days.get(day)!; + 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 dayStats = computeStats(dayEvents); + lines.push(`### ${day} (${dayEvents.length} events, ${dayStats.prompts} prompts, ${dayStats.commits} commits)`); + lines.push(""); + + // Show commits prominently + const commits = dayEvents.filter((e: any) => e.type === "commit"); + if (commits.length > 0) { + lines.push("**Commits:**"); + for (const c of commits) { + const hash = c.commit_hash ? c.commit_hash.slice(0, 7) : "???????"; + const msg = (c.content || c.summary || "").slice(0, 120).replace(/\n/g, " "); + const time = c.timestamp ? new Date(c.timestamp).toISOString().slice(11, 16) : "??:??"; + lines.push(`- \`${hash}\` ${time} — ${msg}`); + } + lines.push(""); + } + + // Show corrections + const corrections = dayEvents.filter((e: any) => e.type === "correction"); + if (corrections.length > 0) { + lines.push("**Corrections:**"); + for (const c of corrections) { + const msg = (c.content || "").slice(0, 120).replace(/\n/g, " "); + lines.push(`- ❌ ${msg}`); + } + lines.push(""); + } + + // Show errors + const errors = dayEvents.filter((e: any) => e.type === "error"); + if (errors.length > 0) { + lines.push("**Errors:**"); + for (const e of errors) { + const msg = (e.content || "").slice(0, 120).replace(/\n/g, " "); + lines.push(`- ⚠️ ${msg}`); + } + lines.push(""); + } + + // Prompt quality trend — show prompt snippets + const prompts = dayEvents.filter((e: any) => e.type === "prompt"); + if (prompts.length > 0 && prompts.length <= 20) { + lines.push("
"); + lines.push(`Prompts (${prompts.length})`); + lines.push(""); + for (const p of prompts) { + const time = p.timestamp ? new Date(p.timestamp).toISOString().slice(11, 16) : "??:??"; + const msg = (p.content || "").slice(0, 200).replace(/\n/g, " "); + lines.push(`- ${time} 💬 ${msg}`); + } + lines.push(""); + lines.push("
"); + lines.push(""); + } + } + + // Footer + lines.push("---"); + lines.push(`_Report generated by preflight export_timeline_`); + + return lines.join("\n"); +} + +// ── Tool registration ────────────────────────────────────────────────────── + +export function registerExportTimeline(server: McpServer): void { + server.tool( + "export_timeline", + "Generate a markdown session report from timeline data. Includes summary stats, daily breakdown with commits/corrections/errors, prompt quality trends, and top tools. Optionally saves to file.", + { + 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 or relative like '7days', '2weeks')"), + until: z.string().optional().describe("End date (ISO or relative)"), + limit: z.number().default(500).describe("Max events to include"), + saveTo: z.string().optional().describe("File path to save the report. If omitted, returns inline."), + }, + async (params) => { + 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}". Set CLAUDE_PROJECT_DIR or onboard projects first.`, + }], + }; + } + + const events = await getTimeline({ + project_dirs: projectDirs, + since, + until, + limit: params.limit, + offset: 0, + }); + + if (events.length === 0) { + return { + content: [{ type: "text", text: "No timeline events found for the given filters." }], + }; + } + + const projectLabel = params.project || (projectDirs.length === 1 ? projectDirs[0] : `${projectDirs.length} projects`); + const markdown = generateMarkdownReport(events, { + project: projectLabel, + since, + until, + scope: params.scope, + }); + + if (params.saveTo) { + const outPath = params.saveTo.startsWith("/") + ? params.saveTo + : join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), params.saveTo); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, markdown, "utf-8"); + return { + content: [{ type: "text", text: `✅ Report saved to ${outPath} (${events.length} events, ${markdown.length} chars)` }], + }; + } + + return { content: [{ type: "text", text: markdown }] }; + } + ); +} From 3a87dd233066f6ec5608188f2792880a1d5ad081 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 10:14:26 -0700 Subject: [PATCH 2/3] test: add 17 unit tests for prompt_score scoring logic - Export scorePrompt function for testability - Test all 4 scoring dimensions (specificity, scope, actionability, done condition) - Test grade boundaries (A+ for perfect, F for vague) - Test feedback generation and total calculation - 60 tests passing (up from 43) --- memory/2026-03-04.md | 6 ++ src/tools/prompt-score.ts | 2 +- tests/tools/prompt-score.test.ts | 120 +++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 memory/2026-03-04.md create mode 100644 tests/tools/prompt-score.test.ts diff --git a/memory/2026-03-04.md b/memory/2026-03-04.md new file mode 100644 index 0000000..09ede83 --- /dev/null +++ b/memory/2026-03-04.md @@ -0,0 +1,6 @@ +# 2026-03-04 + +## Dev Sprint + +- Closed issues #7, #8, #9, #13, #14 — all were already implemented but never closed +- Shipped Ollama embedding support (PR #70, closes #6): new `OllamaEmbeddingProvider` with batch support, config via yml or env vars, 2 new tests (57 total passing) 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..ca378ae --- /dev/null +++ b/tests/tools/prompt-score.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; + +// We need to extract scorePrompt for testing. Since it's not exported, +// we'll test the logic by reimporting the module internals. +// For now, let's replicate the scoring logic to verify behavior, +// then refactor the source to export scorePrompt. + +// Actually — let's just refactor the source to export scorePrompt first. +// This test file assumes the refactored version. + +import { scorePrompt } from "../../src/tools/prompt-score.js"; + +describe("scorePrompt", () => { + describe("specificity", () => { + it("scores 25 for prompts with file paths", () => { + const result = scorePrompt("Fix the bug in src/tools/prompt-score.ts"); + expect(result.specificity).toBe(25); + }); + + it("scores 25 for prompts with backtick identifiers", () => { + const result = scorePrompt("Rename `handleClick` to `onSubmit`"); + expect(result.specificity).toBe(25); + }); + + it("scores 15 for generic component mentions", () => { + const result = scorePrompt("Update the function"); + expect(result.specificity).toBe(15); + }); + + it("scores 5 for completely vague prompts", () => { + const result = scorePrompt("make it better"); + expect(result.specificity).toBe(5); + }); + }); + + describe("scope", () => { + it("scores 25 for bounded tasks", () => { + const result = scorePrompt("Only change this one line"); + expect(result.scope).toBe(25); + }); + + it("scores 10 for overly broad scope", () => { + const result = scorePrompt("Fix all bugs"); + expect(result.scope).toBe(10); + }); + }); + + describe("actionability", () => { + it("scores 25 for specific action verbs", () => { + const result = scorePrompt("Rename the variable to camelCase"); + expect(result.actionability).toBe(25); + }); + + it("scores 15 for vague verbs like 'make'", () => { + const result = scorePrompt("Make the code work"); + expect(result.actionability).toBe(15); + }); + + it("scores 5 for no verb at all", () => { + const result = scorePrompt("the button color"); + expect(result.actionability).toBe(5); + }); + }); + + describe("done condition", () => { + it("scores 25 for prompts with verifiable outcomes", () => { + const result = scorePrompt("Fix it so the test should pass"); + expect(result.doneCondition).toBe(25); + }); + + it("scores 20 for questions", () => { + const result = scorePrompt("Why is this breaking?"); + expect(result.doneCondition).toBe(20); + }); + + it("scores 5 for no done condition", () => { + const result = scorePrompt("Refactor the code"); + expect(result.doneCondition).toBe(5); + }); + }); + + describe("grading", () => { + it("gives A+ for perfect prompts", () => { + // File path (25) + bounded (25) + action verb (25) + outcome (25) = 100 + const result = scorePrompt( + "Fix the bug in `src/server.ts` — only the validation check should return a 400 error" + ); + expect(result.total).toBe(100); + expect(result.grade).toBe("A+"); + }); + + it("gives F for terrible prompts", () => { + const result = scorePrompt("stuff"); + expect(result.total).toBeLessThanOrEqual(45); + expect(result.grade).toBe("F"); + }); + + it("includes feedback for low-scoring dimensions", () => { + const result = scorePrompt("stuff"); + expect(result.feedback.length).toBeGreaterThan(0); + expect(result.feedback.some((f) => f.includes("📁"))).toBe(true); + }); + + it("gives praise for perfect scores", () => { + const result = scorePrompt( + "Fix the bug in `src/server.ts` — only the validation check should return a 400 error" + ); + expect(result.feedback[0]).toContain("🏆"); + }); + }); + + describe("total calculation", () => { + it("total equals sum of all dimensions", () => { + const result = scorePrompt("Add a test for the parser module"); + expect(result.total).toBe( + result.specificity + result.scope + result.actionability + result.doneCondition + ); + }); + }); +}); From 07367618555252f45bf4b1e73c553008b5d1fa01 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 10:14:31 -0700 Subject: [PATCH 3/3] chore: ignore memory directory --- .gitignore | 1 + memory/2026-03-04.md | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 memory/2026-03-04.md diff --git a/.gitignore b/.gitignore index c49e8d7..59f96b3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.js.map .worktrees/ +memory/ diff --git a/memory/2026-03-04.md b/memory/2026-03-04.md deleted file mode 100644 index 09ede83..0000000 --- a/memory/2026-03-04.md +++ /dev/null @@ -1,6 +0,0 @@ -# 2026-03-04 - -## Dev Sprint - -- Closed issues #7, #8, #9, #13, #14 — all were already implemented but never closed -- Shipped Ollama embedding support (PR #70, closes #6): new `OllamaEmbeddingProvider` with batch support, config via yml or env vars, 2 new tests (57 total passing)