relay-code 0.1.0__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.
tools/core/read.py ADDED
@@ -0,0 +1,129 @@
1
+ from pydantic import BaseModel, Field
2
+ from utils.paths import resolve_path, is_binary_file
3
+ from utils.text import count_tokens, truncate_text
4
+ from tools.base import Tool, ToolKind, ToolInvocation, ToolResult
5
+
6
+ class ReadFileParams(BaseModel):
7
+
8
+ path: str = Field(
9
+ ...,
10
+ description='Path to the file to read (relative to the working dir or abs path)'
11
+ )
12
+
13
+ offset: int = Field(
14
+ 1,
15
+ ge=1,
16
+ description='1-indexed line number to start reading from. Defaults to 1 (start of file). Minimum value is 1.'
17
+ )
18
+
19
+ limit: int | None = Field(
20
+ None,
21
+ ge=1,
22
+ description='Maximum number of lines to read starting from offset. If omitted, reads to the end of the file.'
23
+ )
24
+
25
+ class ReadFileTool(Tool):
26
+ name = 'read'
27
+ description = (
28
+ "Read the contents of a file from the filesystem and return it as text with line numbers prefixed "
29
+ "(e.g. '1: import os'). If 'offset' exceeds the file's line count, returns an empty result. "
30
+ "Fails if the file does not exist, is a directory, or cannot be read (e.g. binary/permission errors). "
31
+ "Cannot read binary files (images, executables, etc.). "
32
+ "After reading the contents of the file, present the line numbers."
33
+ )
34
+ kind = ToolKind.READ
35
+
36
+ schema = ReadFileParams
37
+
38
+ MAX_FILE_SIZE = 1024 * 1024 * 10
39
+ MAX_OUTPUT_TOKENS = 25000
40
+
41
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
42
+ params = ReadFileParams(**invocation.params)
43
+ path = resolve_path(invocation.cwd, params.path)
44
+
45
+ if not path.exists():
46
+ return ToolResult.error_result(f"File not found at: {path}")
47
+
48
+ if not path.is_file():
49
+ return ToolResult.error_result(f"Path is not a file: {path}")
50
+
51
+ file_size = path.stat().st_size
52
+
53
+ if file_size > self.MAX_FILE_SIZE:
54
+ return ToolResult.error_result(
55
+ f"File too large ({file_size / (1024 * 1024):.1f}MB). "
56
+ f"The maximum file size is: {self.MAX_FILE_SIZE / (1024 * 1024):.0f}MB."
57
+ )
58
+
59
+ if is_binary_file(path):
60
+ file_size_mb = file_size / (1024 * 1024)
61
+ size_str = f"{file_size_mb:.2f}MB" if file_size_mb >= 1 else f"{file_size} bytes"
62
+ return ToolResult.error_result(
63
+ f"Cannot read binary file: {path.name} ({size_str}). "
64
+ f"This tool only reads text files."
65
+ )
66
+ try:
67
+ try:
68
+ content = path.read_text(encoding='utf-8')
69
+ except UnicodeDecodeError:
70
+ content = path.read_text(encoding='latin-1')
71
+
72
+ lines = content.splitlines()
73
+ total_lines = len(lines)
74
+
75
+ if total_lines == 0:
76
+ return ToolResult.success_result(
77
+ "File is empty.", metadata={
78
+ "lines": 0
79
+ }
80
+ )
81
+
82
+ start_index = max(0, params.offset - 1)
83
+
84
+ if params.limit is not None:
85
+ end_index = min(start_index + params.limit, total_lines)
86
+ else:
87
+ end_index = total_lines
88
+
89
+ selected_lines = lines[start_index:end_index]
90
+ formatted_lines = []
91
+
92
+ for i, line in enumerate(selected_lines, start=start_index):
93
+ formatted_lines.append(f"{i + 1:6}|{line}")
94
+
95
+ output = "\n".join(formatted_lines)
96
+ token_count = count_tokens(output)
97
+
98
+ truncated = False
99
+ if token_count > self.MAX_OUTPUT_TOKENS:
100
+ output = truncate_text(
101
+ output,
102
+ "",
103
+ self.MAX_OUTPUT_TOKENS,
104
+ suffix=f"\n... [truncated] {total_lines} total lines."
105
+ )
106
+ truncated = True
107
+
108
+ metadata_lines = []
109
+ if start_index > 0 or end_index < total_lines:
110
+ metadata_lines.append(
111
+ f"Showing lines {start_index + 1}-{end_index} of {total_lines}"
112
+ )
113
+
114
+ if metadata_lines:
115
+ header = ' | '.join(metadata_lines) + "\n\n"
116
+ output = header + output
117
+
118
+ return ToolResult.success_result(
119
+ output=output,
120
+ truncated=truncated,
121
+ metadata={
122
+ 'path': str(path),
123
+ 'total_lines': total_lines,
124
+ 'shown_start': start_index + 1,
125
+ 'shown_end': end_index,
126
+ },
127
+ )
128
+ except Exception as e:
129
+ return ToolResult.error_result(f"Error: failed to read file: {e}")
tools/core/shell.py ADDED
@@ -0,0 +1,162 @@
1
+ import asyncio
2
+ import os
3
+ import fnmatch
4
+ from pathlib import Path
5
+ import signal
6
+ import sys
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from tools.base import Tool, ToolInvocation, ToolKind, ToolResult
11
+
12
+ DANGEROUS_COMMANDS = {
13
+ "rm -rf /",
14
+ "rm -rf ~",
15
+ "rm -rf /*",
16
+ "dd if=/dev/zero",
17
+ "dd if=/dev/random",
18
+ "mkfs",
19
+ "fdisk",
20
+ "parted",
21
+ ":(){ :|:& };:",
22
+ "chmod 777 /",
23
+ "chmod -R 777",
24
+ "shutdown",
25
+ "reboot",
26
+ "halt",
27
+ "poweroff",
28
+ "init 0",
29
+ "init 6",
30
+ "sleep",
31
+ }
32
+
33
+ class ShellParams(BaseModel):
34
+
35
+ command: str = Field(
36
+ ...,
37
+ description='The shell command used to execute system commands',
38
+ )
39
+
40
+ timeout: int = Field(
41
+ 120,
42
+ ge=1,
43
+ le=600,
44
+ description='Timeout in seconds (defaulted: 120s)'
45
+ )
46
+
47
+ cwd: str | None = Field(
48
+ None,
49
+ description='The working directory for the command to be ran'
50
+ )
51
+
52
+ class ShellTool(Tool):
53
+
54
+ name = 'shell'
55
+ kind = ToolKind.SHELL
56
+ description = "Execute a shell command. Use this for running system wide commands, scripts and usage of CLI Tools."
57
+
58
+ schema = ShellParams
59
+
60
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
61
+ params = ShellParams(**invocation.params)
62
+
63
+ command = params.command.lower().strip()
64
+ for dangerous in DANGEROUS_COMMANDS:
65
+ if dangerous in command:
66
+ return ToolResult.error(
67
+ f'Command blocked: {params.command}',
68
+ metadata = {
69
+ 'blocked': True
70
+ }
71
+ )
72
+
73
+ if params.cwd:
74
+ cwd = Path(params.cwd)
75
+ if not cwd.is_absolute():
76
+ cwd = invocation.cwd / cwd
77
+ else:
78
+ cwd = invocation.cwd
79
+
80
+ if not cwd.exists():
81
+ return ToolResult.error_result(
82
+ f"Working directory doesn't exist: {cwd}"
83
+ )
84
+
85
+ env = self._build_environment()
86
+ if sys.platform == 'win32':
87
+ shell_cmd = ['cmd.exe', '/c', params.command]
88
+ else:
89
+ shell_cmd = ['/bin/bash', '-c', params.command]
90
+
91
+ process = await asyncio.create_subprocess_exec(
92
+ *shell_cmd,
93
+ stdout=asyncio.subprocess.PIPE,
94
+ stderr=asyncio.subprocess.PIPE,
95
+ cwd=cwd,
96
+ env=env,
97
+ start_new_session=True,
98
+ )
99
+ try:
100
+ stdout_data, stderr_data = await asyncio.wait_for(
101
+ process.communicate(),
102
+ timeout = params.timeout
103
+ )
104
+ except asyncio.TimeoutError:
105
+ if sys.platform != 'win32':
106
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
107
+ else:
108
+ process.kill()
109
+ await process.wait()
110
+ return ToolResult.error_result(
111
+ f'Command timed out after: {params.timeout}'
112
+ )
113
+
114
+ exit_code = process.returncode
115
+
116
+ stdout = stdout_data.decode(
117
+ 'utf-8',
118
+ errors='replace'
119
+ )
120
+
121
+ stderr = stderr_data.decode(
122
+ 'utf-8',
123
+ errors='replace'
124
+ )
125
+
126
+ output = ""
127
+ if stdout.strip():
128
+ output += stdout.rstrip()
129
+
130
+ if stderr.strip():
131
+ output += "\n--- stderr ---\n"
132
+ output += stderr.rstrip()
133
+
134
+ if exit_code != 0:
135
+ output += f'\nExit code: {exit_code}'
136
+
137
+ if len(output) > 100*1024:
138
+ output = output[: 100 * 1024] + "\n.. [output truncated]"
139
+
140
+ return ToolResult(
141
+ success=exit_code==0,
142
+ output=output,
143
+ error=stderr if exit_code != 0 else None,
144
+ exit_code=exit_code,
145
+ )
146
+
147
+ def _build_environment(self) -> dict[str, str]:
148
+ env = os.environ.copy()
149
+
150
+ shell_environment = self.config.shell_environment
151
+
152
+ if not shell_environment.ignore_default_excludes:
153
+ for pattern in shell_environment.exclude_patterns:
154
+ keys_to_remove = [k for k in env.keys() if fnmatch.fnmatch(k.upper(), pattern.upper())]
155
+
156
+ for k in keys_to_remove:
157
+ del env[k]
158
+
159
+ if shell_environment.set_vars:
160
+ env.update(shell_environment.set_vars)
161
+
162
+ return env
tools/core/todo.py ADDED
@@ -0,0 +1,135 @@
1
+ import uuid
2
+ from typing import Any
3
+
4
+ from config.config import Config
5
+ from tools.base import Tool, ToolInvocation, ToolKind, ToolResult
6
+ from pydantic import BaseModel, Field
7
+
8
+ PENDING = 'pending'
9
+ COMPLETED = 'completed'
10
+
11
+
12
+ class TodoParams(BaseModel):
13
+
14
+ action: str = Field(
15
+ ...,
16
+ description="Action: 'add', 'complete', 'list', 'clear'"
17
+ )
18
+
19
+ id: str | None = Field(
20
+ None,
21
+ description="A Todo ID (required for the 'complete' action)",
22
+ )
23
+
24
+ content: str | None = Field(
25
+ None,
26
+ description="The content for the 'add' action",
27
+ )
28
+
29
+
30
+ class TodoTool(Tool):
31
+ name = 'todo'
32
+ description = 'Manage a task list for the current session, use this to track progress on more complex and multi-step tasks that can get resourse intensive.'
33
+ kind = ToolKind.MEMORY
34
+ schema = TodoParams
35
+
36
+ def __init__(self, config: Config) -> None:
37
+ super().__init__(config)
38
+ self._todos: dict[str, str] = {}
39
+ self._status: dict[str, str] = {}
40
+
41
+ def is_mutating(self, params: dict[str, Any]) -> bool:
42
+ return False
43
+
44
+ def _snapshot(self) -> dict[str, Any]:
45
+ todos = [
46
+ {'id': todo_id, 'content': content, 'status': self._status[todo_id]}
47
+ for todo_id, content in self._todos.items()
48
+ ]
49
+ return {
50
+ 'todos': todos,
51
+ 'completed': sum(1 for todo in todos if todo['status'] == COMPLETED),
52
+ 'total': len(todos),
53
+ }
54
+
55
+ def _listing(self) -> str:
56
+ if not self._todos:
57
+ return "No todos."
58
+
59
+ snapshot = self._snapshot()
60
+ lines = [f"Todos ({snapshot['completed']}/{snapshot['total']} completed):"]
61
+
62
+ for todo in snapshot['todos']:
63
+ marker = 'x' if todo['status'] == COMPLETED else ' '
64
+ lines.append(f" [{marker}] [{todo['id']}] {todo['content']}")
65
+
66
+ return "\n".join(lines)
67
+
68
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
69
+
70
+ params = TodoParams(**invocation.params)
71
+
72
+ try:
73
+ action = params.action.lower()
74
+
75
+ if action == 'add':
76
+ if not params.content:
77
+ return ToolResult.error_result(
78
+ "Content is required for the 'add' action."
79
+ )
80
+
81
+ todo_id = str(uuid.uuid4())[:8]
82
+
83
+ self._todos[todo_id] = params.content
84
+ self._status[todo_id] = PENDING
85
+
86
+ return ToolResult.success_result(
87
+ f"Added todo [{todo_id}]: {params.content}\n\n{self._listing()}",
88
+ metadata=self._snapshot(),
89
+ )
90
+
91
+ elif action == 'complete':
92
+ if not params.id:
93
+ return ToolResult.error_result(
94
+ "An id is required for the 'complete' action."
95
+ )
96
+ if params.id not in self._todos:
97
+ return ToolResult.error_result(
98
+ f"Todo not found: {params.id}"
99
+ )
100
+
101
+ self._status[params.id] = COMPLETED
102
+ content = self._todos[params.id]
103
+
104
+ return ToolResult.success_result(
105
+ f"Completed todo [{params.id}]: {content}\n\n{self._listing()}",
106
+ metadata=self._snapshot(),
107
+ )
108
+
109
+ elif action == 'list':
110
+ return ToolResult.success_result(
111
+ self._listing(),
112
+ metadata=self._snapshot(),
113
+ )
114
+
115
+ elif action == 'clear':
116
+ count = len(self._todos)
117
+
118
+ self._todos.clear()
119
+ self._status.clear()
120
+
121
+ return ToolResult.success_result(
122
+ f"Cleared {count} todo(s).",
123
+ metadata=self._snapshot(),
124
+ )
125
+
126
+ else:
127
+ return ToolResult.error_result(
128
+ f"Unknown action: {params.action}"
129
+ )
130
+
131
+ except Exception as e:
132
+
133
+ return ToolResult.error_result(
134
+ f"Unknown error: {e}"
135
+ )
tools/core/write.py ADDED
@@ -0,0 +1,76 @@
1
+ from pathlib import Path
2
+ from pydantic import BaseModel, Field
3
+ from tools.base import FileDiff, Tool, ToolInvocation, ToolKind, ToolResult
4
+ from utils.paths import ensure_parent_dir, resolve_path
5
+
6
+ class WriteFileParams(BaseModel):
7
+ path: str = Field(
8
+ ...,
9
+ description='Path to the file to write, relative to the working or absolute path.'
10
+ )
11
+ content: str = Field(
12
+ ...,
13
+ description="Content to write to a file."
14
+ )
15
+ create_directories: bool = Field(
16
+ True,
17
+ description='Create parent directories if they are yet to exist.'
18
+ )
19
+
20
+ class WriteFileTool(Tool):
21
+ name = 'write'
22
+ description = (
23
+ "Write contents to a file, creates a file if it doesn't exist, "
24
+ "or overwrites the file if it does. Parent directories are created automatically."
25
+ "Use this tool for creating new files or replacing file contents."
26
+ "For partial or smaller modifications, use the edit tool instead."
27
+ )
28
+ kind = ToolKind.WRITE
29
+ schema = WriteFileParams
30
+
31
+ async def execute(self, invocation: ToolInvocation) -> ToolResult:
32
+ params = WriteFileParams(**invocation.params)
33
+ path = resolve_path(invocation.cwd, params.path)
34
+
35
+ is_new_file = not path.exists()
36
+ old_content = ""
37
+
38
+ if not is_new_file:
39
+ try:
40
+ old_content = path.read_text(
41
+ encoding='utf-8'
42
+ )
43
+ except:
44
+ pass
45
+
46
+ try:
47
+ if params.create_directories:
48
+ ensure_parent_dir(path)
49
+ elif not path.parent.exists():
50
+ return ToolResult.error_result(f'Parent directory does not exist: {path.parent}')
51
+ path.write_text(params.content, encoding='utf-8')
52
+
53
+ action = "Created" if is_new_file else 'Updated'
54
+ line_count = len(params.content.splitlines())
55
+
56
+ return ToolResult.success_result(
57
+ f"{action} {path} {line_count} lines",
58
+ diff=FileDiff(
59
+ path=path,
60
+ old_content=old_content,
61
+ new_content=params.content,
62
+ is_new_file=is_new_file
63
+ ),
64
+ metadata = {
65
+ 'path': str(path),
66
+ 'is_new_file': is_new_file,
67
+ 'lines': line_count,
68
+ 'bytes': len(params.content.encode(
69
+ 'utf-8'
70
+ ))
71
+ }
72
+ )
73
+
74
+ except OSError as e:
75
+ return ToolResult.error_result(f"Failed to write file: {e}")
76
+
File without changes
tools/memory/memory.py ADDED
@@ -0,0 +1,191 @@
1
+ import json
2
+ import uuid
3
+ from typing import Any
4
+
5
+ from config.config import Config
6
+ from config.loader import get_data_dir
7
+ from tools.base import Tool, ToolInvocation, ToolKind, ToolResult
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class MemoryParams(BaseModel):
12
+
13
+ action: str = Field(
14
+ ...,
15
+ description="Action: 'set', 'get', 'delete', 'list', 'clear'"
16
+ )
17
+
18
+ key: str | None = Field(
19
+ None,
20
+ description="Memory key: required for set, get, delete.",
21
+ )
22
+
23
+ value: str | None = Field(
24
+ None,
25
+ description="Value to store (required for set)",
26
+ )
27
+
28
+
29
+ class MemoryTool(Tool):
30
+ name = 'memory'
31
+ description = 'Store and retrieve persistent memory, use this tool to remember user information and preferences, for example: important context, user-specific details.'
32
+ kind = ToolKind.MEMORY
33
+ schema = MemoryParams
34
+
35
+ def _load_memory(self) -> dict:
36
+
37
+ data_dir = get_data_dir()
38
+
39
+ data_dir.mkdir(
40
+ parents=True,
41
+ exist_ok=True
42
+ )
43
+
44
+ path = data_dir / 'user_memory.json'
45
+
46
+ if not path.exists():
47
+ return {
48
+ 'entries': {}
49
+ }
50
+
51
+ try:
52
+ content = path.read_text(
53
+ encoding='utf-8'
54
+ )
55
+ return json.loads(content)
56
+ except Exception:
57
+ return {
58
+ "entries": {}
59
+ }
60
+
61
+ def _save_memory(self, memory: dict) -> None:
62
+
63
+ data_dir = get_data_dir()
64
+
65
+ data_dir.mkdir(
66
+ parents=True,
67
+ exist_ok=True
68
+ )
69
+
70
+ path = data_dir / 'user_memory.json'
71
+
72
+ path.write_text(json.dumps(
73
+ memory,
74
+ indent=2,
75
+ ensure_ascii=False
76
+ )
77
+ )
78
+
79
+ def _snapshot(
80
+ self,
81
+ memory: dict,
82
+ action: str,
83
+ key: str | None = None,
84
+ ) -> dict[str, Any]:
85
+ entries = memory.get('entries', {})
86
+ return {
87
+ 'entries': [
88
+ {'key': k, 'value': v}
89
+ for k, v in sorted(entries.items())
90
+ ],
91
+ 'count': len(entries),
92
+ 'action': action,
93
+ 'active_key': key,
94
+ }
95
+
96
+ async def execute(self, invocation: ToolInvocation):
97
+
98
+ params = MemoryParams(**invocation.params)
99
+
100
+ try:
101
+ if params.action.lower() == "set":
102
+ if not params.key or not params.value:
103
+ return ToolResult.error_result(
104
+ "Key and value are required for set action."
105
+ )
106
+ memory = self._load_memory()
107
+ memory['entries'][params.key] = params.value
108
+ self._save_memory(memory)
109
+
110
+ return ToolResult.success_result(
111
+ f"Set memory: {params.key}",
112
+ metadata=self._snapshot(memory, "set", params.key),
113
+ )
114
+
115
+ elif params.action.lower() == "get":
116
+ if not params.key:
117
+ return ToolResult.error_result(
118
+ "Key required for: 'get' action"
119
+ )
120
+
121
+ memory = self._load_memory()
122
+ if params.key not in memory.get('entries', {}):
123
+ return ToolResult.success_result(
124
+ f"Memory not found: {params.key}",
125
+ metadata=self._snapshot(memory, "get", params.key),
126
+ )
127
+ return ToolResult.success_result(
128
+ f"Memory found: {params.key}: {memory['entries'][params.key]}",
129
+ metadata=self._snapshot(memory, "get", params.key),
130
+ )
131
+
132
+ elif params.action == "delete":
133
+ if not params.key:
134
+ return ToolResult.error_result(
135
+ "Key required for: 'delete' action"
136
+ )
137
+ memory = self._load_memory()
138
+ if params.key not in memory.get('entries', {}):
139
+ return ToolResult.success_result(
140
+ f"Memory not found: {params.key}"
141
+ )
142
+
143
+ del memory['entries'][params.key]
144
+ self._save_memory(memory)
145
+
146
+ return ToolResult.success_result(
147
+ f"Deleted memory: {params.key}",
148
+ metadata=self._snapshot(memory, "delete", params.key),
149
+ )
150
+
151
+ elif params.action == "list":
152
+
153
+ memory = self._load_memory()
154
+ entries = memory.get('entries', {})
155
+
156
+ if not entries:
157
+ return ToolResult.success_result(
158
+ "No memory has been stored.",
159
+ metadata=self._snapshot(memory, "list"),
160
+ )
161
+
162
+ lines = [f"Stored memories:"]
163
+ for key, value in sorted(entries.items()):
164
+ lines.append(f" {key}: {value}")
165
+
166
+ return ToolResult.success_result(
167
+ "\n".join(lines),
168
+ metadata=self._snapshot(memory, "list"),
169
+ )
170
+
171
+ elif params.action == "clear":
172
+
173
+ memory = self._load_memory()
174
+ count = len(memory.get('entries', {}))
175
+ memory['entries'] = {}
176
+ self._save_memory(memory)
177
+ return ToolResult.success_result(
178
+ f"Cleared {count} entries",
179
+ metadata=self._snapshot(memory, "clear"),
180
+ )
181
+
182
+ else:
183
+
184
+ return ToolResult.error_result(
185
+ f"Unknown action: {params.action}"
186
+ )
187
+
188
+ except Exception as e:
189
+ return ToolResult.error_result(
190
+ f"Something went wrong: {e}"
191
+ )
File without changes