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:moonshotai/Kimi-K2.6"
 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 (recursively walk subdirectories)
243	refsPath := filepath.Join(path, "references")
244	filepath.WalkDir(refsPath, func(refPath string, d os.DirEntry, err error) error {
245		if err != nil {
246			if os.IsNotExist(err) {
247				return nil
248			}
249			skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot access references path %s: %v", refPath, err))
250			return nil
251		}
252		if d.IsDir() {
253			return nil
254		}
255		refContent, err := os.ReadFile(refPath)
256		if err != nil {
257			skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot read reference %s: %v", refPath, err))
258			return nil
259		}
260		// Use path relative to references/ dir as the key
261		relPath, _ := filepath.Rel(refsPath, refPath)
262		jobs = append(jobs, TokenJob{
263			ID:   fmt.Sprintf("%s:ref:%s", skill.Dir, relPath),
264			Text: string(refContent),
265		})
266		return nil
267	})
268
269	// Interleave enqueue and drain to prevent deadlock
270	processResult := func(result TokenResult) {
271		if result.Err != nil {
272			skill.Errors = append(skill.Errors, fmt.Sprintf("Token count failed for %s: %v", result.ID, result.Err))
273			return
274		}
275		parts := strings.SplitN(result.ID, ":", 3)
276		if len(parts) < 2 {
277			return
278		}
279		switch parts[1] {
280		case "name":
281			skill.Tokens.Name = result.Count
282		case "description":
283			skill.Tokens.Description = result.Count
284		case "body":
285			skill.Tokens.Body = result.Count
286		case "ref":
287			if len(parts) == 3 {
288				skill.Tokens.References[parts[2]] = result.Count
289			}
290		}
291	}
292
293	outstanding := 0
294	for _, job := range jobs {
295		counter.Count(job.ID, job.Text)
296		outstanding++
297		// Drain any available results to prevent backpressure
298		for {
299			if result, ok := counter.TryGetResult(); ok {
300				processResult(result)
301				outstanding--
302			} else {
303				break
304			}
305		}
306	}
307
308	// Drain remaining results
309	for outstanding > 0 {
310		result := counter.GetResult()
311		processResult(result)
312		outstanding--
313	}
314
315	// Calculate total
316	skill.Tokens.Total = skill.Tokens.Name + skill.Tokens.Description + skill.Tokens.Body
317	for _, count := range skill.Tokens.References {
318		skill.Tokens.Total += count
319	}
320
321	return skill, nil
322}
323
324func parseFrontmatter(content string) (Frontmatter, string, error) {
325	lines := strings.Split(content, "\n")
326	if len(lines) < 3 || lines[0] != "---" {
327		return Frontmatter{}, "", fmt.Errorf("missing frontmatter")
328	}
329
330	var fm Frontmatter
331	var endIdx int
332	var inDescription bool
333	var descriptionLines []string
334
335	for i := 1; i < len(lines); i++ {
336		if lines[i] == "---" {
337			endIdx = i
338			break
339		}
340
341		line := lines[i]
342
343		// Parse name
344		if strings.HasPrefix(line, "name:") {
345			fm.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
346			continue
347		}
348
349		// Parse description (might be multi-line)
350		if strings.HasPrefix(line, "description:") {
351			descPart := strings.TrimSpace(strings.TrimPrefix(line, "description:"))
352			if descPart != "" {
353				descriptionLines = append(descriptionLines, descPart)
354			}
355			inDescription = true
356			continue
357		}
358
359		// Continue multi-line description
360		if inDescription && strings.HasPrefix(line, "  ") {
361			descriptionLines = append(descriptionLines, strings.TrimSpace(line))
362			continue
363		}
364
365		// End of description
366		if inDescription && !strings.HasPrefix(line, "  ") {
367			inDescription = false
368		}
369	}
370
371	fm.Description = strings.Join(descriptionLines, " ")
372
373	if endIdx == 0 {
374		return Frontmatter{}, "", fmt.Errorf("unclosed frontmatter")
375	}
376
377	body := strings.Join(lines[endIdx+1:], "\n")
378	return fm, body, nil
379}
380
381func validateSkill(skill SkillInfo) []string {
382	var errors []string
383
384	// Validate name
385	if len(skill.Frontmatter.Name) < 1 || len(skill.Frontmatter.Name) > 64 {
386		errors = append(errors, "name must be 1-64 characters")
387	}
388
389	namePattern := regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
390	if !namePattern.MatchString(skill.Frontmatter.Name) {
391		errors = append(errors, "name must be lowercase letters, numbers, hyphens only; no leading/trailing/consecutive hyphens")
392	}
393
394	if skill.Frontmatter.Name != skill.Dir {
395		errors = append(errors, fmt.Sprintf("name '%s' doesn't match directory '%s'", skill.Frontmatter.Name, skill.Dir))
396	}
397
398	// Validate description
399	if len(skill.Frontmatter.Description) < 1 {
400		errors = append(errors, "description is empty")
401	} else if len(skill.Frontmatter.Description) > 1024 {
402		errors = append(errors, fmt.Sprintf("description is %d characters (max 1024)", len(skill.Frontmatter.Description)))
403	}
404
405	// Check body line count
406	if skill.BodyLines > 500 {
407		errors = append(errors, fmt.Sprintf("body has %d lines (recommended: < 500)", skill.BodyLines))
408	}
409
410	return errors
411}
412
413func countTokensAPI(apiKey string, text string) (int, error) {
414	reqBody := map[string]interface{}{
415		"model": model,
416		"messages": []map[string]string{
417			{
418				"role":    "user",
419				"content": text,
420			},
421		},
422	}
423
424	jsonData, err := json.Marshal(reqBody)
425	if err != nil {
426		return 0, fmt.Errorf("marshal request: %w", err)
427	}
428
429	req, err := http.NewRequest("POST", syntheticAPI, bytes.NewBuffer(jsonData))
430	if err != nil {
431		return 0, fmt.Errorf("create request: %w", err)
432	}
433
434	req.Header.Set("Authorization", "Bearer "+apiKey)
435	req.Header.Set("Content-Type", "application/json")
436	req.Header.Set("anthropic-version", "2023-06-01")
437
438	resp, err := httpClient.Do(req)
439	if err != nil {
440		return 0, fmt.Errorf("HTTP request: %w", err)
441	}
442	defer resp.Body.Close()
443
444	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
445		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
446		return 0, fmt.Errorf("API status %d: %s", resp.StatusCode, string(body))
447	}
448
449	var result struct {
450		InputTokens int `json:"input_tokens"`
451	}
452
453	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
454		return 0, fmt.Errorf("decode response: %w", err)
455	}
456
457	return result.InputTokens, nil
458}
459
460type PrevTokens struct {
461	Total    int
462	Metadata int
463	Body     int
464}
465
466func buildComparisons(currentSkills []SkillInfo, counter *TokenCounter) map[string]SkillComparison {
467	comparisons := make(map[string]SkillComparison)
468
469	for _, skill := range currentSkills {
470		prev, err := getSkillTokensFromGit(skill.Dir, counter)
471		if err != nil {
472			// Skill is new
473			if skill.Tokens.Total > 0 {
474				comparisons[skill.Dir] = SkillComparison{
475					PrevTotal:     0,
476					PrevMetadata:  0,
477					PrevBody:      0,
478					Delta:         skill.Tokens.Total,
479					MetadataDelta: skill.Tokens.Name + skill.Tokens.Description,
480					BodyDelta:     skill.Tokens.Body,
481					Percent:       100.0,
482					IsNew:         true,
483				}
484			}
485			continue
486		}
487
488		delta := skill.Tokens.Total - prev.Total
489		metadataDelta := (skill.Tokens.Name + skill.Tokens.Description) - prev.Metadata
490		bodyDelta := skill.Tokens.Body - prev.Body
491		var percent float64
492		if prev.Total > 0 {
493			percent = (float64(delta) / float64(prev.Total)) * 100
494		}
495
496		if delta != 0 || metadataDelta != 0 || bodyDelta != 0 {
497			comparisons[skill.Dir] = SkillComparison{
498				PrevTotal:     prev.Total,
499				PrevMetadata:  prev.Metadata,
500				PrevBody:      prev.Body,
501				Delta:         delta,
502				MetadataDelta: metadataDelta,
503				BodyDelta:     bodyDelta,
504				Percent:       percent,
505				IsNew:         false,
506			}
507		}
508	}
509
510	return comparisons
511}
512
513func getSkillTokensFromGit(skillDir string, counter *TokenCounter) (PrevTokens, error) {
514	// Get file from HEAD
515	skillPath := fmt.Sprintf("skills/%s/SKILL.md", skillDir)
516	cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", skillPath))
517	output, err := cmd.Output()
518	if err != nil {
519		return PrevTokens{}, err
520	}
521
522	// Parse frontmatter and body
523	fm, body, err := parseFrontmatter(string(output))
524	if err != nil {
525		return PrevTokens{}, err
526	}
527
528	// Collect all jobs first (no channel use yet)
529	var jobs []TokenJob
530	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:name", skillDir), Text: fm.Name})
531	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:description", skillDir), Text: fm.Description})
532	jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:body", skillDir), Text: body})
533
534	// Get reference files from HEAD
535	refsPath := fmt.Sprintf("skills/%s/references", skillDir)
536	cmd = exec.Command("git", "ls-tree", "-r", "--name-only", "HEAD", refsPath)
537	output, err = cmd.Output()
538	if err != nil {
539		// Log non-fatal git errors (e.g., refs directory doesn't exist in HEAD)
540		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 {
541			fmt.Fprintf(os.Stderr, "Warning: cannot list git refs for %s (may not exist in HEAD)\n", refsPath)
542		}
543	} else {
544		refPaths := strings.Split(strings.TrimSpace(string(output)), "\n")
545		for _, refPath := range refPaths {
546			if refPath == "" {
547				continue
548			}
549			cmd = exec.Command("git", "show", fmt.Sprintf("HEAD:%s", refPath))
550			refContent, err := cmd.Output()
551			if err != nil {
552				fmt.Fprintf(os.Stderr, "Warning: cannot read %s from HEAD: %v\n", refPath, err)
553				continue
554			}
555			jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:ref", skillDir), Text: string(refContent)})
556		}
557	}
558
559	// Interleave enqueue and drain to prevent deadlock
560	prev := PrevTokens{}
561	processResult := func(result TokenResult) {
562		if result.Err != nil {
563			fmt.Fprintf(os.Stderr, "Warning: token count failed for %s: %v\n", result.ID, result.Err)
564			return
565		}
566		parts := strings.SplitN(result.ID, ":", 3)
567		if len(parts) < 3 {
568			prev.Total += result.Count
569			return
570		}
571		switch parts[2] {
572		case "name":
573			prev.Metadata += result.Count
574		case "description":
575			prev.Metadata += result.Count
576		case "body":
577			prev.Body += result.Count
578		case "ref":
579			// References are counted in total but not metadata or body
580		}
581		prev.Total += result.Count
582	}
583
584	outstanding := 0
585	for _, job := range jobs {
586		counter.Count(job.ID, job.Text)
587		outstanding++
588		// Drain any available results to prevent backpressure
589		for {
590			if result, ok := counter.TryGetResult(); ok {
591				processResult(result)
592				outstanding--
593			} else {
594				break
595			}
596		}
597	}
598
599	// Drain remaining results
600	for outstanding > 0 {
601		result := counter.GetResult()
602		processResult(result)
603		outstanding--
604	}
605
606	return prev, nil
607}
608
609func printSkillReport(skill SkillInfo, comp *SkillComparison) {
610	fmt.Printf("\n=== %s ===\n", skill.Dir)
611
612	if len(skill.Errors) > 0 {
613		fmt.Println("\nValidation errors:")
614		for _, err := range skill.Errors {
615			fmt.Printf("  ✗ %s\n", err)
616		}
617	}
618
619	fmt.Println("\nToken breakdown:")
620	fmt.Printf("  Name:        %5d tokens\n", skill.Tokens.Name)
621	fmt.Printf("  Description: %5d tokens\n", skill.Tokens.Description)
622	fmt.Printf("  Body:        %5d tokens (%d lines)\n", skill.Tokens.Body, skill.BodyLines)
623
624	if len(skill.Tokens.References) > 0 {
625		fmt.Println("  References:")
626		// Sort reference names for consistent output
627		refNames := make([]string, 0, len(skill.Tokens.References))
628		for name := range skill.Tokens.References {
629			refNames = append(refNames, name)
630		}
631		sort.Strings(refNames)
632
633		for _, name := range refNames {
634			count := skill.Tokens.References[name]
635			fmt.Printf("    %-40s %5d tokens\n", name, count)
636		}
637	}
638
639	fmt.Println("  ───────────────────────────────────────────────")
640
641	// Print total with comparison if available
642	if comp != nil {
643		sign := "+"
644		if comp.Delta < 0 {
645			sign = ""
646		}
647		indicator := ""
648		if comp.IsNew {
649			indicator = " [NEW]"
650		} else if comp.Percent > 20 {
651			indicator = " ⚠️"
652		} else if comp.Percent < -20 {
653			indicator = " ✓"
654		}
655		fmt.Printf("  Total:       %5d tokens (%s%d, %s%.1f%% from HEAD)%s\n",
656			skill.Tokens.Total, sign, comp.Delta, sign, comp.Percent, indicator)
657	} else {
658		fmt.Printf("  Total:       %5d tokens\n", skill.Tokens.Total)
659	}
660
661	// Warn if approaching budget
662	if skill.Tokens.Body > 5000 {
663		fmt.Println("  ⚠️  Body exceeds recommended 5000 token budget!")
664	} else if skill.Tokens.Body > 4000 {
665		fmt.Println("  ⚠️  Body approaching 5000 token budget")
666	}
667}
668
669func printSummary(skills []SkillInfo, comparisons map[string]SkillComparison) {
670	fmt.Println("\n" + strings.Repeat("=", 60))
671	fmt.Println("SUMMARY")
672	fmt.Println(strings.Repeat("=", 60))
673
674	totalTokens := 0
675	totalMetadataTokens := 0
676	totalBodyTokens := 0
677	totalErrors := 0
678	totalDelta := 0
679	metadataDelta := 0
680	bodyDelta := 0
681
682	for _, skill := range skills {
683		totalTokens += skill.Tokens.Total
684		totalMetadataTokens += skill.Tokens.Name + skill.Tokens.Description
685		totalBodyTokens += skill.Tokens.Body
686		totalErrors += len(skill.Errors)
687		if comp, ok := comparisons[skill.Dir]; ok {
688			totalDelta += comp.Delta
689			metadataDelta += comp.MetadataDelta
690			bodyDelta += comp.BodyDelta
691		}
692	}
693
694	fmt.Printf("\nSkills: %d\n", len(skills))
695	if comparisons != nil && metadataDelta != 0 {
696		fmt.Printf("Metadata: %d tokens (%+d)\n", totalMetadataTokens, metadataDelta)
697	} else {
698		fmt.Printf("Metadata: %d tokens\n", totalMetadataTokens)
699	}
700	if comparisons != nil && bodyDelta != 0 {
701		fmt.Printf("Combined bodies: %d tokens (%+d)\n", totalBodyTokens, bodyDelta)
702	} else {
703		fmt.Printf("Combined bodies: %d tokens\n", totalBodyTokens)
704	}
705	if comparisons != nil && totalDelta != 0 {
706		fmt.Printf("Overall: %d tokens (%+d from HEAD)\n", totalTokens, totalDelta)
707	} else {
708		fmt.Printf("Overall: %d tokens\n", totalTokens)
709	}
710	fmt.Printf("Validation errors: %d\n", totalErrors)
711
712	// Find largest skills
713	sort.Slice(skills, func(i, j int) bool {
714		return skills[i].Tokens.Total > skills[j].Tokens.Total
715	})
716
717	fmt.Println("\nLargest skills (by total tokens):")
718	for i := 0; i < 5 && i < len(skills); i++ {
719		skill := skills[i]
720		if comp, ok := comparisons[skill.Dir]; ok {
721			sign := "+"
722			if comp.Delta < 0 {
723				sign = ""
724			}
725			fmt.Printf("  %d. %-40s %5d tokens (%s%d)\n",
726				i+1, skill.Dir, skill.Tokens.Total, sign, comp.Delta)
727		} else {
728			fmt.Printf("  %d. %-40s %5d tokens\n", i+1, skill.Dir, skill.Tokens.Total)
729		}
730	}
731
732	// Show biggest changes if comparing
733	if comparisons != nil && len(comparisons) > 0 {
734		type changeEntry struct {
735			name string
736			comp SkillComparison
737		}
738		var changes []changeEntry
739		for name, comp := range comparisons {
740			changes = append(changes, changeEntry{name, comp})
741		}
742
743		sort.Slice(changes, func(i, j int) bool {
744			absI := changes[i].comp.Delta
745			if absI < 0 {
746				absI = -absI
747			}
748			absJ := changes[j].comp.Delta
749			if absJ < 0 {
750				absJ = -absJ
751			}
752			return absI > absJ
753		})
754
755		fmt.Println("\nBiggest changes:")
756		displayed := 0
757		for _, change := range changes {
758			if displayed >= 5 {
759				break
760			}
761			sign := "+"
762			if change.comp.Delta < 0 {
763				sign = ""
764			}
765			indicator := ""
766			if change.comp.IsNew {
767				indicator = " [NEW]"
768			} else if change.comp.Percent > 20 {
769				indicator = " ⚠️"
770			} else if change.comp.Percent < -20 {
771				indicator = " ✓"
772			}
773			fmt.Printf("  %-40s %s%-5d tokens (%s%.1f%%)%s\n",
774				change.name, sign, change.comp.Delta, sign, change.comp.Percent, indicator)
775			displayed++
776		}
777	}
778}