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,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from anyio import NamedTemporaryFile, Path as AsyncPath
|
|
12
|
+
|
|
13
|
+
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
|
|
14
|
+
from vibe.core.utils import is_windows, utc_now
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from vibe.core.agents.models import AgentProfile
|
|
18
|
+
from vibe.core.config import SessionLoggingConfig, VibeConfig
|
|
19
|
+
from vibe.core.tools.manager import ToolManager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
METADATA_FILENAME = "meta.json"
|
|
23
|
+
MESSAGES_FILENAME = "messages.jsonl"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionLogger:
|
|
27
|
+
def __init__(self, session_config: SessionLoggingConfig, session_id: str) -> None:
|
|
28
|
+
self.session_config = session_config
|
|
29
|
+
self.enabled = session_config.enabled
|
|
30
|
+
|
|
31
|
+
if not self.enabled:
|
|
32
|
+
self.save_dir: Path | None = None
|
|
33
|
+
self.session_prefix: str | None = None
|
|
34
|
+
self.session_id: str = "disabled"
|
|
35
|
+
self.session_start_time: str = "N/A"
|
|
36
|
+
self.session_dir: Path | None = None
|
|
37
|
+
self.session_metadata: SessionMetadata | None = None
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
self.save_dir = Path(session_config.save_dir)
|
|
41
|
+
self.session_prefix = session_config.session_prefix
|
|
42
|
+
self.session_id = session_id
|
|
43
|
+
self.session_start_time = utc_now().isoformat()
|
|
44
|
+
|
|
45
|
+
self.save_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self.session_dir = self.save_folder
|
|
47
|
+
self.session_metadata = self._initialize_session_metadata()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def save_folder(self) -> Path:
|
|
51
|
+
if self.save_dir is None or self.session_prefix is None:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"Cannot get session save folder when logging is disabled"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
timestamp = utc_now().strftime("%Y%m%d_%H%M%S")
|
|
57
|
+
folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
|
|
58
|
+
return self.save_dir / folder_name
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def metadata_filepath(self) -> Path:
|
|
62
|
+
if self.session_dir is None:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
"Cannot get session metadata filepath when logging is disabled"
|
|
65
|
+
)
|
|
66
|
+
return self.session_dir / METADATA_FILENAME
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def messages_filepath(self) -> Path:
|
|
70
|
+
if self.session_dir is None:
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"Cannot get session messages filepath when logging is disabled"
|
|
73
|
+
)
|
|
74
|
+
return self.session_dir / MESSAGES_FILENAME
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def git_commit(self) -> str | None:
|
|
78
|
+
try:
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["git", "rev-parse", "HEAD"],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
stdin=subprocess.DEVNULL if is_windows() else None,
|
|
83
|
+
text=True,
|
|
84
|
+
timeout=5.0,
|
|
85
|
+
)
|
|
86
|
+
if result.returncode == 0 and result.stdout:
|
|
87
|
+
return result.stdout.strip()
|
|
88
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
89
|
+
pass
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def git_branch(self) -> str | None:
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
stdin=subprocess.DEVNULL if is_windows() else None,
|
|
99
|
+
text=True,
|
|
100
|
+
timeout=5.0,
|
|
101
|
+
)
|
|
102
|
+
if result.returncode == 0 and result.stdout:
|
|
103
|
+
return result.stdout.strip()
|
|
104
|
+
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
105
|
+
pass
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def username(self) -> str:
|
|
110
|
+
try:
|
|
111
|
+
return getpass.getuser()
|
|
112
|
+
except Exception:
|
|
113
|
+
return "unknown"
|
|
114
|
+
|
|
115
|
+
def _initialize_session_metadata(self) -> SessionMetadata:
|
|
116
|
+
git_commit = self.git_commit
|
|
117
|
+
git_branch = self.git_branch
|
|
118
|
+
user_name = self.username
|
|
119
|
+
|
|
120
|
+
return SessionMetadata(
|
|
121
|
+
session_id=self.session_id,
|
|
122
|
+
start_time=self.session_start_time,
|
|
123
|
+
end_time=None,
|
|
124
|
+
git_commit=git_commit,
|
|
125
|
+
git_branch=git_branch,
|
|
126
|
+
username=user_name,
|
|
127
|
+
environment={"working_directory": str(Path.cwd())},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _get_title(self, messages: list[LLMMessage]) -> str:
|
|
131
|
+
first_user_message = None
|
|
132
|
+
for message in messages:
|
|
133
|
+
if message.role == Role.user:
|
|
134
|
+
first_user_message = message
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if first_user_message is None:
|
|
138
|
+
title = "Untitled session"
|
|
139
|
+
else:
|
|
140
|
+
MAX_TITLE_LENGTH = 50
|
|
141
|
+
text = str(first_user_message.content)
|
|
142
|
+
title = text[:MAX_TITLE_LENGTH]
|
|
143
|
+
if len(text) > MAX_TITLE_LENGTH:
|
|
144
|
+
title += "…"
|
|
145
|
+
|
|
146
|
+
return title
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
async def persist_metadata(metadata: Any, session_dir: Path) -> None:
|
|
150
|
+
temp_metadata_filepath = None
|
|
151
|
+
metadata_filepath = session_dir / METADATA_FILENAME
|
|
152
|
+
try:
|
|
153
|
+
async with NamedTemporaryFile(
|
|
154
|
+
mode="w",
|
|
155
|
+
suffix=".json.tmp",
|
|
156
|
+
dir=str(session_dir),
|
|
157
|
+
delete=False,
|
|
158
|
+
encoding="utf-8",
|
|
159
|
+
) as f:
|
|
160
|
+
temp_metadata_filepath = Path(str(f.name))
|
|
161
|
+
await f.write(json.dumps(metadata, indent=2, ensure_ascii=False))
|
|
162
|
+
await f.flush()
|
|
163
|
+
os.fsync(f.wrapped.fileno())
|
|
164
|
+
|
|
165
|
+
os.replace(temp_metadata_filepath, str(metadata_filepath))
|
|
166
|
+
except Exception as e:
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
f"Failed to persist session metadata to {metadata_filepath}: {e}"
|
|
169
|
+
) from e
|
|
170
|
+
finally:
|
|
171
|
+
if (
|
|
172
|
+
temp_metadata_filepath
|
|
173
|
+
and temp_metadata_filepath.exists()
|
|
174
|
+
and temp_metadata_filepath.is_file()
|
|
175
|
+
):
|
|
176
|
+
temp_metadata_filepath.unlink()
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
async def persist_messages(messages: list[dict], session_dir: Path) -> None:
|
|
180
|
+
messages_filepath = session_dir / "messages.jsonl"
|
|
181
|
+
try:
|
|
182
|
+
if not messages_filepath.exists():
|
|
183
|
+
messages_filepath.touch()
|
|
184
|
+
|
|
185
|
+
async with await AsyncPath(messages_filepath).open(
|
|
186
|
+
"a", encoding="utf-8"
|
|
187
|
+
) as f:
|
|
188
|
+
for message in messages:
|
|
189
|
+
await f.write(json.dumps(message, ensure_ascii=False) + "\n")
|
|
190
|
+
await f.flush()
|
|
191
|
+
os.fsync(f.wrapped.fileno())
|
|
192
|
+
except Exception as e:
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
f"Failed to persist session messages to {messages_filepath}: {e}"
|
|
195
|
+
) from e
|
|
196
|
+
|
|
197
|
+
async def save_interaction(
|
|
198
|
+
self,
|
|
199
|
+
messages: list[LLMMessage],
|
|
200
|
+
stats: AgentStats,
|
|
201
|
+
base_config: VibeConfig,
|
|
202
|
+
tool_manager: ToolManager,
|
|
203
|
+
agent_profile: AgentProfile,
|
|
204
|
+
) -> None:
|
|
205
|
+
if not self.enabled or self.session_dir is None:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if self.session_metadata is None:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# If the session directory does not exist, create it
|
|
212
|
+
try:
|
|
213
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
except OSError as e:
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
f"Failed to create session directory at {self.session_dir}: {type(e).__name__}: {e}"
|
|
217
|
+
) from e
|
|
218
|
+
|
|
219
|
+
# Read old metadata and get total_messages
|
|
220
|
+
try:
|
|
221
|
+
if self.metadata_filepath.exists():
|
|
222
|
+
async with await AsyncPath(self.metadata_filepath).open(
|
|
223
|
+
encoding="utf-8", errors="ignore"
|
|
224
|
+
) as f:
|
|
225
|
+
old_metadata = json.loads(await f.read())
|
|
226
|
+
old_total_messages = old_metadata["total_messages"]
|
|
227
|
+
else:
|
|
228
|
+
old_total_messages = 0
|
|
229
|
+
except Exception as e:
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f"Failed to read session metadata at {self.metadata_filepath}: {e}"
|
|
232
|
+
) from e
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
non_system_messages = [m for m in messages if m.role != Role.system]
|
|
236
|
+
# Append new messages
|
|
237
|
+
new_messages = non_system_messages[old_total_messages:]
|
|
238
|
+
|
|
239
|
+
if len(new_messages) == 0:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
|
|
243
|
+
await SessionLogger.persist_messages(messages_data, self.session_dir)
|
|
244
|
+
|
|
245
|
+
# If message update succeeded, write metadata
|
|
246
|
+
tools_available = [
|
|
247
|
+
{
|
|
248
|
+
"type": "function",
|
|
249
|
+
"function": {
|
|
250
|
+
"name": tool_class.get_name(),
|
|
251
|
+
"description": tool_class.description,
|
|
252
|
+
"parameters": tool_class.get_parameters(),
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
for tool_class in tool_manager.available_tools.values()
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
title = self._get_title(messages)
|
|
259
|
+
system_prompt = (
|
|
260
|
+
messages[0].model_dump()
|
|
261
|
+
if len(messages) > 0 and messages[0].role == Role.system
|
|
262
|
+
else None
|
|
263
|
+
)
|
|
264
|
+
total_messages = len(non_system_messages)
|
|
265
|
+
|
|
266
|
+
metadata_dump = {
|
|
267
|
+
**self.session_metadata.model_dump(),
|
|
268
|
+
"end_time": utc_now().isoformat(),
|
|
269
|
+
"stats": stats.model_dump(),
|
|
270
|
+
"title": title,
|
|
271
|
+
"total_messages": total_messages,
|
|
272
|
+
"tools_available": tools_available,
|
|
273
|
+
"config": base_config.model_dump(mode="json"),
|
|
274
|
+
"agent_profile": {
|
|
275
|
+
"name": agent_profile.name,
|
|
276
|
+
"overrides": agent_profile.overrides,
|
|
277
|
+
},
|
|
278
|
+
"system_prompt": system_prompt,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await SessionLogger.persist_metadata(metadata_dump, self.session_dir)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
raise RuntimeError(
|
|
284
|
+
f"Failed to save session to {self.session_dir}: {e}"
|
|
285
|
+
) from e
|
|
286
|
+
finally:
|
|
287
|
+
self.cleanup_tmp_files()
|
|
288
|
+
|
|
289
|
+
def reset_session(self, session_id: str) -> None:
|
|
290
|
+
"""Clear existing session info and setup a new session"""
|
|
291
|
+
if not self.enabled:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
self.session_id = session_id
|
|
295
|
+
self.session_start_time = utc_now().isoformat()
|
|
296
|
+
self.session_dir = self.save_folder
|
|
297
|
+
self.session_metadata = self._initialize_session_metadata()
|
|
298
|
+
|
|
299
|
+
def cleanup_tmp_files(self) -> None:
|
|
300
|
+
"""Delete temporary files created more than 5 minutes ago"""
|
|
301
|
+
if not self.enabled or not self.save_dir:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
now = utc_now()
|
|
305
|
+
ago = now - timedelta(minutes=5)
|
|
306
|
+
|
|
307
|
+
tmp_files = self.save_dir.glob("**/*.json.tmp") # Recursive search
|
|
308
|
+
|
|
309
|
+
for file_path in tmp_files:
|
|
310
|
+
if file_path.is_file():
|
|
311
|
+
try:
|
|
312
|
+
file_mtime = datetime.fromtimestamp(
|
|
313
|
+
file_path.stat().st_mtime, tz=UTC
|
|
314
|
+
)
|
|
315
|
+
if file_mtime < ago:
|
|
316
|
+
file_path.unlink()
|
|
317
|
+
except Exception:
|
|
318
|
+
continue
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from vibe.core.config import SessionLoggingConfig
|
|
8
|
+
from vibe.core.session.session_logger import SessionLogger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def migrate_sessions_entrypoint(session_config: SessionLoggingConfig) -> int:
|
|
12
|
+
return asyncio.run(migrate_sessions(session_config))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def migrate_sessions(session_config: SessionLoggingConfig) -> int:
|
|
16
|
+
"""Helper for migrating session data from singular JSON files to the format introduced in Vibe 2.0 with per-session folders with split metadata and message files."""
|
|
17
|
+
save_dir = session_config.save_dir
|
|
18
|
+
if not save_dir or not session_config.enabled:
|
|
19
|
+
return 0
|
|
20
|
+
|
|
21
|
+
successful_migrations = 0
|
|
22
|
+
session_files = list(Path(save_dir).glob(f"{session_config.session_prefix}_*.json"))
|
|
23
|
+
for session_file in session_files:
|
|
24
|
+
try:
|
|
25
|
+
with open(session_file) as f:
|
|
26
|
+
session_data = f.read()
|
|
27
|
+
session_json = json.loads(session_data)
|
|
28
|
+
metadata = session_json["metadata"]
|
|
29
|
+
messages = session_json["messages"]
|
|
30
|
+
|
|
31
|
+
session_dir = Path(save_dir) / session_file.stem
|
|
32
|
+
session_dir.mkdir()
|
|
33
|
+
|
|
34
|
+
await SessionLogger.persist_metadata(metadata, session_dir)
|
|
35
|
+
await SessionLogger.persist_messages(messages, session_dir)
|
|
36
|
+
session_file.unlink()
|
|
37
|
+
successful_migrations += 1
|
|
38
|
+
except Exception:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
return successful_migrations
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from vibe.core.skills.manager import SkillManager
|
|
4
|
+
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
|
5
|
+
from vibe.core.skills.parser import SkillParseError
|
|
6
|
+
|
|
7
|
+
__all__ = ["SkillInfo", "SkillManager", "SkillMetadata", "SkillParseError"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from vibe.core.paths.config_paths import resolve_local_skills_dirs
|
|
9
|
+
from vibe.core.paths.global_paths import GLOBAL_SKILLS_DIR
|
|
10
|
+
from vibe.core.skills.models import SkillInfo, SkillMetadata
|
|
11
|
+
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
|
|
12
|
+
from vibe.core.utils import name_matches
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from vibe.core.config import VibeConfig
|
|
16
|
+
|
|
17
|
+
logger = getLogger("vibe")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SkillManager:
|
|
21
|
+
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
|
|
22
|
+
self._config_getter = config_getter
|
|
23
|
+
self._search_paths = self._compute_search_paths(self._config)
|
|
24
|
+
self._available: dict[str, SkillInfo] = self._discover_skills()
|
|
25
|
+
|
|
26
|
+
if self._available:
|
|
27
|
+
logger.info(
|
|
28
|
+
"Discovered %d skill(s) from %d search path(s)",
|
|
29
|
+
len(self._available),
|
|
30
|
+
len(self._search_paths),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def _config(self) -> VibeConfig:
|
|
35
|
+
return self._config_getter()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def available_skills(self) -> dict[str, SkillInfo]:
|
|
39
|
+
if self._config.enabled_skills:
|
|
40
|
+
return {
|
|
41
|
+
name: info
|
|
42
|
+
for name, info in self._available.items()
|
|
43
|
+
if name_matches(name, self._config.enabled_skills)
|
|
44
|
+
}
|
|
45
|
+
if self._config.disabled_skills:
|
|
46
|
+
return {
|
|
47
|
+
name: info
|
|
48
|
+
for name, info in self._available.items()
|
|
49
|
+
if not name_matches(name, self._config.disabled_skills)
|
|
50
|
+
}
|
|
51
|
+
return dict(self._available)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _compute_search_paths(config: VibeConfig) -> list[Path]:
|
|
55
|
+
paths: list[Path] = []
|
|
56
|
+
|
|
57
|
+
for path in config.skill_paths:
|
|
58
|
+
if path.is_dir():
|
|
59
|
+
paths.append(path)
|
|
60
|
+
|
|
61
|
+
paths.extend(resolve_local_skills_dirs(Path.cwd()))
|
|
62
|
+
|
|
63
|
+
if GLOBAL_SKILLS_DIR.path.is_dir():
|
|
64
|
+
paths.append(GLOBAL_SKILLS_DIR.path)
|
|
65
|
+
|
|
66
|
+
unique: list[Path] = []
|
|
67
|
+
for p in paths:
|
|
68
|
+
rp = p.resolve()
|
|
69
|
+
if rp not in unique:
|
|
70
|
+
unique.append(rp)
|
|
71
|
+
|
|
72
|
+
return unique
|
|
73
|
+
|
|
74
|
+
def _discover_skills(self) -> dict[str, SkillInfo]:
|
|
75
|
+
skills: dict[str, SkillInfo] = {}
|
|
76
|
+
for base in self._search_paths:
|
|
77
|
+
if not base.is_dir():
|
|
78
|
+
continue
|
|
79
|
+
for name, info in self._discover_skills_in_dir(base).items():
|
|
80
|
+
if name not in skills:
|
|
81
|
+
skills[name] = info
|
|
82
|
+
else:
|
|
83
|
+
logger.debug(
|
|
84
|
+
"Skipping duplicate skill '%s' at %s (already loaded from %s)",
|
|
85
|
+
name,
|
|
86
|
+
info.skill_path,
|
|
87
|
+
skills[name].skill_path,
|
|
88
|
+
)
|
|
89
|
+
return skills
|
|
90
|
+
|
|
91
|
+
def _discover_skills_in_dir(self, base: Path) -> dict[str, SkillInfo]:
|
|
92
|
+
skills: dict[str, SkillInfo] = {}
|
|
93
|
+
for skill_dir in base.iterdir():
|
|
94
|
+
if not skill_dir.is_dir():
|
|
95
|
+
continue
|
|
96
|
+
skill_file = skill_dir / "SKILL.md"
|
|
97
|
+
if not skill_file.is_file():
|
|
98
|
+
continue
|
|
99
|
+
if (skill_info := self._try_load_skill(skill_file)) is not None:
|
|
100
|
+
skills[skill_info.name] = skill_info
|
|
101
|
+
return skills
|
|
102
|
+
|
|
103
|
+
def _try_load_skill(self, skill_file: Path) -> SkillInfo | None:
|
|
104
|
+
try:
|
|
105
|
+
skill_info = self._parse_skill_file(skill_file)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.warning("Failed to parse skill at %s: %s", skill_file, e)
|
|
108
|
+
return None
|
|
109
|
+
return skill_info
|
|
110
|
+
|
|
111
|
+
def _parse_skill_file(self, skill_path: Path) -> SkillInfo:
|
|
112
|
+
try:
|
|
113
|
+
content = skill_path.read_text(encoding="utf-8")
|
|
114
|
+
except OSError as e:
|
|
115
|
+
raise SkillParseError(f"Cannot read file: {e}") from e
|
|
116
|
+
|
|
117
|
+
frontmatter, _ = parse_frontmatter(content)
|
|
118
|
+
metadata = SkillMetadata.model_validate(frontmatter)
|
|
119
|
+
|
|
120
|
+
skill_name_from_dir = skill_path.parent.name
|
|
121
|
+
if metadata.name != skill_name_from_dir:
|
|
122
|
+
logger.warning(
|
|
123
|
+
"Skill name '%s' doesn't match directory name '%s' at %s",
|
|
124
|
+
metadata.name,
|
|
125
|
+
skill_name_from_dir,
|
|
126
|
+
skill_path,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return SkillInfo.from_metadata(metadata, skill_path)
|
|
130
|
+
|
|
131
|
+
def get_skill(self, name: str) -> SkillInfo | None:
|
|
132
|
+
return self.available_skills.get(name)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SkillMetadata(BaseModel):
|
|
10
|
+
model_config = {"populate_by_name": True}
|
|
11
|
+
|
|
12
|
+
name: str = Field(
|
|
13
|
+
...,
|
|
14
|
+
min_length=1,
|
|
15
|
+
max_length=64,
|
|
16
|
+
pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$",
|
|
17
|
+
description="Skill identifier. Lowercase letters, numbers, and hyphens only.",
|
|
18
|
+
)
|
|
19
|
+
description: str = Field(
|
|
20
|
+
...,
|
|
21
|
+
min_length=1,
|
|
22
|
+
max_length=1024,
|
|
23
|
+
description="What this skill does and when to use it.",
|
|
24
|
+
)
|
|
25
|
+
license: str | None = Field(
|
|
26
|
+
default=None, description="License name or reference to a bundled license file."
|
|
27
|
+
)
|
|
28
|
+
compatibility: str | None = Field(
|
|
29
|
+
default=None,
|
|
30
|
+
max_length=500,
|
|
31
|
+
description="Environment requirements (intended product, system packages, etc.).",
|
|
32
|
+
)
|
|
33
|
+
metadata: dict[str, str] = Field(
|
|
34
|
+
default_factory=dict,
|
|
35
|
+
description="Arbitrary key-value mapping for additional metadata.",
|
|
36
|
+
)
|
|
37
|
+
allowed_tools: list[str] = Field(
|
|
38
|
+
default_factory=list,
|
|
39
|
+
validation_alias="allowed-tools",
|
|
40
|
+
description="Space-delimited list of pre-approved tools (experimental).",
|
|
41
|
+
)
|
|
42
|
+
user_invocable: bool = Field(
|
|
43
|
+
default=True,
|
|
44
|
+
validation_alias="user-invocable",
|
|
45
|
+
description="Controls whether the skill appears in the slash command menu.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@field_validator("allowed_tools", mode="before")
|
|
49
|
+
@classmethod
|
|
50
|
+
def parse_allowed_tools(cls, v: str | list[str] | None) -> list[str]:
|
|
51
|
+
if v is None:
|
|
52
|
+
return []
|
|
53
|
+
if isinstance(v, str):
|
|
54
|
+
return v.split()
|
|
55
|
+
return list(v)
|
|
56
|
+
|
|
57
|
+
@field_validator("metadata", mode="before")
|
|
58
|
+
@classmethod
|
|
59
|
+
def normalize_metadata(cls, v: dict[str, Any] | None) -> dict[str, str]:
|
|
60
|
+
if v is None:
|
|
61
|
+
return {}
|
|
62
|
+
return {str(k): str(val) for k, val in v.items()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SkillInfo(BaseModel):
|
|
66
|
+
name: str
|
|
67
|
+
description: str
|
|
68
|
+
license: str | None = None
|
|
69
|
+
compatibility: str | None = None
|
|
70
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
71
|
+
allowed_tools: list[str] = Field(default_factory=list)
|
|
72
|
+
user_invocable: bool = True
|
|
73
|
+
skill_path: Path
|
|
74
|
+
|
|
75
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def skill_dir(self) -> Path:
|
|
79
|
+
return self.skill_path.parent.resolve()
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_metadata(cls, meta: SkillMetadata, skill_path: Path) -> SkillInfo:
|
|
83
|
+
return cls(
|
|
84
|
+
name=meta.name,
|
|
85
|
+
description=meta.description,
|
|
86
|
+
license=meta.license,
|
|
87
|
+
compatibility=meta.compatibility,
|
|
88
|
+
metadata=meta.metadata,
|
|
89
|
+
allowed_tools=meta.allowed_tools,
|
|
90
|
+
user_invocable=meta.user_invocable,
|
|
91
|
+
skill_path=skill_path.resolve(),
|
|
92
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SkillParseError(Exception):
|
|
10
|
+
def __init__(self, reason: str) -> None:
|
|
11
|
+
super().__init__(reason)
|
|
12
|
+
self.reason = reason
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
FM_BOUNDARY = re.compile(r"^-{3,}\s*$", re.MULTILINE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
|
19
|
+
splits = FM_BOUNDARY.split(content, 2)
|
|
20
|
+
if len(splits) < 3 or splits[0].strip(): # noqa: PLR2004
|
|
21
|
+
raise SkillParseError(
|
|
22
|
+
"Missing or invalid YAML frontmatter (metadata section must start and end with ---)"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
yaml_content = splits[1]
|
|
26
|
+
markdown_body = splits[2]
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
frontmatter = yaml.safe_load(yaml_content)
|
|
30
|
+
except yaml.YAMLError as e:
|
|
31
|
+
raise SkillParseError(f"Invalid YAML frontmatter: {e}") from e
|
|
32
|
+
|
|
33
|
+
if frontmatter is None:
|
|
34
|
+
frontmatter = {}
|
|
35
|
+
|
|
36
|
+
if not isinstance(frontmatter, dict):
|
|
37
|
+
raise SkillParseError("YAML frontmatter must be a mapping/dictionary")
|
|
38
|
+
|
|
39
|
+
return frontmatter, markdown_body
|