kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""read_file tool: read a text file from the workspace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kiwimatecoder.documents import extract_document
|
|
8
|
+
from kiwimatecoder.session import Session
|
|
9
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
10
|
+
from kiwimatecoder.tools.paths import PathError, resolve_for_read
|
|
11
|
+
|
|
12
|
+
MAX_BYTES = 256 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_binary(sample: bytes) -> bool:
|
|
16
|
+
return b"\x00" in sample
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_file(args: dict[str, Any], session: Session) -> ToolResult:
|
|
20
|
+
path = str(args.get("path") or "")
|
|
21
|
+
if not path:
|
|
22
|
+
return ToolResult.error("'path' is required")
|
|
23
|
+
try:
|
|
24
|
+
resolved = resolve_for_read(
|
|
25
|
+
path,
|
|
26
|
+
session.workspace_root,
|
|
27
|
+
trusted=bool(getattr(session, "trusted_workspace", False)),
|
|
28
|
+
)
|
|
29
|
+
except PathError as exc:
|
|
30
|
+
return ToolResult.error(str(exc))
|
|
31
|
+
|
|
32
|
+
if not resolved.exists():
|
|
33
|
+
return ToolResult.error(f"File not found: {path}")
|
|
34
|
+
if resolved.is_dir():
|
|
35
|
+
return ToolResult.error(f"'{path}' is a directory, use list_dir")
|
|
36
|
+
|
|
37
|
+
document = extract_document(resolved)
|
|
38
|
+
if document is not None:
|
|
39
|
+
content, kind = document
|
|
40
|
+
return ToolResult(
|
|
41
|
+
content=(
|
|
42
|
+
f"[{kind} document: extracted text; offset/limit are ignored "
|
|
43
|
+
f"for documents]\n{content or '[empty document]'}"
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
data = resolved.read_bytes()
|
|
48
|
+
if _is_binary(data[:1024]):
|
|
49
|
+
return ToolResult.error(f"'{path}' appears to be a binary file")
|
|
50
|
+
|
|
51
|
+
text = data.decode("utf-8", "replace")
|
|
52
|
+
lines = text.splitlines()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
offset = int(args.get("offset", 0) or 0)
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return ToolResult.error("'offset' must be an integer")
|
|
58
|
+
if offset < 0:
|
|
59
|
+
return ToolResult.error("'offset' must be non-negative")
|
|
60
|
+
limit = args.get("limit")
|
|
61
|
+
if limit is not None:
|
|
62
|
+
try:
|
|
63
|
+
limit = int(limit)
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
return ToolResult.error("'limit' must be an integer")
|
|
66
|
+
if limit < 0:
|
|
67
|
+
return ToolResult.error("'limit' must be non-negative")
|
|
68
|
+
if offset or limit is not None:
|
|
69
|
+
end = offset + limit if limit is not None else len(lines)
|
|
70
|
+
lines = lines[offset:end]
|
|
71
|
+
|
|
72
|
+
truncated = False
|
|
73
|
+
body = "\n".join(lines)
|
|
74
|
+
if len(body.encode("utf-8")) > MAX_BYTES:
|
|
75
|
+
body = body.encode("utf-8")[:MAX_BYTES].decode("utf-8", "ignore")
|
|
76
|
+
truncated = True
|
|
77
|
+
|
|
78
|
+
numbered = "\n".join(
|
|
79
|
+
f"{offset + i + 1}\t{line}" for i, line in enumerate(body.splitlines())
|
|
80
|
+
)
|
|
81
|
+
if truncated:
|
|
82
|
+
numbered += "\n... [truncated: file exceeds 256KB]"
|
|
83
|
+
return ToolResult(content=numbered or "[empty file]")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
read_file_tool = FunctionTool(
|
|
87
|
+
name="read_file",
|
|
88
|
+
description=(
|
|
89
|
+
"Read the contents of a text file in the workspace. Returns the file "
|
|
90
|
+
"with 1-based line numbers. Use offset/limit for large files. Jupyter "
|
|
91
|
+
"notebooks (.ipynb), PDFs (.pdf), and Word documents (.docx) are "
|
|
92
|
+
"extracted to text automatically (offset/limit do not apply)."
|
|
93
|
+
),
|
|
94
|
+
parameters={
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"path": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "Path to the file, relative to the workspace root.",
|
|
100
|
+
},
|
|
101
|
+
"offset": {
|
|
102
|
+
"type": "integer",
|
|
103
|
+
"description": "0-based line number to start reading from.",
|
|
104
|
+
},
|
|
105
|
+
"limit": {
|
|
106
|
+
"type": "integer",
|
|
107
|
+
"description": "Maximum number of lines to read.",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
"required": ["path"],
|
|
111
|
+
},
|
|
112
|
+
func=_read_file,
|
|
113
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Extensible registry owning the name -> tool map and each tool's source.
|
|
2
|
+
|
|
3
|
+
The registry implements the mapping protocol so existing callers that treat
|
|
4
|
+
``TOOLS`` as a plain dict keep working (``TOOLS["read_file"]``,
|
|
5
|
+
``TOOLS.get("nope")``, iteration, ``.values()``). Tools registered at import
|
|
6
|
+
time are labelled ``"builtin"``; dynamically registered tools default to a
|
|
7
|
+
``"plugin"`` source and can be loaded from an external source in future.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterator, Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from kiwimatecoder.tools.base import FunctionTool
|
|
16
|
+
|
|
17
|
+
BUILTIN_SOURCE = "builtin"
|
|
18
|
+
PLUGIN_SOURCE = "plugin"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolRegistry(Mapping[str, FunctionTool]):
|
|
22
|
+
"""Ordered name -> :class:`FunctionTool` map with source tracking."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._tools: dict[str, FunctionTool] = {}
|
|
26
|
+
self._sources: dict[str, str] = {}
|
|
27
|
+
|
|
28
|
+
def register(
|
|
29
|
+
self,
|
|
30
|
+
tool: FunctionTool,
|
|
31
|
+
source: str = BUILTIN_SOURCE,
|
|
32
|
+
replace: bool = False,
|
|
33
|
+
) -> FunctionTool:
|
|
34
|
+
"""Register ``tool``, raising ``ValueError`` on a duplicate name.
|
|
35
|
+
|
|
36
|
+
Pass ``replace=True`` to swap an existing tool with the same name.
|
|
37
|
+
"""
|
|
38
|
+
name = getattr(tool, "name", "")
|
|
39
|
+
if not name:
|
|
40
|
+
raise ValueError("Tool name must be non-empty.")
|
|
41
|
+
if name in self._tools and not replace:
|
|
42
|
+
raise ValueError(f"Tool '{name}' is already registered.")
|
|
43
|
+
self._tools[name] = tool
|
|
44
|
+
self._sources[name] = str(source or BUILTIN_SOURCE)
|
|
45
|
+
return tool
|
|
46
|
+
|
|
47
|
+
def unregister(self, name: str, force: bool = False) -> bool:
|
|
48
|
+
"""Remove a tool, returning whether it was removed.
|
|
49
|
+
|
|
50
|
+
Unknown names return ``False``. Built-in tools are protected unless
|
|
51
|
+
``force=True`` so a plugin cannot silently shadow core behaviour.
|
|
52
|
+
"""
|
|
53
|
+
if name not in self._tools:
|
|
54
|
+
return False
|
|
55
|
+
if self._sources.get(name) == BUILTIN_SOURCE and not force:
|
|
56
|
+
return False
|
|
57
|
+
del self._tools[name]
|
|
58
|
+
self._sources.pop(name, None)
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def __getitem__(self, name: str) -> FunctionTool:
|
|
62
|
+
return self._tools[name]
|
|
63
|
+
|
|
64
|
+
def __contains__(self, name: object) -> bool:
|
|
65
|
+
return name in self._tools
|
|
66
|
+
|
|
67
|
+
def __iter__(self) -> Iterator[str]:
|
|
68
|
+
return iter(self._tools)
|
|
69
|
+
|
|
70
|
+
def __len__(self) -> int:
|
|
71
|
+
return len(self._tools)
|
|
72
|
+
|
|
73
|
+
def all(self) -> list[FunctionTool]:
|
|
74
|
+
"""Every registered tool in registration order."""
|
|
75
|
+
return list(self._tools.values())
|
|
76
|
+
|
|
77
|
+
def read_only(self) -> list[FunctionTool]:
|
|
78
|
+
"""Every read-only tool (no approval required) in registration order."""
|
|
79
|
+
return [tool for tool in self._tools.values() if not tool.needs_approval]
|
|
80
|
+
|
|
81
|
+
def schemas(self, read_only: bool = False) -> list[dict[str, Any]]:
|
|
82
|
+
"""OpenAI tool schemas, optionally restricted to read-only tools."""
|
|
83
|
+
tools = self.read_only() if read_only else self.all()
|
|
84
|
+
return [tool.schema() for tool in tools]
|
|
85
|
+
|
|
86
|
+
def sources(self) -> dict[str, list[str]]:
|
|
87
|
+
"""Map each source label to its sorted tool names."""
|
|
88
|
+
grouped: dict[str, list[str]] = {}
|
|
89
|
+
for name, source in self._sources.items():
|
|
90
|
+
grouped.setdefault(source, []).append(name)
|
|
91
|
+
return {source: sorted(names) for source, names in grouped.items()}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""run_bash tool: execute a shell command in the workspace.
|
|
2
|
+
|
|
3
|
+
Note: unlike the read/write/list/search/edit file tools (which use
|
|
4
|
+
resolve_in_workspace for sandboxing), run_bash is not restricted beyond setting
|
|
5
|
+
cwd to the workspace root. The command string is executed via shell=True (with
|
|
6
|
+
user approval in ask/plan modes). Intended for git, pytest, builds, etc.
|
|
7
|
+
|
|
8
|
+
When remote execution is configured (``config remote enable on``), the command
|
|
9
|
+
runs through SSH or in a devcontainer/Docker container instead, with the remote
|
|
10
|
+
wrapper shown in the approval preview. A missing CLI falls back to the plain
|
|
11
|
+
shell with a warning.
|
|
12
|
+
|
|
13
|
+
When OS-level sandboxing is enabled (``config sandbox enable on``), the command
|
|
14
|
+
is executed through the platform sandbox instead (macOS seatbelt or Linux
|
|
15
|
+
bubblewrap) and the approval preview shows the wrapped command. A missing
|
|
16
|
+
backend falls back to the plain shell with a warning. Remote execution applies
|
|
17
|
+
first: a remote command is never additionally wrapped in a local sandbox.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shlex
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from kiwimatecoder.config import get_sandbox, load_config
|
|
29
|
+
from kiwimatecoder.remote import remote_preview, wrap_remote_command
|
|
30
|
+
from kiwimatecoder.sandbox import wrap_command
|
|
31
|
+
from kiwimatecoder.session import Session
|
|
32
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
33
|
+
|
|
34
|
+
DEFAULT_TIMEOUT = 120
|
|
35
|
+
MAX_TIMEOUT = 3600
|
|
36
|
+
MAX_OUTPUT = 30_000
|
|
37
|
+
|
|
38
|
+
_FALLBACK_ARGV = ["/bin/sh", "-c"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def preview(args: dict[str, Any], session: Session) -> str:
|
|
42
|
+
"""Return the command text (and wrappers) for the approval prompt."""
|
|
43
|
+
command = str(args.get("command", ""))
|
|
44
|
+
cfg = load_config()
|
|
45
|
+
remote_argv, remote_warning = wrap_remote_command(
|
|
46
|
+
command, workspace=session.workspace_root, cfg=cfg
|
|
47
|
+
)
|
|
48
|
+
if remote_argv is not None:
|
|
49
|
+
return remote_preview(command, workspace=session.workspace_root, cfg=cfg)
|
|
50
|
+
|
|
51
|
+
text = command
|
|
52
|
+
if remote_warning:
|
|
53
|
+
text = f"{command}\n[remote] {remote_warning}"
|
|
54
|
+
|
|
55
|
+
settings = get_sandbox(cfg)
|
|
56
|
+
if not settings["enabled"]:
|
|
57
|
+
return text
|
|
58
|
+
argv, warning = wrap_command(command, workspace=session.workspace_root, cfg=cfg)
|
|
59
|
+
if warning:
|
|
60
|
+
return f"{text}\n[sandbox] {warning}"
|
|
61
|
+
mode = "network on" if settings["network"] else "network off"
|
|
62
|
+
return f"{text}\n[sandbox] wrapped ({mode}): {shlex.join(argv)}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _truncate(text: str) -> str:
|
|
66
|
+
if len(text) > MAX_OUTPUT:
|
|
67
|
+
return text[:MAX_OUTPUT] + "\n... [output truncated]"
|
|
68
|
+
return text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _popen(
|
|
72
|
+
command: str, *, argv: list[str] | None, workspace: str
|
|
73
|
+
) -> subprocess.Popen[str]:
|
|
74
|
+
"""Start the command, through ``argv`` when wrapped, else via the shell."""
|
|
75
|
+
if argv is not None:
|
|
76
|
+
return subprocess.Popen(
|
|
77
|
+
argv,
|
|
78
|
+
shell=False,
|
|
79
|
+
cwd=workspace,
|
|
80
|
+
stdout=subprocess.PIPE,
|
|
81
|
+
stderr=subprocess.PIPE,
|
|
82
|
+
text=True,
|
|
83
|
+
start_new_session=True,
|
|
84
|
+
)
|
|
85
|
+
return subprocess.Popen(
|
|
86
|
+
command,
|
|
87
|
+
shell=True,
|
|
88
|
+
cwd=workspace,
|
|
89
|
+
stdout=subprocess.PIPE,
|
|
90
|
+
stderr=subprocess.PIPE,
|
|
91
|
+
text=True,
|
|
92
|
+
start_new_session=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_bash(args: dict[str, Any], session: Session) -> ToolResult:
|
|
97
|
+
command = str(args.get("command") or "")
|
|
98
|
+
if not command:
|
|
99
|
+
return ToolResult.error("'command' is required")
|
|
100
|
+
try:
|
|
101
|
+
timeout = int(args.get("timeout", DEFAULT_TIMEOUT) or DEFAULT_TIMEOUT)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return ToolResult.error("'timeout' must be an integer")
|
|
104
|
+
timeout = max(1, min(timeout, MAX_TIMEOUT))
|
|
105
|
+
|
|
106
|
+
workspace_root = session.workspace_root
|
|
107
|
+
remote_argv, remote_warning = wrap_remote_command(
|
|
108
|
+
command, workspace=workspace_root
|
|
109
|
+
)
|
|
110
|
+
prefix: list[str] = []
|
|
111
|
+
wrapped: str | None = None
|
|
112
|
+
if remote_argv is not None:
|
|
113
|
+
argv = remote_argv
|
|
114
|
+
wrapped = "remote"
|
|
115
|
+
else:
|
|
116
|
+
if remote_warning:
|
|
117
|
+
prefix.append(f"[remote] {remote_warning}")
|
|
118
|
+
argv, sandbox_warning = wrap_command(command, workspace=workspace_root)
|
|
119
|
+
if argv != [*_FALLBACK_ARGV, command]:
|
|
120
|
+
wrapped = "sandbox"
|
|
121
|
+
if sandbox_warning:
|
|
122
|
+
prefix.append(f"[sandbox] {sandbox_warning}")
|
|
123
|
+
|
|
124
|
+
workspace = str(workspace_root)
|
|
125
|
+
try:
|
|
126
|
+
proc = _popen(
|
|
127
|
+
command, argv=argv if wrapped else None, workspace=workspace
|
|
128
|
+
)
|
|
129
|
+
except OSError as exc:
|
|
130
|
+
if wrapped is None:
|
|
131
|
+
return ToolResult.error(f"Failed to start command: {exc}")
|
|
132
|
+
# The wrapper exists on PATH but could not start; keep the tool usable.
|
|
133
|
+
kind = "remote" if wrapped == "remote" else "sandbox"
|
|
134
|
+
fallback = "running locally" if kind == "remote" else "running unsandboxed"
|
|
135
|
+
prefix.append(
|
|
136
|
+
f"[{kind}] failed to start the {kind} command ({exc}); {fallback}"
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
proc = _popen(command, argv=None, workspace=workspace)
|
|
140
|
+
except OSError as exc2:
|
|
141
|
+
return ToolResult.error(f"Failed to start command: {exc2}")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
145
|
+
except subprocess.TimeoutExpired:
|
|
146
|
+
# start_new_session=True put the shell (and its children) in their own
|
|
147
|
+
# process group, so killpg takes down the whole tree.
|
|
148
|
+
try:
|
|
149
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
150
|
+
except (ProcessLookupError, PermissionError):
|
|
151
|
+
pass
|
|
152
|
+
try:
|
|
153
|
+
proc.communicate(timeout=5)
|
|
154
|
+
except subprocess.TimeoutExpired:
|
|
155
|
+
pass
|
|
156
|
+
return ToolResult.error(f"Command timed out after {timeout}s")
|
|
157
|
+
|
|
158
|
+
parts = list(prefix)
|
|
159
|
+
if stdout:
|
|
160
|
+
parts.append(_truncate(stdout))
|
|
161
|
+
if stderr:
|
|
162
|
+
parts.append("[stderr]\n" + _truncate(stderr))
|
|
163
|
+
parts.append(f"[exit code: {proc.returncode}]")
|
|
164
|
+
return ToolResult(content="\n".join(parts), ok=proc.returncode == 0)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
run_bash_tool = FunctionTool(
|
|
168
|
+
name="run_bash",
|
|
169
|
+
description=(
|
|
170
|
+
"Run a shell command in the workspace root and return its stdout, "
|
|
171
|
+
"stderr, and exit code. Use for builds, tests, git, etc."
|
|
172
|
+
),
|
|
173
|
+
parameters={
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": {
|
|
176
|
+
"command": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"description": "The shell command to execute.",
|
|
179
|
+
},
|
|
180
|
+
"timeout": {
|
|
181
|
+
"type": "integer",
|
|
182
|
+
"description": "Timeout in seconds (default 120, max 3600).",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
"required": ["command"],
|
|
186
|
+
},
|
|
187
|
+
func=_run_bash,
|
|
188
|
+
runs=True,
|
|
189
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""search tool: grep-style content search and glob-style filename search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from kiwimatecoder.session import Session
|
|
13
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
14
|
+
from kiwimatecoder.tools.paths import (
|
|
15
|
+
PathError,
|
|
16
|
+
display_path,
|
|
17
|
+
get_workspace_ignore,
|
|
18
|
+
resolve_in_workspace,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
MAX_MATCHES = 200
|
|
22
|
+
DEFAULT_SEMANTIC_HITS = 10
|
|
23
|
+
MAX_SEMANTIC_HITS = 50
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _iter_files(root: Path, session: Session, glob_pattern: str | None = None) -> Iterator[Path]:
|
|
27
|
+
ignore = get_workspace_ignore(session.workspace_root)
|
|
28
|
+
for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False):
|
|
29
|
+
dir_p = Path(dirpath)
|
|
30
|
+
dirnames[:] = [
|
|
31
|
+
d for d in dirnames
|
|
32
|
+
if not ignore.is_ignored(dir_p / d, is_dir=True)
|
|
33
|
+
]
|
|
34
|
+
dirnames.sort()
|
|
35
|
+
for f in sorted(filenames):
|
|
36
|
+
file_p = dir_p / f
|
|
37
|
+
if ignore.is_ignored(file_p, is_dir=False):
|
|
38
|
+
continue
|
|
39
|
+
if glob_pattern:
|
|
40
|
+
rel = display_path(file_p, session.workspace_root)
|
|
41
|
+
if not fnmatch.fnmatch(f, glob_pattern) and not fnmatch.fnmatch(
|
|
42
|
+
rel, glob_pattern
|
|
43
|
+
):
|
|
44
|
+
continue
|
|
45
|
+
yield file_p
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _glob_search(root: Path, pattern: str, session: Session) -> list[str]:
|
|
49
|
+
ignore = get_workspace_ignore(session.workspace_root)
|
|
50
|
+
results: list[str] = []
|
|
51
|
+
for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False):
|
|
52
|
+
dir_p = Path(dirpath)
|
|
53
|
+
dirnames[:] = [
|
|
54
|
+
d for d in dirnames
|
|
55
|
+
if not ignore.is_ignored(dir_p / d, is_dir=True)
|
|
56
|
+
]
|
|
57
|
+
dirnames.sort()
|
|
58
|
+
for name in sorted(filenames + dirnames):
|
|
59
|
+
p = dir_p / name
|
|
60
|
+
is_dir = name in dirnames
|
|
61
|
+
if ignore.is_ignored(p, is_dir=is_dir):
|
|
62
|
+
continue
|
|
63
|
+
rel = display_path(p, session.workspace_root)
|
|
64
|
+
if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(rel, pattern):
|
|
65
|
+
results.append(rel)
|
|
66
|
+
if len(results) >= MAX_MATCHES:
|
|
67
|
+
return results
|
|
68
|
+
return results
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _grep_search(
|
|
72
|
+
root: Path, pattern: str, glob: str | None, session: Session
|
|
73
|
+
) -> list[str]:
|
|
74
|
+
try:
|
|
75
|
+
regex = re.compile(pattern)
|
|
76
|
+
except re.error as exc:
|
|
77
|
+
raise ValueError(f"Invalid regex: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
results: list[str] = []
|
|
80
|
+
for p in _iter_files(root, session, glob):
|
|
81
|
+
try:
|
|
82
|
+
data = p.read_bytes()
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
if b"\x00" in data[:1024]:
|
|
86
|
+
continue
|
|
87
|
+
rel = display_path(p, session.workspace_root)
|
|
88
|
+
for lineno, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1):
|
|
89
|
+
if regex.search(line):
|
|
90
|
+
results.append(f"{rel}:{lineno}: {line.strip()}")
|
|
91
|
+
if len(results) >= MAX_MATCHES:
|
|
92
|
+
return results
|
|
93
|
+
return results
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _semantic_search(
|
|
97
|
+
root: Path, query: str, args: dict[str, Any], session: Session
|
|
98
|
+
) -> list[str]:
|
|
99
|
+
"""Rank the workspace index against ``query`` and format the hits.
|
|
100
|
+
|
|
101
|
+
The index is refreshed incrementally first (and auto-built on first use).
|
|
102
|
+
Indexing failures are converted to ``ValueError`` so the tool returns a
|
|
103
|
+
friendly error instead of raising.
|
|
104
|
+
"""
|
|
105
|
+
from kiwimatecoder import config
|
|
106
|
+
from kiwimatecoder.index import builder, search as index_search
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
if not config.get_index()["enabled"]:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"Codebase indexing is disabled. Set "
|
|
112
|
+
'"index": {"enabled": true} in the config to use mode=\'semantic\'.'
|
|
113
|
+
)
|
|
114
|
+
builder.build_index(session.workspace_root)
|
|
115
|
+
limit_value = args.get("limit")
|
|
116
|
+
try:
|
|
117
|
+
limit = (
|
|
118
|
+
int(limit_value)
|
|
119
|
+
if limit_value is not None
|
|
120
|
+
else DEFAULT_SEMANTIC_HITS
|
|
121
|
+
)
|
|
122
|
+
except (TypeError, ValueError) as exc:
|
|
123
|
+
raise ValueError("'limit' must be an integer") from exc
|
|
124
|
+
limit = max(1, min(limit, MAX_SEMANTIC_HITS))
|
|
125
|
+
hits = index_search.search_index(
|
|
126
|
+
query, limit=limit, workspace_root=session.workspace_root
|
|
127
|
+
)
|
|
128
|
+
except ValueError:
|
|
129
|
+
raise
|
|
130
|
+
except Exception as exc: # indexing must never crash a tool call
|
|
131
|
+
raise ValueError(f"Semantic search failed: {exc}") from exc
|
|
132
|
+
|
|
133
|
+
workspace = session.workspace_root.resolve()
|
|
134
|
+
prefix = ""
|
|
135
|
+
if root != workspace:
|
|
136
|
+
try:
|
|
137
|
+
prefix = root.relative_to(workspace).as_posix() + "/"
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise ValueError("The search path is outside the workspace.") from exc
|
|
140
|
+
results: list[str] = []
|
|
141
|
+
for hit in hits:
|
|
142
|
+
if prefix and not hit.path.startswith(prefix):
|
|
143
|
+
continue
|
|
144
|
+
results.append(f"{hit.path}:{hit.start_line}: {hit.snippet}")
|
|
145
|
+
return results
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _search(args: dict[str, Any], session: Session) -> ToolResult:
|
|
149
|
+
pattern = str(args.get("pattern") or "")
|
|
150
|
+
if not pattern:
|
|
151
|
+
return ToolResult.error("'pattern' is required")
|
|
152
|
+
mode = str(args.get("mode", "grep") or "grep")
|
|
153
|
+
path = str(args.get("path", ".") or ".")
|
|
154
|
+
try:
|
|
155
|
+
root = resolve_in_workspace(path, session.workspace_root)
|
|
156
|
+
except PathError as exc:
|
|
157
|
+
return ToolResult.error(str(exc))
|
|
158
|
+
if not root.is_dir():
|
|
159
|
+
return ToolResult.error(f"'{path}' is not a directory")
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
if mode == "semantic":
|
|
163
|
+
results = _semantic_search(root, pattern, args, session)
|
|
164
|
+
elif mode == "glob":
|
|
165
|
+
results = _glob_search(root, pattern, session)
|
|
166
|
+
else:
|
|
167
|
+
glob_arg = args.get("glob")
|
|
168
|
+
glob_val = str(glob_arg) if glob_arg is not None else None
|
|
169
|
+
results = _grep_search(root, pattern, glob_val, session)
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
return ToolResult.error(str(exc))
|
|
172
|
+
|
|
173
|
+
if not results:
|
|
174
|
+
return ToolResult(content="No matches found.")
|
|
175
|
+
header = ""
|
|
176
|
+
if len(results) >= MAX_MATCHES:
|
|
177
|
+
header = f"[showing first {MAX_MATCHES} matches]\n"
|
|
178
|
+
return ToolResult(content=header + "\n".join(results))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
search_tool = FunctionTool(
|
|
182
|
+
name="search",
|
|
183
|
+
description=(
|
|
184
|
+
"Search the workspace. mode='grep' finds a regex in file contents "
|
|
185
|
+
"(optionally filtered by a glob); mode='glob' finds files by name "
|
|
186
|
+
"pattern (e.g. '**/*.py'); mode='semantic' ranks files against a "
|
|
187
|
+
"natural-language query using the local codebase index (auto-built and "
|
|
188
|
+
"refreshed incrementally) and returns paths, line numbers, and "
|
|
189
|
+
"snippets."
|
|
190
|
+
),
|
|
191
|
+
parameters={
|
|
192
|
+
"type": "object",
|
|
193
|
+
"properties": {
|
|
194
|
+
"pattern": {
|
|
195
|
+
"type": "string",
|
|
196
|
+
"description": (
|
|
197
|
+
"Regex (grep mode), filename glob (glob mode), or a "
|
|
198
|
+
"natural-language query (semantic mode)."
|
|
199
|
+
),
|
|
200
|
+
},
|
|
201
|
+
"mode": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"enum": ["grep", "glob", "semantic"],
|
|
204
|
+
"description": "Search mode. Defaults to grep.",
|
|
205
|
+
},
|
|
206
|
+
"path": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"description": "Directory to search in. Defaults to workspace root.",
|
|
209
|
+
},
|
|
210
|
+
"glob": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"description": "Optional filename glob to restrict grep (e.g. '**/*.py').",
|
|
213
|
+
},
|
|
214
|
+
"limit": {
|
|
215
|
+
"type": "integer",
|
|
216
|
+
"description": (
|
|
217
|
+
"Maximum semantic hits to return (default 10, max 50)."
|
|
218
|
+
),
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
"required": ["pattern"],
|
|
222
|
+
},
|
|
223
|
+
func=_search,
|
|
224
|
+
)
|