v2.5.0 (#495)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
9421fbc08e
commit
5103019b01
104 changed files with 7277 additions and 691 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.4.2"
|
||||
__version__ = "2.5.0"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from acp import (
|
|||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
RequestError,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeResponse,
|
||||
run_agent,
|
||||
|
|
@ -55,6 +54,17 @@ from pydantic import BaseModel, ConfigDict
|
|||
|
||||
from vibe import VIBE_ROOT, __version__
|
||||
from vibe.acp.acp_logger import acp_message_observer
|
||||
from vibe.acp.exceptions import (
|
||||
ConfigurationError,
|
||||
ConversationLimitError,
|
||||
InternalError,
|
||||
InvalidRequestError,
|
||||
NotImplementedMethodError,
|
||||
RateLimitError,
|
||||
SessionLoadError,
|
||||
SessionNotFoundError,
|
||||
UnauthenticatedError,
|
||||
)
|
||||
from vibe.acp.tools.base import BaseAcpTool
|
||||
from vibe.acp.tools.session_update import (
|
||||
tool_call_session_update,
|
||||
|
|
@ -93,13 +103,14 @@ from vibe.core.proxy_setup import (
|
|||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import (
|
||||
ApprovalCallback,
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
AsyncApprovalCallback,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
EntrypointMetadata,
|
||||
LLMMessage,
|
||||
RateLimitError as CoreRateLimitError,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
ToolCallEvent,
|
||||
|
|
@ -107,7 +118,11 @@ from vibe.core.types import (
|
|||
ToolStreamEvent,
|
||||
UserMessageEvent,
|
||||
)
|
||||
from vibe.core.utils import CancellationReason, get_user_cancellation_message
|
||||
from vibe.core.utils import (
|
||||
CancellationReason,
|
||||
ConversationLimitException,
|
||||
get_user_cancellation_message,
|
||||
)
|
||||
|
||||
|
||||
class AcpSessionLoop(BaseModel):
|
||||
|
|
@ -201,7 +216,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
async def authenticate(
|
||||
self, method_id: str, **kwargs: Any
|
||||
) -> AuthenticateResponse | None:
|
||||
raise NotImplementedError("Not implemented yet")
|
||||
raise NotImplementedMethodError("authenticate")
|
||||
|
||||
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
|
||||
return EntrypointMetadata(
|
||||
|
|
@ -219,9 +234,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
config.tool_paths.extend(self._get_acp_tool_overrides())
|
||||
return config
|
||||
except MissingAPIKeyError as e:
|
||||
raise RequestError.auth_required({
|
||||
"message": "You must be authenticated before creating a session"
|
||||
}) from e
|
||||
raise UnauthenticatedError.from_missing_api_key(e) from e
|
||||
except Exception as e:
|
||||
raise ConfigurationError(str(e)) from e
|
||||
|
||||
async def _create_acp_session(
|
||||
self, session_id: str, agent_loop: AgentLoop
|
||||
|
|
@ -295,7 +310,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
for override in overrides
|
||||
]
|
||||
|
||||
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
|
||||
def _create_approval_callback(self, session_id: str) -> ApprovalCallback:
|
||||
session = self._get_session(session_id)
|
||||
|
||||
def _handle_permission_selection(
|
||||
|
|
@ -347,7 +362,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
|
||||
def _get_session(self, session_id: str) -> AcpSessionLoop:
|
||||
if session_id not in self.sessions:
|
||||
raise RequestError.invalid_params({"session": "Not found"})
|
||||
raise SessionNotFoundError(session_id)
|
||||
return self.sessions[session_id]
|
||||
|
||||
async def _replay_tool_calls(self, session_id: str, msg: LLMMessage) -> None:
|
||||
|
|
@ -395,7 +410,17 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
hint="KEY value to set, KEY to unset, or empty for help"
|
||||
)
|
||||
),
|
||||
)
|
||||
),
|
||||
AvailableCommand(
|
||||
name="leanstall",
|
||||
description="Install the Lean 4 agent (leanstral)",
|
||||
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
|
||||
),
|
||||
AvailableCommand(
|
||||
name="unleanstall",
|
||||
description="Uninstall the Lean 4 agent",
|
||||
input=AvailableCommandInput(root=UnstructuredCommandInput(hint="")),
|
||||
),
|
||||
]
|
||||
|
||||
update = update_available_commands(commands)
|
||||
|
|
@ -429,6 +454,60 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
async def _handle_leanstall_command(self, session_id: str) -> PromptResponse:
|
||||
session = self._get_session(session_id)
|
||||
current = list(session.agent_loop.base_config.installed_agents)
|
||||
if "lean" in current:
|
||||
message = "Lean agent is already installed."
|
||||
else:
|
||||
VibeConfig.save_updates({"installed_agents": [*current, "lean"]})
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
)
|
||||
await session.agent_loop.reload_with_initial_messages(
|
||||
base_config=new_config
|
||||
)
|
||||
message = (
|
||||
"Lean agent installed. Start a new session to switch to Lean mode."
|
||||
)
|
||||
|
||||
await self.client.session_update(
|
||||
session_id=session_id,
|
||||
update=AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=message),
|
||||
),
|
||||
)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
async def _handle_unleanstall_command(self, session_id: str) -> PromptResponse:
|
||||
session = self._get_session(session_id)
|
||||
current = list(session.agent_loop.base_config.installed_agents)
|
||||
if "lean" not in current:
|
||||
message = "Lean agent is not installed."
|
||||
else:
|
||||
VibeConfig.save_updates({
|
||||
"installed_agents": [a for a in current if a != "lean"]
|
||||
})
|
||||
new_config = VibeConfig.load(
|
||||
tool_paths=session.agent_loop.config.tool_paths,
|
||||
disabled_tools=["ask_user_question", "exit_plan_mode"],
|
||||
)
|
||||
await session.agent_loop.reload_with_initial_messages(
|
||||
base_config=new_config
|
||||
)
|
||||
message = "Lean agent uninstalled."
|
||||
|
||||
await self.client.session_update(
|
||||
session_id=session_id,
|
||||
update=AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=message),
|
||||
),
|
||||
)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
@override
|
||||
async def load_session(
|
||||
self,
|
||||
|
|
@ -446,16 +525,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
session_id, config.session_logging
|
||||
)
|
||||
if session_dir is None:
|
||||
raise RequestError.invalid_params({
|
||||
"session_id": f"Session not found: {session_id}"
|
||||
})
|
||||
raise SessionNotFoundError(session_id)
|
||||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_dir)
|
||||
except ValueError as e:
|
||||
raise RequestError.invalid_params({
|
||||
"session_id": f"Failed to load session: {e}"
|
||||
}) from e
|
||||
except Exception as e:
|
||||
raise SessionLoadError(session_id, str(e)) from e
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
config=config,
|
||||
|
|
@ -605,7 +680,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
session = self._get_session(session_id)
|
||||
|
||||
if session.task is not None:
|
||||
raise RuntimeError(
|
||||
raise InvalidRequestError(
|
||||
"Concurrent prompts are not supported yet, wait for agent loop to finish"
|
||||
)
|
||||
|
||||
|
|
@ -614,6 +689,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
if text_prompt.strip().lower().startswith("/proxy-setup"):
|
||||
return await self._handle_proxy_setup_command(session_id, text_prompt)
|
||||
|
||||
if text_prompt.strip().lower().startswith("/unleanstall"):
|
||||
return await self._handle_unleanstall_command(session_id)
|
||||
|
||||
if text_prompt.strip().lower().startswith("/leanstall"):
|
||||
return await self._handle_leanstall_command(session_id)
|
||||
|
||||
temp_user_message_id: str | None = kwargs.get("messageId")
|
||||
|
||||
async def agent_loop_task() -> None:
|
||||
|
|
@ -629,16 +710,14 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
except asyncio.CancelledError:
|
||||
return PromptResponse(stop_reason="cancelled")
|
||||
|
||||
except Exception as e:
|
||||
await self.client.session_update(
|
||||
session_id=session_id,
|
||||
update=AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=TextContentBlock(type="text", text=f"Error: {e!s}"),
|
||||
),
|
||||
)
|
||||
except CoreRateLimitError as e:
|
||||
raise RateLimitError.from_core(e) from e
|
||||
|
||||
return PromptResponse(stop_reason="refusal")
|
||||
except ConversationLimitException as e:
|
||||
raise ConversationLimitError(str(e)) from e
|
||||
|
||||
except Exception as e:
|
||||
raise InternalError(str(e)) from e
|
||||
|
||||
finally:
|
||||
session.task = None
|
||||
|
|
@ -687,7 +766,9 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
block_prompt = "\n".join(parts)
|
||||
text_prompt = f"{text_prompt}{separator}{block_prompt}"
|
||||
case _:
|
||||
raise ValueError(f"Unsupported content block type: {block.type}")
|
||||
raise InvalidRequestError(
|
||||
f"We currently don't support {block.type} content blocks"
|
||||
)
|
||||
return text_prompt
|
||||
|
||||
async def _run_agent_loop(
|
||||
|
|
@ -775,7 +856,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ForkSessionResponse:
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedMethodError("fork_session")
|
||||
|
||||
@override
|
||||
async def resume_session(
|
||||
|
|
@ -785,15 +866,15 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResumeSessionResponse:
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedMethodError("resume_session")
|
||||
|
||||
@override
|
||||
async def ext_method(self, method: str, params: dict) -> dict:
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedMethodError("ext_method")
|
||||
|
||||
@override
|
||||
async def ext_notification(self, method: str, params: dict) -> None:
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedMethodError("ext_notification")
|
||||
|
||||
@override
|
||||
def on_connect(self, conn: Client) -> None:
|
||||
|
|
|
|||
121
vibe/acp/exceptions.py
Normal file
121
vibe/acp/exceptions.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Structured ACP error classes for the Vibe agent.
|
||||
|
||||
Error codes follow JSON-RPC 2.0 (https://www.jsonrpc.org/specification#error_object)
|
||||
and ACP error handling (https://agentclientprotocol.com/protocol/overview#error-handling):
|
||||
|
||||
-32700 Parse error (JSON-RPC standard)
|
||||
-32600 Invalid request (JSON-RPC standard)
|
||||
-32601 Method not found (JSON-RPC standard)
|
||||
-32602 Invalid params (JSON-RPC standard)
|
||||
-32603 Internal error (JSON-RPC standard)
|
||||
-32000 to -32099 Server errors (JSON-RPC implementation-defined)
|
||||
-31xxx Application errors (Vibe-specific, outside reserved range)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from acp import RequestError
|
||||
|
||||
from vibe.core.config import MissingAPIKeyError
|
||||
from vibe.core.types import RateLimitError as CoreRateLimitError
|
||||
|
||||
# JSON-RPC 2.0 standard codes
|
||||
UNAUTHENTICATED = -32000
|
||||
METHOD_NOT_FOUND = -32601
|
||||
INVALID_PARAMS = -32602
|
||||
INTERNAL_ERROR = -32603
|
||||
|
||||
# Vibe application codes (outside JSON-RPC reserved range)
|
||||
RATE_LIMITED = -31001
|
||||
CONFIGURATION_ERROR = -31002
|
||||
CONVERSATION_LIMIT = -31003
|
||||
|
||||
|
||||
class VibeRequestError(RequestError):
|
||||
code: int
|
||||
|
||||
def __init__(self, message: str, data: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(self.code, message, data)
|
||||
|
||||
|
||||
class UnauthenticatedError(VibeRequestError):
|
||||
code = UNAUTHENTICATED
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(message=detail)
|
||||
|
||||
@classmethod
|
||||
def from_missing_api_key(cls, exc: MissingAPIKeyError) -> UnauthenticatedError:
|
||||
return cls(f"Missing API key for {exc.provider_name} provider.")
|
||||
|
||||
|
||||
class NotImplementedMethodError(VibeRequestError):
|
||||
code = METHOD_NOT_FOUND
|
||||
|
||||
def __init__(self, method: str) -> None:
|
||||
super().__init__(
|
||||
message=f"Method not implemented: {method}", data={"method": method}
|
||||
)
|
||||
|
||||
|
||||
class InvalidRequestError(VibeRequestError):
|
||||
code = INVALID_PARAMS
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(message=detail)
|
||||
|
||||
|
||||
class SessionNotFoundError(VibeRequestError):
|
||||
code = INVALID_PARAMS
|
||||
|
||||
def __init__(self, session_id: str) -> None:
|
||||
super().__init__(
|
||||
message=f"Session not found: {session_id}", data={"session_id": session_id}
|
||||
)
|
||||
|
||||
|
||||
class SessionLoadError(VibeRequestError):
|
||||
code = INVALID_PARAMS
|
||||
|
||||
def __init__(self, session_id: str, detail: str) -> None:
|
||||
super().__init__(
|
||||
message=f"Failed to load session {session_id}: {detail}",
|
||||
data={"session_id": session_id},
|
||||
)
|
||||
|
||||
|
||||
class RateLimitError(VibeRequestError):
|
||||
code = RATE_LIMITED
|
||||
|
||||
def __init__(self, provider: str, model: str) -> None:
|
||||
super().__init__(
|
||||
message=f"Rate limit exceeded for {provider} (model: {model}).",
|
||||
data={"provider": provider, "model": model},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_core(cls, exc: CoreRateLimitError) -> RateLimitError:
|
||||
return cls(exc.provider, exc.model)
|
||||
|
||||
|
||||
class ConfigurationError(VibeRequestError):
|
||||
code = CONFIGURATION_ERROR
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(message=detail)
|
||||
|
||||
|
||||
class ConversationLimitError(VibeRequestError):
|
||||
code = CONVERSATION_LIMIT
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(message=detail)
|
||||
|
||||
|
||||
class InternalError(VibeRequestError):
|
||||
code = INTERNAL_ERROR
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(message=detail or "Internal error")
|
||||
|
|
@ -4,18 +4,19 @@ from pathlib import Path
|
|||
|
||||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.core.tools.base import BaseToolState, ToolError
|
||||
from vibe.core.tools.base import ToolError
|
||||
from vibe.core.tools.builtins.read_file import (
|
||||
ReadFile as CoreReadFileTool,
|
||||
ReadFileArgs,
|
||||
ReadFileResult,
|
||||
ReadFileState,
|
||||
_ReadResult,
|
||||
)
|
||||
|
||||
ReadFileResult = ReadFileResult
|
||||
|
||||
|
||||
class AcpReadFileState(BaseToolState, AcpToolState):
|
||||
class AcpReadFileState(ReadFileState, AcpToolState):
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,11 +57,14 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
|
|||
def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
|
||||
if is_user_cancellation_event(event):
|
||||
tool_status = "failed"
|
||||
raw_output = (
|
||||
TaggedText.from_string(event.skip_reason).message
|
||||
if event.skip_reason
|
||||
else None
|
||||
)
|
||||
if event.skip_reason:
|
||||
raw_output = TaggedText.from_string(event.skip_reason).message
|
||||
elif event.error:
|
||||
raw_output = TaggedText.from_string(event.error).message
|
||||
elif event.result:
|
||||
raw_output = event.result.model_dump_json()
|
||||
else:
|
||||
raw_output = None
|
||||
elif event.result:
|
||||
tool_status = "completed"
|
||||
raw_output = event.result.model_dump_json()
|
||||
|
|
|
|||
|
|
@ -77,6 +77,21 @@ class CommandRegistry:
|
|||
description="Browse and resume past sessions",
|
||||
handler="_show_session_picker",
|
||||
),
|
||||
"voice": Command(
|
||||
aliases=frozenset(["/voice"]),
|
||||
description="Toggle voice mode on/off",
|
||||
handler="_toggle_voice_mode",
|
||||
),
|
||||
"leanstall": Command(
|
||||
aliases=frozenset(["/leanstall"]),
|
||||
description="Install the Lean 4 agent (leanstral)",
|
||||
handler="_install_lean",
|
||||
),
|
||||
"unleanstall": Command(
|
||||
aliases=frozenset(["/unleanstall"]),
|
||||
description="Uninstall the Lean 4 agent",
|
||||
handler="_uninstall_lean",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class HistoryManager:
|
|||
self._save_history()
|
||||
self.reset_navigation()
|
||||
|
||||
def get_previous(self, current_input: str, prefix: str = "") -> str | None:
|
||||
def get_previous(self, current_input: str) -> str | None:
|
||||
if not self._entries:
|
||||
return None
|
||||
|
||||
|
|
@ -66,21 +66,19 @@ class HistoryManager:
|
|||
self._temp_input = current_input
|
||||
self._current_index = len(self._entries)
|
||||
|
||||
for i in range(self._current_index - 1, -1, -1):
|
||||
if self._entries[i].startswith(prefix):
|
||||
self._current_index = i
|
||||
return self._entries[i]
|
||||
if self._current_index <= 0:
|
||||
return None
|
||||
|
||||
return None
|
||||
self._current_index -= 1
|
||||
return self._entries[self._current_index]
|
||||
|
||||
def get_next(self, prefix: str = "") -> str | None:
|
||||
def get_next(self) -> str | None:
|
||||
if self._current_index == -1:
|
||||
return None
|
||||
|
||||
for i in range(self._current_index + 1, len(self._entries)):
|
||||
if self._entries[i].startswith(prefix):
|
||||
self._current_index = i
|
||||
return self._entries[i]
|
||||
if self._current_index < len(self._entries) - 1:
|
||||
self._current_index += 1
|
||||
return self._entries[self._current_index]
|
||||
|
||||
result = self._temp_input
|
||||
self.reset_navigation()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from os import getenv
|
||||
|
||||
|
|
@ -19,6 +20,11 @@ UPGRADE_URL = CONSOLE_CLI_URL
|
|||
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
|
||||
|
||||
|
||||
class MistralCodePlanName(StrEnum):
|
||||
FREE = "F"
|
||||
ENTERPRISE = "E"
|
||||
|
||||
|
||||
class PlanInfo:
|
||||
plan_type: WhoAmIPlanType
|
||||
plan_name: str
|
||||
|
|
@ -51,6 +57,18 @@ class PlanInfo:
|
|||
def is_chat_pro_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.CHAT
|
||||
|
||||
def is_free_mistral_code_plan(self) -> bool:
|
||||
return (
|
||||
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
|
||||
and self.plan_name.upper() == MistralCodePlanName.FREE
|
||||
)
|
||||
|
||||
def is_mistral_code_enterprise_plan(self) -> bool:
|
||||
return (
|
||||
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
|
||||
and self.plan_name.upper() == MistralCodePlanName.ENTERPRISE
|
||||
)
|
||||
|
||||
|
||||
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
|
||||
if not api_key:
|
||||
|
|
@ -79,11 +97,14 @@ def plan_offer_cta(payload: PlanInfo | None) -> str | None:
|
|||
return
|
||||
if payload.prompt_switching_to_pro_plan:
|
||||
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
|
||||
if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
|
||||
if (
|
||||
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
|
||||
or payload.is_free_mistral_code_plan()
|
||||
):
|
||||
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
|
||||
|
||||
|
||||
def plan_title(payload: PlanInfo | None) -> str | None:
|
||||
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
|
||||
if not payload:
|
||||
return None
|
||||
if payload.is_chat_pro_plan():
|
||||
|
|
@ -92,4 +113,8 @@ def plan_title(payload: PlanInfo | None) -> str | None:
|
|||
return "[API] Experiment plan"
|
||||
if payload.is_paid_api_plan():
|
||||
return "[API] Scale plan"
|
||||
if payload.is_free_mistral_code_plan():
|
||||
return "Mistral Code Free"
|
||||
if payload.is_mistral_code_enterprise_plan():
|
||||
return "Mistral Code Enterprise"
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pydantic import TypeAdapter, ValidationError
|
|||
class WhoAmIPlanType(StrEnum):
|
||||
API = "API"
|
||||
CHAT = "CHAT"
|
||||
MISTRAL_CODE = "MISTRAL_CODE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
|
|
@ -87,8 +88,11 @@ from vibe.cli.update_notifier import (
|
|||
should_show_whats_new,
|
||||
)
|
||||
from vibe.cli.update_notifier.update import do_update
|
||||
from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
|
||||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.audio_recorder import AudioRecorder
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
|
|
@ -112,6 +116,7 @@ from vibe.core.tools.builtins.ask_user_question import (
|
|||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.transcribe import make_transcribe_client
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
ApprovalResponse,
|
||||
|
|
@ -147,7 +152,11 @@ class ChatScroll(VerticalScroll):
|
|||
|
||||
@property
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_offset.y >= (self.max_scroll_y - 3)
|
||||
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
||||
|
||||
def _check_anchor(self) -> None:
|
||||
if self._anchored and self._anchor_released and self.is_at_bottom:
|
||||
self._anchor_released = False
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
|
@ -196,6 +205,7 @@ async def prune_oldest_children(
|
|||
class VibeApp(App): # noqa: PLR0904
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
CSS_PATH = "app.tcss"
|
||||
PAUSE_GC_ON_SCROLL: ClassVar[bool] = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "clear_quit", "Quit", show=False),
|
||||
|
|
@ -222,10 +232,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
current_version: str = CORE_VERSION,
|
||||
plan_offer_gateway: WhoAmIGateway | None = None,
|
||||
terminal_notifier: NotificationPort | None = None,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.agent_loop = agent_loop
|
||||
self._voice_manager: VoiceManagerPort = (
|
||||
voice_manager or self._make_default_voice_manager()
|
||||
)
|
||||
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
|
||||
self,
|
||||
get_enabled=lambda: self.config.enable_notifications,
|
||||
|
|
@ -238,6 +252,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._loading_widget: LoadingWidget | None = None
|
||||
self._pending_approval: asyncio.Future | None = None
|
||||
self._pending_question: asyncio.Future | None = None
|
||||
self._user_interaction_lock = asyncio.Lock()
|
||||
|
||||
self.event_handler: EventHandler | None = None
|
||||
|
||||
|
|
@ -296,6 +311,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
skill_entries_getter=self._get_skill_entries,
|
||||
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
|
||||
nuage_enabled=self.config.nuage_enabled,
|
||||
voice_manager=self._voice_manager,
|
||||
)
|
||||
|
||||
with Horizontal(id="bottom-bar"):
|
||||
|
|
@ -346,6 +362,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
||||
gc.collect()
|
||||
gc.freeze()
|
||||
|
||||
def _process_initial_prompt(self) -> None:
|
||||
if self._teleport_on_start:
|
||||
self.run_worker(
|
||||
|
|
@ -402,8 +421,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._pending_approval and not self._pending_approval.done():
|
||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_approval_app_approval_granted_always_tool(
|
||||
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
|
||||
) -> None:
|
||||
|
|
@ -414,8 +431,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._pending_approval and not self._pending_approval.done():
|
||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_approval_app_approval_rejected(
|
||||
self, message: ApprovalApp.ApprovalRejected
|
||||
) -> None:
|
||||
|
|
@ -425,8 +440,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._remove_loading_widget()
|
||||
|
||||
|
|
@ -435,16 +448,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
|
||||
self._pending_question.set_result(result)
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
|
||||
if self._pending_question and not self._pending_question.done():
|
||||
result = AskUserQuestionResult(answers=[], cancelled=True)
|
||||
self._pending_question.set_result(result)
|
||||
|
||||
await self._switch_to_input_app()
|
||||
await self._interrupt_agent_loop()
|
||||
|
||||
async def _remove_loading_widget(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
|
|
@ -682,26 +690,32 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._is_tool_enabled_in_main_agent(tool):
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
|
||||
self._pending_approval = None
|
||||
return result
|
||||
async with self._user_interaction_lock:
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
return result
|
||||
finally:
|
||||
self._pending_approval = None
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _user_input_callback(self, args: BaseModel) -> BaseModel:
|
||||
question_args = cast(AskUserQuestionArgs, args)
|
||||
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_question_app(question_args)
|
||||
result = await self._pending_question
|
||||
|
||||
self._pending_question = None
|
||||
return result
|
||||
async with self._user_interaction_lock:
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_question_app(question_args)
|
||||
result = await self._pending_question
|
||||
return result
|
||||
finally:
|
||||
self._pending_question = None
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||
self._agent_running = True
|
||||
|
|
@ -756,10 +770,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._terminal_notifier.notify(NotificationContext.COMPLETE)
|
||||
|
||||
def _rate_limit_message(self) -> str:
|
||||
upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
|
||||
WhoAmIPlanType.API,
|
||||
WhoAmIPlanType.UNAUTHORIZED,
|
||||
}
|
||||
upgrade_to_pro = self._plan_info and (
|
||||
self._plan_info.plan_type
|
||||
in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
|
||||
or self._plan_info.is_free_mistral_code_plan()
|
||||
)
|
||||
if upgrade_to_pro:
|
||||
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
return "Rate limits exceeded. Please wait a moment before trying again."
|
||||
|
|
@ -862,6 +877,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self._interrupt_requested = True
|
||||
|
||||
if self._pending_approval and not self._pending_approval.done():
|
||||
feedback = str(
|
||||
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
|
||||
)
|
||||
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
|
||||
if self._pending_question and not self._pending_question.done():
|
||||
self._pending_question.set_result(
|
||||
AskUserQuestionResult(answers=[], cancelled=True)
|
||||
)
|
||||
|
||||
if self._agent_task and not self._agent_task.done():
|
||||
self._agent_task.cancel()
|
||||
try:
|
||||
|
|
@ -1035,6 +1060,28 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _install_lean(self) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" in current:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Lean agent is already installed.")
|
||||
)
|
||||
return
|
||||
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
|
||||
await self._reload_config()
|
||||
|
||||
async def _uninstall_lean(self) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" not in current:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Lean agent is not installed.")
|
||||
)
|
||||
return
|
||||
VibeConfig.save_updates({
|
||||
"installed_agents": [a for a in current if a != "lean"]
|
||||
})
|
||||
await self._reload_config()
|
||||
|
||||
async def _clear_history(self) -> None:
|
||||
try:
|
||||
self._reset_ui_state()
|
||||
|
|
@ -1166,6 +1213,33 @@ class VibeApp(App): # noqa: PLR0904
|
|||
ErrorMessage(result.message, collapsed=self._tools_collapsed)
|
||||
)
|
||||
|
||||
def _make_default_voice_manager(self) -> VoiceManager:
|
||||
try:
|
||||
model = self.config.get_active_transcribe_model()
|
||||
provider = self.config.get_transcribe_provider_for_model(model)
|
||||
transcribe_client = make_transcribe_client(provider, model)
|
||||
except (ValueError, KeyError) as exc:
|
||||
logger.error(
|
||||
"Failed to initialize transcription, check transcribe model configuration",
|
||||
exc_info=exc,
|
||||
)
|
||||
transcribe_client = None
|
||||
|
||||
return VoiceManager(
|
||||
lambda: self.config,
|
||||
audio_recorder=AudioRecorder(),
|
||||
transcribe_client=transcribe_client,
|
||||
)
|
||||
|
||||
async def _toggle_voice_mode(self) -> None:
|
||||
result = self._voice_manager.toggle_voice_mode()
|
||||
self.agent_loop.refresh_config()
|
||||
if result.enabled:
|
||||
msg = "Voice mode enabled. Press ctrl+r to start recording."
|
||||
else:
|
||||
msg = "Voice mode disabled."
|
||||
await self._mount_and_scroll(UserCommandMessage(msg))
|
||||
|
||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
|
|
@ -1291,7 +1365,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
|
||||
def action_interrupt(self) -> None:
|
||||
def action_interrupt(self) -> None: # noqa: PLR0911
|
||||
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
|
||||
self._voice_manager.cancel_recording()
|
||||
return
|
||||
|
||||
current_time = time.monotonic()
|
||||
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
|
|
@ -1424,6 +1502,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container
|
||||
and self._switch_agent_generation == my_gen
|
||||
):
|
||||
self.call_from_thread(self._refresh_banner)
|
||||
self.call_from_thread(
|
||||
setattr, self._chat_input_container, "switching_mode", False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ TextArea > .text-area--cursor {
|
|||
#completion-popup {
|
||||
width: 100%;
|
||||
padding: 1;
|
||||
padding-left: 3;
|
||||
color: ansi_default;
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +113,11 @@ TextArea > .text-area--cursor {
|
|||
border: solid ansi_red;
|
||||
border-title-color: ansi_red;
|
||||
}
|
||||
|
||||
&.border-recording {
|
||||
border: solid $mistral_orange;
|
||||
border-title-color: $mistral_orange;
|
||||
}
|
||||
}
|
||||
|
||||
#input-body {
|
||||
|
|
@ -119,7 +125,8 @@ TextArea > .text-area--cursor {
|
|||
}
|
||||
|
||||
#prompt,
|
||||
#prompt-spinner {
|
||||
#prompt-spinner,
|
||||
#recording-indicator {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
color: $mistral_orange;
|
||||
|
|
@ -137,6 +144,10 @@ TextArea > .text-area--cursor {
|
|||
border: none;
|
||||
padding: 0;
|
||||
scrollbar-visibility: hidden;
|
||||
|
||||
&.recording {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
}
|
||||
|
||||
ToastRack {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class EventHandler:
|
|||
skip_reason=TaggedText.from_string(event.skip_reason).message
|
||||
if event.skip_reason
|
||||
else None,
|
||||
cancelled=event.cancelled,
|
||||
duration=event.duration,
|
||||
tool_call_id=event.tool_call_id,
|
||||
)
|
||||
|
|
|
|||
5
vibe/cli/textual_ui/recording/__init__.py
Normal file
5
vibe/cli/textual_ui/recording/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
|
||||
|
||||
__all__ = ["RecordingIndicator"]
|
||||
79
vibe/cli/textual_ui/recording/recording_indicator.py
Normal file
79
vibe/cli/textual_ui/recording/recording_indicator.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
TranscribeState,
|
||||
VoiceManagerListener,
|
||||
VoiceManagerPort,
|
||||
)
|
||||
|
||||
PEAK_BLOCKS = "▁▂▃▄▅▆▇█"
|
||||
FILL_BLOCKS = "▏▎▍▌▋▊▉█"
|
||||
PEAK_POLL_INTERVAL = 0.05
|
||||
|
||||
|
||||
class RecordingIndicator(VoiceManagerListener, Static):
|
||||
def __init__(self, voice_manager: VoiceManagerPort) -> None:
|
||||
super().__init__(PEAK_BLOCKS[0], id="recording-indicator")
|
||||
self._voice_manager = voice_manager
|
||||
self._peak_timer: Timer | None = None
|
||||
self._flushing_animation_timer: Timer | None = None
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._voice_manager.add_listener(self)
|
||||
if self._voice_manager.transcribe_state == TranscribeState.RECORDING:
|
||||
self._start_peak_polling()
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._voice_manager.remove_listener(self)
|
||||
self._stop_peak_polling()
|
||||
self._stop_flushing_animation_timer()
|
||||
|
||||
def on_transcribe_state_change(self, state: TranscribeState) -> None:
|
||||
match state:
|
||||
case TranscribeState.RECORDING:
|
||||
self._start_peak_polling()
|
||||
case TranscribeState.FLUSHING:
|
||||
self._stop_peak_polling()
|
||||
self._start_flushing_animation_timer()
|
||||
case TranscribeState.IDLE:
|
||||
self._stop_peak_polling()
|
||||
self._stop_flushing_animation_timer()
|
||||
|
||||
def _start_peak_polling(self) -> None:
|
||||
self._peak_timer = self.set_interval(
|
||||
PEAK_POLL_INTERVAL, self._poll_peak, pause=False
|
||||
)
|
||||
|
||||
def _poll_peak(self) -> None:
|
||||
if self._voice_manager.transcribe_state != TranscribeState.RECORDING:
|
||||
return
|
||||
index = min(
|
||||
int(self._voice_manager.peak * len(PEAK_BLOCKS)), len(PEAK_BLOCKS) - 1
|
||||
)
|
||||
self.update(PEAK_BLOCKS[index])
|
||||
|
||||
def _stop_peak_polling(self) -> None:
|
||||
if self._peak_timer:
|
||||
self._peak_timer.stop()
|
||||
self._peak_timer = None
|
||||
|
||||
def _start_flushing_animation_timer(self) -> None:
|
||||
self._processing_index = 0
|
||||
self.update(FILL_BLOCKS[0])
|
||||
self._flushing_animation_timer = self.set_interval(
|
||||
0.1, self._advance_flushing_animation
|
||||
)
|
||||
|
||||
def _advance_flushing_animation(self) -> None:
|
||||
if self._voice_manager.transcribe_state != TranscribeState.FLUSHING:
|
||||
return
|
||||
self._processing_index = (self._processing_index + 1) % len(FILL_BLOCKS)
|
||||
self.update(FILL_BLOCKS[self._processing_index])
|
||||
|
||||
def _stop_flushing_animation_timer(self) -> None:
|
||||
if self._flushing_animation_timer:
|
||||
self._flushing_animation_timer.stop()
|
||||
self._flushing_animation_timer = None
|
||||
|
|
@ -11,9 +11,16 @@ from textual.widget import Widget
|
|||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.history_manager import HistoryManager
|
||||
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
TranscribeState,
|
||||
VoiceManagerListener,
|
||||
VoiceManagerPort,
|
||||
)
|
||||
from vibe.core.logger import logger
|
||||
|
||||
|
||||
class _PromptSpinner(SpinnerMixin, Static):
|
||||
|
|
@ -29,7 +36,7 @@ class _PromptSpinner(SpinnerMixin, Static):
|
|||
self.start_spinner_timer()
|
||||
|
||||
|
||||
class ChatInputBody(Widget):
|
||||
class ChatInputBody(VoiceManagerListener, Widget):
|
||||
class Submitted(Message):
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
|
|
@ -39,6 +46,7 @@ class ChatInputBody(Widget):
|
|||
self,
|
||||
history_file: Path | None = None,
|
||||
nuage_enabled: bool = False,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -46,6 +54,8 @@ class ChatInputBody(Widget):
|
|||
self.prompt_widget: NoMarkupStatic | None = None
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._switching_mode = False
|
||||
self._voice_manager = voice_manager
|
||||
self._recording_indicator: RecordingIndicator | None = None
|
||||
|
||||
if history_file:
|
||||
self.history = HistoryManager(history_file)
|
||||
|
|
@ -60,13 +70,21 @@ class ChatInputBody(Widget):
|
|||
yield self.prompt_widget
|
||||
|
||||
self.input_widget = ChatTextArea(
|
||||
id="input", nuage_enabled=self._nuage_enabled
|
||||
id="input",
|
||||
nuage_enabled=self._nuage_enabled,
|
||||
voice_manager=self._voice_manager,
|
||||
)
|
||||
yield self.input_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self.input_widget:
|
||||
self.input_widget.focus()
|
||||
if self._voice_manager:
|
||||
self._voice_manager.add_listener(self)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
if self._voice_manager:
|
||||
self._voice_manager.remove_listener(self)
|
||||
|
||||
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
|
||||
if text.startswith("!"):
|
||||
|
|
@ -103,7 +121,6 @@ class ChatInputBody(Widget):
|
|||
cursor_pos = (0, col)
|
||||
|
||||
self.input_widget.move_cursor(cursor_pos)
|
||||
self.input_widget._last_cursor_col = col
|
||||
self.input_widget._cursor_pos_after_load = cursor_pos
|
||||
self.input_widget._cursor_moved_since_load = False
|
||||
|
||||
|
|
@ -111,7 +128,7 @@ class ChatInputBody(Widget):
|
|||
self._notify_completion_reset()
|
||||
|
||||
def on_chat_text_area_history_previous(
|
||||
self, event: ChatTextArea.HistoryPrevious
|
||||
self, _event: ChatTextArea.HistoryPrevious
|
||||
) -> None:
|
||||
if not self.history or not self.input_widget:
|
||||
return
|
||||
|
|
@ -119,52 +136,25 @@ class ChatInputBody(Widget):
|
|||
if self.history._current_index == -1:
|
||||
self.input_widget._original_text = self.input_widget.text
|
||||
|
||||
if (
|
||||
self.history._current_index != -1
|
||||
and self.input_widget._last_used_prefix is not None
|
||||
and self.input_widget._last_used_prefix != event.prefix
|
||||
):
|
||||
self.history.reset_navigation()
|
||||
|
||||
self.input_widget._last_used_prefix = event.prefix
|
||||
previous = self.history.get_previous(
|
||||
self.input_widget._original_text, prefix=event.prefix
|
||||
)
|
||||
previous = self.history.get_previous(self.input_widget._original_text)
|
||||
|
||||
if previous is not None:
|
||||
self._load_history_entry(previous)
|
||||
|
||||
def on_chat_text_area_history_next(self, event: ChatTextArea.HistoryNext) -> None:
|
||||
def on_chat_text_area_history_next(self, _event: ChatTextArea.HistoryNext) -> None:
|
||||
if not self.history or not self.input_widget:
|
||||
return
|
||||
|
||||
if self.history._current_index == -1:
|
||||
return
|
||||
|
||||
if (
|
||||
self.input_widget._last_used_prefix is not None
|
||||
and self.input_widget._last_used_prefix != event.prefix
|
||||
):
|
||||
self.history.reset_navigation()
|
||||
next_entry = self.history.get_next()
|
||||
if next_entry is not None:
|
||||
self._load_history_entry(next_entry)
|
||||
|
||||
self.input_widget._last_used_prefix = event.prefix
|
||||
|
||||
has_next = any(
|
||||
self.history._entries[i].startswith(event.prefix)
|
||||
for i in range(self.history._current_index + 1, len(self.history._entries))
|
||||
)
|
||||
|
||||
original_matches = self.input_widget._original_text.startswith(event.prefix)
|
||||
|
||||
if has_next or original_matches:
|
||||
next_entry = self.history.get_next(prefix=event.prefix)
|
||||
if next_entry is not None:
|
||||
cursor_col = (
|
||||
len(event.prefix) if self.history._current_index == -1 else None
|
||||
)
|
||||
self._load_history_entry(next_entry, cursor_col=cursor_col)
|
||||
|
||||
def on_chat_text_area_history_reset(self, event: ChatTextArea.HistoryReset) -> None:
|
||||
def on_chat_text_area_history_reset(
|
||||
self, _event: ChatTextArea.HistoryReset
|
||||
) -> None:
|
||||
if self.history:
|
||||
self.history.reset_navigation()
|
||||
if self.input_widget:
|
||||
|
|
@ -250,3 +240,68 @@ class ChatInputBody(Widget):
|
|||
|
||||
if cursor_offset is not None:
|
||||
self.input_widget.set_cursor_offset(max(0, min(cursor_offset, len(text))))
|
||||
|
||||
def on_transcribe_state_change(self, state: TranscribeState) -> None:
|
||||
if state == TranscribeState.RECORDING:
|
||||
self._start_recording_ui()
|
||||
elif state == TranscribeState.IDLE:
|
||||
self._stop_recording_ui()
|
||||
|
||||
def on_transcribe_text(self, text: str) -> None:
|
||||
if not self.input_widget:
|
||||
return
|
||||
self.input_widget.insert(text)
|
||||
|
||||
def _start_recording_ui(self) -> None:
|
||||
if not self._voice_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
self.screen.get_widget_by_id("input-box").add_class("border-recording")
|
||||
|
||||
if self.input_widget:
|
||||
self.input_widget.cursor_blink = False
|
||||
self.input_widget.add_class("recording")
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = False
|
||||
self._recording_indicator = RecordingIndicator(self._voice_manager)
|
||||
self.query_one(Horizontal).mount(self._recording_indicator, before=0)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start recording UI", exc_info=e)
|
||||
self._reset_recording_ui()
|
||||
|
||||
def _stop_recording_ui(self) -> None:
|
||||
try:
|
||||
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
|
||||
|
||||
if self.input_widget:
|
||||
self.input_widget.cursor_blink = True
|
||||
self.input_widget.remove_class("recording")
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = True
|
||||
self._update_prompt()
|
||||
if self._recording_indicator:
|
||||
self._recording_indicator.remove()
|
||||
self._recording_indicator = None
|
||||
except Exception as e:
|
||||
logger.error("Failed to stop recording UI", exc_info=e)
|
||||
self._reset_recording_ui()
|
||||
|
||||
def _reset_recording_ui(self) -> None:
|
||||
try:
|
||||
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.input_widget:
|
||||
self.input_widget.cursor_blink = True
|
||||
self.input_widget.remove_class("recording")
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = True
|
||||
self._update_prompt()
|
||||
if self._recording_indicator:
|
||||
try:
|
||||
self._recording_indicator.remove()
|
||||
except Exception:
|
||||
pass
|
||||
self._recording_indicator = None
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class CompletionPopup(Static):
|
|||
label_style = "bold reverse" if idx == selected else "bold"
|
||||
description_style = "italic" if idx == selected else "dim"
|
||||
|
||||
text.append(label, style=label_style)
|
||||
text.append(self._display_label(label), style=label_style)
|
||||
if description:
|
||||
text.append(" ")
|
||||
text.append(description, style=description_style)
|
||||
|
|
@ -41,3 +41,8 @@ class CompletionPopup(Static):
|
|||
|
||||
def show(self) -> None:
|
||||
self.styles.display = "block"
|
||||
|
||||
def _display_label(self, label: str) -> str:
|
||||
if label.startswith("@"):
|
||||
return label[1:]
|
||||
return label
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|||
)
|
||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
|
||||
from vibe.core.agents import AgentSafety
|
||||
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ class ChatInputContainer(Vertical):
|
|||
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
|
||||
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
|
||||
nuage_enabled: bool = False,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -56,6 +58,7 @@ class ChatInputContainer(Vertical):
|
|||
file_watcher_for_autocomplete_getter
|
||||
)
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._voice_manager = voice_manager
|
||||
|
||||
self._completion_manager = MultiCompletionManager([
|
||||
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
|
||||
|
|
@ -90,6 +93,7 @@ class ChatInputContainer(Vertical):
|
|||
history_file=self._history_file,
|
||||
id="input-body",
|
||||
nuage_enabled=self._nuage_enabled,
|
||||
voice_manager=self._voice_manager,
|
||||
)
|
||||
|
||||
yield self._body
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|||
MultiCompletionManager,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
TranscribeState,
|
||||
VoiceManagerPort,
|
||||
)
|
||||
|
||||
InputMode = Literal["!", "/", ">", "&"]
|
||||
|
||||
|
|
@ -37,14 +42,10 @@ class ChatTextArea(TextArea):
|
|||
super().__init__()
|
||||
|
||||
class HistoryPrevious(Message):
|
||||
def __init__(self, prefix: str) -> None:
|
||||
self.prefix = prefix
|
||||
super().__init__()
|
||||
pass
|
||||
|
||||
class HistoryNext(Message):
|
||||
def __init__(self, prefix: str) -> None:
|
||||
self.prefix = prefix
|
||||
super().__init__()
|
||||
pass
|
||||
|
||||
class HistoryReset(Message):
|
||||
"""Message sent when history navigation should be reset."""
|
||||
|
|
@ -56,20 +57,23 @@ class ChatTextArea(TextArea):
|
|||
self.mode = mode
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
nuage_enabled: bool = False,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._input_mode: InputMode = self.DEFAULT_MODE
|
||||
self._history_prefix: str | None = None
|
||||
self._last_text = ""
|
||||
self._navigating_history = False
|
||||
self._last_cursor_col: int = 0
|
||||
self._last_used_prefix: str | None = None
|
||||
self._original_text: str = ""
|
||||
self._cursor_pos_after_load: tuple[int, int] | None = None
|
||||
self._cursor_moved_since_load: bool = False
|
||||
self._completion_manager: MultiCompletionManager | None = None
|
||||
self._app_has_focus: bool = True
|
||||
self._voice_manager = voice_manager
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
if self._app_has_focus:
|
||||
|
|
@ -100,7 +104,6 @@ class ChatTextArea(TextArea):
|
|||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
if not self._navigating_history and self.text != self._last_text:
|
||||
self._reset_prefix()
|
||||
self._original_text = ""
|
||||
self._cursor_pos_after_load = None
|
||||
self._cursor_moved_since_load = False
|
||||
|
|
@ -114,11 +117,6 @@ class ChatTextArea(TextArea):
|
|||
self.get_full_text(), self._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
|
||||
self._history_prefix = None
|
||||
if clear_last_used:
|
||||
self._last_used_prefix = None
|
||||
|
||||
def _mark_cursor_moved_if_needed(self) -> None:
|
||||
if (
|
||||
self._cursor_pos_after_load is not None
|
||||
|
|
@ -126,20 +124,8 @@ class ChatTextArea(TextArea):
|
|||
and self.cursor_location != self._cursor_pos_after_load
|
||||
):
|
||||
self._cursor_moved_since_load = True
|
||||
self._reset_prefix(clear_last_used=False)
|
||||
|
||||
def _get_prefix_up_to_cursor(self) -> str:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
if cursor_row < len(lines):
|
||||
visible_prefix = lines[cursor_row][:cursor_col]
|
||||
if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
|
||||
return self._input_mode + visible_prefix
|
||||
return visible_prefix
|
||||
return ""
|
||||
|
||||
def _handle_history_up(self) -> bool:
|
||||
_, cursor_col = self.cursor_location
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._cursor_pos_after_load is not None
|
||||
and not self._cursor_moved_since_load
|
||||
|
|
@ -150,63 +136,61 @@ class ChatTextArea(TextArea):
|
|||
)
|
||||
|
||||
if should_intercept:
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
||||
if self._history_prefix is None:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryPrevious(self._history_prefix))
|
||||
self.post_message(self.HistoryPrevious())
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_on_loaded_history_entry(self) -> bool:
|
||||
return self._cursor_pos_after_load is not None
|
||||
|
||||
def _has_history_prefix(self) -> bool:
|
||||
return self._history_prefix is not None
|
||||
|
||||
def _has_history_navigation_context(self) -> bool:
|
||||
return self._is_on_loaded_history_entry() or self._has_history_prefix()
|
||||
|
||||
def _should_intercept_history_down(self) -> bool:
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
|
||||
)
|
||||
if history_loaded_and_cursor_unmoved and self._has_history_prefix():
|
||||
if self._is_on_loaded_history_entry() and not self._cursor_moved_since_load:
|
||||
return True
|
||||
|
||||
if not self.navigator.is_last_wrapped_line(self.cursor_location):
|
||||
return False
|
||||
|
||||
return self._has_history_navigation_context()
|
||||
return self._is_on_loaded_history_entry()
|
||||
|
||||
def _handle_history_down(self) -> bool:
|
||||
_, cursor_col = self.cursor_location
|
||||
|
||||
if not self._should_intercept_history_down():
|
||||
return False
|
||||
|
||||
navigating_loaded_history = self._is_on_loaded_history_entry()
|
||||
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
if not navigating_loaded_history:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
||||
if self._history_prefix is None:
|
||||
if navigating_loaded_history and self._last_used_prefix is not None:
|
||||
self._history_prefix = self._last_used_prefix
|
||||
else:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryNext(self._history_prefix))
|
||||
self.post_message(self.HistoryNext())
|
||||
return True
|
||||
|
||||
async def _handle_voice_key(self, event: events.Key) -> bool:
|
||||
if not self._voice_manager:
|
||||
return False
|
||||
|
||||
# Handle key pressed during audio recording
|
||||
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if event.key == "ctrl+c": # Escape is handled in app.py
|
||||
self._voice_manager.cancel_recording()
|
||||
elif self._voice_manager.transcribe_state == TranscribeState.RECORDING:
|
||||
await self._voice_manager.stop_recording()
|
||||
return True
|
||||
|
||||
# Handle audio record keybind
|
||||
if self._voice_manager.is_enabled and event.key == "ctrl+r":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
try:
|
||||
self._voice_manager.start_recording()
|
||||
except RecordingStartError as e:
|
||||
self.notify(str(e), severity="warning")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
|
||||
if await self._handle_voice_key(event):
|
||||
return
|
||||
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
manager = self._completion_manager
|
||||
|
|
@ -223,7 +207,6 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
return
|
||||
|
||||
|
|
@ -232,7 +215,6 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
return
|
||||
|
||||
|
|
@ -323,7 +305,6 @@ class ChatTextArea(TextArea):
|
|||
self.move_cursor((last_row, len(lines[last_row])))
|
||||
|
||||
def reset_history_state(self) -> None:
|
||||
self._reset_prefix()
|
||||
self._original_text = ""
|
||||
self._cursor_pos_after_load = None
|
||||
self._cursor_moved_since_load = False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.cli.textual_ui.app import ChatScroll
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
|
|
@ -67,6 +70,7 @@ class StreamingMessageBase(Static):
|
|||
self._markdown: Markdown | None = None
|
||||
self._stream: MarkdownStream | None = None
|
||||
self._content_initialized = False
|
||||
self._to_write_buffer = ""
|
||||
|
||||
def _get_markdown(self) -> Markdown:
|
||||
if self._markdown is None:
|
||||
|
|
@ -80,23 +84,46 @@ class StreamingMessageBase(Static):
|
|||
self._stream = Markdown.get_stream(self._get_markdown())
|
||||
return self._stream
|
||||
|
||||
def _is_chat_at_bottom(self) -> bool:
|
||||
try:
|
||||
chat = cast("ChatScroll", self.app.query_one("#chat"))
|
||||
return chat.is_at_bottom
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
async def append_content(self, content: str) -> None:
|
||||
if not content:
|
||||
return
|
||||
|
||||
self._content += content
|
||||
if self._should_write_content():
|
||||
|
||||
if not self._should_write_content():
|
||||
return
|
||||
|
||||
if self._is_chat_at_bottom():
|
||||
to_write = self._to_write_buffer + content
|
||||
self._to_write_buffer = ""
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(content)
|
||||
await stream.write(to_write)
|
||||
return
|
||||
|
||||
self._to_write_buffer += content
|
||||
|
||||
async def write_initial_content(self) -> None:
|
||||
if self._content_initialized:
|
||||
return
|
||||
self._content_initialized = True
|
||||
if self._content and self._should_write_content():
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(self._content)
|
||||
self._to_write_buffer = ""
|
||||
|
||||
async def stop_stream(self) -> None:
|
||||
if self._to_write_buffer and self._should_write_content():
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(self._to_write_buffer)
|
||||
self._to_write_buffer = ""
|
||||
|
||||
if self._stream is None:
|
||||
return
|
||||
|
||||
|
|
@ -187,6 +214,7 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
|
|||
await self._markdown.update("")
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(self._content)
|
||||
self._to_write_buffer = ""
|
||||
|
||||
|
||||
class UserCommandMessage(Static):
|
||||
|
|
|
|||
15
vibe/cli/voice_manager/__init__.py
Normal file
15
vibe/cli/voice_manager/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.voice_manager.voice_manager import VoiceManager
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
VoiceManagerPort,
|
||||
VoiceToggleResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecordingStartError",
|
||||
"VoiceManager",
|
||||
"VoiceManagerPort",
|
||||
"VoiceToggleResult",
|
||||
]
|
||||
186
vibe/cli/voice_manager/voice_manager.py
Normal file
186
vibe/cli/voice_manager/voice_manager.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from asyncio import CancelledError, Task, create_task, wait_for
|
||||
from collections.abc import Callable
|
||||
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
TranscribeState,
|
||||
VoiceManagerListener,
|
||||
VoiceToggleResult,
|
||||
)
|
||||
from vibe.core.audio_recorder import AudioRecorderPort
|
||||
from vibe.core.audio_recorder.audio_recorder_port import (
|
||||
AlreadyRecordingError,
|
||||
AudioBackendUnavailableError,
|
||||
NoAudioInputDeviceError,
|
||||
RecordingMode,
|
||||
)
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.transcribe.transcribe_client_port import (
|
||||
TranscribeClientPort,
|
||||
TranscribeDone,
|
||||
TranscribeError,
|
||||
TranscribeSessionCreated,
|
||||
TranscribeTextDelta,
|
||||
)
|
||||
|
||||
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class VoiceManager:
|
||||
def __init__(
|
||||
self,
|
||||
config_getter: Callable[[], VibeConfig],
|
||||
audio_recorder: AudioRecorderPort,
|
||||
transcribe_client: TranscribeClientPort | None,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._audio_recorder = audio_recorder
|
||||
self._transcribe_client = transcribe_client
|
||||
self._transcribe_state = TranscribeState.IDLE
|
||||
self._transcribe_task: Task[None] | None = None
|
||||
self._listeners: list[VoiceManagerListener] = []
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return self._config_getter().voice_mode_enabled
|
||||
|
||||
@property
|
||||
def transcribe_state(self) -> TranscribeState:
|
||||
return self._transcribe_state
|
||||
|
||||
@property
|
||||
def peak(self) -> float:
|
||||
return self._audio_recorder.peak
|
||||
|
||||
def toggle_voice_mode(self) -> VoiceToggleResult:
|
||||
new_state = not self.is_enabled
|
||||
if not new_state:
|
||||
self.cancel_recording()
|
||||
|
||||
VibeConfig.save_updates({"voice_mode_enabled": new_state})
|
||||
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_voice_mode_change(new_state)
|
||||
except Exception:
|
||||
logger.error("Listener raised during voice mode change", exc_info=True)
|
||||
|
||||
return VoiceToggleResult(enabled=new_state)
|
||||
|
||||
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None:
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
return
|
||||
|
||||
if self._transcribe_client is None:
|
||||
logger.warning(
|
||||
"Failed to start recording as the transcribe client is missing"
|
||||
)
|
||||
raise RecordingStartError("Transcribe client is not available")
|
||||
|
||||
model = self._config_getter().get_active_transcribe_model()
|
||||
|
||||
try:
|
||||
self._audio_recorder.start(mode, sample_rate=model.sample_rate)
|
||||
except AlreadyRecordingError:
|
||||
raise RecordingStartError("Recording is already in progress")
|
||||
except AudioBackendUnavailableError:
|
||||
raise RecordingStartError("Audio backend is unavailable")
|
||||
except NoAudioInputDeviceError:
|
||||
raise RecordingStartError("No audio input device found")
|
||||
|
||||
self._set_state(TranscribeState.RECORDING)
|
||||
self._transcribe_task = create_task(self._run_transcription())
|
||||
|
||||
async def stop_recording(self) -> None:
|
||||
if self._transcribe_state != TranscribeState.RECORDING:
|
||||
return
|
||||
should_flush_queue = self._audio_recorder.mode == RecordingMode.STREAM
|
||||
|
||||
if should_flush_queue:
|
||||
self._set_state(TranscribeState.FLUSHING)
|
||||
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
|
||||
|
||||
if self._transcribe_task is not None:
|
||||
try:
|
||||
await wait_for(
|
||||
self._transcribe_task, timeout=TRANSCRIPTION_DRAIN_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("Transcription task timed out, cancelling")
|
||||
self._transcribe_task.cancel()
|
||||
except CancelledError:
|
||||
pass
|
||||
self._transcribe_task = None
|
||||
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
def cancel_recording(self) -> None:
|
||||
if self._transcribe_state == TranscribeState.IDLE:
|
||||
return
|
||||
|
||||
self._audio_recorder.cancel()
|
||||
|
||||
if self._transcribe_task is not None:
|
||||
self._transcribe_task.cancel()
|
||||
self._transcribe_task = None
|
||||
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
def add_listener(self, listener: VoiceManagerListener) -> None:
|
||||
if listener not in self._listeners:
|
||||
self._listeners.append(listener)
|
||||
|
||||
def remove_listener(self, listener: VoiceManagerListener) -> None:
|
||||
try:
|
||||
self._listeners.remove(listener)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def _run_transcription(self) -> None:
|
||||
if self._transcribe_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
audio_stream = self._audio_recorder.audio_stream()
|
||||
|
||||
async for event in self._transcribe_client.transcribe(audio_stream):
|
||||
match event:
|
||||
case TranscribeTextDelta(text=text):
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_transcribe_text(text)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Listener raised during transcribe text",
|
||||
exc_info=True,
|
||||
)
|
||||
case TranscribeDone():
|
||||
pass
|
||||
case TranscribeError(message=msg):
|
||||
raise RuntimeError(msg)
|
||||
case TranscribeSessionCreated():
|
||||
pass
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
except CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Transcription failed", exc_info=exc)
|
||||
self._audio_recorder.cancel()
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
def _set_state(self, state: TranscribeState) -> None:
|
||||
if self._transcribe_state == state:
|
||||
return
|
||||
|
||||
self._transcribe_state = state
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_transcribe_state_change(state)
|
||||
except Exception:
|
||||
logger.error("Listener raised during state change", exc_info=True)
|
||||
56
vibe/cli/voice_manager/voice_manager_port.py
Normal file
56
vibe/cli/voice_manager/voice_manager_port.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
from typing import Protocol
|
||||
|
||||
from vibe.core.audio_recorder.audio_recorder_port import RecordingMode
|
||||
|
||||
|
||||
class TranscribeState(StrEnum):
|
||||
IDLE = auto()
|
||||
RECORDING = auto()
|
||||
FLUSHING = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VoiceToggleResult:
|
||||
enabled: bool
|
||||
|
||||
|
||||
class RecordingStartError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VoiceManagerListener:
|
||||
def on_transcribe_state_change(self, state: TranscribeState) -> None:
|
||||
pass
|
||||
|
||||
def on_voice_mode_change(self, enabled: bool) -> None:
|
||||
pass
|
||||
|
||||
def on_transcribe_text(self, text: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class VoiceManagerPort(Protocol):
|
||||
@property
|
||||
def is_enabled(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def transcribe_state(self) -> TranscribeState: ...
|
||||
|
||||
@property
|
||||
def peak(self) -> float: ...
|
||||
|
||||
def toggle_voice_mode(self) -> VoiceToggleResult: ...
|
||||
|
||||
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None: ...
|
||||
|
||||
async def stop_recording(self) -> None: ...
|
||||
|
||||
def cancel_recording(self) -> None: ...
|
||||
|
||||
def add_listener(self, listener: VoiceManagerListener) -> None: ...
|
||||
|
||||
def remove_listener(self, listener: VoiceManagerListener) -> None: ...
|
||||
|
|
@ -2,13 +2,14 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
import contextlib
|
||||
from enum import StrEnum, auto
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -16,7 +17,7 @@ from pydantic import BaseModel
|
|||
from vibe.cli.terminal_setup import detect_terminal
|
||||
from vibe.core.agents.manager import AgentManager
|
||||
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
|
||||
from vibe.core.config import Backend, ProviderConfig, VibeConfig
|
||||
from vibe.core.config import Backend, ModelConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.exceptions import BackendError
|
||||
from vibe.core.llm.format import (
|
||||
|
|
@ -66,7 +67,6 @@ from vibe.core.types import (
|
|||
ApprovalCallback,
|
||||
ApprovalResponse,
|
||||
AssistantEvent,
|
||||
AsyncApprovalCallback,
|
||||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
|
|
@ -78,7 +78,6 @@ from vibe.core.types import (
|
|||
RateLimitError,
|
||||
ReasoningEvent,
|
||||
Role,
|
||||
SyncApprovalCallback,
|
||||
ToolCall,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
|
|
@ -87,6 +86,7 @@ from vibe.core.types import (
|
|||
UserMessageEvent,
|
||||
)
|
||||
from vibe.core.utils import (
|
||||
CANCELLATION_TAG,
|
||||
TOOL_ERROR_TAG,
|
||||
VIBE_STOP_EVENT_TAG,
|
||||
CancellationReason,
|
||||
|
|
@ -217,6 +217,10 @@ class AgentLoop:
|
|||
def agent_profile(self) -> AgentProfile:
|
||||
return self.agent_manager.active_profile
|
||||
|
||||
@property
|
||||
def base_config(self) -> VibeConfig:
|
||||
return self._base_config
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
return self.agent_manager.config
|
||||
|
|
@ -239,6 +243,10 @@ class AgentLoop:
|
|||
self.config.tools[tool_name].permission = permission
|
||||
self.tool_manager.invalidate_tool(tool_name)
|
||||
|
||||
def refresh_config(self) -> None:
|
||||
self._base_config = VibeConfig.load()
|
||||
self.agent_manager.invalidate_config()
|
||||
|
||||
def emit_new_session_telemetry(self) -> None:
|
||||
entrypoint = (
|
||||
self.entrypoint_metadata.agent_entrypoint
|
||||
|
|
@ -348,15 +356,9 @@ class AgentLoop:
|
|||
if self._max_price is not None:
|
||||
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
|
||||
|
||||
active_model = self.config.get_active_model()
|
||||
if active_model.auto_compact_threshold > 0:
|
||||
self.middleware_pipeline.add(
|
||||
AutoCompactMiddleware(active_model.auto_compact_threshold)
|
||||
)
|
||||
if self.config.context_warnings:
|
||||
self.middleware_pipeline.add(
|
||||
ContextWarningMiddleware(0.5, active_model.auto_compact_threshold)
|
||||
)
|
||||
self.middleware_pipeline.add(AutoCompactMiddleware())
|
||||
if self.config.context_warnings:
|
||||
self.middleware_pipeline.add(ContextWarningMiddleware(0.5))
|
||||
|
||||
self.middleware_pipeline.add(
|
||||
ReadOnlyAgentMiddleware(
|
||||
|
|
@ -425,6 +427,10 @@ class AgentLoop:
|
|||
messages=self.messages, stats=self.stats, config=self.config
|
||||
)
|
||||
|
||||
def _build_metadata(self) -> dict[str, str]:
|
||||
base = self.entrypoint_metadata.model_dump() if self.entrypoint_metadata else {}
|
||||
return base | {"session_id": self.session_id}
|
||||
|
||||
def _get_extra_headers(self, provider: ProviderConfig) -> dict[str, str]:
|
||||
headers: dict[str, str] = {
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
|
|
@ -577,39 +583,35 @@ class AgentLoop:
|
|||
tool_instance = self.tool_manager.get(tool_call.tool_name)
|
||||
except Exception as exc:
|
||||
error_msg = f"Error getting tool '{tool_call.tool_name}': {exc}"
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, error_msg, "failure")
|
||||
yield self._tool_failure_event(tool_call, error_msg)
|
||||
return
|
||||
|
||||
decision = await self._should_execute_tool(
|
||||
tool_instance, tool_call.validated_args, tool_call.call_id
|
||||
)
|
||||
|
||||
if decision.verdict == ToolExecutionResponse.SKIP:
|
||||
self.stats.tool_calls_rejected += 1
|
||||
skip_reason = decision.feedback or str(
|
||||
get_user_cancellation_message(
|
||||
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
|
||||
)
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
skipped=True,
|
||||
skip_reason=skip_reason,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
|
||||
return
|
||||
|
||||
self.stats.tool_calls_agreed += 1
|
||||
|
||||
decision: ToolDecision | None = None
|
||||
try:
|
||||
decision = await self._should_execute_tool(
|
||||
tool_instance, tool_call.validated_args, tool_call.call_id
|
||||
)
|
||||
|
||||
if decision.verdict == ToolExecutionResponse.SKIP:
|
||||
self.stats.tool_calls_rejected += 1
|
||||
skip_reason = decision.feedback or str(
|
||||
get_user_cancellation_message(
|
||||
CancellationReason.TOOL_SKIPPED, tool_call.tool_name
|
||||
)
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
skipped=True,
|
||||
skip_reason=skip_reason,
|
||||
cancelled=f"<{CANCELLATION_TAG}>" in skip_reason,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, skip_reason, "skipped", decision)
|
||||
return
|
||||
|
||||
self.stats.tool_calls_agreed += 1
|
||||
|
||||
start_time = time.perf_counter()
|
||||
result_model = None
|
||||
async for item in tool_instance.invoke(
|
||||
|
|
@ -637,6 +639,9 @@ class AgentLoop:
|
|||
|
||||
result_dict = result_model.model_dump()
|
||||
text = "\n".join(f"{k}: {v}" for k, v in result_dict.items())
|
||||
extra = tool_instance.get_result_extra(result_model)
|
||||
if extra:
|
||||
text += "\n\n" + extra
|
||||
self._handle_tool_response(
|
||||
tool_call, text, "success", decision, result_dict
|
||||
)
|
||||
|
|
@ -644,6 +649,7 @@ class AgentLoop:
|
|||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
result=result_model,
|
||||
cancelled=getattr(result_model, "cancelled", False),
|
||||
duration=duration,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
|
@ -653,35 +659,27 @@ class AgentLoop:
|
|||
cancel = str(
|
||||
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
|
||||
)
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=cancel,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
self._handle_tool_response(tool_call, cancel, "failure", decision)
|
||||
self.stats.tool_calls_failed += 1
|
||||
yield self._tool_failure_event(tool_call, cancel, decision, cancelled=True)
|
||||
raise
|
||||
|
||||
except (ToolError, ToolPermissionError) as exc:
|
||||
except Exception as exc:
|
||||
error_msg = f"<{TOOL_ERROR_TAG}>{tool_instance.get_name()} failed: {exc}</{TOOL_ERROR_TAG}>"
|
||||
yield ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
if isinstance(exc, ToolPermissionError):
|
||||
self.stats.tool_calls_agreed -= 1
|
||||
self.stats.tool_calls_rejected += 1
|
||||
else:
|
||||
self.stats.tool_calls_failed += 1
|
||||
self._handle_tool_response(tool_call, error_msg, "failure", decision)
|
||||
yield self._tool_failure_event(tool_call, error_msg, decision)
|
||||
|
||||
async def _handle_tool_calls(
|
||||
self, resolved: ResolvedMessage
|
||||
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
|
||||
async for event in self._emit_failed_tool_events(resolved.failed_calls):
|
||||
yield event
|
||||
if not resolved.tool_calls:
|
||||
return
|
||||
|
||||
for tool_call in resolved.tool_calls:
|
||||
yield ToolCallEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
|
|
@ -689,8 +687,62 @@ class AgentLoop:
|
|||
args=tool_call.validated_args,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
async for event in self._process_one_tool_call(tool_call):
|
||||
|
||||
async for event in self._run_tools_concurrently(resolved.tool_calls):
|
||||
yield event
|
||||
|
||||
async def _execute_tool_to_queue(
|
||||
self,
|
||||
tc: ResolvedToolCall,
|
||||
queue: asyncio.Queue[ToolCallEvent | ToolResultEvent | ToolStreamEvent | None],
|
||||
) -> None:
|
||||
"""Run a single tool call, sending events to the queue."""
|
||||
async for event in self._process_one_tool_call(tc):
|
||||
await queue.put(event)
|
||||
|
||||
async def _run_tools_concurrently(
|
||||
self, tool_calls: list[ResolvedToolCall]
|
||||
) -> AsyncGenerator[ToolCallEvent | ToolResultEvent | ToolStreamEvent]:
|
||||
"""Execute multiple tool calls concurrently, yielding events as they arrive."""
|
||||
queue: asyncio.Queue[
|
||||
ToolCallEvent | ToolResultEvent | ToolStreamEvent | None
|
||||
] = asyncio.Queue()
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(self._execute_tool_to_queue(tc, queue))
|
||||
for tc in tool_calls
|
||||
]
|
||||
|
||||
async def _signal_when_all_done() -> None:
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
finally:
|
||||
await queue.put(None)
|
||||
|
||||
monitor = asyncio.create_task(_signal_when_all_done())
|
||||
|
||||
try:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if event is None:
|
||||
break
|
||||
yield event
|
||||
except GeneratorExit:
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
finally:
|
||||
if not monitor.done():
|
||||
monitor.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await monitor
|
||||
|
||||
def _handle_tool_response(
|
||||
self,
|
||||
|
|
@ -714,8 +766,27 @@ class AgentLoop:
|
|||
result=result,
|
||||
)
|
||||
|
||||
async def _chat(self, max_tokens: int | None = None) -> LLMChunk:
|
||||
active_model = self.config.get_active_model()
|
||||
def _tool_failure_event(
|
||||
self,
|
||||
tool_call: ResolvedToolCall,
|
||||
error_msg: str,
|
||||
decision: ToolDecision | None = None,
|
||||
cancelled: bool = False,
|
||||
) -> ToolResultEvent:
|
||||
"""Create a ToolResultEvent for a failed tool and record the failure."""
|
||||
self._handle_tool_response(tool_call, error_msg, "failure", decision)
|
||||
return ToolResultEvent(
|
||||
tool_name=tool_call.tool_name,
|
||||
tool_class=tool_call.tool_class,
|
||||
error=error_msg,
|
||||
cancelled=cancelled,
|
||||
tool_call_id=tool_call.call_id,
|
||||
)
|
||||
|
||||
async def _chat(
|
||||
self, max_tokens: int | None = None, model_override: ModelConfig | None = None
|
||||
) -> LLMChunk:
|
||||
active_model = model_override or self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
||||
available_tools = self.format_handler.get_available_tools(self.tool_manager)
|
||||
|
|
@ -731,9 +802,7 @@ class AgentLoop:
|
|||
tool_choice=tool_choice,
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
metadata=self._build_metadata(),
|
||||
)
|
||||
end_time = time.perf_counter()
|
||||
|
||||
|
|
@ -777,9 +846,7 @@ class AgentLoop:
|
|||
tool_choice=tool_choice,
|
||||
extra_headers=self._get_extra_headers(provider),
|
||||
max_tokens=max_tokens,
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
metadata=self._build_metadata(),
|
||||
):
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
chunk.message
|
||||
|
|
@ -855,26 +922,17 @@ class AgentLoop:
|
|||
approval_type=ToolPermission.ASK,
|
||||
feedback="Tool execution not permitted.",
|
||||
)
|
||||
if asyncio.iscoroutinefunction(self.approval_callback):
|
||||
async_callback = cast(AsyncApprovalCallback, self.approval_callback)
|
||||
response, feedback = await async_callback(tool_name, args, tool_call_id)
|
||||
else:
|
||||
sync_callback = cast(SyncApprovalCallback, self.approval_callback)
|
||||
response, feedback = sync_callback(tool_name, args, tool_call_id)
|
||||
response, feedback = await self.approval_callback(tool_name, args, tool_call_id)
|
||||
|
||||
match response:
|
||||
case ApprovalResponse.YES:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.EXECUTE,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback=feedback,
|
||||
)
|
||||
case ApprovalResponse.NO:
|
||||
return ToolDecision(
|
||||
verdict=ToolExecutionResponse.SKIP,
|
||||
approval_type=ToolPermission.ASK,
|
||||
feedback=feedback,
|
||||
)
|
||||
verdict = ToolExecutionResponse.EXECUTE
|
||||
case _:
|
||||
verdict = ToolExecutionResponse.SKIP
|
||||
|
||||
return ToolDecision(
|
||||
verdict=verdict, approval_type=ToolPermission.ASK, feedback=feedback
|
||||
)
|
||||
|
||||
def _clean_message_history(self) -> None:
|
||||
ACCEPTABLE_HISTORY_SIZE = 2
|
||||
|
|
@ -892,17 +950,19 @@ class AgentLoop:
|
|||
expected_responses = len(msg.tool_calls)
|
||||
|
||||
if expected_responses > 0:
|
||||
actual_responses = 0
|
||||
responded_ids: set[str] = set()
|
||||
j = i + 1
|
||||
while j < len(self.messages) and self.messages[j].role == "tool":
|
||||
actual_responses += 1
|
||||
if self.messages[j].tool_call_id:
|
||||
responded_ids.add(self.messages[j].tool_call_id)
|
||||
j += 1
|
||||
|
||||
if actual_responses < expected_responses:
|
||||
insertion_point = i + 1 + actual_responses
|
||||
if len(responded_ids) < expected_responses:
|
||||
insertion_point = j
|
||||
|
||||
for call_idx in range(actual_responses, expected_responses):
|
||||
tool_call_data = msg.tool_calls[call_idx]
|
||||
for tool_call_data in msg.tool_calls:
|
||||
if (tool_call_data.id or "") in responded_ids:
|
||||
continue
|
||||
|
||||
empty_response = LLMMessage(
|
||||
role=Role.tool,
|
||||
|
|
@ -990,7 +1050,9 @@ class AgentLoop:
|
|||
self.messages.append(
|
||||
LLMMessage(role=Role.user, content=summary_request)
|
||||
)
|
||||
summary_result = await self._chat()
|
||||
summary_result = await self._chat(
|
||||
model_override=self.config.get_compaction_model()
|
||||
)
|
||||
|
||||
if summary_result.usage is None:
|
||||
raise AgentLoopLLMResponseError(
|
||||
|
|
@ -1010,9 +1072,7 @@ class AgentLoop:
|
|||
messages=self.messages,
|
||||
tools=self.format_handler.get_available_tools(self.tool_manager),
|
||||
extra_headers={"user-agent": get_user_agent(provider.backend)},
|
||||
metadata=self.entrypoint_metadata.model_dump()
|
||||
if self.entrypoint_metadata
|
||||
else None,
|
||||
metadata=self._build_metadata(),
|
||||
)
|
||||
|
||||
self.stats.context_tokens = actual_context_tokens
|
||||
|
|
|
|||
|
|
@ -50,19 +50,25 @@ class AgentManager:
|
|||
|
||||
@property
|
||||
def available_agents(self) -> dict[str, AgentProfile]:
|
||||
installed = self._config.installed_agents
|
||||
base = {
|
||||
name: profile
|
||||
for name, profile in self._available.items()
|
||||
if not profile.install_required or name in installed
|
||||
}
|
||||
if self._config.enabled_agents:
|
||||
return {
|
||||
name: profile
|
||||
for name, profile in self._available.items()
|
||||
for name, profile in base.items()
|
||||
if name_matches(name, self._config.enabled_agents)
|
||||
}
|
||||
if self._config.disabled_agents:
|
||||
return {
|
||||
name: profile
|
||||
for name, profile in self._available.items()
|
||||
for name, profile in base.items()
|
||||
if not name_matches(name, self._config.disabled_agents)
|
||||
}
|
||||
return dict(self._available)
|
||||
return base
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class BuiltinAgentName(StrEnum):
|
|||
ACCEPT_EDITS = "accept-edits"
|
||||
AUTO_APPROVE = "auto-approve"
|
||||
EXPLORE = "explore"
|
||||
LEAN = "lean"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -51,6 +52,7 @@ class AgentProfile:
|
|||
safety: AgentSafety
|
||||
agent_type: AgentType = AgentType.AGENT
|
||||
overrides: dict[str, Any] = field(default_factory=dict)
|
||||
install_required: bool = False
|
||||
|
||||
def apply_to_config(self, base: VibeConfig) -> VibeConfig:
|
||||
from vibe.core.config import VibeConfig as VC
|
||||
|
|
@ -134,10 +136,51 @@ EXPLORE = AgentProfile(
|
|||
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
|
||||
)
|
||||
|
||||
LEAN = AgentProfile(
|
||||
name=BuiltinAgentName.LEAN,
|
||||
display_name="Lean",
|
||||
description="Specialized mode for Lean 4 code analysis, proof assistance, and theorem proving",
|
||||
safety=AgentSafety.NEUTRAL,
|
||||
agent_type=AgentType.AGENT,
|
||||
install_required=True,
|
||||
overrides={
|
||||
"system_prompt_id": "lean",
|
||||
"active_model": "leanstral",
|
||||
"providers": [
|
||||
{
|
||||
"name": "mistral-testing",
|
||||
"api_base": "https://api.mistral.ai/v1",
|
||||
"api_key_env_var": "MISTRAL_API_KEY",
|
||||
"api_style": "reasoning",
|
||||
"backend": "generic",
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"name": "labs-leanstral-2603",
|
||||
"provider": "mistral-testing",
|
||||
"alias": "leanstral",
|
||||
"thinking": "high",
|
||||
"temperature": 1.0,
|
||||
"auto_compact_threshold": 168_000,
|
||||
}
|
||||
],
|
||||
"compaction_model": {
|
||||
"name": "mistral-vibe-cli-latest",
|
||||
"provider": "mistral-testing",
|
||||
"alias": "devstral-compact",
|
||||
"temperature": 0.2,
|
||||
"thinking": "off",
|
||||
},
|
||||
"tools": {"bash": {"default_timeout": 1200}},
|
||||
},
|
||||
)
|
||||
|
||||
BUILTIN_AGENTS: dict[str, AgentProfile] = {
|
||||
BuiltinAgentName.DEFAULT: DEFAULT,
|
||||
BuiltinAgentName.PLAN: PLAN,
|
||||
BuiltinAgentName.ACCEPT_EDITS: ACCEPT_EDITS,
|
||||
BuiltinAgentName.AUTO_APPROVE: AUTO_APPROVE,
|
||||
BuiltinAgentName.EXPLORE: EXPLORE,
|
||||
BuiltinAgentName.LEAN: LEAN,
|
||||
}
|
||||
|
|
|
|||
23
vibe/core/audio_recorder/__init__.py
Normal file
23
vibe/core/audio_recorder/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.audio_recorder.audio_recorder import AudioRecorder
|
||||
from vibe.core.audio_recorder.audio_recorder_port import (
|
||||
AlreadyRecordingError,
|
||||
AudioBackendUnavailableError,
|
||||
AudioRecorderPort,
|
||||
AudioRecording,
|
||||
IncompatibleSampleRateError,
|
||||
NoAudioInputDeviceError,
|
||||
RecordingMode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AlreadyRecordingError",
|
||||
"AudioBackendUnavailableError",
|
||||
"AudioRecorder",
|
||||
"AudioRecorderPort",
|
||||
"AudioRecording",
|
||||
"IncompatibleSampleRateError",
|
||||
"NoAudioInputDeviceError",
|
||||
"RecordingMode",
|
||||
]
|
||||
286
vibe/core/audio_recorder/audio_recorder.py
Normal file
286
vibe/core/audio_recorder/audio_recorder.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import io
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
import wave
|
||||
|
||||
from vibe.core.audio_recorder.audio_recorder_port import (
|
||||
AlreadyRecordingError,
|
||||
AudioBackendUnavailableError,
|
||||
AudioRecording,
|
||||
IncompatibleSampleRateError,
|
||||
NoAudioInputDeviceError,
|
||||
RecordingMode,
|
||||
)
|
||||
from vibe.core.logger import logger
|
||||
|
||||
try:
|
||||
import sounddevice as sd
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sounddevice import CallbackFlags, RawInputStream
|
||||
except OSError:
|
||||
sd = None # type: ignore[assignment]
|
||||
|
||||
DEFAULT_SAMPLE_RATE = 48_000
|
||||
DEFAULT_CHANNELS = 1
|
||||
DTYPE = "int16"
|
||||
DEFAULT_BLOCKSIZE = 4096
|
||||
DEFAULT_SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
|
||||
INT16_ABS_MAX = 2**15 - 1
|
||||
DRAIN_TIMEOUT = 5.0
|
||||
DEFAULT_MAX_DURATION = 300.0 # 5 min
|
||||
|
||||
|
||||
class AudioRecorder:
|
||||
"""Records audio from the default microphone using sounddevice.
|
||||
|
||||
Supports both buffer mode (stop returns WAV bytes) and streaming
|
||||
mode (async generator yields chunks).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._mode: RecordingMode = RecordingMode.BUFFER
|
||||
self._stream: RawInputStream | None = None
|
||||
self._frames: list[bytes] = []
|
||||
self._peak: float = 0.0
|
||||
self._recording: bool = False
|
||||
self._start_time: float = 0.0
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._audio_queue: asyncio.Queue[bytes | None] | None = None
|
||||
self._audio_queue_drained: threading.Event | None = None
|
||||
self._max_duration_timer: threading.Timer | None = None
|
||||
self._on_expire: Callable[[AudioRecording], object] | None = None
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
return self._recording
|
||||
|
||||
@property
|
||||
def mode(self) -> RecordingMode:
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def peak(self) -> float:
|
||||
"""Current audio peak level normalized to [0.0, 1.0], updated per audio block."""
|
||||
return self._peak
|
||||
|
||||
def start(
|
||||
self,
|
||||
mode: RecordingMode,
|
||||
*,
|
||||
sample_rate: int = DEFAULT_SAMPLE_RATE,
|
||||
channels: int = DEFAULT_CHANNELS,
|
||||
max_duration: float = DEFAULT_MAX_DURATION,
|
||||
on_expire: Callable[[AudioRecording], object] | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if self._recording:
|
||||
raise AlreadyRecordingError("Already recording")
|
||||
|
||||
if not sd:
|
||||
error_message = "sounddevice is not available, audio recording disabled"
|
||||
logger.error(error_message)
|
||||
raise AudioBackendUnavailableError(error_message)
|
||||
|
||||
try:
|
||||
sample_rate = self._guard_audio_input(sample_rate, channels)
|
||||
except NoAudioInputDeviceError as exc:
|
||||
logger.error("No audio input device available, recording disabled")
|
||||
raise exc
|
||||
except IncompatibleSampleRateError as exc:
|
||||
logger.warning(
|
||||
"Requested sample rate %d Hz not supported, falling back to %d Hz",
|
||||
sample_rate,
|
||||
exc.fallback_sample_rate,
|
||||
)
|
||||
sample_rate = exc.fallback_sample_rate
|
||||
|
||||
self._mode = mode
|
||||
self._sample_rate = sample_rate
|
||||
self._channels = channels
|
||||
self._peak = 0.0
|
||||
self._start_time = time.monotonic()
|
||||
self._frames = []
|
||||
|
||||
if mode == RecordingMode.BUFFER:
|
||||
self._audio_queue = None
|
||||
self._loop = None
|
||||
self._audio_queue_drained = None
|
||||
else:
|
||||
self._audio_queue_drained = threading.Event()
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._audio_queue = asyncio.Queue()
|
||||
except RuntimeError:
|
||||
self._loop = None
|
||||
self._audio_queue = None
|
||||
|
||||
self._stream = sd.RawInputStream(
|
||||
samplerate=self._sample_rate,
|
||||
channels=self._channels,
|
||||
dtype=DTYPE,
|
||||
blocksize=DEFAULT_BLOCKSIZE,
|
||||
callback=self._audio_callback,
|
||||
)
|
||||
self._stream.start()
|
||||
self._recording = True
|
||||
|
||||
self._on_expire = on_expire
|
||||
self._start_max_duration_timer(max_duration)
|
||||
|
||||
def stop(self, *, wait_for_queue_drained: bool = True) -> AudioRecording:
|
||||
with self._lock:
|
||||
if not self._recording or self._stream is None:
|
||||
return AudioRecording(data=b"", duration=0.0)
|
||||
|
||||
self._reset_max_duration_timer()
|
||||
|
||||
self._stop_stream()
|
||||
self._recording = False
|
||||
duration = time.monotonic() - self._start_time
|
||||
|
||||
if self._mode == RecordingMode.BUFFER:
|
||||
wav_data = self._encode_wav()
|
||||
self._frames = []
|
||||
return AudioRecording(data=wav_data, duration=duration)
|
||||
|
||||
loop = self._loop
|
||||
self._push_sentinel()
|
||||
try:
|
||||
on_event_loop = asyncio.get_running_loop() is loop
|
||||
except RuntimeError:
|
||||
on_event_loop = False
|
||||
if (
|
||||
wait_for_queue_drained
|
||||
and self._audio_queue_drained is not None
|
||||
and not on_event_loop
|
||||
):
|
||||
self._audio_queue_drained.wait(timeout=DRAIN_TIMEOUT)
|
||||
return AudioRecording(data=b"", duration=duration)
|
||||
|
||||
def cancel(self) -> None:
|
||||
with self._lock:
|
||||
if not self._recording or self._stream is None:
|
||||
return
|
||||
|
||||
self._reset_max_duration_timer()
|
||||
self._stop_stream()
|
||||
self._recording = False
|
||||
|
||||
if self._mode == RecordingMode.BUFFER:
|
||||
self._frames = []
|
||||
else:
|
||||
self._push_sentinel()
|
||||
|
||||
async def audio_stream(self) -> AsyncGenerator[bytes, None]:
|
||||
queue = self._audio_queue
|
||||
if queue is None:
|
||||
return
|
||||
audio_queue_drained = self._audio_queue_drained
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
if audio_queue_drained is not None:
|
||||
audio_queue_drained.set()
|
||||
|
||||
def _audio_callback(
|
||||
self, indata: bytes, frames: int, time_info: object, status: CallbackFlags
|
||||
) -> None:
|
||||
if status:
|
||||
logger.warning("Audio callback status: %s", status)
|
||||
|
||||
raw = bytes(indata)
|
||||
|
||||
n_samples = frames * self._channels
|
||||
if n_samples > 0:
|
||||
samples = struct.unpack(f"<{n_samples}h", raw)
|
||||
self._peak = min(max(abs(s) for s in samples) / INT16_ABS_MAX, 1.0)
|
||||
|
||||
if self._mode == RecordingMode.BUFFER:
|
||||
self._frames.append(raw)
|
||||
|
||||
if (
|
||||
self._mode == RecordingMode.STREAM
|
||||
and self._loop is not None
|
||||
and self._audio_queue is not None
|
||||
):
|
||||
self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, raw)
|
||||
|
||||
def _stop_stream(self) -> None:
|
||||
if self._stream is not None:
|
||||
self._stream.stop()
|
||||
self._stream.close()
|
||||
self._stream = None
|
||||
|
||||
def _push_sentinel(self) -> None:
|
||||
"""Push None to the audio queue to signal end-of-stream to the consumer."""
|
||||
if self._loop is not None and self._audio_queue is not None:
|
||||
self._loop.call_soon_threadsafe(self._audio_queue.put_nowait, None)
|
||||
self._audio_queue = None
|
||||
self._loop = None
|
||||
|
||||
@staticmethod
|
||||
def _guard_audio_input(sample_rate: int, channels: int) -> int:
|
||||
if sd is None:
|
||||
raise RuntimeError("sounddevice is not available")
|
||||
try:
|
||||
device_info = sd.query_devices(kind="input")
|
||||
except Exception as exc:
|
||||
raise NoAudioInputDeviceError("No audio input device available") from exc
|
||||
|
||||
try:
|
||||
sd.check_input_settings(
|
||||
samplerate=sample_rate, channels=channels, dtype=DTYPE
|
||||
)
|
||||
except sd.PortAudioError as exc:
|
||||
fallback = int(device_info["default_samplerate"])
|
||||
raise IncompatibleSampleRateError(
|
||||
f"Requested sample rate {sample_rate} Hz is not supported by the default "
|
||||
f"input device; device default is {fallback} Hz",
|
||||
fallback_sample_rate=fallback,
|
||||
) from exc
|
||||
|
||||
return sample_rate
|
||||
|
||||
def _encode_wav(self) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(self._channels)
|
||||
wf.setsampwidth(DEFAULT_SAMPLE_WIDTH)
|
||||
wf.setframerate(self._sample_rate)
|
||||
wf.writeframes(b"".join(self._frames))
|
||||
return buf.getvalue()
|
||||
|
||||
def _on_max_duration_expired(self) -> None:
|
||||
result = self.stop()
|
||||
if self._on_expire is not None:
|
||||
self._on_expire(result)
|
||||
|
||||
def _start_max_duration_timer(self, max_duration: float) -> None:
|
||||
if max_duration <= 0:
|
||||
return
|
||||
|
||||
self._max_duration_timer = threading.Timer(
|
||||
max_duration, self._on_max_duration_expired
|
||||
)
|
||||
self._max_duration_timer.daemon = True
|
||||
self._max_duration_timer.start()
|
||||
|
||||
def _reset_max_duration_timer(self) -> None:
|
||||
if self._max_duration_timer is None:
|
||||
return
|
||||
|
||||
self._max_duration_timer.cancel()
|
||||
self._max_duration_timer = None
|
||||
64
vibe/core/audio_recorder/audio_recorder_port.py
Normal file
64
vibe/core/audio_recorder/audio_recorder_port.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class RecordingMode(StrEnum):
|
||||
BUFFER = auto()
|
||||
STREAM = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AudioRecording:
|
||||
"""Result of a completed recording."""
|
||||
|
||||
data: bytes
|
||||
duration: float
|
||||
|
||||
|
||||
class AlreadyRecordingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AudioBackendUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoAudioInputDeviceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class IncompatibleSampleRateError(Exception):
|
||||
def __init__(self, message: str, fallback_sample_rate: int) -> None:
|
||||
super().__init__(message)
|
||||
self.fallback_sample_rate = fallback_sample_rate
|
||||
|
||||
|
||||
class AudioRecorderPort(Protocol):
|
||||
@property
|
||||
def is_recording(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def peak(self) -> float: ...
|
||||
|
||||
@property
|
||||
def mode(self) -> RecordingMode: ...
|
||||
|
||||
def start(
|
||||
self,
|
||||
mode: RecordingMode,
|
||||
*,
|
||||
sample_rate: int = ...,
|
||||
channels: int = ...,
|
||||
max_duration: float = ...,
|
||||
on_expire: Callable[[AudioRecording], object] | None = ...,
|
||||
) -> None: ...
|
||||
|
||||
def stop(self, *, wait_for_queue_drained: bool = ...) -> AudioRecording: ...
|
||||
|
||||
def cancel(self) -> None: ...
|
||||
|
||||
def audio_stream(self) -> AsyncGenerator[bytes, None]: ...
|
||||
|
|
@ -5,6 +5,10 @@ from pathlib import Path
|
|||
from typing import NamedTuple
|
||||
|
||||
from vibe.core.autocompletion.file_indexer import FileIndexer, IndexEntry
|
||||
from vibe.core.autocompletion.file_indexer.store import (
|
||||
ASCII_CODEPOINT_LIMIT,
|
||||
build_ascii_mask,
|
||||
)
|
||||
from vibe.core.autocompletion.fuzzy import fuzzy_match
|
||||
|
||||
DEFAULT_MAX_ENTRIES_TO_PROCESS = 32000
|
||||
|
|
@ -67,6 +71,18 @@ class CommandCompleter(Completer):
|
|||
|
||||
|
||||
class PathCompleter(Completer):
|
||||
class MatchRank(NamedTuple):
|
||||
exact_directory: int
|
||||
immediate_child_of_exact_path: int
|
||||
exact_filename: int
|
||||
preferred_stem_match: int
|
||||
exact_stem: int
|
||||
stem_prefix: int
|
||||
name_prefix: int
|
||||
extension_match: int
|
||||
fuzzy_score: float
|
||||
shallow_path: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS,
|
||||
|
|
@ -82,6 +98,7 @@ class PathCompleter(Completer):
|
|||
search_pattern: str
|
||||
path_prefix: str
|
||||
immediate_only: bool
|
||||
search_pattern_ascii_mask: int | None
|
||||
|
||||
def _extract_partial(self, before_cursor: str) -> str | None:
|
||||
if "@" not in before_cursor:
|
||||
|
|
@ -97,11 +114,16 @@ class PathCompleter(Completer):
|
|||
|
||||
def _build_search_context(self, partial_path: str) -> _SearchContext:
|
||||
suffix = partial_path.split("/")[-1]
|
||||
search_pattern_ascii_mask = self._build_query_ascii_mask(partial_path)
|
||||
|
||||
if not partial_path:
|
||||
# "@" => show top-level dir and files
|
||||
return self._SearchContext(
|
||||
search_pattern="", path_prefix="", suffix=suffix, immediate_only=True
|
||||
search_pattern="",
|
||||
path_prefix="",
|
||||
suffix=suffix,
|
||||
immediate_only=True,
|
||||
search_pattern_ascii_mask=search_pattern_ascii_mask,
|
||||
)
|
||||
|
||||
if partial_path.endswith("/"):
|
||||
|
|
@ -111,6 +133,7 @@ class PathCompleter(Completer):
|
|||
path_prefix=partial_path,
|
||||
suffix=suffix,
|
||||
immediate_only=True,
|
||||
search_pattern_ascii_mask=search_pattern_ascii_mask,
|
||||
)
|
||||
|
||||
return self._SearchContext(
|
||||
|
|
@ -119,29 +142,40 @@ class PathCompleter(Completer):
|
|||
path_prefix="",
|
||||
suffix=suffix,
|
||||
immediate_only=False,
|
||||
search_pattern_ascii_mask=search_pattern_ascii_mask,
|
||||
)
|
||||
|
||||
def _build_query_ascii_mask(self, pattern: str) -> int | None:
|
||||
if any(ord(char) >= ASCII_CODEPOINT_LIMIT for char in pattern):
|
||||
return None
|
||||
return build_ascii_mask(pattern.lower())
|
||||
|
||||
def _is_immediate_child_of_prefix(self, path_str: str, prefix: str) -> bool:
|
||||
prefix_without_slash = prefix.rstrip("/")
|
||||
prefix_with_slash = f"{prefix_without_slash}/"
|
||||
|
||||
if path_str.startswith(prefix_with_slash):
|
||||
after_prefix = path_str[len(prefix_with_slash) :]
|
||||
else:
|
||||
idx = path_str.find(prefix_with_slash)
|
||||
if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
|
||||
return False
|
||||
after_prefix = path_str[idx + len(prefix_with_slash) :]
|
||||
|
||||
return bool(after_prefix) and "/" not in after_prefix
|
||||
|
||||
def _matches_prefix(self, entry: IndexEntry, context: _SearchContext) -> bool:
|
||||
path_str = entry.rel
|
||||
|
||||
if context.path_prefix:
|
||||
prefix_without_slash = context.path_prefix.rstrip("/")
|
||||
prefix_with_slash = f"{prefix_without_slash}/"
|
||||
|
||||
if path_str == prefix_without_slash and entry.is_dir:
|
||||
# do not suggest the dir itself (e.g. "@src/" => don't suggest "@src/")
|
||||
return False
|
||||
|
||||
if path_str.startswith(prefix_with_slash):
|
||||
after_prefix = path_str[len(prefix_with_slash) :]
|
||||
else:
|
||||
idx = path_str.find(prefix_with_slash)
|
||||
if idx == -1 or (idx > 0 and path_str[idx - 1] != "/"):
|
||||
return False
|
||||
after_prefix = path_str[idx + len(prefix_with_slash) :]
|
||||
|
||||
# only suggest files/dirs that are immediate children of the prefix
|
||||
return bool(after_prefix) and "/" not in after_prefix
|
||||
return self._is_immediate_child_of_prefix(path_str, context.path_prefix)
|
||||
|
||||
if context.immediate_only and "/" in path_str:
|
||||
# when user just typed "@", only show top-level entries
|
||||
|
|
@ -153,15 +187,72 @@ class PathCompleter(Completer):
|
|||
def _is_visible(self, entry: IndexEntry, context: _SearchContext) -> bool:
|
||||
return not (entry.name.startswith(".") and not context.suffix.startswith("."))
|
||||
|
||||
def _can_possibly_fuzzy_match(
|
||||
self, entry: IndexEntry, context: _SearchContext
|
||||
) -> bool:
|
||||
if context.search_pattern_ascii_mask is None:
|
||||
return True
|
||||
return (
|
||||
entry.ascii_mask & context.search_pattern_ascii_mask
|
||||
) == context.search_pattern_ascii_mask
|
||||
|
||||
def _format_label(self, entry: IndexEntry) -> str:
|
||||
suffix = "/" if entry.is_dir else ""
|
||||
return f"@{entry.rel}{suffix}"
|
||||
|
||||
def _build_match_rank(
|
||||
self, entry: IndexEntry, context: _SearchContext, fuzzy_score: float
|
||||
) -> MatchRank:
|
||||
query = context.suffix.lower()
|
||||
if not query:
|
||||
return self.MatchRank(
|
||||
exact_directory=0,
|
||||
immediate_child_of_exact_path=0,
|
||||
exact_filename=0,
|
||||
preferred_stem_match=0,
|
||||
exact_stem=0,
|
||||
stem_prefix=0,
|
||||
name_prefix=0,
|
||||
extension_match=0,
|
||||
fuzzy_score=fuzzy_score,
|
||||
shallow_path=-entry.rel.count("/"),
|
||||
)
|
||||
|
||||
name = entry.name.lower()
|
||||
rel = entry.rel.lower()
|
||||
stem = Path(entry.name).stem.lower()
|
||||
extension = Path(entry.name).suffix.lower()
|
||||
query_extension = Path(query).suffix.lower()
|
||||
query_stem = Path(query).stem.lower()
|
||||
query_looks_like_filename = "." in query
|
||||
query_looks_like_path = "/" in context.search_pattern
|
||||
exact_directory = int(entry.is_dir and rel == context.search_pattern.lower())
|
||||
immediate_child_of_exact_path = int(
|
||||
query_looks_like_path
|
||||
and self._is_immediate_child_of_prefix(rel, context.search_pattern.lower())
|
||||
)
|
||||
|
||||
return self.MatchRank(
|
||||
exact_directory=exact_directory,
|
||||
immediate_child_of_exact_path=immediate_child_of_exact_path,
|
||||
exact_filename=int(query_looks_like_filename and name == query),
|
||||
preferred_stem_match=int(stem == query and extension != ".lock"),
|
||||
exact_stem=int(
|
||||
stem == query or (query_looks_like_filename and stem == query_stem)
|
||||
),
|
||||
stem_prefix=int(
|
||||
stem.startswith(query_stem if query_looks_like_filename else query)
|
||||
),
|
||||
name_prefix=int(name.startswith(query)),
|
||||
extension_match=int(bool(query_extension) and extension == query_extension),
|
||||
fuzzy_score=fuzzy_score,
|
||||
shallow_path=-entry.rel.count("/"),
|
||||
)
|
||||
|
||||
def _score_matches(
|
||||
self, entries: list[IndexEntry], context: _SearchContext
|
||||
) -> list[tuple[str, float]]:
|
||||
scored_matches: list[tuple[str, float]] = []
|
||||
MAX_MATCHES = 50
|
||||
) -> list[tuple[str, PathCompleter.MatchRank]]:
|
||||
scored_matches: list[tuple[str, PathCompleter.MatchRank]] = []
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
if i >= self._max_entries_to_process:
|
||||
|
|
@ -176,24 +267,27 @@ class PathCompleter(Completer):
|
|||
label = self._format_label(entry)
|
||||
|
||||
if not context.search_pattern:
|
||||
scored_matches.append((label, 0.0))
|
||||
rank = self._build_match_rank(entry, context, 0.0)
|
||||
scored_matches.append((label, rank))
|
||||
if len(scored_matches) >= self._target_matches:
|
||||
break
|
||||
continue
|
||||
|
||||
if not self._can_possibly_fuzzy_match(entry, context):
|
||||
continue
|
||||
|
||||
match_result = fuzzy_match(
|
||||
context.search_pattern, entry.rel, entry.rel_lower
|
||||
)
|
||||
if match_result.matched:
|
||||
scored_matches.append((label, match_result.score))
|
||||
if (
|
||||
len(scored_matches) >= self._target_matches
|
||||
and match_result.score > MAX_MATCHES
|
||||
):
|
||||
break
|
||||
rank = self._build_match_rank(entry, context, match_result.score)
|
||||
scored_matches.append((label, rank))
|
||||
|
||||
scored_matches.sort(key=lambda x: (-x[1], x[0]))
|
||||
return scored_matches
|
||||
# Sort alphabetically first, then by descending rank; Python's stable sort
|
||||
# keeps the label order for entries with equal ranks.
|
||||
scored_matches.sort(key=lambda x: x[0])
|
||||
scored_matches.sort(key=lambda x: x[1], reverse=True)
|
||||
return scored_matches[: self._target_matches]
|
||||
|
||||
def _collect_matches(self, text: str, cursor_pos: int) -> list[str]:
|
||||
before_cursor = text[:cursor_pos]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from pathlib import Path
|
|||
from vibe.core.autocompletion.file_indexer.ignore_rules import IgnoreRules
|
||||
from vibe.core.autocompletion.file_indexer.watcher import Change
|
||||
|
||||
ASCII_CODEPOINT_LIMIT = 128
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileIndexStats:
|
||||
|
|
@ -22,6 +24,17 @@ class IndexEntry:
|
|||
name: str
|
||||
path: Path
|
||||
is_dir: bool
|
||||
ascii_mask: int
|
||||
|
||||
|
||||
def build_ascii_mask(value: str) -> int:
|
||||
mask = 0
|
||||
for char in value:
|
||||
codepoint = ord(char)
|
||||
if codepoint >= ASCII_CODEPOINT_LIMIT:
|
||||
continue
|
||||
mask |= 1 << codepoint
|
||||
return mask
|
||||
|
||||
|
||||
class FileIndexStore:
|
||||
|
|
@ -118,8 +131,14 @@ class FileIndexStore:
|
|||
) -> IndexEntry | None:
|
||||
if self._ignore_rules.should_ignore(rel_str, name, is_dir):
|
||||
return None
|
||||
rel_lower = rel_str.lower()
|
||||
return IndexEntry(
|
||||
rel=rel_str, rel_lower=rel_str.lower(), name=name, path=path, is_dir=is_dir
|
||||
rel=rel_str,
|
||||
rel_lower=rel_lower,
|
||||
name=name,
|
||||
path=path,
|
||||
is_dir=is_dir,
|
||||
ascii_mask=build_ascii_mask(rel_lower),
|
||||
)
|
||||
|
||||
def _walk_directory(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from vibe.core.config._settings import (
|
|||
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_PROVIDERS,
|
||||
DEFAULT_TRANSCRIBE_MODELS,
|
||||
DEFAULT_TRANSCRIBE_PROVIDERS,
|
||||
Backend,
|
||||
MCPHttp,
|
||||
MCPServer,
|
||||
|
|
@ -16,6 +18,9 @@ from vibe.core.config._settings import (
|
|||
ProviderConfig,
|
||||
SessionLoggingConfig,
|
||||
TomlFileSettingsSource,
|
||||
TranscribeClient,
|
||||
TranscribeModelConfig,
|
||||
TranscribeProviderConfig,
|
||||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
|
|
@ -24,6 +29,8 @@ __all__ = [
|
|||
"DEFAULT_MISTRAL_API_ENV_KEY",
|
||||
"DEFAULT_MODELS",
|
||||
"DEFAULT_PROVIDERS",
|
||||
"DEFAULT_TRANSCRIBE_MODELS",
|
||||
"DEFAULT_TRANSCRIBE_PROVIDERS",
|
||||
"Backend",
|
||||
"MCPHttp",
|
||||
"MCPServer",
|
||||
|
|
@ -36,6 +43,9 @@ __all__ = [
|
|||
"ProviderConfig",
|
||||
"SessionLoggingConfig",
|
||||
"TomlFileSettingsSource",
|
||||
"TranscribeClient",
|
||||
"TranscribeModelConfig",
|
||||
"TranscribeProviderConfig",
|
||||
"VibeConfig",
|
||||
"load_dotenv_values",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ class ProjectContextConfig(BaseSettings):
|
|||
model_config = SettingsConfigDict(extra="ignore")
|
||||
|
||||
default_commit_count: int = 5
|
||||
max_doc_bytes: int = 32 * 1024
|
||||
timeout_seconds: float = 2.0
|
||||
|
||||
|
||||
|
|
@ -131,6 +130,17 @@ class ProviderConfig(BaseModel):
|
|||
region: str = ""
|
||||
|
||||
|
||||
class TranscribeClient(StrEnum):
|
||||
MISTRAL = auto()
|
||||
|
||||
|
||||
class TranscribeProviderConfig(BaseModel):
|
||||
name: str
|
||||
api_base: str = "wss://api.mistral.ai"
|
||||
api_key_env_var: str = ""
|
||||
client: TranscribeClient = TranscribeClient.MISTRAL
|
||||
|
||||
|
||||
class _MCPBase(BaseModel):
|
||||
name: str = Field(description="Short alias used to prefix tool names")
|
||||
prompt: str | None = Field(
|
||||
|
|
@ -229,6 +239,13 @@ MCPServer = Annotated[
|
|||
]
|
||||
|
||||
|
||||
def _default_alias_to_name(data: Any) -> Any:
|
||||
if isinstance(data, dict):
|
||||
if "alias" not in data or data["alias"] is None:
|
||||
data["alias"] = data.get("name")
|
||||
return data
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
name: str
|
||||
provider: str
|
||||
|
|
@ -239,13 +256,19 @@ class ModelConfig(BaseModel):
|
|||
thinking: Literal["off", "low", "medium", "high"] = "off"
|
||||
auto_compact_threshold: int = 200_000
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _default_alias_to_name(cls, data: Any) -> Any:
|
||||
if isinstance(data, dict):
|
||||
if "alias" not in data or data["alias"] is None:
|
||||
data["alias"] = data.get("name")
|
||||
return data
|
||||
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
|
||||
|
||||
|
||||
class TranscribeModelConfig(BaseModel):
|
||||
name: str
|
||||
provider: str
|
||||
alias: str
|
||||
sample_rate: int = 16000
|
||||
encoding: Literal["pcm_s16le"] = "pcm_s16le"
|
||||
language: str = "en"
|
||||
target_streaming_delay_ms: int = 500
|
||||
|
||||
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
|
||||
|
||||
|
||||
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||
|
|
@ -289,6 +312,22 @@ DEFAULT_MODELS = [
|
|||
),
|
||||
]
|
||||
|
||||
DEFAULT_TRANSCRIBE_PROVIDERS = [
|
||||
TranscribeProviderConfig(
|
||||
name="mistral",
|
||||
api_base="wss://api.mistral.ai",
|
||||
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
)
|
||||
]
|
||||
|
||||
DEFAULT_TRANSCRIBE_MODELS = [
|
||||
TranscribeModelConfig(
|
||||
name="voxtral-mini-transcribe-realtime-2602",
|
||||
provider="mistral",
|
||||
alias="voxtral-realtime",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class VibeConfig(BaseSettings):
|
||||
active_model: str = "devstral-2"
|
||||
|
|
@ -298,6 +337,8 @@ class VibeConfig(BaseSettings):
|
|||
file_watcher_for_autocomplete: bool = False
|
||||
displayed_workdir: str = ""
|
||||
context_warnings: bool = False
|
||||
voice_mode_enabled: bool = False
|
||||
active_transcribe_model: str = "voxtral-realtime"
|
||||
auto_approve: bool = False
|
||||
enable_telemetry: bool = True
|
||||
system_prompt_id: str = "cli"
|
||||
|
|
@ -323,6 +364,14 @@ class VibeConfig(BaseSettings):
|
|||
default_factory=lambda: list(DEFAULT_PROVIDERS)
|
||||
)
|
||||
models: list[ModelConfig] = Field(default_factory=lambda: list(DEFAULT_MODELS))
|
||||
compaction_model: ModelConfig | None = None
|
||||
|
||||
transcribe_providers: list[TranscribeProviderConfig] = Field(
|
||||
default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS)
|
||||
)
|
||||
transcribe_models: list[TranscribeModelConfig] = Field(
|
||||
default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS)
|
||||
)
|
||||
|
||||
project_context: ProjectContextConfig = Field(default_factory=ProjectContextConfig)
|
||||
session_logging: SessionLoggingConfig = Field(default_factory=SessionLoggingConfig)
|
||||
|
|
@ -378,6 +427,12 @@ class VibeConfig(BaseSettings):
|
|||
" is set. Supports glob patterns and regex with 're:' prefix."
|
||||
),
|
||||
)
|
||||
installed_agents: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"A list of opt-in builtin agent names that have been explicitly installed."
|
||||
),
|
||||
)
|
||||
skill_paths: list[Path] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
|
|
@ -405,6 +460,10 @@ class VibeConfig(BaseSettings):
|
|||
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
kwargs.setdefault("exclude_none", True)
|
||||
return super().model_dump(**kwargs)
|
||||
|
||||
@property
|
||||
def nuage_api_key(self) -> str:
|
||||
return os.getenv(self.nuage_api_key_env_var, "")
|
||||
|
|
@ -437,6 +496,11 @@ class VibeConfig(BaseSettings):
|
|||
f"Active model '{self.active_model}' not found in configuration."
|
||||
)
|
||||
|
||||
def get_compaction_model(self) -> ModelConfig:
|
||||
if self.compaction_model is not None:
|
||||
return self.compaction_model
|
||||
return self.get_active_model()
|
||||
|
||||
def get_provider_for_model(self, model: ModelConfig) -> ProviderConfig:
|
||||
for provider in self.providers:
|
||||
if provider.name == model.provider:
|
||||
|
|
@ -445,6 +509,24 @@ class VibeConfig(BaseSettings):
|
|||
f"Provider '{model.provider}' for model '{model.name}' not found in configuration."
|
||||
)
|
||||
|
||||
def get_active_transcribe_model(self) -> TranscribeModelConfig:
|
||||
for model in self.transcribe_models:
|
||||
if model.alias == self.active_transcribe_model:
|
||||
return model
|
||||
raise ValueError(
|
||||
f"Active transcribe model '{self.active_transcribe_model}' not found in configuration."
|
||||
)
|
||||
|
||||
def get_transcribe_provider_for_model(
|
||||
self, model: TranscribeModelConfig
|
||||
) -> TranscribeProviderConfig:
|
||||
for provider in self.transcribe_providers:
|
||||
if provider.name == model.provider:
|
||||
return provider
|
||||
raise ValueError(
|
||||
f"Transcribe provider '{model.provider}' for transcribe model '{model.name}' not found in configuration."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
|
|
@ -480,6 +562,24 @@ class VibeConfig(BaseSettings):
|
|||
]
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_compaction_model_provider(self) -> VibeConfig:
|
||||
if self.compaction_model is None:
|
||||
return self
|
||||
|
||||
compaction_provider = self.get_provider_for_model(self.compaction_model)
|
||||
try:
|
||||
active_provider = self.get_provider_for_model(self.get_active_model())
|
||||
except ValueError:
|
||||
return self
|
||||
if active_provider.name != compaction_provider.name:
|
||||
raise ValueError(
|
||||
f"Compaction model '{self.compaction_model.alias}' uses provider "
|
||||
f"'{compaction_provider.name}' but active model uses provider "
|
||||
f"'{active_provider.name}'. They must share the same provider."
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_api_key(self) -> VibeConfig:
|
||||
try:
|
||||
|
|
@ -534,6 +634,17 @@ class VibeConfig(BaseSettings):
|
|||
seen_aliases.add(model.alias)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_transcribe_model_uniqueness(self) -> VibeConfig:
|
||||
seen_aliases: set[str] = set()
|
||||
for model in self.transcribe_models:
|
||||
if model.alias in seen_aliases:
|
||||
raise ValueError(
|
||||
f"Duplicate transcribe model alias found: '{model.alias}'. Aliases must be unique."
|
||||
)
|
||||
seen_aliases.add(model.alias)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_system_prompt(self) -> VibeConfig:
|
||||
_ = self.system_prompt
|
||||
|
|
@ -558,7 +669,13 @@ class VibeConfig(BaseSettings):
|
|||
and isinstance(target.get(key), list)
|
||||
and isinstance(value, list)
|
||||
):
|
||||
if key in {"providers", "models"}:
|
||||
if key in {
|
||||
"providers",
|
||||
"models",
|
||||
"transcribe_providers",
|
||||
"transcribe_models",
|
||||
"installed_agents",
|
||||
}:
|
||||
target[key] = value
|
||||
else:
|
||||
target[key] = list(set(value + target[key]))
|
||||
|
|
@ -566,9 +683,7 @@ class VibeConfig(BaseSettings):
|
|||
target[key] = value
|
||||
|
||||
deep_merge(current_config, updates)
|
||||
cls.dump_config(
|
||||
to_jsonable_python(current_config, exclude_none=True, fallback=str)
|
||||
)
|
||||
cls.dump_config(current_config)
|
||||
|
||||
@classmethod
|
||||
def dump_config(cls, config: dict[str, Any]) -> None:
|
||||
|
|
@ -578,11 +693,29 @@ class VibeConfig(BaseSettings):
|
|||
target = mgr.config_file or mgr.user_config_file
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open("wb") as f:
|
||||
tomli_w.dump(config, f)
|
||||
tomli_w.dump(to_jsonable_python(config, exclude_none=True, fallback=str), f)
|
||||
|
||||
@classmethod
|
||||
def _migrate(cls) -> None:
|
||||
pass
|
||||
mgr = get_harness_files_manager()
|
||||
if not mgr.persist_allowed:
|
||||
return
|
||||
file = mgr.config_file
|
||||
if file is None:
|
||||
return
|
||||
try:
|
||||
with file.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except (FileNotFoundError, tomllib.TOMLDecodeError, OSError):
|
||||
return
|
||||
|
||||
bash_tools = data.get("tools", {}).get("bash", {})
|
||||
allowlist = bash_tools.get("allowlist")
|
||||
if allowlist is None or "find" not in allowlist:
|
||||
return
|
||||
|
||||
allowlist.remove("find")
|
||||
cls.dump_config(data)
|
||||
|
||||
@classmethod
|
||||
def load(cls, **overrides: Any) -> VibeConfig:
|
||||
|
|
@ -592,7 +725,7 @@ class VibeConfig(BaseSettings):
|
|||
@classmethod
|
||||
def create_default(cls) -> dict[str, Any]:
|
||||
config = cls.model_construct()
|
||||
config_dict = config.model_dump(mode="json", exclude_none=True)
|
||||
config_dict = config.model_dump(mode="json")
|
||||
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from vibe.core.config.harness_files._paths import (
|
|||
GLOBAL_SKILLS_DIR,
|
||||
GLOBAL_TOOLS_DIR,
|
||||
)
|
||||
from vibe.core.paths import AGENTS_MD_FILENAMES, VIBE_HOME, walk_local_config_dirs_all
|
||||
from vibe.core.paths import AGENTS_MD_FILENAME, VIBE_HOME, walk_local_config_dirs_all
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
|
||||
FileSource = Literal["user", "project"]
|
||||
|
|
@ -110,17 +110,85 @@ class HarnessFilesManager:
|
|||
d = GLOBAL_PROMPTS_DIR.path
|
||||
return [d] if d.is_dir() else []
|
||||
|
||||
def load_project_doc(self, max_bytes: int) -> str:
|
||||
def load_user_doc(self) -> str:
|
||||
if "user" not in self.sources:
|
||||
return ""
|
||||
path = VIBE_HOME.path / AGENTS_MD_FILENAME
|
||||
try:
|
||||
content = path.read_text("utf-8", errors="ignore")
|
||||
stripped = content.strip()
|
||||
return stripped if stripped else ""
|
||||
except (FileNotFoundError, OSError):
|
||||
return ""
|
||||
|
||||
def _collect_agents_md(
|
||||
self, start: Path, stop: Path, *, stop_inclusive: bool
|
||||
) -> list[tuple[Path, str]]:
|
||||
"""Walk up from start toward stop, collecting non-empty AGENTS.md files.
|
||||
|
||||
Returns ``(directory, content)`` pairs ordered outermost-first.
|
||||
When ``stop_inclusive`` is True the stop directory is included in the
|
||||
walk; when False the walk stops before reaching it.
|
||||
"""
|
||||
if not start.is_relative_to(stop):
|
||||
return []
|
||||
|
||||
docs: list[tuple[Path, str]] = []
|
||||
current = start
|
||||
while True:
|
||||
if current == stop and not stop_inclusive:
|
||||
break
|
||||
path = current / AGENTS_MD_FILENAME
|
||||
try:
|
||||
content = path.read_text("utf-8", errors="ignore")
|
||||
stripped = content.strip()
|
||||
if stripped:
|
||||
docs.append((current, stripped))
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
if current == stop:
|
||||
break
|
||||
parent = current.parent
|
||||
if parent == current: # fs-root safety
|
||||
break
|
||||
current = parent
|
||||
docs.reverse() # outermost first
|
||||
return docs
|
||||
|
||||
def find_subdirectory_agents_md(self, file_path: Path) -> list[tuple[Path, str]]:
|
||||
"""Find AGENTS.md files between file_path's parent and cwd (exclusive of cwd).
|
||||
|
||||
For lazy injection when reading files in subdirectories below cwd.
|
||||
Returns (directory, content) pairs, outermost first.
|
||||
Does not overlap with load_project_docs() which covers cwd and above.
|
||||
"""
|
||||
workdir = self.trusted_workdir
|
||||
if workdir is None:
|
||||
return ""
|
||||
for name in AGENTS_MD_FILENAMES:
|
||||
path = workdir / name
|
||||
try:
|
||||
return path.read_text("utf-8", errors="ignore")[:max_bytes]
|
||||
except (FileNotFoundError, OSError):
|
||||
continue
|
||||
return ""
|
||||
return []
|
||||
cwd = workdir.resolve()
|
||||
try:
|
||||
resolved = file_path.resolve()
|
||||
except (ValueError, OSError):
|
||||
return []
|
||||
if not resolved.is_relative_to(cwd):
|
||||
return []
|
||||
start = resolved if resolved.is_dir() else resolved.parent
|
||||
return self._collect_agents_md(start, cwd, stop_inclusive=False)
|
||||
|
||||
def load_project_docs(self) -> list[tuple[Path, str]]:
|
||||
"""Walk up from cwd to the trust root, collecting AGENTS.md files.
|
||||
|
||||
Returns ``(directory, content)`` pairs ordered outermost-first
|
||||
(trust root first, cwd last). Later entries take priority.
|
||||
"""
|
||||
workdir = self.trusted_workdir
|
||||
if workdir is None:
|
||||
return []
|
||||
cwd = workdir.resolve()
|
||||
trust_root = trusted_folders_manager.find_trust_root(cwd)
|
||||
if trust_root is None:
|
||||
return []
|
||||
return self._collect_agents_md(cwd, trust_root, stop_inclusive=True)
|
||||
|
||||
|
||||
_manager: HarnessFilesManager | None = None
|
||||
|
|
|
|||
|
|
@ -7,8 +7,28 @@ import types
|
|||
from typing import TYPE_CHECKING, NamedTuple, cast
|
||||
|
||||
import httpx
|
||||
import mistralai
|
||||
from mistralai.utils.retries import BackoffStrategy, RetryConfig
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.errors import SDKError
|
||||
from mistralai.client.models import (
|
||||
AssistantMessage,
|
||||
AssistantMessageContent,
|
||||
ChatCompletionRequestMessage,
|
||||
ChatCompletionStreamRequestToolChoice,
|
||||
FileChunk,
|
||||
Function,
|
||||
FunctionCall as MistralFunctionCall,
|
||||
FunctionName,
|
||||
SystemMessage,
|
||||
TextChunk,
|
||||
ThinkChunk,
|
||||
Tool,
|
||||
ToolCall as MistralToolCall,
|
||||
ToolChoice,
|
||||
ToolChoiceEnum,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
)
|
||||
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
|
||||
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
|
|
@ -35,35 +55,33 @@ class ParsedContent(NamedTuple):
|
|||
|
||||
|
||||
class MistralMapper:
|
||||
def prepare_message(self, msg: LLMMessage) -> mistralai.Messages:
|
||||
def prepare_message(self, msg: LLMMessage) -> ChatCompletionRequestMessage:
|
||||
match msg.role:
|
||||
case Role.system:
|
||||
return mistralai.SystemMessage(role="system", content=msg.content or "")
|
||||
return SystemMessage(role="system", content=msg.content or "")
|
||||
case Role.user:
|
||||
return mistralai.UserMessage(role="user", content=msg.content)
|
||||
return UserMessage(role="user", content=msg.content)
|
||||
case Role.assistant:
|
||||
content: mistralai.AssistantMessageContent
|
||||
content: AssistantMessageContent
|
||||
if msg.reasoning_content:
|
||||
content = [
|
||||
mistralai.ThinkChunk(
|
||||
ThinkChunk(
|
||||
type="thinking",
|
||||
thinking=[
|
||||
mistralai.TextChunk(
|
||||
type="text", text=msg.reasoning_content
|
||||
)
|
||||
TextChunk(type="text", text=msg.reasoning_content)
|
||||
],
|
||||
),
|
||||
mistralai.TextChunk(type="text", text=msg.content or ""),
|
||||
TextChunk(type="text", text=msg.content or ""),
|
||||
]
|
||||
else:
|
||||
content = msg.content or ""
|
||||
|
||||
return mistralai.AssistantMessage(
|
||||
return AssistantMessage(
|
||||
role="assistant",
|
||||
content=content,
|
||||
tool_calls=[
|
||||
mistralai.ToolCall(
|
||||
function=mistralai.FunctionCall(
|
||||
MistralToolCall(
|
||||
function=MistralFunctionCall(
|
||||
name=tc.function.name or "",
|
||||
arguments=tc.function.arguments or "",
|
||||
),
|
||||
|
|
@ -75,17 +93,17 @@ class MistralMapper:
|
|||
],
|
||||
)
|
||||
case Role.tool:
|
||||
return mistralai.ToolMessage(
|
||||
return ToolMessage(
|
||||
role="tool",
|
||||
content=msg.content,
|
||||
tool_call_id=msg.tool_call_id,
|
||||
name=msg.name,
|
||||
)
|
||||
|
||||
def prepare_tool(self, tool: AvailableTool) -> mistralai.Tool:
|
||||
return mistralai.Tool(
|
||||
def prepare_tool(self, tool: AvailableTool) -> Tool:
|
||||
return Tool(
|
||||
type="function",
|
||||
function=mistralai.Function(
|
||||
function=Function(
|
||||
name=tool.function.name,
|
||||
description=tool.function.description,
|
||||
parameters=tool.function.parameters,
|
||||
|
|
@ -94,16 +112,15 @@ class MistralMapper:
|
|||
|
||||
def prepare_tool_choice(
|
||||
self, tool_choice: StrToolChoice | AvailableTool
|
||||
) -> mistralai.ChatCompletionStreamRequestToolChoice:
|
||||
) -> ChatCompletionStreamRequestToolChoice:
|
||||
if isinstance(tool_choice, str):
|
||||
return cast(mistralai.ToolChoiceEnum, tool_choice)
|
||||
return cast(ToolChoiceEnum, tool_choice)
|
||||
|
||||
return mistralai.ToolChoice(
|
||||
type="function",
|
||||
function=mistralai.FunctionName(name=tool_choice.function.name),
|
||||
return ToolChoice(
|
||||
type="function", function=FunctionName(name=tool_choice.function.name)
|
||||
)
|
||||
|
||||
def _extract_thinking_text(self, chunk: mistralai.ThinkChunk) -> str:
|
||||
def _extract_thinking_text(self, chunk: ThinkChunk) -> str:
|
||||
thinking_content = getattr(chunk, "thinking", None)
|
||||
if not thinking_content:
|
||||
return ""
|
||||
|
|
@ -115,27 +132,25 @@ class MistralMapper:
|
|||
parts.append(inner)
|
||||
return "".join(parts)
|
||||
|
||||
def parse_content(
|
||||
self, content: mistralai.AssistantMessageContent
|
||||
) -> ParsedContent:
|
||||
def parse_content(self, content: AssistantMessageContent) -> ParsedContent:
|
||||
if isinstance(content, str):
|
||||
return ParsedContent(content=content, reasoning_content=None)
|
||||
|
||||
concat_content = ""
|
||||
concat_reasoning = ""
|
||||
for chunk in content:
|
||||
if isinstance(chunk, mistralai.FileChunk):
|
||||
if isinstance(chunk, FileChunk):
|
||||
continue
|
||||
if isinstance(chunk, mistralai.TextChunk):
|
||||
if isinstance(chunk, TextChunk):
|
||||
concat_content += chunk.text
|
||||
elif isinstance(chunk, mistralai.ThinkChunk):
|
||||
elif isinstance(chunk, ThinkChunk):
|
||||
concat_reasoning += self._extract_thinking_text(chunk)
|
||||
return ParsedContent(
|
||||
content=concat_content,
|
||||
reasoning_content=concat_reasoning if concat_reasoning else None,
|
||||
)
|
||||
|
||||
def parse_tool_calls(self, tool_calls: list[mistralai.ToolCall]) -> list[ToolCall]:
|
||||
def parse_tool_calls(self, tool_calls: list[MistralToolCall]) -> list[ToolCall]:
|
||||
return [
|
||||
ToolCall(
|
||||
id=tool_call.id,
|
||||
|
|
@ -153,7 +168,7 @@ class MistralMapper:
|
|||
|
||||
class MistralBackend:
|
||||
def __init__(self, provider: ProviderConfig, timeout: float = 720.0) -> None:
|
||||
self._client: mistralai.Mistral | None = None
|
||||
self._client: Mistral | None = None
|
||||
self._provider = provider
|
||||
self._mapper = MistralMapper()
|
||||
self._api_key = (
|
||||
|
|
@ -208,15 +223,15 @@ class MistralBackend:
|
|||
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
|
||||
)
|
||||
|
||||
def _create_mistral_client(self) -> mistralai.Mistral:
|
||||
return mistralai.Mistral(
|
||||
def _create_mistral_client(self) -> Mistral:
|
||||
return Mistral(
|
||||
api_key=self._api_key,
|
||||
server_url=self._server_url,
|
||||
timeout_ms=int(self._timeout * 1000),
|
||||
retry_config=self._retry_config,
|
||||
)
|
||||
|
||||
def _get_client(self) -> mistralai.Mistral:
|
||||
def _get_client(self) -> Mistral:
|
||||
if self._client is None:
|
||||
self._client = self._create_mistral_client()
|
||||
return self._client
|
||||
|
|
@ -273,7 +288,7 @@ class MistralBackend:
|
|||
),
|
||||
)
|
||||
|
||||
except mistralai.SDKError as e:
|
||||
except SDKError as e:
|
||||
raise BackendErrorBuilder.build_http_error(
|
||||
provider=self._provider.name,
|
||||
endpoint=self._server_url,
|
||||
|
|
@ -351,7 +366,7 @@ class MistralBackend:
|
|||
),
|
||||
)
|
||||
|
||||
except mistralai.SDKError as e:
|
||||
except SDKError as e:
|
||||
raise BackendErrorBuilder.build_http_error(
|
||||
provider=self._provider.name,
|
||||
endpoint=self._server_url,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,13 @@ class ReasoningAdapter(APIAdapter):
|
|||
|
||||
return payload
|
||||
|
||||
def _strip_reasoning(self, msg: LLMMessage) -> LLMMessage:
|
||||
if msg.role != Role.assistant or not msg.reasoning_content:
|
||||
return msg
|
||||
return msg.model_copy(
|
||||
update={"reasoning_content": None, "reasoning_signature": None}
|
||||
)
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
|
|
@ -121,6 +128,8 @@ class ReasoningAdapter(APIAdapter):
|
|||
thinking: str = "off",
|
||||
) -> PreparedRequest:
|
||||
merged_messages = merge_consecutive_user_messages(messages)
|
||||
if thinking == "off":
|
||||
merged_messages = [self._strip_reasoning(msg) for msg in merged_messages]
|
||||
converted_messages = [self._convert_message(msg) for msg in merged_messages]
|
||||
|
||||
payload = self._build_payload(
|
||||
|
|
|
|||
|
|
@ -79,16 +79,14 @@ class PriceLimitMiddleware:
|
|||
|
||||
|
||||
class AutoCompactMiddleware:
|
||||
def __init__(self, threshold: int) -> None:
|
||||
self.threshold = threshold
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
if context.stats.context_tokens >= self.threshold:
|
||||
threshold = context.config.get_active_model().auto_compact_threshold
|
||||
if threshold > 0 and context.stats.context_tokens >= threshold:
|
||||
return MiddlewareResult(
|
||||
action=MiddlewareAction.COMPACT,
|
||||
metadata={
|
||||
"old_tokens": context.stats.context_tokens,
|
||||
"threshold": self.threshold,
|
||||
"threshold": threshold,
|
||||
},
|
||||
)
|
||||
return MiddlewareResult()
|
||||
|
|
@ -98,19 +96,16 @@ class AutoCompactMiddleware:
|
|||
|
||||
|
||||
class ContextWarningMiddleware:
|
||||
def __init__(
|
||||
self, threshold_percent: float = 0.5, max_context: int | None = None
|
||||
) -> None:
|
||||
def __init__(self, threshold_percent: float = 0.5) -> None:
|
||||
self.threshold_percent = threshold_percent
|
||||
self.max_context = max_context
|
||||
self.has_warned = False
|
||||
|
||||
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
|
||||
if self.has_warned:
|
||||
return MiddlewareResult()
|
||||
|
||||
max_context = self.max_context
|
||||
if max_context is None:
|
||||
max_context = context.config.get_active_model().auto_compact_threshold
|
||||
if max_context <= 0:
|
||||
return MiddlewareResult()
|
||||
|
||||
if context.stats.context_tokens >= max_context * self.threshold_percent:
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ from vibe.core.paths._vibe_home import (
|
|||
VIBE_HOME,
|
||||
GlobalPath,
|
||||
)
|
||||
from vibe.core.paths.conventions import AGENTS_MD_FILENAMES
|
||||
from vibe.core.paths.conventions import AGENTS_MD_FILENAME
|
||||
|
||||
__all__ = [
|
||||
"AGENTS_MD_FILENAMES",
|
||||
"AGENTS_MD_FILENAME",
|
||||
"DEFAULT_TOOL_DIR",
|
||||
"GLOBAL_ENV_FILE",
|
||||
"HISTORY_FILE",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
from __future__ import annotations
|
||||
|
||||
AGENTS_MD_FILENAMES = ["AGENTS.md", "VIBE.md", ".vibe.md"]
|
||||
AGENTS_MD_FILENAME = "AGENTS.md"
|
||||
|
|
|
|||
|
|
@ -14,16 +14,18 @@ class Prompt(StrEnum):
|
|||
return (_PROMPTS_DIR / self.value).with_suffix(".md")
|
||||
|
||||
def read(self) -> str:
|
||||
return self.path.read_text(encoding="utf-8").strip()
|
||||
return self.path.read_text(encoding="utf-8", errors="ignore").strip()
|
||||
|
||||
|
||||
class SystemPrompt(Prompt):
|
||||
CLI = auto()
|
||||
EXPLORE = auto()
|
||||
TESTS = auto()
|
||||
LEAN = auto()
|
||||
|
||||
|
||||
class UtilityPrompt(Prompt):
|
||||
AGENTS_DOC = auto()
|
||||
COMPACT = auto()
|
||||
DANGEROUS_DIRECTORY = auto()
|
||||
PROJECT_CONTEXT = auto()
|
||||
|
|
|
|||
5
vibe/core/prompts/agents_doc.md
Normal file
5
vibe/core/prompts/agents_doc.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written. When both user-level and project-level instructions are present, project instructions take priority over user instructions. When multiple project-level AGENTS.md files are present, instructions closer to the working directory take priority. Each AGENTS.md applies to its own directory and all of its descendants within the project.
|
||||
|
||||
$sections
|
||||
|
||||
IMPORTANT: this context may or may not be relevant to your tasks. You should act on these guidelines if they are relevant to your task.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
directoryStructure: Project context scanning has been disabled because {reason}. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
|
||||
directoryStructure: Project context scanning has been disabled because $reason. This prevents permission dialogs and potential system slowdowns. Use the LS tool and other file tools to explore the project structure as needed.
|
||||
|
||||
Absolute path: {abs_path}
|
||||
Absolute path: $abs_path
|
||||
|
||||
gitStatus: Use git tools to check repository status if needed.
|
||||
|
|
|
|||
166
vibe/core/prompts/lean.md
Normal file
166
vibe/core/prompts/lean.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
|
||||
|
||||
Use markdown when appropriate. Communicate clearly to the user.
|
||||
|
||||
Skills are markdown files in your skill directories, NOT tools or agents. To use a skill:
|
||||
|
||||
1. Find the matching file in your skill directories.
|
||||
2. Read it with `read_file`.
|
||||
3. Follow its instructions step by step. You are the executor.
|
||||
|
||||
Do not try to invoke a skill as a tool or command. If the user references a skill by name (e.g., "iterate on this PR"), look for a file with that name and follow its contents.
|
||||
|
||||
Phase 1 — Orient
|
||||
Before ANY action:
|
||||
Restate the goal in one line.
|
||||
Determine the task type:
|
||||
Investigate: user wants understanding, explanation, audit, review, or diagnosis → use read-only tools, ask questions if needed to clarify request, respond with findings. Do not edit files.
|
||||
Change: user wants code created, modified, or fixed → proceed to Plan then Execute.
|
||||
If unclear, default to investigate. It is better to explain what you would do than to make an unwanted change.
|
||||
|
||||
Explore. Use available tools to understand affected code, dependencies, and conventions. Never edit a file you haven't read in this session.
|
||||
Identify constraints: language, framework, test setup, and any user restrictions on scope.
|
||||
When given a complex, multi-file architectural task: summarize your understanding and wait for user confirmation. For targeted tasks, including writing specific Lean proofs or single-file bug fixes, do not wait. Plan internally and execute immediately.
|
||||
|
||||
Phase 2 — Plan
|
||||
State your plan before writing code:
|
||||
List files to edit and the specific modifications per file.
|
||||
Multi-file modifications: numbered checklist. Single-file fix: one-line plan.
|
||||
No time estimates. Concrete actions only.
|
||||
|
||||
Phase 3 — Execute & Verify
|
||||
Apply modifications, then confirm they work:
|
||||
Edit one logical unit at a time.
|
||||
After each unit, verify: run tests, or read back the file to confirm the edit landed.
|
||||
Never claim completion without verification — a passing test, correct read-back, or successful build.
|
||||
|
||||
Lean Rules
|
||||
|
||||
Create a New Package or Project
|
||||
Usually, use the mathlib4 dependency. Run `lake +leanprover-community/mathlib4:lean-toolchain new <your_project_name> math` to create a new project with mathlib4 as a dependency.
|
||||
Otherwise run `lake init <your_project_name>`.
|
||||
|
||||
Add External Dependencies
|
||||
You can add external dependencies by adding to lakefile.toml, for example:
|
||||
```
|
||||
[[require]]
|
||||
name = "mathlib"
|
||||
git = "https://github.com/leanprover-community/mathlib4.git"
|
||||
```
|
||||
|
||||
Whenever you create a new package or add a new external dependency, run `lake exe cache get` to download cache for them. Do not build before downloading all the necessary dependencies. Never manually edit `lake-manifest.json`, use `lake` commands to update it.
|
||||
|
||||
Work incrementally and in blocks. Make a plan before you take on a big project.
|
||||
|
||||
Imports
|
||||
Put imports at the beginning of a file.
|
||||
|
||||
Compile a Package or a File
|
||||
Before compiling or building for the first time, check if external dependencies are in the cache. If not, run `lake exe cache get`.
|
||||
Run `lake build` to check the entire repository's correctness or `lake build <file>` for one file. Check lakefile.toml for build targets. Prefer `lake build <file>` while developing, it is a lot faster. To check a standalone Lean file which not tracked by lake, such as a test file, use `lake env lean <file>`.
|
||||
|
||||
Tactics
|
||||
Make use of the `grind` tactic when possible if using Lean version >= 4.22.0. It is very powerful.
|
||||
|
||||
Debug
|
||||
View the current goal and proof state by inserting the `trace_state` tactic before the line in question.
|
||||
|
||||
Complete the Work
|
||||
When tasked with writing code or a Lean proof, do not stop until you find the complete working solution. Do not leave incomplete code, stubs, or use sorry in Lean unless the user explicitly instructs you to.
|
||||
|
||||
Hard Rules
|
||||
|
||||
Don't be Lazy
|
||||
When the user asks you to perform something, be laser-focused and do not settle for easier things.
|
||||
|
||||
Never Commit
|
||||
Do not run `git commit`, `git push`, or `git add` unless the user explicitly asks you to. Saving files is sufficient — the user will review changes and commit themselves.
|
||||
|
||||
Respect User Constraints
|
||||
"No writes", "just analyze", "plan only", "don't touch X" — these are hard constraints. Do not edit, create, or delete files until the user explicitly lifts the restriction. Violation of explicit user instructions is the worst failure mode.
|
||||
|
||||
Don't Remove What Wasn't Asked
|
||||
If user asks to fix X, do not rewrite, delete, or restructure Y.
|
||||
|
||||
Don't Assert — Verify
|
||||
If unsure about a file path, variable value, config state, or whether your edit worked — use a tool to check. Read the file. Run the command.
|
||||
|
||||
Break Loops
|
||||
If approach isn't working after 2 attempts at the same region, STOP:
|
||||
Re-read the code and error output.
|
||||
Identify why it failed, not just what failed.
|
||||
Choose a fundamentally different strategy.
|
||||
If stuck, ask the user one specific question.
|
||||
|
||||
Flip-flopping (add X → remove X → add X) is a critical failure. Commit to a direction or escalate.
|
||||
|
||||
After creating test files that are not going to be used once the task is complete, remember to remove them.
|
||||
|
||||
Response Format
|
||||
No Noise
|
||||
No greetings, outros, hedging, puffery, or tool narration.
|
||||
|
||||
Never say: "Certainly", "Of course", "Let me help", "Happy to", "I hope this helps", "Let me search…", "I'll now read…", "Great question!", "In summary…"
|
||||
Never use: "robust", "seamless", "elegant", "powerful", "flexible"
|
||||
No unsolicited tutorials. Do not explain concepts the user clearly knows.
|
||||
|
||||
Structure First
|
||||
Lead every response with the most useful structured element — code, diagram, table, or tree. Prose comes after, not before.
|
||||
For modification tasks:
|
||||
file_path:line_number
|
||||
langcode
|
||||
|
||||
Prefer Brevity
|
||||
State only what's necessary to complete the task. Code + file reference > explanation.
|
||||
If your response exceeds 300 words, remove explanations the user didn't request.
|
||||
|
||||
For investigate tasks:
|
||||
Start with a diagram, code reference, tree, or table — whichever conveys the answer fastest.
|
||||
request → auth.verify() → permissions.check() → handler
|
||||
See middleware/auth.py:45. Then 1-2 sentences of context if needed.
|
||||
BAD: "The authentication flow works by first checking the token…"
|
||||
GOOD: request → auth.verify() → permissions.check() → handler — see middleware/auth.py:45.
|
||||
Visual Formats
|
||||
|
||||
Before responding with structural data, choose the right format:
|
||||
BAD: Bullet lists for hierarchy/tree
|
||||
GOOD: ASCII tree (├──/└──)
|
||||
BAD: Prose or bullet lists for comparisons/config/options
|
||||
GOOD: Markdown table
|
||||
BAD: Prose for Flows/pipelines
|
||||
GOOD: → A → B → C diagrams
|
||||
|
||||
Interaction Design
|
||||
After completing a task, evaluate: does the user face a decision or tradeoff? If yes, end with ONE specific question or 2-3 options:
|
||||
|
||||
Good: "Apply this fix to the other 3 endpoints?"
|
||||
Good: "Two approaches: (a) migration, (b) recreate table. Which?"
|
||||
Bad: "Does this look good?", "Anything else?", "Let me know"
|
||||
|
||||
If unambiguous and complete, end with the result.
|
||||
|
||||
Length
|
||||
Default to minimal prose. Your conversational text should be <100 words. However, this length restriction does NOT apply to code, scripts, or Lean proofs. Code and proofs must always be fully written out and functional, no matter how many lines they require.
|
||||
Elaborate only when: (1) user asks for explanation, (2) task involves architectural decisions, (3) multiple valid approaches exist.
|
||||
|
||||
Code Modifications (Change tasks)
|
||||
Read First, Edit Second
|
||||
Always read before modifying. Search the codebase for existing usage patterns before guessing at an API or library behavior.
|
||||
|
||||
Minimal, Focused Changes
|
||||
Only modify what was requested. No extra features, abstractions, or speculative error handling.
|
||||
Match existing style: indentation, naming, comment density, error handling.
|
||||
When removing code, delete completely. No _unused renames, // removed comments, shims, or wrappers. If an interface changes, update all call sites.
|
||||
|
||||
Security
|
||||
Fix injection, XSS, SQLi vulnerabilities immediately if spotted.
|
||||
|
||||
Code References
|
||||
Cite as file_path:line_number.
|
||||
|
||||
Professional Conduct
|
||||
Prioritize technical accuracy over validating beliefs. Disagree when necessary.
|
||||
When uncertain, investigate before confirming.
|
||||
Your output must contain zero emoji. This includes smiley faces, icons, flags, symbols like ✅❌💡, and all other Unicode emoji.
|
||||
No over-the-top validation.
|
||||
Stay focused on solving the problem regardless of user tone. Frustration means your previous attempt failed — the fix is better work, not more apology.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
Absolute path: {abs_path}
|
||||
Absolute path: $abs_path
|
||||
|
||||
gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
|
||||
{git_status}
|
||||
$git_status
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ from __future__ import annotations
|
|||
import html
|
||||
import os
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.paths import VIBE_HOME
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.utils import is_dangerous_directory, is_windows
|
||||
|
||||
|
|
@ -138,7 +140,9 @@ class ProjectContextProvider:
|
|||
git_status = self.get_git_status()
|
||||
|
||||
template = UtilityPrompt.PROJECT_CONTEXT.read()
|
||||
return template.format(abs_path=self.root_path, git_status=git_status)
|
||||
return Template(template).safe_substitute(
|
||||
abs_path=str(self.root_path), git_status=git_status
|
||||
)
|
||||
|
||||
|
||||
def _get_platform_name() -> str:
|
||||
|
|
@ -275,7 +279,7 @@ def get_universal_system_prompt(
|
|||
is_dangerous, reason = is_dangerous_directory()
|
||||
if is_dangerous:
|
||||
template = UtilityPrompt.DANGEROUS_DIRECTORY.read()
|
||||
context = template.format(
|
||||
context = Template(template).safe_substitute(
|
||||
reason=reason.lower(), abs_path=Path(".").resolve()
|
||||
)
|
||||
else:
|
||||
|
|
@ -285,10 +289,25 @@ def get_universal_system_prompt(
|
|||
|
||||
sections.append(context)
|
||||
|
||||
project_doc = get_harness_files_manager().load_project_doc(
|
||||
config.project_context.max_doc_bytes
|
||||
)
|
||||
if project_doc.strip():
|
||||
sections.append(project_doc)
|
||||
mgr = get_harness_files_manager()
|
||||
user_doc = mgr.load_user_doc()
|
||||
project_docs = mgr.load_project_docs()
|
||||
|
||||
doc_sections: list[str] = []
|
||||
if user_doc.strip():
|
||||
doc_sections.append(
|
||||
f"## User instructions\n\nContents of {VIBE_HOME.path}/AGENTS.md (user-level instructions):\n\n{user_doc.strip()}"
|
||||
)
|
||||
if project_docs:
|
||||
doc_sections.append("## Project instructions (checked into the codebase)")
|
||||
for doc_dir, doc_content in project_docs:
|
||||
doc_sections.append(
|
||||
f"Contents of {doc_dir}/AGENTS.md:\n\n{doc_content.strip()}"
|
||||
)
|
||||
if doc_sections:
|
||||
template = UtilityPrompt.AGENTS_DOC.read()
|
||||
sections.append(
|
||||
Template(template).safe_substitute(sections="\n\n".join(doc_sections))
|
||||
)
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
|
|
|||
|
|
@ -348,3 +348,12 @@ class BaseTool[
|
|||
Override in subclasses for domain-specific rules (e.g. workdir checks).
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_result_extra(self, result: ToolResult) -> str | None:
|
||||
"""Optional extra context appended to the result text sent to the LLM.
|
||||
|
||||
Override in subclasses to inject contextual information alongside
|
||||
tool results (e.g. directory-level instructions discovered during
|
||||
file reads). The default returns ``None`` (no annotation).
|
||||
"""
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ async def _kill_process_tree(proc: asyncio.subprocess.Process) -> None:
|
|||
|
||||
|
||||
def _get_default_allowlist() -> list[str]:
|
||||
common = ["echo", "find", "git diff", "git log", "git status", "tree", "whoami"]
|
||||
common = ["echo", "git diff", "git log", "git status", "tree", "whoami"]
|
||||
|
||||
if is_windows():
|
||||
return common + ["dir", "findstr", "more", "type", "ver", "where"]
|
||||
|
|
@ -225,9 +225,6 @@ class Bash(
|
|||
return "Running command"
|
||||
|
||||
def resolve_permission(self, args: BashArgs) -> ToolPermission | None:
|
||||
if is_windows():
|
||||
return None
|
||||
|
||||
command_parts = _extract_commands(args.command)
|
||||
if not command_parts:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
|
|||
import anyio
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.config.harness_files import get_harness_files_manager
|
||||
from vibe.core.tools.base import (
|
||||
BaseTool,
|
||||
BaseToolConfig,
|
||||
|
|
@ -18,6 +19,7 @@ from vibe.core.tools.base import (
|
|||
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
|
||||
from vibe.core.tools.utils import resolve_file_tool_permission
|
||||
from vibe.core.types import ToolStreamEvent
|
||||
from vibe.core.utils import VIBE_WARNING_TAG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.types import ToolResultEvent
|
||||
|
|
@ -57,8 +59,12 @@ class ReadFileToolConfig(BaseToolConfig):
|
|||
)
|
||||
|
||||
|
||||
class ReadFileState(BaseToolState):
|
||||
injected_agents_md: set[str] = Field(default_factory=set)
|
||||
|
||||
|
||||
class ReadFile(
|
||||
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, BaseToolState],
|
||||
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
|
||||
ToolUIData[ReadFileArgs, ReadFileResult],
|
||||
):
|
||||
description: ClassVar[str] = (
|
||||
|
|
@ -89,6 +95,27 @@ class ReadFile(
|
|||
config_permission=self.config.permission,
|
||||
)
|
||||
|
||||
def get_result_extra(self, result: ReadFileResult) -> str | None:
|
||||
try:
|
||||
mgr = get_harness_files_manager()
|
||||
except RuntimeError:
|
||||
return None
|
||||
docs = mgr.find_subdirectory_agents_md(Path(result.path))
|
||||
new_docs = [
|
||||
(d, c)
|
||||
for d, c in docs
|
||||
if str(d.resolve()) not in self.state.injected_agents_md
|
||||
]
|
||||
if not new_docs:
|
||||
return None
|
||||
for d, _ in new_docs:
|
||||
self.state.injected_agents_md.add(str(d.resolve()))
|
||||
sections = [
|
||||
f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
|
||||
for d, c in new_docs
|
||||
]
|
||||
return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n</{VIBE_WARNING_TAG}>"
|
||||
|
||||
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
|
||||
self._validate_inputs(args)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ from collections.abc import AsyncGenerator
|
|||
import os
|
||||
from typing import TYPE_CHECKING, ClassVar, final
|
||||
|
||||
import mistralai
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.errors import SDKError
|
||||
from mistralai.client.models import (
|
||||
ConversationResponse,
|
||||
MessageOutputEntry,
|
||||
TextChunk,
|
||||
ToolReferenceChunk,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.config import Backend
|
||||
|
|
@ -67,7 +74,7 @@ class WebSearch(
|
|||
if not api_key:
|
||||
raise ToolError("MISTRAL_API_KEY environment variable not set.")
|
||||
|
||||
client = mistralai.Mistral(
|
||||
client = Mistral(
|
||||
api_key=api_key,
|
||||
server_url=self._resolve_server_url(ctx),
|
||||
timeout_ms=self.config.timeout * 1000,
|
||||
|
|
@ -85,7 +92,7 @@ class WebSearch(
|
|||
|
||||
yield self._parse_response(response)
|
||||
|
||||
except mistralai.SDKError as exc:
|
||||
except SDKError as exc:
|
||||
raise ToolError(f"Mistral API error: {exc}") from exc
|
||||
|
||||
def _resolve_server_url(self, ctx: InvokeContext | None) -> str | None:
|
||||
|
|
@ -96,19 +103,17 @@ class WebSearch(
|
|||
return get_server_url_from_api_base(provider.api_base)
|
||||
return None
|
||||
|
||||
def _parse_response(
|
||||
self, response: mistralai.ConversationResponse
|
||||
) -> WebSearchResult:
|
||||
def _parse_response(self, response: ConversationResponse) -> WebSearchResult:
|
||||
text_parts: list[str] = []
|
||||
sources: dict[str, WebSearchSource] = {}
|
||||
|
||||
for entry in response.outputs:
|
||||
if not isinstance(entry, mistralai.MessageOutputEntry):
|
||||
if not isinstance(entry, MessageOutputEntry):
|
||||
continue
|
||||
for chunk in entry.content:
|
||||
if isinstance(chunk, mistralai.TextChunk):
|
||||
if isinstance(chunk, TextChunk):
|
||||
text_parts.append(chunk.text)
|
||||
elif isinstance(chunk, mistralai.ToolReferenceChunk) and chunk.url:
|
||||
elif isinstance(chunk, ToolReferenceChunk) and chunk.url:
|
||||
if chunk.url not in sources:
|
||||
sources[chunk.url] = WebSearchSource(
|
||||
title=chunk.title, url=chunk.url
|
||||
|
|
|
|||
23
vibe/core/transcribe/__init__.py
Normal file
23
vibe/core/transcribe/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.transcribe.factory import make_transcribe_client
|
||||
from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
|
||||
from vibe.core.transcribe.transcribe_client_port import (
|
||||
TranscribeClientPort,
|
||||
TranscribeDone,
|
||||
TranscribeError,
|
||||
TranscribeEvent,
|
||||
TranscribeSessionCreated,
|
||||
TranscribeTextDelta,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MistralTranscribeClient",
|
||||
"TranscribeClientPort",
|
||||
"TranscribeDone",
|
||||
"TranscribeError",
|
||||
"TranscribeEvent",
|
||||
"TranscribeSessionCreated",
|
||||
"TranscribeTextDelta",
|
||||
"make_transcribe_client",
|
||||
]
|
||||
19
vibe/core/transcribe/factory.py
Normal file
19
vibe/core/transcribe/factory.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.config import (
|
||||
TranscribeClient,
|
||||
TranscribeModelConfig,
|
||||
TranscribeProviderConfig,
|
||||
)
|
||||
from vibe.core.transcribe.mistral_transcribe_client import MistralTranscribeClient
|
||||
from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
|
||||
|
||||
TRANSCRIBE_CLIENT_MAP: dict[TranscribeClient, type[TranscribeClientPort]] = {
|
||||
TranscribeClient.MISTRAL: MistralTranscribeClient
|
||||
}
|
||||
|
||||
|
||||
def make_transcribe_client(
|
||||
provider: TranscribeProviderConfig, model: TranscribeModelConfig
|
||||
) -> TranscribeClientPort:
|
||||
return TRANSCRIBE_CLIENT_MAP[provider.client](provider=provider, model=model)
|
||||
63
vibe/core/transcribe/mistral_transcribe_client.py
Normal file
63
vibe/core/transcribe/mistral_transcribe_client.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
import os
|
||||
|
||||
from mistralai.client import Mistral
|
||||
from mistralai.client.models import (
|
||||
AudioFormat,
|
||||
RealtimeTranscriptionError,
|
||||
RealtimeTranscriptionSessionCreated,
|
||||
TranscriptionStreamDone,
|
||||
TranscriptionStreamTextDelta,
|
||||
)
|
||||
from mistralai.extra.realtime import UnknownRealtimeEvent
|
||||
|
||||
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
|
||||
from vibe.core.transcribe.transcribe_client_port import (
|
||||
TranscribeDone,
|
||||
TranscribeError,
|
||||
TranscribeEvent,
|
||||
TranscribeSessionCreated,
|
||||
TranscribeTextDelta,
|
||||
)
|
||||
|
||||
|
||||
class MistralTranscribeClient:
|
||||
def __init__(
|
||||
self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
|
||||
) -> None:
|
||||
self._api_key = os.getenv(provider.api_key_env_var, "")
|
||||
self._server_url = provider.api_base
|
||||
self._model_name = model.name
|
||||
self._audio_format = AudioFormat(
|
||||
encoding=model.encoding, sample_rate=model.sample_rate
|
||||
)
|
||||
self._target_streaming_delay_ms = model.target_streaming_delay_ms
|
||||
self._client: Mistral | None = None
|
||||
|
||||
def _get_client(self) -> Mistral:
|
||||
if self._client is None:
|
||||
self._client = Mistral(api_key=self._api_key, server_url=self._server_url)
|
||||
return self._client
|
||||
|
||||
async def transcribe(
|
||||
self, audio_stream: AsyncIterator[bytes]
|
||||
) -> AsyncIterator[TranscribeEvent]:
|
||||
client = self._get_client()
|
||||
async for event in client.audio.realtime.transcribe_stream(
|
||||
audio_stream=audio_stream,
|
||||
model=self._model_name,
|
||||
audio_format=self._audio_format,
|
||||
target_streaming_delay_ms=self._target_streaming_delay_ms,
|
||||
):
|
||||
if isinstance(event, RealtimeTranscriptionSessionCreated):
|
||||
yield TranscribeSessionCreated()
|
||||
elif isinstance(event, TranscriptionStreamTextDelta):
|
||||
yield TranscribeTextDelta(text=event.text)
|
||||
elif isinstance(event, TranscriptionStreamDone):
|
||||
yield TranscribeDone()
|
||||
elif isinstance(event, RealtimeTranscriptionError):
|
||||
yield TranscribeError(message=str(event.error.message))
|
||||
elif isinstance(event, UnknownRealtimeEvent):
|
||||
continue
|
||||
42
vibe/core/transcribe/transcribe_client_port.py
Normal file
42
vibe/core/transcribe/transcribe_client_port.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from vibe.core.config import TranscribeModelConfig, TranscribeProviderConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscribeSessionCreated:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscribeTextDelta:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscribeDone:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TranscribeError:
|
||||
message: str
|
||||
|
||||
|
||||
TranscribeEvent = (
|
||||
TranscribeSessionCreated | TranscribeTextDelta | TranscribeDone | TranscribeError
|
||||
)
|
||||
|
||||
|
||||
class TranscribeClientPort(Protocol):
|
||||
def __init__(
|
||||
self, provider: TranscribeProviderConfig, model: TranscribeModelConfig
|
||||
) -> None: ...
|
||||
|
||||
def transcribe(
|
||||
self, audio_stream: AsyncIterator[bytes]
|
||||
) -> AsyncIterator[TranscribeEvent]: ...
|
||||
|
|
@ -6,14 +6,14 @@ import tomllib
|
|||
import tomli_w
|
||||
|
||||
from vibe.core.paths import (
|
||||
AGENTS_MD_FILENAMES,
|
||||
AGENTS_MD_FILENAME,
|
||||
TRUSTED_FOLDERS_FILE,
|
||||
walk_local_config_dirs_all,
|
||||
)
|
||||
|
||||
|
||||
def has_agents_md_file(path: Path) -> bool:
|
||||
return any((path / name).exists() for name in AGENTS_MD_FILENAMES)
|
||||
return (path / AGENTS_MD_FILENAME).exists()
|
||||
|
||||
|
||||
def has_trustable_content(path: Path) -> bool:
|
||||
|
|
@ -60,11 +60,35 @@ class TrustedFoldersManager:
|
|||
pass
|
||||
|
||||
def is_trusted(self, path: Path) -> bool | None:
|
||||
normalized = self._normalize_path(path)
|
||||
if normalized in self._trusted:
|
||||
return True
|
||||
if normalized in self._untrusted:
|
||||
return False
|
||||
"""Check trust walking up from *path* to filesystem root.
|
||||
|
||||
The first ancestor (or *path* itself) found in either the trusted
|
||||
or untrusted list wins. Returns ``None`` when no decision exists.
|
||||
"""
|
||||
current = Path(self._normalize_path(path))
|
||||
while True:
|
||||
s = str(current)
|
||||
if s in self._trusted:
|
||||
return True
|
||||
if s in self._untrusted:
|
||||
return False
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return None
|
||||
|
||||
def find_trust_root(self, path: Path) -> Path | None:
|
||||
"""Return the closest ancestor (or *path* itself) explicitly in the trusted list."""
|
||||
current = Path(self._normalize_path(path))
|
||||
while True:
|
||||
s = str(current)
|
||||
if s in self._trusted:
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return None
|
||||
|
||||
def add_trusted(self, path: Path) -> None:
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ class ToolResultEvent(BaseEvent):
|
|||
error: str | None = None
|
||||
skipped: bool = False
|
||||
skip_reason: str | None = None
|
||||
cancelled: bool = False
|
||||
duration: float | None = None
|
||||
tool_call_id: str
|
||||
|
||||
|
|
@ -403,16 +404,10 @@ class OutputFormat(StrEnum):
|
|||
STREAMING = auto()
|
||||
|
||||
|
||||
type AsyncApprovalCallback = Callable[
|
||||
type ApprovalCallback = Callable[
|
||||
[str, BaseModel, str], Awaitable[tuple[ApprovalResponse, str | None]]
|
||||
]
|
||||
|
||||
type SyncApprovalCallback = Callable[
|
||||
[str, BaseModel, str], tuple[ApprovalResponse, str | None]
|
||||
]
|
||||
|
||||
type ApprovalCallback = AsyncApprovalCallback | SyncApprovalCallback
|
||||
|
||||
type UserInputCallback = Callable[[BaseModel], Awaitable[BaseModel]]
|
||||
|
||||
type SwitchAgentCallback = Callable[[str], Awaitable[None]]
|
||||
|
|
|
|||
|
|
@ -88,12 +88,9 @@ def get_user_cancellation_message(
|
|||
|
||||
|
||||
def is_user_cancellation_event(event: BaseEvent) -> bool:
|
||||
return (
|
||||
isinstance(event, ToolResultEvent)
|
||||
and event.skipped
|
||||
and event.skip_reason is not None
|
||||
and f"<{CANCELLATION_TAG}>" in event.skip_reason
|
||||
)
|
||||
if not isinstance(event, ToolResultEvent):
|
||||
return False
|
||||
return event.cancelled
|
||||
|
||||
|
||||
def is_dangerous_directory(path: Path | str = ".") -> tuple[bool, str]:
|
||||
|
|
|
|||
|
|
@ -46,14 +46,16 @@ class TrustFolderDialog(CenterMiddle):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with CenterMiddle(id="trust-dialog"):
|
||||
yield NoMarkupStatic("⚠ Trust this folder?", id="trust-dialog-title")
|
||||
yield NoMarkupStatic(
|
||||
"⚠ Trust this folder and all its subfolders?", id="trust-dialog-title"
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
str(self.folder_path),
|
||||
id="trust-dialog-path",
|
||||
classes="trust-dialog-path",
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder?",
|
||||
"Files that can modify your Mistral Vibe setup were found here. Do you trust this folder and all its subfolders?",
|
||||
id="trust-dialog-message",
|
||||
classes="trust-dialog-message",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# What's new in v2.4.2
|
||||
|
||||
- **Skill arguments**: Skills now extract arguments when invoked, allowing you to pass arguments
|
||||
- **Auto-compact fallback**: Auto-compact threshold falls back to the global setting when not defined at model level
|
||||
# What's new in v2.5.0
|
||||
- **Lean mode**: Setup a dedicated theorem proving agent powered by leanstral with /leanstall
|
||||
- **Parallel tool execution**: Parallel tool calls are sped up by being ran at the same time
|
||||
- **Voice mode**: Real-time transcription support with /voice
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue