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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- 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
|