symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,167 @@
1
+ """Read a text or image file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import Field
8
+
9
+ from coding_agent.config import ReadFileConfig
10
+ from coding_agent.tools.base import ToolArgsModel, WorkspaceTool
11
+ from core_ai.content import image_part_from_bytes, sniff_image_media_type
12
+
13
+ DEFAULT_READ_FILE_CONFIG = ReadFileConfig()
14
+
15
+
16
+ class ReadFileArgs(ToolArgsModel):
17
+ path: str = Field(
18
+ ...,
19
+ min_length=1,
20
+ description=(
21
+ "Path to a UTF-8 text file or image (png, jpeg, gif, webp, bmp, tiff). "
22
+ "Relative paths start at the working directory; absolute and ~ paths are allowed."
23
+ ),
24
+ )
25
+ offset: int = Field(
26
+ default=1,
27
+ ge=1,
28
+ description=(
29
+ "1-based line number to start reading from. Use to page "
30
+ "through large text files (e.g. offset=501 to read past the cap). "
31
+ "Ignored for images."
32
+ ),
33
+ )
34
+ limit: int = Field(
35
+ default=0,
36
+ ge=0,
37
+ description=(
38
+ "Maximum number of lines to return. 0 means no line cap; "
39
+ "the byte cap still applies. Ignored for images."
40
+ ),
41
+ )
42
+
43
+
44
+ class ReadFileTool(WorkspaceTool):
45
+ name = "read_file"
46
+ description = (
47
+ "Read a file. UTF-8 text is returned with line numbers "
48
+ "(` 12|code`, capped at ~32KB; pass offset to page). Images (png, jpeg, "
49
+ "gif, webp, bmp, tiff) are returned as visual content the model can see. "
50
+ "Relative paths start at the working directory; absolute and ~ paths are allowed."
51
+ )
52
+ args_model = ReadFileArgs
53
+
54
+ def __init__(
55
+ self,
56
+ workspace: str | Path,
57
+ *,
58
+ config: ReadFileConfig = DEFAULT_READ_FILE_CONFIG,
59
+ ) -> None:
60
+ self.config = config
61
+ super().__init__(workspace, parallel=True)
62
+
63
+ def run(
64
+ self, path: str, offset: int = 1, limit: int = 0
65
+ ) -> str | list[dict[str, object]]:
66
+ try:
67
+ target = self.resolve_path(path)
68
+ except (TypeError, ValueError) as exc:
69
+ return f"error: {exc}"
70
+
71
+ if not isinstance(offset, int) or offset < 1:
72
+ return "error: offset must be an integer >= 1"
73
+ if not isinstance(limit, int) or limit < 0:
74
+ return "error: limit must be an integer >= 0"
75
+
76
+ if not target.exists():
77
+ return f"error: file not found: {path}"
78
+ if not target.is_file():
79
+ return f"error: not a file: {path}"
80
+
81
+ try:
82
+ size = target.stat().st_size
83
+ with target.open("rb") as handle:
84
+ header = handle.read(32)
85
+ except OSError as exc:
86
+ return f"error: failed to read {path}: {exc}"
87
+
88
+ media_type = sniff_image_media_type(header, filename=target.name)
89
+ if media_type:
90
+ return self.read_image(target, path, media_type, size)
91
+ return self.read_text(target, path, offset, limit)
92
+
93
+ def read_image(
94
+ self,
95
+ target: Path,
96
+ path: str,
97
+ media_type: str,
98
+ size: int,
99
+ ) -> str | list[dict[str, object]]:
100
+ if size > self.config.max_image_bytes:
101
+ return (
102
+ f"error: image exceeds {self.config.max_image_bytes:,} bytes: {path} "
103
+ f"({size:,} bytes)"
104
+ )
105
+ try:
106
+ payload = target.read_bytes()
107
+ except OSError as exc:
108
+ return f"error: failed to read {path}: {exc}"
109
+ media_type = sniff_image_media_type(payload, filename=target.name) or media_type
110
+ return [
111
+ {
112
+ "type": "text",
113
+ "text": f"Read image {path} ({media_type}, {size:,} bytes)",
114
+ },
115
+ image_part_from_bytes(
116
+ payload,
117
+ media_type=media_type,
118
+ filename=target.name,
119
+ ),
120
+ ]
121
+
122
+ def read_text(self, target: Path, path: str, offset: int, limit: int) -> str:
123
+ try:
124
+ with target.open("r", encoding="utf-8") as fh:
125
+ lines: list[str] = []
126
+ bytes_read = 0
127
+ collected = 0
128
+ truncated = False
129
+ for line_no, line in enumerate(fh, start=1):
130
+ if line_no < offset:
131
+ continue
132
+ if limit and collected >= limit:
133
+ break
134
+ encoded = line.encode("utf-8")
135
+ if bytes_read + len(encoded) > self.config.max_text_bytes:
136
+ truncated = True
137
+ break
138
+ lines.append(line)
139
+ bytes_read += len(encoded)
140
+ collected += 1
141
+ except UnicodeDecodeError:
142
+ return f"error: file is not valid UTF-8 text: {path}"
143
+ except OSError as exc:
144
+ return f"error: failed to read {path}: {exc}"
145
+
146
+ text = numbered_text(lines, offset)
147
+ if truncated:
148
+ text += (
149
+ f"\n<output truncated at {bytes_read} bytes; "
150
+ f"read again with offset={offset + collected} to continue>"
151
+ )
152
+ return text
153
+
154
+
155
+ def numbered_text(lines: list[str], start: int) -> str:
156
+ if not lines:
157
+ return ""
158
+ last = start + len(lines) - 1
159
+ width = max(4, len(str(last)))
160
+ rendered: list[str] = []
161
+ for index, line in enumerate(lines):
162
+ body = line[:-1] if line.endswith("\n") else line
163
+ rendered.append(f"{start + index:>{width}}|{body}")
164
+ text = "\n".join(rendered)
165
+ if lines[-1].endswith("\n"):
166
+ text += "\n"
167
+ return text
@@ -0,0 +1,433 @@
1
+ """Unified search for file names and text content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from pydantic import Field
14
+
15
+ from coding_agent.config import SearchConfig
16
+ from coding_agent.tools.base import ToolArgsModel, WorkspaceTool
17
+
18
+ DEFAULT_SKIP_DIRS = {
19
+ ".git",
20
+ ".hg",
21
+ ".svn",
22
+ ".venv",
23
+ "venv",
24
+ "node_modules",
25
+ "__pycache__",
26
+ ".mypy_cache",
27
+ ".pytest_cache",
28
+ ".ruff_cache",
29
+ "dist",
30
+ "build",
31
+ ".tox",
32
+ }
33
+
34
+ Rule = tuple[re.Pattern, bool, bool] # (regex, dir_only, negated)
35
+
36
+
37
+ def glob_to_regex(pattern: str) -> str:
38
+ """Convert a gitignore-style glob into a regex fragment.
39
+
40
+ ``*`` matches within a path segment, ``**`` crosses segments, and
41
+ ``?`` matches one non-slash character.
42
+ """
43
+ i = 0
44
+ n = len(pattern)
45
+ out: list[str] = []
46
+ while i < n:
47
+ c = pattern[i]
48
+ if c == "*":
49
+ if i + 1 < n and pattern[i + 1] == "*":
50
+ out.append(".*")
51
+ i += 2
52
+ else:
53
+ out.append("[^/]*")
54
+ i += 1
55
+ elif c == "?":
56
+ out.append("[^/]")
57
+ i += 1
58
+ else:
59
+ out.append(re.escape(c))
60
+ i += 1
61
+ return "".join(out)
62
+
63
+
64
+ def parse_line(line: str) -> Rule | None:
65
+ """Parse one gitignore line into a rule, or None to skip it.
66
+
67
+ Supports the common subset: blank lines, ``#`` comments, ``!``
68
+ negation, trailing ``/`` for directory-only rules, and ``/``-anchored
69
+ patterns. Last matching rule wins.
70
+ """
71
+ line = line.strip()
72
+ if not line or line.startswith("#"):
73
+ return None
74
+ negated = False
75
+ if line.startswith("!"):
76
+ negated = True
77
+ line = line[1:].strip()
78
+ if not line:
79
+ return None
80
+ dir_only = line.endswith("/")
81
+ if dir_only:
82
+ line = line.rstrip("/")
83
+ anchored = line.startswith("/")
84
+ line = line.lstrip("/")
85
+ if anchored or "/" in line:
86
+ regex = re.compile(glob_to_regex(line))
87
+ else:
88
+ regex = re.compile(r"(?:.*/)?" + glob_to_regex(line))
89
+ return regex, dir_only, negated
90
+
91
+
92
+ def is_matched(rules: tuple[Rule, ...], rel_path: str, is_dir: bool) -> bool | None:
93
+ """Last matching rule's ignore status, or None if no rule matched."""
94
+ result = None
95
+ for regex, dir_only, negated in rules:
96
+ if dir_only and not is_dir:
97
+ continue
98
+ if regex.fullmatch(rel_path):
99
+ result = not negated
100
+ return result
101
+
102
+
103
+ def rel_ignored(rules: tuple[Rule, ...], rel_path: str) -> bool:
104
+ """Whether ``rel_path`` (posix) is ignored, checking each ancestor dir."""
105
+ if rel_path in (".", ""):
106
+ return False
107
+ parts = rel_path.split("/")
108
+ ignored = False
109
+ for i in range(1, len(parts) + 1):
110
+ candidate = "/".join(parts[:i])
111
+ m = is_matched(rules, candidate, i < len(parts))
112
+ if m is True:
113
+ ignored = True
114
+ elif m is False and not ignored:
115
+ ignored = False
116
+ return ignored
117
+
118
+
119
+ @lru_cache(maxsize=128)
120
+ def load_rules(
121
+ workspace: Path,
122
+ ) -> tuple[tuple[Path, tuple[Rule, ...]], ...]:
123
+ """Parse every .gitignore under the workspace, keyed by its parent dir."""
124
+ matchers: list[tuple[Path, tuple[Rule, ...]]] = []
125
+ if not workspace.exists():
126
+ return tuple(matchers)
127
+ for gif in workspace.rglob(".gitignore"):
128
+ if any(part in DEFAULT_SKIP_DIRS for part in gif.parts):
129
+ continue
130
+ try:
131
+ rules = tuple(filter(None, (parse_line(line) for line in gif.read_text(encoding="utf-8").splitlines())))
132
+ except (OSError, UnicodeDecodeError):
133
+ continue
134
+ matchers.append((gif.parent, rules))
135
+ return tuple(matchers)
136
+
137
+
138
+ def is_ignored(path: Path, workspace: Path) -> bool:
139
+ """Whether ``path`` is matched by a .gitignore under ``workspace``."""
140
+ for base, rules in load_rules(workspace):
141
+ if path.is_relative_to(base) and rel_ignored(rules, path.relative_to(base).as_posix()):
142
+ return True
143
+ return False
144
+
145
+
146
+ def _display_path(path: Path, base: Path) -> str:
147
+ try:
148
+ return path.relative_to(base).as_posix()
149
+ except ValueError:
150
+ return str(path)
151
+
152
+
153
+ DEFAULT_SEARCH_CONFIG = SearchConfig()
154
+
155
+
156
+ def find_rg() -> Optional[str]:
157
+ """Path to ripgrep, or ``None`` so search falls back to the Python walker."""
158
+ return shutil.which("rg")
159
+
160
+
161
+ class RgUnavailable(Exception):
162
+ """ripgrep could not run this query; use the Python walker instead."""
163
+
164
+
165
+ class SearchArgs(ToolArgsModel):
166
+ query: str = Field(..., min_length=1, description="Text or regular expression to search for.")
167
+ mode: str = Field(default="content", description="Search mode: 'content' or 'files'.")
168
+ path: str = Field(
169
+ default=".",
170
+ description="File or directory to search. Relative paths start at the working directory.",
171
+ )
172
+ glob: str = Field(default="", description="Optional file glob, for example '*.py'.")
173
+ regex: bool = Field(default=False, description="Interpret query as a regular expression.")
174
+ case_insensitive: bool = Field(default=False, description="Match without regard to case.")
175
+ max_results: int = Field(default=DEFAULT_SEARCH_CONFIG.default_max_results, ge=1)
176
+ max_line_chars: int = Field(default=DEFAULT_SEARCH_CONFIG.default_max_line_chars, ge=1)
177
+
178
+
179
+ def _strip_dot(path: str) -> str:
180
+ return path[2:] if path.startswith("./") else path
181
+
182
+
183
+ class SearchTool(WorkspaceTool):
184
+ name = "search"
185
+ description = (
186
+ "Search file names or text content. Returns file paths for mode=files "
187
+ "and path:line:content for mode=content. Supports literal or regex queries, "
188
+ "path/glob scoping, case-insensitive matching, and bounded output. "
189
+ "Relative paths start at the working directory. Uses ripgrep when installed."
190
+ )
191
+ args_model = SearchArgs
192
+
193
+ def __init__(
194
+ self,
195
+ workspace: str | Path,
196
+ *,
197
+ config: SearchConfig = DEFAULT_SEARCH_CONFIG,
198
+ ) -> None:
199
+ self.config = config
200
+ super().__init__(workspace, parallel=True)
201
+ properties = self.parameters["properties"]
202
+ properties["max_results"]["default"] = config.default_max_results
203
+ properties["max_line_chars"]["default"] = config.default_max_line_chars
204
+
205
+ def prepare_args(self, args: dict[str, object]) -> dict[str, object]:
206
+ prepared = super().prepare_args(args)
207
+ prepared.setdefault("max_results", self.config.default_max_results)
208
+ prepared.setdefault("max_line_chars", self.config.default_max_line_chars)
209
+ return prepared
210
+
211
+ def run(
212
+ self,
213
+ query: str,
214
+ mode: str = "content",
215
+ path: str = ".",
216
+ glob: str = "",
217
+ regex: bool = False,
218
+ case_insensitive: bool = False,
219
+ max_results: int = DEFAULT_SEARCH_CONFIG.default_max_results,
220
+ max_line_chars: int = DEFAULT_SEARCH_CONFIG.default_max_line_chars,
221
+ ) -> str:
222
+ mode = mode.strip().lower()
223
+ if mode not in {"content", "files"}:
224
+ return "error: mode must be 'content' or 'files'"
225
+ try:
226
+ root = self.resolve_path(path)
227
+ except (TypeError, ValueError) as exc:
228
+ return f"error: {exc}"
229
+ if not root.exists():
230
+ return f"error: path not found: {path}"
231
+
232
+ flags = re.IGNORECASE if case_insensitive else 0
233
+ try:
234
+ matcher = re.compile(query if regex else re.escape(query), flags)
235
+ except re.error as exc:
236
+ return f"error: invalid regex: {exc}"
237
+
238
+ limit = max(1, max_results)
239
+ line_cap = max(1, max_line_chars)
240
+ base = root if root.is_dir() else root.parent
241
+
242
+ rg = find_rg() if root.is_dir() else None
243
+ if rg is not None:
244
+ try:
245
+ results = self._rg_search(
246
+ rg, root, mode=mode, query=query, glob=glob, regex=regex,
247
+ case_insensitive=case_insensitive, matcher=matcher,
248
+ limit=limit, line_cap=line_cap,
249
+ )
250
+ except RgUnavailable:
251
+ results = self._python_search(root, base, mode, glob, matcher, limit, line_cap)
252
+ else:
253
+ results = self._python_search(root, base, mode, glob, matcher, limit, line_cap)
254
+
255
+ if not results:
256
+ return f"no {mode} matches for {query!r}"
257
+ suffix = " (capped)" if len(results) >= limit else ""
258
+ return f"{len(results)} {mode} matches{suffix}\n" + "\n".join(results)
259
+
260
+ def _rg_search(
261
+ self,
262
+ rg: str,
263
+ root: Path,
264
+ *,
265
+ mode: str,
266
+ query: str,
267
+ glob: str,
268
+ regex: bool,
269
+ case_insensitive: bool,
270
+ matcher: re.Pattern,
271
+ limit: int,
272
+ line_cap: int,
273
+ ) -> list[str]:
274
+ """Search ``root`` (a directory) with ripgrep. Output matches the Python walker.
275
+
276
+ ripgrep already skips hidden files, binary files, and gitignored paths;
277
+ ``--no-require-git`` applies .gitignore outside a git checkout too, and
278
+ the default skip directories are excluded explicitly.
279
+ """
280
+ command = [rg, "--color=never", "--sort=path", "--no-require-git", "--no-messages"]
281
+ for name in sorted(DEFAULT_SKIP_DIRS):
282
+ command += ["--glob", f"!{name}/"]
283
+ if glob:
284
+ command += ["--glob", glob]
285
+ if mode == "files":
286
+ command.append("--files")
287
+ else:
288
+ command += ["--line-number", "--with-filename", "--no-heading", "--null"]
289
+ if case_insensitive:
290
+ command.append("--ignore-case")
291
+ if not regex:
292
+ command.append("--fixed-strings")
293
+ command += ["--regexp", query]
294
+ command.append(".")
295
+
296
+ results: list[str] = []
297
+ try:
298
+ process = subprocess.Popen(
299
+ command, cwd=root, stdin=subprocess.DEVNULL,
300
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
301
+ )
302
+ except OSError as exc:
303
+ raise RgUnavailable(str(exc)) from exc
304
+ if process.stdout is None:
305
+ process.kill()
306
+ process.wait()
307
+ raise RgUnavailable("rg produced no output stream")
308
+ capped = False
309
+ try:
310
+ for raw in process.stdout:
311
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
312
+ if mode == "files":
313
+ relative = _strip_dot(line)
314
+ if not matcher.search(relative):
315
+ continue
316
+ results.append(relative)
317
+ else:
318
+ path, sep, rest = line.partition("\0")
319
+ line_no, sep2, content = rest.partition(":")
320
+ if not sep or not sep2 or not line_no.isdigit():
321
+ continue
322
+ content = content.rstrip()
323
+ if len(content) > line_cap:
324
+ content = content[:line_cap] + "…"
325
+ results.append(f"{_strip_dot(path)}:{line_no}:{content}")
326
+ if len(results) >= limit:
327
+ capped = True
328
+ break
329
+ finally:
330
+ if capped:
331
+ process.kill()
332
+ process.stdout.close()
333
+ code = process.wait()
334
+ # 0 = matches, 1 = no matches. Anything else (e.g. a regex ripgrep
335
+ # does not support) falls back to Python's re semantics.
336
+ if not capped and code not in (0, 1):
337
+ raise RgUnavailable(f"rg exited with {code}")
338
+ return results
339
+
340
+ def _python_search(
341
+ self,
342
+ root: Path,
343
+ base: Path,
344
+ mode: str,
345
+ glob: str,
346
+ matcher: re.Pattern,
347
+ limit: int,
348
+ line_cap: int,
349
+ ) -> list[str]:
350
+ results: list[str] = []
351
+ if mode == "files":
352
+ for file_path in self._iter_files(root, glob, base):
353
+ relative = _display_path(file_path, base)
354
+ if matcher.search(relative):
355
+ results.append(relative)
356
+ if len(results) >= limit:
357
+ break
358
+ else:
359
+ for file_path in self._iter_files(root, glob, base):
360
+ relative = _display_path(file_path, base)
361
+ for line_no, line in self._iter_text_lines(file_path):
362
+ if not matcher.search(line):
363
+ continue
364
+ content = line.rstrip()
365
+ if len(content) > line_cap:
366
+ content = content[:line_cap] + "…"
367
+ results.append(f"{relative}:{line_no}:{content}")
368
+ if len(results) >= limit:
369
+ break
370
+ if len(results) >= limit:
371
+ break
372
+ return results
373
+
374
+ def _iter_files(self, root: Path, glob_pattern: str, base: Path):
375
+ if root.is_file():
376
+ yield from self._yield_file(root, glob_pattern, base)
377
+ return
378
+ # Depth-first in path order, the same order as ``rg --sort=path``.
379
+ try:
380
+ children = sorted(root.iterdir(), key=lambda path: path.name)
381
+ except OSError:
382
+ return
383
+ for child in children:
384
+ if child.is_dir():
385
+ if self._searchable_dir(child, base):
386
+ yield from self._iter_files(child, glob_pattern, base)
387
+ continue
388
+ yield from self._yield_file(child, glob_pattern, base)
389
+
390
+ def _yield_file(self, candidate: Path, glob_pattern: str, base: Path):
391
+ if not candidate.is_file() or not self._searchable(candidate, base):
392
+ return
393
+ relative = _display_path(candidate, base)
394
+ if glob_pattern and not (
395
+ fnmatch.fnmatch(candidate.name, glob_pattern)
396
+ or fnmatch.fnmatch(relative, glob_pattern)
397
+ ):
398
+ return
399
+ yield candidate
400
+
401
+ def _relative_parts(self, path: Path, base: Path) -> tuple[str, ...]:
402
+ try:
403
+ return path.relative_to(base).parts
404
+ except ValueError:
405
+ return path.parts
406
+
407
+ def _searchable_dir(self, path: Path, base: Path) -> bool:
408
+ relative_parts = self._relative_parts(path, base)
409
+ if any(part in DEFAULT_SKIP_DIRS for part in relative_parts):
410
+ return False
411
+ if any(part.startswith(".") for part in relative_parts):
412
+ return False
413
+ return not is_ignored(path, base)
414
+
415
+ def _searchable(self, path: Path, base: Path) -> bool:
416
+ relative_parts = self._relative_parts(path, base)
417
+ if any(part in DEFAULT_SKIP_DIRS for part in relative_parts):
418
+ return False
419
+ if any(part.startswith(".") for part in relative_parts):
420
+ return False
421
+ return not is_ignored(path, base)
422
+
423
+ def _iter_text_lines(self, path: Path):
424
+ try:
425
+ with path.open("rb") as handle:
426
+ sample = handle.read(self.config.binary_sniff_bytes)
427
+ if b"\x00" in sample:
428
+ return
429
+ text = path.read_text(encoding="utf-8")
430
+ except (OSError, UnicodeDecodeError):
431
+ return
432
+ for line_no, line in enumerate(text.splitlines(), start=1):
433
+ yield line_no, line