@@ -290,62 +290,89 @@ func readInstalledPluginPaths(home string) map[string]string {
290290 return out
291291}
292292
293- // extractThinkingSnapshot aggregates thinking-block tokens per model,
294- // driven off the SAME per-block attribution that lands on each span's
295- // cc.llm_call.attributed_output_tokens. This guarantees that
296- // Σ attributed[thinking] over the trace == cc.thinking.summary.total_tokens.
297- // `cc.thinking.{summary, by_model}`.
293+ // extractThinkingSnapshot aggregates thinking-block tokens bucketed by effort
294+ // level. Level is derived from actual thinking tokens per LLM call (the
295+ // transcript does not expose the requested budget_tokens).
298296//
299- // `parsed` should be the dedup-applied output of ParseAssistantMessages +
300- // DeduplicateUsage on the turn's entries. Pass nil to reparse (for
301- // callers that don't already have a cached slice) .
297+ // Buckets: minimal ≤500, light 501–3 000, medium 3 001–10 000, heavy >10 000.
298+ //
299+ // `cc.thinking.{summary, by_level}` .
302300func extractThinkingSnapshot (entries []TranscriptEntry , parsed []ParsedEntry ) map [string ]interface {} {
303301 if parsed == nil {
304302 parsed = ParseAssistantMessages (entries )
305303 DeduplicateUsage (parsed )
306304 }
307305
308- type group struct {
309- tokens , blockCount int
310- }
311- byModel := map [string ]* group {}
312- totalTokens , totalBlocks := 0 , 0
313-
306+ // Sum thinking tokens per LLM call (MessageID).
307+ callThinking := map [string ]int {}
308+ anonTokens := 0
314309 for _ , p := range parsed {
315310 if p .ContentType != "thinking" {
316311 continue
317312 }
318- g , ok := byModel [p .Model ]
319- if ! ok {
320- g = & group {}
321- byModel [p .Model ] = g
313+ if p .MessageID == "" {
314+ anonTokens += p .AttributedOutputTokens
315+ continue
322316 }
323- g . tokens += p .AttributedOutputTokens
324- g . blockCount ++
325- totalTokens += p . AttributedOutputTokens
326- totalBlocks ++
317+ callThinking [ p . MessageID ] += p .AttributedOutputTokens
318+ }
319+ if anonTokens > 0 {
320+ callThinking [ "__anon" ] = anonTokens
327321 }
328- if totalBlocks == 0 {
322+ if len ( callThinking ) == 0 {
329323 return nil
330324 }
331325
332- byModelOut := make ([]map [string ]interface {}, 0 , len (byModel ))
333- for m , g := range byModel {
334- byModelOut = append (byModelOut , map [string ]interface {}{
335- "model" : m ,
336- "tokens" : g .tokens ,
337- "block_count" : g .blockCount ,
326+ type levelGroup struct { calls , tokens int }
327+ byLevel := map [string ]* levelGroup {}
328+ totalTokens , totalCalls := 0 , 0
329+
330+ for _ , tok := range callThinking {
331+ l := thinkingLevel (tok )
332+ g , ok := byLevel [l ]
333+ if ! ok {
334+ g = & levelGroup {}
335+ byLevel [l ] = g
336+ }
337+ g .calls ++
338+ g .tokens += tok
339+ totalTokens += tok
340+ totalCalls ++
341+ }
342+
343+ order := []string {"minimal" , "light" , "medium" , "heavy" }
344+ byLevelOut := make ([]map [string ]interface {}, 0 , len (byLevel ))
345+ for _ , l := range order {
346+ g , ok := byLevel [l ]
347+ if ! ok {
348+ continue
349+ }
350+ byLevelOut = append (byLevelOut , map [string ]interface {}{
351+ "level" : l ,
352+ "tokens" : g .tokens ,
353+ "call_count" : g .calls ,
338354 })
339355 }
340- sort .Slice (byModelOut , func (i , j int ) bool {
341- return byModelOut [i ]["tokens" ].(int ) > byModelOut [j ]["tokens" ].(int )
342- })
356+
343357 return map [string ]interface {}{
344358 "summary" : map [string ]interface {}{
345359 "total_tokens" : totalTokens ,
346- "block_count " : totalBlocks ,
360+ "call_count " : totalCalls ,
347361 },
348- "by_model" : byModelOut ,
362+ "by_level" : byLevelOut ,
363+ }
364+ }
365+
366+ func thinkingLevel (tokens int ) string {
367+ switch {
368+ case tokens > 10000 :
369+ return "heavy"
370+ case tokens > 3000 :
371+ return "medium"
372+ case tokens > 500 :
373+ return "light"
374+ default :
375+ return "minimal"
349376 }
350377}
351378
@@ -597,21 +624,20 @@ func promptBucket(tokens int) string {
597624}
598625
599626// extractFileAttachmentsSnapshot returns @-mentioned + system-injected file
600- // attachments this turn. Skill bodies are NOT here — they go under
601- // cc.skills.loaded. `cc.file_attachments.{summary, files }`.
627+ // attachments this turn grouped by file extension . Skill bodies are NOT here —
628+ // they go under cc.skills.loaded. `cc.file_attachments.{summary, by_type }`.
602629func extractFileAttachmentsSnapshot (entries []TranscriptEntry ) map [string ]interface {} {
603- files := []map [string ]interface {}{}
604- total := 0
630+ type group struct { tokens , count int }
631+ byExt := map [string ]* group {}
632+ total , fileCount := 0 , 0
633+
605634 for _ , e := range entries {
606635 if e .Type != "attachment" || e .Attachment == nil {
607636 continue
608637 }
609638 if e .Attachment .Type != "file" {
610639 continue
611640 }
612- // File attachment shape: attachment.content is a JSON object with
613- // a nested file.content string. The struct treats Content as
614- // RawMessage so we decode lazily here.
615641 var wrapper struct {
616642 File struct {
617643 Path string `json:"path,omitempty"`
@@ -621,26 +647,46 @@ func extractFileAttachmentsSnapshot(entries []TranscriptEntry) map[string]interf
621647 if err := json .Unmarshal (e .Attachment .Content , & wrapper ); err != nil {
622648 continue
623649 }
624- body := wrapper .File .Content
625- // Auto-detect — file attachments vary (source code, markdown, JSON, …).
626- tokens := tokEstimate (body )
627- files = append (files , map [string ]interface {}{
628- "path" : wrapper .File .Path ,
629- "sha256" : sha256hex (body ),
630- "body_tokens" : tokens ,
631- "content_type" : "source" , // bucket classification deferred — single bucket for now
632- })
650+ tokens := tokEstimate (wrapper .File .Content )
651+
652+ ext := strings .ToLower (filepath .Ext (wrapper .File .Path ))
653+ if ext == "" {
654+ ext = "other"
655+ }
656+
657+ g , ok := byExt [ext ]
658+ if ! ok {
659+ g = & group {}
660+ byExt [ext ] = g
661+ }
662+ g .tokens += tokens
663+ g .count ++
633664 total += tokens
665+ fileCount ++
634666 }
635- if len (files ) == 0 {
667+
668+ if fileCount == 0 {
636669 return nil
637670 }
671+
672+ byTypeOut := make ([]map [string ]interface {}, 0 , len (byExt ))
673+ for ext , g := range byExt {
674+ byTypeOut = append (byTypeOut , map [string ]interface {}{
675+ "ext" : ext ,
676+ "tokens" : g .tokens ,
677+ "file_count" : g .count ,
678+ })
679+ }
680+ sort .Slice (byTypeOut , func (i , j int ) bool {
681+ return byTypeOut [i ]["tokens" ].(int ) > byTypeOut [j ]["tokens" ].(int )
682+ })
683+
638684 return map [string ]interface {}{
639685 "summary" : map [string ]interface {}{
640686 "total_tokens" : total ,
641- "file_count" : len ( files ) ,
687+ "file_count" : fileCount ,
642688 },
643- "files " : files ,
689+ "by_type " : byTypeOut ,
644690 }
645691}
646692
0 commit comments