tass 0.1.11__py3-none-any.whl → 0.1.13__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.
- src/app.py +55 -210
- src/cli.py +23 -2
- src/constants.py +64 -113
- src/llm_client.py +49 -0
- src/tools/__init__.py +12 -0
- src/tools/edit_file.py +180 -0
- src/tools/execute.py +121 -0
- src/tools/read_file.py +61 -0
- src/utils.py +4 -28
- {tass-0.1.11.dist-info → tass-0.1.13.dist-info}/METADATA +13 -3
- tass-0.1.13.dist-info/RECORD +15 -0
- tass-0.1.11.dist-info/RECORD +0 -10
- {tass-0.1.11.dist-info → tass-0.1.13.dist-info}/WHEEL +0 -0
- {tass-0.1.11.dist-info → tass-0.1.13.dist-info}/entry_points.txt +0 -0
- {tass-0.1.11.dist-info → tass-0.1.13.dist-info}/licenses/LICENSE +0 -0
src/app.py
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import os
|
|
3
|
-
import subprocess
|
|
4
|
-
from pathlib import Path
|
|
5
2
|
|
|
6
|
-
import requests
|
|
7
3
|
from prompt_toolkit import prompt
|
|
8
|
-
from rich.console import
|
|
4
|
+
from rich.console import Group
|
|
9
5
|
from rich.live import Live
|
|
10
6
|
from rich.markdown import Markdown
|
|
11
7
|
from rich.panel import Panel
|
|
@@ -13,34 +9,40 @@ from rich.text import Text
|
|
|
13
9
|
|
|
14
10
|
from src.constants import (
|
|
15
11
|
SYSTEM_PROMPT,
|
|
16
|
-
|
|
12
|
+
console,
|
|
13
|
+
)
|
|
14
|
+
from src.llm_client import LLMClient
|
|
15
|
+
from src.tools import (
|
|
16
|
+
EDIT_FILE_TOOL,
|
|
17
|
+
EXECUTE_TOOL,
|
|
18
|
+
READ_FILE_TOOL,
|
|
19
|
+
edit_file,
|
|
20
|
+
execute,
|
|
21
|
+
read_file,
|
|
17
22
|
)
|
|
18
23
|
from src.utils import (
|
|
19
24
|
FileCompleter,
|
|
20
25
|
create_key_bindings,
|
|
21
|
-
is_read_only_command,
|
|
22
26
|
)
|
|
23
27
|
|
|
24
|
-
console = Console()
|
|
25
|
-
|
|
26
28
|
|
|
27
29
|
class TassApp:
|
|
28
30
|
|
|
29
|
-
def __init__(self):
|
|
31
|
+
def __init__(self, yolo_mode: bool = False):
|
|
32
|
+
self.yolo_mode = yolo_mode
|
|
30
33
|
self.messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
31
|
-
self.
|
|
34
|
+
self.llm_client = LLMClient()
|
|
32
35
|
self.key_bindings = create_key_bindings()
|
|
33
36
|
self.file_completer = FileCompleter()
|
|
34
37
|
self.TOOLS_MAP = {
|
|
35
|
-
"execute":
|
|
36
|
-
"read_file":
|
|
37
|
-
"edit_file":
|
|
38
|
+
"execute": execute,
|
|
39
|
+
"read_file": read_file,
|
|
40
|
+
"edit_file": edit_file,
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
def
|
|
41
|
-
test_url = f"{self.host}/v1/models"
|
|
43
|
+
def check_llm_host(self):
|
|
42
44
|
try:
|
|
43
|
-
response =
|
|
45
|
+
response = self.llm_client.get_models()
|
|
44
46
|
console.print("Terminal Assistant [green](LLM connection ✓)[/green]")
|
|
45
47
|
if response.status_code == 200:
|
|
46
48
|
return
|
|
@@ -48,20 +50,20 @@ class TassApp:
|
|
|
48
50
|
console.print("Terminal Assistant [red](LLM connection ✗)[/red]")
|
|
49
51
|
|
|
50
52
|
console.print("\n[red]Could not connect to LLM[/red]")
|
|
51
|
-
console.print(f"If your LLM isn't running on {self.host}, you can set the [bold]TASS_HOST[/] environment variable to a different URL.")
|
|
53
|
+
console.print(f"If your LLM isn't running on {self.llm_client.host}, you can set the [bold]TASS_HOST[/] environment variable to a different URL.")
|
|
52
54
|
new_host = console.input(
|
|
53
55
|
"Enter a different URL for this session (or press Enter to keep current): "
|
|
54
56
|
).strip()
|
|
55
57
|
|
|
56
58
|
if new_host:
|
|
57
|
-
self.host = new_host
|
|
59
|
+
self.llm_client.host = new_host
|
|
58
60
|
|
|
59
61
|
try:
|
|
60
|
-
response =
|
|
62
|
+
response = self.llm_client.get_models()
|
|
61
63
|
if response.status_code == 200:
|
|
62
|
-
console.print(f"[green]Connection established to {self.host}[/green]")
|
|
64
|
+
console.print(f"[green]Connection established to {self.llm_client.host}[/green]")
|
|
63
65
|
except Exception:
|
|
64
|
-
console.print(f"[red]Unable to verify new host {self.host}. Continuing with it anyway.[/red]")
|
|
66
|
+
console.print(f"[red]Unable to verify new host {self.llm_client.host}. Continuing with it anyway.[/red]")
|
|
65
67
|
|
|
66
68
|
def summarize(self):
|
|
67
69
|
max_messages = 20
|
|
@@ -78,15 +80,13 @@ class TassApp:
|
|
|
78
80
|
)
|
|
79
81
|
|
|
80
82
|
console.print("\n - Summarizing conversation...")
|
|
81
|
-
response =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
},
|
|
83
|
+
response = self.llm_client.get_chat_completions(
|
|
84
|
+
messages=[*self.messages, {"role": "user", "content": prompt}],
|
|
85
|
+
tools=[
|
|
86
|
+
EDIT_FILE_TOOL,
|
|
87
|
+
EXECUTE_TOOL,
|
|
88
|
+
READ_FILE_TOOL,
|
|
89
|
+
], # For caching purposes
|
|
90
90
|
)
|
|
91
91
|
data = response.json()
|
|
92
92
|
summary = data["choices"][0]["message"]["content"]
|
|
@@ -94,16 +94,13 @@ class TassApp:
|
|
|
94
94
|
console.print(" [green]Summarization completed[/green]")
|
|
95
95
|
|
|
96
96
|
def call_llm(self) -> bool:
|
|
97
|
-
response =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
},
|
|
105
|
-
"stream": True,
|
|
106
|
-
},
|
|
97
|
+
response = self.llm_client.get_chat_completions(
|
|
98
|
+
messages=self.messages,
|
|
99
|
+
tools=[
|
|
100
|
+
EDIT_FILE_TOOL,
|
|
101
|
+
EXECUTE_TOOL,
|
|
102
|
+
READ_FILE_TOOL,
|
|
103
|
+
],
|
|
107
104
|
stream=True,
|
|
108
105
|
)
|
|
109
106
|
|
|
@@ -158,7 +155,7 @@ class TassApp:
|
|
|
158
155
|
live.update(generate_layout())
|
|
159
156
|
|
|
160
157
|
delta = chunk["choices"][0]["delta"]
|
|
161
|
-
if not any(
|
|
158
|
+
if not any(delta.get(key) for key in ["content", "reasoning_content", "tool_calls"]):
|
|
162
159
|
continue
|
|
163
160
|
|
|
164
161
|
if delta.get("reasoning_content"):
|
|
@@ -212,6 +209,7 @@ class TassApp:
|
|
|
212
209
|
for tool_call in tool_calls_map.values():
|
|
213
210
|
tool = self.TOOLS_MAP[tool_call["function"]["name"]]
|
|
214
211
|
tool_args = json.loads(tool_call["function"]["arguments"])
|
|
212
|
+
tool_args["yolo_mode"] = self.yolo_mode
|
|
215
213
|
result = tool(**tool_args)
|
|
216
214
|
self.messages.append(
|
|
217
215
|
{
|
|
@@ -223,177 +221,25 @@ class TassApp:
|
|
|
223
221
|
)
|
|
224
222
|
return False
|
|
225
223
|
except Exception as e:
|
|
226
|
-
self.messages.append({"role": "user", "content":
|
|
224
|
+
self.messages.append({"role": "user", "content": f"Tool call failed: {e}"})
|
|
225
|
+
console.print(f" [red]Tool call failed: {str(e).strip()}[/red]")
|
|
227
226
|
return self.call_llm()
|
|
228
227
|
|
|
229
|
-
def
|
|
230
|
-
if
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
f"cat -n {path}",
|
|
239
|
-
shell=True,
|
|
240
|
-
capture_output=True,
|
|
241
|
-
text=True,
|
|
242
|
-
)
|
|
243
|
-
except Exception as e:
|
|
244
|
-
console.print(" [red]read_file failed[/red]")
|
|
245
|
-
console.print(f" [red]{str(e).strip()}[/red]")
|
|
246
|
-
return f"read_file failed: {str(e)}"
|
|
247
|
-
|
|
248
|
-
out = result.stdout
|
|
249
|
-
err = result.stderr.strip()
|
|
250
|
-
if result.returncode != 0:
|
|
251
|
-
console.print(" [red]read_file failed[/red]")
|
|
252
|
-
if err:
|
|
253
|
-
console.print(f" [red]{err}[/red]")
|
|
254
|
-
return f"read_file failed: {err}"
|
|
255
|
-
|
|
256
|
-
lines = []
|
|
257
|
-
line_num = 1
|
|
258
|
-
for line in out.split("\n"):
|
|
259
|
-
if line_num < start:
|
|
260
|
-
line_num += 1
|
|
261
|
-
continue
|
|
262
|
-
|
|
263
|
-
lines.append(line)
|
|
264
|
-
line_num += 1
|
|
265
|
-
|
|
266
|
-
if len(lines) >= num_lines:
|
|
267
|
-
lines.append("... (truncated)")
|
|
268
|
-
break
|
|
269
|
-
|
|
270
|
-
console.print(" [green]Command succeeded[/green]")
|
|
271
|
-
return "\n".join(lines)
|
|
272
|
-
|
|
273
|
-
def edit_file(self, path: str, edits: list[dict]) -> str:
|
|
274
|
-
for edit in edits:
|
|
275
|
-
edit["applied"] = False
|
|
276
|
-
|
|
277
|
-
def find_edit(n: int) -> dict | None:
|
|
278
|
-
for edit in edits:
|
|
279
|
-
if edit["line_start"] <= n <= edit["line_end"]:
|
|
280
|
-
return edit
|
|
281
|
-
|
|
282
|
-
return None
|
|
283
|
-
|
|
284
|
-
file_exists = Path(path).exists()
|
|
285
|
-
if file_exists:
|
|
286
|
-
with open(path, "r") as f:
|
|
287
|
-
original_content = f.read()
|
|
288
|
-
else:
|
|
289
|
-
original_content = ""
|
|
290
|
-
|
|
291
|
-
final_lines = []
|
|
292
|
-
original_lines = original_content.split("\n")
|
|
293
|
-
diff_text = f"{'Editing' if file_exists else 'Creating'} {path}"
|
|
294
|
-
for i, line in enumerate(original_lines):
|
|
295
|
-
line_num = i + 1
|
|
296
|
-
edit = find_edit(line_num)
|
|
297
|
-
if not edit:
|
|
298
|
-
final_lines.append(line)
|
|
299
|
-
continue
|
|
300
|
-
|
|
301
|
-
if edit["applied"]:
|
|
302
|
-
continue
|
|
303
|
-
|
|
304
|
-
replace_lines = edit["content"].split("\n")
|
|
305
|
-
if edit["content"]:
|
|
306
|
-
final_lines.extend(replace_lines)
|
|
307
|
-
original_lines = original_content.split("\n")
|
|
308
|
-
replaced_lines = original_lines[edit["line_start"] - 1:edit["line_end"]]
|
|
309
|
-
|
|
310
|
-
prev_line_num = line_num if line_num == 1 else line_num - 1
|
|
311
|
-
line_before = "" if i == 0 else f" {original_lines[i - 1]}\n"
|
|
312
|
-
line_after = "" if edit["line_end"] == len(original_lines) else f"\n {original_lines[edit['line_end']]}"
|
|
313
|
-
replaced_with_minuses = "\n".join([f"-{line}" for line in replaced_lines]) if file_exists else ""
|
|
314
|
-
replaced_with_pluses = ""
|
|
315
|
-
if edit["content"]:
|
|
316
|
-
replaced_with_pluses = "\n" + "\n".join([f"+{line}" for line in edit["content"].split("\n")])
|
|
317
|
-
diff_text = f"{diff_text}\n\n@@ -{prev_line_num},{len(replaced_lines)} +{prev_line_num},{len(replace_lines)} @@\n{line_before}{replaced_with_minuses}{replaced_with_pluses}{line_after}"
|
|
318
|
-
edit["applied"] = True
|
|
319
|
-
|
|
320
|
-
console.print()
|
|
321
|
-
console.print(Markdown(f"```diff\n{diff_text}\n```"))
|
|
322
|
-
answer = console.input("\n[bold]Run?[/] ([bold]Y[/]/n): ").strip().lower()
|
|
323
|
-
if answer not in ("yes", "y", ""):
|
|
324
|
-
reason = console.input("Why not? (optional, press Enter to skip): ").strip()
|
|
325
|
-
return f"User declined: {reason or 'no reason'}"
|
|
326
|
-
|
|
327
|
-
console.print(" └ Running...")
|
|
328
|
-
try:
|
|
329
|
-
with open(path, "w") as f:
|
|
330
|
-
f.write("\n".join(final_lines))
|
|
331
|
-
except Exception as e:
|
|
332
|
-
console.print(" [red]edit_file failed[/red]")
|
|
333
|
-
console.print(f" [red]{str(e).strip()}[/red]")
|
|
334
|
-
return f"edit_file failed: {str(e).strip()}"
|
|
335
|
-
|
|
336
|
-
console.print(" [green]Command succeeded[/green]")
|
|
337
|
-
return f"Successfully edited {path}"
|
|
338
|
-
|
|
339
|
-
def execute(self, command: str, explanation: str) -> str:
|
|
340
|
-
command = command.strip()
|
|
341
|
-
requires_confirmation = not is_read_only_command(command)
|
|
342
|
-
if requires_confirmation:
|
|
343
|
-
console.print()
|
|
344
|
-
console.print(Markdown(f"```shell\n{command}\n```"))
|
|
345
|
-
if explanation:
|
|
346
|
-
console.print(f"Explanation: {explanation}")
|
|
347
|
-
answer = console.input("\n[bold]Run?[/] ([bold]Y[/]/n): ").strip().lower()
|
|
348
|
-
if answer not in ("yes", "y", ""):
|
|
349
|
-
reason = console.input("Why not? (optional, press Enter to skip): ").strip()
|
|
350
|
-
return f"User declined: {reason or 'no reason'}"
|
|
351
|
-
|
|
352
|
-
if requires_confirmation:
|
|
353
|
-
console.print(" └ Running...")
|
|
354
|
-
else:
|
|
355
|
-
console.print(f" └ Running [bold]{command}[/] (Explanation: {explanation})...")
|
|
356
|
-
|
|
357
|
-
try:
|
|
358
|
-
result = subprocess.run(
|
|
359
|
-
command,
|
|
360
|
-
shell=True,
|
|
361
|
-
capture_output=True,
|
|
362
|
-
text=True,
|
|
363
|
-
)
|
|
364
|
-
except Exception as e:
|
|
365
|
-
console.print(" [red]subprocess.run failed[/red]")
|
|
366
|
-
console.print(f" [red]{str(e).strip()}[/red]")
|
|
367
|
-
return f"subprocess.run failed: {str(e).strip()}"
|
|
368
|
-
|
|
369
|
-
out = result.stdout
|
|
370
|
-
err = result.stderr.strip()
|
|
371
|
-
if result.returncode == 0:
|
|
372
|
-
console.print(" [green]Command succeeded[/green]")
|
|
373
|
-
else:
|
|
374
|
-
console.print(f" [red]Command failed[/red] (code {result.returncode})")
|
|
375
|
-
if err:
|
|
376
|
-
console.print(f" [red]{err}[/red]")
|
|
377
|
-
|
|
378
|
-
if len(out.split("\n")) > 1000:
|
|
379
|
-
out_first_1000 = "\n".join(out.split("\n")[:1000])
|
|
380
|
-
out = f"{out_first_1000}... (Truncated)"
|
|
381
|
-
|
|
382
|
-
if len(err.split("\n")) > 1000:
|
|
383
|
-
err_first_1000 = "\n".join(err.split("\n")[:1000])
|
|
384
|
-
err = f"{err_first_1000}... (Truncated)"
|
|
385
|
-
|
|
386
|
-
if len(out) > 20000:
|
|
387
|
-
out = f"{out[:20000]}... (Truncated)"
|
|
388
|
-
|
|
389
|
-
if len(err) > 20000:
|
|
390
|
-
err = f"{err[:20000]}... (Truncated)"
|
|
228
|
+
def run(self, initial_input: str | None = None):
|
|
229
|
+
if initial_input:
|
|
230
|
+
self.messages.append({"role": "user", "content": initial_input})
|
|
231
|
+
while True:
|
|
232
|
+
try:
|
|
233
|
+
finished = self.call_llm()
|
|
234
|
+
except Exception as e:
|
|
235
|
+
console.print(f"Failed to call LLM: {e}")
|
|
236
|
+
break
|
|
391
237
|
|
|
392
|
-
|
|
238
|
+
if finished:
|
|
239
|
+
return
|
|
393
240
|
|
|
394
|
-
def run(self):
|
|
395
241
|
try:
|
|
396
|
-
self.
|
|
242
|
+
self.check_llm_host()
|
|
397
243
|
except KeyboardInterrupt:
|
|
398
244
|
console.print("\nBye!")
|
|
399
245
|
return
|
|
@@ -426,12 +272,11 @@ class TassApp:
|
|
|
426
272
|
break
|
|
427
273
|
|
|
428
274
|
self.messages.append({"role": "user", "content": user_input})
|
|
429
|
-
|
|
430
275
|
while True:
|
|
431
276
|
try:
|
|
432
277
|
finished = self.call_llm()
|
|
433
278
|
except Exception as e:
|
|
434
|
-
console.print(f"Failed to call LLM: {
|
|
279
|
+
console.print(f"Failed to call LLM: {e}")
|
|
435
280
|
break
|
|
436
281
|
|
|
437
282
|
if finished:
|
src/cli.py
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
1
3
|
from src.app import TassApp
|
|
2
4
|
|
|
3
5
|
|
|
4
6
|
def main():
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
parser = argparse.ArgumentParser(
|
|
8
|
+
description="Terminal Assistant - Ask an LLM to run commands"
|
|
9
|
+
)
|
|
10
|
+
parser.add_argument(
|
|
11
|
+
"--yolo",
|
|
12
|
+
action="store_true",
|
|
13
|
+
help="YOLO mode: execute all commands and edit files without asking for confirmation",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"prompt",
|
|
17
|
+
nargs="?",
|
|
18
|
+
help="Prompt to run (enclose in quotes; runs in single-shot mode and exits)",
|
|
19
|
+
)
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
|
|
22
|
+
app = TassApp(yolo_mode=args.yolo)
|
|
23
|
+
|
|
24
|
+
if args.prompt:
|
|
25
|
+
app.run(initial_input=args.prompt)
|
|
26
|
+
else:
|
|
27
|
+
app.run()
|
src/constants.py
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import subprocess
|
|
1
4
|
from pathlib import Path
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
CWD_PATH = Path.cwd().resolve()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_shell_info() -> str:
|
|
13
|
+
shell_path = os.environ.get('SHELL', '')
|
|
14
|
+
shell_name = shell_path.split('/')[-1] if shell_path else 'unknown'
|
|
15
|
+
return shell_name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_git_info() -> str:
|
|
19
|
+
try:
|
|
20
|
+
git_info = []
|
|
21
|
+
|
|
22
|
+
subprocess.run(['git', 'rev-parse', '--git-dir'], capture_output=True, check=True, cwd=CWD_PATH)
|
|
23
|
+
result = subprocess.run(['git', 'branch', '--show-current'], capture_output=True, text=True, cwd=CWD_PATH)
|
|
24
|
+
branch = result.stdout.strip()
|
|
25
|
+
if branch:
|
|
26
|
+
git_info.append(f"Branch: {branch}")
|
|
27
|
+
|
|
28
|
+
result = subprocess.run(['git', 'status', '--porcelain'], capture_output=True, text=True, cwd=CWD_PATH)
|
|
29
|
+
has_changes = bool(result.stdout.strip())
|
|
30
|
+
git_info.append(f"Uncommitted changes: {'Yes' if has_changes else 'No'}")
|
|
31
|
+
|
|
32
|
+
return '\n'.join(git_info)
|
|
33
|
+
except Exception:
|
|
34
|
+
return "Not a git repository"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_directory_listing() -> str:
|
|
38
|
+
try:
|
|
39
|
+
entries = []
|
|
40
|
+
iterdir = CWD_PATH.iterdir()
|
|
41
|
+
for count, entry in enumerate(sorted(iterdir)):
|
|
42
|
+
if count >= 100:
|
|
43
|
+
entries.append(f"... and {len(list(iterdir)) - 100} more items")
|
|
44
|
+
break
|
|
45
|
+
if entry.is_dir():
|
|
46
|
+
entries.append(f"{entry.name}/")
|
|
47
|
+
else:
|
|
48
|
+
entries.append(entry.name)
|
|
49
|
+
return '\n'.join(entries) if entries else "Empty directory"
|
|
50
|
+
except PermissionError:
|
|
51
|
+
return "Permission denied"
|
|
52
|
+
|
|
4
53
|
|
|
5
54
|
SYSTEM_PROMPT = f"""You are tass, or Terminal Assistant, a helpful AI that executes shell commands based on natural-language requests.
|
|
6
55
|
|
|
@@ -8,115 +57,17 @@ If the user's request involves making changes to the filesystem such as creating
|
|
|
8
57
|
|
|
9
58
|
If a user asks for an answer or explanation to something instead of requesting to run a command, answer briefly and concisely. Do not supply extra information, suggestions, tips, or anything of the sort.
|
|
10
59
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
},
|
|
26
|
-
"explanation": {
|
|
27
|
-
"type": "string",
|
|
28
|
-
"description": "A brief explanation of why you want to run this command. Keep it to a single sentence.",
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
"required": ["command", "explanation"],
|
|
32
|
-
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
"type": "function",
|
|
38
|
-
"function": {
|
|
39
|
-
"name": "edit_file",
|
|
40
|
-
"description": "Edits (or creates) a file. Can make multiple edits in one call. Each edit replaces the contents between 'line_start' and 'line_end' inclusive with 'content'. If creating a file, only return a single edit where 'line_start' and 'line_end' are both 1 and 'content' is the entire contents of the file. You must use the read_file tool before editing a file.",
|
|
41
|
-
"parameters": {
|
|
42
|
-
"type": "object",
|
|
43
|
-
"properties": {
|
|
44
|
-
"path": {
|
|
45
|
-
"type": "string",
|
|
46
|
-
"description": "Relative path of the file",
|
|
47
|
-
},
|
|
48
|
-
"edits": {
|
|
49
|
-
"type": "array",
|
|
50
|
-
"description": "List of edits to apply. Each edit must contain 'line_start', 'line_end', and 'content'.",
|
|
51
|
-
"items": {
|
|
52
|
-
"type": "object",
|
|
53
|
-
"properties": {
|
|
54
|
-
"line_start": {
|
|
55
|
-
"type": "integer",
|
|
56
|
-
"description": "The first line to remove (inclusive)",
|
|
57
|
-
},
|
|
58
|
-
"line_end": {
|
|
59
|
-
"type": "integer",
|
|
60
|
-
"description": "The last line to remove (inclusive)",
|
|
61
|
-
},
|
|
62
|
-
"content": {
|
|
63
|
-
"type": "string",
|
|
64
|
-
"description": "The content to replace with. Must have the correct spacing and indentation for all lines.",
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
"required": ["line_start", "line_end", "content"],
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
"required": ["path", "edits"],
|
|
72
|
-
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"type": "function",
|
|
78
|
-
"function": {
|
|
79
|
-
"name": "read_file",
|
|
80
|
-
"description": "Read a file's contents (the first 1000 lines by default). When reading a file for the first time, do not change the defaults and always read the first 1000 lines unless you are absolutely certain of which lines need to be read. The output will be identical to calling `cat -n <path>` with preceding spaces, line number and a tab.",
|
|
81
|
-
"parameters": {
|
|
82
|
-
"type": "object",
|
|
83
|
-
"properties": {
|
|
84
|
-
"path": {
|
|
85
|
-
"type": "string",
|
|
86
|
-
"description": "Relative path of the file",
|
|
87
|
-
},
|
|
88
|
-
"start": {
|
|
89
|
-
"type": "integer",
|
|
90
|
-
"description": "Which line to start reading from",
|
|
91
|
-
"default": 1,
|
|
92
|
-
},
|
|
93
|
-
"num_lines": {
|
|
94
|
-
"type": "integer",
|
|
95
|
-
"description": "Number of lines to read, defaults to 1000",
|
|
96
|
-
"default": 1000,
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
"required": ["path"],
|
|
100
|
-
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
]
|
|
105
|
-
|
|
106
|
-
READ_ONLY_COMMANDS = [
|
|
107
|
-
"ls",
|
|
108
|
-
"cat",
|
|
109
|
-
"less",
|
|
110
|
-
"more",
|
|
111
|
-
"echo",
|
|
112
|
-
"head",
|
|
113
|
-
"tail",
|
|
114
|
-
"wc",
|
|
115
|
-
"grep",
|
|
116
|
-
"find",
|
|
117
|
-
"ack",
|
|
118
|
-
"which",
|
|
119
|
-
"sed",
|
|
120
|
-
"find",
|
|
121
|
-
"test",
|
|
122
|
-
]
|
|
60
|
+
This app has a feature where the user can refer to files or directories by typing @ which will open an file autocomplete dropdown. When this feature is used, the @ will remain in the filename. When working with said file, ignore the preceding @.
|
|
61
|
+
|
|
62
|
+
Current working directory: {CWD_PATH}
|
|
63
|
+
|
|
64
|
+
# Directory Context
|
|
65
|
+
OS: {platform.system()}
|
|
66
|
+
Shell: {get_shell_info()}
|
|
67
|
+
|
|
68
|
+
# Git Info
|
|
69
|
+
{get_git_info()}
|
|
70
|
+
|
|
71
|
+
# Directory Listing
|
|
72
|
+
{get_directory_listing()}
|
|
73
|
+
"""
|
src/llm_client.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LLMClient:
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.host = os.environ.get("TASS_HOST", "http://localhost:8080")
|
|
11
|
+
self.api_key = os.environ.get("TASS_API_KEY", "")
|
|
12
|
+
|
|
13
|
+
def request(
|
|
14
|
+
self,
|
|
15
|
+
method: Literal["get", "post"],
|
|
16
|
+
url: str,
|
|
17
|
+
**kwargs,
|
|
18
|
+
):
|
|
19
|
+
return requests.request(
|
|
20
|
+
method,
|
|
21
|
+
f"{self.host}{url}",
|
|
22
|
+
headers={
|
|
23
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
24
|
+
},
|
|
25
|
+
**kwargs,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def get(self, url: str, **kwargs):
|
|
29
|
+
return self.request("get", url, **kwargs)
|
|
30
|
+
|
|
31
|
+
def post(self, url: str, **kwargs):
|
|
32
|
+
return self.request("post", url, **kwargs)
|
|
33
|
+
|
|
34
|
+
def get_models(self):
|
|
35
|
+
return self.get("/v1/models", timeout=2)
|
|
36
|
+
|
|
37
|
+
def get_chat_completions(self, messages: list[dict], tools: list[dict], stream: bool = False):
|
|
38
|
+
return self.post(
|
|
39
|
+
"/v1/chat/completions",
|
|
40
|
+
json={
|
|
41
|
+
"messages": messages,
|
|
42
|
+
"tools": tools,
|
|
43
|
+
"stream": stream,
|
|
44
|
+
"chat_template_kwargs": {
|
|
45
|
+
"reasoning_effort": "medium",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
stream=stream,
|
|
49
|
+
)
|
src/tools/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .edit_file import EDIT_FILE_TOOL, edit_file
|
|
2
|
+
from .execute import EXECUTE_TOOL, execute
|
|
3
|
+
from .read_file import READ_FILE_TOOL, read_file
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"EDIT_FILE_TOOL",
|
|
7
|
+
"EXECUTE_TOOL",
|
|
8
|
+
"READ_FILE_TOOL",
|
|
9
|
+
"edit_file",
|
|
10
|
+
"execute",
|
|
11
|
+
"read_file",
|
|
12
|
+
]
|
src/tools/edit_file.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from difflib import SequenceMatcher, unified_diff
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.markdown import Markdown
|
|
6
|
+
|
|
7
|
+
from src.constants import console
|
|
8
|
+
|
|
9
|
+
EDIT_FILE_TOOL = {
|
|
10
|
+
"type": "function",
|
|
11
|
+
"function": {
|
|
12
|
+
"name": "edit_file",
|
|
13
|
+
"description": "Edits (or creates) a file. Can make multiple edits in one call. Each edit finds the instance of 'find' and replaces it with 'replace'. When creating a file, only return a single edit where 'find' is empty and 'replace' is the entire contents of the file. Both 'find' and 'replace' must always be entire lines and never parts of a line, and they must always have correct dnd complete indentation. You must use the read_file tool before editing a file.",
|
|
14
|
+
"parameters": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"path": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Relative path of the file",
|
|
20
|
+
},
|
|
21
|
+
"edits": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"description": "List of edits to apply.",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"find": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Content to find. Include additional previous/following lines if necessary to uniquely identify the section.",
|
|
30
|
+
},
|
|
31
|
+
"replace": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "The content to replace with. Must have the correct spacing and indentation for all lines.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
"required": ["find", "replace"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"required": ["path", "edits"],
|
|
41
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class LineEdit:
|
|
49
|
+
|
|
50
|
+
line_start: int
|
|
51
|
+
line_end: int
|
|
52
|
+
replace: str
|
|
53
|
+
applied: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def remove_empty_lines(s: str) -> str:
|
|
57
|
+
while "\n\n" in s:
|
|
58
|
+
s = s.replace("\n\n", "\n")
|
|
59
|
+
return s
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fuzzy_match(edit_find: str, lines: list[str]) -> tuple[int, int] | None:
|
|
63
|
+
if not lines:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
num_edit_find_lines = len(edit_find.split("\n"))
|
|
67
|
+
edit_find_trimmed = remove_empty_lines(edit_find)
|
|
68
|
+
best_ratio = 0.0
|
|
69
|
+
best_start = -1
|
|
70
|
+
best_window_len = 1
|
|
71
|
+
for i in range(len(lines)):
|
|
72
|
+
for j in range(1, min(num_edit_find_lines * 2, len(lines) - i)):
|
|
73
|
+
window_text = remove_empty_lines("\n".join(lines[i : i + j]))
|
|
74
|
+
ratio = SequenceMatcher(None, edit_find_trimmed, window_text).ratio()
|
|
75
|
+
if ratio > best_ratio:
|
|
76
|
+
best_ratio = ratio
|
|
77
|
+
best_start = i
|
|
78
|
+
best_window_len = j
|
|
79
|
+
if best_ratio == 1.0:
|
|
80
|
+
return best_start + 1, best_start + best_window_len
|
|
81
|
+
|
|
82
|
+
if best_ratio >= 0.9:
|
|
83
|
+
return best_start + 1, best_start + best_window_len
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def convert_edit_to_line_edit(edit: dict, original_content: str) -> LineEdit:
|
|
89
|
+
if not original_content and not edit["find"]:
|
|
90
|
+
return LineEdit(1, 1, edit["replace"], False)
|
|
91
|
+
|
|
92
|
+
lines = original_content.split("\n")
|
|
93
|
+
edit_lines = edit["find"].split("\n")
|
|
94
|
+
|
|
95
|
+
# First try exact matches
|
|
96
|
+
for i in range(len(lines)):
|
|
97
|
+
if lines[i : i + len(edit_lines)] == edit_lines:
|
|
98
|
+
return LineEdit(i + 1, i + len(edit_lines), edit["replace"], False)
|
|
99
|
+
|
|
100
|
+
# Then try matching without spacing
|
|
101
|
+
for i in range(len(lines)):
|
|
102
|
+
stripped_lines = [line.strip() for line in lines[i : i + len(edit_lines)]]
|
|
103
|
+
stripped_edit_lines = [line.strip() for line in edit_lines]
|
|
104
|
+
if stripped_lines == stripped_edit_lines:
|
|
105
|
+
return LineEdit(i + 1, i + len(edit_lines), edit["replace"], False)
|
|
106
|
+
|
|
107
|
+
# Finally try sequence matching while ignoring whitespace
|
|
108
|
+
fuzzy_edit = fuzzy_match(edit["find"], lines)
|
|
109
|
+
if fuzzy_edit:
|
|
110
|
+
return LineEdit(*fuzzy_edit, edit["replace"], False)
|
|
111
|
+
|
|
112
|
+
raise Exception("Edit not found in file")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def edit_file(path: str, edits: list[dict], yolo_mode: bool = False) -> str:
|
|
116
|
+
def find_line_edit(n: int) -> LineEdit | None:
|
|
117
|
+
for line_edit in line_edits:
|
|
118
|
+
if line_edit.line_start <= n <= line_edit.line_end:
|
|
119
|
+
return line_edit
|
|
120
|
+
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
file_exists = Path(path).exists()
|
|
124
|
+
if file_exists:
|
|
125
|
+
with open(path) as f:
|
|
126
|
+
original_content = f.read()
|
|
127
|
+
else:
|
|
128
|
+
original_content = ""
|
|
129
|
+
|
|
130
|
+
line_edits: list[LineEdit] = [
|
|
131
|
+
convert_edit_to_line_edit(edit, original_content)
|
|
132
|
+
for edit in edits
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
final_lines = []
|
|
136
|
+
original_lines = original_content.split("\n")
|
|
137
|
+
for i, line in enumerate(original_lines):
|
|
138
|
+
line_num = i + 1
|
|
139
|
+
line_edit = find_line_edit(line_num)
|
|
140
|
+
if not line_edit:
|
|
141
|
+
final_lines.append(line)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if line_edit.applied:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
replace_lines = line_edit.replace.split("\n")
|
|
148
|
+
if line_edit.replace:
|
|
149
|
+
final_lines.extend(replace_lines)
|
|
150
|
+
original_lines = original_content.split("\n")
|
|
151
|
+
line_edit.applied = True
|
|
152
|
+
|
|
153
|
+
unified_diff_md = ""
|
|
154
|
+
for line in unified_diff(
|
|
155
|
+
[f"{line}\n" for line in original_lines],
|
|
156
|
+
[f"{line}\n" for line in final_lines],
|
|
157
|
+
fromfile=path,
|
|
158
|
+
tofile=path,
|
|
159
|
+
):
|
|
160
|
+
unified_diff_md += line
|
|
161
|
+
|
|
162
|
+
console.print()
|
|
163
|
+
console.print(Markdown(f"```diff\n{unified_diff_md}\n```"))
|
|
164
|
+
|
|
165
|
+
if not yolo_mode:
|
|
166
|
+
answer = console.input("\n[bold]Run?[/] ([bold]Y[/]/n): ").strip().lower()
|
|
167
|
+
if answer not in ("yes", "y", ""):
|
|
168
|
+
reason = console.input("Why not? (optional, press Enter to skip): ").strip()
|
|
169
|
+
return f"User declined: {reason or 'no reason'}"
|
|
170
|
+
console.print(" └ Running...")
|
|
171
|
+
try:
|
|
172
|
+
with open(path, "w") as f:
|
|
173
|
+
f.write("\n".join(final_lines))
|
|
174
|
+
except Exception as e:
|
|
175
|
+
console.print(" [red]edit_file failed[/red]")
|
|
176
|
+
console.print(f" [red]{str(e).strip()}[/red]")
|
|
177
|
+
return f"edit_file failed: {str(e).strip()}"
|
|
178
|
+
|
|
179
|
+
console.print(" [green]Command succeeded[/green]")
|
|
180
|
+
return f"Successfully edited {path}"
|
src/tools/execute.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
from rich.markdown import Markdown
|
|
4
|
+
|
|
5
|
+
from src.constants import (
|
|
6
|
+
CWD_PATH,
|
|
7
|
+
console,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
READ_ONLY_COMMANDS = [
|
|
11
|
+
"ls",
|
|
12
|
+
"cat",
|
|
13
|
+
"less",
|
|
14
|
+
"more",
|
|
15
|
+
"echo",
|
|
16
|
+
"head",
|
|
17
|
+
"tail",
|
|
18
|
+
"wc",
|
|
19
|
+
"grep",
|
|
20
|
+
"find",
|
|
21
|
+
"ack",
|
|
22
|
+
"which",
|
|
23
|
+
"sed",
|
|
24
|
+
"find",
|
|
25
|
+
"test",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
EXECUTE_TOOL = {
|
|
29
|
+
"type": "function",
|
|
30
|
+
"function": {
|
|
31
|
+
"name": "execute",
|
|
32
|
+
"description": f"Executes a shell command. The current working directory for all commands will be {CWD_PATH}",
|
|
33
|
+
"parameters": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"command": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Full shell command to be executed.",
|
|
39
|
+
},
|
|
40
|
+
"explanation": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "A brief explanation of why you want to run this command. Keep it to a single sentence.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
"required": ["command", "explanation"],
|
|
46
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_read_only_command(command: str) -> bool:
|
|
53
|
+
"""A simple check to see if the command is only for reading files.
|
|
54
|
+
|
|
55
|
+
Not a comprehensive or foolproof check by any means, and will
|
|
56
|
+
return false negatives to be safe.
|
|
57
|
+
"""
|
|
58
|
+
if ">" in command:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
# Replace everything that potentially runs another command with a pipe
|
|
62
|
+
command = command.replace("&&", "|")
|
|
63
|
+
command = command.replace("||", "|")
|
|
64
|
+
command = command.replace(";", "|")
|
|
65
|
+
|
|
66
|
+
pipes = command.split("|")
|
|
67
|
+
return all(pipe.strip().split()[0] in READ_ONLY_COMMANDS for pipe in pipes)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def execute(command: str, explanation: str, yolo_mode: bool = False) -> str:
|
|
71
|
+
command = command.strip()
|
|
72
|
+
requires_confirmation = not yolo_mode and not is_read_only_command(command)
|
|
73
|
+
if requires_confirmation:
|
|
74
|
+
console.print()
|
|
75
|
+
console.print(Markdown(f"```shell\n{command}\n```"))
|
|
76
|
+
if explanation:
|
|
77
|
+
console.print(f"Explanation: {explanation}")
|
|
78
|
+
answer = console.input("\n[bold]Run?[/] ([bold]Y[/]/n): ").strip().lower()
|
|
79
|
+
if answer not in ("yes", "y", ""):
|
|
80
|
+
reason = console.input("Why not? (optional, press Enter to skip): ").strip()
|
|
81
|
+
return f"User declined: {reason or 'no reason'}"
|
|
82
|
+
console.print(" └ Running...")
|
|
83
|
+
else:
|
|
84
|
+
console.print(f" └ Running [bold]{command}[/] (Explanation: {explanation})")
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
result = subprocess.run(
|
|
88
|
+
command,
|
|
89
|
+
shell=True,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
console.print(" [red]subprocess.run failed[/red]")
|
|
95
|
+
console.print(f" [red]{str(e).strip()}[/red]")
|
|
96
|
+
return f"subprocess.run failed: {str(e).strip()}"
|
|
97
|
+
|
|
98
|
+
out = result.stdout
|
|
99
|
+
err = result.stderr.strip()
|
|
100
|
+
if result.returncode == 0:
|
|
101
|
+
console.print(" [green]Command succeeded[/green]")
|
|
102
|
+
else:
|
|
103
|
+
console.print(f" [red]Command failed[/red] (code {result.returncode})")
|
|
104
|
+
if err:
|
|
105
|
+
console.print(f" [red]{err}[/red]")
|
|
106
|
+
|
|
107
|
+
if len(out.split("\n")) > 1000:
|
|
108
|
+
out_first_1000 = "\n".join(out.split("\n")[:1000])
|
|
109
|
+
out = f"{out_first_1000}... (Truncated)"
|
|
110
|
+
|
|
111
|
+
if len(err.split("\n")) > 1000:
|
|
112
|
+
err_first_1000 = "\n".join(err.split("\n")[:1000])
|
|
113
|
+
err = f"{err_first_1000}... (Truncated)"
|
|
114
|
+
|
|
115
|
+
if len(out) > 20000:
|
|
116
|
+
out = f"{out[:20000]}... (Truncated)"
|
|
117
|
+
|
|
118
|
+
if len(err) > 20000:
|
|
119
|
+
err = f"{err[:20000]}... (Truncated)"
|
|
120
|
+
|
|
121
|
+
return f"Command output (exit {result.returncode}):\n{out}\n{err}"
|
src/tools/read_file.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from src.constants import console
|
|
2
|
+
|
|
3
|
+
READ_FILE_TOOL = {
|
|
4
|
+
"type": "function",
|
|
5
|
+
"function": {
|
|
6
|
+
"name": "read_file",
|
|
7
|
+
"description": "Read a file's contents (the first 1000 lines by default). When reading a file for the first time, do not change the defaults and always read the first 1000 lines unless you are absolutely certain of which lines need to be read.",
|
|
8
|
+
"parameters": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"properties": {
|
|
11
|
+
"path": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "Relative path of the file",
|
|
14
|
+
},
|
|
15
|
+
"start": {
|
|
16
|
+
"type": "integer",
|
|
17
|
+
"description": "Which line to start reading from",
|
|
18
|
+
"default": 1,
|
|
19
|
+
},
|
|
20
|
+
"num_lines": {
|
|
21
|
+
"type": "integer",
|
|
22
|
+
"description": "Number of lines to read, defaults to 1000",
|
|
23
|
+
"default": 1000,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
"required": ["path"],
|
|
27
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_file(path: str, start: int = 1, num_lines: int = 1000, yolo_mode: bool = False) -> str:
|
|
34
|
+
if start == 1 and num_lines == 1000:
|
|
35
|
+
console.print(f" └ Reading file [bold]{path}[/]...")
|
|
36
|
+
else:
|
|
37
|
+
last_line = start + num_lines - 1
|
|
38
|
+
console.print(f" └ Reading file [bold]{path}[/] (lines {start}-{last_line})...")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with open(path) as f:
|
|
42
|
+
lines = []
|
|
43
|
+
line_num = 1
|
|
44
|
+
for line in f:
|
|
45
|
+
if line_num < start:
|
|
46
|
+
line_num += 1
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
lines.append(line)
|
|
50
|
+
line_num += 1
|
|
51
|
+
|
|
52
|
+
if len(lines) >= num_lines:
|
|
53
|
+
lines.append("... (truncated)")
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
console.print(" [green]Command succeeded[/green]")
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
console.print(" [red]read_file failed[/red]")
|
|
60
|
+
console.print(f" [red]{str(e).strip()}[/red]")
|
|
61
|
+
return f"read_file failed: {e}"
|
src/utils.py
CHANGED
|
@@ -1,37 +1,13 @@
|
|
|
1
1
|
from prompt_toolkit.completion import (
|
|
2
|
+
CompleteEvent,
|
|
2
3
|
Completer,
|
|
3
4
|
Completion,
|
|
4
|
-
CompleteEvent,
|
|
5
5
|
)
|
|
6
6
|
from prompt_toolkit.document import Document
|
|
7
7
|
from prompt_toolkit.key_binding import KeyBindings
|
|
8
8
|
from prompt_toolkit.keys import Keys
|
|
9
9
|
|
|
10
|
-
from src.constants import
|
|
11
|
-
READ_ONLY_COMMANDS,
|
|
12
|
-
cwd_path,
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
def is_read_only_command(command: str) -> bool:
|
|
16
|
-
"""A simple check to see if the command is only for reading files.
|
|
17
|
-
|
|
18
|
-
Not a comprehensive or foolproof check by any means, and will
|
|
19
|
-
return false negatives to be safe.
|
|
20
|
-
"""
|
|
21
|
-
if ">" in command:
|
|
22
|
-
return False
|
|
23
|
-
|
|
24
|
-
# Replace everything that potentially runs another command with a pipe
|
|
25
|
-
command = command.replace("&&", "|")
|
|
26
|
-
command = command.replace("||", "|")
|
|
27
|
-
command = command.replace(";", "|")
|
|
28
|
-
|
|
29
|
-
pipes = command.split("|")
|
|
30
|
-
for pipe in pipes:
|
|
31
|
-
if pipe.strip().split()[0] not in READ_ONLY_COMMANDS:
|
|
32
|
-
return False
|
|
33
|
-
|
|
34
|
-
return True
|
|
10
|
+
from src.constants import CWD_PATH
|
|
35
11
|
|
|
36
12
|
|
|
37
13
|
class FileCompleter(Completer):
|
|
@@ -47,13 +23,13 @@ class FileCompleter(Completer):
|
|
|
47
23
|
|
|
48
24
|
text_after_at = text[last_at_pos + 1 : cursor_pos]
|
|
49
25
|
|
|
50
|
-
base_dir =
|
|
26
|
+
base_dir = CWD_PATH
|
|
51
27
|
search_pattern = text_after_at
|
|
52
28
|
prefix = ""
|
|
53
29
|
|
|
54
30
|
if "/" in text_after_at:
|
|
55
31
|
dir_part, file_part = text_after_at.rsplit("/", 1)
|
|
56
|
-
base_dir =
|
|
32
|
+
base_dir = CWD_PATH / dir_part
|
|
57
33
|
search_pattern = file_part
|
|
58
34
|
prefix = dir_part + "/"
|
|
59
35
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tass
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.13
|
|
4
4
|
Summary: A terminal assistant that allows you to ask an LLM to run commands.
|
|
5
5
|
Project-URL: Homepage, https://github.com/cetincan0/tass
|
|
6
6
|
Author: Can Cetin
|
|
@@ -44,11 +44,21 @@ You can run it with
|
|
|
44
44
|
tass
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
or if you only want to ask/request a single thing
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
tass "convert video.mp4 to audio.mp3"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
tass has only been tested with llama.cpp with LLMs such as gpt-oss-120b and MiniMax M2.1, but any LLM with tool calling capabilities should work.
|
|
54
|
+
|
|
55
|
+
By default, tass will try connecting to http://localhost:8080. To use another host, set the `TASS_HOST` environment variable. If your server requires an API key, you can set the `TASS_API_KEY` environment variable. At the moment there's no support for connecting tass to a non-local API, nor are there plans for it. I plan on keeping tass completely local. There's no telemetry, no logs, just a simple REPL loop.
|
|
48
56
|
|
|
49
57
|
Once it's running, you can ask questions or give commands like "Create an empty file called test.txt" and it will propose a command to run after user confirmation.
|
|
50
58
|
|
|
51
|
-
You can enter multiline input by ending lines with a backslash (\\). The continuation prompt will
|
|
59
|
+
You can enter multiline input by ending lines with a backslash (\\). The continuation prompt will keep appearing until you enter a line without a trailing backslash.
|
|
60
|
+
|
|
61
|
+
You can use the --yolo flag to turn off user confirmations for executing commands and editing files, but I would only recommend using this if you're benchmarking tass with an LLM and highly recommend not using it outside of testing/benchmarking scenarios.
|
|
52
62
|
|
|
53
63
|
## Upgrade
|
|
54
64
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
src/__init__.py,sha256=tu2q9W5_pkq30l3tRMTGahColBAAubbLP6LaB3l3IFg,89
|
|
2
|
+
src/app.py,sha256=S5-4L4zK09v2wypcbycChlRDLjvd_JG4pLNUyaonrOg,10379
|
|
3
|
+
src/cli.py,sha256=uFt8UJVfQWqMo0UVf2j8ecdZc-rTsHZuU_zh7BM-A1w,663
|
|
4
|
+
src/constants.py,sha256=Gowy-jdKCDUJxZMf8uANCPkmzB5nl3Dl_CWv52E-3Aw,2592
|
|
5
|
+
src/llm_client.py,sha256=bD59tT9EQvF6UuYbMLwFZZwj1iOXtVxRoQZMttWKnlQ,1264
|
|
6
|
+
src/utils.py,sha256=UB9B03aGv9MmtTfDzMmBBrAlaEnmeLmNHeSWqkGoWsU,2401
|
|
7
|
+
src/tools/__init__.py,sha256=5XmZH5XrwRju52BaI_fG7jl2Jllx7C5mr7wsX2Q46DE,269
|
|
8
|
+
src/tools/edit_file.py,sha256=UNuWPdhfw4xJ7grQbHmyYhYBfsxUDlvK45bnCycJs68,6372
|
|
9
|
+
src/tools/execute.py,sha256=3ljDYhL4ogzy89TGvM-XKLaW-fIEUmyr8WTBAyFk8Yk,3685
|
|
10
|
+
src/tools/read_file.py,sha256=LTbchpwP07-QCJOd-cItxgiEWx0tOBWYup8Uf0pc8FA,2171
|
|
11
|
+
tass-0.1.13.dist-info/METADATA,sha256=Fe7PBKpjdtNJyH-sFKZE_eB0tq7_SskwIFHugsLIa3U,2152
|
|
12
|
+
tass-0.1.13.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
tass-0.1.13.dist-info/entry_points.txt,sha256=pviKuIOuHvaQ7_YiFxatJEY8XYfh3EzVWy4LJh0v-A0,38
|
|
14
|
+
tass-0.1.13.dist-info/licenses/LICENSE,sha256=Cdr-_YJHgGaf2vJjcoOsRJySkDaogUhu3yIDvpz7GEQ,1066
|
|
15
|
+
tass-0.1.13.dist-info/RECORD,,
|
tass-0.1.11.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
src/__init__.py,sha256=tu2q9W5_pkq30l3tRMTGahColBAAubbLP6LaB3l3IFg,89
|
|
2
|
-
src/app.py,sha256=4rdWfwRpjBR_nYE674WktiJq_kzHivlES5-G7D6qk6U,16421
|
|
3
|
-
src/cli.py,sha256=op3fYcyfek_KqCCiA-Zdlc9jVZSCi036whMmR2ZjjAs,76
|
|
4
|
-
src/constants.py,sha256=LYNON4xoqCssh4wA7rjkjyY2JhDXUPFmkQlTz_N0oz8,5089
|
|
5
|
-
src/utils.py,sha256=Uoi60eko9ivvnF-se68yAuwUDFEOHb9Y2SepDaOAbTU,3069
|
|
6
|
-
tass-0.1.11.dist-info/METADATA,sha256=soHqn90KislDBTSa1QQraCJ6gJN2q6kElgMo6VJPgX4,1717
|
|
7
|
-
tass-0.1.11.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
-
tass-0.1.11.dist-info/entry_points.txt,sha256=pviKuIOuHvaQ7_YiFxatJEY8XYfh3EzVWy4LJh0v-A0,38
|
|
9
|
-
tass-0.1.11.dist-info/licenses/LICENSE,sha256=Cdr-_YJHgGaf2vJjcoOsRJySkDaogUhu3yIDvpz7GEQ,1066
|
|
10
|
-
tass-0.1.11.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|