skill-stats.go

  1//usr/bin/env go run "$0" "$@"; exit
  2package main
  3
  4import (
  5	"bytes"
  6	"encoding/json"
  7	"flag"
  8	"fmt"
  9	"io"
 10	"net/http"
 11	"os"
 12	"os/exec"
 13	"path/filepath"
 14	"regexp"
 15	"sort"
 16	"strings"
 17	"sync"
 18	"time"
 19)
 20
 21const (
 22	syntheticAPI = "https://api.synthetic.new/anthropic/v1/messages/count_tokens"
 23	model        = "hf:deepseek-ai/DeepSeek-V3-0324"
 24	workerCount  = 5 // Number of parallel API workers
 25)
 26
 27var httpClient = &http.Client{
 28	Timeout: 30 * time.Second,
 29}
 30
 31type Frontmatter struct {
 32	Name        string
 33	Description string
 34}
 35
 36type TokenCount struct {
 37	Name        int
 38	Description int
 39	Body        int
 40	References  map[string]int
 41	Total       int
 42}
 43
 44type SkillInfo struct {
 45	Dir         string
 46	Frontmatter Frontmatter
 47	BodyLines   int
 48	Tokens      TokenCount
 49	Errors      []string
 50}
 51
 52type TokenJob struct {
 53	ID   string
 54	Text string
 55}
 56
 57type TokenResult struct {
 58	ID    string
 59	Count int
 60	Err   error
 61}
 62
 63type SkillComparison struct {
 64	PrevTotal      int
 65	PrevMetadata   int
 66	PrevBody       int
 67	Delta          int
 68	MetadataDelta  int
 69	BodyDelta      int
 70	Percent        float64
 71	IsNew          bool
 72}
 73
 74func main() {
 75	compare := flag.Bool("compare", false, "Compare with HEAD commit")
 76	workers := flag.Int("workers", workerCount, "Number of parallel API workers")
 77	flag.Parse()
 78
 79	apiKey := os.Getenv("SYNTHETIC_API_KEY")
 80	if apiKey == "" {
 81		fmt.Fprintln(os.Stderr, "Error: SYNTHETIC_API_KEY environment variable not set")
 82		os.Exit(1)
 83	}
 84
 85	// Start worker pool
 86	counter := newTokenCounter(apiKey, *workers)
 87	defer counter.Close()
 88
 89	skills, err := analyzeSkills(counter)
 90	if err != nil {
 91		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
 92		os.Exit(1)
 93	}
 94
 95	// Build comparison map if requested
 96	var comparisons map[string]SkillComparison
 97	if *compare {
 98		comparisons = buildComparisons(skills, counter)
 99	}
100
101	// Sort skills by name for consistent output
102	sort.Slice(skills, func(i, j int) bool {
103		return skills[i].Dir < skills[j].Dir
104	})
105
106	// Print reports
107	for _, skill := range skills {
108		var comp *SkillComparison
109		if comparisons != nil {
110			if c, ok := comparisons[skill.Dir]; ok {
111				comp = &c
112			}
113		}
114		printSkillReport(skill, comp)
115	}
116
117	// Print summary
118	printSummary(skills, comparisons)
119}
120
121// TokenCounter manages a pool of workers for parallel token counting
122type TokenCounter struct {
123	apiKey  string
124	jobs    chan TokenJob
125	results chan TokenResult
126	wg      sync.WaitGroup
127}
128
129func newTokenCounter(apiKey string, workers int) *TokenCounter {
130	tc := &TokenCounter{
131		apiKey:  apiKey,
132		jobs:    make(chan TokenJob, 100),
133		results: make(chan TokenResult, 100),
134	}
135
136	// Start workers
137	for i := 0; i < workers; i++ {
138		tc.wg.Add(1)
139		go tc.worker()
140	}
141
142	return tc
143}
144
145func (tc *TokenCounter) worker() {
146	defer tc.wg.Done()
147	for job := range tc.jobs {
148		count, err := countTokensAPI(tc.apiKey, job.Text)
149		tc.results <- TokenResult{ID: job.ID, Count: count, Err: err}
150	}
151}
152
153func (tc *TokenCounter) Count(id, text string) {
154	tc.jobs <- TokenJob{ID: id, Text: text}
155}
156
157func (tc *TokenCounter) GetResult() TokenResult {
158	return <-tc.results
159}
160
161func (tc *TokenCounter) TryGetResult() (TokenResult, bool) {
162	select {
163	case r := <-tc.results:
164		return r, true
165	default:
166		return TokenResult{}, false
167	}
168}
169
170func (tc *TokenCounter) Close() {
171	close(tc.jobs)
172	tc.wg.Wait()
173	close(tc.results)
174}
175
176func analyzeSkills(counter *TokenCounter) ([]SkillInfo, error) {
177	skillsDir := "skills"
178	entries, err := os.ReadDir(skillsDir)
179	if err != nil {
180		return nil, fmt.Errorf("cannot read skills directory: %w", err)
181	}
182
183	var skills []SkillInfo
184	for _, entry := range entries {
185		if !entry.IsDir() {
186			continue
187		}
188
189		skillPath := filepath.Join(skillsDir, entry.Name())
190		skill, err := analyzeSkill(skillPath, counter)
191		if err != nil {
192			fmt.Fprintf(os.Stderr, "Warning: error analyzing %s: %v\n", entry.Name(), err)
193			continue
194		}
195		skills = append(skills, skill)
196	}
197
198	return skills, nil
199}
200
201func analyzeSkill(path string, counter *TokenCounter) (SkillInfo, error) {
202	skill := SkillInfo{
203		Dir: filepath.Base(path),
204		Tokens: TokenCount{
205			References: make(map[string]int),
206		},
207	}
208
209	// Read SKILL.md
210	skillMdPath := filepath.Join(path, "SKILL.md")
211	content, err := os.ReadFile(skillMdPath)
212	if err != nil {
213		skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot read SKILL.md: %v", err))
214		return skill, nil
215	}
216
217	// Parse frontmatter and body
218	fm, body, err := parseFrontmatter(string(content))
219	if err != nil {
220		skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot parse frontmatter: %v", err))
221		return skill, nil
222	}
223	skill.Frontmatter = fm
224	trimmedBody := strings.TrimSpace(body)
225	if trimmedBody == "" {
226		skill.BodyLines = 0
227	} else {
228		skill.BodyLines = len(strings.Split(trimmedBody, "\n"))
229	}
230
231	// Validate
232	skill.Errors = append(skill.Errors, validateSkill(skill)...)
233
234	fmt.Fprintf(os.Stderr, "Analyzing %s...\n", skill.Dir)
235
236	// Collect all jobs first (no channel use yet)
237	var jobs []TokenJob
238	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("%s:name", skill.Dir), Text: fm.Name})
239	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("%s:description", skill.Dir), Text: fm.Description})
240	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("%s:body", skill.Dir), Text: body})
241
242	// Collect reference file jobs
243	refsPath := filepath.Join(path, "references")
244	entries, err := os.ReadDir(refsPath)
245	if err != nil {
246		if !os.IsNotExist(err) {
247			skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot read references directory: %v", err))
248		}
249	} else {
250		for _, entry := range entries {
251			if entry.IsDir() {
252				continue
253			}
254			refPath := filepath.Join(refsPath, entry.Name())
255			refContent, err := os.ReadFile(refPath)
256			if err != nil {
257				skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot read reference %s: %v", entry.Name(), err))
258				continue
259			}
260			jobs = append(jobs, TokenJob{
261				ID:   fmt.Sprintf("%s:ref:%s", skill.Dir, entry.Name()),
262				Text: string(refContent),
263			})
264		}
265	}
266
267	// Interleave enqueue and drain to prevent deadlock
268	processResult := func(result TokenResult) {
269		if result.Err != nil {
270			skill.Errors = append(skill.Errors, fmt.Sprintf("Token count failed for %s: %v", result.ID, result.Err))
271			return
272		}
273		parts := strings.SplitN(result.ID, ":", 3)
274		if len(parts) < 2 {
275			return
276		}
277		switch parts[1] {
278		case "name":
279			skill.Tokens.Name = result.Count
280		case "description":
281			skill.Tokens.Description = result.Count
282		case "body":
283			skill.Tokens.Body = result.Count
284		case "ref":
285			if len(parts) == 3 {
286				skill.Tokens.References[parts[2]] = result.Count
287			}
288		}
289	}
290
291	outstanding := 0
292	for _, job := range jobs {
293		counter.Count(job.ID, job.Text)
294		outstanding++
295		// Drain any available results to prevent backpressure
296		for {
297			if result, ok := counter.TryGetResult(); ok {
298				processResult(result)
299				outstanding--
300			} else {
301				break
302			}
303		}
304	}
305
306	// Drain remaining results
307	for outstanding > 0 {
308		result := counter.GetResult()
309		processResult(result)
310		outstanding--
311	}
312
313	// Calculate total
314	skill.Tokens.Total = skill.Tokens.Name + skill.Tokens.Description + skill.Tokens.Body
315	for _, count := range skill.Tokens.References {
316		skill.Tokens.Total += count
317	}
318
319	return skill, nil
320}
321
322func parseFrontmatter(content string) (Frontmatter, string, error) {
323	lines := strings.Split(content, "\n")
324	if len(lines) < 3 || lines[0] != "---" {
325		return Frontmatter{}, "", fmt.Errorf("missing frontmatter")
326	}
327
328	var fm Frontmatter
329	var endIdx int
330	var inDescription bool
331	var descriptionLines []string
332
333	for i := 1; i < len(lines); i++ {
334		if lines[i] == "---" {
335			endIdx = i
336			break
337		}
338
339		line := lines[i]
340
341		// Parse name
342		if strings.HasPrefix(line, "name:") {
343			fm.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
344			continue
345		}
346
347		// Parse description (might be multi-line)
348		if strings.HasPrefix(line, "description:") {
349			descPart := strings.TrimSpace(strings.TrimPrefix(line, "description:"))
350			if descPart != "" {
351				descriptionLines = append(descriptionLines, descPart)
352			}
353			inDescription = true
354			continue
355		}
356
357		// Continue multi-line description
358		if inDescription && strings.HasPrefix(line, "  ") {
359			descriptionLines = append(descriptionLines, strings.TrimSpace(line))
360			continue
361		}
362
363		// End of description
364		if inDescription && !strings.HasPrefix(line, "  ") {
365			inDescription = false
366		}
367	}
368
369	fm.Description = strings.Join(descriptionLines, " ")
370
371	if endIdx == 0 {
372		return Frontmatter{}, "", fmt.Errorf("unclosed frontmatter")
373	}
374
375	body := strings.Join(lines[endIdx+1:], "\n")
376	return fm, body, nil
377}
378
379func validateSkill(skill SkillInfo) []string {
380	var errors []string
381
382	// Validate name
383	if len(skill.Frontmatter.Name) < 1 || len(skill.Frontmatter.Name) > 64 {
384		errors = append(errors, "name must be 1-64 characters")
385	}
386
387	namePattern := regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
388	if !namePattern.MatchString(skill.Frontmatter.Name) {
389		errors = append(errors, "name must be lowercase letters, numbers, hyphens only; no leading/trailing/consecutive hyphens")
390	}
391
392	if skill.Frontmatter.Name != skill.Dir {
393		errors = append(errors, fmt.Sprintf("name '%s' doesn't match directory '%s'", skill.Frontmatter.Name, skill.Dir))
394	}
395
396	// Validate description
397	if len(skill.Frontmatter.Description) < 1 {
398		errors = append(errors, "description is empty")
399	} else if len(skill.Frontmatter.Description) > 1024 {
400		errors = append(errors, fmt.Sprintf("description is %d characters (max 1024)", len(skill.Frontmatter.Description)))
401	}
402
403	// Check body line count
404	if skill.BodyLines > 500 {
405		errors = append(errors, fmt.Sprintf("body has %d lines (recommended: < 500)", skill.BodyLines))
406	}
407
408	return errors
409}
410
411func countTokensAPI(apiKey string, text string) (int, error) {
412	reqBody := map[string]interface{}{
413		"model": model,
414		"messages": []map[string]string{
415			{
416				"role":    "user",
417				"content": text,
418			},
419		},
420	}
421
422	jsonData, err := json.Marshal(reqBody)
423	if err != nil {
424		return 0, fmt.Errorf("marshal request: %w", err)
425	}
426
427	req, err := http.NewRequest("POST", syntheticAPI, bytes.NewBuffer(jsonData))
428	if err != nil {
429		return 0, fmt.Errorf("create request: %w", err)
430	}
431
432	req.Header.Set("Authorization", "Bearer "+apiKey)
433	req.Header.Set("Content-Type", "application/json")
434	req.Header.Set("anthropic-version", "2023-06-01")
435
436	resp, err := httpClient.Do(req)
437	if err != nil {
438		return 0, fmt.Errorf("HTTP request: %w", err)
439	}
440	defer resp.Body.Close()
441
442	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
443		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
444		return 0, fmt.Errorf("API status %d: %s", resp.StatusCode, string(body))
445	}
446
447	var result struct {
448		InputTokens int `json:"input_tokens"`
449	}
450
451	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
452		return 0, fmt.Errorf("decode response: %w", err)
453	}
454
455	return result.InputTokens, nil
456}
457
458type PrevTokens struct {
459	Total    int
460	Metadata int
461	Body     int
462}
463
464func buildComparisons(currentSkills []SkillInfo, counter *TokenCounter) map[string]SkillComparison {
465	comparisons := make(map[string]SkillComparison)
466
467	for _, skill := range currentSkills {
468		prev, err := getSkillTokensFromGit(skill.Dir, counter)
469		if err != nil {
470			// Skill is new
471			if skill.Tokens.Total > 0 {
472				comparisons[skill.Dir] = SkillComparison{
473					PrevTotal:     0,
474					PrevMetadata:  0,
475					PrevBody:      0,
476					Delta:         skill.Tokens.Total,
477					MetadataDelta: skill.Tokens.Name + skill.Tokens.Description,
478					BodyDelta:     skill.Tokens.Body,
479					Percent:       100.0,
480					IsNew:         true,
481				}
482			}
483			continue
484		}
485
486		delta := skill.Tokens.Total - prev.Total
487		metadataDelta := (skill.Tokens.Name + skill.Tokens.Description) - prev.Metadata
488		bodyDelta := skill.Tokens.Body - prev.Body
489		var percent float64
490		if prev.Total > 0 {
491			percent = (float64(delta) / float64(prev.Total)) * 100
492		}
493
494		if delta != 0 || metadataDelta != 0 || bodyDelta != 0 {
495			comparisons[skill.Dir] = SkillComparison{
496				PrevTotal:     prev.Total,
497				PrevMetadata:  prev.Metadata,
498				PrevBody:      prev.Body,
499				Delta:         delta,
500				MetadataDelta: metadataDelta,
501				BodyDelta:     bodyDelta,
502				Percent:       percent,
503				IsNew:         false,
504			}
505		}
506	}
507
508	return comparisons
509}
510
511func getSkillTokensFromGit(skillDir string, counter *TokenCounter) (PrevTokens, error) {
512	// Get file from HEAD
513	skillPath := fmt.Sprintf("skills/%s/SKILL.md", skillDir)
514	cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", skillPath))
515	output, err := cmd.Output()
516	if err != nil {
517		return PrevTokens{}, err
518	}
519
520	// Parse frontmatter and body
521	fm, body, err := parseFrontmatter(string(output))
522	if err != nil {
523		return PrevTokens{}, err
524	}
525
526	// Collect all jobs first (no channel use yet)
527	var jobs []TokenJob
528	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:name", skillDir), Text: fm.Name})
529	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:description", skillDir), Text: fm.Description})
530	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:body", skillDir), Text: body})
531
532	// Get reference files from HEAD
533	refsPath := fmt.Sprintf("skills/%s/references", skillDir)
534	cmd = exec.Command("git", "ls-tree", "-r", "--name-only", "HEAD", refsPath)
535	output, err = cmd.Output()
536	if err != nil {
537		// Log non-fatal git errors (e.g., refs directory doesn't exist in HEAD)
538		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 {
539			fmt.Fprintf(os.Stderr, "Warning: cannot list git refs for %s (may not exist in HEAD)\n", refsPath)
540		}
541	} else {
542		refPaths := strings.Split(strings.TrimSpace(string(output)), "\n")
543		for _, refPath := range refPaths {
544			if refPath == "" {
545				continue
546			}
547			cmd = exec.Command("git", "show", fmt.Sprintf("HEAD:%s", refPath))
548			refContent, err := cmd.Output()
549			if err != nil {
550				fmt.Fprintf(os.Stderr, "Warning: cannot read %s from HEAD: %v\n", refPath, err)
551				continue
552			}
553			jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:ref", skillDir), Text: string(refContent)})
554		}
555	}
556
557	// Interleave enqueue and drain to prevent deadlock
558	prev := PrevTokens{}
559	processResult := func(result TokenResult) {
560		if result.Err != nil {
561			fmt.Fprintf(os.Stderr, "Warning: token count failed for %s: %v\n", result.ID, result.Err)
562			return
563		}
564		parts := strings.SplitN(result.ID, ":", 3)
565		if len(parts) < 3 {
566			prev.Total += result.Count
567			return
568		}
569		switch parts[2] {
570		case "name":
571			prev.Metadata += result.Count
572		case "description":
573			prev.Metadata += result.Count
574		case "body":
575			prev.Body += result.Count
576		case "ref":
577			// References are counted in total but not metadata or body
578		}
579		prev.Total += result.Count
580	}
581
582	outstanding := 0
583	for _, job := range jobs {
584		counter.Count(job.ID, job.Text)
585		outstanding++
586		// Drain any available results to prevent backpressure
587		for {
588			if result, ok := counter.TryGetResult(); ok {
589				processResult(result)
590				outstanding--
591			} else {
592				break
593			}
594		}
595	}
596
597	// Drain remaining results
598	for outstanding > 0 {
599		result := counter.GetResult()
600		processResult(result)
601		outstanding--
602	}
603
604	return prev, nil
605}
606
607func printSkillReport(skill SkillInfo, comp *SkillComparison) {
608	fmt.Printf("\n=== %s ===\n", skill.Dir)
609
610	if len(skill.Errors) > 0 {
611		fmt.Println("\nValidation errors:")
612		for _, err := range skill.Errors {
613			fmt.Printf("  ✗ %s\n", err)
614		}
615	}
616
617	fmt.Println("\nToken breakdown:")
618	fmt.Printf("  Name:        %5d tokens\n", skill.Tokens.Name)
619	fmt.Printf("  Description: %5d tokens\n", skill.Tokens.Description)
620	fmt.Printf("  Body:        %5d tokens (%d lines)\n", skill.Tokens.Body, skill.BodyLines)
621
622	if len(skill.Tokens.References) > 0 {
623		fmt.Println("  References:")
624		// Sort reference names for consistent output
625		refNames := make([]string, 0, len(skill.Tokens.References))
626		for name := range skill.Tokens.References {
627			refNames = append(refNames, name)
628		}
629		sort.Strings(refNames)
630
631		for _, name := range refNames {
632			count := skill.Tokens.References[name]
633			fmt.Printf("    %-40s %5d tokens\n", name, count)
634		}
635	}
636
637	fmt.Println("  ───────────────────────────────────────────────")
638
639	// Print total with comparison if available
640	if comp != nil {
641		sign := "+"
642		if comp.Delta < 0 {
643			sign = ""
644		}
645		indicator := ""
646		if comp.IsNew {
647			indicator = " [NEW]"
648		} else if comp.Percent > 20 {
649			indicator = " ⚠️"
650		} else if comp.Percent < -20 {
651			indicator = " ✓"
652		}
653		fmt.Printf("  Total:       %5d tokens (%s%d, %s%.1f%% from HEAD)%s\n",
654			skill.Tokens.Total, sign, comp.Delta, sign, comp.Percent, indicator)
655	} else {
656		fmt.Printf("  Total:       %5d tokens\n", skill.Tokens.Total)
657	}
658
659	// Warn if approaching budget
660	if skill.Tokens.Body > 5000 {
661		fmt.Println("  ⚠️  Body exceeds recommended 5000 token budget!")
662	} else if skill.Tokens.Body > 4000 {
663		fmt.Println("  ⚠️  Body approaching 5000 token budget")
664	}
665}
666
667func printSummary(skills []SkillInfo, comparisons map[string]SkillComparison) {
668	fmt.Println("\n" + strings.Repeat("=", 60))
669	fmt.Println("SUMMARY")
670	fmt.Println(strings.Repeat("=", 60))
671
672	totalTokens := 0
673	totalMetadataTokens := 0
674	totalBodyTokens := 0
675	totalErrors := 0
676	totalDelta := 0
677	metadataDelta := 0
678	bodyDelta := 0
679
680	for _, skill := range skills {
681		totalTokens += skill.Tokens.Total
682		totalMetadataTokens += skill.Tokens.Name + skill.Tokens.Description
683		totalBodyTokens += skill.Tokens.Body
684		totalErrors += len(skill.Errors)
685		if comp, ok := comparisons[skill.Dir]; ok {
686			totalDelta += comp.Delta
687			metadataDelta += comp.MetadataDelta
688			bodyDelta += comp.BodyDelta
689		}
690	}
691
692	fmt.Printf("\nSkills: %d\n", len(skills))
693	if comparisons != nil && metadataDelta != 0 {
694		fmt.Printf("Metadata: %d tokens (%+d)\n", totalMetadataTokens, metadataDelta)
695	} else {
696		fmt.Printf("Metadata: %d tokens\n", totalMetadataTokens)
697	}
698	if comparisons != nil && bodyDelta != 0 {
699		fmt.Printf("Combined bodies: %d tokens (%+d)\n", totalBodyTokens, bodyDelta)
700	} else {
701		fmt.Printf("Combined bodies: %d tokens\n", totalBodyTokens)
702	}
703	if comparisons != nil && totalDelta != 0 {
704		fmt.Printf("Overall: %d tokens (%+d from HEAD)\n", totalTokens, totalDelta)
705	} else {
706		fmt.Printf("Overall: %d tokens\n", totalTokens)
707	}
708	fmt.Printf("Validation errors: %d\n", totalErrors)
709
710	// Find largest skills
711	sort.Slice(skills, func(i, j int) bool {
712		return skills[i].Tokens.Total > skills[j].Tokens.Total
713	})
714
715	fmt.Println("\nLargest skills (by total tokens):")
716	for i := 0; i < 5 && i < len(skills); i++ {
717		skill := skills[i]
718		if comp, ok := comparisons[skill.Dir]; ok {
719			sign := "+"
720			if comp.Delta < 0 {
721				sign = ""
722			}
723			fmt.Printf("  %d. %-40s %5d tokens (%s%d)\n",
724				i+1, skill.Dir, skill.Tokens.Total, sign, comp.Delta)
725		} else {
726			fmt.Printf("  %d. %-40s %5d tokens\n", i+1, skill.Dir, skill.Tokens.Total)
727		}
728	}
729
730	// Show biggest changes if comparing
731	if comparisons != nil && len(comparisons) > 0 {
732		type changeEntry struct {
733			name string
734			comp SkillComparison
735		}
736		var changes []changeEntry
737		for name, comp := range comparisons {
738			changes = append(changes, changeEntry{name, comp})
739		}
740
741		sort.Slice(changes, func(i, j int) bool {
742			absI := changes[i].comp.Delta
743			if absI < 0 {
744				absI = -absI
745			}
746			absJ := changes[j].comp.Delta
747			if absJ < 0 {
748				absJ = -absJ
749			}
750			return absI > absJ
751		})
752
753		fmt.Println("\nBiggest changes:")
754		displayed := 0
755		for _, change := range changes {
756			if displayed >= 5 {
757				break
758			}
759			sign := "+"
760			if change.comp.Delta < 0 {
761				sign = ""
762			}
763			indicator := ""
764			if change.comp.IsNew {
765				indicator = " [NEW]"
766			} else if change.comp.Percent > 20 {
767				indicator = " ⚠️"
768			} else if change.comp.Percent < -20 {
769				indicator = " ✓"
770			}
771			fmt.Printf("  %-40s %s%-5d tokens (%s%.1f%%)%s\n",
772				change.name, sign, change.comp.Delta, sign, change.comp.Percent, indicator)
773			displayed++
774		}
775	}
776}