Skip to content

Commit 97ce369

Browse files
committed
fix: enrich CLI export to match API output quality (Closes #5)
- Frontmatter: add workspace, workspace_name, model, total_tool_calls, tool_call_breakdown, wall_clock_seconds, total_response_time_sec, total_thinking_time_sec, max_context_tokens_used, context_token_limit, lines_added, lines_removed, files_read, files_written, commands_run - Add session header line: Created | Model | Tool calls | Duration - Add ## Session Summary block with ### Files Touched, ### Commands Run, and ### Tool Results (terminal success/error, file reads/edits, searches, web fetches) - Capitalise role labels: ### User / ### Assistant - Add per-message metadata line with model, response time, thinking time, and context window occupancy from modelInfo, thinkingDurationMs, contextWindowStatusAtCreation - Include thinking duration in <details><summary>Thinking (Xs)</summary> - Route all tool calls through parse_tool_call for structured summary/ input/output rendering instead of raw toolFormerData fields - Remove content caps: command strings (was 120 chars), files-read list (was 20), commands list (was 30) Made-with: Cursor
1 parent 242d81c commit 97ce369

1 file changed

Lines changed: 222 additions & 65 deletions

File tree

scripts/export.py

Lines changed: 222 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
is_excluded_by_rules,
3030
)
3131
from utils.path_helpers import get_workspace_folder_paths as _shared_get_workspace_folder_paths
32+
from utils.tool_parser import parse_tool_call
3233

3334
_logger = logging.getLogger(__name__)
3435

@@ -524,7 +525,7 @@ def assign_workspace(cd, cid):
524525
rel_dir = os.path.join(today, ws_slug, "chat")
525526
out_path = os.path.join(out_dir, rel_dir, filename)
526527

527-
# Build bubbles
528+
# Build bubbles with full metadata
528529
bubbles = []
529530
for h in headers:
530531
b = bubble_map.get(h.get("bubbleId"))
@@ -540,26 +541,38 @@ def assign_workspace(cd, cid):
540541

541542
btype = "user" if h.get("type") == 1 else "ai"
542543

543-
tool_calls = None
544-
if has_tool:
545-
tfd = b["toolFormerData"]
546-
tool_calls = [{
547-
"name": tfd.get("name"),
548-
"params": tfd.get("params") if isinstance(tfd.get("params"), str) else tfd.get("rawArgs"),
549-
"result": (tfd.get("result") or "") if isinstance(tfd.get("result"), str) else None,
550-
"status": tfd.get("status"),
551-
}]
552-
553544
thinking = None
545+
thinking_duration_ms = None
554546
if b.get("thinking"):
555-
thinking = b["thinking"] if isinstance(b["thinking"], str) else (b["thinking"].get("text") if isinstance(b["thinking"], dict) else None)
547+
thinking = b["thinking"] if isinstance(b["thinking"], str) else (
548+
b["thinking"].get("text") if isinstance(b["thinking"], dict) else None
549+
)
550+
thinking_duration_ms = b.get("thinkingDurationMs")
551+
552+
tool_info = None
553+
if has_tool:
554+
tool_info = parse_tool_call(b["toolFormerData"])
555+
556+
model_info = (b.get("modelInfo") or {}).get("modelName")
557+
if model_info == "default":
558+
model_info = None
559+
560+
ctx_window = b.get("contextWindowStatusAtCreation") or {}
561+
ctx_tokens_used = ctx_window.get("tokensUsed", 0)
562+
ctx_token_limit = ctx_window.get("tokenLimit", 0)
563+
ctx_pct_remaining = ctx_window.get("percentageRemainingFloat") or ctx_window.get("percentageRemaining")
556564

557565
bubbles.append({
558566
"type": btype,
559567
"text": text,
560568
"timestamp": to_epoch_ms(b.get("createdAt")) or to_epoch_ms(b.get("timestamp")) or int(datetime.now().timestamp() * 1000),
561-
"toolCalls": tool_calls,
569+
"tool": tool_info,
562570
"thinking": thinking,
571+
"thinkingDurationMs": thinking_duration_ms,
572+
"model": model_info,
573+
"contextTokensUsed": ctx_tokens_used if ctx_tokens_used > 0 else None,
574+
"contextTokenLimit": ctx_token_limit if ctx_token_limit > 0 else None,
575+
"contextPctRemaining": round(ctx_pct_remaining, 1) if ctx_pct_remaining else None,
563576
})
564577

565578
# Code block diffs
@@ -570,67 +583,211 @@ def assign_workspace(cd, cid):
570583
"timestamp": to_epoch_ms(cd.get("lastUpdatedAt")) or to_epoch_ms(cd.get("createdAt")) or int(datetime.now().timestamp() * 1000),
571584
})
572585

