2929 is_excluded_by_rules ,
3030)
3131from 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