semora-coding 0.2.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.
@@ -0,0 +1,257 @@
1
+ """Workspace-backed read, write, and edit built-ins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from pathlib import PurePath
8
+ from typing import Any
9
+
10
+ from semora.workspace import ToolContext, WorkspaceViolation
11
+
12
+ from ._types import BuiltinToolState, ToolResult, error_result, require_workspace, text_result
13
+
14
+ MAX_LINES = 2_000
15
+ MAX_BYTES = 8 * 1024 * 1024
16
+ NOTEBOOK_OUTPUT_LIMIT = 10 * 1024
17
+ IMAGE_MIME_BY_EXT = {
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".webp": "image/webp",
22
+ ".gif": "image/gif",
23
+ }
24
+
25
+
26
+ async def read_tool(
27
+ _call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
28
+ ) -> ToolResult:
29
+ """Read a text file, image, notebook, or directory through ``WorkspaceFS``.
30
+
31
+ Ports ``createReadTool().execute`` and its ``numberLines`` expression.
32
+ """
33
+ params = arguments if isinstance(arguments, dict) else {}
34
+ raw_path = params.get("path")
35
+ path = raw_path.strip() if isinstance(raw_path, str) else ""
36
+ if not path:
37
+ return error_result("path is required")
38
+ workspace = require_workspace(context)
39
+ if workspace is None:
40
+ return error_result(
41
+ "read requires an active workspace; configure AgentRuntime(workspace_provider=...) "
42
+ "or pass a ToolContext to builtin_tools()"
43
+ )
44
+
45
+ try:
46
+ stat = await workspace.fs.stat(path)
47
+ except FileNotFoundError:
48
+ return await _not_found(path, context)
49
+ except (OSError, ValueError, WorkspaceViolation) as error:
50
+ return error_result(f"Cannot read: {error}")
51
+
52
+ if stat.is_directory:
53
+ try:
54
+ entries = await workspace.fs.readdir(path)
55
+ except (OSError, ValueError, WorkspaceViolation) as error:
56
+ return error_result(f"Cannot read directory: {error}")
57
+ lines = sorted(entry.name + ("/" if entry.is_directory else "") for entry in entries)
58
+ return text_result(f"Directory: {path}\n\n" + "\n".join(lines))
59
+ if not stat.is_file:
60
+ return error_result(f"Cannot read: {path} is not a regular file")
61
+
62
+ suffix = PurePath(path).suffix.lower()
63
+ try:
64
+ data = await workspace.fs.read_file(path)
65
+ except (OSError, ValueError, WorkspaceViolation) as error:
66
+ return error_result(f"Cannot read: {error}")
67
+
68
+ if suffix in IMAGE_MIME_BY_EXT:
69
+ return {
70
+ "type": "image",
71
+ "data": base64.b64encode(data).decode("ascii"),
72
+ "mime_type": IMAGE_MIME_BY_EXT[suffix],
73
+ }
74
+ if suffix == ".pdf":
75
+ return error_result(
76
+ "Cannot read PDF: page rendering is not available in this runtime; "
77
+ "provide a PDF-capable tool if the application needs it"
78
+ )
79
+ if len(data) > MAX_BYTES:
80
+ return error_result(f"Cannot read: {path} exceeds {MAX_BYTES} byte cap ({len(data)} bytes)")
81
+ if suffix == ".ipynb":
82
+ return _read_notebook(path, data)
83
+
84
+ offset = _positive_int(params.get("offset"))
85
+ limit = _positive_int(params.get("limit"))
86
+ key = f"{workspace.id}\0{path}"
87
+ signature = (int(stat.mtime_ms), stat.size, offset, limit)
88
+ if state.read_files.get(key) == signature:
89
+ return text_result(
90
+ f"<file unchanged since you last read it — its content is already in context: {path}>"
91
+ )
92
+ state.read_files[key] = signature
93
+ return text_result(_number_lines(data.decode("utf-8", errors="replace"), offset, limit))
94
+
95
+
96
+ async def write_tool(
97
+ _call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
98
+ ) -> ToolResult:
99
+ """Create or replace a workspace file, matching ``createWriteTool().execute``."""
100
+ params = arguments if isinstance(arguments, dict) else {}
101
+ raw_path = params.get("path")
102
+ path = raw_path.strip() if isinstance(raw_path, str) else ""
103
+ if not path:
104
+ return error_result("path is required")
105
+ content = params.get("content")
106
+ if not isinstance(content, str):
107
+ return error_result("content is required")
108
+ workspace = require_workspace(context)
109
+ if workspace is None:
110
+ return error_result("write requires an active workspace")
111
+ try:
112
+ resolved = await workspace.resolve(path, access="write")
113
+ async with state.file_lock(f"{workspace.id}\0{resolved.relative_path}"):
114
+ await workspace.fs.write_file(path, content.encode(), atomic=True)
115
+ except (OSError, ValueError, WorkspaceViolation) as error:
116
+ return error_result(f"Cannot write: {error}")
117
+ return text_result(f"Wrote {len(content)} bytes to {path}")
118
+
119
+
120
+ async def edit_tool(
121
+ _call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
122
+ ) -> ToolResult:
123
+ """Apply an exact string replacement, matching ``createEditTool().execute``."""
124
+ params = arguments if isinstance(arguments, dict) else {}
125
+ raw_path = params.get("path")
126
+ path = raw_path.strip() if isinstance(raw_path, str) else ""
127
+ if not path:
128
+ return error_result("path is required")
129
+ old = params.get("old_string")
130
+ new = params.get("new_string")
131
+ if not isinstance(old, str):
132
+ return error_result("old_string is required")
133
+ if not isinstance(new, str):
134
+ return error_result("new_string is required")
135
+ if old == new:
136
+ return error_result("old_string and new_string are identical")
137
+ workspace = require_workspace(context)
138
+ if workspace is None:
139
+ return error_result("edit requires an active workspace")
140
+
141
+ try:
142
+ resolved = await workspace.resolve(path, access="readwrite")
143
+ async with state.file_lock(f"{workspace.id}\0{resolved.relative_path}"):
144
+ stat = await workspace.fs.stat(path)
145
+ if stat.is_directory:
146
+ return error_result(f"Cannot edit: {path} is a directory")
147
+ content = (await workspace.fs.read_file(path)).decode("utf-8")
148
+ occurrences = content.count(old)
149
+ if occurrences == 0:
150
+ return error_result("old_string not found in file")
151
+ replace_all = params.get("replace_all") is True
152
+ if occurrences > 1 and not replace_all:
153
+ return error_result(
154
+ f"old_string appears {occurrences} times; provide a more specific string "
155
+ "or set replace_all=true"
156
+ )
157
+ updated = content.replace(old, new) if replace_all else content.replace(old, new, 1)
158
+ await workspace.fs.write_file(
159
+ path, updated.encode(), mode=stat.mode & 0o777, atomic=True
160
+ )
161
+ except FileNotFoundError:
162
+ return error_result(f"Cannot edit: {path} not found")
163
+ except UnicodeDecodeError:
164
+ return error_result(f"Cannot edit: {path} is not UTF-8 text")
165
+ except (OSError, ValueError, WorkspaceViolation) as error:
166
+ return error_result(f"Cannot edit: {error}")
167
+ count = occurrences if params.get("replace_all") is True else 1
168
+ return text_result(f"Replaced {count} occurrence{'s' if count != 1 else ''} in {path}")
169
+
170
+
171
+ def _number_lines(content: str, offset: int | None, limit: int | None) -> str:
172
+ lines = content.split("\n")
173
+ start = offset - 1 if offset is not None else 0
174
+ count = min(limit, MAX_LINES) if limit is not None else MAX_LINES
175
+ end = min(start + count, len(lines))
176
+ numbered = "\n".join(
177
+ f"{line_number:>6}→{line}"
178
+ for line_number, line in enumerate(lines[start:end], start=start + 1)
179
+ )
180
+ if end < len(lines):
181
+ numbered += (
182
+ f"\n\n[Showing lines {start + 1}-{end} of {len(lines)}. "
183
+ f"Use offset={end + 1} to continue.]"
184
+ )
185
+ return numbered
186
+
187
+
188
+ def _positive_int(value: Any) -> int | None:
189
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
190
+ return None
191
+ return int(value)
192
+
193
+
194
+ async def _not_found(path: str, context: ToolContext) -> ToolResult:
195
+ workspace = context.workspace
196
+ assert workspace is not None
197
+ pure = PurePath(path)
198
+ try:
199
+ entries = await workspace.fs.readdir(str(pure.parent))
200
+ except (OSError, ValueError, WorkspaceViolation):
201
+ return error_result(f"Cannot read: {path} not found")
202
+ match = next(
203
+ (
204
+ entry.name
205
+ for entry in entries
206
+ if entry.name != pure.name and PurePath(entry.name).stem == pure.stem
207
+ ),
208
+ None,
209
+ )
210
+ suggestion = str(pure.parent / match) if match is not None else None
211
+ message = f"Cannot read: {path} not found"
212
+ if suggestion is not None:
213
+ message += f". Did you mean {suggestion}?"
214
+ return error_result(message)
215
+
216
+
217
+ def _read_notebook(path: str, data: bytes) -> ToolResult:
218
+ try:
219
+ notebook = json.loads(data)
220
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
221
+ return error_result(f"Cannot read notebook {path}: {error}")
222
+ cells = notebook.get("cells") if isinstance(notebook, dict) else None
223
+ if not isinstance(cells, list):
224
+ return error_result(f"Cannot read notebook {path}: cells is not a list")
225
+ sections: list[str] = []
226
+ for index, cell in enumerate(cells, start=1):
227
+ if not isinstance(cell, dict):
228
+ continue
229
+ kind = cell.get("cell_type", "unknown")
230
+ source = cell.get("source", "")
231
+ text = "".join(source) if isinstance(source, list) else str(source)
232
+ sections.append(f"## Cell {index} ({kind})\n{text}")
233
+ outputs = cell.get("outputs", [])
234
+ if isinstance(outputs, list):
235
+ rendered = "".join(_notebook_output(item) for item in outputs)
236
+ if rendered:
237
+ sections.append(rendered[:NOTEBOOK_OUTPUT_LIMIT])
238
+ return text_result("\n\n".join(sections))
239
+
240
+
241
+ def _notebook_output(output: object) -> str:
242
+ if not isinstance(output, dict):
243
+ return ""
244
+ for key in ("text", "traceback"):
245
+ value = output.get(key)
246
+ if isinstance(value, list):
247
+ return "".join(str(item) for item in value)
248
+ if isinstance(value, str):
249
+ return value
250
+ data = output.get("data")
251
+ if isinstance(data, dict):
252
+ text = data.get("text/plain")
253
+ if isinstance(text, list):
254
+ return "".join(str(item) for item in text)
255
+ if isinstance(text, str):
256
+ return text
257
+ return ""
@@ -0,0 +1,384 @@
1
+ """Workspace-backed glob and grep built-ins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ from collections.abc import Mapping, Sequence
8
+
9
+ from semora.workspace import CommandResult, SandboxCommand, ToolContext, WorkspaceViolation
10
+
11
+ from ._types import (
12
+ BuiltinToolState,
13
+ ToolResult,
14
+ error_result,
15
+ require_workspace,
16
+ text_result,
17
+ tool_environment,
18
+ )
19
+
20
+ SEARCH_TIMEOUT_SECONDS = 120.0
21
+ DEFAULT_HEAD_LIMIT = 250
22
+ MAX_COLUMNS = 500
23
+
24
+
25
+ async def glob_tool(
26
+ _call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
27
+ ) -> ToolResult:
28
+ """Find paths using ripgrep, porting ``createGlobTool().execute``."""
29
+ params = arguments if isinstance(arguments, dict) else {}
30
+ raw_pattern = params.get("pattern")
31
+ pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
32
+ if not pattern:
33
+ return error_result("pattern is required")
34
+ if ".." in pattern:
35
+ return error_result('pattern must not contain ".."')
36
+ workspace = require_workspace(context)
37
+ if workspace is None:
38
+ return error_result("glob requires an active workspace")
39
+ offset = _clamp_int(params.get("offset"), 0, 0)
40
+ head_limit = _clamp_int(params.get("head_limit"), DEFAULT_HEAD_LIMIT, 0)
41
+ raw_path = params.get("path")
42
+ path = raw_path.strip() if isinstance(raw_path, str) and raw_path.strip() else "."
43
+ try:
44
+ resolved = await workspace.fs.real_path(path)
45
+ except FileNotFoundError:
46
+ return text_result("No files found.")
47
+ except (OSError, ValueError, WorkspaceViolation) as error:
48
+ return error_result(f"Cannot glob: {error}")
49
+ if await _detect_engine(context, state) != "rg":
50
+ return error_result("glob requires ripgrep (rg), which was not found on PATH")
51
+ try:
52
+ process = await _run_engine(
53
+ context,
54
+ "rg",
55
+ ["--files", "--glob", pattern, str(resolved.path)],
56
+ str(resolved.root),
57
+ )
58
+ except (OSError, ValueError, WorkspaceViolation) as error:
59
+ return error_result(f"Cannot glob: {error}")
60
+ classified = _classify(process, "glob")
61
+ if isinstance(classified, str):
62
+ return error_result(classified)
63
+ stdout, warning = classified
64
+ lines = [line for line in stdout.splitlines() if line]
65
+ if not lines:
66
+ return text_result("No files found." + warning)
67
+ return await _format_files(
68
+ lines, str(resolved.root), offset, head_limit, warning, context
69
+ )
70
+
71
+
72
+ async def grep_tool(
73
+ _call_id: str, arguments: object, context: ToolContext, state: BuiltinToolState
74
+ ) -> ToolResult:
75
+ """Search contents, porting ``createGrepTool().execute`` and argument builders."""
76
+ params = arguments if isinstance(arguments, dict) else {}
77
+ raw_pattern = params.get("pattern")
78
+ pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
79
+ if not pattern:
80
+ return error_result("pattern is required")
81
+ output_mode = params.get("output_mode", "content")
82
+ if output_mode not in {"content", "files_with_matches", "count"}:
83
+ return error_result("output_mode must be content, files_with_matches, or count")
84
+ raw_glob = params.get("glob")
85
+ glob = raw_glob.strip() if isinstance(raw_glob, str) and raw_glob.strip() else None
86
+ if glob is not None and ".." in glob:
87
+ return error_result('glob must not contain ".."')
88
+ workspace = require_workspace(context)
89
+ if workspace is None:
90
+ return error_result("grep requires an active workspace")
91
+ raw_path = params.get("path")
92
+ path = raw_path.strip() if isinstance(raw_path, str) and raw_path.strip() else "."
93
+ try:
94
+ resolved = await workspace.fs.real_path(path)
95
+ except FileNotFoundError:
96
+ return text_result("No matches found.")
97
+ except (OSError, ValueError, WorkspaceViolation) as error:
98
+ return error_result(f"Cannot grep: {error}")
99
+
100
+ offset = _clamp_int(params.get("offset"), 0, 0)
101
+ head_limit = _clamp_int(params.get("head_limit"), DEFAULT_HEAD_LIMIT, 0)
102
+ engine = await _detect_engine(context, state)
103
+ target = str(resolved.path)
104
+ args = (
105
+ _rg_args(params, pattern, str(output_mode), glob, target)
106
+ if engine == "rg"
107
+ else _grep_args(params, pattern, str(output_mode), glob, target)
108
+ )
109
+ try:
110
+ process = await _run_engine(context, engine, args, str(resolved.root))
111
+ except (OSError, ValueError, WorkspaceViolation) as error:
112
+ return error_result(f"Cannot grep: {error}")
113
+ classified = _classify(process, "grep")
114
+ if isinstance(classified, str):
115
+ return error_result(classified)
116
+ stdout, warning = classified
117
+ degraded = _grep_degradation(params) if engine == "grep" else ""
118
+ tail = warning + (f"\n\n{degraded}" if degraded else "")
119
+ lines = [line for line in stdout.splitlines() if line]
120
+ if not lines:
121
+ return text_result("No matches found." + tail)
122
+ root = str(resolved.root)
123
+ if output_mode == "files_with_matches":
124
+ return await _format_files(lines, root, offset, head_limit, tail, context)
125
+ if output_mode == "count":
126
+ return _format_count(lines, root, offset, head_limit, tail)
127
+ return _format_content(lines, root, offset, head_limit, tail)
128
+
129
+
130
+ async def _detect_engine(context: ToolContext, state: BuiltinToolState) -> str:
131
+ workspace = context.workspace
132
+ assert workspace is not None
133
+ cached = state.search_engines.get(workspace.id)
134
+ if cached is not None:
135
+ return cached
136
+ try:
137
+ result = await _run_engine(context, "rg", ["--version"], str(workspace.root), timeout=5)
138
+ engine = "rg" if result.exit_code == 0 else "grep"
139
+ except (OSError, ValueError, WorkspaceViolation):
140
+ engine = "grep"
141
+ state.search_engines[workspace.id] = engine
142
+ return engine
143
+
144
+
145
+ async def _run_engine(
146
+ context: ToolContext,
147
+ engine: str,
148
+ args: Sequence[str],
149
+ cwd: str,
150
+ *,
151
+ timeout: float = SEARCH_TIMEOUT_SECONDS,
152
+ ) -> CommandResult:
153
+ workspace = context.workspace
154
+ assert workspace is not None
155
+ return await workspace.run(
156
+ SandboxCommand(
157
+ argv=[engine, *args],
158
+ cwd=cwd,
159
+ env=tool_environment(()),
160
+ inherit_env=False,
161
+ timeout_seconds=timeout,
162
+ require_isolation=workspace.isolated,
163
+ allowed_domains=() if workspace.isolated else None,
164
+ )
165
+ )
166
+
167
+
168
+ def _classify(result: CommandResult, noun: str) -> tuple[str, str] | str:
169
+ has_stdout = bool(result.stdout.strip())
170
+ if result.aborted:
171
+ return (
172
+ (result.stdout, f"\n\n[{noun} aborted: partial results returned]")
173
+ if has_stdout
174
+ else f"{noun} aborted"
175
+ )
176
+ if result.timed_out:
177
+ return (
178
+ (result.stdout, f"\n\n[{noun} timed out: partial results returned]")
179
+ if has_stdout
180
+ else f"{noun} timed out"
181
+ )
182
+ if result.signal:
183
+ partial = f"\n\n[{noun} killed by signal {result.signal}: partial results returned]"
184
+ return (
185
+ (result.stdout, partial)
186
+ if has_stdout
187
+ else f"{noun} killed by signal {result.signal}"
188
+ )
189
+ if result.exit_code in {0, 1}:
190
+ return result.stdout, ""
191
+ detail = result.stderr.strip() or f"{noun} failed with exit {result.exit_code}"
192
+ if has_stdout:
193
+ warning = (
194
+ f"\n\n[{noun} exited {result.exit_code}: {detail}; partial results returned]"
195
+ )
196
+ return result.stdout, warning
197
+ return detail
198
+
199
+
200
+ def _rg_args(
201
+ params: Mapping[str, object],
202
+ pattern: str,
203
+ output_mode: str,
204
+ glob: str | None,
205
+ target: str,
206
+ ) -> list[str]:
207
+ args = ["--max-columns", str(MAX_COLUMNS)]
208
+ if params.get("multiline") is True:
209
+ args.extend(["-U", "--multiline-dotall"])
210
+ if params.get("-i") is True:
211
+ args.append("-i")
212
+ if output_mode == "files_with_matches":
213
+ args.append("-l")
214
+ elif output_mode == "count":
215
+ args.append("-c")
216
+ else:
217
+ if params.get("-n", True) is not False:
218
+ args.append("-n")
219
+ _push_context(args, params)
220
+ _push_pattern(args, pattern)
221
+ file_type = params.get("type")
222
+ if isinstance(file_type, str) and file_type:
223
+ args.extend(["--type", file_type])
224
+ for item in _split_globs(glob):
225
+ args.extend(["--glob", item])
226
+ args.append(target)
227
+ return args
228
+
229
+
230
+ def _grep_args(
231
+ params: Mapping[str, object],
232
+ pattern: str,
233
+ output_mode: str,
234
+ glob: str | None,
235
+ target: str,
236
+ ) -> list[str]:
237
+ args = ["-rE", "--color=never"]
238
+ if params.get("-i") is True:
239
+ args.append("-i")
240
+ if output_mode == "files_with_matches":
241
+ args.append("-l")
242
+ elif output_mode == "count":
243
+ args.append("-c")
244
+ else:
245
+ if params.get("-n", True) is not False:
246
+ args.append("-n")
247
+ _push_context(args, params)
248
+ for item in _split_globs(glob):
249
+ if "/" not in item:
250
+ args.append(f"--include={item}")
251
+ args.append("--")
252
+ _push_pattern(args, pattern)
253
+ args.append(target)
254
+ return args
255
+
256
+
257
+ def _push_context(args: list[str], params: Mapping[str, object]) -> None:
258
+ for key in ("context", "-C"):
259
+ if key in params:
260
+ args.extend(["-C", str(_clamp_int(params[key], 0, 0))])
261
+ return
262
+ if "-B" in params:
263
+ args.extend(["-B", str(_clamp_int(params["-B"], 0, 0))])
264
+ if "-A" in params:
265
+ args.extend(["-A", str(_clamp_int(params["-A"], 0, 0))])
266
+
267
+
268
+ def _push_pattern(args: list[str], pattern: str) -> None:
269
+ if pattern.startswith("-"):
270
+ args.extend(["-e", pattern])
271
+ else:
272
+ args.append(pattern)
273
+
274
+
275
+ def _split_globs(glob: str | None) -> list[str]:
276
+ if glob is None:
277
+ return []
278
+ output: list[str] = []
279
+ for raw in glob.split():
280
+ output.extend([raw] if "{" in raw and "}" in raw else filter(None, raw.split(",")))
281
+ return output
282
+
283
+
284
+ def _grep_degradation(params: Mapping[str, object]) -> str:
285
+ lost: list[str] = []
286
+ if params.get("type"):
287
+ lost.append("type")
288
+ if params.get("multiline"):
289
+ lost.append("multiline")
290
+ lost.append(".gitignore not respected")
291
+ return f"[grep fallback: ripgrep not found — {', '.join(lost)} unavailable]"
292
+
293
+
294
+ async def _format_files(
295
+ lines: list[str],
296
+ root: str,
297
+ offset: int,
298
+ head_limit: int,
299
+ tail: str,
300
+ context: ToolContext,
301
+ ) -> ToolResult:
302
+ workspace = context.workspace
303
+ assert workspace is not None
304
+
305
+ async def mtime(path: str) -> float:
306
+ try:
307
+ return (await workspace.fs.stat(path)).mtime_ms
308
+ except (OSError, ValueError, WorkspaceViolation):
309
+ return 0
310
+
311
+ mtimes = await asyncio.gather(*(mtime(path) for path in lines))
312
+ sorted_lines = [
313
+ path
314
+ for path, _ in sorted(
315
+ zip(lines, mtimes, strict=True), key=lambda item: (-item[1], item[0])
316
+ )
317
+ ]
318
+ items, limited = _page(sorted_lines, head_limit, offset)
319
+ relative = [_strip_root(path, root) for path in items]
320
+ info = _limit_info(head_limit if limited else None, offset)
321
+ noun = "file" if len(relative) == 1 else "files"
322
+ header = f"Found {len(relative)} {noun}" + (f" ({info})" if info else "")
323
+ return text_result(f"{header}\n" + "\n".join(relative) + tail)
324
+
325
+
326
+ def _format_count(
327
+ lines: list[str], root: str, offset: int, head_limit: int, tail: str
328
+ ) -> ToolResult:
329
+ items, limited = _page(lines, head_limit, offset)
330
+ total = 0
331
+ output: list[str] = []
332
+ for line in items:
333
+ path, separator, count = line.rpartition(":")
334
+ if separator and count.isdigit():
335
+ total += int(count)
336
+ output.append(f"{_strip_root(path, root)}:{count}")
337
+ else:
338
+ output.append(_strip_root(line, root))
339
+ info = _limit_info(head_limit if limited else None, offset)
340
+ noun = "occurrence" if total == 1 else "occurrences"
341
+ files = "file" if len(output) == 1 else "files"
342
+ summary = f"Found {total} total {noun} across {len(output)} {files}."
343
+ if info:
344
+ summary += f" ({info})"
345
+ return text_result("\n".join(output) + f"\n\n{summary}" + tail)
346
+
347
+
348
+ def _format_content(
349
+ lines: list[str], root: str, offset: int, head_limit: int, tail: str
350
+ ) -> ToolResult:
351
+ items, limited = _page(lines, head_limit, offset)
352
+ output = [_strip_content_root(line, root) for line in items]
353
+ info = _limit_info(head_limit if limited else None, offset)
354
+ footer = f"\n\n[pagination: {info}]" if info else ""
355
+ return text_result("\n".join(output) + footer + tail)
356
+
357
+
358
+ def _strip_root(path: str, root: str) -> str:
359
+ prefix = root.rstrip("/") + "/"
360
+ return path[len(prefix) :] if path.startswith(prefix) else path
361
+
362
+
363
+ def _strip_content_root(line: str, root: str) -> str:
364
+ prefix = root.rstrip("/") + "/"
365
+ return line[len(prefix) :] if line.startswith(prefix) else line
366
+
367
+
368
+ def _page(items: list[str], limit: int, offset: int) -> tuple[list[str], bool]:
369
+ if limit == 0:
370
+ return items[offset:], False
371
+ return items[offset : offset + limit], len(items) - offset > limit
372
+
373
+
374
+ def _limit_info(limit: int | None, offset: int) -> str:
375
+ parts = ([f"limit {limit}"] if limit is not None else []) + (
376
+ [f"offset {offset}"] if offset else []
377
+ )
378
+ return ", ".join(parts)
379
+
380
+
381
+ def _clamp_int(value: object, fallback: int, minimum: int) -> int:
382
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
383
+ return fallback
384
+ return max(minimum, int(value))