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>