codemaster-cli 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. codemaster_cli-2.2.0.dist-info/METADATA +645 -0
  2. codemaster_cli-2.2.0.dist-info/RECORD +170 -0
  3. codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
  4. codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
  5. vibe/__init__.py +6 -0
  6. vibe/acp/__init__.py +0 -0
  7. vibe/acp/acp_agent_loop.py +746 -0
  8. vibe/acp/entrypoint.py +81 -0
  9. vibe/acp/tools/__init__.py +0 -0
  10. vibe/acp/tools/base.py +100 -0
  11. vibe/acp/tools/builtins/bash.py +134 -0
  12. vibe/acp/tools/builtins/read_file.py +54 -0
  13. vibe/acp/tools/builtins/search_replace.py +129 -0
  14. vibe/acp/tools/builtins/todo.py +65 -0
  15. vibe/acp/tools/builtins/write_file.py +98 -0
  16. vibe/acp/tools/session_update.py +118 -0
  17. vibe/acp/utils.py +213 -0
  18. vibe/cli/__init__.py +0 -0
  19. vibe/cli/autocompletion/__init__.py +0 -0
  20. vibe/cli/autocompletion/base.py +22 -0
  21. vibe/cli/autocompletion/path_completion.py +177 -0
  22. vibe/cli/autocompletion/slash_command.py +99 -0
  23. vibe/cli/cli.py +188 -0
  24. vibe/cli/clipboard.py +69 -0
  25. vibe/cli/commands.py +116 -0
  26. vibe/cli/entrypoint.py +163 -0
  27. vibe/cli/history_manager.py +91 -0
  28. vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
  29. vibe/cli/plan_offer/decide_plan_offer.py +87 -0
  30. vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
  31. vibe/cli/terminal_setup.py +323 -0
  32. vibe/cli/textual_ui/__init__.py +0 -0
  33. vibe/cli/textual_ui/ansi_markdown.py +58 -0
  34. vibe/cli/textual_ui/app.py +1546 -0
  35. vibe/cli/textual_ui/app.tcss +1020 -0
  36. vibe/cli/textual_ui/external_editor.py +32 -0
  37. vibe/cli/textual_ui/handlers/__init__.py +5 -0
  38. vibe/cli/textual_ui/handlers/event_handler.py +147 -0
  39. vibe/cli/textual_ui/widgets/__init__.py +0 -0
  40. vibe/cli/textual_ui/widgets/approval_app.py +192 -0
  41. vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
  42. vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
  43. vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
  44. vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  45. vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
  46. vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  47. vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  48. vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
  49. vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  50. vibe/cli/textual_ui/widgets/compact.py +41 -0
  51. vibe/cli/textual_ui/widgets/config_app.py +171 -0
  52. vibe/cli/textual_ui/widgets/context_progress.py +30 -0
  53. vibe/cli/textual_ui/widgets/load_more.py +43 -0
  54. vibe/cli/textual_ui/widgets/loading.py +201 -0
  55. vibe/cli/textual_ui/widgets/messages.py +277 -0
  56. vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
  57. vibe/cli/textual_ui/widgets/path_display.py +28 -0
  58. vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
  59. vibe/cli/textual_ui/widgets/question_app.py +496 -0
  60. vibe/cli/textual_ui/widgets/spinner.py +194 -0
  61. vibe/cli/textual_ui/widgets/status_message.py +76 -0
  62. vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
  63. vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
  64. vibe/cli/textual_ui/widgets/tools.py +201 -0
  65. vibe/cli/textual_ui/windowing/__init__.py +29 -0
  66. vibe/cli/textual_ui/windowing/history.py +105 -0
  67. vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
  68. vibe/cli/textual_ui/windowing/state.py +105 -0
  69. vibe/cli/update_notifier/__init__.py +47 -0
  70. vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  71. vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  72. vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  73. vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
  74. vibe/cli/update_notifier/ports/update_gateway.py +53 -0
  75. vibe/cli/update_notifier/update.py +139 -0
  76. vibe/cli/update_notifier/whats_new.py +49 -0
  77. vibe/core/__init__.py +5 -0
  78. vibe/core/agent_loop.py +1075 -0
  79. vibe/core/agents/__init__.py +31 -0
  80. vibe/core/agents/manager.py +165 -0
  81. vibe/core/agents/models.py +122 -0
  82. vibe/core/auth/__init__.py +6 -0
  83. vibe/core/auth/crypto.py +137 -0
  84. vibe/core/auth/github.py +178 -0
  85. vibe/core/autocompletion/__init__.py +0 -0
  86. vibe/core/autocompletion/completers.py +257 -0
  87. vibe/core/autocompletion/file_indexer/__init__.py +10 -0
  88. vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  89. vibe/core/autocompletion/file_indexer/indexer.py +179 -0
  90. vibe/core/autocompletion/file_indexer/store.py +169 -0
  91. vibe/core/autocompletion/file_indexer/watcher.py +71 -0
  92. vibe/core/autocompletion/fuzzy.py +189 -0
  93. vibe/core/autocompletion/path_prompt.py +108 -0
  94. vibe/core/autocompletion/path_prompt_adapter.py +149 -0
  95. vibe/core/config.py +673 -0
  96. vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
  97. vibe/core/llm/__init__.py +0 -0
  98. vibe/core/llm/backend/anthropic.py +630 -0
  99. vibe/core/llm/backend/base.py +38 -0
  100. vibe/core/llm/backend/factory.py +7 -0
  101. vibe/core/llm/backend/generic.py +425 -0
  102. vibe/core/llm/backend/mistral.py +381 -0
  103. vibe/core/llm/backend/vertex.py +115 -0
  104. vibe/core/llm/exceptions.py +195 -0
  105. vibe/core/llm/format.py +184 -0
  106. vibe/core/llm/message_utils.py +24 -0
  107. vibe/core/llm/types.py +120 -0
  108. vibe/core/middleware.py +209 -0
  109. vibe/core/output_formatters.py +85 -0
  110. vibe/core/paths/__init__.py +0 -0
  111. vibe/core/paths/config_paths.py +68 -0
  112. vibe/core/paths/global_paths.py +40 -0
  113. vibe/core/programmatic.py +56 -0
  114. vibe/core/prompts/__init__.py +32 -0
  115. vibe/core/prompts/cli.md +111 -0
  116. vibe/core/prompts/compact.md +48 -0
  117. vibe/core/prompts/dangerous_directory.md +5 -0
  118. vibe/core/prompts/explore.md +50 -0
  119. vibe/core/prompts/project_context.md +8 -0
  120. vibe/core/prompts/tests.md +1 -0
  121. vibe/core/proxy_setup.py +65 -0
  122. vibe/core/session/session_loader.py +222 -0
  123. vibe/core/session/session_logger.py +318 -0
  124. vibe/core/session/session_migration.py +41 -0
  125. vibe/core/skills/__init__.py +7 -0
  126. vibe/core/skills/manager.py +132 -0
  127. vibe/core/skills/models.py +92 -0
  128. vibe/core/skills/parser.py +39 -0
  129. vibe/core/system_prompt.py +466 -0
  130. vibe/core/telemetry/__init__.py +0 -0
  131. vibe/core/telemetry/send.py +185 -0
  132. vibe/core/teleport/errors.py +9 -0
  133. vibe/core/teleport/git.py +196 -0
  134. vibe/core/teleport/nuage.py +180 -0
  135. vibe/core/teleport/teleport.py +208 -0
  136. vibe/core/teleport/types.py +54 -0
  137. vibe/core/tools/base.py +336 -0
  138. vibe/core/tools/builtins/ask_user_question.py +134 -0
  139. vibe/core/tools/builtins/bash.py +357 -0
  140. vibe/core/tools/builtins/grep.py +310 -0
  141. vibe/core/tools/builtins/prompts/__init__.py +0 -0
  142. vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
  143. vibe/core/tools/builtins/prompts/bash.md +73 -0
  144. vibe/core/tools/builtins/prompts/grep.md +4 -0
  145. vibe/core/tools/builtins/prompts/read_file.md +13 -0
  146. vibe/core/tools/builtins/prompts/search_replace.md +43 -0
  147. vibe/core/tools/builtins/prompts/task.md +24 -0
  148. vibe/core/tools/builtins/prompts/todo.md +199 -0
  149. vibe/core/tools/builtins/prompts/write_file.md +42 -0
  150. vibe/core/tools/builtins/read_file.py +222 -0
  151. vibe/core/tools/builtins/search_replace.py +456 -0
  152. vibe/core/tools/builtins/task.py +154 -0
  153. vibe/core/tools/builtins/todo.py +134 -0
  154. vibe/core/tools/builtins/write_file.py +160 -0
  155. vibe/core/tools/manager.py +341 -0
  156. vibe/core/tools/mcp.py +397 -0
  157. vibe/core/tools/ui.py +68 -0
  158. vibe/core/trusted_folders.py +86 -0
  159. vibe/core/types.py +405 -0
  160. vibe/core/utils.py +396 -0
  161. vibe/setup/onboarding/__init__.py +39 -0
  162. vibe/setup/onboarding/base.py +14 -0
  163. vibe/setup/onboarding/onboarding.tcss +134 -0
  164. vibe/setup/onboarding/screens/__init__.py +5 -0
  165. vibe/setup/onboarding/screens/api_key.py +200 -0
  166. vibe/setup/onboarding/screens/provider_selection.py +87 -0
  167. vibe/setup/onboarding/screens/welcome.py +136 -0
  168. vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
  169. vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  170. vibe/whats_new.md +5 -0
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from vibe.core.autocompletion.file_indexer.ignore_rules import IgnoreRules
9
+ from vibe.core.autocompletion.file_indexer.watcher import Change
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class FileIndexStats:
14
+ rebuilds: int = 0
15
+ incremental_updates: int = 0
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class IndexEntry:
20
+ rel: str
21
+ rel_lower: str
22
+ name: str
23
+ path: Path
24
+ is_dir: bool
25
+
26
+
27
+ class FileIndexStore:
28
+ def __init__(
29
+ self,
30
+ ignore_rules: IgnoreRules,
31
+ stats: FileIndexStats,
32
+ mass_change_threshold: int = 200,
33
+ ) -> None:
34
+ self._ignore_rules = ignore_rules
35
+ self._stats = stats
36
+ self._mass_change_threshold = mass_change_threshold
37
+ self._entries_by_rel: dict[str, IndexEntry] = {}
38
+ self._ordered_entries: list[IndexEntry] | None = None
39
+ self._root: Path | None = None
40
+
41
+ @property
42
+ def root(self) -> Path | None:
43
+ return self._root
44
+
45
+ def clear(self) -> None:
46
+ self._entries_by_rel.clear()
47
+ self._ordered_entries = None
48
+ self._root = None
49
+
50
+ def rebuild(
51
+ self, root: Path, should_cancel: Callable[[], bool] | None = None
52
+ ) -> None:
53
+ resolved_root = root.resolve()
54
+ self._ignore_rules.ensure_for_root(resolved_root)
55
+ entries = self._walk_directory(resolved_root, cancel_check=should_cancel)
56
+ self._entries_by_rel = {entry.rel: entry for entry in entries}
57
+ self._ordered_entries = entries
58
+ self._root = resolved_root
59
+ self._stats.rebuilds += 1
60
+
61
+ def snapshot(self) -> list[IndexEntry]:
62
+ if not self._entries_by_rel:
63
+ return []
64
+
65
+ if self._ordered_entries is None:
66
+ self._ordered_entries = sorted(
67
+ self._entries_by_rel.values(), key=lambda entry: entry.rel
68
+ )
69
+
70
+ return list(self._ordered_entries)
71
+
72
+ def apply_changes(self, changes: list[tuple[Change, Path]]) -> None:
73
+ if self._root is None:
74
+ return
75
+
76
+ if len(changes) > self._mass_change_threshold:
77
+ self.rebuild(self._root)
78
+ return
79
+
80
+ modified = False
81
+ for change, path in changes:
82
+ try:
83
+ rel_str = path.relative_to(self._root).as_posix()
84
+ except ValueError:
85
+ continue
86
+
87
+ if not rel_str:
88
+ continue
89
+
90
+ if change is Change.deleted:
91
+ if self._remove_entry(rel_str):
92
+ modified = True
93
+ continue
94
+
95
+ if not path.exists():
96
+ continue
97
+
98
+ if path.is_dir():
99
+ dir_entry = self._create_entry(rel_str, path.name, path, True)
100
+ if dir_entry:
101
+ self._entries_by_rel[rel_str] = dir_entry
102
+ modified = True
103
+ for entry in self._walk_directory(path, rel_str):
104
+ self._entries_by_rel[entry.rel] = entry
105
+ modified = True
106
+ else:
107
+ file_entry = self._create_entry(rel_str, path.name, path, False)
108
+ if file_entry:
109
+ self._entries_by_rel[file_entry.rel] = file_entry
110
+ modified = True
111
+
112
+ if modified:
113
+ self._ordered_entries = None
114
+ self._stats.incremental_updates += 1
115
+
116
+ def _create_entry(
117
+ self, rel_str: str, name: str, path: Path, is_dir: bool
118
+ ) -> IndexEntry | None:
119
+ if self._ignore_rules.should_ignore(rel_str, name, is_dir):
120
+ return None
121
+ return IndexEntry(
122
+ rel=rel_str, rel_lower=rel_str.lower(), name=name, path=path, is_dir=is_dir
123
+ )
124
+
125
+ def _walk_directory(
126
+ self,
127
+ directory: Path,
128
+ rel_prefix: str = "",
129
+ cancel_check: Callable[[], bool] | None = None,
130
+ ) -> list[IndexEntry]:
131
+ results: list[IndexEntry] = []
132
+ try:
133
+ with os.scandir(directory) as iterator:
134
+ for entry in iterator:
135
+ if cancel_check and cancel_check():
136
+ break
137
+
138
+ is_dir = entry.is_dir(follow_symlinks=False)
139
+ name = entry.name
140
+ rel_str = f"{rel_prefix}/{name}" if rel_prefix else name
141
+ path = Path(entry.path)
142
+
143
+ index_entry = self._create_entry(rel_str, name, path, is_dir)
144
+ if not index_entry:
145
+ continue
146
+
147
+ results.append(index_entry)
148
+
149
+ if is_dir:
150
+ results.extend(
151
+ self._walk_directory(path, rel_str, cancel_check)
152
+ )
153
+ except (PermissionError, OSError):
154
+ pass
155
+
156
+ return results
157
+
158
+ def _remove_entry(self, rel_str: str) -> bool:
159
+ entry = self._entries_by_rel.pop(rel_str, None)
160
+ if not entry:
161
+ return False
162
+
163
+ if entry.is_dir:
164
+ prefix = f"{rel_str}/"
165
+ to_remove = [key for key in self._entries_by_rel if key.startswith(prefix)]
166
+ for key in to_remove:
167
+ self._entries_by_rel.pop(key, None)
168
+
169
+ return True
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable
4
+ from pathlib import Path
5
+ from threading import Event, Thread
6
+
7
+ from watchfiles import Change, watch
8
+
9
+
10
+ class WatchController:
11
+ def __init__(
12
+ self, on_changes: Callable[[Path, Iterable[tuple[Change, str]]], None]
13
+ ) -> None:
14
+ self._on_changes = on_changes
15
+ self._thread: Thread | None = None
16
+ self._stop_event: Event | None = None
17
+ self._ready_event: Event | None = None
18
+ self._root: Path | None = None
19
+
20
+ def start(self, root: Path) -> None:
21
+ resolved_root = root.resolve()
22
+ if self._thread and self._thread.is_alive() and self._root == resolved_root:
23
+ return
24
+
25
+ self.stop()
26
+
27
+ stop_event = Event()
28
+ ready_event = Event()
29
+ thread = Thread(
30
+ target=self._watch_loop,
31
+ args=(resolved_root, stop_event, ready_event),
32
+ name="file-indexer-watch",
33
+ daemon=True,
34
+ )
35
+
36
+ self._thread = thread
37
+ self._stop_event = stop_event
38
+ self._ready_event = ready_event
39
+ self._root = resolved_root
40
+
41
+ thread.start()
42
+ ready_event.wait(timeout=0.5)
43
+
44
+ def stop(self) -> None:
45
+ thread = self._thread
46
+ if self._stop_event:
47
+ self._stop_event.set()
48
+ self._thread = None
49
+ self._stop_event = None
50
+ self._ready_event = None
51
+ self._root = None
52
+
53
+ if thread and thread.is_alive():
54
+ thread.join(timeout=1)
55
+
56
+ def _watch_loop(self, root: Path, stop_event: Event, ready_event: Event) -> None:
57
+ try:
58
+ watcher = watch(
59
+ str(root), stop_event=stop_event, step=200, yield_on_timeout=True
60
+ )
61
+ ready_event.set()
62
+ for changes in watcher:
63
+ if not ready_event.is_set():
64
+ ready_event.set()
65
+ if stop_event.is_set():
66
+ break
67
+ if not changes:
68
+ continue
69
+ self._on_changes(root, changes)
70
+ except Exception:
71
+ ready_event.set()
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ PREFIX_MULTIPLIER = 2.0
6
+ WORD_BOUNDARY_MULTIPLIER = 1.8
7
+ CONSECUTIVE_MULTIPLIER = 1.3
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class MatchResult:
12
+ matched: bool
13
+ score: float
14
+ matched_indices: tuple[int, ...]
15
+
16
+
17
+ def fuzzy_match(pattern: str, text: str, text_lower: str | None = None) -> MatchResult:
18
+ if not pattern:
19
+ return MatchResult(matched=True, score=0.0, matched_indices=())
20
+
21
+ if text_lower is None:
22
+ text_lower = text.lower()
23
+ return _find_best_match(pattern, pattern.lower(), text_lower, text)
24
+
25
+
26
+ def _find_best_match(
27
+ pattern_original: str, pattern_lower: str, text_lower: str, text_original: str
28
+ ) -> MatchResult:
29
+ if len(pattern_lower) > len(text_lower):
30
+ return MatchResult(matched=False, score=0.0, matched_indices=())
31
+
32
+ if text_lower.startswith(pattern_lower):
33
+ indices = tuple(range(len(pattern_lower)))
34
+ score = _calculate_score(
35
+ pattern_original, pattern_lower, text_lower, indices, text_original
36
+ )
37
+ return MatchResult(
38
+ matched=True, score=score * PREFIX_MULTIPLIER, matched_indices=indices
39
+ )
40
+
41
+ best_score = -1.0
42
+ best_indices: tuple[int, ...] = ()
43
+
44
+ for matcher in (
45
+ _try_word_boundary_match,
46
+ _try_consecutive_match,
47
+ _try_subsequence_match,
48
+ ):
49
+ match = matcher(pattern_original, pattern_lower, text_lower, text_original)
50
+ if match.matched and match.score > best_score:
51
+ best_score = match.score
52
+ best_indices = match.matched_indices
53
+
54
+ if best_score >= 0:
55
+ return MatchResult(matched=True, score=best_score, matched_indices=best_indices)
56
+
57
+ return MatchResult(matched=False, score=0.0, matched_indices=())
58
+
59
+
60
+ def _try_word_boundary_match(
61
+ pattern_original: str, pattern: str, text_lower: str, text_original: str
62
+ ) -> MatchResult:
63
+ indices: list[int] = []
64
+ pattern_idx = 0
65
+
66
+ for i, char in enumerate(text_lower):
67
+ if pattern_idx >= len(pattern):
68
+ break
69
+
70
+ is_boundary = (
71
+ i == 0
72
+ or text_lower[i - 1] in "/-_."
73
+ or (text_original[i].isupper() and not text_original[i - 1].isupper())
74
+ )
75
+
76
+ if char == pattern[pattern_idx]:
77
+ if is_boundary or (indices and i == indices[-1] + 1) or not indices:
78
+ indices.append(i)
79
+ pattern_idx += 1
80
+
81
+ if pattern_idx == len(pattern):
82
+ score = _calculate_score(
83
+ pattern_original, pattern, text_lower, tuple(indices), text_original
84
+ )
85
+ return MatchResult(
86
+ matched=True,
87
+ score=score * WORD_BOUNDARY_MULTIPLIER,
88
+ matched_indices=tuple(indices),
89
+ )
90
+
91
+ return MatchResult(matched=False, score=0.0, matched_indices=())
92
+
93
+
94
+ def _try_consecutive_match(
95
+ pattern_original: str, pattern: str, text_lower: str, text_original: str
96
+ ) -> MatchResult:
97
+ indices: list[int] = []
98
+ pattern_idx = 0
99
+
100
+ for i, char in enumerate(text_lower):
101
+ if pattern_idx >= len(pattern):
102
+ break
103
+
104
+ if char == pattern[pattern_idx]:
105
+ indices.append(i)
106
+ pattern_idx += 1
107
+ elif indices:
108
+ indices.clear()
109
+ pattern_idx = 0
110
+
111
+ if pattern_idx == len(pattern):
112
+ score = _calculate_score(
113
+ pattern_original, pattern, text_lower, tuple(indices), text_original
114
+ )
115
+ return MatchResult(
116
+ matched=True,
117
+ score=score * CONSECUTIVE_MULTIPLIER,
118
+ matched_indices=tuple(indices),
119
+ )
120
+
121
+ return MatchResult(matched=False, score=0.0, matched_indices=())
122
+
123
+
124
+ def _try_subsequence_match(
125
+ pattern_original: str, pattern: str, text_lower: str, text_original: str
126
+ ) -> MatchResult:
127
+ indices: list[int] = []
128
+ pattern_idx = 0
129
+
130
+ for i, char in enumerate(text_lower):
131
+ if pattern_idx >= len(pattern):
132
+ break
133
+ if char == pattern[pattern_idx]:
134
+ indices.append(i)
135
+ pattern_idx += 1
136
+
137
+ if pattern_idx == len(pattern):
138
+ score = _calculate_score(
139
+ pattern_original, pattern, text_lower, tuple(indices), text_original
140
+ )
141
+ return MatchResult(matched=True, score=score, matched_indices=tuple(indices))
142
+
143
+ return MatchResult(matched=False, score=0.0, matched_indices=())
144
+
145
+
146
+ def _calculate_score(
147
+ pattern_original: str,
148
+ pattern: str,
149
+ text_lower: str,
150
+ indices: tuple[int, ...],
151
+ text_original: str,
152
+ ) -> float:
153
+ if not indices:
154
+ return 0.0
155
+
156
+ base_score = 100.0
157
+ if indices[0] == 0:
158
+ base_score += 50.0
159
+ else:
160
+ base_score -= indices[0] * 2
161
+
162
+ consecutive_bonus = sum(
163
+ 10.0 for i in range(len(indices) - 1) if indices[i + 1] == indices[i] + 1
164
+ )
165
+
166
+ boundary_bonus = 0.0
167
+ for idx in indices:
168
+ if idx == 0 or text_lower[idx - 1] in "/-_.":
169
+ boundary_bonus += 5.0
170
+ elif text_original[idx].isupper() and (
171
+ idx == 0 or not text_original[idx - 1].isupper()
172
+ ):
173
+ boundary_bonus += 3.0
174
+
175
+ case_bonus = sum(
176
+ 2.0
177
+ for i, text_idx in enumerate(indices)
178
+ if i < len(pattern_original)
179
+ and text_idx < len(text_original)
180
+ and pattern_original[i] == text_original[text_idx]
181
+ )
182
+
183
+ gap_penalty = sum(
184
+ (indices[i + 1] - indices[i] - 1) * 1.5 for i in range(len(indices) - 1)
185
+ )
186
+
187
+ return max(
188
+ 0.0, base_score + consecutive_bonus + boundary_bonus + case_bonus - gap_penalty
189
+ )
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class PathResource:
10
+ path: Path
11
+ alias: str
12
+ kind: Literal["file", "directory"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class PathPromptPayload:
17
+ display_text: str
18
+ prompt_text: str
19
+ resources: list[PathResource]
20
+
21
+
22
+ def build_path_prompt_payload(
23
+ message: str, *, base_dir: Path | None = None
24
+ ) -> PathPromptPayload:
25
+ if not message:
26
+ return PathPromptPayload(message, message, [])
27
+
28
+ resolved_base = (base_dir or Path.cwd()).resolve()
29
+ prompt_parts: list[str] = []
30
+ resources: list[PathResource] = []
31
+ pos = 0
32
+
33
+ while pos < len(message):
34
+ if _is_path_anchor(message, pos):
35
+ candidate, new_pos = _extract_candidate(message, pos + 1)
36
+ if candidate and (resource := _to_resource(candidate, resolved_base)):
37
+ resources.append(resource)
38
+ prompt_parts.append(candidate)
39
+ pos = new_pos
40
+ continue
41
+
42
+ prompt_parts.append(message[pos])
43
+ pos += 1
44
+
45
+ prompt_text = "".join(prompt_parts)
46
+ unique_resources = _dedupe_resources(resources)
47
+ return PathPromptPayload(message, prompt_text, unique_resources)
48
+
49
+
50
+ def _is_path_anchor(message: str, pos: int) -> bool:
51
+ if message[pos] != "@":
52
+ return False
53
+ if pos == 0:
54
+ return True
55
+ return not (message[pos - 1].isalnum() or message[pos - 1] == "_")
56
+
57
+
58
+ def _extract_candidate(message: str, start: int) -> tuple[str | None, int]:
59
+ if start >= len(message):
60
+ return None, start
61
+
62
+ quote = message[start]
63
+ if quote in {"'", '"'}:
64
+ end_quote = message.find(quote, start + 1)
65
+ if end_quote == -1:
66
+ return None, start
67
+ return message[start + 1 : end_quote], end_quote + 1
68
+
69
+ end = start
70
+ while end < len(message) and _is_path_char(message[end]):
71
+ end += 1
72
+
73
+ if end == start:
74
+ return None, start
75
+
76
+ return message[start:end], end
77
+
78
+
79
+ def _is_path_char(char: str) -> bool:
80
+ return char.isalnum() or char in "._/\\-()[]{}"
81
+
82
+
83
+ def _to_resource(candidate: str, base_dir: Path) -> PathResource | None:
84
+ if not candidate:
85
+ return None
86
+
87
+ candidate_path = Path(candidate)
88
+ resolved = (
89
+ candidate_path if candidate_path.is_absolute() else base_dir / candidate_path
90
+ )
91
+ resolved = resolved.resolve()
92
+
93
+ if not resolved.exists():
94
+ return None
95
+
96
+ kind = "directory" if resolved.is_dir() else "file"
97
+ return PathResource(path=resolved, alias=candidate, kind=kind)
98
+
99
+
100
+ def _dedupe_resources(resources: list[PathResource]) -> list[PathResource]:
101
+ seen: set[Path] = set()
102
+ unique: list[PathResource] = []
103
+ for resource in resources:
104
+ if resource.path in seen:
105
+ continue
106
+ seen.add(resource.path)
107
+ unique.append(resource)
108
+ return unique
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ import mimetypes
5
+ from pathlib import Path
6
+
7
+ from vibe.core.autocompletion.path_prompt import (
8
+ PathPromptPayload,
9
+ PathResource,
10
+ build_path_prompt_payload,
11
+ )
12
+
13
+ DEFAULT_MAX_EMBED_BYTES = 256 * 1024
14
+
15
+ ResourceBlock = dict[str, str | None]
16
+
17
+
18
+ def render_path_prompt(
19
+ message: str,
20
+ *,
21
+ base_dir: Path,
22
+ max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES,
23
+ ) -> str:
24
+ payload = build_path_prompt_payload(message, base_dir=base_dir)
25
+ blocks = _path_prompt_to_content_blocks(payload, max_embed_bytes=max_embed_bytes)
26
+ return _content_blocks_to_prompt_text(blocks)
27
+
28
+
29
+ def _path_prompt_to_content_blocks(
30
+ payload: PathPromptPayload, *, max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES
31
+ ) -> list[ResourceBlock]:
32
+ blocks: list[ResourceBlock] = [{"type": "text", "text": payload.prompt_text}]
33
+
34
+ for resource in payload.resources:
35
+ match resource.kind:
36
+ case "file":
37
+ embedded = _try_embed_text_resource(resource, max_embed_bytes)
38
+ if embedded:
39
+ blocks.append(embedded)
40
+ else:
41
+ blocks.append({
42
+ "type": "resource_link",
43
+ "uri": resource.path.as_uri(),
44
+ "name": resource.alias,
45
+ })
46
+ case "directory":
47
+ blocks.append({
48
+ "type": "resource_link",
49
+ "uri": resource.path.as_uri(),
50
+ "name": resource.alias,
51
+ })
52
+
53
+ return blocks
54
+
55
+
56
+ def _try_embed_text_resource(
57
+ resource: PathResource, max_embed_bytes: int | None
58
+ ) -> ResourceBlock | None:
59
+ try:
60
+ data = resource.path.read_bytes()
61
+ except OSError:
62
+ return None
63
+
64
+ if max_embed_bytes is not None and len(data) > max_embed_bytes:
65
+ return None
66
+
67
+ if not _is_probably_text(resource, data):
68
+ return None
69
+
70
+ try:
71
+ text = data.decode("utf-8")
72
+ except UnicodeDecodeError:
73
+ return None
74
+
75
+ return {"type": "resource", "uri": resource.path.as_uri(), "text": text}
76
+
77
+
78
+ def _content_blocks_to_prompt_text(blocks: Sequence[ResourceBlock]) -> str:
79
+ parts = []
80
+
81
+ for block in blocks:
82
+ block_text = _format_content_block(block)
83
+ if block_text is not None:
84
+ parts.append(block_text)
85
+
86
+ return "\n\n".join(parts)
87
+
88
+
89
+ def _format_content_block(block: ResourceBlock) -> str | None:
90
+ match block.get("type"):
91
+ case "text":
92
+ return block.get("text") or ""
93
+
94
+ case "resource":
95
+ block_content = block.get("text") or ""
96
+ fence = "```"
97
+ return f"{block.get('uri')}\n{fence}\n{block_content}\n{fence}"
98
+
99
+ case "resource_link":
100
+ fields = {
101
+ "uri": block.get("uri"),
102
+ "name": block.get("name"),
103
+ "title": block.get("title"),
104
+ "description": block.get("description"),
105
+ "mime_type": block.get("mime_type"),
106
+ "size": block.get("size"),
107
+ }
108
+ parts = [
109
+ f"{k}: {v}"
110
+ for k, v in fields.items()
111
+ if v is not None and (v or isinstance(v, (int, float)))
112
+ ]
113
+ return "\n".join(parts)
114
+
115
+ case _:
116
+ return None
117
+
118
+
119
+ BINARY_MIME_PREFIXES = (
120
+ "audio/",
121
+ "image/",
122
+ "video/",
123
+ "application/zip",
124
+ "application/x-zip-compressed",
125
+ )
126
+
127
+
128
+ def _is_probably_text(path: PathResource, data: bytes) -> bool:
129
+ mime_guess, _ = mimetypes.guess_type(path.path.name)
130
+ if mime_guess and mime_guess.startswith(BINARY_MIME_PREFIXES):
131
+ return False
132
+
133
+ if not data:
134
+ return True
135
+ if b"\x00" in data:
136
+ return False
137
+
138
+ DEL_CODE = 127
139
+ NON_PRINTABLE_MAX_PROPORTION = 0.1
140
+ NON_PRINTABLE_MAX_CODE = 31
141
+ NON_PRINTABLE_EXCEPTIONS = [9, 10, 11, 12]
142
+ non_text = sum(
143
+ 1
144
+ for b in data
145
+ if b <= NON_PRINTABLE_MAX_CODE
146
+ and b not in NON_PRINTABLE_EXCEPTIONS
147
+ or b == DEL_CODE
148
+ )
149
+ return (non_text / len(data)) < NON_PRINTABLE_MAX_PROPORTION