573-
bubbles.sort(key=lambda b: b.get("timestamp") or 0)
586+
bubbles.sort(key=lambda bub: bub.get("timestamp") or 0)
587+
588+
# Compute per-assistant-bubble response times
589+
last_user_ts = None
590+
for bub in bubbles:
591+
if bub["type"] == "user":
592+
last_user_ts = bub.get("timestamp")
593+
elif bub["type"] == "ai" and last_user_ts:
594+
bts = bub.get("timestamp")
595+
if bts and bts > last_user_ts:
596+
bub["responseTimeMs"] = bts - last_user_ts
597+
598+
# Session-level aggregates
599+
total_response_ms = sum(bub.get("responseTimeMs", 0) for bub in bubbles)
600+
total_thinking_ms = sum(bub.get("thinkingDurationMs", 0) or 0 for bub in bubbles)
601+
total_tool_calls = sum(1 for bub in bubbles if bub.get("tool"))
602+
max_ctx_used = max((bub.get("contextTokensUsed") or 0) for bub in bubbles) if bubbles else 0
603+
ctx_limit = max((bub.get("contextTokenLimit") or 0) for bub in bubbles) if bubbles else 0
604+
605+
tool_breakdown = {}
606+
for bub in bubbles:
607+
if bub.get("tool"):
608+
tn = bub["tool"].get("name", "unknown")
609+
tool_breakdown[tn] = tool_breakdown.get(tn, 0) + 1
610+
611+
lines_added = cd.get("totalLinesAdded", 0)
612+
lines_removed = cd.get("totalLinesRemoved", 0)
613+
614+
# Wall-clock duration from bubble timestamps
615+
ts_vals = [bub["timestamp"] for bub in bubbles if bub.get("timestamp")]
616+
wall_clock_sec = int((max(ts_vals) - min(ts_vals)) / 1000) if len(ts_vals) >= 2 else None
617+
618+
# Collect file/command activity and tool result stats from tool calls
619+
files_read_list = []
620+
files_written_list = []
621+
commands_run_list = []
622+
tool_result_stats = {
623+
"terminal_success": 0, "terminal_error": 0,
624+
"file_reads": 0, "file_edits": 0,
625+
"searches": 0, "web": 0,
626+
}
627+
for bub in bubbles:
628+
if not bub.get("tool"):
629+
continue
630+
t = bub["tool"]
631+
tn = t.get("name", "")
632+
status = t.get("status") or ""
633+
raw_input = str(t.get("input") or "").strip()
634+
first_line = raw_input.split("\n")[0] if raw_input else ""
635+
if tn == "read_file_v2" and first_line:
636+
files_read_list.append(first_line)
637+
tool_result_stats["file_reads"] += 1
638+
elif tn == "edit_file_v2" and first_line:
639+
files_written_list.append(first_line)
640+
tool_result_stats["file_edits"] += 1
641+
elif tn == "run_terminal_command_v2" and raw_input:
642+
commands_run_list.append(raw_input)
643+
if status == "completed":
644+
tool_result_stats["terminal_success"] += 1
645+
elif status in ("error", "failed"):
646+
tool_result_stats["terminal_error"] += 1
647+
else:
648+
tool_result_stats["terminal_success"] += 1
649+
elif tn in ("ripgrep_raw_search", "glob_file_search", "semantic_search_full"):
650+
tool_result_stats["searches"] += 1
651+
elif tn in ("web_search", "web_fetch"):
652+
tool_result_stats["web"] += 1
574653

