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.
- agent/__init__.py +6 -0
- agent/agent.py +162 -0
- agent/events.py +133 -0
- agent/session.py +71 -0
- client/__init__.py +23 -0
- client/llm_client.py +236 -0
- client/response.py +83 -0
- config/__init__.py +6 -0
- config/config.py +76 -0
- config/credentials.py +54 -0
- config/loader.py +108 -0
- config/oauth.py +137 -0
- context/__init__.py +5 -0
- context/manager.py +92 -0
- main.py +231 -0
- prompts/__init__.py +0 -0
- prompts/system.py +350 -0
- relay_code-0.1.0.dist-info/METADATA +64 -0
- relay_code-0.1.0.dist-info/RECORD +50 -0
- relay_code-0.1.0.dist-info/WHEEL +5 -0
- relay_code-0.1.0.dist-info/entry_points.txt +2 -0
- relay_code-0.1.0.dist-info/licenses/LICENSE +674 -0
- relay_code-0.1.0.dist-info/top_level.txt +9 -0
- tools/__init__.py +14 -0
- tools/base.py +188 -0
- tools/core/__init__.py +44 -0
- tools/core/directories.py +65 -0
- tools/core/edit.py +162 -0
- tools/core/glob.py +92 -0
- tools/core/grep.py +128 -0
- tools/core/read.py +129 -0
- tools/core/shell.py +162 -0
- tools/core/todo.py +135 -0
- tools/core/write.py +76 -0
- tools/memory/__init__.py +0 -0
- tools/memory/memory.py +191 -0
- tools/network/__init__.py +0 -0
- tools/network/fetch.py +72 -0
- tools/network/search.py +72 -0
- tools/registry.py +96 -0
- ui/__init__.py +5 -0
- ui/app.py +949 -0
- ui/format.py +145 -0
- ui/logo.py +46 -0
- ui/renderer.py +580 -0
- ui/theme.py +60 -0
- utils/__init__.py +17 -0
- utils/errors.py +47 -0
- utils/paths.py +44 -0
- utils/text.py +74 -0
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
|
+
|
tools/memory/__init__.py
ADDED
|
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
|