From 46dd4991d93796b09b53d77d5c7613156e95550a Mon Sep 17 00:00:00 2001 From: eermongkonchai Date: Tue, 24 Mar 2026 23:51:55 -0400 Subject: [PATCH] feat: add Tesslate Studio trace support with auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full support for parsing and analyzing Tesslate Studio trace exports alongside the existing Claude Code JSONL format. Users can now drop Tesslate Studio JSON exports on the upload page and get the same wrapped dashboard, story, and summary card experience. ## Parser & Types - Add `TesslateExport`, `TesslateProject`, `TesslateChat`, `TesslateMessage`, `TesslateAgentStep`, `TesslateUsageLog` and related interfaces in `src/lib/types/tesslate.ts` to model the raw Tesslate JSON export format - Add `"tesslate-studio"` to the `TraceSource` union type - Create `parseTesslateStudioFiles()` in `src/lib/parsers/tesslate-studio.ts`: - Maps Tesslate chats to sessions, messages to messages - Expands agent_steps into toolCalls/toolResults on assistant messages - Correlates usage_logs to assistant messages by project + timestamp proximity for token usage data - Normalizes model names (strips "builtin/" prefix, converts dots to hyphens for pricing lookup) - Handles both single-project (`"project"` key) and full-dump (`"projects"` array) export formats - Merges and deduplicates data across multiple uploaded files ## Auto-Detection - Add `detectTraceFormat()` to `file-utils.ts` that identifies Tesslate Studio JSON (by checking for `chats`/`agent_steps`/`messages`/`usage_logs` signature keys) vs Claude Code JSONL (by checking for `sessionId` in first line) - Update `parseFiles()` in `index.ts` to auto-detect format from file content and route to the correct parser — no user toggle needed - Expand `TRACE_EXTENSIONS` to include `.json` and update `filterTraceFiles()` to accept JSON files while skipping known config files (package.json, tsconfig.json, etc.) ## Analyzer Compatibility - Expand `FILE_TOOL_NAMES` in `constants.ts` to include Tesslate tool equivalents: `read_file`, `write_file`, `edit_file` - Add dot-variant model name aliases to the cost-estimate PRICING table (`claude-sonnet-4.6`, `claude-opus-4.6`, `claude-haiku-4.5`) since Tesslate uses dots instead of hyphens in version segments ## UI Updates - Update FileDropzone: accept `.json` files, update help text and error messages to mention Tesslate Studio, add Tesslate Studio accordion in the help section with export instructions - Update upload page analytics to use dynamic `agent_type` from parsed `traceData.source` instead of hardcoded `"claude_code"` - Update home page: overline badge and footer now say "Claude Code & Tesslate Studio" - Add source badge on the dashboard header showing "Tesslate Studio" or "Claude Code" based on the uploaded trace source ## Tests - Add `tesslate-studio.test.ts` with 8 tests covering: session mapping, message parsing, tool call expansion, error detection, usage log correlation, model normalization, empty data handling, and multi-project format support - Add `format-detection.test.ts` with 7 tests covering: Tesslate JSON detection (multiple key patterns), Claude JSONL detection, and unknown format handling - Add test fixture `sample-tesslate-export.json` with representative data All 204 tests pass. Build compiles cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/page.tsx | 6 +- src/app/upload/page.tsx | 11 +- src/app/wrapped/page.tsx | 9 +- src/components/upload/file-dropzone.tsx | 44 ++- src/lib/analyzers/constants.ts | 7 +- src/lib/analyzers/cost-estimate.ts | 6 + .../fixtures/sample-tesslate-export.json | 290 ++++++++++++++++++ .../__tests__/format-detection.test.ts | 50 +++ .../parsers/__tests__/tesslate-studio.test.ts | 144 +++++++++ src/lib/parsers/file-utils.ts | 69 ++++- src/lib/parsers/index.ts | 32 +- src/lib/parsers/tesslate-studio.ts | 268 ++++++++++++++++ src/lib/types/index.ts | 1 + src/lib/types/tesslate.ts | 117 +++++++ src/lib/types/trace.ts | 2 +- 15 files changed, 1028 insertions(+), 28 deletions(-) create mode 100644 src/lib/parsers/__tests__/fixtures/sample-tesslate-export.json create mode 100644 src/lib/parsers/__tests__/format-detection.test.ts create mode 100644 src/lib/parsers/__tests__/tesslate-studio.test.ts create mode 100644 src/lib/parsers/tesslate-studio.ts create mode 100644 src/lib/types/tesslate.ts 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