-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCodeLLM_Bridge.py
More file actions
6342 lines (5245 loc) · 271 KB
/
Copy pathCodeLLM_Bridge.py
File metadata and controls
6342 lines (5245 loc) · 271 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Tokenization Support
====================
This application uses the `tiktoken` package to count tokens for text that is
sent to language models. The tokenizer is chosen based on the selected model
using `tiktoken.encoding_for_model()`. See the documentation block below for a
detailed overview of installation, model mappings and best practices when
working with OpenAI tokenizers.
"""
import os
import json
import time
import fnmatch
import re
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
from tkinter.scrolledtext import ScrolledText
import datetime
import shutil
import threading
from global_hotkeys import register_hotkey, start_checking_hotkeys, stop_checking_hotkeys
import pyperclip
import keyboard
import tempfile
import signal
import queue
import tiktoken
try:
from anthropic import Anthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
ANTHROPIC_AVAILABLE = False
print("Warning: anthropic package not installed. Smart Select feature will be disabled.")
TOKENIZATION_HELP = """
Below is a **drop-in technical docstring** you can paste straight into your codebase (or feed to an AI-agent) to give it everything it needs to install `tiktoken`, pick the right tokenizer for any OpenAI model, and fall back gracefully when new models appear.
---
## SUMMARY (WHAT THIS FILE COVERS)
* **Installation + version pin** for Python
* **Core `tiktoken` API**: `encoding_for_model`, `encode`, `decode`, chat helpers
* **Complete mapping table** – which encoding each OpenAI model (GPT-4o, o-series, GPT-4, GPT-3.5, Codex, embeddings, edits, legacy GPT-3) uses and its context window
* **Fallback patterns** for future releases (manual patching of `MODEL_PREFIX_TO_ENCODING`)
* **Performance tips & gotchas** when counting tokens in production
Everything is annotated with authoritative sources so the agent can verify or expand if needed.
---
## 1 INSTALLATION & VERSIONING
```bash
pip install --upgrade "tiktoken>=0.9"
```
\*`≥ 0.9 (14 Feb 2025)` is the first wheel that **ships the `o200k_base` vocab and the `"o3-" / "o4-" / "gpt-4o"` mappings** – earlier versions hard-crash on those names.
---
## 2 CORE PYTHON API
```python
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o") # auto-selects o200k_base
ids = enc.encode("Hello world") # → [9906, 2157]
text = enc.decode(ids) # ≡ "Hello world"
tokens = len(ids) # quick count
```
### Counting chat payloads (billing-accurate)
```python
def count_chat_tokens(messages, model="gpt-4o"):
enc = tiktoken.encoding_for_model(model)
num = 0
for m in messages:
num += 3 # <|start|>role<|sep|>
num += len(enc.encode(m["role"]))
num += len(enc.encode(m["content"]))
return num + 3 # assistant priming
```
---
## 3 MODEL → ENCODING → CONTEXT WINDOW
| Model family / prefix | Encoding returned by `encoding_for_model()` | Context window (tokens) |
| ---------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ |
| `gpt-4o`, `gpt-4o-mini`, `gpt-4o-*` | **`o200k_base`** | 128 000 |
| `o3-*`, `o4-*` (incl. `o3-pro`, `o4-mini-high`) | **`o200k_base`** | 200 000 |
| `gpt-4*`, `gpt-3.5*`, `text-embedding-3*`, `text-embedding-ada-002` | **`cl100k_base`** | 128 000 / 16 000 (model-specific) |
| `code-davinci-*`, `davinci-codex`, `cushman-codex`, `gpt-4-turbo-preview-2024-...` | **`p50k_base`** | 8 192–16 384 (see model card) |
| `text-davinci-edit-001`, `code-davinci-edit-001` | **`p50k_edit`** | 8 192 |
| Legacy GPT-3 (`davinci`, `curie`, `babbage`, `ada`), similarity / search twins | **`r50k_base`** (aka GPT-2) | 2 049 |
---
## 4 FUTURE-PROOFING (MANUAL PATCH)
If OpenAI launches, say, **`o5-ultra`** before updating `tiktoken`, hot-patch:
```python
import tiktoken, re
if re.match(r"^o5-", model_name):
tiktoken.MODEL_PREFIX_TO_ENCODING["o5-"] = "o200k_base"
enc = tiktoken.encoding_for_model(model_name)
```
---
## 5 PERFORMANCE TIPS / PITFALLS
1. **Stale wheel = wrong counts** – 80 % of "token mismatch" bug reports are pinned to `tiktoken<0.9` running against o-models.
2. **Role framing tokens** matter; omit them and you’ll **under-bill** by 6 tokens per message.
3. **Different vocabs ≠ interchangeable** – `o200k_base` compresses non-Latin text better than `cl100k_base`.
4. **Stream large texts** – at 5 MiB/s on a single core (`o200k_base`), CPU tokenization becomes the bottleneck before the network. Use the Rust-compiled wheel or batch encode.
---
## 6 INTEGRATION CHECKLIST FOR YOUR APP
1. **Add to requirements**: `tiktoken>=0.9,<1.0`
2. **Import once**; reuse the `Encoding` object per worker thread.
3. **Wrap chat counting** with the helper above; log counts for cost observability.
4. **Unit-test**: feed a 3-message chat and assert the token count is the expected integer (locks future upgrades).
5. **On model upgrade day**:
* update the mapping wheel (`pip install -U tiktoken`)
* if unavailable, hot-patch the prefix (Section 4)
* regenerate golden counts in tests.
---
## 7 SPEED DIAL (LINKS YOUR AGENT CAN FOLLOW)
* `github.com/openai/tiktoken` – source & changelog
* OpenAI Cookbook “How to count tokens with tiktoken” – practical recipes
* OpenAI Models doc – official list & context windows
* Help Center FAQ on o3/o4-mini – confirms 200 k window
* Model compare page (o3-pro) – live pricing & limits
* Community threads confirming `o200k_base` for GPT-4o
* Azure fine-tuning tutorial – same tokenizer usage in .NET/Java
* tiktoken-go mapping list (cross-lang parity)
* GitHub PR adding `o4-` prefix (how updates land)
* GitHub issue showing `MODEL_PREFIX_TO_ENCODING` structure
---
**Copy-paste this doc block wherever your AI code-gen agent can read it, and you’ll never get blindsided by a “Could not automatically map XYZ to a tokeniser” error again.**
"""
class LoadingDialog:
"""A dialog that shows loading progress with the ability to cancel."""
def __init__(self, parent, title="Loading Profile"):
self.parent = parent
self.cancelled = False
self.current_operation = ""
# Create the dialog window
self.dialog = tk.Toplevel(parent)
self.dialog.title(title)
self.dialog.geometry("500x200")
self.dialog.resizable(False, False)
# Center the dialog on parent
self.dialog.transient(parent)
self.dialog.grab_set()
# Create the UI
self.setup_ui()
# Make dialog modal
self.dialog.protocol("WM_DELETE_WINDOW", self.on_cancel)
def setup_ui(self):
"""Set up the loading dialog UI."""
main_frame = tk.Frame(self.dialog, padx=20, pady=20)
main_frame.pack(fill=tk.BOTH, expand=True)
# Title
title_label = tk.Label(main_frame, text="Loading Profile...",
font=("Arial", 14, "bold"))
title_label.pack(pady=(0, 10))
# Progress bar
self.progress = ttk.Progressbar(main_frame, mode='indeterminate', length=400)
self.progress.pack(pady=(0, 10))
self.progress.start(10) # Start animation
# Current operation label
self.operation_label = tk.Label(main_frame, text="Initializing...",
wraplength=450, justify=tk.LEFT)
self.operation_label.pack(pady=(0, 10))
# Current folder/file label
self.detail_label = tk.Label(main_frame, text="",
wraplength=450, justify=tk.LEFT,
font=("Arial", 9), fg="gray")
self.detail_label.pack(pady=(0, 15))
# Buttons frame
button_frame = tk.Frame(main_frame)
button_frame.pack(fill=tk.X)
# Skip button
self.skip_button = tk.Button(button_frame, text="Skip This Profile",
command=self.on_skip,
bg="#ff9800", fg="white")
self.skip_button.pack(side=tk.LEFT, padx=(0, 10))
# Cancel button
self.cancel_button = tk.Button(button_frame, text="Cancel & Use Default",
command=self.on_cancel,
bg="#f44336", fg="white")
self.cancel_button.pack(side=tk.LEFT, padx=(0, 10))
# Disable timeouts button
self.disable_timeout_button = tk.Button(button_frame, text="Disable Timeouts",
command=self.on_disable_timeouts,
bg="#2196f3", fg="white")
self.disable_timeout_button.pack(side=tk.LEFT)
# Status label
self.status_label = tk.Label(main_frame, text="", fg="red", font=("Arial", 8))
self.status_label.pack(pady=(10, 0))
def update_operation(self, operation_text):
"""Update the main operation text."""
self.current_operation = operation_text
if hasattr(self, 'operation_label'):
self.operation_label.config(text=operation_text)
self.dialog.update()
def update_detail(self, detail_text):
"""Update the detailed progress text."""
if hasattr(self, 'detail_label'):
# Truncate very long paths
if len(detail_text) > 60:
detail_text = "..." + detail_text[-57:]
self.detail_label.config(text=detail_text)
self.dialog.update()
def update_status(self, status_text, color="red"):
"""Update the status message."""
if hasattr(self, 'status_label'):
self.status_label.config(text=status_text, fg=color)
self.dialog.update()
def on_skip(self):
"""Handle skip button click."""
self.cancelled = "skip"
self.update_status("Skipping current profile...", "orange")
def on_cancel(self):
"""Handle cancel button click."""
self.cancelled = "cancel"
self.update_status("Cancelling and using default profile...", "red")
def on_disable_timeouts(self):
"""Handle disable timeouts button click."""
self.cancelled = "disable_timeouts"
self.update_status("Timeouts disabled - loading will continue without time limits...", "blue")
# Hide the disable timeouts button and update the other buttons
self.disable_timeout_button.pack_forget()
self.skip_button.config(text="Continue in Background")
self.cancel_button.config(text="Cancel & Use Default")
def is_cancelled(self):
"""Check if operation was cancelled."""
return self.cancelled != False
def get_cancel_type(self):
"""Get the type of cancellation (skip, cancel, or disable_timeouts)."""
return self.cancelled
def close(self):
"""Close the dialog."""
if hasattr(self, 'progress'):
self.progress.stop()
if hasattr(self, 'dialog'):
self.dialog.grab_release()
self.dialog.destroy()
CONFIG_FILE = "app_settings.json"
PROFILES_DIR = "profiles"
HISTORY_DIR = "history" # New directory for history
LAST_PROFILE_FILE = "last_profile.txt" # File to store the last selected profile
DEFAULT_PREPEND_STRING = "Apply ALL these changes one by one -" # Default string for prepending
DEFAULT_MODEL = "o3" # Default tokenizer model
# Theme colors
LIGHT_THEME = {
"bg": "#f0f0f0",
"fg": "#000000",
"button_bg": "#e0e0e0",
"button_fg": "#000000",
"text_bg": "#ffffff",
"text_fg": "#000000",
"listbox_bg": "#ffffff",
"listbox_fg": "#000000",
"tree_bg": "#ffffff",
"tree_fg": "#000000",
"frame_bg": "#f0f0f0",
"status_bg": "#f0f0f0",
"status_fg": "#333333",
"highlight_bg": "#0078d7",
"highlight_fg": "#ffffff"
}
DARK_THEME = {
"bg": "#1e1e1e",
"fg": "#ffffff",
"button_bg": "#333333",
"button_fg": "#ffffff",
"text_bg": "#121212",
"text_fg": "#e0e0e0",
"listbox_bg": "#121212",
"listbox_fg": "#e0e0e0",
"tree_bg": "#121212",
"tree_fg": "#e0e0e0",
"frame_bg": "#1e1e1e",
"status_bg": "#007acc",
"status_fg": "#ffffff",
"highlight_bg": "#0078d7",
"highlight_fg": "#ffffff",
"content_bg": "#080808", # Even darker for content areas
"content_fg": "#e0e0e0" # Bright text for contrast
}
# Timeout settings for folder loading (configurable)
FOLDER_LOADING_TIMEOUT = 60 # seconds - total time to load a profile (increased from 10)
FOLDER_ACCESS_TIMEOUT = 10 # seconds per folder access check (increased from 3)
# Note: The monitor also provides an additional 30-second grace period for loading
# For local folders, access checks are optimized to be much faster
# You can adjust these values:
# - Increase FOLDER_LOADING_TIMEOUT if you have very large projects
# - Increase FOLDER_ACCESS_TIMEOUT if you have slow network connections
# - Decrease them if you want faster fallback for unresponsive servers
class TimeoutError(Exception):
"""Custom timeout exception"""
pass
class FolderLoadingTimeout:
"""Context manager for handling folder loading timeouts"""
def __init__(self, timeout_seconds):
self.timeout_seconds = timeout_seconds
self.old_handler = None
def __enter__(self):
# For Unix-like systems, use signal
if os.name != 'nt':
self.old_handler = signal.signal(signal.SIGALRM, self._timeout_handler)
signal.alarm(self.timeout_seconds)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Cancel the alarm and restore old handler (Unix only)
if os.name != 'nt':
signal.alarm(0)
if self.old_handler is not None:
signal.signal(signal.SIGALRM, self.old_handler)
def _timeout_handler(self, signum, frame):
raise TimeoutError(f"Folder loading timed out after {self.timeout_seconds} seconds")
# =============================================================================
# SMART SELECT AI FEATURE - Constants and Classes
# =============================================================================
# Extensions that are pre-filtered (AI never sees these)
SMART_SELECT_IGNORED_EXTENSIONS = {
# Media files
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.svg', '.webp', '.tiff', '.tif',
'.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp',
'.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac', '.wma',
# Archives
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.tgz', '.tbz2',
# Executables and binaries
'.exe', '.dll', '.so', '.dylib', '.msi', '.deb', '.rpm', '.app', '.dmg',
'.bin', '.dat', '.db', '.sqlite', '.sqlite3',
# Fonts
'.ttf', '.otf', '.woff', '.woff2', '.eot',
# Documents (non-code)
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.odt', '.ods', '.odp',
# Other
'.pyc', '.pyo', '.class', '.o', '.obj', '.a', '.lib', '.pdb',
'.map', '.min.js', '.min.css', # Minified files
'.lock', # Lock files
}
# Directories that are pre-filtered (AI never sees these)
SMART_SELECT_IGNORED_DIRS = {
# Package managers / Dependencies
'node_modules', 'venv', '.venv', 'env', '.env', '__pycache__', '.pytest_cache',
'vendor', 'packages', 'bower_components', '.pip', 'site-packages', 'pip-cache',
'.npm', '.yarn', '.pnpm-store',
# Build outputs
'dist', 'build', 'out', 'output', 'bin', 'obj', 'target', 'cmake-build-debug',
'cmake-build-release', '.next', '.nuxt', '.output', '.vercel', '.netlify',
# Version control
'.git', '.svn', '.hg', '.bzr',
# IDE/Editor
'.idea', '.vscode', '.vs', '.eclipse', '.settings', '.project',
# Cache/temp
'.cache', '.tmp', 'tmp', 'temp', 'logs', '.mypy_cache', '.ruff_cache',
'.tox', '.nox', 'htmlcov', '.coverage', '.nyc_output', 'coverage',
# Other
'.terraform', '.vagrant', '.docker', '__MACOSX',
}
# Code extensions the AI should consider
SMART_SELECT_CODE_EXTENSIONS = {
'.py', '.js', '.ts', '.tsx', '.jsx', '.html', '.htm', '.css', '.scss', '.less', '.sass',
'.json', '.yaml', '.yml', '.xml', '.toml', '.ini', '.cfg', '.conf',
'.java', '.c', '.cpp', '.cc', '.h', '.hpp', '.hxx', '.cs', '.fs', '.vb',
'.go', '.rs', '.rb', '.php', '.swift', '.kt', '.kts', '.scala', '.clj', '.cljs',
'.vue', '.svelte', '.astro',
'.md', '.rst', '.txt', # Documentation
'.sql', '.graphql', '.gql',
'.sh', '.bash', '.zsh', '.fish', '.bat', '.cmd', '.ps1', '.psm1',
'.lua', '.r', '.R', '.pl', '.pm', '.dart', '.zig', '.nim', '.ex', '.exs', '.erl', '.hrl',
'.tf', '.tfvars', # Terraform
'.dockerfile', '.containerfile', # Docker
'.makefile', '.mk', '.cmake', # Build files
'.proto', '.thrift', '.avsc', # Schema files
'.env.example', '.env.sample', # Example env files (not actual .env)
}
SMART_SELECT_DEFAULT_PROMPT = """You are the AI assistant for **CodeLLM Bridge** by Psynect Corp, a desktop application
that helps developers copy their source code into AI chats (like ChatGPT, Claude, Gemini, etc.) for analysis.
## Your Purpose
Users want to paste their codebase into an AI chat to get help understanding, debugging, or improving their code.
Your job is to intelligently select the relevant source code files they should include, while keeping the
total size manageable (AI chats have context limits).
## What to SELECT (these files help AI understand the codebase):
- Source code files (.py, .js, .ts, .jsx, .tsx, .java, .cpp, .c, .go, .rs, .rb, .php, etc.)
- Configuration files (package.json, requirements.txt, tsconfig.json, Cargo.toml, etc.)
- Entry points and main files
- README, documentation if helpful
- Test files (only if user asks or they're essential)
## What to EXCLUDE (use your intelligence to recognize these):
**CRITICAL: Use your judgment to identify and skip ANY code that is NOT written by the project developers:**
1. **Dependencies & Third-Party Code** - ANY folder OR FILE that contains external/downloaded code:
- Common folder names: node_modules, venv, vendor, packages, site-packages, libs, lib, external
- But also recognize: third_party, deps, dependencies, sdk, SDK, extern, thirdparty
- **Dependency FILES**: minified bundles (.min.js), vendored copies, auto-generated code
- Look for clues: folders with hundreds of subfolders, version numbers in names, different coding style
- If unsure, use `read_file_preview` - dependency files often have copyright headers, "generated" comments, or minified code
2. **SDKs and Frameworks** - Pre-built code the user didn't write:
- Unity packages, Unreal plugins, game engine assets
- Platform SDKs (Android, iOS, Windows SDK files)
- Framework templates/boilerplate
3. **Build Outputs** - Generated files, not source:
- dist, build, out, .next, .nuxt, bin, obj, target, __pycache__
4. **Non-Code Files** - Images, videos, audio, archives, executables, fonts
5. **IDE/Cache** - .idea, .vscode, .vs, .cache, .tmp, logs
6. **Lock Files** - package-lock.json, yarn.lock, Gemfile.lock (too large, no value)
## How to Work
1. START by calling `list_directory` on the root folder(s) to see the project structure
2. Explore subdirectories that look relevant (src/, lib/, app/, components/, etc.)
3. If unsure about a file, use `read_file_preview` to peek at the first few lines
4. Use `check_files` or `check_directory` to select what's relevant
5. For large directories (100+ items), you'll get a summary - use `list_directory_full` if you need all items
6. When done, call `finish_selection` with a summary of what you selected
## Token Awareness
- The user will copy selected files to their clipboard for an AI chat
- Be selective - choose quality over quantity
- Focus on core logic, not every single file
- If a directory has obvious tests/examples subdirs, you can often skip those unless asked
[USER'S ADDITIONAL INSTRUCTIONS BELOW]
"""
# Tool definitions for Smart Select (Claude-compatible format)
SMART_SELECT_TOOLS = [
{
"name": "list_directory",
"description": "List contents of a directory (files and subdirectories). Returns names and types only, not file contents. For large directories (100+ items), returns a summary instead of full list. Dependencies and binary files are pre-filtered.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory path to list (absolute or relative to root folders)"
}
},
"required": ["path"]
}
},
{
"name": "list_directory_full",
"description": "List ALL contents of a large directory (bypasses the 100-item summary limit). Use sparingly - only when you need to see everything in a directory that was summarized.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory path to fully list"
}
},
"required": ["path"]
}
},
{
"name": "read_file_preview",
"description": "Read the first few lines of a file to understand what it contains. Useful for deciding if a file is relevant. Returns up to 50 lines or 2000 characters, whichever is smaller.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to preview"
}
},
"required": ["path"]
}
},
{
"name": "check_files",
"description": "Check (select) one or more files or directories in the tree. Files will be included when copying to clipboard.",
"input_schema": {
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "List of file or directory paths to check/select"
}
},
"required": ["paths"]
}
},
{
"name": "uncheck_files",
"description": "Uncheck (deselect) one or more files or directories in the tree.",
"input_schema": {
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "List of file or directory paths to uncheck/deselect"
}
},
"required": ["paths"]
}
},
{
"name": "check_directory",
"description": "Check all visible code files in a directory recursively.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory path to recursively check"
}
},
"required": ["path"]
}
},
{
"name": "uncheck_directory",
"description": "Uncheck all files in a directory recursively.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory path to recursively uncheck"
}
},
"required": ["path"]
}
},
{
"name": "get_current_selections",
"description": "Get a summary of what's currently selected (checked files count by extension).",
"input_schema": {
"type": "object",
"properties": {},
"required": []
}
},
{
"name": "finish_selection",
"description": "Complete the selection process. Call this when you're done selecting files.",
"input_schema": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "A brief summary of what was selected and why"
}
},
"required": ["summary"]
}
}
]
class SmartSelectAgent:
"""
AI Agent for Smart File Selection using Claude Haiku 4.5.
Implements an agentic loop that:
1. Takes a user prompt about what to select
2. Uses tools to explore directories and select files
3. Continues until finish_selection is called
"""
def __init__(self, api_key: str, folder_monitor_app, model: str = "claude-haiku-4-5-20251001"):
"""
Initialize the Smart Select agent.
Args:
api_key: Anthropic API key
folder_monitor_app: Reference to the FolderMonitorApp instance
model: Claude model to use (default: Claude Haiku 4.5)
"""
if not ANTHROPIC_AVAILABLE:
raise RuntimeError("Anthropic package not installed. Run: pip install anthropic>=0.40.0")
self.api_key = api_key
self.client = Anthropic(api_key=api_key)
self.app = folder_monitor_app
self.model = model
self.conversation_history = []
self.cancelled = False
def cancel(self):
"""Cancel the current operation."""
self.cancelled = True
def _is_ignored_extension(self, filename: str) -> bool:
"""Check if a file has an ignored extension."""
_, ext = os.path.splitext(filename.lower())
return ext in SMART_SELECT_IGNORED_EXTENSIONS
def _is_ignored_dir(self, dirname: str) -> bool:
"""Check if a directory name should be ignored."""
return dirname.lower() in SMART_SELECT_IGNORED_DIRS
def _is_code_extension(self, filename: str) -> bool:
"""Check if a file has a code extension."""
_, ext = os.path.splitext(filename.lower())
return ext in SMART_SELECT_CODE_EXTENSIONS
def _resolve_path(self, path: str) -> str:
"""Resolve a path that might be relative to a root folder."""
# If it's already absolute and exists, use it
if os.path.isabs(path) and os.path.exists(path):
return path
# Try to find it relative to each root folder
for root in self.app.root_folders:
full_path = os.path.join(root, path)
if os.path.exists(full_path):
return full_path
# Return as-is (might be an absolute path that doesn't exist)
return path
def execute_tool(self, tool_name: str, tool_input: dict) -> str:
"""Execute a tool and return the result as a string."""
try:
if tool_name == "list_directory":
return self._list_directory(tool_input.get("path", ""))
elif tool_name == "list_directory_full":
return self._list_directory_full(tool_input.get("path", ""))
elif tool_name == "read_file_preview":
return self._read_file_preview(tool_input.get("path", ""))
elif tool_name == "check_files":
return self._check_files(tool_input.get("paths", []))
elif tool_name == "uncheck_files":
return self._uncheck_files(tool_input.get("paths", []))
elif tool_name == "check_directory":
return self._check_directory(tool_input.get("path", ""))
elif tool_name == "uncheck_directory":
return self._uncheck_directory(tool_input.get("path", ""))
elif tool_name == "get_current_selections":
return self._get_current_selections()
elif tool_name == "finish_selection":
return self._finish_selection(tool_input.get("summary", "Selection complete"))
else:
return f"Unknown tool: {tool_name}"
except Exception as e:
return f"Error executing {tool_name}: {str(e)}"
def _list_directory(self, path: str) -> str:
"""List contents of a directory, filtering out ignored items."""
if not path:
# List all root folders
if not self.app.root_folders:
return "No root folders configured. Please add folders first."
result = "Root folders:\n"
for root in self.app.root_folders:
result += f" 📁 {root}\n"
return result
resolved_path = self._resolve_path(path)
if not os.path.exists(resolved_path):
return f"Path does not exist: {path}"
if not os.path.isdir(resolved_path):
return f"Not a directory: {path}"
try:
items = os.listdir(resolved_path)
except PermissionError:
return f"Permission denied: {path}"
except Exception as e:
return f"Error reading directory: {e}"
# Filter out ignored items
filtered_dirs = []
filtered_files = []
for item in items:
item_path = os.path.join(resolved_path, item)
if os.path.isdir(item_path):
if not self._is_ignored_dir(item):
filtered_dirs.append(item)
else:
if not self._is_ignored_extension(item):
filtered_files.append(item)
total_items = len(filtered_dirs) + len(filtered_files)
if total_items == 0:
return f"Directory is empty or contains only ignored items: {path}"
if total_items > 100:
# Summarize large directories
result = f"Large directory ({total_items} items after filtering):\n"
result += f" 📁 Subdirectories: {len(filtered_dirs)}\n"
result += f" 📄 Files: {len(filtered_files)}\n"
# Show a sample
if filtered_dirs:
result += f" Sample dirs: {', '.join(filtered_dirs[:5])}"
if len(filtered_dirs) > 5:
result += f" ... and {len(filtered_dirs) - 5} more"
result += "\n"
if filtered_files:
# Group by extension
ext_counts = {}
for f in filtered_files:
_, ext = os.path.splitext(f)
ext = ext or "(no ext)"
ext_counts[ext] = ext_counts.get(ext, 0) + 1
result += f" File types: {dict(ext_counts)}\n"
result += "\nUse check_directory to select all code files, or explore subdirectories individually."
return result
# Show full listing for smaller directories
result = f"Contents of {path}:\n"
for d in sorted(filtered_dirs):
result += f" 📁 {d}/\n"
for f in sorted(filtered_files):
is_code = self._is_code_extension(f)
marker = "✓" if is_code else " "
result += f" {marker} 📄 {f}\n"
return result
def _list_directory_full(self, path: str) -> str:
"""List ALL contents of a directory (no 100-item limit)."""
if not path:
return "Path required for list_directory_full"
resolved_path = self._resolve_path(path)
if not os.path.exists(resolved_path):
return f"Path does not exist: {path}"
if not os.path.isdir(resolved_path):
return f"Not a directory: {path}"
try:
items = os.listdir(resolved_path)
except PermissionError:
return f"Permission denied: {path}"
except Exception as e:
return f"Error reading directory: {e}"
# Filter out ignored items
filtered_dirs = []
filtered_files = []
for item in items:
item_path = os.path.join(resolved_path, item)
if os.path.isdir(item_path):
if not self._is_ignored_dir(item):
filtered_dirs.append(item)
else:
if not self._is_ignored_extension(item):
filtered_files.append(item)
# Show full listing (no limit)
result = f"Full contents of {path} ({len(filtered_dirs)} dirs, {len(filtered_files)} files):\n"
for d in sorted(filtered_dirs):
result += f" 📁 {d}/\n"
for f in sorted(filtered_files):
is_code = self._is_code_extension(f)
marker = "✓" if is_code else " "
result += f" {marker} 📄 {f}\n"
return result
def _read_file_preview(self, path: str) -> str:
"""Read the first few lines of a file to preview its contents."""
if not path:
return "Path required for read_file_preview"
resolved_path = self._resolve_path(path)
if not os.path.exists(resolved_path):
return f"File does not exist: {path}"
if os.path.isdir(resolved_path):
return f"Cannot preview a directory: {path}"
# Check file size first
try:
file_size = os.path.getsize(resolved_path)
if file_size > 1_000_000: # 1MB
return f"File too large to preview ({file_size:,} bytes): {path}"
except:
pass
try:
# Try reading with UTF-8
with open(resolved_path, 'r', encoding='utf-8', errors='replace') as f:
lines = []
total_chars = 0
max_lines = 50
max_chars = 2000
for line in f:
if len(lines) >= max_lines or total_chars >= max_chars:
break
lines.append(line.rstrip('\n\r'))
total_chars += len(line)
preview = '\n'.join(lines)
if total_chars >= max_chars or len(lines) >= max_lines:
preview += f"\n... (truncated, showing first {len(lines)} lines)"
return f"Preview of {os.path.basename(path)}:\n```\n{preview}\n```"
except Exception as e:
return f"Error reading file: {e}"
def _check_files(self, paths: list) -> str:
"""Check (select) files in the tree."""
if not paths:
return "No paths provided"
checked_count = 0
errors = []
for path in paths:
resolved_path = self._resolve_path(path)
if resolved_path in self.app.folder_tree_data:
if not self.app.folder_tree_data[resolved_path]['checked']:
self.app.folder_tree_data[resolved_path]['checked'] = True
checked_count += 1
# Update tree display
self._update_tree_item_display(resolved_path)
else:
errors.append(f"Not in tree: {path}")
result = f"Checked {checked_count} item(s)"
if errors:
result += f"\nCould not check: {', '.join(errors[:5])}"
if len(errors) > 5:
result += f" ... and {len(errors) - 5} more"
return result
def _uncheck_files(self, paths: list) -> str:
"""Uncheck (deselect) files in the tree."""
if not paths:
return "No paths provided"
unchecked_count = 0
for path in paths:
resolved_path = self._resolve_path(path)
if resolved_path in self.app.folder_tree_data:
if self.app.folder_tree_data[resolved_path]['checked']:
self.app.folder_tree_data[resolved_path]['checked'] = False
unchecked_count += 1
self._update_tree_item_display(resolved_path)
return f"Unchecked {unchecked_count} item(s)"
def _check_directory(self, path: str) -> str:
"""Recursively check all code files in a directory."""
resolved_path = self._resolve_path(path)
if not os.path.exists(resolved_path):
return f"Path does not exist: {path}"
checked_count = 0
# Check all items in folder_tree_data that are under this path
for item_path, info in self.app.folder_tree_data.items():
if item_path.startswith(resolved_path):
if not info['is_dir'] and self._is_code_extension(item_path):
if not info['checked']:
info['checked'] = True
checked_count += 1
self._update_tree_item_display(item_path)
return f"Recursively checked {checked_count} code file(s) in {path}"
def _uncheck_directory(self, path: str) -> str:
"""Recursively uncheck all files in a directory."""
resolved_path = self._resolve_path(path)
unchecked_count = 0
for item_path, info in self.app.folder_tree_data.items():
if item_path.startswith(resolved_path):
if info['checked']:
info['checked'] = False
unchecked_count += 1
self._update_tree_item_display(item_path)
return f"Recursively unchecked {unchecked_count} item(s) in {path}"
def _get_current_selections(self) -> str:
"""Get summary of current selections."""
checked_files = []
checked_dirs = []
for path, info in self.app.folder_tree_data.items():
if info['checked']:
if info['is_dir']:
checked_dirs.append(path)
else:
checked_files.append(path)
if not checked_files and not checked_dirs:
return "Nothing is currently selected."
result = f"Currently selected: {len(checked_files)} files, {len(checked_dirs)} directories\n"
# Group files by extension
ext_counts = {}
for f in checked_files:
_, ext = os.path.splitext(f)
ext = ext or "(no ext)"
ext_counts[ext] = ext_counts.get(ext, 0) + 1
if ext_counts:
result += f"By extension: {dict(ext_counts)}\n"
# Show sample of selected files
if checked_files:
result += f"Sample files: {', '.join([os.path.basename(f) for f in checked_files[:10]])}"
if len(checked_files) > 10:
result += f" ... and {len(checked_files) - 10} more"
return result
def _finish_selection(self, summary: str) -> str:
"""Finish the selection process."""