575654
# Frontmatter
576-
fm = {
577-
"log_id": composer_id,
578-
"log_type": "chat",
579-
"title": title,
580-
"created_at": datetime.fromtimestamp((to_epoch_ms(cd.get("createdAt")) or ts) / 1000).isoformat(),
581-
"updated_at": datetime.fromtimestamp(updated_at / 1000).isoformat() if updated_at else datetime.now().isoformat(),
582-
"workspace_id": ws_id,
583-
"workspace_path": None if ws_id == "global" else ws_id,
584-
"storage_kind": "global",
585-
"message_count": len(bubbles),
586-
}
587-
total_tc = sum(len(b.get("toolCalls") or []) for b in bubbles)
588-
total_think = sum(1 for b in bubbles if b.get("thinking"))
589-
if total_tc:
590-
fm["tool_calls_count"] = total_tc
655+
created_ms = to_epoch_ms(cd.get("createdAt")) or ts
656+
fm_lines = ["---"]
657+
fm_lines.append(f"log_id: {composer_id}")
658+
fm_lines.append(f"log_type: chat")
659+
fm_lines.append(f'title: "{title.replace(chr(34), chr(92)+chr(34))}"')
660+
fm_lines.append(f"created_at: {datetime.fromtimestamp(created_ms / 1000).isoformat()}")
661+
fm_lines.append(f"updated_at: {datetime.fromtimestamp(updated_at / 1000).isoformat() if updated_at else datetime.now().isoformat()}")
662+
fm_lines.append(f"workspace: {ws_slug}")
663+
fm_lines.append(f'workspace_name: "{ws_display_name}"')
664+
if model_name and model_name != "default":
665+
fm_lines.append(f"model: {model_name}")
666+
fm_lines.append(f"message_count: {len(bubbles)}")
667+
if total_tool_calls:
668+
fm_lines.append(f"total_tool_calls: {total_tool_calls}")
669+
if tool_breakdown:
670+
fm_lines.append("tool_call_breakdown:")
671+
for tn, cnt in sorted(tool_breakdown.items(), key=lambda x: -x[1]):
672+
fm_lines.append(f" {tn}: {cnt}")
673+
total_think = sum(1 for bub in bubbles if bub.get("thinking"))
591674
if total_think:
592-
fm["thinking_count"] = total_think
675+
fm_lines.append(f"thinking_count: {total_think}")
676+
if wall_clock_sec is not None:
677+
fm_lines.append(f"wall_clock_seconds: {wall_clock_sec}")
678+
if total_response_ms:
679+
fm_lines.append(f"total_response_time_sec: {total_response_ms / 1000:.1f}")
680+
if total_thinking_ms:
681+
fm_lines.append(f"total_thinking_time_sec: {total_thinking_ms / 1000:.1f}")
682+
if max_ctx_used and ctx_limit:
683+
fm_lines.append(f"max_context_tokens_used: {max_ctx_used}")
684+
fm_lines.append(f"context_token_limit: {ctx_limit}")
685+
if lines_added or lines_removed:
686+
fm_lines.append(f"lines_added: {lines_added}")
687+
fm_lines.append(f"lines_removed: {lines_removed}")
688+
if files_read_list or files_written_list:
689+
fm_lines.append(f"files_read: {len(files_read_list)}")
690+
fm_lines.append(f"files_written: {len(files_written_list)}")
691+
if commands_run_list:
692+
fm_lines.append(f"commands_run: {len(commands_run_list)}")
693+
fm_lines.append("---")
694+
fm_str = "\n".join(fm_lines) + "\n\n"
695+
696+
# Header
697+
header = f"# {title}\n\n"
698+
meta_parts = []
699+
if created_ms:
700+
meta_parts.append(f"Created: {datetime.fromtimestamp(created_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')}")
701+
if model_name and model_name != "default":
702+
meta_parts.append(f"Model: {model_name}")
703+
if total_tool_calls:
704+
meta_parts.append(f"Tool calls: {total_tool_calls}")
705+
if wall_clock_sec is not None:
706+
hrs, rem = divmod(wall_clock_sec, 3600)
707+
mins, secs = divmod(rem, 60)
708+
dur = f"{hrs}h {mins}m" if hrs else (f"{mins}m {secs}s" if mins else f"{secs}s")
709+
meta_parts.append(f"Duration: {dur}")
710+
header += f"_{' | '.join(meta_parts)}_\n\n---\n\n" if meta_parts else "---\n\n"
711+
712+
# Session summary block
713+
summary = ""
714+
if files_read_list or files_written_list or commands_run_list:
715+
summary += "## Session Summary\n\n"
716+
if files_written_list or files_read_list:
717+
summary += "### Files Touched\n\n"
718+
summary += "| Action | File |\n|--------|------|\n"
719+
for fp in files_written_list:
720+
summary += f"| Edit | `{fp}` |\n"
721+
for fp in files_read_list:
722+
summary += f"| Read | `{fp}` |\n"
723+
summary += "\n"
724+
if commands_run_list:
725+
summary += "### Commands Run\n\n"
726+
for i, cmd in enumerate(commands_run_list, 1):
727+
summary += f"{i}. `{cmd}`\n"
728+
summary += "\n"
729+
non_zero = {k: v for k, v in tool_result_stats.items() if v > 0}
730+
if non_zero:
731+
summary += "### Tool Results\n\n"
732+
labels = {
733+
"terminal_success": "Terminal Success",
734+
"terminal_error": "Terminal Error",
735+
"file_reads": "File Reads",
736+
"file_edits": "File Edits",
737+
"searches": "Searches",
738+
"web": "Web Fetches",
739+
}
740+
for k, v in non_zero.items():
741+
summary += f"- {labels.get(k, k)}: {v}\n"
742+
summary += "\n"
743+
summary += "---\n\n"
593744

