tass 0.1.8__py3-none-any.whl → 0.1.10__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 CHANGED
@@ -4,7 +4,7 @@ import subprocess
4
4
  from pathlib import Path
5
5
 
6
6
  import requests
7
-
7
+ from prompt_toolkit import prompt
8
8
  from rich.console import Console, Group
9
9
  from rich.live import Live
10
10
  from rich.markdown import Markdown
@@ -15,7 +15,11 @@ from src.constants import (
15
15
  SYSTEM_PROMPT,
16
16
  TOOLS,
17
17
  )
18
- from src.utils import is_read_only_command
18
+ from src.utils import (
19
+ FileCompleter,
20
+ create_key_bindings,
21
+ is_read_only_command,
22
+ )
19
23
 
20
24
  console = Console()
21
25
 
@@ -25,6 +29,8 @@ class TassApp:
25
29
  def __init__(self):
26
30
  self.messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
27
31
  self.host = os.environ.get("TASS_HOST", "http://localhost:8080")
32
+ self.key_bindings = create_key_bindings()
33
+ self.file_completer = FileCompleter()
28
34
  self.TOOLS_MAP = {
29
35
  "execute": self.execute,
30
36
  "read_file": self.read_file,
@@ -64,11 +70,14 @@ class TassApp:
64
70
 
65
71
  prompt = (
66
72
  "The conversation is becoming long and might soon go beyond the "
67
- "context limit. Please provide a concise summary of the conversation, "
68
- "preserving all important details. Keep the summary short enough "
69
- "to fit within a few paragraphs at the most."
73
+ "context limit. Please provide a detailed summary of the conversation, "
74
+ "preserving all important details. Make sure context is not lost so that "
75
+ "the conversation can continue without needing to reclarify anything. "
76
+ "You don't have to preserve entire contents of files that have been read "
77
+ " or edited, they can be read again if necessary."
70
78
  )
71
79
 
80
+ console.print("\n - Summarizing conversation...")
72
81
  response = requests.post(
73
82
  f"{self.host}/v1/chat/completions",
74
83
  json={
@@ -82,6 +91,7 @@ class TassApp:
82
91
  data = response.json()
83
92
  summary = data["choices"][0]["message"]["content"]
84
93
  self.messages = [self.messages[0], {"role": "assistant", "content": f"Summary of the conversation so far:\n{summary}"}]
94
+ console.print(" [green]Summarization completed[/green]")
85
95
 
86
96
  def call_llm(self) -> bool:
87
97
  response = requests.post(
@@ -137,15 +147,29 @@ class TassApp:
137
147
  continue
138
148
 
139
149
  chunk = json.loads(line.removeprefix("data:"))
150
+ if all(k in chunk.get("timings", {}) for k in ["prompt_n", "prompt_per_second", "predicted_n", "predicted_per_second"]):
151
+ timings = chunk["timings"]
152
+ timings_str = (
153
+ f"Input: {timings['prompt_n']:,} tokens, {timings['prompt_per_second']:,.2f} tok/s | "
154
+ f"Output: {timings['predicted_n']:,} tokens, {timings['predicted_per_second']:,.2f} tok/s"
155
+ )
156
+
157
+ if chunk["choices"][0]["finish_reason"]:
158
+ live.update(generate_layout())
159
+
140
160
  delta = chunk["choices"][0]["delta"]
161
+ if not any([delta.get(key) for key in ["content", "reasoning_content", "tool_calls"]]):
162
+ continue
163
+
164
+ if delta.get("reasoning_content"):
165
+ reasoning_content += delta["reasoning_content"]
166
+ live.update(generate_layout())
167
+
141
168
  if delta.get("content"):
142
169
  content += delta["content"]
143
170
  live.update(generate_layout())
144
- if delta.get("reasoning_content" ):
145
- reasoning_content += delta["reasoning_content"]
146
- live.update(generate_layout())
147
171
 
148
- for tool_call_delta in delta.get("tool_calls", []):
172
+ for tool_call_delta in delta.get("tool_calls") or []:
149
173
  index = tool_call_delta["index"]
150
174
  if index not in tool_calls_map:
151
175
  tool_calls_map[index] = (
@@ -172,22 +196,12 @@ class TassApp:
172
196
  if function.get("arguments"):
173
197
  tool_call["function"]["arguments"] += function["arguments"]
174
198
 
175
- if all(k in chunk.get("timings", {}) for k in ["prompt_n", "prompt_per_second", "predicted_n", "predicted_per_second"]):
176
- timings = chunk["timings"]
177
- timings_str = (
178
- f"Input: {timings['prompt_n']:,} tokens, {timings['prompt_per_second']:,.2f} tok/s | "
179
- f"Output: {timings['predicted_n']:,} tokens, {timings['predicted_per_second']:,.2f} tok/s"
180
- )
181
-
182
- if chunk["choices"][0]["finish_reason"]:
183
- live.update(generate_layout())
184
-
185
199
  self.messages.append(
186
200
  {
187
201
  "role": "assistant",
188
- "content": content,
189
- "reasoning_content": reasoning_content,
190
- "tool_calls": list(tool_calls_map.values()),
202
+ "content": content.strip(),
203
+ "reasoning_content": reasoning_content.strip(),
204
+ "tool_calls": list(tool_calls_map.values()) or [],
191
205
  }
192
206
  )
193
207
 
@@ -212,11 +226,12 @@ class TassApp:
212
226
  self.messages.append({"role": "user", "content": str(e)})
213
227
  return self.call_llm()
214
228
 
215
- def read_file(self, path: str, start: int = 1) -> str:
216
- if start == 1:
229
+ def read_file(self, path: str, start: int = 1, num_lines: int = 1000) -> str:
230
+ if start == 1 and num_lines == 1000:
217
231
  console.print(f" └ Reading file [bold]{path}[/]...")
218
232
  else:
219
- console.print(f" Reading file [bold]{path}[/] (from line {start})...")
233
+ last_line = start + num_lines - 1
234
+ console.print(f" └ Reading file [bold]{path}[/] (lines {start}-{last_line})...")
220
235
 
221
236
  try:
222
237
  result = subprocess.run(
@@ -248,14 +263,15 @@ class TassApp:
248
263
  lines.append(line)
249
264
  line_num += 1
250
265
 
251
- if len(lines) >= 1000:
266
+ if len(lines) >= num_lines:
252
267
  lines.append("... (truncated)")
253
268
  break
254
269
 
255
270
  console.print(" [green]Command succeeded[/green]")
256
- return "".join(lines)
271
+ return "\n".join(lines)
257
272
 
258
273
  def edit_file(self, path: str, edits: list[dict]) -> str:
274
+ console.print(json.dumps(edits, indent=2))
259
275
  for edit in edits:
260
276
  edit["applied"] = False
261
277
 
@@ -286,8 +302,9 @@ class TassApp:
286
302
  if edit["applied"]:
287
303
  continue
288
304
 
289
- replace_lines = edit["replace"].split("\n")
290
- final_lines.extend(replace_lines)
305
+ replace_lines = edit["content"].split("\n")
306
+ if edit["content"]:
307
+ final_lines.extend(replace_lines)
291
308
  original_lines = original_content.split("\n")
292
309
  replaced_lines = original_lines[edit["line_start"] - 1:edit["line_end"]]
293
310
 
@@ -295,8 +312,10 @@ class TassApp:
295
312
  line_before = "" if i == 0 else f" {original_lines[i - 1]}\n"
296
313
  line_after = "" if edit["line_end"] == len(original_lines) else f"\n {original_lines[edit['line_end']]}"
297
314
  replaced_with_minuses = "\n".join([f"-{line}" for line in replaced_lines]) if file_exists else ""
298
- replace_with_pluses = "\n".join([f"+{line}" for line in edit["replace"].split("\n")])
299
- 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}\n{replace_with_pluses}{line_after}"
315
+ replaced_with_pluses = ""
316
+ if edit["content"]:
317
+ replaced_with_pluses = "\n" + "\n".join([f"+{line}" for line in edit["content"].split("\n")])
318
+ 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}"
300
319
  edit["applied"] = True
301
320
 
302
321
  console.print()
@@ -385,12 +404,16 @@ class TassApp:
385
404
  try:
386
405
  input_lines = []
387
406
  while True:
388
- input_line = console.input("> ")
407
+ input_line = prompt(
408
+ "> ",
409
+ completer=self.file_completer,
410
+ complete_while_typing=True,
411
+ key_bindings=self.key_bindings,
412
+ )
389
413
  if not input_line or input_line[-1] != "\\":
390
414
  input_lines.append(input_line)
391
415
  break
392
416
  input_lines.append(input_line[:-1])
393
-
394
417
  user_input = "\n".join(input_lines)
395
418
  except KeyboardInterrupt:
396
419
  console.print("\nBye!")
src/constants.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
- _cwd_path = Path.cwd().resolve()
3
+ cwd_path = Path.cwd().resolve()
4
4
 
5
5
  SYSTEM_PROMPT = f"""You are tass, or Terminal Assistant, a helpful AI that executes shell commands based on natural-language requests.
6
6
 
@@ -8,7 +8,7 @@ If the user's request involves making changes to the filesystem such as creating
8
8
 
9
9
  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
10
 
11
- Current working directory: {_cwd_path}"""
11
+ Current working directory: {cwd_path}"""
12
12
 
13
13
  TOOLS = [
14
14
  {
@@ -37,7 +37,7 @@ TOOLS = [
37
37
  "type": "function",
38
38
  "function": {
39
39
  "name": "edit_file",
40
- "description": "Edits (or creates) a file. Makes multiple replacements in one call. Each edit removes the contents between 'line_start' and 'line_end' inclusive and replaces it with 'replace'. If creating a file, only return a single edit where the line_start and line_end are both 1 and replace is the entire contents of the 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
41
  "parameters": {
42
42
  "type": "object",
43
43
  "properties": {
@@ -47,7 +47,7 @@ TOOLS = [
47
47
  },
48
48
  "edits": {
49
49
  "type": "array",
50
- "description": "List of edits to apply. Each edit must contain 'line_start', 'line_end', and 'replace'.",
50
+ "description": "List of edits to apply. Each edit must contain 'line_start', 'line_end', and 'content'.",
51
51
  "items": {
52
52
  "type": "object",
53
53
  "properties": {
@@ -59,12 +59,12 @@ TOOLS = [
59
59
  "type": "integer",
60
60
  "description": "The last line to remove (inclusive)",
61
61
  },
62
- "replace": {
62
+ "content": {
63
63
  "type": "string",
64
- "description": "The string to replace with. Must have the correct spacing and indentation for all lines.",
64
+ "description": "The content to replace with. Must have the correct spacing and indentation for all lines.",
65
65
  },
66
66
  },
67
- "required": ["line_start", "line_end", "replace"],
67
+ "required": ["line_start", "line_end", "content"],
68
68
  },
69
69
  },
70
70
  },
@@ -77,7 +77,7 @@ TOOLS = [
77
77
  "type": "function",
78
78
  "function": {
79
79
  "name": "read_file",
80
- "description": "Read a file's contents (up to 1000 lines). The output will be identical to calling `cat -n <path>` with preceding spaces, line number and a tab.",
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
81
  "parameters": {
82
82
  "type": "object",
83
83
  "properties": {
@@ -90,6 +90,11 @@ TOOLS = [
90
90
  "description": "Which line to start reading from",
91
91
  "default": 1,
92
92
  },
93
+ "num_lines": {
94
+ "type": "integer",
95
+ "description": "Number of lines to read, defaults to 1000",
96
+ "default": 1000,
97
+ },
93
98
  },
94
99
  "required": ["path"],
95
100
  "$schema": "http://json-schema.org/draft-07/schema#",
@@ -113,4 +118,5 @@ READ_ONLY_COMMANDS = [
113
118
  "which",
114
119
  "sed",
115
120
  "find",
121
+ "test",
116
122
  ]
src/utils.py CHANGED
@@ -1,5 +1,16 @@
1
- from src.constants import READ_ONLY_COMMANDS
1
+ from prompt_toolkit.completion import (
2
+ Completer,
3
+ Completion,
4
+ CompleteEvent,
5
+ )
6
+ from prompt_toolkit.document import Document
7
+ from prompt_toolkit.key_binding import KeyBindings
8
+ from prompt_toolkit.keys import Keys
2
9
 
10
+ from src.constants import (
11
+ READ_ONLY_COMMANDS,
12
+ cwd_path,
13
+ )
3
14
 
4
15
  def is_read_only_command(command: str) -> bool:
5
16
  """A simple check to see if the command is only for reading files.
@@ -21,3 +32,67 @@ def is_read_only_command(command: str) -> bool:
21
32
  return False
22
33
 
23
34
  return True
35
+
36
+
37
+ class FileCompleter(Completer):
38
+
39
+ def get_completions(self, document: Document, complete_event: CompleteEvent) -> list[Completion]:
40
+ """Return file completions for text after @ anywhere in input."""
41
+ text = document.text
42
+ cursor_pos = document.cursor_position
43
+ text_before_cursor = document.text_before_cursor
44
+ last_at_pos = text_before_cursor.rfind("@")
45
+ if last_at_pos == -1:
46
+ return []
47
+
48
+ text_after_at = text[last_at_pos + 1 : cursor_pos]
49
+
50
+ base_dir = cwd_path
51
+ search_pattern = text_after_at
52
+ prefix = ""
53
+
54
+ if "/" in text_after_at:
55
+ dir_part, file_part = text_after_at.rsplit("/", 1)
56
+ base_dir = cwd_path / dir_part
57
+ search_pattern = file_part
58
+ prefix = dir_part + "/"
59
+
60
+ if not base_dir.is_dir():
61
+ return []
62
+
63
+ try:
64
+ dirs = []
65
+ files = []
66
+ for entry in sorted(base_dir.iterdir()):
67
+ name = entry.name
68
+ if search_pattern in name:
69
+ full_path = prefix + name
70
+ if entry.is_dir():
71
+ dirs.append(Completion(full_path, display=name, start_position=-len(text_after_at), style="Blue"))
72
+ else:
73
+ files.append(Completion(full_path, display=name, start_position=-len(text_after_at)))
74
+
75
+ return dirs + files
76
+ except (PermissionError, OSError):
77
+ return []
78
+
79
+
80
+ def create_key_bindings() -> KeyBindings:
81
+ bindings = KeyBindings()
82
+
83
+ @bindings.add(Keys.Backspace)
84
+ def backspace_with_completion(event):
85
+ """Also trigger completion on backspace."""
86
+ buffer = event.current_buffer
87
+ buffer.delete_before_cursor(count=1)
88
+ text_before_cursor = buffer.document.text_before_cursor
89
+ last_at_pos = text_before_cursor.rfind("@")
90
+ if last_at_pos != -1:
91
+ buffer.start_completion()
92
+
93
+ @bindings.add(Keys.ControlC)
94
+ def ctrl_c_handler(event):
95
+ """Ctrl+C should raise KeyboardInterrupt."""
96
+ raise KeyboardInterrupt()
97
+
98
+ return bindings
@@ -1,18 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tass
3
- Version: 0.1.8
3
+ Version: 0.1.10
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
7
7
  License: MIT
8
8
  License-File: LICENSE
9
9
  Requires-Python: >=3.10
10
+ Requires-Dist: prompt-toolkit>=3.0.52
10
11
  Requires-Dist: requests>=2.32.5
11
12
  Requires-Dist: rich>=14.2.0
12
13
  Description-Content-Type: text/markdown
13
14
 
14
15
  # tass
15
16
 
17
+ <p align="center">
18
+ <img src="assets/tass.gif" alt="Demo" />
19
+ </p>
20
+
16
21
  A terminal assistant that allows you to ask an LLM to run commands.
17
22
 
18
23
  ## Warning
@@ -39,7 +44,7 @@ You can run it with
39
44
  tass
40
45
  ```
41
46
 
42
- tass has only been tested with gpt-oss-120b using llama.cpp so far, but in theory any LLM with tool calling capabilities should work. By default, it will try connecting to http://localhost:8080. If you want to use another host, set the `TASS_HOST` environment variable.
47
+ tass has only been tested with gpt-oss-120b using llama.cpp so far, but in theory any LLM with tool calling capabilities should work. By default, it will try connecting to http://localhost:8080. If you want to use another host, set the `TASS_HOST` environment variable. At the moment there's no support for connecting tass to a non-local API, nor are there plans for it. For the time being, I plan on keeping tass completely local. There's no telemetry, no logs, just a simple REPL loop.
43
48
 
44
49
  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.
45
50
 
@@ -0,0 +1,10 @@
1
+ src/__init__.py,sha256=tu2q9W5_pkq30l3tRMTGahColBAAubbLP6LaB3l3IFg,89
2
+ src/app.py,sha256=45r-M59vjJb1TXdkfSkTVJq1waLyNYVLxkafav5t6oI,16472
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.10.dist-info/METADATA,sha256=xG6XaXmXrLhDE1uo06tK1nXaSWYz2W8nA-Yx2-ZACHA,1717
7
+ tass-0.1.10.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ tass-0.1.10.dist-info/entry_points.txt,sha256=pviKuIOuHvaQ7_YiFxatJEY8XYfh3EzVWy4LJh0v-A0,38
9
+ tass-0.1.10.dist-info/licenses/LICENSE,sha256=Cdr-_YJHgGaf2vJjcoOsRJySkDaogUhu3yIDvpz7GEQ,1066
10
+ tass-0.1.10.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- src/__init__.py,sha256=tu2q9W5_pkq30l3tRMTGahColBAAubbLP6LaB3l3IFg,89
2
- src/app.py,sha256=_ukqGrJdaaaZJiGM_XLNL7tZr2SzI_A-zlLRJrRI52I,15346
3
- src/cli.py,sha256=op3fYcyfek_KqCCiA-Zdlc9jVZSCi036whMmR2ZjjAs,76
4
- src/constants.py,sha256=pzriopu167r3yOOcnC80sMjPKEZoDmYV8e8i5aK0rvM,4629
5
- src/utils.py,sha256=rKq34DVmFbsWPy7R6Bfdvv1ztzFLPT4hUd8BFpPHjqs,681
6
- tass-0.1.8.dist-info/METADATA,sha256=w9zw9A2ARBv09FQpL-I2lRpbUd7PSGpeT51mhYIEksU,1392
7
- tass-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
- tass-0.1.8.dist-info/entry_points.txt,sha256=pviKuIOuHvaQ7_YiFxatJEY8XYfh3EzVWy4LJh0v-A0,38
9
- tass-0.1.8.dist-info/licenses/LICENSE,sha256=Cdr-_YJHgGaf2vJjcoOsRJySkDaogUhu3yIDvpz7GEQ,1066
10
- tass-0.1.8.dist-info/RECORD,,
File without changes