diff --git a/src/app/page.tsx b/src/app/page.tsx index 1a40ba5..115aa7e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -126,7 +126,7 @@ export default function Home() { - Now supporting Claude Code traces + Now supporting Claude Code & Tesslate Studio @@ -527,9 +527,7 @@ export default function Home() { Agent Wrapped — open source, privacy-first
- Currently supports Claude Code - | - More agents coming soon + Supports Claude Code & Tesslate Studio | sum + f.size, 0) trackFileUploaded({ - agent_type: "claude_code", + agent_type: "auto_detect", file_count: files.length, total_size_bytes: totalSize, }) try { setStatus("parsing") - trackParsingStarted({ agent_type: "claude_code" }) + trackParsingStarted({ agent_type: "auto_detect" }) const parseStart = performance.now() const traceData = await parseFiles(files) + const agentType = traceData.source === "tesslate-studio" ? "tesslate_studio" : "claude_code" setTraceData(traceData) trackParsingCompleted({ - agent_type: "claude_code", + agent_type: agentType, duration_ms: Math.round(performance.now() - parseStart), }) @@ -57,7 +58,7 @@ export default function UploadPage() { setAnalysisResult(result) trackAnalysisCompleted({ - agent_type: "claude_code", + agent_type: agentType, session_count: result.rawStats.totalSessions, message_count: result.rawStats.totalMessages, }) @@ -66,7 +67,7 @@ export default function UploadPage() { } catch (err) { const message = err instanceof Error ? err.message : "Failed to process files" setError(message) - trackParsingFailed({ agent_type: "claude_code", error_type: message }) + trackParsingFailed({ agent_type: "auto_detect", error_type: message }) trackError({ error_type: "parsing_error", error_message: message, diff --git a/src/app/wrapped/page.tsx b/src/app/wrapped/page.tsx index 6be124d..238f170 100644 --- a/src/app/wrapped/page.tsx +++ b/src/app/wrapped/page.tsx @@ -223,7 +223,14 @@ function DashboardContent() {
-

Dashboard

+
+

Dashboard

+ {state.traceData?.source && ( + + {state.traceData.source === "tesslate-studio" ? "Tesslate Studio" : "Claude Code"} + + )} +
{/* Desktop nav — hidden on small screens */}
diff --git a/src/components/upload/file-dropzone.tsx b/src/components/upload/file-dropzone.tsx index 84c56a9..7d1e65e 100644 --- a/src/components/upload/file-dropzone.tsx +++ b/src/components/upload/file-dropzone.tsx @@ -42,7 +42,7 @@ export function FileDropzone({ onFilesSelected }: FileDropzoneProps) { const traceFiles = filterTraceFiles(allFiles) if (traceFiles.length === 0) { - setErrors(["No trace files (.jsonl) found. Make sure you're uploading your .claude/ directory or individual .jsonl files."]) + setErrors(["No trace files found. Upload your .claude/ directory (.jsonl files) or Tesslate Studio export (.json files)."]) setIsScanning(false) return } @@ -246,7 +246,7 @@ export function FileDropzone({ onFilesSelected }: FileDropzoneProps) {

- {isDragOver ? "Drop it here" : "Drop a folder or .jsonl files here"} + {isDragOver ? "Drop it here" : "Drop a folder, .jsonl, or .json files here"}

or use the buttons below to browse @@ -277,7 +277,7 @@ export function FileDropzone({ onFilesSelected }: FileDropzoneProps) {

- We'll find all .jsonl trace files automatically + We'll find all trace files automatically

)} @@ -323,7 +323,7 @@ export function FileDropzone({ onFilesSelected }: FileDropzoneProps) { Where are my trace files?

- Claude Code stores traces in a hidden .claude/ folder in your home directory. + Claude Code traces are in your .claude/ folder. Tesslate Studio traces can be exported as JSON.

@@ -449,6 +449,42 @@ export function FileDropzone({ onFilesSelected }: FileDropzoneProps) {
+ + {/* Tesslate Studio */} +
+ + + + + + + + Tesslate Studio + + + + + +
+
    +
  • + 1. + + Export your trace data from Tesslate Studio as a JSON file + +
  • +
  • + 2. + + Drop the .json export file(s) into the box above + +
  • +
+

+ Supports both single-project and full account exports. +

+
+
diff --git a/src/lib/analyzers/constants.ts b/src/lib/analyzers/constants.ts index f90eafc..9874599 100644 --- a/src/lib/analyzers/constants.ts +++ b/src/lib/analyzers/constants.ts @@ -151,7 +151,12 @@ export const STOPWORDS = new Set([ "some", "any", "other", "new", ]) -export const FILE_TOOL_NAMES = ["Read", "Edit", "Write", "Glob", "Grep"] +export const FILE_TOOL_NAMES = [ + // Claude Code tools + "Read", "Edit", "Write", "Glob", "Grep", + // Tesslate Studio tools + "read_file", "write_file", "edit_file", +] export const EXTENSION_TO_LANGUAGE: Record = { ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript", ".jsx": "JavaScript", diff --git a/src/lib/analyzers/cost-estimate.ts b/src/lib/analyzers/cost-estimate.ts index d37df00..1027fe5 100644 --- a/src/lib/analyzers/cost-estimate.ts +++ b/src/lib/analyzers/cost-estimate.ts @@ -26,6 +26,12 @@ const PRICING: Record = { "claude-3-5-haiku-20241022": { input: 0.8, output: 4.0 }, "claude-3-haiku": { input: 0.25, output: 1.25 }, "claude-3-haiku-20240307": { input: 0.25, output: 1.25 }, + // Dot-variant aliases (Tesslate Studio uses dots in model versions) + "claude-opus-4.6": { input: 5.0, output: 25.0 }, + "claude-opus-4.5": { input: 5.0, output: 25.0 }, + "claude-sonnet-4.6": { input: 3.0, output: 15.0 }, + "claude-sonnet-4.5": { input: 3.0, output: 15.0 }, + "claude-haiku-4.5": { input: 1.0, output: 5.0 }, // Fallback for unknown models _default: { input: 3.0, output: 15.0 }, } diff --git a/src/lib/parsers/__tests__/fixtures/sample-tesslate-export.json b/src/lib/parsers/__tests__/fixtures/sample-tesslate-export.json new file mode 100644 index 0000000..f08ca7d --- /dev/null +++ b/src/lib/parsers/__tests__/fixtures/sample-tesslate-export.json @@ -0,0 +1,290 @@ +{ + "exported_at": "2026-03-22T21:50:35.671961+00:00", + "project": { + "id": "proj-001", + "name": "Test Project", + "slug": "test-project-abc", + "description": "A test project", + "owner_id": "user-001", + "has_git_repo": true, + "git_remote_url": "https://github.com/example/test.git", + "deploy_type": "development", + "environment_status": "active", + "created_at": "2026-03-20T10:00:00.000000+00:00", + "updated_at": "2026-03-22T12:00:00.000000+00:00" + }, + "chats": [ + { + "id": "chat-001", + "user_id": "user-001", + "project_id": "proj-001", + "created_at": "2026-03-22T10:00:00.000000+00:00", + "title": "Install shadcn", + "origin": "browser", + "status": "completed", + "updated_at": "2026-03-22T10:05:00.000000+00:00" + }, + { + "id": "chat-002", + "user_id": "user-001", + "project_id": "proj-001", + "created_at": "2026-03-22T11:00:00.000000+00:00", + "title": "Fix login bug", + "origin": "browser", + "status": "completed", + "updated_at": "2026-03-22T11:10:00.000000+00:00" + } + ], + "messages": [ + { + "id": "msg-001", + "chat_id": "chat-001", + "role": "user", + "content": "install shadcn please", + "message_metadata": null, + "created_at": "2026-03-22T10:00:00.000000+00:00", + "updated_at": "2026-03-22T10:00:00.000000+00:00" + }, + { + "id": "msg-002", + "chat_id": "chat-001", + "role": "assistant", + "content": "I'll install shadcn for you.", + "message_metadata": { + "agent_mode": true, + "agent_type": "TesslateAgent", + "iterations": 2, + "tool_calls_made": 2, + "completion_reason": "completed", + "executed_by": "worker", + "task_id": "task-001" + }, + "created_at": "2026-03-22T10:00:05.000000+00:00", + "updated_at": "2026-03-22T10:02:00.000000+00:00" + }, + { + "id": "msg-003", + "chat_id": "chat-001", + "role": "user", + "content": "thanks, that looks great!", + "message_metadata": null, + "created_at": "2026-03-22T10:03:00.000000+00:00", + "updated_at": "2026-03-22T10:03:00.000000+00:00" + }, + { + "id": "msg-004", + "chat_id": "chat-002", + "role": "user", + "content": "fix the login bug in auth.ts", + "message_metadata": null, + "created_at": "2026-03-22T11:00:00.000000+00:00", + "updated_at": "2026-03-22T11:00:00.000000+00:00" + }, + { + "id": "msg-005", + "chat_id": "chat-002", + "role": "assistant", + "content": "I found and fixed the bug.", + "message_metadata": { + "agent_mode": true, + "agent_type": "TesslateAgent", + "iterations": 3, + "tool_calls_made": 3, + "completion_reason": "completed", + "executed_by": "worker", + "task_id": "task-002" + }, + "created_at": "2026-03-22T11:00:10.000000+00:00", + "updated_at": "2026-03-22T11:05:00.000000+00:00" + } + ], + "agent_steps": [ + { + "id": "step-001", + "message_id": "msg-002", + "chat_id": "chat-001", + "step_index": 0, + "step_data": { + "iteration": 1, + "thought": null, + "tool_calls": [ + { + "name": "bash_exec", + "parameters": { + "command": "npx shadcn-ui@latest init" + }, + "result": { + "success": true, + "tool": "bash_exec", + "result": { + "success": true, + "message": "Command executed successfully", + "output": "shadcn-ui initialized" + } + } + } + ], + "response_text": "", + "is_complete": false, + "timestamp": "2026-03-22T10:00:10.000000+00:00" + }, + "created_at": "2026-03-22T10:00:10.000000+00:00" + }, + { + "id": "step-002", + "message_id": "msg-002", + "chat_id": "chat-001", + "step_index": 1, + "step_data": { + "iteration": 2, + "thought": "Let me verify the installation", + "tool_calls": [ + { + "name": "read_file", + "parameters": { + "path": "/app/components.json" + }, + "result": { + "success": true, + "tool": "read_file", + "result": { + "success": true, + "message": "File read successfully", + "file_path": "/app/components.json", + "content": "{\"style\": \"default\"}" + } + } + } + ], + "response_text": "shadcn is installed successfully.", + "is_complete": true, + "timestamp": "2026-03-22T10:01:00.000000+00:00" + }, + "created_at": "2026-03-22T10:01:00.000000+00:00" + }, + { + "id": "step-003", + "message_id": "msg-005", + "chat_id": "chat-002", + "step_index": 0, + "step_data": { + "iteration": 1, + "thought": null, + "tool_calls": [ + { + "name": "read_file", + "parameters": { + "path": "/app/src/auth.ts" + }, + "result": { + "success": true, + "tool": "read_file", + "result": { + "success": true, + "message": "File read successfully", + "file_path": "/app/src/auth.ts", + "content": "export function login() { ... }" + } + } + } + ], + "response_text": "", + "is_complete": false, + "timestamp": "2026-03-22T11:00:15.000000+00:00" + }, + "created_at": "2026-03-22T11:00:15.000000+00:00" + }, + { + "id": "step-004", + "message_id": "msg-005", + "chat_id": "chat-002", + "step_index": 1, + "step_data": { + "iteration": 2, + "thought": null, + "tool_calls": [ + { + "name": "edit_file", + "parameters": { + "file_path": "/app/src/auth.ts", + "changes": "fixed login validation" + }, + "result": { + "success": true, + "tool": "edit_file", + "result": { + "success": true, + "message": "File edited successfully" + } + } + } + ], + "response_text": "", + "is_complete": false, + "timestamp": "2026-03-22T11:01:00.000000+00:00" + }, + "created_at": "2026-03-22T11:01:00.000000+00:00" + }, + { + "id": "step-005", + "message_id": "msg-005", + "chat_id": "chat-002", + "step_index": 2, + "step_data": { + "iteration": 3, + "thought": null, + "tool_calls": [ + { + "name": "bash_exec", + "parameters": { + "command": "npm test" + }, + "result": { + "success": false, + "tool": "bash_exec", + "error": "Test failed: expected true got false" + } + } + ], + "response_text": "I fixed the login bug but one test is still failing.", + "is_complete": true, + "timestamp": "2026-03-22T11:02:00.000000+00:00" + }, + "created_at": "2026-03-22T11:02:00.000000+00:00" + } + ], + "usage_logs": [ + { + "id": "usage-001", + "user_id": "user-001", + "agent_id": "agent-001", + "project_id": "proj-001", + "model": "builtin/claude-sonnet-4.6", + "tokens_input": 1500, + "tokens_output": 200, + "cost_input": 1, + "cost_output": 1, + "cost_total": 2, + "billed_status": "credited", + "created_at": "2026-03-22T10:00:08.000000+00:00", + "is_byok": false + }, + { + "id": "usage-002", + "user_id": "user-001", + "agent_id": "agent-001", + "project_id": "proj-001", + "model": "claude-sonnet-4.6", + "tokens_input": 2000, + "tokens_output": 350, + "cost_input": 1, + "cost_output": 1, + "cost_total": 2, + "billed_status": "credited", + "created_at": "2026-03-22T11:00:12.000000+00:00", + "is_byok": false + } + ], + "agent_command_logs": [], + "shell_sessions": [] +} diff --git a/src/lib/parsers/__tests__/format-detection.test.ts b/src/lib/parsers/__tests__/format-detection.test.ts new file mode 100644 index 0000000..a1a8f91 --- /dev/null +++ b/src/lib/parsers/__tests__/format-detection.test.ts @@ -0,0 +1,50 @@ +import { detectTraceFormat } from "../file-utils" + +describe("detectTraceFormat", () => { + it("detects Tesslate Studio format with chats and agent_steps", () => { + const content = JSON.stringify({ + exported_at: "2026-03-22T21:50:35.671961+00:00", + chats: [], + messages: [], + agent_steps: [], + usage_logs: [], + }) + expect(detectTraceFormat(content)).toBe("tesslate-studio") + }) + + it("detects Tesslate Studio format with project key", () => { + const content = JSON.stringify({ + exported_at: "2026-03-22T21:50:35.671961+00:00", + project: { id: "proj-001", name: "Test" }, + messages: [], + }) + expect(detectTraceFormat(content)).toBe("tesslate-studio") + }) + + it("detects Tesslate Studio format with projects array", () => { + const content = JSON.stringify({ + exported_at: "2026-03-22T21:50:35.671961+00:00", + projects: [{ id: "proj-001", name: "Test" }], + messages: [], + }) + expect(detectTraceFormat(content)).toBe("tesslate-studio") + }) + + it("detects Claude Code format from JSONL with sessionId", () => { + const content = '{"uuid":"abc","sessionId":"session-1","type":"user","message":{"role":"user","content":"hello"}}\n' + expect(detectTraceFormat(content)).toBe("claude-code") + }) + + it("returns unknown for unrecognized JSON", () => { + const content = JSON.stringify({ foo: "bar", baz: 123 }) + expect(detectTraceFormat(content)).toBe("unknown") + }) + + it("returns unknown for non-JSON text", () => { + expect(detectTraceFormat("this is not json at all")).toBe("unknown") + }) + + it("returns unknown for empty string", () => { + expect(detectTraceFormat("")).toBe("unknown") + }) +}) diff --git a/src/lib/parsers/__tests__/tesslate-studio.test.ts b/src/lib/parsers/__tests__/tesslate-studio.test.ts new file mode 100644 index 0000000..0aec375 --- /dev/null +++ b/src/lib/parsers/__tests__/tesslate-studio.test.ts @@ -0,0 +1,144 @@ +import { readFileSync } from "fs" +import { join } from "path" +import { parseTesslateStudioFiles } from "../tesslate-studio" + +function createMockFile(content: string, name: string): File { + return new File([content], name, { type: "application/json" }) +} + +const FIXTURE_PATH = join(__dirname, "fixtures", "sample-tesslate-export.json") +const fixtureContent = readFileSync(FIXTURE_PATH, "utf-8") + +describe("parseTesslateStudioFiles", () => { + it("parses fixture into correct number of sessions", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + expect(result.source).toBe("tesslate-studio") + expect(result.sessions).toHaveLength(2) + }) + + it("maps chats to sessions with correct metadata", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + const session1 = result.sessions[0] + expect(session1.id).toBe("chat-001") + expect(session1.project).toBe("Test Project") + expect(session1.startTime).toBe("2026-03-22T10:00:00.000000+00:00") + }) + + it("maps messages correctly with roles", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + const session1 = result.sessions[0] + expect(session1.messages).toHaveLength(3) + expect(session1.messages[0].role).toBe("user") + expect(session1.messages[0].content).toBe("install shadcn please") + expect(session1.messages[1].role).toBe("assistant") + expect(session1.messages[2].role).toBe("user") + expect(session1.messages[2].content).toBe("thanks, that looks great!") + }) + + it("expands agent steps into tool calls and results", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + // Chat-001 assistant message should have 2 tool calls from 2 steps + const assistantMsg = result.sessions[0].messages[1] + expect(assistantMsg.toolCalls).toHaveLength(2) + expect(assistantMsg.toolCalls![0].name).toBe("bash_exec") + expect(assistantMsg.toolCalls![1].name).toBe("read_file") + + expect(assistantMsg.toolResults).toHaveLength(2) + expect(assistantMsg.toolResults![0].isError).toBe(false) + expect(assistantMsg.toolResults![1].isError).toBe(false) + }) + + it("marks failed tool results as errors", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + // Chat-002 assistant message has a failing bash_exec at step_index 2 + const assistantMsg = result.sessions[1].messages[1] + expect(assistantMsg.toolCalls).toHaveLength(3) + expect(assistantMsg.toolResults).toHaveLength(3) + + // The last tool result should be an error + const lastResult = assistantMsg.toolResults![2] + expect(lastResult.isError).toBe(true) + expect(lastResult.content).toContain("Test failed") + }) + + it("correlates usage logs with assistant messages for token data", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + // First assistant message should have usage log correlated + const msg1 = result.sessions[0].messages[1] + expect(msg1.tokenUsage).toBeDefined() + expect(msg1.tokenUsage!.inputTokens).toBe(1500) + expect(msg1.tokenUsage!.outputTokens).toBe(200) + }) + + it("normalizes model names (strips builtin/ prefix)", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + // First usage log has "builtin/claude-sonnet-4.6" — should be normalized + const msg1 = result.sessions[0].messages[1] + expect(msg1.model).toBe("claude-sonnet-4-6") + }) + + it("populates metadata correctly", async () => { + const file = createMockFile(fixtureContent, "export.json") + const result = await parseTesslateStudioFiles([file]) + + expect(result.metadata.totalFiles).toBe(1) + expect(result.metadata.projectPaths).toContain("Test Project") + expect(result.metadata.earliestTimestamp).toBeTruthy() + expect(result.metadata.latestTimestamp).toBeTruthy() + }) + + it("handles empty messages gracefully", async () => { + const emptyExport = JSON.stringify({ + exported_at: "2026-03-22T21:50:35.671961+00:00", + project: { + id: "proj-empty", + name: "Empty", + slug: "empty", + owner_id: "user-001", + created_at: "2026-03-22T10:00:00.000000+00:00", + updated_at: "2026-03-22T10:00:00.000000+00:00", + }, + chats: [], + messages: [], + agent_steps: [], + usage_logs: [], + }) + const file = createMockFile(emptyExport, "empty.json") + const result = await parseTesslateStudioFiles([file]) + + expect(result.source).toBe("tesslate-studio") + expect(result.sessions).toHaveLength(0) + }) + + it("handles multi-project (full dump) format with projects array", async () => { + const parsed = JSON.parse(fixtureContent) + // Convert single-project to multi-project format + const multiProject = { + ...parsed, + projects: [parsed.project], + export_type: "full_production_dump", + } + delete multiProject.project + + const file = createMockFile(JSON.stringify(multiProject), "full-dump.json") + const result = await parseTesslateStudioFiles([file]) + + expect(result.source).toBe("tesslate-studio") + expect(result.sessions).toHaveLength(2) + expect(result.sessions[0].project).toBe("Test Project") + }) +}) diff --git a/src/lib/parsers/file-utils.ts b/src/lib/parsers/file-utils.ts index 7c7e606..4eb086b 100644 --- a/src/lib/parsers/file-utils.ts +++ b/src/lib/parsers/file-utils.ts @@ -4,7 +4,7 @@ */ const ACCEPTED_EXTENSIONS = [".jsonl", ".json", ".zip"] -const TRACE_EXTENSIONS = [".jsonl"] +const TRACE_EXTENSIONS = [".jsonl", ".json"] const DEFAULT_MAX_BYTES = 100 * 1024 * 1024 // 100MB /** @@ -105,9 +105,25 @@ export async function readDirectoryEntries( return files } +// JSON files that are never trace files (common project config files) +const JSON_SKIP_LIST = new Set([ + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.node.json", + "tsconfig.app.json", + "next.config.json", + ".eslintrc.json", + "composer.json", + "manifest.json", + "settings.json", + "launch.json", + "extensions.json", +]) + /** - * Filter files from a directory to only include relevant Claude Code trace files. - * Looks for .jsonl files in projects/ subdirectories (conversation logs). + * Filter files from a directory to only include relevant trace files. + * Accepts .jsonl files (Claude Code) and .json files (Tesslate Studio). * Skips meta files, settings, and other non-trace files. */ export function filterTraceFiles(files: File[]): File[] { @@ -115,8 +131,8 @@ export function filterTraceFiles(files: File[]): File[] { const path = file.webkitRelativePath || file.name const name = file.name.toLowerCase() - // Must be a JSONL file - if (!name.endsWith(".jsonl")) return false + // Must be a JSONL or JSON file + if (!name.endsWith(".jsonl") && !name.endsWith(".json")) return false // Skip the global history.jsonl (it's just snapshots, not full conversations) if (name === "history.jsonl") return false @@ -124,17 +140,56 @@ export function filterTraceFiles(files: File[]): File[] { // Skip subagent meta files if (name.endsWith(".meta.json")) return false - // Prefer files inside projects/ directories (full conversation logs) - // but also accept top-level .jsonl files + // Skip known non-trace JSON config files + if (JSON_SKIP_LIST.has(name)) return false + const pathLower = path.toLowerCase() // Skip settings, cache, and other non-trace directories if (pathLower.includes("/cache/") || pathLower.includes("/backups/")) return false + if (pathLower.includes("/node_modules/")) return false return true }) } +/** + * Detect the trace format from file content. + * Checks for Tesslate Studio signature keys in JSON files. + */ +export function detectTraceFormat(text: string): "claude-code" | "tesslate-studio" | "unknown" { + // Try to parse as JSON first (Tesslate format) + try { + const parsed = JSON.parse(text) + if (typeof parsed === "object" && parsed !== null) { + // Tesslate exports have "chats" + ("agent_steps" or "messages" + "usage_logs") + const hasTesslateKeys = + ("chats" in parsed && "agent_steps" in parsed) || + ("chats" in parsed && "messages" in parsed && "usage_logs" in parsed) || + ("project" in parsed && "messages" in parsed) || + ("projects" in parsed && "messages" in parsed) + if (hasTesslateKeys) return "tesslate-studio" + } + } catch { + // Not valid JSON — could be JSONL (Claude Code format) + } + + // Check if it looks like JSONL (Claude Code) + const firstLine = text.split("\n").find((l) => l.trim() !== "") + if (firstLine) { + try { + const parsed = JSON.parse(firstLine) + if (typeof parsed === "object" && parsed !== null && "sessionId" in parsed) { + return "claude-code" + } + } catch { + // Not JSONL either + } + } + + return "unknown" +} + /** * Extract metadata files from a directory upload for environment insights. * Returns categorized files: plugins, plans, todos, subagent metas. diff --git a/src/lib/parsers/index.ts b/src/lib/parsers/index.ts index 6dba24a..1799b32 100644 --- a/src/lib/parsers/index.ts +++ b/src/lib/parsers/index.ts @@ -2,18 +2,19 @@ * Parser entry point. * * Validates uploaded files and dispatches to the appropriate format-specific parser. - * For the MVP, only Claude Code JSONL is supported. + * Supports Claude Code (JSONL) and Tesslate Studio (JSON) trace formats. */ import type { TraceData } from "@/lib/types" import { parseClaudeCodeFiles } from "./claude-code" -import { validateFileType, validateFileSize, isTraceFile } from "./file-utils" +import { parseTesslateStudioFiles } from "./tesslate-studio" +import { validateFileType, validateFileSize, isTraceFile, readFileAsText, detectTraceFormat } from "./file-utils" /** * Parse uploaded trace files into normalized TraceData. * - * Validates file type and size before parsing. Currently assumes Claude Code - * format — future versions will auto-detect based on file contents. + * Validates file type and size, then auto-detects the trace format + * based on file content and routes to the appropriate parser. */ export async function parseFiles(files: File[]): Promise { if (files.length === 0) { @@ -46,10 +47,30 @@ export async function parseFiles(files: File[]): Promise { throw new Error("No valid trace files found") } - return parseClaudeCodeFiles(traceFiles) + // Auto-detect format from the first file's content + const firstFileText = await readFileAsText(traceFiles[0]) + const format = detectTraceFormat(firstFileText) + + switch (format) { + case "tesslate-studio": + return parseTesslateStudioFiles(traceFiles) + case "claude-code": + return parseClaudeCodeFiles(traceFiles) + default: { + // Fallback: try Claude Code parser for .jsonl files, Tesslate for .json + const name = traceFiles[0].name.toLowerCase() + if (name.endsWith(".jsonl")) { + return parseClaudeCodeFiles(traceFiles) + } else if (name.endsWith(".json")) { + return parseTesslateStudioFiles(traceFiles) + } + throw new Error("Unrecognized trace file format. Please upload Claude Code (.jsonl) or Tesslate Studio (.json) trace files.") + } + } } export { parseClaudeCodeFiles } from "./claude-code" +export { parseTesslateStudioFiles } from "./tesslate-studio" export { readFileAsText, parseJSONL, @@ -58,4 +79,5 @@ export { isTraceFile, readDirectoryEntries, filterTraceFiles, + detectTraceFormat, } from "./file-utils" diff --git a/src/lib/parsers/tesslate-studio.ts b/src/lib/parsers/tesslate-studio.ts new file mode 100644 index 0000000..24e74c5 --- /dev/null +++ b/src/lib/parsers/tesslate-studio.ts @@ -0,0 +1,268 @@ +/** + * Parser for Tesslate Studio trace exports. + * + * Converts Tesslate's relational JSON format (projects, chats, messages, + * agent_steps, usage_logs) into the normalized TraceData schema used by analyzers. + */ + +import type { TraceData, Session, Message, ToolCall, ToolResult } from "@/lib/types" +import type { + TesslateExport, + TesslateProject, + TesslateChat, + TesslateMessage, + TesslateAgentStep, + TesslateUsageLog, +} from "@/lib/types/tesslate" +import { readFileAsText } from "./file-utils" + +/** + * Normalize a Tesslate model name for pricing lookup. + * Strips "builtin/" prefix and converts dots to hyphens in version segments. + * e.g. "builtin/claude-sonnet-4.6" → "claude-sonnet-4-6" + */ +function normalizeModelName(model: string): string { + let normalized = model.replace(/^builtin\//, "") + // Convert version dots to hyphens: "claude-sonnet-4.6" → "claude-sonnet-4-6" + normalized = normalized.replace(/(\d+)\.(\d+)/g, "$1-$2") + return normalized +} + +/** + * Parse Tesslate Studio JSON export files into normalized TraceData. + */ +export async function parseTesslateStudioFiles(files: File[]): Promise { + // Read and parse all JSON files + const exports: TesslateExport[] = [] + for (const file of files) { + const text = await readFileAsText(file) + try { + exports.push(JSON.parse(text) as TesslateExport) + } catch { + console.warn(`Skipping invalid JSON file: ${file.name}`) + } + } + + if (exports.length === 0) { + throw new Error("No valid Tesslate Studio trace files found") + } + + // Merge data across files, deduplicating by id + const projectMap = new Map() + const chatMap = new Map() + const messageMap = new Map() + const stepMap = new Map() + const usageLogMap = new Map() + + for (const exp of exports) { + // Handle both "project" (single) and "projects" (array) formats + const projects = exp.projects ?? (exp.project ? [exp.project] : []) + for (const p of projects) projectMap.set(p.id, p) + for (const c of exp.chats) chatMap.set(c.id, c) + for (const m of exp.messages) messageMap.set(m.id, m) + for (const s of exp.agent_steps) stepMap.set(s.id, s) + for (const u of exp.usage_logs) usageLogMap.set(u.id, u) + } + + // Build lookup indices + const messagesByChat = new Map() + for (const msg of messageMap.values()) { + const list = messagesByChat.get(msg.chat_id) ?? [] + list.push(msg) + messagesByChat.set(msg.chat_id, list) + } + // Sort messages within each chat by timestamp + for (const [chatId, msgs] of messagesByChat) { + msgs.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + messagesByChat.set(chatId, msgs) + } + + const stepsByMessage = new Map() + for (const step of stepMap.values()) { + const list = stepsByMessage.get(step.message_id) ?? [] + list.push(step) + stepsByMessage.set(step.message_id, list) + } + // Sort steps by step_index + for (const [msgId, steps] of stepsByMessage) { + steps.sort((a, b) => a.step_index - b.step_index) + stepsByMessage.set(msgId, steps) + } + + // Group usage logs by project_id, sorted by timestamp + const usageLogsByProject = new Map() + for (const log of usageLogMap.values()) { + const key = log.project_id ?? "__no_project__" + const list = usageLogsByProject.get(key) ?? [] + list.push(log) + usageLogsByProject.set(key, list) + } + for (const [key, logs] of usageLogsByProject) { + logs.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + usageLogsByProject.set(key, logs) + } + + // Convert chats to sessions + const sessions: Session[] = [] + const allTimestamps: number[] = [] + const projectPaths = new Set() + + for (const chat of chatMap.values()) { + const project = projectMap.get(chat.project_id) + const chatMessages = messagesByChat.get(chat.id) ?? [] + + if (chatMessages.length === 0) continue + + const projectName = project?.name ?? chat.project_id + projectPaths.add(projectName) + + // Get usage logs for this chat's project to correlate with assistant messages + const projectLogs = usageLogsByProject.get(chat.project_id) ?? [] + // Filter to logs within this chat's time window (with some buffer) + const chatStart = new Date(chat.created_at).getTime() - 5000 + const chatEnd = new Date(chat.updated_at).getTime() + 5000 + const chatLogs = projectLogs.filter((log) => { + const t = new Date(log.created_at).getTime() + return t >= chatStart && t <= chatEnd + }) + // Track which logs have been claimed + const claimedLogs = new Set() + + const messages: Message[] = chatMessages.map((msg) => { + const ts = new Date(msg.created_at).getTime() + allTimestamps.push(ts) + + const toolCalls: ToolCall[] = [] + const toolResults: ToolResult[] = [] + + // Expand agent steps into tool calls/results for assistant messages + if (msg.role === "assistant") { + const steps = stepsByMessage.get(msg.id) ?? [] + for (const step of steps) { + for (const tc of step.step_data.tool_calls) { + const callId = `${step.id}-${tc.name}-${toolCalls.length}` + toolCalls.push({ + id: callId, + name: tc.name, + input: tc.parameters, + }) + + const resultContent = tc.result.success + ? JSON.stringify(tc.result.result ?? {}) + : tc.result.error ?? JSON.stringify(tc.result.result ?? {}) + + toolResults.push({ + toolCallId: callId, + content: resultContent, + isError: !tc.result.success, + }) + } + } + } + + // Correlate usage logs with assistant messages + let model: string | undefined + let tokenUsage: Message["tokenUsage"] | undefined + + if (msg.role === "assistant") { + // Find closest unclaimed usage log within a 60-second window + const msgTime = new Date(msg.created_at).getTime() + let bestLog: TesslateUsageLog | null = null + let bestDelta = Infinity + + for (const log of chatLogs) { + if (claimedLogs.has(log.id)) continue + const delta = Math.abs(new Date(log.created_at).getTime() - msgTime) + if (delta < 60_000 && delta < bestDelta) { + bestDelta = delta + bestLog = log + } + } + + if (bestLog) { + claimedLogs.add(bestLog.id) + model = normalizeModelName(bestLog.model) + tokenUsage = { + inputTokens: bestLog.tokens_input, + outputTokens: bestLog.tokens_output, + } + } + + // If no single log matched, try to aggregate all unclaimed logs in the chat window + // that fall between this message and the next message + if (!tokenUsage && chatLogs.length > 0) { + const remainingLogs = chatLogs.filter((l) => !claimedLogs.has(l.id)) + if (remainingLogs.length > 0) { + // For the first unclaimed log, use it + const log = remainingLogs[0] + claimedLogs.add(log.id) + model = normalizeModelName(log.model) + tokenUsage = { + inputTokens: log.tokens_input, + outputTokens: log.tokens_output, + } + } + } + } + + // Build content: include agent thought and response_text from steps + let content = msg.content + if (msg.role === "assistant") { + const steps = stepsByMessage.get(msg.id) ?? [] + const stepTexts: string[] = [] + for (const step of steps) { + if (step.step_data.thought) { + stepTexts.push(step.step_data.thought) + } + if (step.step_data.response_text && step.step_data.response_text !== msg.content) { + stepTexts.push(step.step_data.response_text) + } + } + if (stepTexts.length > 0 && !content) { + content = stepTexts.join("\n") + } + } + + return { + id: msg.id, + timestamp: msg.created_at, + role: msg.role, + content, + model, + tokenUsage, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + } + }) + + const startTime = chatMessages[0].created_at + const endTime = chatMessages[chatMessages.length - 1].updated_at ?? chatMessages[chatMessages.length - 1].created_at + + sessions.push({ + id: chat.id, + startTime, + endTime, + project: projectName, + messages, + cwd: project?.slug ?? "", + }) + } + + // Sort sessions by start time + sessions.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()) + + // Compute metadata + const earliest = allTimestamps.length > 0 ? new Date(Math.min(...allTimestamps)).toISOString() : "" + const latest = allTimestamps.length > 0 ? new Date(Math.max(...allTimestamps)).toISOString() : "" + + return { + source: "tesslate-studio", + sessions, + metadata: { + totalFiles: files.length, + earliestTimestamp: earliest, + latestTimestamp: latest, + projectPaths: Array.from(projectPaths), + }, + } +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 99f2e08..2ebae9c 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,2 +1,3 @@ export * from "./trace" export * from "./analysis" +export * from "./tesslate" diff --git a/src/lib/types/tesslate.ts b/src/lib/types/tesslate.ts new file mode 100644 index 0000000..6d5cf06 --- /dev/null +++ b/src/lib/types/tesslate.ts @@ -0,0 +1,117 @@ +// === Tesslate Studio Raw Export Format === + +export interface TesslateExport { + exported_at: string + export_type?: string + // Full dump uses "projects" (array), single-project export uses "project" (object) + projects?: TesslateProject[] + project?: TesslateProject + chats: TesslateChat[] + messages: TesslateMessage[] + agent_steps: TesslateAgentStep[] + usage_logs: TesslateUsageLog[] + agent_command_logs?: unknown[] + shell_sessions?: unknown[] +} + +export interface TesslateProject { + id: string + name: string + slug: string + description?: string | null + owner_id: string + has_git_repo?: boolean + git_remote_url?: string | null + deploy_type?: string + environment_status?: string + last_activity?: string | null + created_at: string + updated_at: string +} + +export interface TesslateChat { + id: string + user_id: string + project_id: string + created_at: string + title: string | null + origin: string + status: string + updated_at: string +} + +export interface TesslateMessage { + id: string + chat_id: string + role: "user" | "assistant" + content: string + message_metadata: TesslateMessageMetadata | null + created_at: string + updated_at: string +} + +export interface TesslateMessageMetadata { + agent_mode?: boolean + agent_type?: string + iterations?: number + tool_calls_made?: number + completion_reason?: string + session_id?: string | null + executed_by?: string + task_id?: string + trajectory_path?: string | null + steps_table?: boolean +} + +export interface TesslateAgentStep { + id: string + message_id: string + chat_id: string + step_index: number + step_data: TesslateStepData + created_at: string +} + +export interface TesslateStepData { + iteration: number + thought: string | null + tool_calls: TesslateToolCall[] + response_text: string + is_complete: boolean + timestamp: string +} + +export interface TesslateToolCall { + name: string + parameters: Record + result: TesslateToolResult +} + +export interface TesslateToolResult { + success: boolean + tool: string + result?: Record + error?: string +} + +export interface TesslateUsageLog { + id: string + user_id: string + agent_id: string + project_id: string | null + model: string + tokens_input: number + tokens_output: number + cost_input: number + cost_output: number + cost_total: number + creator_id?: string | null + creator_revenue?: number + platform_revenue?: number + billed_status: string + invoice_id?: string | null + billed_at?: string | null + request_id?: string | null + created_at: string + is_byok: boolean +} diff --git a/src/lib/types/trace.ts b/src/lib/types/trace.ts index be9c69e..04c08c1 100644 --- a/src/lib/types/trace.ts +++ b/src/lib/types/trace.ts @@ -40,7 +40,7 @@ export interface ClaudeCodeRawMessage { } // === Normalized Schema (parser output / analyzer input) === -export type TraceSource = "claude-code" | "cursor" | "aider" | "continue" | "unknown" +export type TraceSource = "claude-code" | "tesslate-studio" | "cursor" | "aider" | "continue" | "unknown" export interface TraceData { source: TraceSource