code-puppy 0.0.198__tar.gz → 0.0.200__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {code_puppy-0.0.198 → code_puppy-0.0.200}/PKG-INFO +1 -1
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/base_agent.py +76 -22
- code_puppy-0.0.200/code_puppy/command_line/attachments.py +375 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/model_picker_completion.py +14 -5
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/prompt_toolkit_completion.py +119 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/main.py +90 -46
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/screens/settings.py +14 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/pyproject.toml +1 -1
- {code_puppy-0.0.198 → code_puppy-0.0.200}/.gitignore +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/LICENSE +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/README.md +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/__main__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_c_reviewer.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_code_puppy.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_code_reviewer.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_cpp_reviewer.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_creator_agent.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_golang_reviewer.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_javascript_reviewer.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_manager.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_python_reviewer.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_qa_expert.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_qa_kitten.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_security_auditor.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/agent_typescript_reviewer.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/agents/json_agent.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/callbacks.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/command_handler.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/file_path_completion.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/load_context_completion.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/add_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/base.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/handler.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/help_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/install_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/list_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/logs_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/remove_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/restart_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/search_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/start_all_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/start_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/status_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/stop_all_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/stop_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/test_command.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/utils.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/mcp/wizard_utils.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/motd.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/utils.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/config.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/http_utils.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/async_lifecycle.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/blocking_startup.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/captured_stdio_server.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/circuit_breaker.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/config_wizard.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/dashboard.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/error_isolation.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/examples/retry_example.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/health_monitor.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/managed_server.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/manager.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/registry.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/retry_manager.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/server_registry_catalog.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/status_tracker.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/mcp_/system_tools.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/message_queue.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/queue_console.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/renderers.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/spinner/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/spinner/console_spinner.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/spinner/spinner_base.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/messaging/spinner/textual_spinner.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/model_factory.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/models.json +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/plugins/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/plugins/example_custom_command/register_callbacks.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/reopenable_async_client.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/round_robin_model.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/session_storage.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/status_display.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/summarization_agent.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/agent_tools.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_control.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_interactions.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_locators.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_navigation.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_screenshot.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_scripts.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/browser_workflows.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/camoufox_manager.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/browser/vqa_agent.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/command_runner.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/common.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/file_modifications.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/file_operations.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tools/tools_content.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/app.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/chat_view.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/command_history_modal.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/copy_button.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/custom_widgets.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/human_input_modal.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/input_area.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/sidebar.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/components/status_bar.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/messages.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/models/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/models/chat_message.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/models/command_history.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/models/enums.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/screens/__init__.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/screens/autosave_picker.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/screens/help.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/screens/mcp_install_wizard.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui/screens/tools.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/tui_state.py +0 -0
- {code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/version_checker.py +0 -0
@@ -6,12 +6,13 @@ import math
|
|
6
6
|
import signal
|
7
7
|
import uuid
|
8
8
|
from abc import ABC, abstractmethod
|
9
|
-
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
9
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Union
|
10
10
|
|
11
11
|
import mcp
|
12
12
|
import pydantic
|
13
13
|
import pydantic_ai.models
|
14
14
|
from pydantic_ai import Agent as PydanticAgent
|
15
|
+
from pydantic_ai import BinaryContent, DocumentUrl, ImageUrl
|
15
16
|
from pydantic_ai import RunContext, UsageLimitExceeded
|
16
17
|
from pydantic_ai.messages import (
|
17
18
|
ModelMessage,
|
@@ -180,6 +181,21 @@ class BaseAgent(ABC):
|
|
180
181
|
return get_global_model_name()
|
181
182
|
return pinned
|
182
183
|
|
184
|
+
def _clean_binaries(self, messages: List[ModelMessage]) -> List[ModelMessage]:
|
185
|
+
cleaned = []
|
186
|
+
for message in messages:
|
187
|
+
parts = []
|
188
|
+
for part in message.parts:
|
189
|
+
if hasattr(part, "content") and isinstance(part.content, list):
|
190
|
+
content = []
|
191
|
+
for item in part.content:
|
192
|
+
if not isinstance(item, BinaryContent):
|
193
|
+
content.append(item)
|
194
|
+
part.content = content
|
195
|
+
parts.append(part)
|
196
|
+
cleaned.append(message)
|
197
|
+
return cleaned
|
198
|
+
|
183
199
|
# Message history processing methods (moved from state_management.py and message_history_processor.py)
|
184
200
|
def _stringify_part(self, part: Any) -> str:
|
185
201
|
"""Create a stable string representation for a message part.
|
@@ -213,6 +229,12 @@ class BaseAgent(ABC):
|
|
213
229
|
)
|
214
230
|
elif isinstance(content, dict):
|
215
231
|
attributes.append(f"content={json.dumps(content, sort_keys=True)}")
|
232
|
+
elif isinstance(content, list):
|
233
|
+
for item in content:
|
234
|
+
if isinstance(item, str):
|
235
|
+
attributes.append(f"content={item}")
|
236
|
+
if isinstance(item, BinaryContent):
|
237
|
+
attributes.append(f"BinaryContent={hash(item.data)}")
|
216
238
|
else:
|
217
239
|
attributes.append(f"content={repr(content)}")
|
218
240
|
result = "|".join(attributes)
|
@@ -259,6 +281,13 @@ class BaseAgent(ABC):
|
|
259
281
|
result = json.dumps(part.content.model_dump())
|
260
282
|
elif isinstance(part.content, dict):
|
261
283
|
result = json.dumps(part.content)
|
284
|
+
elif isinstance(part.content, list):
|
285
|
+
result = ""
|
286
|
+
for item in part.content:
|
287
|
+
if isinstance(item, str):
|
288
|
+
result += item + "\n"
|
289
|
+
if isinstance(item, BinaryContent):
|
290
|
+
result += f"BinaryContent={hash(item.data)}\n"
|
262
291
|
else:
|
263
292
|
result = str(part.content)
|
264
293
|
|
@@ -460,16 +489,21 @@ class BaseAgent(ABC):
|
|
460
489
|
|
461
490
|
def get_model_context_length(self) -> int:
|
462
491
|
"""
|
463
|
-
|
464
|
-
"""
|
465
|
-
model_configs = ModelFactory.load_config()
|
466
|
-
model_name = get_global_model_name()
|
467
|
-
|
468
|
-
# Get context length from model config
|
469
|
-
model_config = model_configs.get(model_name, {})
|
470
|
-
context_length = model_config.get("context_length", 128000) # Default value
|
492
|
+
Return the context length for this agent's effective model.
|
471
493
|
|
472
|
-
|
494
|
+
Honors per-agent pinned model via `self.get_model_name()`; falls back
|
495
|
+
to global model when no pin is set. Defaults conservatively on failure.
|
496
|
+
"""
|
497
|
+
try:
|
498
|
+
model_configs = ModelFactory.load_config()
|
499
|
+
# Use the agent's effective model (respects /pin_model)
|
500
|
+
model_name = self.get_model_name()
|
501
|
+
model_config = model_configs.get(model_name, {})
|
502
|
+
context_length = model_config.get("context_length", 128000)
|
503
|
+
return int(context_length)
|
504
|
+
except Exception:
|
505
|
+
# Be safe; don't blow up status/compaction if model lookup fails
|
506
|
+
return 128000
|
473
507
|
|
474
508
|
def prune_interrupted_tool_calls(
|
475
509
|
self, messages: List[ModelMessage]
|
@@ -601,6 +635,7 @@ class BaseAgent(ABC):
|
|
601
635
|
f"Final token count after processing: {final_token_count}",
|
602
636
|
message_group="token_context_status",
|
603
637
|
)
|
638
|
+
|
604
639
|
self.set_message_history(result_messages)
|
605
640
|
for m in summarized_messages:
|
606
641
|
self.add_compacted_message_hash(self.hash_message(m))
|
@@ -869,28 +904,47 @@ class BaseAgent(ABC):
|
|
869
904
|
self.message_history_processor(ctx, _message_history)
|
870
905
|
return self.get_message_history()
|
871
906
|
|
872
|
-
async def run_with_mcp(
|
873
|
-
|
874
|
-
|
875
|
-
|
876
|
-
|
877
|
-
|
907
|
+
async def run_with_mcp(
|
908
|
+
self,
|
909
|
+
prompt: str,
|
910
|
+
*,
|
911
|
+
attachments: Optional[Sequence[BinaryContent]] = None,
|
912
|
+
link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
|
913
|
+
**kwargs,
|
914
|
+
) -> Any:
|
915
|
+
"""Run the agent with MCP servers, attachments, and full cancellation support.
|
878
916
|
|
879
917
|
Args:
|
880
|
-
prompt:
|
881
|
-
|
882
|
-
|
918
|
+
prompt: Primary user prompt text (may be empty when attachments present).
|
919
|
+
attachments: Local binary payloads (e.g., dragged images) to include.
|
920
|
+
link_attachments: Remote assets (image/document URLs) to include.
|
921
|
+
**kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
|
883
922
|
|
884
923
|
Returns:
|
885
|
-
The agent's response
|
924
|
+
The agent's response.
|
886
925
|
|
887
926
|
Raises:
|
888
|
-
asyncio.CancelledError: When execution is cancelled by user
|
927
|
+
asyncio.CancelledError: When execution is cancelled by user.
|
889
928
|
"""
|
890
929
|
group_id = str(uuid.uuid4())
|
891
930
|
# Avoid double-loading: reuse existing agent if already built
|
892
931
|
pydantic_agent = self._code_generation_agent or self.reload_code_generation_agent()
|
893
932
|
|
933
|
+
# Build combined prompt payload when attachments are provided.
|
934
|
+
attachment_parts: List[Any] = []
|
935
|
+
if attachments:
|
936
|
+
attachment_parts.extend(list(attachments))
|
937
|
+
if link_attachments:
|
938
|
+
attachment_parts.extend(list(link_attachments))
|
939
|
+
|
940
|
+
if attachment_parts:
|
941
|
+
prompt_payload: Union[str, List[Any]] = []
|
942
|
+
if prompt:
|
943
|
+
prompt_payload.append(prompt)
|
944
|
+
prompt_payload.extend(attachment_parts)
|
945
|
+
else:
|
946
|
+
prompt_payload = prompt
|
947
|
+
|
894
948
|
async def run_agent_task():
|
895
949
|
try:
|
896
950
|
self.set_message_history(
|
@@ -898,7 +952,7 @@ class BaseAgent(ABC):
|
|
898
952
|
)
|
899
953
|
usage_limits = pydantic_ai.agent._usage.UsageLimits(request_limit=get_message_limit())
|
900
954
|
result_ = await pydantic_agent.run(
|
901
|
-
|
955
|
+
prompt_payload,
|
902
956
|
message_history=self.get_message_history(),
|
903
957
|
usage_limits=usage_limits,
|
904
958
|
**kwargs,
|
@@ -0,0 +1,375 @@
|
|
1
|
+
"""Helpers for parsing file attachments from interactive prompts."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import mimetypes
|
6
|
+
import os
|
7
|
+
import shlex
|
8
|
+
from dataclasses import dataclass
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Iterable, List, Sequence
|
11
|
+
|
12
|
+
from pydantic_ai import BinaryContent, DocumentUrl, ImageUrl
|
13
|
+
|
14
|
+
SUPPORTED_INLINE_SCHEMES = {"http", "https"}
|
15
|
+
|
16
|
+
# Allow common extensions people drag in the terminal.
|
17
|
+
DEFAULT_ACCEPTED_IMAGE_EXTENSIONS = {
|
18
|
+
".png",
|
19
|
+
".jpg",
|
20
|
+
".jpeg",
|
21
|
+
".gif",
|
22
|
+
".bmp",
|
23
|
+
".webp",
|
24
|
+
".tiff",
|
25
|
+
}
|
26
|
+
DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS = set()
|
27
|
+
|
28
|
+
|
29
|
+
@dataclass
|
30
|
+
class PromptAttachment:
|
31
|
+
"""Represents a binary attachment parsed from the input prompt."""
|
32
|
+
|
33
|
+
placeholder: str
|
34
|
+
content: BinaryContent
|
35
|
+
|
36
|
+
|
37
|
+
@dataclass
|
38
|
+
class PromptLinkAttachment:
|
39
|
+
"""Represents a URL attachment supported by pydantic-ai."""
|
40
|
+
|
41
|
+
placeholder: str
|
42
|
+
url_part: ImageUrl | DocumentUrl
|
43
|
+
|
44
|
+
|
45
|
+
@dataclass
|
46
|
+
class ProcessedPrompt:
|
47
|
+
"""Container for parsed input prompt and attachments."""
|
48
|
+
|
49
|
+
prompt: str
|
50
|
+
attachments: List[PromptAttachment]
|
51
|
+
link_attachments: List[PromptLinkAttachment]
|
52
|
+
warnings: List[str]
|
53
|
+
|
54
|
+
|
55
|
+
class AttachmentParsingError(RuntimeError):
|
56
|
+
"""Raised when we fail to load a user-provided attachment."""
|
57
|
+
|
58
|
+
|
59
|
+
def _is_probable_path(token: str) -> bool:
|
60
|
+
"""Heuristically determine whether a token is a local filesystem path."""
|
61
|
+
|
62
|
+
if not token:
|
63
|
+
return False
|
64
|
+
if token.startswith("#"):
|
65
|
+
return False
|
66
|
+
# Windows drive letters or Unix absolute/relative paths
|
67
|
+
if token.startswith(("/", "~", "./", "../")):
|
68
|
+
return True
|
69
|
+
if len(token) >= 2 and token[1] == ":":
|
70
|
+
return True
|
71
|
+
# Things like `path/to/file.png`
|
72
|
+
return os.sep in token or "\"" in token
|
73
|
+
|
74
|
+
|
75
|
+
def _unescape_dragged_path(token: str) -> str:
|
76
|
+
"""Convert backslash-escaped spaces used by drag-and-drop to literal spaces."""
|
77
|
+
# Shell/terminal escaping typically produces '\ ' sequences
|
78
|
+
return token.replace(r"\ ", " ")
|
79
|
+
|
80
|
+
|
81
|
+
def _normalise_path(token: str) -> Path:
|
82
|
+
"""Expand user shortcuts and resolve relative components without touching fs."""
|
83
|
+
# First unescape any drag-and-drop backslash spaces before other expansions
|
84
|
+
unescaped = _unescape_dragged_path(token)
|
85
|
+
expanded = os.path.expanduser(unescaped)
|
86
|
+
try:
|
87
|
+
# This will not resolve against symlinks because we do not call resolve()
|
88
|
+
return Path(expanded).absolute()
|
89
|
+
except Exception as exc:
|
90
|
+
raise AttachmentParsingError(f"Invalid path '{token}': {exc}") from exc
|
91
|
+
|
92
|
+
|
93
|
+
def _determine_media_type(path: Path) -> str:
|
94
|
+
"""Best-effort media type detection for images only."""
|
95
|
+
|
96
|
+
mime, _ = mimetypes.guess_type(path.name)
|
97
|
+
if mime:
|
98
|
+
return mime
|
99
|
+
if path.suffix.lower() in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS:
|
100
|
+
return "image/png"
|
101
|
+
return "application/octet-stream"
|
102
|
+
|
103
|
+
|
104
|
+
def _load_binary(path: Path) -> bytes:
|
105
|
+
try:
|
106
|
+
return path.read_bytes()
|
107
|
+
except FileNotFoundError as exc:
|
108
|
+
raise AttachmentParsingError(f"Attachment not found: {path}") from exc
|
109
|
+
except PermissionError as exc:
|
110
|
+
raise AttachmentParsingError(f"Cannot read attachment (permission denied): {path}") from exc
|
111
|
+
except OSError as exc:
|
112
|
+
raise AttachmentParsingError(f"Failed to read attachment {path}: {exc}") from exc
|
113
|
+
|
114
|
+
|
115
|
+
def _tokenise(prompt: str) -> Iterable[str]:
|
116
|
+
"""Split the prompt preserving quoted segments using shell-like semantics."""
|
117
|
+
|
118
|
+
if not prompt:
|
119
|
+
return []
|
120
|
+
try:
|
121
|
+
# On Windows, avoid POSIX escaping so backslashes are preserved
|
122
|
+
posix_mode = os.name != "nt"
|
123
|
+
return shlex.split(prompt, posix=posix_mode)
|
124
|
+
except ValueError:
|
125
|
+
# Fallback naive split when shlex fails (e.g. unmatched quotes)
|
126
|
+
return prompt.split()
|
127
|
+
|
128
|
+
|
129
|
+
def _strip_attachment_token(token: str) -> str:
|
130
|
+
"""Trim surrounding whitespace/punctuation terminals tack onto paths."""
|
131
|
+
|
132
|
+
return token.strip().strip(",;:()[]{}")
|
133
|
+
|
134
|
+
|
135
|
+
def _candidate_paths(
|
136
|
+
tokens: Sequence[str],
|
137
|
+
start: int,
|
138
|
+
max_span: int = 5,
|
139
|
+
) -> Iterable[tuple[str, int]]:
|
140
|
+
"""Yield space-joined token slices to reconstruct paths with spaces."""
|
141
|
+
|
142
|
+
collected: list[str] = []
|
143
|
+
for offset, raw in enumerate(tokens[start : start + max_span]):
|
144
|
+
collected.append(raw)
|
145
|
+
yield " ".join(collected), start + offset + 1
|
146
|
+
|
147
|
+
|
148
|
+
def _is_supported_extension(path: Path) -> bool:
|
149
|
+
suffix = path.suffix.lower()
|
150
|
+
return suffix in DEFAULT_ACCEPTED_IMAGE_EXTENSIONS | DEFAULT_ACCEPTED_DOCUMENT_EXTENSIONS
|
151
|
+
|
152
|
+
|
153
|
+
def _parse_link(token: str) -> PromptLinkAttachment | None:
|
154
|
+
if "://" not in token:
|
155
|
+
return None
|
156
|
+
scheme = token.split(":", 1)[0].lower()
|
157
|
+
if scheme not in SUPPORTED_INLINE_SCHEMES:
|
158
|
+
return None
|
159
|
+
if token.lower().endswith(".pdf"):
|
160
|
+
return PromptLinkAttachment(
|
161
|
+
placeholder=token,
|
162
|
+
url_part=DocumentUrl(url=token),
|
163
|
+
)
|
164
|
+
return PromptLinkAttachment(
|
165
|
+
placeholder=token,
|
166
|
+
url_part=ImageUrl(url=token),
|
167
|
+
)
|
168
|
+
|
169
|
+
|
170
|
+
@dataclass
|
171
|
+
class _DetectedPath:
|
172
|
+
placeholder: str
|
173
|
+
path: Path | None
|
174
|
+
start_index: int
|
175
|
+
consumed_until: int
|
176
|
+
unsupported: bool = False
|
177
|
+
link: PromptLinkAttachment | None = None
|
178
|
+
|
179
|
+
def has_path(self) -> bool:
|
180
|
+
return self.path is not None and not self.unsupported
|
181
|
+
|
182
|
+
|
183
|
+
def _detect_path_tokens(prompt: str) -> tuple[list[_DetectedPath], list[str]]:
|
184
|
+
# Preserve backslash-spaces from drag-and-drop before shlex tokenization
|
185
|
+
# Replace '\ ' with a marker that shlex won't split, then restore later
|
186
|
+
ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
|
187
|
+
masked_prompt = prompt.replace(r"\ ", ESCAPE_MARKER)
|
188
|
+
tokens = list(_tokenise(masked_prompt))
|
189
|
+
# Restore escaped spaces in individual tokens
|
190
|
+
tokens = [t.replace(ESCAPE_MARKER, " ") for t in tokens]
|
191
|
+
|
192
|
+
detections: list[_DetectedPath] = []
|
193
|
+
warnings: list[str] = []
|
194
|
+
|
195
|
+
index = 0
|
196
|
+
while index < len(tokens):
|
197
|
+
token = tokens[index]
|
198
|
+
|
199
|
+
link_attachment = _parse_link(token)
|
200
|
+
if link_attachment:
|
201
|
+
detections.append(
|
202
|
+
_DetectedPath(
|
203
|
+
placeholder=token,
|
204
|
+
path=None,
|
205
|
+
start_index=index,
|
206
|
+
consumed_until=index + 1,
|
207
|
+
link=link_attachment,
|
208
|
+
)
|
209
|
+
)
|
210
|
+
index += 1
|
211
|
+
continue
|
212
|
+
|
213
|
+
stripped_token = _strip_attachment_token(token)
|
214
|
+
if not _is_probable_path(stripped_token):
|
215
|
+
index += 1
|
216
|
+
continue
|
217
|
+
|
218
|
+
start_index = index
|
219
|
+
consumed_until = index + 1
|
220
|
+
candidate_path_token = stripped_token
|
221
|
+
# For placeholder: try to reconstruct escaped representation; if none, use raw token
|
222
|
+
original_tokens_for_slice = list(_tokenise(masked_prompt))[index:consumed_until]
|
223
|
+
candidate_placeholder = "".join(
|
224
|
+
ot.replace(ESCAPE_MARKER, r"\ ") if ESCAPE_MARKER in ot else ot
|
225
|
+
for ot in original_tokens_for_slice
|
226
|
+
)
|
227
|
+
# If placeholder seems identical to raw token, just use the raw token
|
228
|
+
if candidate_placeholder == token.replace(" ", r"\ "):
|
229
|
+
candidate_placeholder = token
|
230
|
+
|
231
|
+
try:
|
232
|
+
path = _normalise_path(candidate_path_token)
|
233
|
+
except AttachmentParsingError as exc:
|
234
|
+
warnings.append(str(exc))
|
235
|
+
index = consumed_until
|
236
|
+
continue
|
237
|
+
|
238
|
+
if not path.exists() or not path.is_file():
|
239
|
+
found_span = False
|
240
|
+
last_path = path
|
241
|
+
for joined, end_index in _candidate_paths(tokens, index):
|
242
|
+
stripped_joined = _strip_attachment_token(joined)
|
243
|
+
if not _is_probable_path(stripped_joined):
|
244
|
+
continue
|
245
|
+
candidate_path_token = stripped_joined
|
246
|
+
candidate_placeholder = joined
|
247
|
+
consumed_until = end_index
|
248
|
+
try:
|
249
|
+
last_path = _normalise_path(candidate_path_token)
|
250
|
+
except AttachmentParsingError as exc:
|
251
|
+
warnings.append(str(exc))
|
252
|
+
found_span = False
|
253
|
+
break
|
254
|
+
if last_path.exists() and last_path.is_file():
|
255
|
+
path = last_path
|
256
|
+
found_span = True
|
257
|
+
# We'll rebuild escaped placeholder after this block
|
258
|
+
break
|
259
|
+
if not found_span:
|
260
|
+
warnings.append(f"Attachment ignored (not a file): {path}")
|
261
|
+
index += 1
|
262
|
+
continue
|
263
|
+
# Reconstruct escaped placeholder for multi-token paths
|
264
|
+
original_tokens_for_path = tokens[index:consumed_until]
|
265
|
+
escaped_placeholder = " ".join(original_tokens_for_path).replace(" ", r"\ ")
|
266
|
+
candidate_placeholder = escaped_placeholder
|
267
|
+
if not _is_supported_extension(path):
|
268
|
+
detections.append(
|
269
|
+
_DetectedPath(
|
270
|
+
placeholder=candidate_placeholder,
|
271
|
+
path=path,
|
272
|
+
start_index=start_index,
|
273
|
+
consumed_until=consumed_until,
|
274
|
+
unsupported=True,
|
275
|
+
)
|
276
|
+
)
|
277
|
+
index = consumed_until
|
278
|
+
continue
|
279
|
+
|
280
|
+
# Reconstruct escaped placeholder for exact replacement later
|
281
|
+
# For unquoted spaces, keep the original literal token from the prompt
|
282
|
+
# so replacement matches precisely
|
283
|
+
escaped_placeholder = candidate_placeholder
|
284
|
+
|
285
|
+
detections.append(
|
286
|
+
_DetectedPath(
|
287
|
+
placeholder=candidate_placeholder,
|
288
|
+
path=path,
|
289
|
+
start_index=start_index,
|
290
|
+
consumed_until=consumed_until,
|
291
|
+
)
|
292
|
+
)
|
293
|
+
index = consumed_until
|
294
|
+
|
295
|
+
return detections, warnings
|
296
|
+
|
297
|
+
|
298
|
+
def parse_prompt_attachments(prompt: str) -> ProcessedPrompt:
|
299
|
+
"""Extract attachments from the prompt returning cleaned text and metadata."""
|
300
|
+
|
301
|
+
attachments: List[PromptAttachment] = []
|
302
|
+
|
303
|
+
detections, detection_warnings = _detect_path_tokens(prompt)
|
304
|
+
warnings: List[str] = list(detection_warnings)
|
305
|
+
|
306
|
+
link_attachments = [d.link for d in detections if d.link is not None]
|
307
|
+
|
308
|
+
for detection in detections:
|
309
|
+
if detection.link is not None and detection.path is None:
|
310
|
+
continue
|
311
|
+
if detection.path is None:
|
312
|
+
continue
|
313
|
+
if detection.unsupported:
|
314
|
+
warnings.append(
|
315
|
+
f"Unsupported attachment type: {detection.path.suffix or detection.path.name}"
|
316
|
+
)
|
317
|
+
continue
|
318
|
+
|
319
|
+
try:
|
320
|
+
media_type = _determine_media_type(detection.path)
|
321
|
+
data = _load_binary(detection.path)
|
322
|
+
except AttachmentParsingError as exc:
|
323
|
+
warnings.append(str(exc))
|
324
|
+
continue
|
325
|
+
attachments.append(
|
326
|
+
PromptAttachment(
|
327
|
+
placeholder=detection.placeholder,
|
328
|
+
content=BinaryContent(data=data, media_type=media_type),
|
329
|
+
)
|
330
|
+
)
|
331
|
+
|
332
|
+
# Rebuild cleaned_prompt by skipping tokens consumed as file paths.
|
333
|
+
# This preserves original punctuation and spacing for non-attachment tokens.
|
334
|
+
ESCAPE_MARKER = "\u0000ESCAPED_SPACE\u0000"
|
335
|
+
masked = prompt.replace(r"\ ", ESCAPE_MARKER)
|
336
|
+
tokens = list(_tokenise(masked))
|
337
|
+
|
338
|
+
# Build exact token spans for file attachments (supported or unsupported)
|
339
|
+
# Skip spans for: supported files (path present and not unsupported) and links.
|
340
|
+
spans = [
|
341
|
+
(d.start_index, d.consumed_until)
|
342
|
+
for d in detections
|
343
|
+
if (d.path is not None and not d.unsupported) or (d.link is not None and d.path is None)
|
344
|
+
]
|
345
|
+
cleaned_parts: list[str] = []
|
346
|
+
i = 0
|
347
|
+
while i < len(tokens):
|
348
|
+
span = next((s for s in spans if s[0] <= i < s[1]), None)
|
349
|
+
if span is not None:
|
350
|
+
i = span[1]
|
351
|
+
continue
|
352
|
+
cleaned_parts.append(tokens[i].replace(ESCAPE_MARKER, " "))
|
353
|
+
i += 1
|
354
|
+
|
355
|
+
cleaned_prompt = " ".join(cleaned_parts).strip()
|
356
|
+
cleaned_prompt = " ".join(cleaned_prompt.split())
|
357
|
+
|
358
|
+
if cleaned_prompt == "" and attachments:
|
359
|
+
cleaned_prompt = "Describe the attached files in detail."
|
360
|
+
|
361
|
+
return ProcessedPrompt(
|
362
|
+
prompt=cleaned_prompt,
|
363
|
+
attachments=attachments,
|
364
|
+
link_attachments=link_attachments,
|
365
|
+
warnings=warnings,
|
366
|
+
)
|
367
|
+
|
368
|
+
|
369
|
+
__all__ = [
|
370
|
+
"ProcessedPrompt",
|
371
|
+
"PromptAttachment",
|
372
|
+
"PromptLinkAttachment",
|
373
|
+
"AttachmentParsingError",
|
374
|
+
"parse_prompt_attachments",
|
375
|
+
]
|
{code_puppy-0.0.198 → code_puppy-0.0.200}/code_puppy/command_line/model_picker_completion.py
RENAMED
@@ -29,13 +29,22 @@ def set_active_model(model_name: str):
|
|
29
29
|
Sets the active model name by updating the config (for persistence).
|
30
30
|
"""
|
31
31
|
set_model_name(model_name)
|
32
|
-
# Reload agent
|
32
|
+
# Reload the currently active agent so the new model takes effect immediately
|
33
33
|
try:
|
34
|
-
from code_puppy.
|
35
|
-
|
36
|
-
|
34
|
+
from code_puppy.agents import get_current_agent
|
35
|
+
|
36
|
+
current_agent = get_current_agent()
|
37
|
+
# JSON agents may need to refresh their config before reload
|
38
|
+
if hasattr(current_agent, "refresh_config"):
|
39
|
+
try:
|
40
|
+
current_agent.refresh_config()
|
41
|
+
except Exception:
|
42
|
+
# Non-fatal, continue to reload
|
43
|
+
...
|
44
|
+
current_agent.reload_code_generation_agent()
|
37
45
|
except Exception:
|
38
|
-
|
46
|
+
# Swallow errors to avoid breaking the prompt flow; model persists for next run
|
47
|
+
pass
|
39
48
|
|
40
49
|
|
41
50
|
class ModelNameCompleter(Completer):
|