Shofer provides two export formats for task history: Markdown (conversation transcript) and JSON (structured trace). Both export the full raw message exchange between the user and the LLM, including tool calls, tool results, and reasoning blocks.
| Markdown | JSON | |
|---|---|---|
| Button | Export (DownloadIcon) | Export JSON (FileJsonIcon) |
| File extension | .md |
.json |
| Human-readable | ✅ Any text editor | ❌ Needs JSON viewer |
| Machine-readable | ❌ Free-form text | ✅ Schema'd, scriptable |
| Token counts | ❌ | ✅ Per-call + aggregate |
| Cost (USD) | ❌ | ✅ Per-call + aggregate |
| API protocol / model | ❌ | ✅ Per call |
| Task metadata | ❌ | ✅ taskId, mode, timestamps |
| Reasoning | Inline [Reasoning] block |
Dedicated reasoning field |
| Tool calls | Text [Tool Use: name] + key:value |
Structured JSON with input/result |
| Use case | Reading, sharing, auditing | Analytics, cost tracking, evals, replay |
- Start a task in Shofer
- Click the task title bar at the top of the chat panel to expand it
- Use the buttons in the action row:
- Export (download icon) — exports as Markdown (
.md) - Export JSON (file icon) — exports as JSON (
.json)
- Export (download icon) — exports as Markdown (
- Choose a save location in the file dialog
Both buttons are also available from the History panel (clock icon in the VS Code title bar) for completed tasks.
The Markdown export produces a plain-text transcript of the conversation. Each turn is separated by --- and labeled with the role.
**User:**
<user_message>
1+1
</user_message>
...
---
**Assistant:**
[Reasoning]
The user asked "1+1" which equals 2...
2
---
**User:**
[ERROR] You did not use a tool...
...
---
**Assistant:**
[Reasoning]
...
[Tool Use: attempt_completion]
Result: 1 + 1 = 2
| Block | Markdown Representation |
|---|---|
| Text | Inline text |
| Reasoning | [Reasoning]\n{content} |
| Tool use | [Tool Use: {name}]\n{key: value, ...} |
| Tool result | [Tool{ (Error)}]\n{content} |
| Images | [Image] |
Generated by export-markdown.ts from the task's api_conversation_history.jsonl.
The JSON export produces a structured trace designed for programmatic consumption. Each API call is a separate entry with metadata, messages, tool calls, and reasoning.
interface JsonExportTrace {
version: 1 // Schema version
taskId: string // UUID of the task
task: string // Human-readable task description
mode?: string // Mode slug (e.g., "code", "architect")
createdAt: string // ISO 8601 timestamp
calls: JsonExportCall[] // One entry per API request
totalTokens: {
// Aggregate across all calls
input: number
output: number
cacheWrite: number
cacheRead: number
}
totalCostUsd: number // Aggregate cost
totalCalls: number
totalToolCalls: number
}
interface JsonExportCall {
index: number // 1-based call number
apiProtocol?: string // "anthropic", "openai"
model?: string // Model ID
inputTokens: number // Input tokens (estimated if `_tokensEstimated`)
outputTokens: number // Output tokens (estimated if `_tokensEstimated`)
cacheWriteTokens: number
cacheReadTokens: number
costUsd: number
cancelled?: boolean
cancelReason?: string
streamingFailedMessage?: string
messages: MessageParam[] // Anthropic-format messages for this call (empty `[]` for error-only calls)
toolCalls: JsonExportToolCall[] // Tool calls from the assistant message
reasoning?: string // Extracted reasoning / thinking
retryAttempt?: number // Number of retries before this attempt (0 = first try)
durationMs?: number // Whole ms, request open → stream end; absent when no stream end was reached
firstChunkMs?: number // Whole ms, request open → first stream chunk; absent when the stream produced none
thinkingMs?: number // Whole ms the model spent reasoning, summed; ABSENT means no reasoning phase, never 0
reasoningIntervalsMs?: Array<[number, number]> // Every reasoning window as [startMs, endMs] from request open; ABSENT means none, never []
error?: {
// Structured error info when this call failed
message: string
type?: string // e.g. "rate_limit_error", "invalid_request_error"
statusCode?: number // HTTP status code
stack?: string // Stack trace
}
wireRequest?: string // Serialised wire-level request metadata (JSON)
_tokensEstimated?: true // Present when tokens are char/4 heuristic
}
interface JsonExportToolCall {
name: string
id: string
input: Record<string, unknown>
result?: {
content: unknown
isError: boolean
}
}{
"version": 1,
"taskId": "019e127e-e412-75c6-b811-087dd1126e83",
"task": "1+1",
"mode": "code",
"createdAt": "2026-05-10T15:26:18.085Z",
"calls": [
{
"index": 1,
"apiProtocol": "openai",
"inputTokens": 450,
"outputTokens": 12,
"cacheWriteTokens": 0,
"cacheReadTokens": 0,
"costUsd": 0.0005,
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "1+1" }] },
{
"role": "assistant",
"content": [
{ "type": "reasoning", "text": "The user asked..." },
{ "type": "text", "text": "2" }
]
}
],
"toolCalls": [],
"reasoning": "The user asked \"1+1\" which equals 2..."
}
],
"totalTokens": { "input": 450, "output": 12, "cacheWrite": 0, "cacheRead": 0 },
"totalCostUsd": 0.0005,
"totalCalls": 1,
"totalToolCalls": 0
}When the LLM provider does not emit usage chunks in streaming mode (e.g., some OpenAI-compatible providers), token counts are estimated using a character/4 heuristic and the call is marked with "_tokensEstimated": true. When usage data is available from the provider, real values are used without this flag.
API calls that fail entirely (connection errors, rate limits, empty streams) are still included as call entries. These entries have:
- Empty
messages: []andtoolCalls: [] - Structured
errorobject withmessage,type,statusCode, andstack wireRequestshowing what was about to be sentretryAttemptindicating how many retries preceded this attempt
This ensures the trace shows every attempted API call, not just the successful ones.
Each call may include durationMs — whole milliseconds from request open to
stream end, measured with a monotonic clock (see api-req-timing.ts). It is
present exactly when the request reached a stream end (including cancelled and
failed streams); a call still in flight when the task was persisted, or one the
host died holding, carries no durationMs at all — absence means no end is
known, never "took zero time".
The same stream-end rewrites split that duration into the phases a reader can
act on, using the marks the streaming loop already records for the
api_req_finished span — nothing is measured twice:
firstChunkMs— request open → the first stream chunk of any kind. Absent when the stream produced nothing at all (an immediate provider failure): there was no first byte, and zero would claim there was one. This is the HOST's measurement. It is deliberately not calledttfbMs, because theapi_req_startedpayload already carries attfbMsthat shofer-router reports about its own request to the provider — a different observer.reasoningIntervalsMs— EVERY window the model spent reasoning, as[startMs, endMs]pairs on the same open-relative basis asfirstChunkMs. A model that interleaves reasoning with output produces several, in arrival order; a window the stream ended inside is closed atdurationMs, because that time genuinely was reasoning and an interval must never run past the call's own end. Absence means no reasoning was observed — an empty array is never written.thinkingMs— the SUM of those intervals: how much of the call was reasoning, not where. Present exactly whenreasoningIntervalsMsis, and never zero, so a consumer that segments a call into waiting / thinking / output reads "no thinking phase" straight off the field being missing.
The rest of the call — durationMs - firstChunkMs - thinkingMs — is output.
Drawn as a bar it is firstChunkMs of waiting, then the intervals alternating
with output, then whatever remains. Each figure is rounded independently, so a
consumer must clamp the segments to the duration rather than assume they fit
exactly.
Each call may include a wireRequest field — a serialised JSON snapshot captured just before the HTTP call. It contains:
- The model ID and API protocol
- System prompt length and a truncated head (first 500 chars)
- Number of messages and tools sent
- The full normalised message payload and tool definitions
This is useful for diagnosing what data was actually transmitted to the provider, especially when comparing against error responses.
flowchart TB
WV["Webview — TaskActions.tsx<br/>exportCurrentTask / exportCurrentTaskJson"]
WMH["webviewMessageHandler.ts<br/>routes to provider.exportTaskWithId(id)<br/>and provider.exportTaskWithIdJson(id)"]
SP["ShoferProvider.ts<br/>getTaskWithId(id) returns historyItem,<br/>apiConversationHistory, uiMessagesFilePath"]
MDP["exportTaskWithId()<br/>downloadTask(ts, apiConversationHistory, defaultUri)"]
JSONP["exportTaskWithIdJson()<br/>reads ui_messages.jsonl via readTaskMessages()<br/>buildJsonTrace(...)<br/>downloadJsonTask(ts, trace, defaultUri)"]
MD["export-markdown.ts<br/>formatContentBlockToMd()<br/>downloadTask()"]
JS["export-json.ts<br/>buildJsonTrace()<br/>getJsonExportFileName()<br/>downloadJsonTask()<br/>estimateTokens()<br/>estimateMessageTokens()"]
WV -->|postMessage| WMH --> SP
SP --> MDP --> MD
SP --> JSONP --> JS
The JSON exporter resolves the default save filename before calling downloadJsonTask() (via getTaskFileName()), and reads ui_messages.jsonl through the JSONL reader readTaskMessages() wrapped in a try/catch that falls back to an empty uiMessages array — a defensive guard against missing or unreadable data. (There is no separate fs.stat() pre-check; the reader returns [] when the file is absent.)
A JSON export is the entire descendant task tree — the exported task plus every sub-task, each contributing its full apiConversationHistory. That trace can reach many megabytes, so two steps that were once inline on the extension-host thread are now handled carefully:
- Serialize + write run in a worker thread.
downloadJsonTask()callsstringifyJsonToFile(), which hands the trace to aworkerpoolworker (workers/exportJson.ts) that does theJSON.stringify(…, null, 2)and the file write, returning only a byte count. This keeps the heavy serialization and the big-string round-trip off the event loop so the webview stays responsive (mirrors howcountTokensoffloads tokenization). If the worker can't be spawned or errors, it falls back to an in-process write. The write runs inside awithProgress("Writing JSON export…")notification — indeterminate (JSON.stringifyis atomic), but it actually animates now that the main thread is free. - Large exports are not auto-opened. Opening a multi-MB JSON document makes VS Code tokenize/fold it on the UI thread, which is itself a freeze. Files at or below
LARGE_EXPORT_BYTES(5 MB) still auto-open in a preview tab; above it, an information message offers Open / Reveal in File Explorer instead.
One residual main-thread cost remains: structured-cloning the trace object into the worker is
O(n). It is far cheaper than the formerstringify+ pretty-print +Buffer.from+ editor-open chain, but not zero.
All three source files live under {globalStorageUri}/tasks/{taskId}/:
| File | Written by | Contents |
|---|---|---|
history_item.json |
TaskHistoryStore |
HistoryItem — task metadata (id, task, mode, ts, tokens, cost, size) |
api_conversation_history.jsonl |
Task.addToApiConversationHistory() → appendApiMessage() (append-only JSONL; compacted via saveApiConversationHistory()) |
Anthropic.Messages.MessageParam[] — full message history with tool_use, tool_result, reasoning, thinking, thoughtSignature blocks |
ui_messages.jsonl |
Task.addToShoferMessages() / updateShoferMessage() → appendTaskMessage() (append-only JSONL; compacted via saveTaskMessages()) |
ShoferMessage[] — UI-level messages including api_req_started entries with per-call metadata |
Assembly of the JSON trace, with the guards that keep a partial export alive rather than losing the whole thing (dashed = fallback path):
flowchart TB
subgraph DISK["{globalStorageUri}/tasks/{taskId}/"]
direction TB
F1["history_item.json<br/>HistoryItem — TaskHistoryStore"]
F2["api_conversation_history.jsonl<br/>MessageParam[]"]
F3["ui_messages.jsonl<br/>ShoferMessage[] incl. api_req_started"]
end
GET["getTaskWithId(id)<br/>called without skipApiHistory,<br/>so the full history is returned"]
RA["readApiMessages()<br/>append-only log deduped by ts"]
RT["readTaskMessages()<br/>inside a try/catch"]
EMPTY["uiMessages = []<br/>partial export survives"]
BUILD["buildJsonTrace(...)<br/>JsonExportTrace"]
NAME["getTaskFileName() + resolveDefaultSaveUri()"]
DL["downloadJsonTask(ts, trace, defaultUri)"]
WORK["stringifyJsonToFile()<br/>workerpool worker — stringify + write"]
INPROC["in-process write"]
SIZE{"bytes > LARGE_EXPORT_BYTES<br/>5 MB?"}
OPEN["auto-open in a preview tab"]
MSG["information message:<br/>Open / Reveal in File Explorer"]
F1 --> GET
F2 --> RA --> GET
F3 --> RT
RT -->|"read ok"| BUILD
RT -.->|"missing or unreadable"| EMPTY
EMPTY -.-> BUILD
GET --> BUILD
BUILD --> NAME --> DL --> WORK
WORK -.->|"worker unavailable or errors"| INPROC
WORK --> SIZE
INPROC --> SIZE
SIZE -->|"no"| OPEN
SIZE -->|"yes"| MSG
The exporters receive these via ShoferProvider.getTaskWithId(), which constructs the task-directory file paths using constants from GlobalFileNames and reads api_conversation_history.jsonl via readApiMessages() (the append-only log is deduped by ts on read). Note the skipApiHistory option exists for cold task-switch but the export paths call getTaskWithId(id) without it, so they always receive the full apiConversationHistory. The JSON export additionally reads ui_messages.jsonl via readTaskMessages(). Export path resolution and last-path persistence is handled by resolveDefaultSaveUri / saveLastExportPath.
buildJsonTrace() partitions apiConversationHistory by assistant message boundaries:
- Walk the message array sequentially
- Each
assistantmessage closes an API call - Collect all messages from
currentCallStartthrough the assistant message - Match with the
api_req_startedentry at the samecallIndex
Error-Only Calls: API calls that never received an assistant response (connection failures, rate limits, empty streams) are handled by a post-loop while block that catches any unmatched api_req_started entries. These get call entries with messages: [], toolCalls: [] but still carry their error, wire request, and metadata.
flowchart TB
START["walk apiConversationHistory sequentially"]
MSG{"assistant message?"}
ACC["accumulate into the current call<br/>from currentCallStart"]
CLOSE["close the call<br/>collect currentCallStart..assistant"]
MATCH["match the api_req_started entry<br/>at the same callIndex"]
CALL["JsonExportCall<br/>messages, toolCalls, reasoning, metadata"]
END{"more messages?"}
POST["post-loop while block:<br/>unmatched api_req_started entries"]
ERRC["error-only JsonExportCall<br/>messages: [] · toolCalls: []<br/>keeps error, wireRequest, metadata"]
TRACE["JsonExportTrace.calls"]
START --> MSG
MSG -->|"no"| ACC --> END
MSG -->|"yes"| CLOSE --> MATCH --> CALL --> END
END -->|"yes"| MSG
END -->|"no"| POST --> ERRC
CALL --> TRACE
ERRC --> TRACE
The JSON exporter reads per-call metadata from api_req_started ShoferMessages (ShoferApiReqInfo written in Task.ts), parsed as UiApiReqStartedPayload. The three representations — ShoferApiReqInfo (write), UiApiReqStartedPayload (read/parse), JsonExportCall (export output) — must stay in lock-step; adding a field to one without the others silently drops that field from exports.
flowchart LR
W["ShoferApiReqInfo — write<br/>Task.ts emits api_req_started"]
SW["snapshotWireRequest()<br/>snapshotApiReqError()<br/>merge into the last api_req_started"]
P["ui_messages.jsonl<br/>persisted ShoferMessage"]
R["UiApiReqStartedPayload — read/parse<br/>export-json.ts"]
O["JsonExportCall — output<br/>a field missing here is silently dropped"]
W --> SW --> P --> R --> O
Token Estimation Trigger: The char/4 fallback in estimateTokens() fires when calls.every(c => c.inputTokens === 0 && c.outputTokens === 0) — i.e., whenever ALL calls have zero tokens, regardless of cause (all error-only calls, or a provider that emits usage but the capture failed).
Before each this.api.createMessage() call in attemptApiRequest(), a JSON snapshot is captured containing:
- Model ID and API protocol
- System prompt length + truncated head (first 500 chars)
- Number of messages and tools sent
- Full normalised message payload
- Tool definitions (if any)
The snapshot is stored via:
snapshotWireRequest()merges the wire request JSON into the lastapi_req_startedShoferMessage- The message is persisted to
ui_messages.jsonlvia the append/compaction save path - The JSON export reads it as
wireRequestand surfaces it as-is
Trade-offs:
- Pro: Captures what was actually sent without modifying provider handlers
- Con: Does not capture raw HTTP headers, status codes, or provider-specific wire format (messages are always in normalised Anthropic format)
Structured errors use ApiReqError (defined in vscode-extension-host.ts):
interface ApiReqError {
message: string // Human-readable error message
type?: string // e.g. "rate_limit_error", "invalid_request_error"
statusCode?: number // HTTP status code
stack?: string // Stack trace at the point of error
}Capture points:
| Location | When | Method |
|---|---|---|
attemptApiRequest first-chunk catch |
Provider errors, context window exceeded, connection failures | snapshotApiReqError(this.buildApiReqError(error)) |
recursivelyMakeShoferRequests mid-stream catch |
Stream interruption, tool execution failures | snapshotApiReqError(this.buildApiReqError(error)) — only for non-user-cancelled |
Both capture points call snapshotApiReqError() which merges the structured error into the last api_req_started ShoferMessage, then persisted to ui_messages.jsonl.
| File | Role |
|---|---|
export-markdown.ts |
Markdown formatter |
export-json.ts |
JSON formatter, call partitioning, token estimation |
ShoferProvider.ts |
exportTaskWithId() and exportTaskWithIdJson() entry points |
webviewMessageHandler.ts |
exportCurrentTask, exportCurrentTaskJson, exportTaskWithId, exportTaskWithIdJson handlers |
TaskActions.tsx |
Export buttons in the task header |
export.ts |
Export path resolution and last-path persistence |
workers/exportJson.ts |
Worker thread — off-main-thread JSON.stringify + file write |
utils/exportJsonWorker.ts |
stringifyJsonToFile() — worker-pool wrapper with synchronous in-process fallback |
| Version | Date | Changes |
|---|---|---|
| 2.73.0 | 2026-08-26 | Added reasoningIntervalsMs (every reasoning window, not just the first); thinkingMs is now their sum |
| 2.72.0 | 2026-08-26 | Added firstChunkMs and thinkingMs (the phase split inside durationMs; thinkingMs absent = no reasoning) |
| 2.71.0 | 2026-08-25 | Added durationMs (request open → stream end; absent when no stream end was reached) |
| 0.11.7 | 2026-05-16 | Added error, retryAttempt, wireRequest fields; fixed field name mismatch; added error-only call handling |
| 0.11.6 | 2026-05-14 | Initial JSON export with api_req_started-based metadata |
The JSON export mirrors the chrome-extension's in-task trace format (sidepanel.js). Key differences:
| Shofer JSON | Chrome Extension JSON | |
|---|---|---|
| Trigger | Manual export from UI | Manual button in task panel |
| Data source | Persisted files (offline-safe) | In-memory accumulation during task |
| Token source | Provider usage chunks or char/4 estimate |
Provider usage chunks |
| Cost | Calculated from tokens × model pricing | Calculated from tokens × model pricing |
| Tool results | Extracted from next user message's tool_result blocks |
Captured in real-time as tool calls complete |
| LLM call metadata | From api_req_started entries |
From live event stream |
The apiProtocol field comment in export-json.ts said (e.g. "anthropic", "openai-native"). Runtime values from getApiProtocol() are only "anthropic" and "openai"; "openai-native" is a provider name, never an apiProtocol. Comment corrected.
The Markdown example (§Format) wraps user messages in <user_message> XML tags. The actual exporter writes raw content without XML wrapping. The example is illustrative; consider replacing it with verbatim sample output.
export-markdown.ts has export-markdown.spec.ts covering the formatContentBlockToMarkdown formatter, but export-json.ts has no dedicated tests — the JSON trace builder (buildJsonTrace) and the higher-level downloadTask() / exportTaskWithIdJson() paths are untested. buildJsonTrace is a pure function amenable to unit testing.
The char/4 heuristic in estimateTokens() is not calibrated against any provider's tokeniser and may drift significantly for non-English text or code-heavy messages. The _tokensEstimated flag is a reasonable signal but consumers should treat estimated counts as diagnostic, not authoritative.
Tool results are extracted from the next user message's tool_result blocks (export-json.ts:261). If the conversation has been truncated or the tool-result ordering is non-standard, results may be silently missing from the export.
When exportTaskWithIdJson reads ui_messages.jsonl (via readTaskMessages()) while the live task is appending to it, there is a race between the export read and the live-task append/compaction. No locking or snapshot mechanism prevents this. Because the read is line-oriented and deduped by ts, a concurrent append is more forgiving than the old full-file JSON.parse (a half-written trailing line is tolerated and dropped), but a concurrent tmp → rename compaction can still surface a transient read error — caught by the try/catch around the read, which falls back to an empty uiMessages array.
The persisted source files (api_conversation_history.jsonl, ui_messages.jsonl, history_item.json) have no embedded version field. Exporting a task persisted by an older Shofer version may produce a trace whose shape the current buildJsonTrace() cannot parse correctly. (Legacy pre-JSONL *.json snapshots are unlinked on first read per the hard cutover in GlobalFileNames, so such tasks read as empty rather than mis-parsing.)
The ExtendedContentBlock union type used by export-json.ts is imported from export-markdown.ts. This cross-file dependency is implicit and undocumented.