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}