citrus-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.
Files changed (70) hide show
  1. citrus_code/__init__.py +0 -0
  2. citrus_code/__main__.py +5 -0
  3. citrus_code/agent/__init__.py +0 -0
  4. citrus_code/agent/agent.py +255 -0
  5. citrus_code/agent/cancellation.py +66 -0
  6. citrus_code/agent/context.py +20 -0
  7. citrus_code/agent/events.py +97 -0
  8. citrus_code/agent/hooks.py +37 -0
  9. citrus_code/agent/loop.py +429 -0
  10. citrus_code/agent/message_queue.py +44 -0
  11. citrus_code/agent/state.py +13 -0
  12. citrus_code/agent/tool_runner.py +161 -0
  13. citrus_code/agent/types.py +34 -0
  14. citrus_code/ai/__init__.py +0 -0
  15. citrus_code/ai/completion.py +30 -0
  16. citrus_code/ai/events.py +78 -0
  17. citrus_code/ai/provider.py +20 -0
  18. citrus_code/ai/providers/__init__.py +0 -0
  19. citrus_code/ai/providers/chat_completions.py +503 -0
  20. citrus_code/ai/providers/chat_stream_types.py +47 -0
  21. citrus_code/ai/providers/deepseek.py +104 -0
  22. citrus_code/ai/providers/fake.py +101 -0
  23. citrus_code/ai/providers/tool_call_buffer.py +73 -0
  24. citrus_code/ai/registry.py +40 -0
  25. citrus_code/ai/types.py +142 -0
  26. citrus_code/cli/__init__.py +5 -0
  27. citrus_code/cli/main.py +258 -0
  28. citrus_code/cli/preview.py +55 -0
  29. citrus_code/cli/prompts.py +52 -0
  30. citrus_code/cli/render.py +306 -0
  31. citrus_code/cli/session_files.py +75 -0
  32. citrus_code/cli/terminal.py +280 -0
  33. citrus_code/cli/theme.py +51 -0
  34. citrus_code/coding/__init__.py +0 -0
  35. citrus_code/coding/coding_session.py +287 -0
  36. citrus_code/coding/compaction/__init__.py +5 -0
  37. citrus_code/coding/compaction/controller.py +168 -0
  38. citrus_code/coding/compaction/summarize.py +125 -0
  39. citrus_code/coding/compaction/tokens.py +48 -0
  40. citrus_code/coding/context_hooks.py +41 -0
  41. citrus_code/coding/history.py +47 -0
  42. citrus_code/coding/project_context.py +44 -0
  43. citrus_code/coding/session/__init__.py +30 -0
  44. citrus_code/coding/session/manager.py +200 -0
  45. citrus_code/coding/session/projection.py +50 -0
  46. citrus_code/coding/session/recovery.py +33 -0
  47. citrus_code/coding/session/storage.py +138 -0
  48. citrus_code/coding/session/types.py +108 -0
  49. citrus_code/coding/tools/__init__.py +35 -0
  50. citrus_code/coding/tools/bash.py +5 -0
  51. citrus_code/coding/tools/edit.py +101 -0
  52. citrus_code/coding/tools/edit_text.py +172 -0
  53. citrus_code/coding/tools/factory.py +57 -0
  54. citrus_code/coding/tools/find.py +86 -0
  55. citrus_code/coding/tools/grep.py +151 -0
  56. citrus_code/coding/tools/ls.py +61 -0
  57. citrus_code/coding/tools/mutation.py +39 -0
  58. citrus_code/coding/tools/paths.py +11 -0
  59. citrus_code/coding/tools/powershell.py +5 -0
  60. citrus_code/coding/tools/processes.py +103 -0
  61. citrus_code/coding/tools/read.py +133 -0
  62. citrus_code/coding/tools/shell.py +218 -0
  63. citrus_code/coding/tools/truncate.py +87 -0
  64. citrus_code/coding/tools/write.py +94 -0
  65. citrus_code/config.py +34 -0
  66. citrus_code-0.1.0.dist-info/METADATA +412 -0
  67. citrus_code-0.1.0.dist-info/RECORD +70 -0
  68. citrus_code-0.1.0.dist-info/WHEEL +4 -0
  69. citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
  70. citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,172 @@
