From 525eed330cfe9a4e2392da603235a47ab29a9696 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Mon, 16 Mar 2026 16:17:20 -0700 Subject: [PATCH 1/2] feat: add export_report tool for markdown session reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #5 — Generates session reports from timeline data with: - Activity breakdown by event type - Daily activity table - Recent commits list - Prompt quality trends (correction rate tracking) - Configurable periods (day/week/month/custom) - Optional file output Includes 4 tests covering edge cases. --- src/index.ts | 2 + src/tools/export-report.ts | 358 ++++++++++++++++++++++++++++++++++++ tests/export-report.test.ts | 88 +++++++++ 3 files changed, 448 insertions(+) create mode 100644 src/tools/export-report.ts create mode 100644 tests/export-report.test.ts diff --git a/src/index.ts b/src/index.ts index e7e9d00..1528c44 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 { registerExportReport } from "./tools/export-report.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_report", registerExportReport], ]; let registered = 0; diff --git a/src/tools/export-report.ts b/src/tools/export-report.ts new file mode 100644 index 0000000..b49ccc2 --- /dev/null +++ b/src/tools/export-report.ts @@ -0,0 +1,358 @@ +// ============================================================================= +// export_report — Generate markdown reports from timeline data +// Addresses: https://github.com/TerminalGravity/preflight/issues/5 +// ============================================================================= + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getTimeline, listIndexedProjects } from "../lib/timeline-db.js"; +import { getRelatedProjects } from "../lib/config.js"; +import { writeFileSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import type { SearchScope } from "../types.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + 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] : []; + } +} + +interface EventSummary { + total: number; + byType: Record; + byDay: Map>; + corrections: number; + avgPromptsPerDay: number; + activeDays: number; +} + +function summarizeEvents(events: any[]): EventSummary { + const byType: Record = {}; + const byDay = new Map>(); + + for (const e of events) { + byType[e.type] = (byType[e.type] || 0) + 1; + const day = e.timestamp + ? new Date(e.timestamp).toISOString().slice(0, 10) + : "unknown"; + if (!byDay.has(day)) byDay.set(day, {}); + const dayMap = byDay.get(day)!; + dayMap[e.type] = (dayMap[e.type] || 0) + 1; + } + + const activeDays = byDay.size; + const prompts = byType["prompt"] || 0; + + return { + total: events.length, + byType, + byDay, + corrections: byType["correction"] || 0, + avgPromptsPerDay: activeDays > 0 ? Math.round(prompts / activeDays) : 0, + activeDays, + }; +} + +const TYPE_LABELS: Record = { + prompt: "💬 Prompts", + assistant: "🤖 Responses", + tool_call: "🔧 Tool Calls", + correction: "❌ Corrections", + commit: "📦 Commits", + compaction: "🗜️ Compactions", + sub_agent_spawn: "🚀 Sub-agents", + error: "⚠️ Errors", +}; + +function buildMarkdownReport( + title: string, + period: string, + projectName: string, + events: any[], + summary: EventSummary +): string { + const lines: string[] = []; + const now = new Date().toISOString().slice(0, 19).replace("T", " "); + + lines.push(`# ${title}`); + lines.push(""); + lines.push(`**Project:** ${projectName}`); + lines.push(`**Period:** ${period}`); + lines.push(`**Generated:** ${now}`); + lines.push(""); + + // Overview + lines.push("## Overview"); + lines.push(""); + lines.push(`| Metric | Value |`); + lines.push(`| --- | --- |`); + lines.push(`| Total Events | ${summary.total} |`); + lines.push(`| Active Days | ${summary.activeDays} |`); + lines.push(`| Avg Prompts/Day | ${summary.avgPromptsPerDay} |`); + lines.push(`| Corrections | ${summary.corrections} |`); + + const correctionRate = + (summary.byType["prompt"] || 0) > 0 + ? ((summary.corrections / summary.byType["prompt"]) * 100).toFixed(1) + : "0"; + lines.push(`| Correction Rate | ${correctionRate}% |`); + lines.push(""); + + // Activity breakdown + lines.push("## Activity Breakdown"); + lines.push(""); + for (const [type, count] of Object.entries(summary.byType).sort( + (a, b) => b[1] - a[1] + )) { + const label = TYPE_LABELS[type] || type; + const bar = "█".repeat(Math.min(Math.ceil(count / 5), 30)); + lines.push(`- ${label}: **${count}** ${bar}`); + } + lines.push(""); + + // Daily activity + lines.push("## Daily Activity"); + lines.push(""); + const sortedDays = [...summary.byDay.keys()].sort().reverse(); + lines.push(`| Date | Prompts | Tools | Commits | Corrections | Total |`); + lines.push(`| --- | --- | --- | --- | --- | --- |`); + for (const day of sortedDays) { + const d = summary.byDay.get(day)!; + const total = Object.values(d).reduce((a, b) => a + b, 0); + lines.push( + `| ${day} | ${d["prompt"] || 0} | ${d["tool_call"] || 0} | ${d["commit"] || 0} | ${d["correction"] || 0} | ${total} |` + ); + } + lines.push(""); + + // Recent commits + const commits = events + .filter((e) => e.type === "commit") + .sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ) + .slice(0, 20); + + if (commits.length > 0) { + lines.push("## Recent Commits"); + lines.push(""); + for (const c of commits) { + const date = new Date(c.timestamp).toISOString().slice(0, 10); + const hash = c.commit_hash ? c.commit_hash.slice(0, 7) : "???????"; + const msg = (c.content || c.summary || "").slice(0, 100).replace(/\n/g, " "); + lines.push(`- \`${hash}\` ${date} — ${msg}`); + } + lines.push(""); + } + + // Prompt quality trends (corrections vs prompts by day) + if (summary.corrections > 0) { + lines.push("## Prompt Quality Trends"); + lines.push(""); + lines.push( + "_Days with corrections (higher correction rate = more iteration needed):_" + ); + lines.push(""); + for (const day of sortedDays) { + const d = summary.byDay.get(day)!; + const dayPrompts = d["prompt"] || 0; + const dayCorrections = d["correction"] || 0; + if (dayCorrections > 0 && dayPrompts > 0) { + const rate = ((dayCorrections / dayPrompts) * 100).toFixed(0); + lines.push( + `- **${day}**: ${dayCorrections}/${dayPrompts} corrections (${rate}%)` + ); + } + } + lines.push(""); + } + + lines.push("---"); + lines.push("_Generated by preflight `export_report` tool_"); + + return lines.join("\n"); +} + +// ── Registration ─────────────────────────────────────────────────────────── + +export function registerExportReport(server: McpServer) { + server.tool( + "export_report", + "Generate a markdown session report from timeline data. Summarizes activity, prompt quality trends, commits, and daily stats for a given period.", + { + scope: z + .enum(["current", "related", "all"]) + .default("current") + .describe("Search scope"), + project: z + .string() + .optional() + .describe("Specific project directory (overrides scope)"), + period: z + .enum(["day", "week", "month", "custom"]) + .default("week") + .describe("Report period"), + since: z + .string() + .optional() + .describe("Custom start date (ISO 8601). Used when period=custom."), + until: z + .string() + .optional() + .describe("Custom end date (ISO 8601). Used when period=custom."), + output: z + .string() + .optional() + .describe( + "File path to write the report. If omitted, returns inline." + ), + }, + async (params) => { + // Determine date range + let since: string; + let until: string = new Date().toISOString(); + let periodLabel: string; + + switch (params.period) { + case "day": + since = daysAgo(1); + periodLabel = "Last 24 hours"; + break; + case "week": + since = daysAgo(7); + periodLabel = "Last 7 days"; + break; + case "month": + since = daysAgo(30); + periodLabel = "Last 30 days"; + break; + case "custom": + if (!params.since) { + return { + content: [ + { + type: "text", + text: "Error: `since` is required when period=custom.", + }, + ], + }; + } + since = params.since; + until = params.until || until; + periodLabel = `${since.slice(0, 10)} to ${until.slice(0, 10)}`; + break; + default: + since = daysAgo(7); + periodLabel = "Last 7 days"; + } + + // Resolve projects + 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 a project first.`, + }, + ], + }; + } + + // Fetch events + const events = await getTimeline({ + project_dirs: projectDirs, + since, + until, + limit: 5000, + offset: 0, + }); + + if (events.length === 0) { + return { + content: [ + { + type: "text", + text: `No events found for the ${periodLabel} period.`, + }, + ], + }; + } + + const summary = summarizeEvents(events); + const projectName = + params.project || projectDirs.join(", ") || "All Projects"; + + const title = + params.period === "week" + ? "Weekly Session Report" + : params.period === "month" + ? "Monthly Session Report" + : params.period === "day" + ? "Daily Session Report" + : "Session Report"; + + const report = buildMarkdownReport( + title, + periodLabel, + projectName, + events, + summary + ); + + // Write to file if requested + if (params.output) { + try { + mkdirSync(dirname(params.output), { recursive: true }); + writeFileSync(params.output, report, "utf-8"); + return { + content: [ + { + type: "text", + text: `Report written to \`${params.output}\` (${report.length} bytes, ${summary.total} events).`, + }, + ], + }; + } catch (err: any) { + return { + content: [ + { + type: "text", + text: `Failed to write report: ${err.message}\n\n${report}`, + }, + ], + }; + } + } + + return { content: [{ type: "text", text: report }] }; + } + ); +} diff --git a/tests/export-report.test.ts b/tests/export-report.test.ts new file mode 100644 index 0000000..f7b8db1 --- /dev/null +++ b/tests/export-report.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock timeline-db before importing the tool +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 { getTimeline } from "../src/lib/timeline-db.js"; + +// We test the tool handler by registering it on a mock server and capturing the handler +describe("export_report", () => { + let handler: (params: any) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Capture the tool handler from register call + const mockServer = { + tool: (_name: string, _desc: string, _schema: any, fn: any) => { + handler = fn; + }, + }; + + const { registerExportReport } = await import( + "../src/tools/export-report.js" + ); + registerExportReport(mockServer as any); + }); + + it("returns error when no projects found", async () => { + delete process.env.CLAUDE_PROJECT_DIR; + const result = await handler({ + scope: "current", + period: "week", + }); + expect(result.content[0].text).toContain("No projects found"); + }); + + it("returns no-events message for empty timeline", async () => { + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + (getTimeline as any).mockResolvedValue([]); + + const result = await handler({ + scope: "current", + period: "week", + }); + expect(result.content[0].text).toContain("No events found"); + }); + + it("generates a markdown report with stats", async () => { + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + const now = new Date().toISOString(); + const events = [ + { type: "prompt", timestamp: now, content: "fix the bug" }, + { type: "assistant", timestamp: now, content: "done" }, + { type: "tool_call", timestamp: now, content: "read file", tool_name: "Read" }, + { type: "commit", timestamp: now, content: "fix bug", commit_hash: "abc1234def" }, + { type: "correction", timestamp: now, content: "wrong approach" }, + ]; + (getTimeline as any).mockResolvedValue(events); + + const result = await handler({ + scope: "current", + period: "week", + }); + + const text = result.content[0].text; + expect(text).toContain("Weekly Session Report"); + expect(text).toContain("Total Events | 5"); + expect(text).toContain("Correction Rate"); + expect(text).toContain("abc1234"); + expect(text).toContain("Prompt Quality Trends"); + }); + + it("requires since for custom period", async () => { + process.env.CLAUDE_PROJECT_DIR = "/test/project"; + const result = await handler({ + scope: "current", + period: "custom", + }); + expect(result.content[0].text).toContain("`since` is required"); + }); +}); From c91569edfae976050adb5647c8f030a422c09d8d Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Mon, 16 Mar 2026 16:46:35 -0700 Subject: [PATCH 2/2] feat: add Ollama as embedding provider (#6) Add OllamaEmbeddingProvider that uses Ollama's /api/embed endpoint for fully local embeddings without any external API keys. - Supports custom base URL, model, and dimensions - Default model: nomic-embed-text (768 dims) - Batch embedding via single API call - Config via .preflight/config.yml, env vars, or tool params - Added tests for provider creation Closes #6 --- src/lib/config.ts | 15 +++++++-- src/lib/embeddings.ts | 61 +++++++++++++++++++++++++++++++++++- src/lib/timeline-db.ts | 8 ++++- src/tools/onboard-project.ts | 10 ++++-- tests/lib/embeddings.test.ts | 23 ++++++++++++++ 5 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index fc9d8f2..b57ba9b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -11,7 +11,7 @@ import { load as yamlLoad } from "js-yaml"; import { PROJECT_DIR } from "./files.js"; export type Profile = "minimal" | "standard" | "full"; -export type EmbeddingProvider = "local" | "openai"; +export type EmbeddingProvider = "local" | "openai" | "ollama"; export type TriageStrictness = "relaxed" | "standard" | "strict"; export interface RelatedProject { @@ -30,6 +30,9 @@ export interface PreflightConfig { embeddings: { provider: EmbeddingProvider; openai_api_key?: string; + ollama_base_url?: string; + ollama_model?: string; + ollama_dimensions?: number; }; triage: { rules: { @@ -125,7 +128,7 @@ function loadConfig(): PreflightConfig { // Embedding provider const envProvider = process.env.EMBEDDING_PROVIDER?.toLowerCase(); - if (envProvider === "local" || envProvider === "openai") { + if (envProvider === "local" || envProvider === "openai" || envProvider === "ollama") { config.embeddings.provider = envProvider; } @@ -133,6 +136,14 @@ function loadConfig(): PreflightConfig { if (process.env.OPENAI_API_KEY) { config.embeddings.openai_api_key = process.env.OPENAI_API_KEY; } + + // Ollama config + if (process.env.OLLAMA_BASE_URL) { + config.embeddings.ollama_base_url = process.env.OLLAMA_BASE_URL; + } + if (process.env.OLLAMA_EMBED_MODEL) { + config.embeddings.ollama_model = process.env.OLLAMA_EMBED_MODEL; + } } return config; diff --git a/src/lib/embeddings.ts b/src/lib/embeddings.ts index 69b5883..c2ecd6e 100644 --- a/src/lib/embeddings.ts +++ b/src/lib/embeddings.ts @@ -102,11 +102,63 @@ class OpenAIEmbeddingProvider implements EmbeddingProvider { } } +// --- Ollama Provider --- + +class OllamaEmbeddingProvider implements EmbeddingProvider { + dimensions: number; + private baseUrl: string; + private model: string; + + constructor(options?: { baseUrl?: string; model?: string; dimensions?: number }) { + this.baseUrl = (options?.baseUrl ?? "http://localhost:11434").replace(/\/$/, ""); + this.model = options?.model ?? "nomic-embed-text"; + // nomic-embed-text = 768, all-minilm = 384, mxbai-embed-large = 1024 + this.dimensions = options?.dimensions ?? 768; + } + + async embed(text: string): Promise { + const processed = preprocessText(text); + const resp = await fetch(`${this.baseUrl}/api/embed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: this.model, input: processed }), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`Ollama embeddings API error ${resp.status}: ${err}`); + } + + const data = await resp.json(); + return data.embeddings[0]; + } + + async embedBatch(texts: string[]): Promise { + const processed = texts.map(preprocessText); + const resp = await fetch(`${this.baseUrl}/api/embed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: this.model, input: processed }), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error(`Ollama embeddings API error ${resp.status}: ${err}`); + } + + const data = await resp.json(); + return data.embeddings; + } +} + // --- Factory --- export interface EmbeddingConfig { - provider: "local" | "openai"; + provider: "local" | "openai" | "ollama"; apiKey?: string; + ollamaBaseUrl?: string; + ollamaModel?: string; + ollamaDimensions?: number; } export function createEmbeddingProvider(config: EmbeddingConfig): EmbeddingProvider { @@ -114,5 +166,12 @@ export function createEmbeddingProvider(config: EmbeddingConfig): EmbeddingProvi if (!config.apiKey) throw new Error("OpenAI API key required for openai embedding provider"); return new OpenAIEmbeddingProvider(config.apiKey); } + if (config.provider === "ollama") { + return new OllamaEmbeddingProvider({ + baseUrl: config.ollamaBaseUrl, + model: config.ollamaModel, + dimensions: config.ollamaDimensions, + }); + } return new LocalEmbeddingProvider(); } diff --git a/src/lib/timeline-db.ts b/src/lib/timeline-db.ts index 49b4f78..d984bad 100644 --- a/src/lib/timeline-db.ts +++ b/src/lib/timeline-db.ts @@ -55,9 +55,12 @@ export interface ProjectInfo { } export interface TimelineConfig { - embedding_provider: "local" | "openai"; + embedding_provider: "local" | "openai" | "ollama"; embedding_model: string; openai_api_key?: string; + ollama_base_url?: string; + ollama_model?: string; + ollama_dimensions?: number; indexed_projects: Record { _embedder = createEmbeddingProvider({ provider: config.embedding_provider, apiKey: config.openai_api_key, + ollamaBaseUrl: config.ollama_base_url, + ollamaModel: config.ollama_model, + ollamaDimensions: config.ollama_dimensions, }); } return _embedder; diff --git a/src/tools/onboard-project.ts b/src/tools/onboard-project.ts index bca91a0..9be63b2 100644 --- a/src/tools/onboard-project.ts +++ b/src/tools/onboard-project.ts @@ -30,15 +30,18 @@ export function registerOnboardProject(server: McpServer) { "Index a project's Claude Code sessions and git history into the timeline database for semantic search and chronological viewing.", { project_dir: z.string().describe("Absolute path to the project directory"), - embedding_provider: z.enum(["local", "openai"]).default("local"), + embedding_provider: z.enum(["local", "openai", "ollama"]).default("local"), openai_api_key: z.string().optional(), + ollama_base_url: z.string().optional().describe("Ollama server URL (default: http://localhost:11434)"), + ollama_model: z.string().optional().describe("Ollama embedding model (default: nomic-embed-text)"), + ollama_dimensions: z.number().optional().describe("Embedding dimensions for the Ollama model"), git_depth: z.enum(["all", "6months", "1year", "3months"]).default("all"), git_since: z.string().optional().describe("Override git_depth with exact start date (ISO: '2025-08-01')"), git_authors: z.array(z.string()).optional().describe("Filter git commits to these authors. If omitted, auto-detects the primary author (most commits)."), reindex: z.boolean().default(false).describe("If true, drop existing data and rebuild from scratch"), }, async (params) => { - const { project_dir, embedding_provider, openai_api_key, git_depth, git_since, git_authors, reindex } = params; + const { project_dir, embedding_provider, openai_api_key, ollama_base_url, ollama_model, ollama_dimensions, git_depth, git_since, git_authors, reindex } = params; // 1. Validate project_dir if (!fs.existsSync(project_dir)) { @@ -174,6 +177,9 @@ export function registerOnboardProject(server: McpServer) { const embedder = createEmbeddingProvider({ provider: embedding_provider, apiKey: openai_api_key, + ollamaBaseUrl: ollama_base_url, + ollamaModel: ollama_model, + ollamaDimensions: ollama_dimensions, }); const BATCH_SIZE = 50; diff --git a/tests/lib/embeddings.test.ts b/tests/lib/embeddings.test.ts index 68b0ccf..c019104 100644 --- a/tests/lib/embeddings.test.ts +++ b/tests/lib/embeddings.test.ts @@ -63,4 +63,27 @@ describe("createEmbeddingProvider", () => { }); expect(provider.dimensions).toBe(1536); }); + + it("returns ollama provider with default 768 dimensions", () => { + const provider = createEmbeddingProvider({ provider: "ollama" }); + expect(provider.dimensions).toBe(768); + }); + + it("returns ollama provider with custom dimensions", () => { + const provider = createEmbeddingProvider({ + provider: "ollama", + ollamaDimensions: 1024, + }); + expect(provider.dimensions).toBe(1024); + }); + + it("accepts custom ollama model and base URL", () => { + const provider = createEmbeddingProvider({ + provider: "ollama", + ollamaBaseUrl: "http://my-server:11434", + ollamaModel: "mxbai-embed-large", + ollamaDimensions: 1024, + }); + expect(provider.dimensions).toBe(1024); + }); });