wcgw 0.0.5__py3-none-any.whl → 0.0.7__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.
Potentially problematic release.
This version of wcgw might be problematic. Click here for more details.
- wcgw/basic.py +100 -50
- wcgw/common.py +28 -8
- wcgw/openai_utils.py +12 -2
- wcgw/tools.py +122 -73
- {wcgw-0.0.5.dist-info → wcgw-0.0.7.dist-info}/METADATA +16 -7
- wcgw-0.0.7.dist-info/RECORD +10 -0
- wcgw/openai_adapters.py +0 -0
- wcgw-0.0.5.dist-info/RECORD +0 -11
- {wcgw-0.0.5.dist-info → wcgw-0.0.7.dist-info}/WHEEL +0 -0
- {wcgw-0.0.5.dist-info → wcgw-0.0.7.dist-info}/entry_points.txt +0 -0
wcgw/basic.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import base64
|
|
1
2
|
import json
|
|
3
|
+
import mimetypes
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
import sys
|
|
4
6
|
import traceback
|
|
@@ -8,17 +10,21 @@ from openai import OpenAI
|
|
|
8
10
|
from openai.types.chat import (
|
|
9
11
|
ChatCompletionMessageParam,
|
|
10
12
|
ChatCompletionAssistantMessageParam,
|
|
13
|
+
ChatCompletionUserMessageParam,
|
|
14
|
+
ChatCompletionContentPartParam,
|
|
11
15
|
ChatCompletionMessage,
|
|
12
16
|
ParsedChatCompletionMessage,
|
|
13
17
|
)
|
|
14
18
|
import rich
|
|
19
|
+
import petname
|
|
15
20
|
from typer import Typer
|
|
16
21
|
import uuid
|
|
17
22
|
|
|
18
|
-
from .common import
|
|
19
|
-
|
|
23
|
+
from wcgw.common import Config, text_from_editor
|
|
24
|
+
|
|
25
|
+
from .common import Models
|
|
20
26
|
from .openai_utils import get_input_cost, get_output_cost
|
|
21
|
-
from .tools import ExecuteBash,
|
|
27
|
+
from .tools import ExecuteBash, ReadImage, ImageData
|
|
22
28
|
|
|
23
29
|
from .tools import (
|
|
24
30
|
BASH_CLF_OUTPUT,
|
|
@@ -34,40 +40,14 @@ from .tools import (
|
|
|
34
40
|
import tiktoken
|
|
35
41
|
|
|
36
42
|
from urllib import parse
|
|
37
|
-
import subprocess
|
|
38
43
|
import os
|
|
39
|
-
import tempfile
|
|
40
44
|
|
|
41
45
|
import toml
|
|
42
|
-
from pydantic import BaseModel
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
from dotenv import load_dotenv
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
class Config(BaseModel):
|
|
49
|
-
model: Models
|
|
50
|
-
secondary_model: Models
|
|
51
|
-
cost_limit: float
|
|
52
|
-
cost_file: dict[Models, CostData]
|
|
53
|
-
cost_unit: str = "$"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def text_from_editor(console: rich.console.Console) -> str:
|
|
57
|
-
# First consume all the input till now
|
|
58
|
-
discard_input()
|
|
59
|
-
console.print("\n---------------------------------------\n# User message")
|
|
60
|
-
data = input()
|
|
61
|
-
if data:
|
|
62
|
-
return data
|
|
63
|
-
editor = os.environ.get("EDITOR", "vim")
|
|
64
|
-
with tempfile.NamedTemporaryFile(suffix=".tmp") as tf:
|
|
65
|
-
subprocess.run([editor, tf.name], check=True)
|
|
66
|
-
with open(tf.name, "r") as f:
|
|
67
|
-
data = f.read()
|
|
68
|
-
console.print(data)
|
|
69
|
-
return data
|
|
70
|
-
|
|
50
|
+
History = list[ChatCompletionMessageParam]
|
|
71
51
|
|
|
72
52
|
def save_history(history: History, session_id: str) -> None:
|
|
73
53
|
myid = str(history[1]["content"]).replace("/", "_").replace(" ", "_").lower()[:60]
|
|
@@ -80,6 +60,38 @@ def save_history(history: History, session_id: str) -> None:
|
|
|
80
60
|
json.dump(history, f, indent=3)
|
|
81
61
|
|
|
82
62
|
|
|
63
|
+
def parse_user_message_special(msg: str) -> ChatCompletionUserMessageParam:
|
|
64
|
+
# Search for lines starting with `%` and treat them as special commands
|
|
65
|
+
parts: list[ChatCompletionContentPartParam] = []
|
|
66
|
+
for line in msg.split("\n"):
|
|
67
|
+
if line.startswith("%"):
|
|
68
|
+
args = line[1:].strip().split(" ")
|
|
69
|
+
command = args[0]
|
|
70
|
+
assert command == 'image'
|
|
71
|
+
image_path = args[1]
|
|
72
|
+
with open(image_path, 'rb') as f:
|
|
73
|
+
image_bytes = f.read()
|
|
74
|
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
75
|
+
image_type = mimetypes.guess_type(image_path)[0]
|
|
76
|
+
dataurl=f'data:{image_type};base64,{image_b64}'
|
|
77
|
+
parts.append({
|
|
78
|
+
'type': 'image_url',
|
|
79
|
+
'image_url': {
|
|
80
|
+
'url': dataurl,
|
|
81
|
+
'detail': 'auto'
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
else:
|
|
85
|
+
if len(parts) > 0 and parts[-1]['type'] == 'text':
|
|
86
|
+
parts[-1]['text'] += '\n' + line
|
|
87
|
+
else:
|
|
88
|
+
parts.append({'type': 'text', 'text': line})
|
|
89
|
+
return {
|
|
90
|
+
'role': 'user',
|
|
91
|
+
'content': parts
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
83
95
|
app = Typer(pretty_exceptions_show_locals=False)
|
|
84
96
|
|
|
85
97
|
|
|
@@ -94,6 +106,7 @@ def loop(
|
|
|
94
106
|
session_id = str(uuid.uuid4())[:6]
|
|
95
107
|
|
|
96
108
|
history: History = []
|
|
109
|
+
waiting_for_assistant = False
|
|
97
110
|
if resume:
|
|
98
111
|
if resume == "latest":
|
|
99
112
|
resume_path = sorted(Path(".wcgw").iterdir(), key=os.path.getmtime)[-1]
|
|
@@ -108,6 +121,7 @@ def loop(
|
|
|
108
121
|
if history[1]["role"] != "user":
|
|
109
122
|
raise ValueError("Invalid history file, second message should be user")
|
|
110
123
|
first_message = ""
|
|
124
|
+
waiting_for_assistant = history[-1]['role'] != 'assistant'
|
|
111
125
|
|
|
112
126
|
my_dir = os.path.dirname(__file__)
|
|
113
127
|
config_file = os.path.join(my_dir, "..", "..", "config.toml")
|
|
@@ -134,19 +148,18 @@ Execute a bash script. Stateful (beware with subsequent calls).
|
|
|
134
148
|
Execute commands using `execute_command` attribute.
|
|
135
149
|
Do not use interactive commands like nano. Prefer writing simpler commands.
|
|
136
150
|
Last line will always be `(exit <int code>)` except if
|
|
137
|
-
the last line is `(
|
|
151
|
+
the last line is `(pending)` if the program is still running or waiting for user inputs. You can then send input using `send_ascii` attributes. You get status by sending `send_ascii: [10]`.
|
|
138
152
|
Optionally the last line is `(won't exit)` in which case you need to kill the process if you want to run a new command.
|
|
139
153
|
Optionally `exit shell has restarted` is the output, in which case environment resets, you can run fresh commands.
|
|
140
154
|
The first line might be `(...truncated)` if the output is too long.""",
|
|
141
155
|
),
|
|
142
|
-
openai.pydantic_function_tool(
|
|
143
|
-
GetShellOutputLastCommand,
|
|
144
|
-
description="Get output of the last command run in the shell. Use this in case you want to know status of a running program.",
|
|
145
|
-
),
|
|
146
156
|
openai.pydantic_function_tool(
|
|
147
157
|
Writefile,
|
|
148
158
|
description="Write content to a file. Provide file path and content. Use this instead of ExecuteBash for writing files.",
|
|
149
159
|
),
|
|
160
|
+
openai.pydantic_function_tool(
|
|
161
|
+
ReadImage, description="Read an image from the shell."
|
|
162
|
+
),
|
|
150
163
|
]
|
|
151
164
|
uname_sysname = os.uname().sysname
|
|
152
165
|
uname_machine = os.uname().machine
|
|
@@ -165,12 +178,11 @@ System information:
|
|
|
165
178
|
- Machine: {uname_machine}
|
|
166
179
|
"""
|
|
167
180
|
|
|
168
|
-
has_tool_output = False
|
|
169
181
|
if not history:
|
|
170
182
|
history = [{"role": "system", "content": system}]
|
|
171
183
|
else:
|
|
172
184
|
if history[-1]["role"] == "tool":
|
|
173
|
-
|
|
185
|
+
waiting_for_assistant = True
|
|
174
186
|
|
|
175
187
|
client = OpenAI()
|
|
176
188
|
|
|
@@ -189,16 +201,16 @@ System information:
|
|
|
189
201
|
)
|
|
190
202
|
break
|
|
191
203
|
|
|
192
|
-
if not
|
|
204
|
+
if not waiting_for_assistant:
|
|
193
205
|
if first_message:
|
|
194
206
|
msg = first_message
|
|
195
207
|
first_message = ""
|
|
196
208
|
else:
|
|
197
209
|
msg = text_from_editor(user_console)
|
|
198
210
|
|
|
199
|
-
history.append(
|
|
211
|
+
history.append(parse_user_message_special(msg))
|
|
200
212
|
else:
|
|
201
|
-
|
|
213
|
+
waiting_for_assistant = False
|
|
202
214
|
|
|
203
215
|
cost_, input_toks_ = get_input_cost(
|
|
204
216
|
config.cost_file[config.model], enc, history
|
|
@@ -223,6 +235,7 @@ System information:
|
|
|
223
235
|
_histories: History = []
|
|
224
236
|
item: ChatCompletionMessageParam
|
|
225
237
|
full_response: str = ""
|
|
238
|
+
image_histories: History = []
|
|
226
239
|
try:
|
|
227
240
|
for chunk in stream:
|
|
228
241
|
if chunk.choices[0].finish_reason == "tool_calls":
|
|
@@ -236,7 +249,7 @@ System information:
|
|
|
236
249
|
"type": "function",
|
|
237
250
|
"function": {
|
|
238
251
|
"arguments": tool_args,
|
|
239
|
-
"name":
|
|
252
|
+
"name": type(which_tool(tool_args)).__name__,
|
|
240
253
|
},
|
|
241
254
|
}
|
|
242
255
|
for tool_call_id, toolcallargs in tool_call_args_by_id.items()
|
|
@@ -252,7 +265,7 @@ System information:
|
|
|
252
265
|
)
|
|
253
266
|
system_console.print(f"\nTotal cost: {config.cost_unit}{cost:.3f}")
|
|
254
267
|
output_toks += output_toks_
|
|
255
|
-
|
|
268
|
+
|
|
256
269
|
_histories.append(item)
|
|
257
270
|
for tool_call_id, toolcallargs in tool_call_args_by_id.items():
|
|
258
271
|
for toolindex, tool_args in toolcallargs.items():
|
|
@@ -284,13 +297,50 @@ System information:
|
|
|
284
297
|
f"\nTotal cost: {config.cost_unit}{cost:.3f}"
|
|
285
298
|
)
|
|
286
299
|
return output_or_done.task_output, cost
|
|
300
|
+
|
|
287
301
|
output = output_or_done
|
|
288
302
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
if isinstance(output, ImageData):
|
|
304
|
+
randomId = petname.Generate(2, "-")
|
|
305
|
+
if not image_histories:
|
|
306
|
+
image_histories.extend([
|
|
307
|
+
{
|
|
308
|
+
'role': 'assistant',
|
|
309
|
+
'content': f'Share images with ids: {randomId}'
|
|
310
|
+
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
'role': 'user',
|
|
314
|
+
'content': [{
|
|
315
|
+
'type': 'image_url',
|
|
316
|
+
'image_url': {
|
|
317
|
+
'url': output.dataurl,
|
|
318
|
+
'detail': 'auto'
|
|
319
|
+
}
|
|
320
|
+
}]
|
|
321
|
+
}]
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
image_histories[0]['content'] += ', ' + randomId
|
|
325
|
+
image_histories[1]["content"].append({ # type: ignore
|
|
326
|
+
'type': 'image_url',
|
|
327
|
+
'image_url': {
|
|
328
|
+
'url': output.dataurl,
|
|
329
|
+
'detail': 'auto'
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
item = {
|
|
334
|
+
"role": "tool",
|
|
335
|
+
"content": f'Ask user for image id: {randomId}',
|
|
336
|
+
"tool_call_id": tool_call_id + str(toolindex),
|
|
337
|
+
}
|
|
338
|
+
else:
|
|
339
|
+
item = {
|
|
340
|
+
"role": "tool",
|
|
341
|
+
"content": str(output),
|
|
342
|
+
"tool_call_id": tool_call_id + str(toolindex),
|
|
343
|
+
}
|
|
294
344
|
cost_, output_toks_ = get_output_cost(
|
|
295
345
|
config.cost_file[config.model], enc, item
|
|
296
346
|
)
|
|
@@ -298,7 +348,7 @@ System information:
|
|
|
298
348
|
output_toks += output_toks_
|
|
299
349
|
|
|
300
350
|
_histories.append(item)
|
|
301
|
-
|
|
351
|
+
waiting_for_assistant = True
|
|
302
352
|
break
|
|
303
353
|
elif chunk.choices[0].finish_reason:
|
|
304
354
|
assistant_console.print("")
|
|
@@ -327,11 +377,11 @@ System information:
|
|
|
327
377
|
assistant_console.print(chunk_str, end="")
|
|
328
378
|
full_response += chunk_str
|
|
329
379
|
except KeyboardInterrupt:
|
|
330
|
-
|
|
380
|
+
waiting_for_assistant = False
|
|
331
381
|
input("Interrupted...enter to redo the current turn")
|
|
332
382
|
else:
|
|
333
383
|
history.extend(_histories)
|
|
334
|
-
|
|
384
|
+
history.extend(image_histories)
|
|
335
385
|
save_history(history, session_id)
|
|
336
386
|
|
|
337
387
|
return "Couldn't finish the task", cost
|
wcgw/common.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import os
|
|
1
2
|
import select
|
|
3
|
+
import subprocess
|
|
2
4
|
import sys
|
|
5
|
+
import tempfile
|
|
3
6
|
import termios
|
|
4
7
|
import tty
|
|
5
8
|
from typing import Literal
|
|
6
9
|
from pydantic import BaseModel
|
|
10
|
+
import rich
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
class CostData(BaseModel):
|
|
@@ -11,14 +15,6 @@ class CostData(BaseModel):
|
|
|
11
15
|
cost_per_1m_output_tokens: float
|
|
12
16
|
|
|
13
17
|
|
|
14
|
-
from openai.types.chat import (
|
|
15
|
-
ChatCompletionMessageParam,
|
|
16
|
-
ChatCompletionAssistantMessageParam,
|
|
17
|
-
ChatCompletionMessage,
|
|
18
|
-
ParsedChatCompletionMessage,
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
History = list[ChatCompletionMessageParam]
|
|
22
18
|
Models = Literal["gpt-4o-2024-08-06", "gpt-4o-mini"]
|
|
23
19
|
|
|
24
20
|
|
|
@@ -45,3 +41,27 @@ def discard_input() -> None:
|
|
|
45
41
|
finally:
|
|
46
42
|
# Restore old terminal settings
|
|
47
43
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Config(BaseModel):
|
|
47
|
+
model: Models
|
|
48
|
+
secondary_model: Models
|
|
49
|
+
cost_limit: float
|
|
50
|
+
cost_file: dict[Models, CostData]
|
|
51
|
+
cost_unit: str = "$"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def text_from_editor(console: rich.console.Console) -> str:
|
|
55
|
+
# First consume all the input till now
|
|
56
|
+
discard_input()
|
|
57
|
+
console.print("\n---------------------------------------\n# User message")
|
|
58
|
+
data = input()
|
|
59
|
+
if data:
|
|
60
|
+
return data
|
|
61
|
+
editor = os.environ.get("EDITOR", "vim")
|
|
62
|
+
with tempfile.NamedTemporaryFile(suffix=".tmp") as tf:
|
|
63
|
+
subprocess.run([editor, tf.name], check=True)
|
|
64
|
+
with open(tf.name, "r") as f:
|
|
65
|
+
data = f.read()
|
|
66
|
+
console.print(data)
|
|
67
|
+
return data
|
wcgw/openai_utils.py
CHANGED
|
@@ -28,9 +28,19 @@ def get_input_cost(
|
|
|
28
28
|
input_tokens = 0
|
|
29
29
|
for msg in history:
|
|
30
30
|
content = msg["content"]
|
|
31
|
-
|
|
31
|
+
refusal = msg.get("refusal")
|
|
32
|
+
if isinstance(content, list):
|
|
33
|
+
for part in content:
|
|
34
|
+
if 'text' in part:
|
|
35
|
+
input_tokens += len(enc.encode(part['text']))
|
|
36
|
+
elif content is None:
|
|
37
|
+
if refusal is None:
|
|
38
|
+
raise ValueError("Expected content or refusal to be present")
|
|
39
|
+
input_tokens += len(enc.encode(str(refusal)))
|
|
40
|
+
elif not isinstance(content, str):
|
|
32
41
|
raise ValueError(f"Expected content to be string, got {type(content)}")
|
|
33
|
-
|
|
42
|
+
else:
|
|
43
|
+
input_tokens += len(enc.encode(content))
|
|
34
44
|
cost = input_tokens * cost_map.cost_per_1m_input_tokens / 1_000_000
|
|
35
45
|
return cost, input_tokens
|
|
36
46
|
|
wcgw/tools.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import base64
|
|
2
3
|
import json
|
|
4
|
+
import mimetypes
|
|
3
5
|
import sys
|
|
4
6
|
import threading
|
|
5
7
|
import traceback
|
|
6
|
-
from typing import Callable, Literal, Optional, ParamSpec, Sequence, TypeVar, TypedDict
|
|
8
|
+
from typing import Callable, Literal, NewType, Optional, ParamSpec, Sequence, TypeVar, TypedDict
|
|
7
9
|
import uuid
|
|
8
10
|
from pydantic import BaseModel, TypeAdapter
|
|
11
|
+
from websockets.sync.client import connect as syncconnect
|
|
9
12
|
|
|
10
13
|
import os
|
|
11
14
|
import tiktoken
|
|
@@ -67,7 +70,7 @@ class Writefile(BaseModel):
|
|
|
67
70
|
|
|
68
71
|
def start_shell():
|
|
69
72
|
SHELL = pexpect.spawn(
|
|
70
|
-
"/bin/bash",
|
|
73
|
+
"/bin/bash --noprofile --norc",
|
|
71
74
|
env={**os.environ, **{"PS1": "#@@"}},
|
|
72
75
|
echo=False,
|
|
73
76
|
encoding="utf-8",
|
|
@@ -82,11 +85,26 @@ def start_shell():
|
|
|
82
85
|
SHELL = start_shell()
|
|
83
86
|
|
|
84
87
|
|
|
88
|
+
def _is_int(mystr: str) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
int(mystr)
|
|
91
|
+
return True
|
|
92
|
+
except ValueError:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
85
96
|
def _get_exit_code() -> int:
|
|
86
97
|
SHELL.sendline("echo $?")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
98
|
+
before = ""
|
|
99
|
+
while not _is_int(before): # Consume all previous output
|
|
100
|
+
SHELL.expect("#@@")
|
|
101
|
+
assert isinstance(SHELL.before, str)
|
|
102
|
+
before = SHELL.before
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return int((before))
|
|
106
|
+
except ValueError:
|
|
107
|
+
raise ValueError(f"Malformed output: {before}")
|
|
90
108
|
|
|
91
109
|
|
|
92
110
|
Specials = Literal["Key-up", "Key-down", "Key-left", "Key-right", "Enter", "Ctrl-c"]
|
|
@@ -97,10 +115,6 @@ class ExecuteBash(BaseModel):
|
|
|
97
115
|
send_ascii: Optional[Sequence[int | Specials]] = None
|
|
98
116
|
|
|
99
117
|
|
|
100
|
-
class GetShellOutputLastCommand(BaseModel):
|
|
101
|
-
type: Literal["get_output_of_last_command"] = "get_output_of_last_command"
|
|
102
|
-
|
|
103
|
-
|
|
104
118
|
BASH_CLF_OUTPUT = Literal["running", "waiting_for_input", "wont_exit"]
|
|
105
119
|
BASH_STATE: BASH_CLF_OUTPUT = "running"
|
|
106
120
|
|
|
@@ -116,9 +130,9 @@ def get_output_of_last_command(enc: tiktoken.Encoding) -> str:
|
|
|
116
130
|
return output
|
|
117
131
|
|
|
118
132
|
|
|
119
|
-
|
|
120
|
-
1. Get its output using `
|
|
121
|
-
2. Use
|
|
133
|
+
WAITING_INPUT_MESSAGE = """A command is already running waiting for input. NOTE: You can't run multiple shell sessions, likely a previous program hasn't exited.
|
|
134
|
+
1. Get its output using `send_ascii: [10]`
|
|
135
|
+
2. Use `send_ascii` to give inputs to the running program, don't use `execute_command` OR
|
|
122
136
|
3. kill the previous program by sending ctrl+c first using `send_ascii`"""
|
|
123
137
|
|
|
124
138
|
|
|
@@ -131,7 +145,7 @@ def execute_bash(
|
|
|
131
145
|
try:
|
|
132
146
|
if bash_arg.execute_command:
|
|
133
147
|
if BASH_STATE == "waiting_for_input":
|
|
134
|
-
raise ValueError(
|
|
148
|
+
raise ValueError(WAITING_INPUT_MESSAGE)
|
|
135
149
|
elif BASH_STATE == "wont_exit":
|
|
136
150
|
raise ValueError(
|
|
137
151
|
"""A command is already running that hasn't exited. NOTE: You can't run multiple shell sessions, likely a previous program is in infinite loop.
|
|
@@ -192,9 +206,7 @@ def execute_bash(
|
|
|
192
206
|
text = "...(truncated)\n" + enc.decode(tokens[-2047:])
|
|
193
207
|
|
|
194
208
|
last_line = (
|
|
195
|
-
"(
|
|
196
|
-
if BASH_STATE == "waiting_for_input"
|
|
197
|
-
else "(won't exit)"
|
|
209
|
+
"(pending)" if BASH_STATE == "waiting_for_input" else "(won't exit)"
|
|
198
210
|
)
|
|
199
211
|
return text + f"\n{last_line}", cost
|
|
200
212
|
index = SHELL.expect(["#@@", pexpect.TIMEOUT], timeout=wait)
|
|
@@ -211,7 +223,9 @@ def execute_bash(
|
|
|
211
223
|
exit_code = _get_exit_code()
|
|
212
224
|
output += f"\n(exit {exit_code})"
|
|
213
225
|
|
|
214
|
-
except ValueError:
|
|
226
|
+
except ValueError as e:
|
|
227
|
+
console.print(output)
|
|
228
|
+
traceback.print_exc()
|
|
215
229
|
console.print("Malformed output, restarting shell", style="red")
|
|
216
230
|
# Malformed output, restart shell
|
|
217
231
|
SHELL.close(True)
|
|
@@ -220,6 +234,35 @@ def execute_bash(
|
|
|
220
234
|
return output, 0
|
|
221
235
|
|
|
222
236
|
|
|
237
|
+
class ReadImage(BaseModel):
|
|
238
|
+
file_path: str
|
|
239
|
+
type: Literal['ReadImage'] = 'ReadImage'
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def serve_image_in_bg(file_path: str, client_uuid: str, name: str) -> None:
|
|
243
|
+
if not client_uuid:
|
|
244
|
+
client_uuid = str(uuid.uuid4())
|
|
245
|
+
|
|
246
|
+
server_url = "wss://wcgw.arcfu.com/register_serve_image"
|
|
247
|
+
|
|
248
|
+
with open(file_path, "rb") as image_file:
|
|
249
|
+
image_bytes = image_file.read()
|
|
250
|
+
media_type = mimetypes.guess_type(file_path)[0]
|
|
251
|
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
252
|
+
uu = {"name": name, "image_b64": image_b64, "media_type": media_type}
|
|
253
|
+
|
|
254
|
+
with syncconnect(f"{server_url}/{client_uuid}") as websocket:
|
|
255
|
+
try:
|
|
256
|
+
websocket.send(json.dumps(uu))
|
|
257
|
+
except websockets.ConnectionClosed:
|
|
258
|
+
print(f"Connection closed for UUID: {client_uuid}, retrying")
|
|
259
|
+
serve_image_in_bg(file_path, client_uuid, name)
|
|
260
|
+
|
|
261
|
+
class ImageData(BaseModel):
|
|
262
|
+
dataurl: str
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
|
|
223
266
|
Param = ParamSpec("Param")
|
|
224
267
|
|
|
225
268
|
T = TypeVar("T")
|
|
@@ -229,7 +272,7 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
|
|
|
229
272
|
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> T:
|
|
230
273
|
global BASH_STATE
|
|
231
274
|
if BASH_STATE == "waiting_for_input":
|
|
232
|
-
raise ValueError(
|
|
275
|
+
raise ValueError(WAITING_INPUT_MESSAGE)
|
|
233
276
|
elif BASH_STATE == "wont_exit":
|
|
234
277
|
raise ValueError(
|
|
235
278
|
"A command is already running that hasn't exited. NOTE: You can't run multiple shell sessions, likely the previous program is in infinite loop. Please kill the previous program by sending ctrl+c first."
|
|
@@ -238,6 +281,24 @@ def ensure_no_previous_output(func: Callable[Param, T]) -> Callable[Param, T]:
|
|
|
238
281
|
|
|
239
282
|
return wrapper
|
|
240
283
|
|
|
284
|
+
@ensure_no_previous_output
|
|
285
|
+
def read_image_from_shell(file_path: str) -> ImageData:
|
|
286
|
+
if not os.path.isabs(file_path):
|
|
287
|
+
SHELL.sendline("pwd")
|
|
288
|
+
SHELL.expect("#@@")
|
|
289
|
+
assert isinstance(SHELL.before, str)
|
|
290
|
+
current_dir = SHELL.before.strip()
|
|
291
|
+
file_path = os.path.join(current_dir, file_path)
|
|
292
|
+
|
|
293
|
+
if not os.path.exists(file_path):
|
|
294
|
+
raise ValueError(f"File {file_path} does not exist")
|
|
295
|
+
|
|
296
|
+
with open(file_path, "rb") as image_file:
|
|
297
|
+
image_bytes = image_file.read()
|
|
298
|
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
299
|
+
image_type = mimetypes.guess_type(file_path)[0]
|
|
300
|
+
return ImageData(dataurl=f'data:{image_type};base64,{image_b64}')
|
|
301
|
+
|
|
241
302
|
|
|
242
303
|
@ensure_no_previous_output
|
|
243
304
|
def write_file(writefile: Writefile) -> str:
|
|
@@ -280,41 +341,24 @@ def take_help_of_ai_assistant(
|
|
|
280
341
|
return output, cost
|
|
281
342
|
|
|
282
343
|
|
|
283
|
-
class AddTasks(BaseModel):
|
|
284
|
-
task_statement: str
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def add_task(addtask: AddTasks) -> str:
|
|
288
|
-
petname_id = petname.Generate(2, "-")
|
|
289
|
-
return petname_id
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
class RemoveTask(BaseModel):
|
|
293
|
-
task_id: str
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def remove_task(removetask: RemoveTask) -> str:
|
|
297
|
-
return "removed"
|
|
298
|
-
|
|
299
|
-
|
|
300
344
|
def which_tool(args: str) -> BaseModel:
|
|
301
345
|
adapter = TypeAdapter[
|
|
302
|
-
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag
|
|
303
|
-
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag)
|
|
346
|
+
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
|
|
347
|
+
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
|
|
304
348
|
return adapter.validate_python(json.loads(args))
|
|
305
349
|
|
|
306
350
|
|
|
307
351
|
def get_tool_output(
|
|
308
|
-
args: dict | Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag,
|
|
352
|
+
args: dict | Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage,
|
|
309
353
|
enc: tiktoken.Encoding,
|
|
310
354
|
limit: float,
|
|
311
355
|
loop_call: Callable[[str, float], tuple[str, float]],
|
|
312
356
|
is_waiting_user_input: Callable[[str], tuple[BASH_CLF_OUTPUT, float]],
|
|
313
|
-
) -> tuple[str | DoneFlag, float]:
|
|
357
|
+
) -> tuple[str | ImageData | DoneFlag, float]:
|
|
314
358
|
if isinstance(args, dict):
|
|
315
359
|
adapter = TypeAdapter[
|
|
316
|
-
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag
|
|
317
|
-
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag)
|
|
360
|
+
Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage
|
|
361
|
+
](Confirmation | ExecuteBash | Writefile | AIAssistant | DoneFlag | ReadImage)
|
|
318
362
|
arg = adapter.validate_python(args)
|
|
319
363
|
else:
|
|
320
364
|
arg = args
|
|
@@ -334,12 +378,9 @@ def get_tool_output(
|
|
|
334
378
|
elif isinstance(arg, AIAssistant):
|
|
335
379
|
console.print("Calling AI assistant tool")
|
|
336
380
|
output = take_help_of_ai_assistant(arg, limit, loop_call)
|
|
337
|
-
elif isinstance(arg,
|
|
338
|
-
console.print("Calling
|
|
339
|
-
output =
|
|
340
|
-
elif isinstance(arg, get_output_of_last_command):
|
|
341
|
-
console.print("Calling get output of last program tool")
|
|
342
|
-
output = get_output_of_last_command(enc), 0
|
|
381
|
+
elif isinstance(arg, ReadImage):
|
|
382
|
+
console.print("Calling read image tool")
|
|
383
|
+
output = read_image_from_shell(arg.file_path), 0.0
|
|
343
384
|
else:
|
|
344
385
|
raise ValueError(f"Unknown tool: {arg}")
|
|
345
386
|
|
|
@@ -353,9 +394,9 @@ History = list[ChatCompletionMessageParam]
|
|
|
353
394
|
def get_is_waiting_user_input(model: Models, cost_data: CostData):
|
|
354
395
|
enc = tiktoken.encoding_for_model(model if not model.startswith("o1") else "gpt-4o")
|
|
355
396
|
system_prompt = """You need to classify if a bash program is waiting for user input based on its stdout, or if it won't exit. You'll be given the output of any program.
|
|
356
|
-
Return `waiting_for_input` if the program is waiting for INTERACTIVE input only, Return
|
|
397
|
+
Return `waiting_for_input` if the program is waiting for INTERACTIVE input only, Return 'running' if it's waiting for external resources or just waiting to finish.
|
|
357
398
|
Return `wont_exit` if the program won't exit, for example if it's a server.
|
|
358
|
-
Return `
|
|
399
|
+
Return `running` otherwise.
|
|
359
400
|
"""
|
|
360
401
|
history: History = [{"role": "system", "content": system_prompt}]
|
|
361
402
|
client = OpenAI()
|
|
@@ -402,36 +443,35 @@ def execute_user_input() -> None:
|
|
|
402
443
|
while True:
|
|
403
444
|
discard_input()
|
|
404
445
|
user_input = input()
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
async def register_client(server_url: str) -> None:
|
|
446
|
+
with execution_lock:
|
|
447
|
+
try:
|
|
448
|
+
console.log(
|
|
449
|
+
execute_bash(
|
|
450
|
+
default_enc,
|
|
451
|
+
ExecuteBash(
|
|
452
|
+
send_ascii=[ord(x) for x in user_input] + [ord("\n")]
|
|
453
|
+
),
|
|
454
|
+
lambda x: ("waiting_for_input", 0),
|
|
455
|
+
)[0]
|
|
456
|
+
)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
traceback.print_exc()
|
|
459
|
+
console.log(f"Error: {e}")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
async def register_client(server_url: str, client_uuid: str = "") -> None:
|
|
423
463
|
global default_enc, default_model, curr_cost
|
|
424
464
|
# Generate a unique UUID for this client
|
|
425
|
-
|
|
426
|
-
|
|
465
|
+
if not client_uuid:
|
|
466
|
+
client_uuid = str(uuid.uuid4())
|
|
427
467
|
|
|
428
468
|
# Create the WebSocket connection
|
|
429
469
|
async with websockets.connect(f"{server_url}/{client_uuid}") as websocket:
|
|
470
|
+
print(f"Connected. Share this user id with the chatbot: {client_uuid} \nLink: https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access")
|
|
430
471
|
try:
|
|
431
472
|
while True:
|
|
432
473
|
# Wait to receive data from the server
|
|
433
474
|
message = await websocket.recv()
|
|
434
|
-
print(message, type(message))
|
|
435
475
|
mdata = Mdata.model_validate_json(message)
|
|
436
476
|
with execution_lock:
|
|
437
477
|
# is_waiting_user_input = get_is_waiting_user_input(
|
|
@@ -454,8 +494,9 @@ async def register_client(server_url: str) -> None:
|
|
|
454
494
|
assert not isinstance(output, DoneFlag)
|
|
455
495
|
await websocket.send(output)
|
|
456
496
|
|
|
457
|
-
except websockets.ConnectionClosed:
|
|
458
|
-
print(f"Connection closed for UUID: {client_uuid}")
|
|
497
|
+
except (websockets.ConnectionClosed, ConnectionError):
|
|
498
|
+
print(f"Connection closed for UUID: {client_uuid}, retrying")
|
|
499
|
+
await register_client(server_url, client_uuid)
|
|
459
500
|
|
|
460
501
|
|
|
461
502
|
def run() -> None:
|
|
@@ -463,4 +504,12 @@ def run() -> None:
|
|
|
463
504
|
server_url = sys.argv[1]
|
|
464
505
|
else:
|
|
465
506
|
server_url = "wss://wcgw.arcfu.com/register"
|
|
466
|
-
|
|
507
|
+
|
|
508
|
+
thread1 = threading.Thread(target=execute_user_input)
|
|
509
|
+
thread2 = threading.Thread(target=asyncio.run, args=(register_client(server_url),))
|
|
510
|
+
|
|
511
|
+
thread1.start()
|
|
512
|
+
thread2.start()
|
|
513
|
+
|
|
514
|
+
thread1.join()
|
|
515
|
+
thread2.join()
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: wcgw
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.7
|
|
4
4
|
Summary: What could go wrong giving full shell access to chatgpt?
|
|
5
5
|
Project-URL: Homepage, https://github.com/rusiaaman/wcgw
|
|
6
6
|
Author-email: Aman Rusia <gapypi@arcfu.com>
|
|
7
|
-
Requires-Python: >=3.
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Requires-Dist: anthropic>=0.36.2
|
|
8
9
|
Requires-Dist: fastapi>=0.115.0
|
|
9
10
|
Requires-Dist: mypy>=1.11.2
|
|
10
11
|
Requires-Dist: openai>=1.46.0
|
|
@@ -22,10 +23,18 @@ Requires-Dist: uvicorn>=0.31.0
|
|
|
22
23
|
Requires-Dist: websockets>=13.1
|
|
23
24
|
Description-Content-Type: text/markdown
|
|
24
25
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
# Shell access to chatgpt.com
|
|
27
|
+
|
|
28
|
+
### 🚀 Highlights
|
|
29
|
+
- ⚡ **Full Shell Access**: No restrictions, complete control.
|
|
30
|
+
- ⚡ **Create, Execute, Iterate**: Seamless workflow for development and execution.
|
|
31
|
+
- ⚡ **Interactive Command Handling**: Supports interactive commands with ease.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### 🪜 Steps:
|
|
35
|
+
1. Run the [cli client](https://github.com/rusiaaman/wcgw?tab=readme-ov-file#client) in any directory of choice.
|
|
36
|
+
2. Share the generated id with the GPT: `https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access`
|
|
37
|
+
3. The custom GPT can now run any command on your cli
|
|
29
38
|
|
|
30
39
|
## Client
|
|
31
40
|
|
|
@@ -49,7 +58,7 @@ https://chatgpt.com/g/g-Us0AAXkRh-wcgw-giving-shell-access
|
|
|
49
58
|
|
|
50
59
|
Add user id the client generated to the first message along with the instructions.
|
|
51
60
|
|
|
52
|
-
# How
|
|
61
|
+
# How it works
|
|
53
62
|
Your commands are relayed through a server I've hosted at https://wcgw.arcfu.com. The code for that is at `src/relay/serve.py`.
|
|
54
63
|
|
|
55
64
|
The user id that you share with chatgpt is added in the request it sents to the relay server which holds a websocket with the terminal client.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
wcgw/__init__.py,sha256=okSsOWpTKDjEQzgOin3Kdpx4Mc3MFX1RunjopHQSIWE,62
|
|
2
|
+
wcgw/__main__.py,sha256=MjJnFwfYzA1rW47xuSP1EVsi53DTHeEGqESkQwsELFQ,34
|
|
3
|
+
wcgw/basic.py,sha256=o_iyg3Rmqz08LTWzgO7JIA0u_5l6GGaXyJe0zw73v8w,15085
|
|
4
|
+
wcgw/common.py,sha256=gHiP1RVHkvp10jPc1Xzg5DtUqhGbgQ7pTJK7OvUXfZQ,1764
|
|
5
|
+
wcgw/openai_utils.py,sha256=YNwCsA-Wqq7jWrxP0rfQmBTb1dI0s7dWXzQqyTzOZT4,2629
|
|
6
|
+
wcgw/tools.py,sha256=ozJtcuOlMrdGoDmSv4UpolnStdY7Xz8Z7JyrRllKA7s,17088
|
|
7
|
+
wcgw-0.0.7.dist-info/METADATA,sha256=Ei676pDMqOXetAPPmxU5wAXZ6mWRJfLq8eJHyhfKSTY,2022
|
|
8
|
+
wcgw-0.0.7.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
9
|
+
wcgw-0.0.7.dist-info/entry_points.txt,sha256=T-IH7w6Vc650hr8xksC8kJfbJR4uwN8HDudejwDwrNM,59
|
|
10
|
+
wcgw-0.0.7.dist-info/RECORD,,
|
wcgw/openai_adapters.py
DELETED
|
File without changes
|
wcgw-0.0.5.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
wcgw/__init__.py,sha256=okSsOWpTKDjEQzgOin3Kdpx4Mc3MFX1RunjopHQSIWE,62
|
|
2
|
-
wcgw/__main__.py,sha256=MjJnFwfYzA1rW47xuSP1EVsi53DTHeEGqESkQwsELFQ,34
|
|
3
|
-
wcgw/basic.py,sha256=BiVjIwrtiz93SkUedDXSwtfVMKoV8-zEWeFKBIamVSQ,12372
|
|
4
|
-
wcgw/common.py,sha256=jn39zTpaFUO1PWof_z7qBmklaZH5G1blzjlBvez0cg4,1225
|
|
5
|
-
wcgw/openai_adapters.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
wcgw/openai_utils.py,sha256=4Hr9S2-WT8xhDdu3b2YVoX1l9AVxwCFdi_GJbQAx7Us,2202
|
|
7
|
-
wcgw/tools.py,sha256=IHCMbB8Eg1LFhY0sI3e0CMUuGWXCdU7-168efpTk9eo,15155
|
|
8
|
-
wcgw-0.0.5.dist-info/METADATA,sha256=_HvXQu02oMxiFjtBkJ1wJsAMDjFEkMn7nt4c8noa-c0,1669
|
|
9
|
-
wcgw-0.0.5.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
10
|
-
wcgw-0.0.5.dist-info/entry_points.txt,sha256=T-IH7w6Vc650hr8xksC8kJfbJR4uwN8HDudejwDwrNM,59
|
|
11
|
-
wcgw-0.0.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|