1
+ import unicodedata
2
+ from bisect import bisect_right
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class TextEdit(BaseModel):
9
+ model_config = ConfigDict(
10
+ extra="forbid",
11
+ strict=True,
12
+ )
13
+
14
+ old_text: str = Field(
15
+ alias="oldText",
16
+ min_length=1,
17
+ description=(
18
+ "Exact text to replace. It must match "
19
+ "a unique region of the original file."
20
+ ),
21
+ )
22
+
23
+ new_text: str = Field(
24
+ alias="newText",
25
+ description="The replacement text.",
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MatchedEdit:
31
+ edit_index: int
32
+ start: int
33
+ end: int
34
+ new_text: str
35
+
36
+
37
+ def normalize_to_lf(text: str) -> str:
38
+ return text.replace("\r\n", "\n").replace("\r", "\n")
39
+
40
+
41
+ def normalize_for_match(text: str) -> str:
42
+ """Pi's fallback: normalize typography and trailing whitespace, not indentation."""
43
+ text = unicodedata.normalize("NFKC", text)
44
+ text = "\n".join(line.rstrip() for line in text.split("\n"))
45
+ replacements = {
46
+ **dict.fromkeys("\u2018\u2019\u201a\u201b", "'"),
47
+ **dict.fromkeys("\u201c\u201d\u201e\u201f", '"'),
48
+ **dict.fromkeys("\u2010\u2011\u2012\u2013\u2014\u2015\u2212", "-"),
49
+ **dict.fromkeys("\u00a0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000", " "),
50
+ }
51
+ return text.translate(str.maketrans(replacements))
52
+
53
+
54
+ def apply_text_edits(
55
+ content: str,
56
+ edits: list[TextEdit],
57
+ path: str,
58
+ ) -> str:
59
+ if not edits:
60
+ raise ValueError(
61
+ "edits must contain at least one replacement"
62
+ )
63
+
64
+ original = normalize_to_lf(content)
65
+ prepared = [
66
+ (normalize_to_lf(edit.old_text), normalize_to_lf(edit.new_text))
67
+ for edit in edits
68
+ ]
69
+ use_fuzzy = any(old not in original for old, _ in prepared)
70
+ base = normalize_for_match(original) if use_fuzzy else original
71
+
72
+ matches: list[MatchedEdit] = []
73
+
74
+ # 所有匹配都针对同一份原始内容。
75
+ for edit_index, (old_text, new_text) in enumerate(prepared):
76
+ if use_fuzzy:
77
+ old_text = normalize_for_match(old_text)
78
+ if not old_text:
79
+ raise ValueError(f"edits[{edit_index}].oldText must not be empty in {path}")
80
+
81
+ start = base.find(old_text)
82
+
83
+ if start == -1:
84
+ raise ValueError(
85
+ f"Could not find edits[{edit_index}].oldText "
86
+ f"in {path}. The text must match exactly."
87
+ )
88
+
89
+ # Typography-equivalent duplicates are ambiguous even with one exact hit.
90
+ uniqueness_text = normalize_for_match(base)
91
+ uniqueness_old = normalize_for_match(old_text)
92
+ if not uniqueness_old:
93
+ uniqueness_text, uniqueness_old = base, old_text
94
+ first_start = uniqueness_text.find(uniqueness_old)
95
+ second_start = uniqueness_text.find(uniqueness_old, first_start + 1)
96
+
97
+ if second_start != -1:
98
+ raise ValueError(
99
+ f"edits[{edit_index}].oldText is not unique "
100
+ f"in {path}. Provide more surrounding text."
101
+ )
102
+
103
+ matches.append(
104
+ MatchedEdit(
105
+ edit_index=edit_index,
106
+ start=start,
107
+ end=start + len(old_text),
108
+ new_text=new_text,
109
+ )
110
+ )
111
+
112
+ matches.sort(key=lambda match: match.start)
113
+
114
+ # 排序之后,相邻区域就足以用于检测重叠。
115
+ for index in range(1, len(matches)):
116
+ previous = matches[index - 1]
117
+ current = matches[index]
118
+
119
+ if previous.end > current.start:
120
+ raise ValueError(
121
+ f"edits[{previous.edit_index}] and "
122
+ f"edits[{current.edit_index}] overlap "
123
+ f"in {path}. Merge them into one edit."
124
+ )
125
+
126
+ if not use_fuzzy:
127
+ result = original
128
+ # 从后往前替换,保持前面尚未处理的位置有效。
129
+ for match in reversed(matches):
130
+ result = result[:match.start] + match.new_text + result[match.end:]
131
+ else:
132
+ # Only rewrite lines touched by a normalized match. Untouched lines keep
133
+ # their original typography and trailing spaces.
134
+ original_lines = original.splitlines(keepends=True)
135
+ base_lines = base.splitlines(keepends=True)
136
+ if len(original_lines) != len(base_lines):
137
+ raise ValueError("Normalization changed the line structure")
138
+ offsets = [0]
139
+ for line in base_lines:
140
+ offsets.append(offsets[-1] + len(line))
141
+ groups: list[tuple[int, int, list[MatchedEdit]]] = []
142
+ for match in matches:
143
+ start_line = bisect_right(offsets, match.start) - 1
144
+ end_line = bisect_right(offsets, match.end - 1)
145
+ if groups and start_line < groups[-1][1]:
146
+ previous_start, previous_end, group_matches = groups[-1]
147
+ group_matches.append(match)
148
+ groups[-1] = (previous_start, max(previous_end, end_line), group_matches)
149
+ else:
150
+ groups.append((start_line, end_line, [match]))
151
+
152
+ pieces: list[str] = []
153
+ cursor = 0
154
+ for start_line, end_line, group_matches in groups:
155
+ pieces.extend(original_lines[cursor:start_line])
156
+ offset = offsets[start_line]
157
+ block = base[offset:offsets[end_line]]
158
+ for match in reversed(group_matches):
159
+ start, end = match.start - offset, match.end - offset
160
+ block = block[:start] + match.new_text + block[end:]
161
+ pieces.append(block)
162
+ cursor = end_line
163
+ pieces.extend(original_lines[cursor:])
164
+ result = "".join(pieces)
165
+
166
+ if result == original:
167
+ raise ValueError(
168
+ f"No changes made to {path}. "
169
+ "The replacements produced identical content."
170
+ )
171
+
172
+ return result
@@ -0,0 +1,57 @@
1
+ from collections.abc import Callable, Iterable
2
+ from pathlib import Path
3
+
4
+ from ...agent.types import AgentTool
5
+ from .bash import BashTool
6
+ from .edit import EditTool
7
+ from .find import FindTool
8
+ from .grep import GrepTool
9
+ from .ls import LsTool
10
+ from .powershell import PowerShellTool
11
+ from .read import ReadTool
12
+ from .write import WriteTool
13
+
14
+
15
+ DEFAULT_CODING_TOOLS = ("read", "bash", "edit", "write")
16
+ READ_ONLY_TOOLS = ("read", "grep", "find", "ls")
17
+
18
+ _FACTORIES: dict[str, Callable[[Path], AgentTool]] = {
19
+ "read": ReadTool,
20
+ "bash": BashTool,
21
+ "powershell": PowerShellTool,
22
+ "edit": EditTool,
23
+ "write": WriteTool,
24
+ "grep": GrepTool,
25
+ "find": FindTool,
26
+ "ls": LsTool,
27
+ }
28
+
29
+
30
+ def create_tool(name: str, cwd: Path) -> AgentTool:
31
+ factory = _FACTORIES.get(name)
32
+ if factory is None:
33
+ raise ValueError(f"Unknown built-in tool: {name}")
34
+ return factory(cwd)
35
+
36
+
37
+ def create_tools(cwd: Path, names: Iterable[str]) -> list[AgentTool]:
38
+ tools: list[AgentTool] = []
39
+ seen: set[str] = set()
40
+ for name in names:
41
+ if name in seen:
42
+ raise ValueError(f"Duplicate built-in tool: {name}")
43
+ seen.add(name)
44
+ tools.append(create_tool(name, cwd))
45
+ return tools
46
+
47
+
48
+ def create_coding_tools(cwd: Path) -> list[AgentTool]:
49
+ return create_tools(cwd, DEFAULT_CODING_TOOLS)
50
+
51
+
52
+ def create_read_only_tools(cwd: Path) -> list[AgentTool]:
53
+ return create_tools(cwd, READ_ONLY_TOOLS)
54
+
55
+
56
+ def create_all_tools(cwd: Path) -> dict[str, AgentTool]:
57
+ return {name: factory(cwd) for name, factory in _FACTORIES.items()}
@@ -0,0 +1,86 @@
1
+ import os
2
+ import sys
3
+ from contextlib import aclosing
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
7
+
8
+ from ...agent.types import ToolExecutionResult
9
+ from ...ai.types import TextContent, ToolDefinition
10
+ from .paths import resolve_to_cwd
11
+ from .processes import command_lines, find_executable
12
+ from .truncate import truncate_text
13
+
14
+
15
+ class FindArguments(BaseModel):
16
+ model_config = ConfigDict(extra="forbid", strict=True)
17
+
18
+ pattern: str = Field(min_length=1, description="Glob pattern, e.g. '*.py' or 'src/**/*.py'.")
19
+ path: str = Field(default=".", description="Directory to search.")
20
+ limit: int = Field(default=1000, ge=1, description="Maximum results to return.")
21
+
22
+
23
+ class FindTool:
24
+ name: str = "find"
25
+ description: str = (
26
+ "Find paths by glob pattern, respecting .gitignore and including hidden "
27
+ "files. Results are relative to the search directory. Defaults to 1000 "
28
+ "results, with a 50KB output cap."
29
+ )
30
+
31
+ def __init__(self, cwd: Path) -> None:
32
+ self._cwd = cwd.resolve()
33
+
34
+ def get_definition(self) -> ToolDefinition:
35
+ return ToolDefinition(
36
+ name=self.name,
37
+ description=self.description,
38
+ parameters=FindArguments.model_json_schema(),
39
+ )
40
+
41
+ async def execute(
42
+ self, tool_call_id: str, arguments: dict[str, JsonValue]
43
+ ) -> ToolExecutionResult:
44
+ args = FindArguments.model_validate(arguments)
45
+ root = resolve_to_cwd(args.path, self._cwd)
46
+ if not root.is_dir():
47
+ raise NotADirectoryError(f"Not a directory: {root}")
48
+ command = [find_executable("fd"), "--glob", "--color=never", "--hidden", "--max-results", str(args.limit)]
49
+ if not any((parent / ".git").exists() for parent in (root, *root.parents)):
50
+ command.append("--no-require-git")
51
+ pattern = args.pattern
52
+ if "/" in pattern:
53
+ command.append("--full-path")
54
+ if not pattern.startswith(("/", "**/")) and pattern != "**":
55
+ pattern = "**/" + pattern
56
+ if os.name == "nt":
57
+ pattern = pattern.replace("/", r"[/\\]")
58
+ command.extend(["--", pattern, str(root)])
59
+ results: list[str] = []
60
+ async with aclosing(command_lines(command, self._cwd)) as stream:
61
+ async for line in stream:
62
+ trailing = line.endswith(("/", "\\"))
63
+ path = Path(line)
64
+ relative = path.relative_to(root) if path.is_absolute() else path
65
+ label = relative.as_posix()
66
+ if trailing and not label.endswith("/"):
67
+ label += "/"
68
+ results.append(label)
69
+ if len(results) >= args.limit:
70
+ break
71
+
72
+ if not results:
73
+ return ToolExecutionResult(content=[TextContent(text="No files found matching pattern")])
74
+ truncation = truncate_text("\n".join(results), max_lines=sys.maxsize)
75
+ text = truncation.content
76
+ details: dict[str, JsonValue] = {}
77
+ notices: list[str] = []
78
+ if len(results) >= args.limit:
79
+ details["resultLimitReached"] = args.limit
80
+ notices.append(f"{args.limit} results limit reached. Refine the pattern or increase limit")
81
+ if truncation.truncated:
82
+ details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
83
+ notices.append("50KB output limit reached")
84
+ if notices:
85
+ text += "\n\n[" + ". ".join(notices) + "]"
86
+ return ToolExecutionResult(content=[TextContent(text=text)], details=details)
@@ -0,0 +1,151 @@
1
+ import asyncio
2
+ import base64
3
+ import os
4
+ import sys
5
+ from contextlib import aclosing
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
9
+
10
+ from ...agent.types import ToolExecutionResult
11
+ from ...ai.types import TextContent, ToolDefinition
12
+ from .paths import resolve_to_cwd
13
+ from .processes import command_lines, find_executable
14
+ from .truncate import DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH, truncate_text
15
+
16
+
17
+ class GrepArguments(BaseModel):
18
+ model_config = ConfigDict(extra="forbid", strict=True)
19
+
20
+ pattern: str = Field(description="Search pattern: regex, or a literal string when literal is true.")
21
+ path: str = Field(default=".", description="File or directory to search.")
22
+ glob: str | None = Field(default=None, description="Filter files, e.g. '*.py'.")
23
+ ignore_case: bool = Field(default=False, alias="ignoreCase")
24
+ literal: bool = False
25
+ context: int = Field(default=0, ge=0, description="Context lines before and after each match.")
26
+ limit: int = Field(default=100, ge=1, description="Maximum matching lines to return.")
27
+
28
+
29
+ class _RgText(BaseModel):
30
+ model_config = ConfigDict(extra="ignore")
31
+ text: str | None = None
32
+ bytes: str | None = None
33
+
34
+ def value(self) -> str:
35
+ if self.text is not None:
36
+ return self.text
37
+ if self.bytes is not None:
38
+ return os.fsdecode(base64.b64decode(self.bytes))
39
+ raise ValueError("ripgrep returned an empty text field")
40
+
41
+
42
+ class _RgData(BaseModel):
43
+ model_config = ConfigDict(extra="ignore")
44
+ path: _RgText | None = None
45
+ lines: _RgText | None = None
46
+ line_number: int | None = None
47
+
48
+
49
+ class _RgEvent(BaseModel):
50
+ model_config = ConfigDict(extra="ignore")
51
+ type: str
52
+ data: _RgData | None = None
53
+
54
+
55
+ class GrepTool:
56
+ name: str = "grep"
57
+ description: str = (
58
+ "Search file contents using regex or literal text. Respects .gitignore, "
59
+ "includes hidden files, and returns paths with line numbers. Default limit "
60
+ "is 100 matching lines, with a 50KB output cap and 500 characters per line."
61
+ )
62
+
63
+ def __init__(self, cwd: Path) -> None:
64
+ self._cwd = cwd.resolve()
65
+
66
+ def get_definition(self) -> ToolDefinition:
67
+ return ToolDefinition(
68
+ name=self.name,
69
+ description=self.description,
70
+ parameters=GrepArguments.model_json_schema(by_alias=True),
71
+ )
72
+
73
+ async def execute(
74
+ self, tool_call_id: str, arguments: dict[str, JsonValue]
75
+ ) -> ToolExecutionResult:
76
+ args = GrepArguments.model_validate(arguments)
77
+ root = resolve_to_cwd(args.path, self._cwd)
78
+ if not root.exists():
79
+ raise FileNotFoundError(f"Path not found: {root}")
80
+ command = [find_executable("rg"), "--json", "--line-number", "--color=never", "--hidden", "--sort=path"]
81
+ if args.ignore_case:
82
+ command.append("--ignore-case")
83
+ if args.literal:
84
+ command.append("--fixed-strings")
85
+ if args.glob is not None:
86
+ command.extend(["--glob", args.glob])
87
+ command.extend(["--", args.pattern, str(root)])
88
+
89
+ matches: list[tuple[Path, int, str]] = []
90
+ async with aclosing(command_lines(command, self._cwd, success_codes=(0, 1))) as stream:
91
+ async for raw in stream:
92
+ event = _RgEvent.model_validate_json(raw)
93
+ if event.type != "match":
94
+ continue
95
+ data = event.data
96
+ if data is None or data.path is None or data.lines is None or data.line_number is None:
97
+ raise ValueError("ripgrep returned an invalid match")
98
+ matches.append((Path(data.path.value()), data.line_number, data.lines.value()))
99
+ if len(matches) >= args.limit:
100
+ break
101
+
102
+ if not matches:
103
+ return ToolExecutionResult(content=[TextContent(text="No matches found")])
104
+
105
+ output: list[str] = []
106
+ cache: dict[Path, list[str]] = {}
107
+ lines_truncated = False
108
+ output_bytes = 0
109
+ for path, line_number, match_text in matches:
110
+ label = path.relative_to(root).as_posix() if root.is_dir() else path.name
111
+ if args.context:
112
+ if path not in cache:
113
+ text = await asyncio.to_thread(path.read_text, encoding="utf-8", errors="replace")
114
+ cache[path] = text.splitlines()
115
+ lines = cache[path]
116
+ start = max(1, line_number - args.context)
117
+ end = min(len(lines), line_number + args.context)
118
+ else:
119
+ lines = [match_text.rstrip("\r\n")]
120
+ start = end = line_number
121
+
122
+ for number in range(start, end + 1):
123
+ text = lines[number - 1] if args.context else lines[0]
124
+ if len(text) > GREP_MAX_LINE_LENGTH:
125
+ text = text[:GREP_MAX_LINE_LENGTH] + "... [truncated]"
126
+ lines_truncated = True
127
+ separator = ":" if number == line_number else "-"
128
+ row = f"{label}{separator}{number}{separator} {text}"
129
+ output.append(row)
130
+ output_bytes += len(row.encode("utf-8")) + 1
131
+ if output_bytes > DEFAULT_MAX_BYTES:
132
+ break
133
+ if output_bytes > DEFAULT_MAX_BYTES:
134
+ break
135
+
136
+ truncation = truncate_text("\n".join(output), max_lines=sys.maxsize)
137
+ text = truncation.content
138
+ details: dict[str, JsonValue] = {}
139
+ notices: list[str] = []
140
+ if len(matches) >= args.limit:
141
+ details["matchLimitReached"] = args.limit
142
+ notices.append(f"{args.limit} matches limit reached. Refine the pattern or increase limit")
143
+ if truncation.truncated:
144
+ details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
145
+ notices.append("50KB output limit reached")
146
+ if lines_truncated:
147
+ details["linesTruncated"] = True
148
+ notices.append("Some lines were truncated to 500 characters. Use read for full lines")
149
+ if notices:
150
+ text += "\n\n[" + ". ".join(notices) + "]"
151
+ return ToolExecutionResult(content=[TextContent(text=text)], details=details)
@@ -0,0 +1,61 @@
1
+ import asyncio
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
6
+
7
+ from ...agent.types import ToolExecutionResult
8
+ from ...ai.types import TextContent, ToolDefinition
9
+ from .paths import resolve_to_cwd
10
+ from .truncate import truncate_text
11
+
12
+
13
+ class LsArguments(BaseModel):
14
+ model_config = ConfigDict(extra="forbid", strict=True)
15
+
16
+ path: str = Field(default=".", description="Directory to list.")
17
+ limit: int = Field(default=500, ge=1, description="Maximum entries to return.")
18
+
19
+
20
+ class LsTool:
21
+ name: str = "ls"
22
+ description: str = (
23
+ "List directory entries alphabetically, including dotfiles. Directories "
24
+ "have a '/' suffix. Defaults to 500 entries, with a 50KB output cap."
25
+ )
26
+
27
+ def __init__(self, cwd: Path) -> None:
28
+ self._cwd = cwd.resolve()
29
+
30
+ def get_definition(self) -> ToolDefinition:
31
+ return ToolDefinition(
32
+ name=self.name,
33
+ description=self.description,
34
+ parameters=LsArguments.model_json_schema(),
35
+ )
36
+
37
+ async def execute(
38
+ self, tool_call_id: str, arguments: dict[str, JsonValue]
39
+ ) -> ToolExecutionResult:
40
+ args = LsArguments.model_validate(arguments)
41
+ path = resolve_to_cwd(args.path, self._cwd)
42
+ return await asyncio.to_thread(self._list, path, args.limit)
43
+
44
+ def _list(self, path: Path, limit: int) -> ToolExecutionResult:
45
+ if not path.is_dir():
46
+ raise NotADirectoryError(f"Not a directory: {path}")
47
+ entries = sorted(path.iterdir(), key=lambda entry: (entry.name.casefold(), entry.name))
48
+ labels = [entry.name + ("/" if entry.is_dir() else "") for entry in entries[:limit]]
49
+ truncation = truncate_text("\n".join(labels), max_lines=sys.maxsize)
50
+ text = truncation.content or "(empty directory)"
51
+ details: dict[str, JsonValue] = {}
52
+ notices: list[str] = []
53
+ if len(entries) > limit:
54
+ details["entryLimitReached"] = limit
55
+ notices.append(f"{limit} entries limit reached. Increase limit for more")
56
+ if truncation.truncated:
57
+ details["truncation"] = truncation.model_dump(mode="json", exclude={"content"})
58
+ notices.append("50KB output limit reached")
59
+ if notices:
60
+ text += "\n\n[" + ". ".join(notices) + "]"
61
+ return ToolExecutionResult(content=[TextContent(text=text)], details=details)
@@ -0,0 +1,39 @@
1
+ import os
2
+ from _thread import LockType
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from threading import Lock
8
+
9
+
10
+ @dataclass
11
+ class _Entry:
12
+ lock: LockType = field(default_factory=Lock)
13
+ users: int = 0
14
+
15
+
16
+ _entries: dict[str, _Entry] = {}
17
+ _registry_lock = Lock()
18
+
19
+
20
+ @contextmanager
21
+ def file_mutation(path: Path) -> Iterator[None]:
22
+ """Serialize writes to one path inside the actual worker thread.
23
+
24
+ Cancelling the awaiting coroutine cannot release this lock while the
25
+ filesystem operation is still running. Different paths use different locks.
26
+ """
27
+ key = os.path.normcase(str(path.resolve()))
28
+ with _registry_lock:
29
+ entry = _entries.setdefault(key, _Entry())
30
+ entry.users += 1
31
+
32
+ try:
33
+ with entry.lock:
34
+ yield
35
+ finally:
36
+ with _registry_lock:
37
+ entry.users -= 1
38
+ if entry.users == 0:
39
+ del _entries[key]
@@ -0,0 +1,11 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def resolve_to_cwd(value: str, cwd: Path) -> Path:
5
+ """Resolve model-supplied paths against the tool's working directory."""
6
+ if value.startswith("@"):
7
+ value = value[1:]
8
+ path = Path(value).expanduser()
9
+ if not path.is_absolute():
10
+ path = cwd / path
11
+ return path.resolve()
@@ -0,0 +1,5 @@
1
+ from .shell import ShellTool
2
+
3
+
4
+ class PowerShellTool(ShellTool):
5
+ name: str = "powershell"
@@ -0,0 +1,103 @@
1
+ import asyncio
2
+ import os
3
+ import shutil
4
+ import signal
5
+ import subprocess
6
+ from collections.abc import AsyncIterator
7
+ from pathlib import Path
8
+ from typing import BinaryIO
9
+
10
+
11
+ def find_executable(name: str) -> str:
12
+ """Use PATH or pi's already-installed helpers; never download executables."""
13
+ found = shutil.which(name)
14
+ if found:
15
+ return found
16
+ suffix = ".exe" if os.name == "nt" else ""
17
+ bundled = Path.home() / ".pi" / "agent" / "bin" / f"{name}{suffix}"
18
+ if bundled.is_file():
19
+ return str(bundled)
20
+ raise FileNotFoundError(f"{name} is required. Install it and add it to PATH.")
21
+
22
+
23
+ async def spawn_process(
24
+ command: list[str],
25
+ cwd: Path,
26
+ *,
27
+ merge_stderr: bool = False,
28
+ output: BinaryIO | None = None,
29
+ ) -> asyncio.subprocess.Process:
30
+ return await asyncio.create_subprocess_exec(
31
+ *command,
32
+ cwd=cwd,
33
+ stdin=asyncio.subprocess.DEVNULL,
34
+ stdout=output if output is not None else asyncio.subprocess.PIPE,
35
+ stderr=(asyncio.subprocess.STDOUT if merge_stderr else asyncio.subprocess.PIPE),
36
+ start_new_session=os.name != "nt",
37
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
38
+ limit=8 * 1024 * 1024,
39
+ )
40
+
41
+
42
+ async def stop_process(process: asyncio.subprocess.Process) -> None:
43
+ """Stop a running command and its process tree on cancellation or timeout."""
44
+ if process.returncode is not None:
45
+ return
46
+ if os.name == "nt":
47
+ killer = await asyncio.create_subprocess_exec(
48
+ "taskkill", "/PID", str(process.pid), "/T", "/F",
49
+ stdout=asyncio.subprocess.DEVNULL,
50
+ stderr=asyncio.subprocess.DEVNULL,
51
+ creationflags=subprocess.CREATE_NO_WINDOW,
52
+ )
53
+ await killer.wait()
54
+ if process.returncode is None:
55
+ try:
56
+ process.kill()
57
+ except ProcessLookupError:
58
+ pass
59
+ else:
60
+ try:
61
+ os.killpg(process.pid, signal.SIGKILL)
62
+ except ProcessLookupError:
63
+ pass
64
+
65
+
66
+ async def command_lines(
67
+ command: list[str],
68
+ cwd: Path,
69
+ *,
70
+ success_codes: tuple[int, ...] = (0,),
71
+ ) -> AsyncIterator[str]:
72
+ """Stream search output; closing the generator also reaps its child process."""
73
+ process = await spawn_process(command, cwd)
74
+ stderr = bytearray()
75
+
76
+ async def drain_errors() -> None:
77
+ if process.stderr is None:
78
+ return
79
+ while chunk := await process.stderr.read(8192):
80
+ if len(stderr) < 50 * 1024:
81
+ stderr.extend(chunk[:50 * 1024 - len(stderr)])
82
+
83
+ error_task = asyncio.create_task(drain_errors())
84
+ try:
85
+ if process.stdout is None:
86
+ raise RuntimeError("The process has no stdout pipe")
87
+ async for line in process.stdout:
88
+ yield line.decode("utf-8", errors="replace").rstrip("\r\n")
89
+ code = await process.wait()
90
+ await error_task
91
+ if code not in success_codes:
92
+ message = stderr.decode("utf-8", errors="replace").strip()
93
+ raise RuntimeError(message or f"Command exited with code {code}")
94
+ finally:
95
+ await stop_process(process)
96
+ if process.stdout is not None:
97
+ # A killed child may leave unread bytes in a paused pipe. Drain them
98
+ # before waiting for asyncio's process transport to close.
99
+ await process.stdout.read()
100
+ await process.wait()
101
+ if not error_task.done():
102
+ error_task.cancel()
103
+ await asyncio.gather(error_task, return_exceptions=True)