pi-mono-coding 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.
pi_coding/tools/ls.py ADDED
@@ -0,0 +1,126 @@
1
+ """Ls tool - lists directory contents with type indicators and truncation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from pathlib import Path
8
+ from pi_agent.types import AgentToolUpdateCallback
9
+ from typing import Any
10
+
11
+ from pi_agent import AgentTool, AgentToolResult
12
+ from pi_ai.types import TextContent
13
+
14
+ from pi_coding.utils import DEFAULT_MAX_BYTES, format_size, resolve_to_cwd, truncate_head
15
+
16
+ DEFAULT_LIMIT = 500
17
+
18
+ _LS_TOOL_PARAMETERS = {
19
+ "type": "object",
20
+ "properties": {
21
+ "path": {
22
+ "type": "string",
23
+ "description": "Directory to list (default: current directory)",
24
+ },
25
+ "limit": {
26
+ "type": "number",
27
+ "description": "Maximum number of entries to return (default: 500)",
28
+ },
29
+ },
30
+ "required": [],
31
+ }
32
+
33
+
34
+ async def _execute_ls(
35
+ tool_call_id: str,
36
+ params: dict[str, Any],
37
+ cwd: str,
38
+ cancel_event: asyncio.Event | None,
39
+ on_update: AgentToolUpdateCallback | None,
40
+ ) -> AgentToolResult:
41
+ path = params.get("path", ".")
42
+ limit = params.get("limit", DEFAULT_LIMIT)
43
+
44
+ if cancel_event and cancel_event.is_set():
45
+ return AgentToolResult(
46
+ content=[TextContent(type="text", text="Operation aborted")],
47
+ details={"error": "aborted"},
48
+ )
49
+
50
+ dir_path = resolve_to_cwd(path, cwd)
51
+
52
+ if not os.path.exists(dir_path):
53
+ return AgentToolResult(
54
+ content=[TextContent(type="text", text=f"Error: Path not found: {dir_path}")],
55
+ details={"error": "path_not_found", "path": dir_path},
56
+ )
57
+
58
+ if not os.path.isdir(dir_path):
59
+ return AgentToolResult(
60
+ content=[TextContent(type="text", text=f"Error: Not a directory: {dir_path}")],
61
+ details={"error": "not_a_directory", "path": dir_path},
62
+ )
63
+
64
+ try:
65
+ entries = sorted(os.listdir(dir_path), key=lambda s: s.lower())
66
+ except PermissionError as e:
67
+ return AgentToolResult(
68
+ content=[TextContent(type="text", text=f"Error: Cannot read directory: {e}")],
69
+ details={"error": "permission_denied", "path": dir_path},
70
+ )
71
+
72
+ results: list[str] = []
73
+ entry_limit_reached = False
74
+
75
+ for entry in entries:
76
+ if len(results) >= limit:
77
+ entry_limit_reached = True
78
+ break
79
+
80
+ full_path = os.path.join(dir_path, entry)
81
+ suffix = "/" if os.path.isdir(full_path) else ""
82
+ results.append(entry + suffix)
83
+
84
+ if not results:
85
+ return AgentToolResult(
86
+ content=[TextContent(type="text", text="(empty directory)")],
87
+ details={"path": dir_path},
88
+ )
89
+
90
+ raw_output = "\n".join(results)
91
+ truncation = truncate_head(raw_output)
92
+
93
+ output = truncation.content
94
+ details: dict[str, Any] = {}
95
+ notices: list[str] = []
96
+
97
+ if entry_limit_reached:
98
+ notices.append(f"{limit} entries limit reached. Use limit={limit * 2} for more")
99
+ details["entry_limit_reached"] = limit
100
+
101
+ if truncation.truncated:
102
+ notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached")
103
+ details["truncation"] = truncation.__dict__
104
+
105
+ if notices:
106
+ output += f"\n\n[{'. '.join(notices)}]"
107
+
108
+ return AgentToolResult(
109
+ content=[TextContent(type="text", text=output)],
110
+ details=details if details else None,
111
+ )
112
+
113
+
114
+ def create_ls_tool(cwd: str, options: dict[str, Any] | None = None) -> AgentTool:
115
+ return AgentTool(
116
+ name="ls",
117
+ label="List",
118
+ description=f"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to {DEFAULT_LIMIT} entries or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first).",
119
+ parameters=_LS_TOOL_PARAMETERS,
120
+ execute=lambda tool_call_id, params, cancel_event, on_update: _execute_ls(
121
+ tool_call_id, params, cwd, cancel_event, on_update
122
+ ),
123
+ )
124
+
125
+
126
+ ls_tool = create_ls_tool(os.getcwd())
@@ -0,0 +1,210 @@
1
+ """Read tool - reads file contents with line numbers and truncation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import os
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, Callable
10
+
11
+ from pi_agent import AgentTool, AgentToolResult
12
+ from pi_agent.types import AgentToolUpdateCallback
13
+ from pi_ai.types import ImageContent, TextContent
14
+
15
+ from pi_coding.utils import (
16
+ DEFAULT_MAX_BYTES,
17
+ DEFAULT_MAX_LINES,
18
+ TruncationResult,
19
+ format_size,
20
+ resolve_read_path,
21
+ truncate_head,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from pi_coding.utils import TruncationOptions
26
+
27
+ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
28
+ IMAGE_MIME_TYPES = {
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".png": "image/png",
32
+ ".gif": "image/gif",
33
+ ".webp": "image/webp",
34
+ }
35
+
36
+
37
+ def _detect_image_mime_type(path: str) -> str | None:
38
+ ext = Path(path).suffix.lower()
39
+ return IMAGE_MIME_TYPES.get(ext)
40
+
41
+
42
+ _READ_TOOL_PARAMETERS = {
43
+ "type": "object",
44
+ "properties": {
45
+ "path": {
46
+ "type": "string",
47
+ "description": "Path to the file to read (relative or absolute)",
48
+ },
49
+ "offset": {
50
+ "type": "number",
51
+ "description": "Line number to start reading from (1-indexed)",
52
+ },
53
+ "limit": {
54
+ "type": "number",
55
+ "description": "Maximum number of lines to read",
56
+ },
57
+ },
58
+ "required": ["path"],
59
+ }
60
+
61
+
62
+ async def _execute_read(
63
+ tool_call_id: str,
64
+ params: dict[str, Any],
65
+ cwd: str,
66
+ cancel_event: asyncio.Event | None,
67
+ on_update: AgentToolUpdateCallback | None,
68
+ ) -> AgentToolResult:
69
+ path = params.get("path")
70
+ offset = params.get("offset")
71
+ limit = params.get("limit")
72
+
73
+ if not path:
74
+ return AgentToolResult(
75
+ content=[TextContent(type="text", text="Error: 'path' parameter is required")],
76
+ details={"error": "missing_parameter"},
77
+ )
78
+
79
+ absolute_path = resolve_read_path(path, cwd)
80
+
81
+ if cancel_event and cancel_event.is_set():
82
+ return AgentToolResult(
83
+ content=[TextContent(type="text", text="Operation aborted")],
84
+ details={"error": "aborted"},
85
+ )
86
+
87
+ if not os.path.exists(absolute_path):
88
+ return AgentToolResult(
89
+ content=[TextContent(type="text", text=f"Error: File not found: {path}")],
90
+ details={"error": "file_not_found", "path": absolute_path},
91
+ )
92
+
93
+ if not os.path.isfile(absolute_path):
94
+ return AgentToolResult(
95
+ content=[TextContent(type="text", text=f"Error: Not a file: {path}")],
96
+ details={"error": "not_a_file", "path": absolute_path},
97
+ )
98
+
99
+ mime_type = _detect_image_mime_type(absolute_path)
100
+
101
+ if mime_type:
102
+ try:
103
+ with open(absolute_path, "rb") as f:
104
+ data = base64.standard_b64encode(f.read()).decode("utf-8")
105
+ return AgentToolResult(
106
+ content=[
107
+ TextContent(type="text", text=f"Read image file [{mime_type}]"),
108
+ ImageContent(type="image", data=data, mimeType=mime_type),
109
+ ],
110
+ details={"path": absolute_path, "mime_type": mime_type},
111
+ )
112
+ except Exception as e:
113
+ return AgentToolResult(
114
+ content=[TextContent(type="text", text=f"Error reading image: {e}")],
115
+ details={"error": str(e), "path": absolute_path},
116
+ )
117
+
118
+ try:
119
+ with open(absolute_path, encoding="utf-8") as f:
120
+ content = f.read()
121
+
122
+ if cancel_event and cancel_event.is_set():
123
+ return AgentToolResult(
124
+ content=[TextContent(type="text", text="Operation aborted")],
125
+ details={"error": "aborted"},
126
+ )
127
+
128
+ all_lines = content.split("\n")
129
+ total_file_lines = len(all_lines)
130
+
131
+ start_line = max(0, (offset or 1) - 1)
132
+ start_line_display = start_line + 1
133
+
134
+ if start_line >= total_file_lines:
135
+ return AgentToolResult(
136
+ content=[
137
+ TextContent(
138
+ type="text",
139
+ text=f"Error: Offset {offset} is beyond end of file ({total_file_lines} lines total)",
140
+ )
141
+ ],
142
+ details={"error": "offset_out_of_bounds", "total_lines": total_file_lines},
143
+ )
144
+
145
+ if limit is not None:
146
+ end_line = min(start_line + limit, total_file_lines)
147
+ selected_content = "\n".join(all_lines[start_line:end_line])
148
+ user_limited_lines = end_line - start_line
149
+ else:
150
+ selected_content = "\n".join(all_lines[start_line:])
151
+ user_limited_lines = None
152
+
153
+ truncation = truncate_head(selected_content)
154
+
155
+ if truncation.first_line_exceeds_limit:
156
+ first_line_size = format_size(len(all_lines[start_line].encode("utf-8")))
157
+ output_text = f"[Line {start_line_display} is {first_line_size}, exceeds {format_size(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '{start_line_display}p' {path} | head -c {DEFAULT_MAX_BYTES}]"
158
+ return AgentToolResult(
159
+ content=[TextContent(type="text", text=output_text)],
160
+ details={"truncation": truncation.__dict__},
161
+ )
162
+
163
+ if truncation.truncated:
164
+ end_line_display = start_line_display + truncation.output_lines - 1
165
+ next_offset = end_line_display + 1
166
+ output_text = truncation.content
167
+ if truncation.truncated_by == "lines":
168
+ output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines}. Use offset={next_offset} to continue.]"
169
+ else:
170
+ output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Use offset={next_offset} to continue.]"
171
+ return AgentToolResult(
172
+ content=[TextContent(type="text", text=output_text)],
173
+ details={"truncation": truncation.__dict__},
174
+ )
175
+
176
+ if user_limited_lines is not None and start_line + user_limited_lines < total_file_lines:
177
+ remaining = total_file_lines - (start_line + user_limited_lines)
178
+ next_offset = start_line + user_limited_lines + 1
179
+ output_text = truncation.content
180
+ output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
181
+ return AgentToolResult(
182
+ content=[TextContent(type="text", text=output_text)],
183
+ details={"total_lines": total_file_lines},
184
+ )
185
+
186
+ return AgentToolResult(
187
+ content=[TextContent(type="text", text=truncation.content)],
188
+ details={"path": absolute_path, "total_lines": total_file_lines},
189
+ )
190
+
191
+ except Exception as e:
192
+ return AgentToolResult(
193
+ content=[TextContent(type="text", text=f"Error reading file: {e}")],
194
+ details={"error": str(e), "path": absolute_path},
195
+ )
196
+
197
+
198
+ def create_read_tool(cwd: str, options: dict[str, Any] | None = None) -> AgentTool:
199
+ return AgentTool(
200
+ name="read",
201
+ label="Read",
202
+ description=f"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). Use offset/limit for large files.",
203
+ parameters=_READ_TOOL_PARAMETERS,
204
+ execute=lambda tool_call_id, params, cancel_event, on_update: _execute_read(
205
+ tool_call_id, params, cwd, cancel_event, on_update
206
+ ),
207
+ )
208
+
209
+
210
+ read_tool = create_read_tool(os.getcwd())
@@ -0,0 +1,99 @@
1
+ """Write tool - writes content to files with automatic directory creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from pathlib import Path
8
+ from pi_agent.types import AgentToolUpdateCallback
9
+ from typing import Any
10
+
11
+ from pi_agent import AgentTool, AgentToolResult
12
+ from pi_ai.types import TextContent
13
+
14
+ from pi_coding.utils import resolve_read_path
15
+
16
+ _WRITE_TOOL_PARAMETERS = {
17
+ "type": "object",
18
+ "properties": {
19
+ "path": {
20
+ "type": "string",
21
+ "description": "Path to the file to write (relative or absolute)",
22
+ },
23
+ "content": {
24
+ "type": "string",
25
+ "description": "Content to write to the file",
26
+ },
27
+ },
28
+ "required": ["path", "content"],
29
+ }
30
+
31
+
32
+ async def _execute_write(
33
+ tool_call_id: str,
34
+ params: dict[str, Any],
35
+ cwd: str,
36
+ cancel_event: asyncio.Event | None,
37
+ on_update: AgentToolUpdateCallback | None,
38
+ ) -> AgentToolResult:
39
+ path = params.get("path")
40
+ content = params.get("content")
41
+
42
+ if not path or content is None:
43
+ return AgentToolResult(
44
+ content=[
45
+ TextContent(type="text", text="Error: 'path' and 'content' parameters are required")
46
+ ],
47
+ details={"error": "missing_parameters"},
48
+ )
49
+
50
+ absolute_path = resolve_read_path(path, cwd)
51
+
52
+ if cancel_event and cancel_event.is_set():
53
+ return AgentToolResult(
54
+ content=[TextContent(type="text", text="Operation aborted")],
55
+ details={"error": "aborted"},
56
+ )
57
+
58
+ try:
59
+ Path(absolute_path).parent.mkdir(parents=True, exist_ok=True)
60
+
61
+ if cancel_event and cancel_event.is_set():
62
+ return AgentToolResult(
63
+ content=[TextContent(type="text", text="Operation aborted")],
64
+ details={"error": "aborted"},
65
+ )
66
+
67
+ with open(absolute_path, "w", encoding="utf-8") as f:
68
+ f.write(content)
69
+
70
+ lines = content.count("\n") + 1
71
+ chars = len(content)
72
+
73
+ return AgentToolResult(
74
+ content=[
75
+ TextContent(type="text", text=f"Successfully wrote {lines} lines ({chars} chars) to {path}")
76
+ ],
77
+ details={"path": absolute_path, "line_count": lines, "char_count": chars},
78
+ )
79
+
80
+ except Exception as e:
81
+ return AgentToolResult(
82
+ content=[TextContent(type="text", text=f"Error writing file: {e}")],
83
+ details={"error": str(e), "path": absolute_path},
84
+ )
85
+
86
+
87
+ def create_write_tool(cwd: str, options: dict[str, Any] | None = None) -> AgentTool:
88
+ return AgentTool(
89
+ name="write",
90
+ label="Write",
91
+ description="Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
92
+ parameters=_WRITE_TOOL_PARAMETERS,
93
+ execute=lambda tool_call_id, params, cancel_event, on_update: _execute_write(
94
+ tool_call_id, params, cwd, cancel_event, on_update
95
+ ),
96
+ )
97
+
98
+
99
+ write_tool = create_write_tool(os.getcwd())
@@ -0,0 +1,91 @@
1
+ """Shared utility functions for pi_coding."""
2
+
3
+ from pi_coding.utils.edit_diff import (
4
+ EditDiffError,
5
+ EditDiffResult,
6
+ FuzzyMatchResult,
7
+ compute_edit_diff,
8
+ detect_line_ending,
9
+ fuzzy_find_text,
10
+ generate_diff_string,
11
+ normalize_for_fuzzy_match,
12
+ normalize_to_lf,
13
+ restore_line_endings,
14
+ strip_bom,
15
+ )
16
+ from pi_coding.utils.git import (
17
+ GitSource,
18
+ get_current_branch,
19
+ get_repo_status,
20
+ parse_git_url,
21
+ )
22
+ from pi_coding.utils.path_utils import (
23
+ UNICODE_SPACES,
24
+ expand_path,
25
+ file_exists,
26
+ normalize_at_prefix,
27
+ normalize_unicode_spaces,
28
+ resolve_read_path,
29
+ resolve_to_cwd,
30
+ try_curly_quote_variant,
31
+ try_macos_screenshot_path,
32
+ try_nfd_variant,
33
+ )
34
+ from pi_coding.utils.shell import (
35
+ get_shell_config,
36
+ get_shell_env,
37
+ kill_process_tree,
38
+ sanitize_binary_output,
39
+ )
40
+ from pi_coding.utils.truncate import (
41
+ DEFAULT_MAX_BYTES,
42
+ DEFAULT_MAX_LINES,
43
+ GREP_MAX_LINE_LENGTH,
44
+ TruncationOptions,
45
+ TruncationResult,
46
+ format_size,
47
+ truncate_head,
48
+ truncate_line,
49
+ truncate_tail,
50
+ )
51
+
52
+ __all__ = [
53
+ "EditDiffError",
54
+ "EditDiffResult",
55
+ "FuzzyMatchResult",
56
+ "compute_edit_diff",
57
+ "detect_line_ending",
58
+ "fuzzy_find_text",
59
+ "generate_diff_string",
60
+ "normalize_for_fuzzy_match",
61
+ "normalize_to_lf",
62
+ "restore_line_endings",
63
+ "strip_bom",
64
+ "GitSource",
65
+ "get_current_branch",
66
+ "get_repo_status",
67
+ "parse_git_url",
68
+ "UNICODE_SPACES",
69
+ "expand_path",
70
+ "file_exists",
71
+ "normalize_at_prefix",
72
+ "normalize_unicode_spaces",
73
+ "resolve_read_path",
74
+ "resolve_to_cwd",
75
+ "try_curly_quote_variant",
76
+ "try_macos_screenshot_path",
77
+ "try_nfd_variant",
78
+ "get_shell_config",
79
+ "get_shell_env",
80
+ "kill_process_tree",
81
+ "sanitize_binary_output",
82
+ "DEFAULT_MAX_BYTES",
83
+ "DEFAULT_MAX_LINES",
84
+ "GREP_MAX_LINE_LENGTH",
85
+ "TruncationOptions",
86
+ "TruncationResult",
87
+ "format_size",
88
+ "truncate_head",
89
+ "truncate_line",
90
+ "truncate_tail",
91
+ ]