@@ -55,11 +55,15 @@ type rawMessage struct {
5555}
5656
5757// contentBlock represents a single block in an assistant message's content array.
58+ // Also used for tool_result blocks in user messages.
5859type contentBlock struct {
59- Type string `json:"type"`
60- Text string `json:"text"`
61- Name string `json:"name"`
62- Input json.RawMessage `json:"input"`
60+ Type string `json:"type"`
61+ Text string `json:"text"`
62+ Name string `json:"name"`
63+ ID string `json:"id"` // tool_use block ID
64+ ToolUseID string `json:"tool_use_id"` // tool_result reference
65+ Input json.RawMessage `json:"input"`
66+ Content json.RawMessage `json:"content"` // tool_result content (string or array)
6367}
6468
6569// toolInput holds common fields from tool_use input blocks.
@@ -78,6 +82,10 @@ func ParseTranscript(data []byte) (*SessionPayload, error) {
7882 ActorType : "human" ,
7983 }
8084
85+ // pendingPlanReads tracks tool_use IDs for Read calls targeting .claude/plans/ files.
86+ // When the corresponding tool_result arrives in a user message, we extract the plan text.
87+ pendingPlanReads := make (map [string ]bool )
88+
8189 scanner := bufio .NewScanner (bytes .NewReader (data ))
8290 // Increase scanner buffer for large lines (tool results can be huge).
8391 scanner .Buffer (make ([]byte , 0 , 64 * 1024 ), 10 * 1024 * 1024 )
@@ -114,21 +122,22 @@ func ParseTranscript(data []byte) (*SessionPayload, error) {
114122
115123 switch raw .Type {
116124 case "user" :
117- turn , err := parseUserTurn (raw .Message , ts )
125+ turns , err := parseUserTurn (raw .Message , ts , pendingPlanReads )
118126 if err != nil {
119127 continue
120128 }
121- if turn != nil {
122- payload .Turns = append (payload .Turns , * turn )
123- }
129+ payload .Turns = append (payload .Turns , turns ... )
124130
125131 case "assistant" :
126- turns , toolCalls , err := parseAssistantMessage (raw .Message , ts )
132+ turns , toolCalls , planReadIDs , err := parseAssistantMessage (raw .Message , ts )
127133 if err != nil {
128134 continue
129135 }
130136 payload .Turns = append (payload .Turns , turns ... )
131137 payload .ToolCalls = append (payload .ToolCalls , toolCalls ... )
138+ for _ , id := range planReadIDs {
139+ pendingPlanReads [id ] = true
140+ }
132141 }
133142 }
134143
@@ -141,8 +150,10 @@ func ParseTranscript(data []byte) (*SessionPayload, error) {
141150}
142151
143152// parseUserTurn extracts the text content from a user message.
144- // It skips tool_result blocks (which contain file bodies, command outputs).
145- func parseUserTurn (msgRaw json.RawMessage , ts time.Time ) (* Turn , error ) {
153+ // It skips tool_result blocks (which contain file bodies, command outputs),
154+ // except for tool_results matching pendingPlanReads — those contain plan file
155+ // content that should be indexed.
156+ func parseUserTurn (msgRaw json.RawMessage , ts time.Time , pendingPlanReads map [string ]bool ) ([]Turn , error ) {
146157 if len (msgRaw ) == 0 {
147158 return nil , nil
148159 }
@@ -156,37 +167,48 @@ func parseUserTurn(msgRaw json.RawMessage, ts time.Time) (*Turn, error) {
156167 return nil , nil
157168 }
158169
170+ var turns []Turn
171+
172+ // Extract plan content from tool_result blocks matching pending plan reads.
173+ if len (pendingPlanReads ) > 0 {
174+ planTurns := extractPlanToolResults (msg .Content , ts , pendingPlanReads )
175+ turns = append (turns , planTurns ... )
176+ }
177+
159178 text := extractTextContent (msg .Content )
160- if text == "" {
161- return nil , nil
179+ if text != "" {
180+ turns = append (turns , Turn {
181+ Role : "human" ,
182+ Content : text ,
183+ Timestamp : ts ,
184+ })
162185 }
163186
164- return & Turn {
165- Role : "human" ,
166- Content : text ,
167- Timestamp : ts ,
168- }, nil
187+ return turns , nil
169188}
170189
171190// parseAssistantMessage extracts text turns and tool calls from an assistant message.
172191// It discards thinking blocks and tool results.
173- func parseAssistantMessage (msgRaw json.RawMessage , ts time.Time ) ([]Turn , []ToolCall , error ) {
192+ // It also returns IDs of Read tool_use blocks targeting .claude/plans/ files,
193+ // so the caller can match them against subsequent tool_result blocks.
194+ func parseAssistantMessage (msgRaw json.RawMessage , ts time.Time ) ([]Turn , []ToolCall , []string , error ) {
174195 if len (msgRaw ) == 0 {
175- return nil , nil , nil
196+ return nil , nil , nil , nil
176197 }
177198
178199 var msg rawMessage
179200 if err := json .Unmarshal (msgRaw , & msg ); err != nil {
180- return nil , nil , err
201+ return nil , nil , nil , err
181202 }
182203
183204 if msg .Role != "assistant" {
184- return nil , nil , nil
205+ return nil , nil , nil , nil
185206 }
186207
187208 // Content can be a string or an array of blocks.
188209 var turns []Turn
189210 var toolCalls []ToolCall
211+ var planReadIDs []string
190212
191213 // Try as string first.
192214 var textContent string
@@ -198,13 +220,13 @@ func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []Tool
198220 Timestamp : ts ,
199221 })
200222 }
201- return turns , nil , nil
223+ return turns , nil , nil , nil
202224 }
203225
204226 // Parse as array of content blocks.
205227 var blocks []contentBlock
206228 if err := json .Unmarshal (msg .Content , & blocks ); err != nil {
207- return nil , nil , err
229+ return nil , nil , nil , err
208230 }
209231
210232 var textParts []string
@@ -225,6 +247,10 @@ func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []Tool
225247 Timestamp : ts ,
226248 })
227249 }
250+ // Track Read calls targeting plan files.
251+ if id := extractPlanReadID (b ); id != "" {
252+ planReadIDs = append (planReadIDs , id )
253+ }
228254 // Discard: "thinking", "tool_result", etc.
229255 }
230256 }
@@ -244,7 +270,7 @@ func parseAssistantMessage(msgRaw json.RawMessage, ts time.Time) ([]Turn, []Tool
244270 })
245271 }
246272
247- return turns , toolCalls , nil
273+ return turns , toolCalls , planReadIDs , nil
248274}
249275
250276// extractTextContent pulls text from a message content field.
@@ -341,6 +367,105 @@ func extractPlanContent(b contentBlock) string {
341367 return inp .Content
342368}
343369
370+ // extractPlanReadID returns the tool_use ID if this is a Read tool call
371+ // targeting a .claude/plans/ file. The caller uses this to match the
372+ // corresponding tool_result in the next user message.
373+ func extractPlanReadID (b contentBlock ) string {
374+ if b .Name != "Read" {
375+ return ""
376+ }
377+ if len (b .Input ) == 0 || b .ID == "" {
378+ return ""
379+ }
380+
381+ var inp toolInput
382+ if err := json .Unmarshal (b .Input , & inp ); err != nil {
383+ return ""
384+ }
385+
386+ path := inp .FilePath
387+ if path == "" {
388+ path = inp .Path
389+ }
390+ if ! strings .Contains (path , ".claude/plans/" ) {
391+ return ""
392+ }
393+
394+ return b .ID
395+ }
396+
397+ // extractPlanToolResults scans user message content blocks for tool_result
398+ // blocks whose tool_use_id matches a pending plan read. For each match, it
399+ // extracts the text and emits it as an assistant turn (the content originated
400+ // from the assistant's Read call). Matched IDs are removed from the map.
401+ func extractPlanToolResults (content json.RawMessage , ts time.Time , pending map [string ]bool ) []Turn {
402+ if len (content ) == 0 {
403+ return nil
404+ }
405+
406+ var blocks []contentBlock
407+ if err := json .Unmarshal (content , & blocks ); err != nil {
408+ return nil
409+ }
410+
411+ var turns []Turn
412+ for _ , b := range blocks {
413+ if b .Type != "tool_result" {
414+ continue
415+ }
416+ if ! pending [b .ToolUseID ] {
417+ continue
418+ }
419+
420+ text := extractToolResultText (b .Content )
421+ if text != "" {
422+ turns = append (turns , Turn {
423+ Role : "assistant" ,
424+ Content : text ,
425+ Timestamp : ts ,
426+ })
427+ }
428+ delete (pending , b .ToolUseID )
429+ }
430+ return turns
431+ }
432+
433+ // extractToolResultText extracts text from a tool_result content field,
434+ // which can be a plain string or an array of content blocks.
435+ func extractToolResultText (content json.RawMessage ) string {
436+ if len (content ) == 0 {
437+ return ""
438+ }
439+
440+ // Try string.
441+ var s string
442+ if err := json .Unmarshal (content , & s ); err == nil {
443+ return s
444+ }
445+
446+ // Try array of blocks.
447+ var blocks []contentBlock
448+ if err := json .Unmarshal (content , & blocks ); err != nil {
449+ return ""
450+ }
451+
452+ var parts []string
453+ for _ , b := range blocks {
454+ if b .Type == "text" && b .Text != "" {
455+ parts = append (parts , b .Text )
456+ }
457+ }
458+
459+ combined := ""
460+ for i , p := range parts {
461+ if i > 0 {
462+ combined += "\n "
463+ }
464+ combined += p
465+ }
466+ return combined
467+ }
468+
344469func truncate (s string , maxLen int ) string {
345470 if len (s ) <= maxLen {
346471 return s
0 commit comments