feat: add text editor plugin with file read, write, and patch capabilities
linuztx committed
Feb 27, 2026 at 09:58 UTC
1d2425cce1e0812b2b7fc1803b11471ecda7d42b
15 files changed
+633
plugins/text_editor/default_config.yaml
new
+3
@@ -0,0 +1,3 @@
1
+max_line_tokens: 500
2
+default_line_count: 100
3
+max_total_read_tokens: 4000
plugins/text_editor/extensions/.gitkeep
plugins/text_editor/extensions/python/system_prompt/_15_text_editor_prompt.py
new
+20
@@ -0,0 +1,20 @@
1
+from python.helpers.extension import Extension
2
+from python.helpers import plugins
3
+from agent import Agent, LoopData
4
+
5
+
6
+class TextEditorPrompt(Extension):
7
+
8
+ async def execute(
9
+ self,
10
+ system_prompt: list[str] = [],
11
+ loop_data: LoopData = LoopData(),
12
+ **kwargs,
13
+ ):
14
+ config = plugins.get_plugin_config("text_editor", agent=self.agent) or {}
15
+ default_line_count = config.get("default_line_count", 100)
16
+ prompt = self.agent.read_prompt(
17
+ "agent.system.tool.text_editor.md",
18
+ default_line_count=default_line_count,
19
+ )
20
+ system_prompt.append(prompt)
plugins/text_editor/helpers/__init__.py
plugins/text_editor/helpers/file_ops.py
new
+321
@@ -0,0 +1,321 @@
1
+"""
2
+Pure file operations for the text_editor plugin.
3
+
4
+No agent/tool dependencies — only stdlib + tokens helper.
5
+"""
6
+
7
+import os
8
+import shutil
9
+import tempfile
10
+from dataclasses import dataclass, field
11
+from typing import TYPE_CHECKING
12
+
13
+from python.helpers import plugins, tokens
14
+
15
+if TYPE_CHECKING:
16
+ from agent import Agent
17
+
18
+_BINARY_PEEK = 8192
19
+
20
+
21
+# ------------------------------------------------------------------
22
+# Config
23
+# ------------------------------------------------------------------
24
+
25
+def get_config(agent: "Agent") -> dict:
26
+ config = plugins.get_plugin_config("text_editor", agent=agent) or {}
27
+ return {
28
+ "max_line_tokens": int(config.get("max_line_tokens", 500)),
29
+ "default_line_count": int(config.get("default_line_count", 100)),
30
+ "max_total_read_tokens": int(config.get("max_total_read_tokens", 4000)),
31
+ }
32
+
33
+
34
+# ------------------------------------------------------------------
35
+# Binary detection
36
+# ------------------------------------------------------------------
37
+
38
+def is_binary(path: str) -> bool:
39
+ """Detect binary file by checking for null bytes."""
40
+ try:
41
+ with open(path, "rb") as f:
42
+ chunk = f.read(_BINARY_PEEK)
43
+ return b"\x00" in chunk
44
+ except OSError:
45
+ return False
46
+
47
+
48
+# ------------------------------------------------------------------
49
+# Read
50
+# ------------------------------------------------------------------
51
+
52
+@dataclass
53
+class ReadResult:
54
+ content: str = ""
55
+ total_lines: int = 0
56
+ warnings: str = ""
57
+ error: str = ""
58
+
59
+
60
+def read_file(
61
+ path: str,
62
+ line_from: int = 0,
63
+ line_to: int = 0,
64
+ max_line_tokens: int = 500,
65
+ default_line_count: int = 100,
66
+ max_total_read_tokens: int = 4000,
67
+) -> ReadResult:
68
+ """Read a text file and return numbered lines with token budgeting."""
69
+ path = os.path.expanduser(path)
70
+
71
+ if not os.path.isfile(path):
72
+ return ReadResult(error="file not found")
73
+
74
+ if is_binary(path):
75
+ return ReadResult(error="file appears binary, use terminal instead")
76
+
77
+ try:
78
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
79
+ all_lines = f.readlines()
80
+ except OSError as exc:
81
+ return ReadResult(error=str(exc))
82
+
83
+ total_lines = len(all_lines)
84
+ line_from = max(line_from, 0)
85
+ if not line_to:
86
+ line_to = min(line_from + default_line_count, total_lines)
87
+ line_to = min(line_to, total_lines)
88
+
89
+ selected = all_lines[line_from:line_to]
90
+
91
+ warn_parts: list[str] = []
92
+ cropped_lines: list[int] = []
93
+ output_lines: list[str] = []
94
+ running_tokens = 0
95
+ trimmed_by_total = False
96
+
97
+ for i, raw_line in enumerate(selected):
98
+ line_no = line_from + i
99
+ stripped = raw_line.rstrip("\n").rstrip("\r")
100
+ line_tok = tokens.count_tokens(stripped)
101
+
102
+ if line_tok > max_line_tokens:
103
+ chars_per_tok = max(len(stripped) / line_tok, 1)
104
+ keep_chars = int(max_line_tokens * chars_per_tok * tokens.TRIM_BUFFER)
105
+ stripped = stripped[:keep_chars] + "..."
106
+ cropped_lines.append(line_no)
107
+ line_tok = max_line_tokens
108
+
109
+ if running_tokens + line_tok > max_total_read_tokens:
110
+ trimmed_by_total = True
111
+ break
112
+
113
+ running_tokens += line_tok
114
+ output_lines.append(f"{line_no} {stripped}")
115
+
116
+ if cropped_lines:
117
+ nums = " ".join(str(n) for n in cropped_lines)
118
+ warn_parts.append(
119
+ f"long lines {nums} cropped - use terminal for precise manipulation"
120
+ )
121
+ if trimmed_by_total:
122
+ actual_end = line_from + len(output_lines)
123
+ warn_parts.append(
124
+ f"output trimmed at line {actual_end} due to token limit"
125
+ " - use line_from/line_to for remaining"
126
+ )
127
+
128
+ warn_str = ""
129
+ if warn_parts:
130
+ warn_str = "\nwarning: " + "; ".join(warn_parts)
131
+
132
+ return ReadResult(
133
+ content="\n".join(output_lines),
134
+ total_lines=total_lines,
135
+ warnings=warn_str,
136
+ )
137
+
138
+
139
+# ------------------------------------------------------------------
140
+# Write
141
+# ------------------------------------------------------------------
142
+
143
+@dataclass
144
+class WriteResult:
145
+ total_lines: int = 0
146
+ error: str = ""
147
+
148
+
149
+def write_file(path: str, content: str) -> WriteResult:
150
+ """Create or overwrite a file."""
151
+ path = os.path.expanduser(path)
152
+ try:
153
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
154
+ with open(path, "w", encoding="utf-8") as f:
155
+ f.write(content)
156
+ except OSError as exc:
157
+ return WriteResult(error=str(exc))
158
+
159
+ total = content.count("\n") + (
160
+ 1 if content and not content.endswith("\n") else 0
161
+ )
162
+ return WriteResult(total_lines=total)
163
+
164
+
165
+# ------------------------------------------------------------------
166
+# Patch
167
+# ------------------------------------------------------------------
168
+
169
+@dataclass
170
+class PatchResult:
171
+ total_lines: int = 0
172
+ edit_count: int = 0
173
+ error: str = ""
174
+
175
+
176
+def validate_edits(edits: list) -> tuple[list[dict], str]:
177
+ """
178
+ Normalise and validate an edits array.
179
+
180
+ Semantics (to is inclusive):
181
+ {from:2, to:2, content:"x\\n"} - replace line 2
182
+ {from:1, to:3, content:"x\\n"} - replace lines 1-3
183
+ {from:2, to:2} - delete line 2
184
+ {from:5} or {from:5, to:-1} - insert before line 5 (no deletion)
185
+
186
+ Returns (parsed_edits, error_string). error_string is empty on success.
187
+ """
188
+ if not edits or not isinstance(edits, list):
189
+ return [], "edits array is required"
190
+
191
+ parsed: list[dict] = []
192
+ for e in edits:
193
+ if not isinstance(e, dict):
194
+ return [], f"invalid edit entry: {e}"
195
+ frm = int(e.get("from", -1))
196
+ if frm < 0:
197
+ return [], f"edit missing from: {e}"
198
+ # to == -1 or absent means pure insert (no lines removed)
199
+ to = int(e.get("to", -1))
200
+ is_insert = to < 0 or to < frm
201
+ if is_insert:
202
+ to = frm - 1 # normalise: marks zero-width range
203
+ parsed.append({
204
+ "from": frm,
205
+ "to": to,
206
+ "content": e.get("content", ""),
207
+ "insert": is_insert,
208
+ })
209
+
210
+ parsed.sort(key=lambda x: (x["from"], 0 if x["insert"] else 1))
211
+ for i in range(1, len(parsed)):
212
+ prev, cur = parsed[i - 1], parsed[i]
213
+ # Inserts at the same line don't overlap with each other or
214
+ # with a replace that starts at the same line.
215
+ if prev["insert"]:
216
+ continue
217
+ # prev is a replace/delete: its range is [from..to] inclusive
218
+ if cur["from"] <= prev["to"]:
219
+ return [], (
220
+ f"overlapping edits: edit at {prev['from']}"
221
+ f" (to {prev['to']}) and {cur['from']}"
222
+ f" (to {cur['to']})"
223
+ )
224
+
225
+ return parsed, ""
226
+
227
+
228
+def apply_patch(path: str, edits: list[dict]) -> int:
229
+ """
230
+ Apply sorted, validated edits by streaming to a temp file.
231
+
232
+ Edits use inclusive 'to'. Inserts have 'insert': True.
233
+ Returns total line count after patching.
234
+ """
235
+ dir_name = os.path.dirname(path) or "."
236
+ fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
237
+ try:
238
+ with (
239
+ open(path, "r", encoding="utf-8", errors="replace") as src,
240
+ os.fdopen(fd, "w", encoding="utf-8") as dst,
241
+ ):
242
+ edit_idx = 0
243
+ line_no = 0
244
+ total_written = 0
245
+
246
+ for raw_line in src:
247
+ # Process all inserts targeting this line first
248
+ while (
249
+ edit_idx < len(edits)
250
+ and edits[edit_idx]["insert"]
251
+ and edits[edit_idx]["from"] == line_no
252
+ ):
253
+ edit = edits[edit_idx]
254
+ if edit["content"]:
255
+ dst.write(edit["content"])
256
+ total_written += _count_content_lines(edit["content"])
257
+ edit_idx += 1
258
+
259
+ # Check if current line falls in a replace/delete range
260
+ if edit_idx < len(edits) and not edits[edit_idx]["insert"]:
261
+ edit = edits[edit_idx]
262
+ if edit["from"] <= line_no <= edit["to"]:
263
+ # Write replacement content once at range start
264
+ if line_no == edit["from"] and edit["content"]:
265
+ dst.write(edit["content"])
266
+ total_written += _count_content_lines(
267
+ edit["content"]
268
+ )
269
+ # Skip original line; advance edit at range end
270
+ if line_no == edit["to"]:
271
+ edit_idx += 1
272
+ line_no += 1
273
+ continue
274
+
275
+ dst.write(raw_line)
276
+ total_written += 1
277
+ line_no += 1
278
+
279
+ # Remaining edits past end of file
280
+ while edit_idx < len(edits):
281
+ edit = edits[edit_idx]
282
+ if edit["content"]:
283
+ dst.write(edit["content"])
284
+ total_written += _count_content_lines(edit["content"])
285
+ edit_idx += 1
286
+
287
+ shutil.move(tmp_path, path)
288
+ return total_written
289
+ except Exception:
290
+ if os.path.exists(tmp_path):
291
+ os.unlink(tmp_path)
292
+ raise
293
+
294
+
295
+def patch_file(path: str, edits: list) -> PatchResult:
296
+ """Validate and apply edits to a file."""
297
+ path = os.path.expanduser(path)
298
+ if not os.path.isfile(path):
299
+ return PatchResult(error="file not found")
300
+
301
+ parsed, err = validate_edits(edits)
302
+ if err:
303
+ return PatchResult(error=err)
304
+
305
+ try:
306
+ total = apply_patch(path, parsed)
307
+ except Exception as exc:
308
+ return PatchResult(error=str(exc))
309
+
310
+ return PatchResult(total_lines=total, edit_count=len(parsed))
311
+
312
+
313
+# ------------------------------------------------------------------
314
+# Internal
315
+# ------------------------------------------------------------------
316
+
317
+def _count_content_lines(content: str) -> int:
318
+ return content.count("\n") + (
319
+ 1 if content and not content.endswith("\n") else 0
320
+ )
321
+
\ No newline at end of file
plugins/text_editor/plugin.yaml
new
+7
@@ -0,0 +1,7 @@
1
+name: Text Editor
2
+description: Native text file read, write and patch tools with line numbers.
3
+version: 1.0.0
4
+settings_sections:
5
+ - agent
6
+per_project_config: false
7
+per_agent_config: false
plugins/text_editor/prompts/agent.system.tool.text_editor.md
new
+70
@@ -0,0 +1,70 @@
1
+### text_editor
2
+native file read write patch with line numbers
3
+no code execution creating viewing editing text files
4
+terminal (grep find sed) search advanced replacements
5
+
6
+#### text_editor:read
7
+read file numbered lines
8
+args path line_from (inclusive) line_to (inclusive) both optional
9
+defaults first {{default_line_count}} lines if no range
10
+usage:
11
+~~~json
12
+{
13
+ "thoughts": [
14
+ "..."
15
+ ],
16
+ "headline": "...",
17
+ "tool_name": "text_editor:read",
18
+ "tool_args": {
19
+ "path": "/path/file.py",
20
+ "line_from": 1,
21
+ "line_to": 50
22
+ }
23
+}
24
+~~~
25
+
26
+#### text_editor:write
27
+create overwrite entire file
28
+args path content
29
+usage:
30
+~~~json
31
+{
32
+ "thoughts": [
33
+ "..."
34
+ ],
35
+ "headline": "...",
36
+ "tool_name": "text_editor:write",
37
+ "tool_args": {
38
+ "path": "/path/file.py",
39
+ "content": "import os\nprint('hello')\n"
40
+ }
41
+}
42
+~~~
43
+
44
+#### text_editor:patch
45
+apply line edits existing file
46
+args path edits (array of {from, to, content})
47
+from and to are inclusive line numbers
48
+{from:2, to:2, content:"x\n"} replace line 2
49
+{from:1, to:3, content:"x\n"} replace lines 1-3
50
+{from:2, to:2} delete line 2 (no content = delete)
51
+{from:2, content:"x\n"} insert before line 2 (omit to = insert)
52
+always original line numbers from read output dont adjust shifts
53
+edits must not overlap
54
+usage:
55
+~~~json
56
+{
57
+ "thoughts": [
58
+ "..."
59
+ ],
60
+ "headline": "...",
61
+ "tool_name": "text_editor:patch",
62
+ "tool_args": {
63
+ "path": "/path/file.py",
64
+ "edits": [
65
+ {"from": 1, "content": "import sys\n"},
66
+ {"from": 5, "to": 5, "content": " if x == 2:\n"}
67
+ ]
68
+ }
69
+}
70
+~~~
plugins/text_editor/prompts/fw.text_editor.patch_error.md
new
+1
@@ -0,0 +1 @@
1
+error patching {{path}}: {{error}}
plugins/text_editor/prompts/fw.text_editor.patch_ok.md
new
+1
@@ -0,0 +1 @@
1
+{{path}} patched {{edit_count}} edits applied, {{total_lines}} lines now
plugins/text_editor/prompts/fw.text_editor.read_error.md
new
+1
@@ -0,0 +1 @@
1
+error reading {{path}}: {{error}}
plugins/text_editor/prompts/fw.text_editor.read_ok.md
new
+4
@@ -0,0 +1,4 @@
1
+{{path}} {{total_lines}} lines{{warnings}}
2
+>>>
3
+{{content}}
4
+<<<
plugins/text_editor/prompts/fw.text_editor.write_error.md
new
+1
@@ -0,0 +1 @@
1
+error writing {{path}}: {{error}}
plugins/text_editor/prompts/fw.text_editor.write_ok.md
new
+1
@@ -0,0 +1 @@
1
+{{path}} written {{total_lines}} lines
plugins/text_editor/tools/text_editor.py
new
+145
@@ -0,0 +1,145 @@
1
+import os
2
+
3
+from python.helpers.tool import Tool, Response
4
+from python.helpers.extension import call_extensions
5
+from plugins.text_editor.helpers.file_ops import (
6
+ get_config,
7
+ read_file,
8
+ write_file,
9
+ validate_edits,
10
+ apply_patch,
11
+)
12
+
13
+
14
+class TextEditor(Tool):
15
+
16
+ async def execute(self, **kwargs):
17
+ if self.method == "read":
18
+ return await self._read(**kwargs)
19
+ elif self.method == "write":
20
+ return await self._write(**kwargs)
21
+ elif self.method == "patch":
22
+ return await self._patch(**kwargs)
23
+ return Response(
24
+ message=f"unknown method '{self.name}:{self.method}'",
25
+ break_loop=False,
26
+ )
27
+
28
+ # ------------------------------------------------------------------
29
+ # READ
30
+ # ------------------------------------------------------------------
31
+ async def _read(self, path: str = "", **kwargs) -> Response:
32
+ if not path:
33
+ return self._error("read", path, "path is required")
34
+
35
+ cfg = get_config(self.agent)
36
+ line_from = int(kwargs.get("line_from", 0))
37
+ line_to = int(kwargs.get("line_to", 0))
38
+
39
+ result = read_file(
40
+ path,
41
+ line_from=line_from,
42
+ line_to=line_to,
43
+ max_line_tokens=cfg["max_line_tokens"],
44
+ default_line_count=cfg["default_line_count"],
45
+ max_total_read_tokens=cfg["max_total_read_tokens"],
46
+ )
47
+
48
+ if result.error:
49
+ return self._error("read", path, result.error)
50
+
51
+ # Extension point
52
+ ext_data = {"content": result.content, "warnings": result.warnings}
53
+ await call_extensions(
54
+ "text_editor_read_after", agent=self.agent, data=ext_data
55
+ )
56
+
57
+ msg = self.agent.read_prompt(
58
+ "fw.text_editor.read_ok.md",
59
+ path=os.path.expanduser(path),
60
+ total_lines=str(result.total_lines),
61
+ warnings=ext_data["warnings"],
62
+ content=ext_data["content"],
63
+ )
64
+ return Response(message=msg, break_loop=False)
65
+
66
+ # ------------------------------------------------------------------
67
+ # WRITE
68
+ # ------------------------------------------------------------------
69
+ async def _write(self, path: str = "", content: str = "", **kwargs) -> Response:
70
+ if not path:
71
+ return self._error("write", path, "path is required")
72
+
73
+ # Extension point
74
+ ext_data = {"path": path, "content": content}
75
+ await call_extensions(
76
+ "text_editor_write_before", agent=self.agent, data=ext_data
77
+ )
78
+
79
+ result = write_file(ext_data["path"], ext_data["content"])
80
+
81
+ if result.error:
82
+ return self._error("write", path, result.error)
83
+
84
+ # Extension point
85
+ await call_extensions(
86
+ "text_editor_write_after", agent=self.agent,
87
+ data={"path": path, "total_lines": result.total_lines},
88
+ )
89
+
90
+ msg = self.agent.read_prompt(
91
+ "fw.text_editor.write_ok.md",
92
+ path=os.path.expanduser(path),
93
+ total_lines=str(result.total_lines),
94
+ )
95
+ return Response(message=msg, break_loop=False)
96
+
97
+ # ------------------------------------------------------------------
98
+ # PATCH
99
+ # ------------------------------------------------------------------
100
+ async def _patch(self, path: str = "", edits=None, **kwargs) -> Response:
101
+ if not path:
102
+ return self._error("patch", path, "path is required")
103
+
104
+ expanded = os.path.expanduser(path)
105
+ if not os.path.isfile(expanded):
106
+ return self._error("patch", path, "file not found")
107
+
108
+ parsed, err = validate_edits(edits)
109
+ if err:
110
+ return self._error("patch", path, err)
111
+
112
+ # Extension point
113
+ ext_data = {"path": expanded, "edits": parsed}
114
+ await call_extensions(
115
+ "text_editor_patch_before", agent=self.agent, data=ext_data
116
+ )
117
+
118
+ try:
119
+ total_lines = apply_patch(ext_data["path"], ext_data["edits"])
120
+ except Exception as exc:
121
+ return self._error("patch", path, str(exc))
122
+
123
+ # Extension point
124
+ await call_extensions(
125
+ "text_editor_patch_after", agent=self.agent,
126
+ data={"path": expanded, "total_lines": total_lines},
127
+ )
128
+
129
+ msg = self.agent.read_prompt(
130
+ "fw.text_editor.patch_ok.md",
131
+ path=expanded,
132
+ edit_count=str(len(ext_data["edits"])),
133
+ total_lines=str(total_lines),
134
+ )
135
+ return Response(message=msg, break_loop=False)
136
+
137
+ # ------------------------------------------------------------------
138
+ # Shared error helper
139
+ # ------------------------------------------------------------------
140
+ def _error(self, action: str, path: str, error: str) -> Response:
141
+ msg = self.agent.read_prompt(
142
+ f"fw.text_editor.{action}_error.md", path=path, error=error
143
+ )
144
+ return Response(message=msg, break_loop=False)
145
+
\ No newline at end of file
plugins/text_editor/webui/config.html
new
+58
@@ -0,0 +1,58 @@
1
+<html>
2
+<head>
3
+ <title>Text Editor</title>
4
+</head>
5
+
6
+<body>
7
+ <div x-data>
8
+ <template x-if="$store.pluginSettings.settings">
9
+ <div>
10
+ <div class="section-title">Text Editor</div>
11
+ <div class="section-description">
12
+ Settings for the native text file read/write/patch tools.
13
+ </div>
14
+
15
+ <div class="field">
16
+ <div class="field-label">
17
+ <div class="field-title">Max line tokens</div>
18
+ <div class="field-description">
19
+ Lines exceeding this token count are cropped in read output.
20
+ </div>
21
+ </div>
22
+ <div class="field-control">
23
+ <input type="number" min="50" max="5000"
24
+ x-model.number="$store.pluginSettings.settings.max_line_tokens" />
25
+ </div>
26
+ </div>
27
+
28
+ <div class="field">
29
+ <div class="field-label">
30
+ <div class="field-title">Default line count</div>
31
+ <div class="field-description">
32
+ Number of lines returned by read when no range is specified.
33
+ </div>
34
+ </div>
35
+ <div class="field-control">
36
+ <input type="number" min="10" max="1000"
37
+ x-model.number="$store.pluginSettings.settings.default_line_count" />
38
+ </div>
39
+ </div>
40
+
41
+ <div class="field">
42
+ <div class="field-label">
43
+ <div class="field-title">Max total read tokens</div>
44
+ <div class="field-description">
45
+ Total token budget for a single read operation output.
46
+ </div>
47
+ </div>
48
+ <div class="field-control">
49
+ <input type="number" min="500" max="50000"
50
+ x-model.number="$store.pluginSettings.settings.max_total_read_tokens" />
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </template>
55
+ </div>
56
+</body>
57
+
58
+</html>