3939IMAGE_PLACEHOLDER_RE = re .compile (r"\[Image:\s*source:\s*([^\]]+)\]" )
4040IMAGE_LABEL_RE = re .compile (r"\[Image\s+#\d+\]" )
4141SUPPORTED_IMAGE_MEDIA_TYPES = {"image/png" , "image/jpeg" , "image/gif" , "image/webp" }
42+ COMPACTION_PLACEHOLDER = (
43+ "[Context compacted — earlier conversation summarized to continue past the context window]"
44+ )
45+ BASH_INPUT_RE = re .compile (r"<bash-input>(.*?)</bash-input>" , re .DOTALL )
46+ BASH_STDOUT_RE = re .compile (r"<bash-stdout>(.*?)</bash-stdout>" , re .DOTALL )
47+ BASH_STDERR_RE = re .compile (r"<bash-stderr>(.*?)</bash-stderr>" , re .DOTALL )
48+ BASH_NO_OUTPUT_MARKERS = {"" , "(Bash completed with no output)" }
4249
4350ImageUploader = Callable [[Path ], str ]
4451
@@ -119,6 +126,8 @@ def parse_transcript(path: Path, fallback_session_id: str | None = None, image_u
119126 model = ""
120127 created_ms = 0
121128 updated_ms = 0
129+ pending_bash_index : int | None = None
130+ pending_bash_command = ""
122131 with path .open ("r" , encoding = "utf-8" ) as handle :
123132 for line in handle :
124133 if not line .strip ():
@@ -132,28 +141,63 @@ def parse_transcript(path: Path, fallback_session_id: str | None = None, image_u
132141 message = entry .get ("message" )
133142 if not isinstance (message , dict ):
134143 continue
135- content = normalize_content (message .get ("content" ), image_uploader = image_uploader )
136- if content is None :
137- continue
138- role = normalized_role (message .get ("role" ) or entry .get ("type" ), content )
139- content = normalize_local_command_message (role , content )
140- if content is None or is_loaded_skill_body_message (role , content ):
141- continue
142- source_message_id = message .get ("id" ) or ""
143- item = {
144- "role" : role ,
145- "content" : content ,
146- "message_id" : entry .get ("uuid" ) or source_message_id or message .get ("message_id" ) or "" ,
147- }
148- usage = message .get ("usage" )
149- if isinstance (usage , dict ):
150- item ["usage" ] = redact_json (usage )
151- protocol_id = message .get ("protocolMessageID" ) or message .get ("protocol_message_id" )
152- if protocol_id :
153- item ["protocol_message_id" ] = protocol_id
154- if is_duplicate_unavailable_image_message (messages , item ):
155- continue
156- append_transcript_message (messages , merge_sources , item , source_message_id )
144+
145+ if entry .get ("isCompactSummary" ):
146+ # Replace the (very long) auto-compaction summary with a short
147+ # placeholder: keep the timeline marker without bloating the payload.
148+ content = COMPACTION_PLACEHOLDER
149+ role = "user"
150+ else :
151+ content = normalize_content (message .get ("content" ), image_uploader = image_uploader )
152+ if content is None :
153+ continue
154+ role = normalized_role (message .get ("role" ) or entry .get ("type" ), content )
155+ content = normalize_local_command_message (role , content )
156+ if content is None or is_loaded_skill_body_message (role , content ):
157+ continue
158+
159+ bash = parse_bash_block (content ) if role == "user" and isinstance (content , str ) else None
160+ if (
161+ bash is not None
162+ and bash ["kind" ] == "output"
163+ and pending_bash_index is not None
164+ and pending_bash_index == len (messages ) - 1
165+ ):
166+ # Fold a `!command` stdout/stderr message into the bash block
167+ # emitted just before it, rendering both as one terminal view.
168+ messages [pending_bash_index ]["content" ] = render_bash_terminal (
169+ pending_bash_command , bash ["stdout" ], bash ["stderr" ]
170+ )
171+ pending_bash_index = None
172+ else :
173+ if bash is not None :
174+ if bash ["kind" ] == "input" :
175+ content = render_bash_terminal (bash ["command" ], note_empty = False )
176+ elif bash ["kind" ] == "input_output" :
177+ content = render_bash_terminal (bash ["command" ], bash ["stdout" ], bash ["stderr" ])
178+ else :
179+ content = render_bash_terminal (None , bash ["stdout" ], bash ["stderr" ])
180+ source_message_id = message .get ("id" ) or ""
181+ item = {
182+ "role" : role ,
183+ "content" : content ,
184+ "message_id" : entry .get ("uuid" ) or source_message_id or message .get ("message_id" ) or "" ,
185+ }
186+ usage = message .get ("usage" )
187+ if isinstance (usage , dict ):
188+ item ["usage" ] = redact_json (usage )
189+ protocol_id = message .get ("protocolMessageID" ) or message .get ("protocol_message_id" )
190+ if protocol_id :
191+ item ["protocol_message_id" ] = protocol_id
192+ if is_duplicate_unavailable_image_message (messages , item ):
193+ continue
194+ append_transcript_message (messages , merge_sources , item , source_message_id )
195+ if bash is not None and bash ["kind" ] == "input" :
196+ pending_bash_index = len (messages ) - 1
197+ pending_bash_command = bash ["command" ]
198+ else :
199+ pending_bash_index = None
200+
157201 session_id = session_id or entry .get ("sessionId" ) or entry .get ("session_id" ) or ""
158202 cwd = cwd or entry .get ("cwd" ) or ""
159203 branch = branch or entry .get ("gitBranch" ) or entry .get ("git_branch" ) or ""
@@ -265,6 +309,57 @@ def normalize_local_command_message(role: str, content: Any) -> Any | None:
265309 return content
266310
267311
312+ def parse_bash_block (text : str ) -> dict [str , Any ] | None :
313+ # Only treat messages that *start* with a bash tag as real `!command`
314+ # injections. Prose that merely quotes a <bash-input> tag is left untouched.
315+ stripped = text .strip ()
316+ starts_input = stripped .startswith ("<bash-input>" )
317+ starts_output = stripped .startswith ("<bash-stdout>" ) or stripped .startswith ("<bash-stderr>" )
318+ if not starts_input and not starts_output :
319+ return None
320+ has_output = "<bash-stdout>" in stripped or "<bash-stderr>" in stripped
321+ if starts_input :
322+ command = first_tag_capture (BASH_INPUT_RE , stripped )
323+ if has_output :
324+ return {
325+ "kind" : "input_output" ,
326+ "command" : command ,
327+ "stdout" : first_tag_capture (BASH_STDOUT_RE , stripped ),
328+ "stderr" : first_tag_capture (BASH_STDERR_RE , stripped ),
329+ }
330+ return {"kind" : "input" , "command" : command }
331+ return {
332+ "kind" : "output" ,
333+ "stdout" : first_tag_capture (BASH_STDOUT_RE , stripped ),
334+ "stderr" : first_tag_capture (BASH_STDERR_RE , stripped ),
335+ }
336+
337+
338+ def first_tag_capture (pattern : re .Pattern [str ], text : str ) -> str :
339+ match = pattern .search (text )
340+ return match .group (1 ) if match else ""
341+
342+
343+ def render_bash_terminal (command : str | None , stdout : str = "" , stderr : str = "" , note_empty : bool = True ) -> str :
344+ out = stdout .strip ()
345+ err = stderr .strip ()
346+ if out in BASH_NO_OUTPUT_MARKERS :
347+ out = ""
348+ if err in BASH_NO_OUTPUT_MARKERS :
349+ err = ""
350+ lines : list [str ] = []
351+ if command is not None :
352+ lines .append (f"$ { command .strip ()} " )
353+ if out :
354+ lines .append (out )
355+ if err :
356+ lines .append ("# [stderr]" )
357+ lines .append (err )
358+ if not out and not err and command is not None and note_empty :
359+ lines .append ("# (no output)" )
360+ return "```console\n " + "\n " .join (lines ) + "\n ```"
361+
362+
268363def tag_text (text : str , tag_name : str ) -> str :
269364 match = re .search (rf"<{ tag_name } >\s*(.*?)\s*</{ tag_name } >" , text , re .DOTALL )
270365 if not match :
@@ -445,7 +540,7 @@ def title_from_messages(messages: list[dict[str, Any]]) -> str:
445540 if message .get ("role" ) != "user" :
446541 continue
447542 content = message .get ("content" )
448- if isinstance (content , str ) and content .strip ():
543+ if isinstance (content , str ) and content .strip () and content != COMPACTION_PLACEHOLDER :
449544 return content .strip ()[:80 ]
450545 return "Claude Code session"
451546
0 commit comments