copper-pilot-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- copper_pilot_cli/__init__.py +37 -0
- copper_pilot_cli/__main__.py +6 -0
- copper_pilot_cli/_upstream/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
- copper_pilot_cli/_version.py +32 -0
- copper_pilot_cli/clipboard.py +107 -0
- copper_pilot_cli/copper_api.py +24 -0
- copper_pilot_cli/copper_app.py +1071 -0
- copper_pilot_cli/copper_auth.py +232 -0
- copper_pilot_cli/copper_config.py +62 -0
- copper_pilot_cli/copper_features.py +263 -0
- copper_pilot_cli/copper_graph.py +178 -0
- copper_pilot_cli/copper_hooks.py +49 -0
- copper_pilot_cli/copper_main.py +345 -0
- copper_pilot_cli/copper_preferences.py +85 -0
- copper_pilot_cli/copper_presentation.py +325 -0
- copper_pilot_cli/copper_protocol.py +421 -0
- copper_pilot_cli/copper_theme.py +34 -0
- copper_pilot_cli/copper_tools.py +23 -0
- copper_pilot_cli/copper_update.py +32 -0
- copper_pilot_cli/copper_widgets.py +1224 -0
- copper_pilot_cli/copper_workspace.py +151 -0
- copper_pilot_cli/deepagents_tools.py +556 -0
- copper_pilot_cli/diagnostics.py +55 -0
- copper_pilot_cli/langchain.py +215 -0
- copper_pilot_cli/media_utils.py +626 -0
- copper_pilot_cli/py.typed +0 -0
- copper_pilot_cli/sessions.py +1578 -0
- copper_pilot_cli/textual_patches.py +35 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
- copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
- copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
- copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
- copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Typed CopperPilot client protocol derived from the desktop harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import ssl
|
|
11
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
from urllib.parse import urlparse, urlunparse
|
|
16
|
+
|
|
17
|
+
import certifi
|
|
18
|
+
from markdownify import markdownify
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
from websockets.asyncio.client import connect
|
|
21
|
+
from websockets.exceptions import ConnectionClosed
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
MAX_CHAT_START_BYTES = 12 * 1024 * 1024
|
|
26
|
+
STRICT_CHAT_START_BYTES = 8 * 1024 * 1024
|
|
27
|
+
MAX_TOOL_RESULT_BYTES = 8 * 1024 * 1024
|
|
28
|
+
MAX_CONTEXT_MESSAGE_BYTES = 64 * 1024
|
|
29
|
+
MAX_CONTEXT_TOTAL_BYTES = 4 * 1024 * 1024
|
|
30
|
+
MAX_CONTEXT_ENTRIES = 2_000
|
|
31
|
+
MAX_STREAM_BYTES = 64 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProtocolError(RuntimeError):
|
|
35
|
+
"""The hosted protocol was malformed or incomplete."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CopperMode(StrEnum):
|
|
39
|
+
"""CopperPilot chat modes."""
|
|
40
|
+
|
|
41
|
+
AGENT = "agent"
|
|
42
|
+
PLAN = "plan"
|
|
43
|
+
ASK = "ask"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ChatMessage(BaseModel):
|
|
47
|
+
"""Portable context message."""
|
|
48
|
+
|
|
49
|
+
role: Literal["system", "user", "assistant"]
|
|
50
|
+
content: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PlanTodo(BaseModel):
|
|
54
|
+
id: str
|
|
55
|
+
content: str
|
|
56
|
+
status: Literal["open", "running", "done"] = "open"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PlanContext(BaseModel):
|
|
60
|
+
file_path: str
|
|
61
|
+
name: str | None = None
|
|
62
|
+
overview: str | None = None
|
|
63
|
+
overall_status: Literal["open", "running", "done"] = "open"
|
|
64
|
+
todos: list[PlanTodo] = Field(default_factory=list)
|
|
65
|
+
body: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ChatRequest(BaseModel):
|
|
69
|
+
"""Path-first hosted chat request."""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(extra="allow")
|
|
72
|
+
|
|
73
|
+
query: str
|
|
74
|
+
context: list[ChatMessage] = Field(default_factory=list)
|
|
75
|
+
document_context: list[dict[str, Any]] = Field(default_factory=list)
|
|
76
|
+
project_data: dict[str, Any] = Field(default_factory=dict)
|
|
77
|
+
workspace_path: str
|
|
78
|
+
conversation_id: str
|
|
79
|
+
initial_file_tree: str | None = None
|
|
80
|
+
mode: CopperMode = CopperMode.AGENT
|
|
81
|
+
agentic_mode: bool = False
|
|
82
|
+
schematic_path: str | None = None
|
|
83
|
+
pcb_path: str | None = None
|
|
84
|
+
schematic_info: dict[str, Any] | None = None
|
|
85
|
+
pcb_info: dict[str, Any] | None = None
|
|
86
|
+
workflow: str | None = None
|
|
87
|
+
plan_context: PlanContext | None = None
|
|
88
|
+
skill_manifest_id: str | None = None
|
|
89
|
+
runtime_context: dict[str, Any] | None = None
|
|
90
|
+
enable_computer_use: Literal[False] = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ToolRequest(BaseModel):
|
|
94
|
+
tool_call_id: str
|
|
95
|
+
tool_name: str
|
|
96
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
97
|
+
timeout_ms: int | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ChatResponse(BaseModel):
|
|
101
|
+
model_config = ConfigDict(extra="allow")
|
|
102
|
+
|
|
103
|
+
response: str | None = None
|
|
104
|
+
status_update: str | None = None
|
|
105
|
+
status_event: dict[str, Any] | None = None
|
|
106
|
+
reasoning_update: str | None = None
|
|
107
|
+
reasoning_title: str | None = None
|
|
108
|
+
tool_request: ToolRequest | None = None
|
|
109
|
+
usage: dict[str, Any] | None = None
|
|
110
|
+
success: bool | None = None
|
|
111
|
+
error: str | None = None
|
|
112
|
+
is_final: bool = False
|
|
113
|
+
suggestions: list[Any] | None = None
|
|
114
|
+
turn_id: str | None = None
|
|
115
|
+
sequence: int | None = None
|
|
116
|
+
resume_attempt: int | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EventKind(StrEnum):
|
|
120
|
+
TEXT = "text"
|
|
121
|
+
REASONING = "reasoning"
|
|
122
|
+
STATUS = "status"
|
|
123
|
+
STATUS_EVENT = "status_event"
|
|
124
|
+
TOOL_REQUEST = "tool_request"
|
|
125
|
+
TOOL_RESULT = "tool_result"
|
|
126
|
+
USAGE = "usage"
|
|
127
|
+
FINAL = "final"
|
|
128
|
+
ERROR = "error"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class CopperEvent:
|
|
133
|
+
kind: EventKind
|
|
134
|
+
data: Any
|
|
135
|
+
title: str | None = None
|
|
136
|
+
turn_id: str | None = None
|
|
137
|
+
sequence: int | None = None
|
|
138
|
+
resume_attempt: int | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_DATA_URI = re.compile(r"data:([^;,]+)?(?:;[^,]*)?,([A-Za-z0-9+/=\\s]+)")
|
|
142
|
+
_TERMINAL_ESCAPE = re.compile(r"\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _strip_data_uris(text: str) -> str:
|
|
146
|
+
def replace(match: re.Match[str]) -> str:
|
|
147
|
+
digest = hashlib.sha256(match.group(0).encode()).hexdigest()[:12]
|
|
148
|
+
return f"[inline attachment omitted:{digest}]"
|
|
149
|
+
|
|
150
|
+
return _DATA_URI.sub(replace, text)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _truncate_utf8(text: str, limit: int) -> str:
|
|
154
|
+
encoded = text.encode()
|
|
155
|
+
if len(encoded) <= limit:
|
|
156
|
+
return text
|
|
157
|
+
return encoded[:limit].decode(errors="ignore") + "\n[truncated]"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def bound_context(messages: Sequence[ChatMessage]) -> list[ChatMessage]:
|
|
161
|
+
"""Apply the desktop client's bounded newest-first history policy."""
|
|
162
|
+
bounded: list[ChatMessage] = []
|
|
163
|
+
total = 0
|
|
164
|
+
for message in reversed(messages[-MAX_CONTEXT_ENTRIES:]):
|
|
165
|
+
content = _truncate_utf8(_strip_data_uris(message.content), MAX_CONTEXT_MESSAGE_BYTES)
|
|
166
|
+
size = len(content.encode())
|
|
167
|
+
if bounded and total + size > MAX_CONTEXT_TOTAL_BYTES:
|
|
168
|
+
break
|
|
169
|
+
bounded.append(ChatMessage(role=message.role, content=content))
|
|
170
|
+
total += size
|
|
171
|
+
bounded.reverse()
|
|
172
|
+
if len(bounded) < len(messages):
|
|
173
|
+
bounded.insert(
|
|
174
|
+
0,
|
|
175
|
+
ChatMessage(
|
|
176
|
+
role="system",
|
|
177
|
+
content=f"[{len(messages) - len(bounded)} older messages omitted]",
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
return bounded
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def make_chat_start(request: ChatRequest) -> dict[str, Any]:
|
|
184
|
+
"""Serialize and size-check a protocol-v1 chat start frame."""
|
|
185
|
+
payload = request.model_copy(update={"context": bound_context(request.context)})
|
|
186
|
+
frame = {
|
|
187
|
+
"type": "chat_start",
|
|
188
|
+
"protocol_version": 1,
|
|
189
|
+
"request": payload.model_dump(mode="json", exclude_none=True),
|
|
190
|
+
}
|
|
191
|
+
size = len(json.dumps(frame, separators=(",", ":")).encode())
|
|
192
|
+
if size > MAX_CHAT_START_BYTES:
|
|
193
|
+
raise ProtocolError(f"Chat start frame is too large ({size} bytes).")
|
|
194
|
+
return frame
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def websocket_url(base_url: str, *, agentic: bool = True) -> str:
|
|
198
|
+
parsed = urlparse(base_url)
|
|
199
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
200
|
+
path = "/api/copperpilot/analyze/agent/ws" if agentic else "/api/copperpilot/analyze/chat/ws"
|
|
201
|
+
return urlunparse((scheme, parsed.netloc, path, "", "", ""))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def sanitize_status(value: str) -> str:
|
|
205
|
+
"""Convert hosted status HTML to bounded terminal-safe plain markdown."""
|
|
206
|
+
safe = re.sub(
|
|
207
|
+
r"<(?:script|style)\b[^>]*>[\s\S]*?</(?:script|style)>",
|
|
208
|
+
"",
|
|
209
|
+
value,
|
|
210
|
+
flags=re.IGNORECASE,
|
|
211
|
+
)
|
|
212
|
+
text = (
|
|
213
|
+
markdownify(safe, escape_underscores=False).strip()
|
|
214
|
+
if re.search(r"</?[A-Za-z][^>]*>", safe)
|
|
215
|
+
else safe.strip()
|
|
216
|
+
)
|
|
217
|
+
text = _TERMINAL_ESCAPE.sub("", text)
|
|
218
|
+
text = "".join(character for character in text if character in "\n\t" or ord(character) >= 32)
|
|
219
|
+
return _truncate_utf8(text, 16 * 1024)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
ToolHandler = Callable[[ToolRequest], Awaitable[Any]]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class HostedChatClient:
|
|
226
|
+
"""One hosted CopperPilot chat stream."""
|
|
227
|
+
|
|
228
|
+
def __init__(self, base_url: str, api_key: str, fingerprint: str) -> None:
|
|
229
|
+
self.base_url = base_url.rstrip("/")
|
|
230
|
+
self.api_key = api_key
|
|
231
|
+
self.fingerprint = fingerprint
|
|
232
|
+
self._socket: Any = None
|
|
233
|
+
self._owner: str | None = None
|
|
234
|
+
self.first_frame_timeout = 60.0
|
|
235
|
+
self.idle_frame_timeout = 300.0
|
|
236
|
+
self.frame_budget = 10_000
|
|
237
|
+
|
|
238
|
+
async def cancel(self) -> None:
|
|
239
|
+
if self._socket is not None:
|
|
240
|
+
await self._socket.send(json.dumps({"type": "cancel"}))
|
|
241
|
+
|
|
242
|
+
async def stream(
|
|
243
|
+
self,
|
|
244
|
+
request: ChatRequest,
|
|
245
|
+
tool_handler: ToolHandler,
|
|
246
|
+
) -> AsyncIterator[CopperEvent]:
|
|
247
|
+
"""Retry transient connection failures only before any event is observed."""
|
|
248
|
+
for attempt in range(2):
|
|
249
|
+
observed = False
|
|
250
|
+
try:
|
|
251
|
+
async for event in self._stream_once(request, tool_handler):
|
|
252
|
+
observed = True
|
|
253
|
+
yield event
|
|
254
|
+
return
|
|
255
|
+
except (OSError, TimeoutError, ConnectionClosed) as exc:
|
|
256
|
+
logger.warning(
|
|
257
|
+
"Hosted chat transport failed before completion (%s, observed=%s, attempt=%s)",
|
|
258
|
+
type(exc).__name__,
|
|
259
|
+
observed,
|
|
260
|
+
attempt + 1,
|
|
261
|
+
)
|
|
262
|
+
if observed or attempt == 1:
|
|
263
|
+
raise
|
|
264
|
+
await asyncio.sleep(0.25)
|
|
265
|
+
|
|
266
|
+
async def _stream_once(
|
|
267
|
+
self,
|
|
268
|
+
request: ChatRequest,
|
|
269
|
+
tool_handler: ToolHandler,
|
|
270
|
+
) -> AsyncIterator[CopperEvent]:
|
|
271
|
+
"""Stream normalized events and fulfill local tool requests."""
|
|
272
|
+
if self._socket is not None:
|
|
273
|
+
raise ProtocolError("A chat stream is already active.")
|
|
274
|
+
headers = {
|
|
275
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
276
|
+
"User-Agent": "CopperPilot CLI/0.1.0",
|
|
277
|
+
"X-CopperPilot-Fingerprint": self.fingerprint,
|
|
278
|
+
}
|
|
279
|
+
saw_final = False
|
|
280
|
+
url = websocket_url(self.base_url, agentic=request.agentic_mode)
|
|
281
|
+
ssl_context = (
|
|
282
|
+
ssl.create_default_context(cafile=certifi.where()) if url.startswith("wss://") else None
|
|
283
|
+
)
|
|
284
|
+
logger.info(
|
|
285
|
+
"Opening hosted chat transport (host=%s, mode=%s)",
|
|
286
|
+
urlparse(url).netloc,
|
|
287
|
+
request.mode.value,
|
|
288
|
+
)
|
|
289
|
+
async with connect(
|
|
290
|
+
url,
|
|
291
|
+
additional_headers=headers,
|
|
292
|
+
open_timeout=30,
|
|
293
|
+
max_size=16 * 1024 * 1024,
|
|
294
|
+
ssl=ssl_context,
|
|
295
|
+
) as socket:
|
|
296
|
+
self._socket = socket
|
|
297
|
+
self._owner = request.conversation_id
|
|
298
|
+
await socket.send(json.dumps(make_chat_start(request), separators=(",", ":")))
|
|
299
|
+
try:
|
|
300
|
+
frame_count = 0
|
|
301
|
+
stream_bytes = 0
|
|
302
|
+
while not saw_final:
|
|
303
|
+
timeout = (
|
|
304
|
+
self.first_frame_timeout if frame_count == 0 else self.idle_frame_timeout
|
|
305
|
+
)
|
|
306
|
+
try:
|
|
307
|
+
raw = await asyncio.wait_for(socket.recv(), timeout)
|
|
308
|
+
except TimeoutError as exc:
|
|
309
|
+
phase = "first frame" if frame_count == 0 else "next frame"
|
|
310
|
+
message = f"Timed out waiting for the hosted chat {phase}."
|
|
311
|
+
raise ProtocolError(message) from exc
|
|
312
|
+
except ConnectionClosed:
|
|
313
|
+
break
|
|
314
|
+
frame_count += 1
|
|
315
|
+
stream_bytes += len(raw.encode() if isinstance(raw, str) else raw)
|
|
316
|
+
if frame_count > self.frame_budget:
|
|
317
|
+
raise ProtocolError("Chat exceeded the client frame budget.")
|
|
318
|
+
if stream_bytes > MAX_STREAM_BYTES:
|
|
319
|
+
raise ProtocolError("Chat exceeded the client stream byte budget.")
|
|
320
|
+
try:
|
|
321
|
+
frame = json.loads(raw)
|
|
322
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
323
|
+
raise ProtocolError("Hosted chat returned invalid JSON.") from exc
|
|
324
|
+
if not isinstance(frame, dict):
|
|
325
|
+
raise ProtocolError("Hosted chat frame must be a JSON object.")
|
|
326
|
+
frame_type = frame.get("type")
|
|
327
|
+
if frame_type == "error":
|
|
328
|
+
message = frame.get("public_message") or frame.get("message")
|
|
329
|
+
safe_message = sanitize_status(str(message or "Hosted chat failed."))
|
|
330
|
+
yield CopperEvent(EventKind.ERROR, safe_message)
|
|
331
|
+
raise ProtocolError(safe_message)
|
|
332
|
+
if frame_type not in {"event", "final"}:
|
|
333
|
+
raise ProtocolError(f"Unknown frame type: {frame_type!r}")
|
|
334
|
+
try:
|
|
335
|
+
response = ChatResponse.model_validate(frame.get("chat_response") or {})
|
|
336
|
+
except ValueError as exc:
|
|
337
|
+
raise ProtocolError("Hosted chat response has an invalid shape.") from exc
|
|
338
|
+
metadata: dict[str, Any] = {
|
|
339
|
+
"turn_id": response.turn_id,
|
|
340
|
+
"sequence": response.sequence,
|
|
341
|
+
"resume_attempt": response.resume_attempt,
|
|
342
|
+
}
|
|
343
|
+
if response.response:
|
|
344
|
+
yield CopperEvent(
|
|
345
|
+
EventKind.TEXT, sanitize_status(response.response), **metadata
|
|
346
|
+
)
|
|
347
|
+
if response.reasoning_update:
|
|
348
|
+
yield CopperEvent(
|
|
349
|
+
EventKind.REASONING,
|
|
350
|
+
sanitize_status(response.reasoning_update),
|
|
351
|
+
sanitize_status(response.reasoning_title)
|
|
352
|
+
if response.reasoning_title
|
|
353
|
+
else None,
|
|
354
|
+
**metadata,
|
|
355
|
+
)
|
|
356
|
+
if response.status_update:
|
|
357
|
+
yield CopperEvent(
|
|
358
|
+
EventKind.STATUS, sanitize_status(response.status_update), **metadata
|
|
359
|
+
)
|
|
360
|
+
if response.status_event:
|
|
361
|
+
yield CopperEvent(EventKind.STATUS_EVENT, response.status_event, **metadata)
|
|
362
|
+
usage = response.usage
|
|
363
|
+
envelope_usage = frame.get("usage_details")
|
|
364
|
+
if isinstance(envelope_usage, dict):
|
|
365
|
+
usage = {**(usage or {}), **envelope_usage}
|
|
366
|
+
if usage:
|
|
367
|
+
yield CopperEvent(EventKind.USAGE, usage, **metadata)
|
|
368
|
+
if response.tool_request:
|
|
369
|
+
yield CopperEvent(EventKind.TOOL_REQUEST, response.tool_request, **metadata)
|
|
370
|
+
try:
|
|
371
|
+
result = await tool_handler(response.tool_request)
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
result = {
|
|
374
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
375
|
+
"rejected": type(exc).__name__ == "ToolRejected",
|
|
376
|
+
}
|
|
377
|
+
result_frame = {
|
|
378
|
+
"type": "tool_result",
|
|
379
|
+
"conversation_id": request.conversation_id,
|
|
380
|
+
"tool_call_id": response.tool_request.tool_call_id,
|
|
381
|
+
"result": result,
|
|
382
|
+
}
|
|
383
|
+
encoded = json.dumps(result_frame, separators=(",", ":")).encode()
|
|
384
|
+
if len(encoded) > MAX_TOOL_RESULT_BYTES:
|
|
385
|
+
result_frame["result"] = {
|
|
386
|
+
"error": "Tool result exceeded the client frame limit."
|
|
387
|
+
}
|
|
388
|
+
if self._owner != request.conversation_id:
|
|
389
|
+
raise ProtocolError("Chat socket owner changed during tool execution.")
|
|
390
|
+
await socket.send(json.dumps(result_frame, separators=(",", ":")))
|
|
391
|
+
yield CopperEvent(
|
|
392
|
+
EventKind.TOOL_RESULT,
|
|
393
|
+
{
|
|
394
|
+
"tool_call_id": response.tool_request.tool_call_id,
|
|
395
|
+
"tool_name": response.tool_request.tool_name,
|
|
396
|
+
"result": result_frame["result"],
|
|
397
|
+
},
|
|
398
|
+
**metadata,
|
|
399
|
+
)
|
|
400
|
+
if frame_type == "final" or response.is_final:
|
|
401
|
+
saw_final = True
|
|
402
|
+
yield CopperEvent(
|
|
403
|
+
EventKind.FINAL,
|
|
404
|
+
{
|
|
405
|
+
"success": response.success,
|
|
406
|
+
"error": response.error,
|
|
407
|
+
"is_final": True,
|
|
408
|
+
"usage": response.usage,
|
|
409
|
+
"suggestions": response.suggestions,
|
|
410
|
+
"usage_details": envelope_usage
|
|
411
|
+
if isinstance(envelope_usage, dict)
|
|
412
|
+
else None,
|
|
413
|
+
},
|
|
414
|
+
**metadata,
|
|
415
|
+
)
|
|
416
|
+
break
|
|
417
|
+
finally:
|
|
418
|
+
self._socket = None
|
|
419
|
+
self._owner = None
|
|
420
|
+
if not saw_final:
|
|
421
|
+
raise ProtocolError("Chat socket closed before a final response.")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CopperPilot semantic terminal colors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ThemeColors:
|
|
10
|
+
background: str = "#11121D"
|
|
11
|
+
surface: str = "#1A1B2E"
|
|
12
|
+
panel: str = "#25283B"
|
|
13
|
+
text: str = "#C0CAF5"
|
|
14
|
+
muted: str = "#7A83A8"
|
|
15
|
+
primary: str = "#EA580C"
|
|
16
|
+
active: str = "#FB923C"
|
|
17
|
+
success: str = "#9ECE6A"
|
|
18
|
+
warning: str = "#F97316"
|
|
19
|
+
error: str = "#F7768E"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
COLORS = ThemeColors()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def css_variable_defaults() -> dict[str, str]:
|
|
26
|
+
return {
|
|
27
|
+
"mode-bash": COLORS.error,
|
|
28
|
+
"mode-command": COLORS.active,
|
|
29
|
+
"mode-incognito": COLORS.muted,
|
|
30
|
+
"skill": COLORS.primary,
|
|
31
|
+
"skill-hover": COLORS.active,
|
|
32
|
+
"tool": COLORS.primary,
|
|
33
|
+
"tool-hover": COLORS.active,
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Compatibility exports for CopperPilot's Deep Agents tool bridge."""
|
|
2
|
+
|
|
3
|
+
from copper_pilot_cli.deepagents_tools import (
|
|
4
|
+
ApprovalMode,
|
|
5
|
+
ApprovalRequest,
|
|
6
|
+
LocalToolBroker,
|
|
7
|
+
ToolRejected,
|
|
8
|
+
canonical_tool_name,
|
|
9
|
+
dangerous_shell_command,
|
|
10
|
+
normalized_tool_names,
|
|
11
|
+
routine_action,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ApprovalMode",
|
|
16
|
+
"ApprovalRequest",
|
|
17
|
+
"LocalToolBroker",
|
|
18
|
+
"ToolRejected",
|
|
19
|
+
"canonical_tool_name",
|
|
20
|
+
"dangerous_shell_command",
|
|
21
|
+
"normalized_tool_names",
|
|
22
|
+
"routine_action",
|
|
23
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""PyPI-backed CopperPilot update checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from packaging.version import Version
|
|
10
|
+
|
|
11
|
+
from copper_pilot_cli._version import PYPI_URL, __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class UpdateInfo:
|
|
16
|
+
current: str
|
|
17
|
+
latest: str
|
|
18
|
+
available: bool
|
|
19
|
+
install_command: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def check_for_update() -> UpdateInfo:
|
|
23
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
24
|
+
response = await client.get(PYPI_URL)
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
latest = str(response.json()["info"]["version"])
|
|
27
|
+
return UpdateInfo(
|
|
28
|
+
current=__version__,
|
|
29
|
+
latest=latest,
|
|
30
|
+
available=Version(latest) > Version(__version__),
|
|
31
|
+
install_command=f"{sys.executable} -m pip install --upgrade copper-pilot-cli",
|
|
32
|
+
)
|