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.
- codemaster_cli-2.2.0.dist-info/METADATA +645 -0
- codemaster_cli-2.2.0.dist-info/RECORD +170 -0
- codemaster_cli-2.2.0.dist-info/WHEEL +4 -0
- codemaster_cli-2.2.0.dist-info/entry_points.txt +3 -0
- vibe/__init__.py +6 -0
- vibe/acp/__init__.py +0 -0
- vibe/acp/acp_agent_loop.py +746 -0
- vibe/acp/entrypoint.py +81 -0
- vibe/acp/tools/__init__.py +0 -0
- vibe/acp/tools/base.py +100 -0
- vibe/acp/tools/builtins/bash.py +134 -0
- vibe/acp/tools/builtins/read_file.py +54 -0
- vibe/acp/tools/builtins/search_replace.py +129 -0
- vibe/acp/tools/builtins/todo.py +65 -0
- vibe/acp/tools/builtins/write_file.py +98 -0
- vibe/acp/tools/session_update.py +118 -0
- vibe/acp/utils.py +213 -0
- vibe/cli/__init__.py +0 -0
- vibe/cli/autocompletion/__init__.py +0 -0
- vibe/cli/autocompletion/base.py +22 -0
- vibe/cli/autocompletion/path_completion.py +177 -0
- vibe/cli/autocompletion/slash_command.py +99 -0
- vibe/cli/cli.py +188 -0
- vibe/cli/clipboard.py +69 -0
- vibe/cli/commands.py +116 -0
- vibe/cli/entrypoint.py +163 -0
- vibe/cli/history_manager.py +91 -0
- vibe/cli/plan_offer/adapters/http_whoami_gateway.py +67 -0
- vibe/cli/plan_offer/decide_plan_offer.py +87 -0
- vibe/cli/plan_offer/ports/whoami_gateway.py +23 -0
- vibe/cli/terminal_setup.py +323 -0
- vibe/cli/textual_ui/__init__.py +0 -0
- vibe/cli/textual_ui/ansi_markdown.py +58 -0
- vibe/cli/textual_ui/app.py +1546 -0
- vibe/cli/textual_ui/app.tcss +1020 -0
- vibe/cli/textual_ui/external_editor.py +32 -0
- vibe/cli/textual_ui/handlers/__init__.py +5 -0
- vibe/cli/textual_ui/handlers/event_handler.py +147 -0
- vibe/cli/textual_ui/widgets/__init__.py +0 -0
- vibe/cli/textual_ui/widgets/approval_app.py +192 -0
- vibe/cli/textual_ui/widgets/banner/banner.py +85 -0
- vibe/cli/textual_ui/widgets/banner/petit_chat.py +195 -0
- vibe/cli/textual_ui/widgets/braille_renderer.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- vibe/cli/textual_ui/widgets/chat_input/body.py +214 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- vibe/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- vibe/cli/textual_ui/widgets/chat_input/container.py +195 -0
- vibe/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- vibe/cli/textual_ui/widgets/compact.py +41 -0
- vibe/cli/textual_ui/widgets/config_app.py +171 -0
- vibe/cli/textual_ui/widgets/context_progress.py +30 -0
- vibe/cli/textual_ui/widgets/load_more.py +43 -0
- vibe/cli/textual_ui/widgets/loading.py +201 -0
- vibe/cli/textual_ui/widgets/messages.py +277 -0
- vibe/cli/textual_ui/widgets/no_markup_static.py +11 -0
- vibe/cli/textual_ui/widgets/path_display.py +28 -0
- vibe/cli/textual_ui/widgets/proxy_setup_app.py +127 -0
- vibe/cli/textual_ui/widgets/question_app.py +496 -0
- vibe/cli/textual_ui/widgets/spinner.py +194 -0
- vibe/cli/textual_ui/widgets/status_message.py +76 -0
- vibe/cli/textual_ui/widgets/teleport_message.py +31 -0
- vibe/cli/textual_ui/widgets/tool_widgets.py +371 -0
- vibe/cli/textual_ui/widgets/tools.py +201 -0
- vibe/cli/textual_ui/windowing/__init__.py +29 -0
- vibe/cli/textual_ui/windowing/history.py +105 -0
- vibe/cli/textual_ui/windowing/history_windowing.py +71 -0
- vibe/cli/textual_ui/windowing/state.py +105 -0
- vibe/cli/update_notifier/__init__.py +47 -0
- vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- vibe/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- vibe/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- vibe/cli/update_notifier/ports/update_cache_repository.py +16 -0
- vibe/cli/update_notifier/ports/update_gateway.py +53 -0
- vibe/cli/update_notifier/update.py +139 -0
- vibe/cli/update_notifier/whats_new.py +49 -0
- vibe/core/__init__.py +5 -0
- vibe/core/agent_loop.py +1075 -0
- vibe/core/agents/__init__.py +31 -0
- vibe/core/agents/manager.py +165 -0
- vibe/core/agents/models.py +122 -0
- vibe/core/auth/__init__.py +6 -0
- vibe/core/auth/crypto.py +137 -0
- vibe/core/auth/github.py +178 -0
- vibe/core/autocompletion/__init__.py +0 -0
- vibe/core/autocompletion/completers.py +257 -0
- vibe/core/autocompletion/file_indexer/__init__.py +10 -0
- vibe/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- vibe/core/autocompletion/file_indexer/indexer.py +179 -0
- vibe/core/autocompletion/file_indexer/store.py +169 -0
- vibe/core/autocompletion/file_indexer/watcher.py +71 -0
- vibe/core/autocompletion/fuzzy.py +189 -0
- vibe/core/autocompletion/path_prompt.py +108 -0
- vibe/core/autocompletion/path_prompt_adapter.py +149 -0
- vibe/core/config.py +673 -0
- vibe/core/config_PATCH_INSTRUCTIONS.md +77 -0
- vibe/core/llm/__init__.py +0 -0
- vibe/core/llm/backend/anthropic.py +630 -0
- vibe/core/llm/backend/base.py +38 -0
- vibe/core/llm/backend/factory.py +7 -0
- vibe/core/llm/backend/generic.py +425 -0
- vibe/core/llm/backend/mistral.py +381 -0
- vibe/core/llm/backend/vertex.py +115 -0
- vibe/core/llm/exceptions.py +195 -0
- vibe/core/llm/format.py +184 -0
- vibe/core/llm/message_utils.py +24 -0
- vibe/core/llm/types.py +120 -0
- vibe/core/middleware.py +209 -0
- vibe/core/output_formatters.py +85 -0
- vibe/core/paths/__init__.py +0 -0
- vibe/core/paths/config_paths.py +68 -0
- vibe/core/paths/global_paths.py +40 -0
- vibe/core/programmatic.py +56 -0
- vibe/core/prompts/__init__.py +32 -0
- vibe/core/prompts/cli.md +111 -0
- vibe/core/prompts/compact.md +48 -0
- vibe/core/prompts/dangerous_directory.md +5 -0
- vibe/core/prompts/explore.md +50 -0
- vibe/core/prompts/project_context.md +8 -0
- vibe/core/prompts/tests.md +1 -0
- vibe/core/proxy_setup.py +65 -0
- vibe/core/session/session_loader.py +222 -0
- vibe/core/session/session_logger.py +318 -0
- vibe/core/session/session_migration.py +41 -0
- vibe/core/skills/__init__.py +7 -0
- vibe/core/skills/manager.py +132 -0
- vibe/core/skills/models.py +92 -0
- vibe/core/skills/parser.py +39 -0
- vibe/core/system_prompt.py +466 -0
- vibe/core/telemetry/__init__.py +0 -0
- vibe/core/telemetry/send.py +185 -0
- vibe/core/teleport/errors.py +9 -0
- vibe/core/teleport/git.py +196 -0
- vibe/core/teleport/nuage.py +180 -0
- vibe/core/teleport/teleport.py +208 -0
- vibe/core/teleport/types.py +54 -0
- vibe/core/tools/base.py +336 -0
- vibe/core/tools/builtins/ask_user_question.py +134 -0
- vibe/core/tools/builtins/bash.py +357 -0
- vibe/core/tools/builtins/grep.py +310 -0
- vibe/core/tools/builtins/prompts/__init__.py +0 -0
- vibe/core/tools/builtins/prompts/ask_user_question.md +84 -0
- vibe/core/tools/builtins/prompts/bash.md +73 -0
- vibe/core/tools/builtins/prompts/grep.md +4 -0
- vibe/core/tools/builtins/prompts/read_file.md +13 -0
- vibe/core/tools/builtins/prompts/search_replace.md +43 -0
- vibe/core/tools/builtins/prompts/task.md +24 -0
- vibe/core/tools/builtins/prompts/todo.md +199 -0
- vibe/core/tools/builtins/prompts/write_file.md +42 -0
- vibe/core/tools/builtins/read_file.py +222 -0
- vibe/core/tools/builtins/search_replace.py +456 -0
- vibe/core/tools/builtins/task.py +154 -0
- vibe/core/tools/builtins/todo.py +134 -0
- vibe/core/tools/builtins/write_file.py +160 -0
- vibe/core/tools/manager.py +341 -0
- vibe/core/tools/mcp.py +397 -0
- vibe/core/tools/ui.py +68 -0
- vibe/core/trusted_folders.py +86 -0
- vibe/core/types.py +405 -0
- vibe/core/utils.py +396 -0
- vibe/setup/onboarding/__init__.py +39 -0
- vibe/setup/onboarding/base.py +14 -0
- vibe/setup/onboarding/onboarding.tcss +134 -0
- vibe/setup/onboarding/screens/__init__.py +5 -0
- vibe/setup/onboarding/screens/api_key.py +200 -0
- vibe/setup/onboarding/screens/provider_selection.py +87 -0
- vibe/setup/onboarding/screens/welcome.py +136 -0
- vibe/setup/trusted_folders/trust_folder_dialog.py +180 -0
- vibe/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- 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
|