594745
# Body
595746
body = ""
596-
for bubble in bubbles:
597-
role = "user" if bubble["type"] == "user" else "assistant"
747+
for bub in bubbles:
748+
role = "User" if bub["type"] == "user" else "Assistant"
598749
body += f"### {role}\n\n"
599-
if bubble.get("timestamp"):
600-
body += f"_{datetime.fromtimestamp(bubble['timestamp'] / 1000).isoformat()}_\n\n"
601-
if bubble.get("thinking"):
602-
body += f"<details><summary>Thinking</summary>\n\n{bubble['thinking']}\n\n</details>\n\n"
603-
body += bubble["text"] + "\n\n"
604-
if bubble.get("toolCalls"):
605-
for tc in bubble["toolCalls"]:
606-
body += f"> **Tool: {tc.get('name', 'unknown')}**"
607-
if tc.get("status"):
608-
body += f" ({tc['status']})"
609-
body += "\n"
610-
if tc.get("params"):
611-
body += f"> **INPUT:**\n> ```\n"
612-
for pline in str(tc['params']).split("\n"):
613-
body += f"> {pline}\n"
614-
body += f"> ```\n"
615-
if tc.get("result"):
616-
body += f"> **OUTPUT:**\n> ```\n"
617-
for rline in str(tc['result']).split("\n"):
618-
body += f"> {rline}\n"
619-
body += f"> ```\n"
620-
body += "\n"
750+
# Per-message metadata line
751+
meta_parts = []
752+
if bub.get("model"):
753+
meta_parts.append(f"Model: {bub['model']}")
754+
if bub.get("responseTimeMs"):
755+
meta_parts.append(f"Response: {bub['responseTimeMs'] / 1000:.1f}s")
756+
if bub.get("thinkingDurationMs"):
757+
meta_parts.append(f"Thinking: {bub['thinkingDurationMs'] / 1000:.1f}s")
758+
if bub.get("contextTokensUsed") and bub.get("contextTokenLimit"):
759+
pct = bub["contextTokensUsed"] / bub["contextTokenLimit"] * 100
760+
meta_parts.append(f"Context: {bub['contextTokensUsed']:,} / {bub['contextTokenLimit']:,} tokens ({pct:.0f}% used)")
761+
elif bub.get("contextPctRemaining") is not None:
762+
meta_parts.append(f"Context: {bub['contextPctRemaining']}% remaining")
763+
if meta_parts:
764+
body += f"_{' | '.join(meta_parts)}_\n\n"
765+
if bub.get("timestamp"):
766+
body += f"_{datetime.fromtimestamp(bub['timestamp'] / 1000).isoformat()}_\n\n"
767+
if bub.get("thinking"):
768+
dur_str = f" ({bub['thinkingDurationMs'] / 1000:.1f}s)" if bub.get("thinkingDurationMs") else ""
769+
body += f"<details><summary>Thinking{dur_str}</summary>\n\n{bub['thinking']}\n\n</details>\n\n"
770+
body += bub["text"] + "\n\n"
771+
if bub.get("tool"):
772+
t = bub["tool"]
773+
tool_summary = t.get("summary") or t.get("name") or "unknown"
774+
tool_status = t.get("status") or ""
775+
status_str = f" ({tool_status})" if tool_status else ""
776+
body += f"> **Tool: {tool_summary}**{status_str}\n"
777+
if t.get("input"):
778+
body += "> **INPUT:**\n> ```\n"
779+
for iline in str(t["input"]).split("\n"):
780+
body += f"> {iline}\n"
781+
body += "> ```\n"
782+
if t.get("output"):
783+
body += "> **OUTPUT:**\n> ```\n"
784+
for oline in str(t["output"]).split("\n"):
785+
body += f"> {oline}\n"
786+
body += "> ```\n"
787+
body += "\n"
621788
body += "---\n\n"
622789

623-
# Assemble markdown
624-
fm_str = "---\n"
625-
for k, v in fm.items():
626-
if v is None:
627-
fm_str += f"{k}: null\n"
628-
elif isinstance(v, dict):
629-
fm_str += f"{k}: {json.dumps(v)}\n"
630-
else:
631-
fm_str += f"{k}: {v}\n"
632-
fm_str += "---\n\n"
633-
md = fm_str + body
790+
md = fm_str + header + summary + body
634791

635792
rel_path = os.path.join(today, ws_slug, "chat", filename)
636793
exported.append({"id": composer_id, "rel_path": rel_path, "content": md,

0 commit comments

Comments
 (0)